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
@@ -18,18 +18,57 @@
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 { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, mkdirSync, readdirSync, statSync, } from "fs";
22
- import { join, resolve } from "path";
21
+ import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, renameSync, linkSync, mkdirSync, readdirSync, statSync, } from "fs";
22
+ import { basename, dirname, join, resolve } from "path";
23
+ import { execFileSync } from "child_process";
23
24
  import * as os from "os";
24
25
  import { DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_SKILL_LOCK_TTL_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
25
26
  /** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
26
27
  export function isOrchestratorMode() {
27
28
  return Boolean(process.env.SEQUANT_ORCHESTRATOR);
28
29
  }
29
- /** Resolve the locks directory honoring `SEQUANT_LOCKS_DIR` for test isolation. */
30
+ /**
31
+ * Resolve the checkout root shared by the main worktree and every linked
32
+ * worktree of the same repository (#909). `--git-common-dir` (unlike
33
+ * `--show-toplevel`) points at the same physical `.git` for all of them, so
34
+ * a linked worktree lands on the main checkout's `.sequant/locks` instead of
35
+ * growing its own — matching what `pre-tool.sh`'s checkout guard reads.
36
+ * Returns `null` outside a git repository (or if `git` is unavailable).
37
+ *
38
+ * Deliberately omits `--path-format=absolute` (Git >=2.31, 2021): that flag
39
+ * failing on an older git would fall into the same `catch` as "not a repo"
40
+ * and silently re-open the bug this function exists to fix, with no signal
41
+ * that the cause was an old toolchain rather than a bare directory. Plain
42
+ * `--git-common-dir` (Git >=2.5, 2015) is resolved by hand instead —
43
+ * `resolve(cwd, out)` is a no-op when git already returned an absolute path
44
+ * (the common linked-worktree case) and anchors a relative one (the common
45
+ * main-checkout case) to `cwd`, so it is correct either way.
46
+ */
47
+ function resolveGitCheckoutRoot(cwd) {
48
+ try {
49
+ const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
50
+ cwd,
51
+ encoding: "utf8",
52
+ stdio: ["ignore", "pipe", "ignore"],
53
+ }).trim();
54
+ return commonDir ? dirname(resolve(cwd, commonDir)) : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Resolve the locks directory. Priority: explicit option > `SEQUANT_LOCKS_DIR`
62
+ * > the shared git checkout root (#909) > plain cwd-relative (non-git
63
+ * fallback, preserves pre-#909 behavior).
64
+ */
30
65
  export function resolveLocksDir(explicit) {
31
66
  const fromEnv = process.env.SEQUANT_LOCKS_DIR;
32
- return resolve(explicit ?? fromEnv ?? DEFAULT_LOCKS_DIR);
67
+ if (explicit !== undefined || fromEnv !== undefined) {
68
+ return resolve(explicit ?? fromEnv ?? DEFAULT_LOCKS_DIR);
69
+ }
70
+ const gitRoot = resolveGitCheckoutRoot(process.cwd());
71
+ return resolve(gitRoot ?? process.cwd(), DEFAULT_LOCKS_DIR);
33
72
  }
34
73
  /**
35
74
  * Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
@@ -121,6 +160,184 @@ export function classifyStaleness(args) {
121
160
  return "age-exceeded";
122
161
  return null;
123
162
  }
163
+ /** Read just the identity fields of a lock file. Null if missing/unparseable. */
164
+ function readLockIdentity(lockPath) {
165
+ if (!existsSync(lockPath))
166
+ return null;
167
+ try {
168
+ const parsed = JSON.parse(readFileSync(lockPath, "utf-8"));
169
+ if (parsed &&
170
+ typeof parsed.pid === "number" &&
171
+ typeof parsed.hostname === "string" &&
172
+ typeof parsed.startedAt === "string") {
173
+ return {
174
+ pid: parsed.pid,
175
+ hostname: parsed.hostname,
176
+ startedAt: parsed.startedAt,
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ /**
186
+ * Best-effort unlink for a steal's private `tmp` file. Failure to remove it is
187
+ * never worth crashing `acquire` over — the orphan sweep below reclaims it.
188
+ */
189
+ function unlinkBestEffort(path) {
190
+ try {
191
+ unlinkSync(path);
192
+ }
193
+ catch {
194
+ // Best-effort: swept later by sweepStealOrphans.
195
+ }
196
+ }
197
+ /**
198
+ * Age past which a `<lock>.steal.<pid>.<ts>` file cannot be an in-flight steal
199
+ * (the live window is microseconds) and is reclaimed as an orphan.
200
+ */
201
+ const STEAL_ORPHAN_TTL_MS = 10 * 60 * 1000;
202
+ /**
203
+ * Reap aged `*.steal.*` orphans left behind by lost restore races (the
204
+ * documented EEXIST branch below). Best-effort throughout: steals are the only
205
+ * producer and the only consumer, `list()` never sees these files, and a
206
+ * failure here must not affect the steal itself.
207
+ */
208
+ function sweepStealOrphans(lockPath, now) {
209
+ const prefix = `${basename(lockPath)}.steal.`;
210
+ try {
211
+ const dir = dirname(lockPath);
212
+ for (const name of readdirSync(dir)) {
213
+ if (!name.startsWith(prefix))
214
+ continue;
215
+ const orphan = join(dir, name);
216
+ try {
217
+ if (now - statSync(orphan).mtimeMs > STEAL_ORPHAN_TTL_MS) {
218
+ unlinkSync(orphan);
219
+ }
220
+ }
221
+ catch {
222
+ // Raced away or unreadable — skip it.
223
+ }
224
+ }
225
+ }
226
+ catch {
227
+ // Locks dir unreadable — nothing to sweep.
228
+ }
229
+ }
230
+ /**
231
+ * Atomically steal a stale lock (#908) — the compare-and-swap replacement for
232
+ * the old unlink-then-create, shared by both lock classes so they cannot drift
233
+ * (AC-1, AC-2).
234
+ *
235
+ * `unlink(lockPath)` removes whatever inode is at the path — including a fresh
236
+ * lock a winner created microseconds earlier — so two sessions that both
237
+ * classified the same lock stale could each destroy the other's fresh lock and
238
+ * both "win" (two holders, the exact failure the lock exists to prevent). A
239
+ * bare `rename(lockPath, …)` has the identical flaw: rename moves whatever is
240
+ * at the path, fresh or not.
241
+ *
242
+ * So take possession atomically, THEN check identity:
243
+ *
244
+ * 1. `rename(lockPath → tmp)` — atomic on POSIX. Of two racing stealers,
245
+ * exactly one moves the current inode; the loser gets `ENOENT`.
246
+ * 2. Read what we moved. If it is the stale holder we `classified`, discard
247
+ * it (`unlink tmp`) — the steal is legitimate and `lockPath` is now free
248
+ * for the caller's terminal `openSync(…, "wx")` to claim.
249
+ * 3. If it is NOT that holder, a fresh lock appeared between our staleness
250
+ * read and our rename. We must not destroy it: `link` it back into place
251
+ * (never overwrites) and report the steal lost. A third session that
252
+ * claimed `lockPath` in the gap is left intact and our copy becomes a
253
+ * harmless `*.steal.*` orphan, which `list()` ignores (it matches only
254
+ * `.lock`).
255
+ *
256
+ * Returns `true` only when this caller legitimately removed the stale lock it
257
+ * classified. `false` means "lost" — the caller falls through to `writeAtomic`,
258
+ * whose `O_CREAT|O_EXCL` arbitrates the real holder (accepting the current
259
+ * occupant on `EEXIST`).
260
+ *
261
+ * NOTE ON DEVIATION FROM THE #908 SPEC: the plan prescribed a *plain*
262
+ * rename-away ("rename, unlink tmp, ENOENT = lost", then fall through). That is
263
+ * behaviorally identical to the `unlink` it replaces — verified by a
264
+ * hand-driven interleave: in the issue's own documented ordering
265
+ * (`A.steal → A.create → B.steal → B.create`) B's rename succeeds on A's fresh
266
+ * lock and destroys it, two holders, same as today. The identity check in
267
+ * step 2/3 is what actually makes AC-1 ("loser cannot remove the winner's fresh
268
+ * lock") hold and makes AC-4's mutation test possible.
269
+ *
270
+ * RESIDUAL: the sub-millisecond window at step 3 where a third session's
271
+ * O_EXCL create races our `link`-back is not fully closed — plain lock files
272
+ * admit no atomic compare-and-swap on content. It is far narrower than the
273
+ * original (which failed on a *single* race, every time a stealer's removal
274
+ * landed on a fresh lock) and never destroys a live lock. Fully closing it is a
275
+ * larger protocol change (claim file / lease), flagged for follow-up.
276
+ *
277
+ * NEVER THROWS. A steal is an opportunistic optimization on the acquire path;
278
+ * no filesystem error here is worth crashing `acquire` over. Errors degrade to
279
+ * "lost" (`false`) and the caller's terminal create surfaces any real
280
+ * environment problem (EACCES etc.) with the same errno the pre-#908 path did.
281
+ * The one active recovery: if the `link`-back restore fails because the
282
+ * filesystem refuses hard links (EPERM/ENOTSUP), fall back to renaming `tmp`
283
+ * back into place — leaving a fresh lock renamed-away IS the two-holder bug,
284
+ * so restoring it outweighs `link`'s no-overwrite guarantee on such a
285
+ * filesystem.
286
+ *
287
+ * `ops` is a test seam for the link/rename syscalls — production callers omit
288
+ * it. Injecting a failing `link` is the only way to drive the fallback branch
289
+ * deterministically (capability errors like ENOTSUP cannot be provoked on a
290
+ * normal tmpdir).
291
+ */
292
+ export function stealStaleLock(lockPath, classified, self, ops = {}) {
293
+ const rename = ops.rename ?? renameSync;
294
+ const link = ops.link ?? linkSync;
295
+ const tmp = `${lockPath}.steal.${self.pid}.${self.now}`;
296
+ sweepStealOrphans(lockPath, self.now);
297
+ try {
298
+ rename(lockPath, tmp);
299
+ }
300
+ catch {
301
+ // ENOENT: another stealer moved it first — cleanly lost. Anything else
302
+ // (EACCES, EROFS, …): nothing was moved, so there is nothing to restore;
303
+ // report lost and let the terminal create surface the environment problem.
304
+ return false;
305
+ }
306
+ const moved = readLockIdentity(tmp);
307
+ if (moved &&
308
+ moved.pid === classified.pid &&
309
+ moved.hostname === classified.hostname &&
310
+ moved.startedAt === classified.startedAt) {
311
+ unlinkBestEffort(tmp);
312
+ return true;
313
+ }
314
+ // A fresh (or corrupt) holder slipped in between our read and our rename.
315
+ // Put back exactly what we took, without overwriting a newer claim, then
316
+ // lose the steal.
317
+ try {
318
+ link(tmp, lockPath);
319
+ unlinkBestEffort(tmp);
320
+ }
321
+ catch (err) {
322
+ const code = err.code;
323
+ if (code !== "EEXIST" && code !== "ENOENT") {
324
+ // Hard links refused (EPERM/ENOTSUP/…) — restore by rename instead.
325
+ // The overwrite risk this reintroduces needs a third session's create
326
+ // to land in this same sub-ms window ON a no-hardlink filesystem;
327
+ // not restoring at all destroys the fresh lock every time.
328
+ try {
329
+ rename(tmp, lockPath);
330
+ }
331
+ catch {
332
+ // Out of options — degrades to pre-#908 behavior on this filesystem.
333
+ }
334
+ }
335
+ // EEXIST: `lockPath` was re-claimed in the gap → leave `tmp` as an orphan
336
+ // (swept by sweepStealOrphans) rather than clobber the new holder.
337
+ // ENOENT: `tmp` already gone.
338
+ }
339
+ return false;
340
+ }
124
341
  export class LockManager {
125
342
  locksDir;
126
343
  staleAgeMs;
@@ -192,7 +409,15 @@ export class LockManager {
192
409
  isPidAlive: this.isPidAlive,
193
410
  });
194
411
  if (staleReason) {
195
- this.unlinkSafe(lockPath);
412
+ // Compare-and-swap steal, not a blind unlink (#908): only remove the
413
+ // stale inode we classified, never a fresh lock a racing winner may
414
+ // have created at this path in the meantime. Win or lose, fall through
415
+ // to `writeAtomic` — its `O_CREAT|O_EXCL` arbitrates the real holder.
416
+ stealStaleLock(lockPath, {
417
+ pid: existing.pid,
418
+ hostname: existing.hostname,
419
+ startedAt: existing.startedAt,
420
+ }, { pid: this.pid, now: this.now() });
196
421
  }
197
422
  else {
198
423
  return {
@@ -49,6 +49,78 @@ export declare const LockFileSchema: z.ZodObject<{
49
49
  skipPidCheck: z.ZodOptional<z.ZodBoolean>;
50
50
  }, z.core.$strip>;
51
51
  export type LockFile = z.infer<typeof LockFileSchema>;
52
+ /**
53
+ * Filename of the checkout-scoped lock (#901), stored alongside the numeric
54
+ * `<issue>.lock` files. Deliberately non-numeric so `LockManager.list()` —
55
+ * which parses each filename as an issue number and skips anything else —
56
+ * ignores it rather than surfacing a bogus `NaN` issue.
57
+ */
58
+ export declare const CHECKOUT_LOCK_FILENAME = "checkout.lock";
59
+ /**
60
+ * On-disk payload for the checkout-scoped lock (#901).
61
+ *
62
+ * The per-issue lock from #625 is keyed on issue number, so two sessions
63
+ * working *different* issues take different lock files and never contend —
64
+ * yet `git checkout`, `reset`, `rebase` and `merge` are global to a working
65
+ * tree. This lock represents the tree itself.
66
+ *
67
+ * Extends `LockFileSchema` with:
68
+ * - `issue` — which issue the holder is working on, so a refusal can name it
69
+ * (AC-2) and point the loser at the right worktree (AC-3).
70
+ * - `sessionId` — Claude Code's per-session id, read from the PreToolUse
71
+ * stdin envelope. Optional: it is the *preferred* holder identity because a
72
+ * skill shell's PID dies immediately after acquire, but the hook falls back
73
+ * to `pid`+`hostname` when the envelope does not carry one, so nothing
74
+ * depends on it being present.
75
+ */
76
+ export declare const CheckoutLockFileSchema: z.ZodObject<{
77
+ pid: z.ZodNumber;
78
+ hostname: z.ZodString;
79
+ startedAt: z.ZodString;
80
+ command: z.ZodString;
81
+ skipPidCheck: z.ZodOptional<z.ZodBoolean>;
82
+ issue: z.ZodNumber;
83
+ sessionId: z.ZodOptional<z.ZodString>;
84
+ }, z.core.$strip>;
85
+ export type CheckoutLockFile = z.infer<typeof CheckoutLockFileSchema>;
86
+ /** Identity of the session asking for the checkout lock. */
87
+ export interface CheckoutHolderIdentity {
88
+ /** Claude Code session id, when the caller knows it. */
89
+ sessionId?: string;
90
+ /** Falls back to these when `sessionId` is absent on either side. */
91
+ pid: number;
92
+ hostname: string;
93
+ /**
94
+ * The issue this session is working on (#906).
95
+ *
96
+ * Last-resort ownership proxy for `skipPidCheck` locks, whose PID is dead
97
+ * by the time anything checks it. Optional because a live process releasing
98
+ * its own lock identifies itself by PID and needs none of this — but a
99
+ * skill shell that omits it can no longer release, which is deliberate.
100
+ */
101
+ issue?: number;
102
+ }
103
+ /** Outcome of `CheckoutLock.acquire()`. */
104
+ export type CheckoutAcquireResult = {
105
+ acquired: true;
106
+ lockPath: string;
107
+ /** True when the caller already held it — re-acquire is idempotent. */
108
+ reentrant: boolean;
109
+ } | {
110
+ acquired: false;
111
+ holder: CheckoutLockFile;
112
+ lockPath: string;
113
+ stale: false;
114
+ staleReason: null;
115
+ };
116
+ /** Listing entry for the checkout lock, mirroring `LockListing`. */
117
+ export interface CheckoutLockListing {
118
+ holder: CheckoutLockFile;
119
+ ageMs: number;
120
+ stale: boolean;
121
+ staleReason: StaleReason | null;
122
+ lockPath: string;
123
+ }
52
124
  /** Outcome of `LockManager.acquire()`. */
53
125
  export type AcquireResult = {
54
126
  acquired: true;
@@ -45,3 +45,31 @@ export const LockFileSchema = z.object({
45
45
  */
46
46
  skipPidCheck: z.boolean().optional(),
47
47
  });
48
+ /**
49
+ * Filename of the checkout-scoped lock (#901), stored alongside the numeric
50
+ * `<issue>.lock` files. Deliberately non-numeric so `LockManager.list()` —
51
+ * which parses each filename as an issue number and skips anything else —
52
+ * ignores it rather than surfacing a bogus `NaN` issue.
53
+ */
54
+ export const CHECKOUT_LOCK_FILENAME = "checkout.lock";
55
+ /**
56
+ * On-disk payload for the checkout-scoped lock (#901).
57
+ *
58
+ * The per-issue lock from #625 is keyed on issue number, so two sessions
59
+ * working *different* issues take different lock files and never contend —
60
+ * yet `git checkout`, `reset`, `rebase` and `merge` are global to a working
61
+ * tree. This lock represents the tree itself.
62
+ *
63
+ * Extends `LockFileSchema` with:
64
+ * - `issue` — which issue the holder is working on, so a refusal can name it
65
+ * (AC-2) and point the loser at the right worktree (AC-3).
66
+ * - `sessionId` — Claude Code's per-session id, read from the PreToolUse
67
+ * stdin envelope. Optional: it is the *preferred* holder identity because a
68
+ * skill shell's PID dies immediately after acquire, but the hook falls back
69
+ * to `pid`+`hostname` when the envelope does not carry one, so nothing
70
+ * depends on it being present.
71
+ */
72
+ export const CheckoutLockFileSchema = LockFileSchema.extend({
73
+ issue: z.number().int().positive(),
74
+ sessionId: z.string().optional(),
75
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Fenced-code-block tracking for line-oriented markdown scanners.
3
+ *
4
+ * Multiple parsers in this codebase scan an issue/PR body line-by-line
5
+ * looking for patterns (checkbox items, `## Non-Goals` bullets). Without
6
+ * fence awareness, a markdown-authoring example quoted inside a fence
7
+ * — showing what the pattern syntax looks like — gets scanned as if it
8
+ * were real content. See #947 (ac-parser.ts) and its sibling in
9
+ * scope/analyzer.ts's `parseNonGoals`.
10
+ */
11
+ /**
12
+ * Compute, for every line of a split markdown body, whether that line falls
13
+ * inside a fenced code block (CommonMark rules: matching delimiter
14
+ * character, closing fence length >= opening fence length; an unclosed
15
+ * fence runs to EOF). The delimiter lines themselves are marked `true` —
16
+ * they're fence syntax, not real content, so patterns should skip them too.
17
+ */
18
+ export declare function computeFenceMask(lines: string[]): boolean[];
19
+ /**
20
+ * Blank out every line that falls inside a fenced code block, preserving
21
+ * line count (and therefore `\n`-relative offsets) so callers that locate
22
+ * sections via newline-anchored regexes on the full body are unaffected.
23
+ */
24
+ export declare function stripFencedLines(body: string): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Fenced-code-block tracking for line-oriented markdown scanners.
3
+ *
4
+ * Multiple parsers in this codebase scan an issue/PR body line-by-line
5
+ * looking for patterns (checkbox items, `## Non-Goals` bullets). Without
6
+ * fence awareness, a markdown-authoring example quoted inside a fence
7
+ * — showing what the pattern syntax looks like — gets scanned as if it
8
+ * were real content. See #947 (ac-parser.ts) and its sibling in
9
+ * scope/analyzer.ts's `parseNonGoals`.
10
+ */
11
+ /** Matches a fenced-code-block delimiter line (` ``` ` or `~~~`, 3+ repeats). */
12
+ const FENCE_DELIMITER_RE = /^\s*(`{3,}|~{3,})/;
13
+ /**
14
+ * Compute, for every line of a split markdown body, whether that line falls
15
+ * inside a fenced code block (CommonMark rules: matching delimiter
16
+ * character, closing fence length >= opening fence length; an unclosed
17
+ * fence runs to EOF). The delimiter lines themselves are marked `true` —
18
+ * they're fence syntax, not real content, so patterns should skip them too.
19
+ */
20
+ export function computeFenceMask(lines) {
21
+ const mask = new Array(lines.length).fill(false);
22
+ let fenceChar = null;
23
+ let fenceLen = 0;
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const match = lines[i].match(FENCE_DELIMITER_RE);
26
+ if (fenceChar === null) {
27
+ if (match) {
28
+ fenceChar = match[1][0];
29
+ fenceLen = match[1].length;
30
+ mask[i] = true;
31
+ }
32
+ continue;
33
+ }
34
+ mask[i] = true;
35
+ if (match && match[1][0] === fenceChar && match[1].length >= fenceLen) {
36
+ fenceChar = null;
37
+ fenceLen = 0;
38
+ }
39
+ }
40
+ return mask;
41
+ }
42
+ /**
43
+ * Blank out every line that falls inside a fenced code block, preserving
44
+ * line count (and therefore `\n`-relative offsets) so callers that locate
45
+ * sections via newline-anchored regexes on the full body are unaffected.
46
+ */
47
+ export function stripFencedLines(body) {
48
+ const lines = body.split("\n");
49
+ const mask = computeFenceMask(lines);
50
+ return lines.map((line, i) => (mask[i] ? "" : line)).join("\n");
51
+ }
@@ -4,6 +4,7 @@
4
4
  * Detects installed MCP clients (Claude Desktop, Cursor, VS Code)
5
5
  * and generates appropriate configuration entries for Sequant MCP server.
6
6
  */
7
+ import { type McpServerConfig } from "./system.js";
7
8
  /** Path to the project-level MCP config file used by Claude Code */
8
9
  export declare const PROJECT_MCP_JSON = ".mcp.json";
9
10
  /**
@@ -38,6 +39,29 @@ export declare function getSequantMcpConfig(options?: {
38
39
  projectDir?: string;
39
40
  clientType?: McpClientType;
40
41
  }): Record<string, unknown>;
42
+ /**
43
+ * Build the MCP server set for an autonomous phase agent (#936).
44
+ *
45
+ * Phase agents are a different trust domain from the interactive Claude
46
+ * Desktop app: they run unattended, and Claude Desktop configs cannot use
47
+ * `${VAR}` references, so they hold literal secrets that the SDK would
48
+ * otherwise serialize verbatim into the child process's `--mcp-config`
49
+ * argv. This builder allowlists instead of passing through — it unions the
50
+ * project's own `.mcp.json` (secret-free by convention, committed to git)
51
+ * with a guaranteed sequant server entry, and never reads
52
+ * `claude_desktop_config.json` **unless** a server name is explicitly
53
+ * listed in `opts.desktopAllowlist` (from `settings.run.mcpAllowlist`) —
54
+ * the deliberate per-server opt-in for a desktop-only server. A name not
55
+ * present in the desktop config is silently ignored.
56
+ *
57
+ * @param cwd - Directory to resolve `.mcp.json` from (the phase worktree)
58
+ * @param opts.desktopAllowlist - Exact `mcpServers` keys to pass through
59
+ * from Claude Desktop config, despite the default exclusion
60
+ * @returns MCP server configurations for the phase agent
61
+ */
62
+ export declare function getPhaseMcpServersConfig(cwd?: string, opts?: {
63
+ desktopAllowlist?: string[];
64
+ }): Record<string, McpServerConfig>;
41
65
  /**
42
66
  * Detect which MCP-compatible clients are installed
43
67
  */
@@ -8,6 +8,7 @@ import * as fs from "fs";
8
8
  import * as os from "os";
9
9
  import * as path from "path";
10
10
  import { getVersion } from "./version.js";
11
+ import { getMcpServersConfig } from "./system.js";
11
12
  /** Path to the project-level MCP config file used by Claude Code */
12
13
  export const PROJECT_MCP_JSON = ".mcp.json";
13
14
  /**
@@ -59,6 +60,56 @@ export function getSequantMcpConfig(options) {
59
60
  }
60
61
  return config;
61
62
  }
63
+ /**
64
+ * Build the MCP server set for an autonomous phase agent (#936).
65
+ *
66
+ * Phase agents are a different trust domain from the interactive Claude
67
+ * Desktop app: they run unattended, and Claude Desktop configs cannot use
68
+ * `${VAR}` references, so they hold literal secrets that the SDK would
69
+ * otherwise serialize verbatim into the child process's `--mcp-config`
70
+ * argv. This builder allowlists instead of passing through — it unions the
71
+ * project's own `.mcp.json` (secret-free by convention, committed to git)
72
+ * with a guaranteed sequant server entry, and never reads
73
+ * `claude_desktop_config.json` **unless** a server name is explicitly
74
+ * listed in `opts.desktopAllowlist` (from `settings.run.mcpAllowlist`) —
75
+ * the deliberate per-server opt-in for a desktop-only server. A name not
76
+ * present in the desktop config is silently ignored.
77
+ *
78
+ * @param cwd - Directory to resolve `.mcp.json` from (the phase worktree)
79
+ * @param opts.desktopAllowlist - Exact `mcpServers` keys to pass through
80
+ * from Claude Desktop config, despite the default exclusion
81
+ * @returns MCP server configurations for the phase agent
82
+ */
83
+ export function getPhaseMcpServersConfig(cwd, opts) {
84
+ const mcpJsonPath = path.resolve(cwd ?? ".", PROJECT_MCP_JSON);
85
+ let servers = {};
86
+ try {
87
+ const content = fs.readFileSync(mcpJsonPath, "utf-8");
88
+ const config = JSON.parse(content);
89
+ if (config.mcpServers &&
90
+ typeof config.mcpServers === "object" &&
91
+ !Array.isArray(config.mcpServers)) {
92
+ servers = config.mcpServers;
93
+ }
94
+ }
95
+ catch {
96
+ // .mcp.json doesn't exist or is invalid — sequant entry still applies
97
+ }
98
+ const allowedDesktopServers = {};
99
+ if (opts?.desktopAllowlist && opts.desktopAllowlist.length > 0) {
100
+ const desktopServers = getMcpServersConfig() ?? {};
101
+ for (const name of opts.desktopAllowlist) {
102
+ if (desktopServers[name]) {
103
+ allowedDesktopServers[name] = desktopServers[name];
104
+ }
105
+ }
106
+ }
107
+ return {
108
+ ...allowedDesktopServers,
109
+ ...servers,
110
+ sequant: getSequantMcpConfig(),
111
+ };
112
+ }
62
113
  /**
63
114
  * Detect which MCP-compatible clients are installed
64
115
  */
@@ -73,6 +73,10 @@ export declare function detectFeatures(criteria: AcceptanceCriterion[], title: s
73
73
  *
74
74
  * Looks for a "Non-Goals" or "Out of Scope" section with checkbox items.
75
75
  *
76
+ * Fenced code blocks are excluded before matching (#947 sibling) — a
77
+ * Non-Goals example shown inside a fence (e.g. illustrating checkbox
78
+ * syntax) must not itself be read as a real non-goal item.
79
+ *
76
80
  * @param issueBody - Full issue body markdown
77
81
  * @returns Non-goals extraction result
78
82
  */
@@ -16,6 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  import { DEFAULT_SCOPE_CONFIG } from "./types.js";
19
+ import { stripFencedLines } from "../markdown-fence.js";
19
20
  /**
20
21
  * Keywords for clustering AC items by functional area
21
22
  */
@@ -247,14 +248,19 @@ export function detectFeatures(criteria, title) {
247
248
  *
248
249
  * Looks for a "Non-Goals" or "Out of Scope" section with checkbox items.
249
250
  *
251
+ * Fenced code blocks are excluded before matching (#947 sibling) — a
252
+ * Non-Goals example shown inside a fence (e.g. illustrating checkbox
253
+ * syntax) must not itself be read as a real non-goal item.
254
+ *
250
255
  * @param issueBody - Full issue body markdown
251
256
  * @returns Non-goals extraction result
252
257
  */
253
258
  export function parseNonGoals(issueBody) {
254
259
  const items = [];
260
+ const strippedBody = stripFencedLines(issueBody);
255
261
  // Find Non-Goals section (case-insensitive)
256
262
  const sectionPattern = /##\s*(?:Non[- ]?Goals|Out\s+of\s+Scope|Scope\s+Boundaries)\s*\n([\s\S]*?)(?=\n##|\n---|$)/i;
257
- const sectionMatch = issueBody.match(sectionPattern);
263
+ const sectionMatch = strippedBody.match(sectionPattern);
258
264
  if (!sectionMatch) {
259
265
  return {
260
266
  items: [],