sequant 2.10.0 → 2.12.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 (108) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +19 -2
  4. package/dist/bin/cli.js +47 -2
  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/hooks/pre-tool.sh +331 -12
  8. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +7 -18
  9. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +5 -1
  10. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +62 -8
  11. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +187 -28
  12. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +127 -23
  13. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +130 -13
  14. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +306 -8
  15. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +79 -0
  16. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +40 -20
  17. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +14 -1
  18. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  19. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +23 -6
  20. package/dist/src/commands/doctor.js +20 -18
  21. package/dist/src/commands/locks.d.ts +20 -1
  22. package/dist/src/commands/locks.js +206 -4
  23. package/dist/src/commands/ready.d.ts +6 -0
  24. package/dist/src/commands/ready.js +19 -1
  25. package/dist/src/commands/run-display.js +1 -0
  26. package/dist/src/commands/worktree.d.ts +31 -0
  27. package/dist/src/commands/worktree.js +95 -0
  28. package/dist/src/lib/ac-linter.js +26 -0
  29. package/dist/src/lib/ac-parser.d.ts +40 -0
  30. package/dist/src/lib/ac-parser.js +202 -16
  31. package/dist/src/lib/cli-flags.d.ts +23 -0
  32. package/dist/src/lib/cli-flags.js +43 -0
  33. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +2 -0
  34. package/dist/src/lib/cli-ui/run-renderer.js +7 -1
  35. package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
  36. package/dist/src/lib/locks/checkout-lock.js +389 -0
  37. package/dist/src/lib/locks/index.d.ts +6 -3
  38. package/dist/src/lib/locks/index.js +4 -2
  39. package/dist/src/lib/locks/lock-manager.d.ts +81 -1
  40. package/dist/src/lib/locks/lock-manager.js +230 -5
  41. package/dist/src/lib/locks/types.d.ts +72 -0
  42. package/dist/src/lib/locks/types.js +28 -0
  43. package/dist/src/lib/markdown-fence.d.ts +24 -0
  44. package/dist/src/lib/markdown-fence.js +51 -0
  45. package/dist/src/lib/mcp-config.d.ts +24 -0
  46. package/dist/src/lib/mcp-config.js +51 -0
  47. package/dist/src/lib/scope/analyzer.d.ts +4 -0
  48. package/dist/src/lib/scope/analyzer.js +7 -1
  49. package/dist/src/lib/settings.d.ts +111 -1
  50. package/dist/src/lib/settings.js +59 -0
  51. package/dist/src/lib/system.d.ts +7 -3
  52. package/dist/src/lib/system.js +7 -3
  53. package/dist/src/lib/test-tautology-detector.d.ts +4 -3
  54. package/dist/src/lib/test-tautology-detector.js +147 -40
  55. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  56. package/dist/src/lib/workflow/batch-executor.js +154 -23
  57. package/dist/src/lib/workflow/config-resolver.d.ts +25 -0
  58. package/dist/src/lib/workflow/config-resolver.js +90 -0
  59. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +22 -0
  60. package/dist/src/lib/workflow/drivers/claude-code.js +14 -3
  61. package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
  62. package/dist/src/lib/workflow/effort-escalation.js +82 -0
  63. package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
  64. package/dist/src/lib/workflow/error-classifier.js +4 -0
  65. package/dist/src/lib/workflow/log-writer.d.ts +10 -1
  66. package/dist/src/lib/workflow/log-writer.js +20 -0
  67. package/dist/src/lib/workflow/metrics-schema.d.ts +49 -6
  68. package/dist/src/lib/workflow/metrics-schema.js +33 -0
  69. package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
  70. package/dist/src/lib/workflow/mutation-marker.d.ts +86 -0
  71. package/dist/src/lib/workflow/mutation-marker.js +97 -0
  72. package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
  73. package/dist/src/lib/workflow/phase-detection.js +5 -1
  74. package/dist/src/lib/workflow/phase-executor.d.ts +17 -0
  75. package/dist/src/lib/workflow/phase-executor.js +60 -4
  76. package/dist/src/lib/workflow/qa-gaps-marker.d.ts +38 -0
  77. package/dist/src/lib/workflow/qa-gaps-marker.js +66 -0
  78. package/dist/src/lib/workflow/ready-gate.d.ts +53 -1
  79. package/dist/src/lib/workflow/ready-gate.js +105 -14
  80. package/dist/src/lib/workflow/run-log-schema.d.ts +175 -0
  81. package/dist/src/lib/workflow/run-log-schema.js +71 -1
  82. package/dist/src/lib/workflow/run-orchestrator.js +27 -0
  83. package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
  84. package/dist/src/lib/workflow/spec-recommendation.js +142 -0
  85. package/dist/src/lib/workflow/state-schema.d.ts +5 -1
  86. package/dist/src/lib/workflow/state-schema.js +8 -1
  87. package/dist/src/lib/workflow/types.d.ts +78 -0
  88. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  89. package/dist/src/lib/workflow/worktree-manager.js +9 -1
  90. package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
  91. package/dist/src/lib/workflow/worktree-resolver.js +126 -0
  92. package/package.json +4 -3
  93. package/templates/hooks/pre-tool.sh +331 -12
  94. package/templates/scripts/cleanup-worktree.sh +36 -15
  95. package/templates/scripts/new-feature.sh +25 -19
  96. package/templates/skills/_shared/references/subagent-types.md +7 -18
  97. package/templates/skills/assess/SKILL.md +5 -1
  98. package/templates/skills/exec/SKILL.md +62 -8
  99. package/templates/skills/fullsolve/SKILL.md +187 -28
  100. package/templates/skills/loop/SKILL.md +127 -23
  101. package/templates/skills/merger/SKILL.md +130 -13
  102. package/templates/skills/qa/SKILL.md +306 -8
  103. package/templates/skills/release/SKILL.md +79 -0
  104. package/templates/skills/spec/SKILL.md +40 -20
  105. package/templates/skills/spec/references/recommended-workflow.md +14 -1
  106. package/templates/skills/test/SKILL.md +1 -1
  107. package/templates/skills/testgen/SKILL.md +23 -6
  108. package/templates/agents/sequant-explorer.md +0 -24
@@ -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
  */
5
+ export { CheckoutLock, RELEASE_SENTINEL_ISSUE, describeCheckoutHolderIssue, formatCheckoutLockedMessage, isCheckoutOwner, } from "./checkout-lock.js";
6
+ export type { CheckoutLockOptions } from "./checkout-lock.js";
4
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_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
7
- export type { AcquireResult, LockFile, LockListing, SignalOtherResult, SignalReason, StaleReason, } 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
  */
5
+ export { CheckoutLock, RELEASE_SENTINEL_ISSUE, describeCheckoutHolderIssue, formatCheckoutLockedMessage, isCheckoutOwner, } from "./checkout-lock.js";
4
6
  export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, resolveMaxLockAgeMs, } from "./lock-manager.js";
5
- export { DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
7
+ export { CHECKOUT_LOCK_FILENAME, CheckoutLockFileSchema, DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
@@ -18,6 +18,7 @@
18
18
  * method is a no-op (no fs touches, no warnings). Mirrors the
19
19
  * `OrchestratorRenderer` pattern at `src/lib/cli-ui/run-renderer.ts:244`.
20
20
  */
21
+ import { renameSync, linkSync } from "fs";
21
22
  import { type AcquireResult, type LockFile, type LockListing, type SignalOtherResult, type StaleReason } from "./types.js";
22
23
  export interface LockManagerOptions {
23
24
  /** Directory holding `<issue>.lock` files (default: `.sequant/locks`). */
@@ -52,7 +53,11 @@ export interface LockManagerOptions {
52
53
  }
53
54
  /** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
54
55
  export declare function isOrchestratorMode(): boolean;
55
- /** Resolve the locks directory honoring `SEQUANT_LOCKS_DIR` for test isolation. */
56
+ /**
57
+ * Resolve the locks directory. Priority: explicit option > `SEQUANT_LOCKS_DIR`
58
+ * > the shared git checkout root (#909) > plain cwd-relative (non-git
59
+ * fallback, preserves pre-#909 behavior).
60
+ */
56
61
  export declare function resolveLocksDir(explicit?: string): string;
57
62
  /**
58
63
  * Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
@@ -86,6 +91,81 @@ export declare function classifyStaleness(args: {
86
91
  maxLockAgeMs?: number;
87
92
  isPidAlive: (pid: number) => boolean;
88
93
  }): StaleReason | null;
94
+ /** The fields that uniquely identify a lock's holder (any lock class). */
95
+ export interface StaleLockIdentity {
96
+ pid: number;
97
+ hostname: string;
98
+ startedAt: string;
99
+ }
100
+ /**
101
+ * Atomically steal a stale lock (#908) — the compare-and-swap replacement for
102
+ * the old unlink-then-create, shared by both lock classes so they cannot drift
103
+ * (AC-1, AC-2).
104
+ *
105
+ * `unlink(lockPath)` removes whatever inode is at the path — including a fresh
106
+ * lock a winner created microseconds earlier — so two sessions that both
107
+ * classified the same lock stale could each destroy the other's fresh lock and
108
+ * both "win" (two holders, the exact failure the lock exists to prevent). A
109
+ * bare `rename(lockPath, …)` has the identical flaw: rename moves whatever is
110
+ * at the path, fresh or not.
111
+ *
112
+ * So take possession atomically, THEN check identity:
113
+ *
114
+ * 1. `rename(lockPath → tmp)` — atomic on POSIX. Of two racing stealers,
115
+ * exactly one moves the current inode; the loser gets `ENOENT`.
116
+ * 2. Read what we moved. If it is the stale holder we `classified`, discard
117
+ * it (`unlink tmp`) — the steal is legitimate and `lockPath` is now free
118
+ * for the caller's terminal `openSync(…, "wx")` to claim.
119
+ * 3. If it is NOT that holder, a fresh lock appeared between our staleness
120
+ * read and our rename. We must not destroy it: `link` it back into place
121
+ * (never overwrites) and report the steal lost. A third session that
122
+ * claimed `lockPath` in the gap is left intact and our copy becomes a
123
+ * harmless `*.steal.*` orphan, which `list()` ignores (it matches only
124
+ * `.lock`).
125
+ *
126
+ * Returns `true` only when this caller legitimately removed the stale lock it
127
+ * classified. `false` means "lost" — the caller falls through to `writeAtomic`,
128
+ * whose `O_CREAT|O_EXCL` arbitrates the real holder (accepting the current
129
+ * occupant on `EEXIST`).
130
+ *
131
+ * NOTE ON DEVIATION FROM THE #908 SPEC: the plan prescribed a *plain*
132
+ * rename-away ("rename, unlink tmp, ENOENT = lost", then fall through). That is
133
+ * behaviorally identical to the `unlink` it replaces — verified by a
134
+ * hand-driven interleave: in the issue's own documented ordering
135
+ * (`A.steal → A.create → B.steal → B.create`) B's rename succeeds on A's fresh
136
+ * lock and destroys it, two holders, same as today. The identity check in
137
+ * step 2/3 is what actually makes AC-1 ("loser cannot remove the winner's fresh
138
+ * lock") hold and makes AC-4's mutation test possible.
139
+ *
140
+ * RESIDUAL: the sub-millisecond window at step 3 where a third session's
141
+ * O_EXCL create races our `link`-back is not fully closed — plain lock files
142
+ * admit no atomic compare-and-swap on content. It is far narrower than the
143
+ * original (which failed on a *single* race, every time a stealer's removal
144
+ * landed on a fresh lock) and never destroys a live lock. Fully closing it is a
145
+ * larger protocol change (claim file / lease), flagged for follow-up.
146
+ *
147
+ * NEVER THROWS. A steal is an opportunistic optimization on the acquire path;
148
+ * no filesystem error here is worth crashing `acquire` over. Errors degrade to
149
+ * "lost" (`false`) and the caller's terminal create surfaces any real
150
+ * environment problem (EACCES etc.) with the same errno the pre-#908 path did.
151
+ * The one active recovery: if the `link`-back restore fails because the
152
+ * filesystem refuses hard links (EPERM/ENOTSUP), fall back to renaming `tmp`
153
+ * back into place — leaving a fresh lock renamed-away IS the two-holder bug,
154
+ * so restoring it outweighs `link`'s no-overwrite guarantee on such a
155
+ * filesystem.
156
+ *
157
+ * `ops` is a test seam for the link/rename syscalls — production callers omit
158
+ * it. Injecting a failing `link` is the only way to drive the fallback branch
159
+ * deterministically (capability errors like ENOTSUP cannot be provoked on a
160
+ * normal tmpdir).
161
+ */
162
+ export declare function stealStaleLock(lockPath: string, classified: StaleLockIdentity, self: {
163
+ pid: number;
164
+ now: number;
165
+ }, ops?: {
166
+ link?: typeof linkSync;
167
+ rename?: typeof renameSync;
168
+ }): boolean;
89
169
  export declare class LockManager {
90
170
  private readonly locksDir;
91
171
  private readonly staleAgeMs;