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
@@ -30,6 +30,20 @@
30
30
  * ```
31
31
  */
32
32
  import { type AcceptanceCriterion, type AcceptanceCriteria, type ACVerificationMethod } from "./workflow/state-schema.js";
33
+ /**
34
+ * Build a word-boundary-anchored, case-insensitive matcher for a keyword.
35
+ * `\b` sits correctly around spaces and hyphens too, so this is safe for
36
+ * both single words ("ui") and phrases ("end-to-end", "unit test") — a
37
+ * phrase already reads as self-anchoring under plain substring matching,
38
+ * and anchoring it doesn't change that (#946).
39
+ *
40
+ * @param allowPlural - Also match a trailing "s" (e.g. `flag` -> `flags`).
41
+ * Only meaningful for single nouns/verbs that commonly appear inflected
42
+ * in prose; multi-word phrases never need it.
43
+ *
44
+ * @internal Exported for testing only — not part of the module's public API.
45
+ */
46
+ export declare function keywordMatcher(keyword: string, allowPlural?: boolean): RegExp;
33
47
  /**
34
48
  * Infer verification method from description text
35
49
  *
@@ -37,6 +51,32 @@ import { type AcceptanceCriterion, type AcceptanceCriteria, type ACVerificationM
37
51
  * @returns The inferred verification method (defaults to 'manual')
38
52
  */
39
53
  export declare function inferVerificationMethod(description: string): ACVerificationMethod;
54
+ /**
55
+ * Resolve the verification method for an AC, preferring a declared
56
+ * `Evidence:` clause over keyword inference (#938).
57
+ *
58
+ * - Evidence names a backtick-quoted command containing a unit-test
59
+ * token (`test`, `vitest`, `jest`) → `unit_test`.
60
+ * - Evidence names any other backtick-quoted command → `integration_test`.
61
+ * - Evidence is prose with no backtick command (e.g. "human review") →
62
+ * `manual`.
63
+ * - No evidence declared → falls back to {@link inferVerificationMethod}.
64
+ *
65
+ * @param description - The AC description text (evidence clause stripped)
66
+ * @param evidence - The declared evidence clause, if any
67
+ * @returns The resolved verification method
68
+ */
69
+ export declare function resolveVerificationMethod(description: string, evidence?: string): ACVerificationMethod;
70
+ /**
71
+ * Whether a declared `Evidence:` clause describes a CLAUDE.md-style gate
72
+ * test — a fixture-exists / section-present / flag-wired assertion — rather
73
+ * than an ordinary behavioral unit/integration test or a human sign-off.
74
+ *
75
+ * @param evidence - The declared evidence clause text (from {@link splitEvidenceClause})
76
+ * @returns True when the evidence text matches the gate-test keyword set and
77
+ * does not read as a manual-review attestation
78
+ */
79
+ export declare function isGateTestEvidence(evidence: string): boolean;
40
80
  /**
41
81
  * Parse acceptance criteria from GitHub issue markdown
42
82
  *
@@ -30,6 +30,7 @@
30
30
  * ```
31
31
  */
32
32
  import { createAcceptanceCriterion, createAcceptanceCriteria, } from "./workflow/state-schema.js";
33
+ import { computeFenceMask } from "./markdown-fence.js";
33
34
  /**
34
35
  * Regex patterns for AC extraction
35
36
  *
@@ -115,6 +116,39 @@ const VERIFICATION_KEYWORDS = {
115
116
  "manual test": "manual",
116
117
  verify: "manual",
117
118
  };
119
+ /**
120
+ * Escape regex metacharacters in a literal keyword before embedding it in a
121
+ * constructed RegExp.
122
+ */
123
+ function escapeRegExp(literal) {
124
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ }
126
+ /**
127
+ * Build a word-boundary-anchored, case-insensitive matcher for a keyword.
128
+ * `\b` sits correctly around spaces and hyphens too, so this is safe for
129
+ * both single words ("ui") and phrases ("end-to-end", "unit test") — a
130
+ * phrase already reads as self-anchoring under plain substring matching,
131
+ * and anchoring it doesn't change that (#946).
132
+ *
133
+ * @param allowPlural - Also match a trailing "s" (e.g. `flag` -> `flags`).
134
+ * Only meaningful for single nouns/verbs that commonly appear inflected
135
+ * in prose; multi-word phrases never need it.
136
+ *
137
+ * @internal Exported for testing only — not part of the module's public API.
138
+ */
139
+ export function keywordMatcher(keyword, allowPlural = false) {
140
+ const escaped = escapeRegExp(keyword);
141
+ const pattern = allowPlural ? `\\b${escaped}s?\\b` : `\\b${escaped}\\b`;
142
+ return new RegExp(pattern, "i");
143
+ }
144
+ /**
145
+ * Precompiled, longest-keyword-first matchers for {@link VERIFICATION_KEYWORDS}.
146
+ * Longest-first preserves the original precedence (e.g. "unit test" wins
147
+ * over a lone "unit").
148
+ */
149
+ const VERIFICATION_KEYWORD_MATCHERS = Object.entries(VERIFICATION_KEYWORDS)
150
+ .sort((a, b) => b[0].length - a[0].length)
151
+ .map(([keyword, method]) => ({ regex: keywordMatcher(keyword), method }));
118
152
  /**
119
153
  * Infer verification method from description text
120
154
  *
@@ -122,16 +156,156 @@ const VERIFICATION_KEYWORDS = {
122
156
  * @returns The inferred verification method (defaults to 'manual')
123
157
  */
124
158
  export function inferVerificationMethod(description) {
125
- const lowerDesc = description.toLowerCase();
126
- // Check for explicit keywords (longer phrases first)
127
- const sortedKeywords = Object.keys(VERIFICATION_KEYWORDS).sort((a, b) => b.length - a.length);
128
- for (const keyword of sortedKeywords) {
129
- if (lowerDesc.includes(keyword)) {
130
- return VERIFICATION_KEYWORDS[keyword];
159
+ for (const { regex, method } of VERIFICATION_KEYWORD_MATCHERS) {
160
+ if (regex.test(description)) {
161
+ return method;
131
162
  }
132
163
  }
133
164
  return "manual";
134
165
  }
166
+ /**
167
+ * Matches a trailing `Evidence:` clause on an AC line (#938). Only the
168
+ * LAST occurrence is honored — the clause is defined as trailing, and AC
169
+ * prose can legitimately contain the word "Evidence:" earlier in the
170
+ * sentence (e.g. "the report cites strong Evidence: peer review ..."
171
+ * before the real declaration). Splitting on the first match would corrupt
172
+ * extraction by swallowing the real trailing clause into the description.
173
+ */
174
+ const EVIDENCE_CLAUSE_RE = /\bEvidence:\s*/gi;
175
+ /**
176
+ * Split a trailing `Evidence:` clause out of an AC description (#938).
177
+ *
178
+ * @param description - The AC description text (post ID-stripping)
179
+ * @returns The description with the clause removed, plus the declared
180
+ * evidence text if present
181
+ */
182
+ function splitEvidenceClause(description) {
183
+ const matches = [...description.matchAll(EVIDENCE_CLAUSE_RE)];
184
+ if (matches.length === 0)
185
+ return { description };
186
+ const last = matches[matches.length - 1];
187
+ const before = description.slice(0, last.index).trim();
188
+ const evidence = description.slice(last.index + last[0].length).trim();
189
+ if (!before || !evidence)
190
+ return { description };
191
+ return { description: before, evidence };
192
+ }
193
+ /**
194
+ * Matches a backtick-quoted command inside a declared evidence clause.
195
+ * A command (vs. prose like "human review") is what makes evidence
196
+ * runnable/checkable rather than a manual attestation.
197
+ */
198
+ const EVIDENCE_COMMAND_RE = /`([^`]+)`/;
199
+ /**
200
+ * Command tokens that indicate a unit-test invocation. Anything else
201
+ * backtick-quoted (CLI commands, curl, scripts) is treated as an
202
+ * integration-level check.
203
+ */
204
+ const UNIT_TEST_COMMAND_RE = /\b(test|vitest|jest)\b/i;
205
+ /**
206
+ * Resolve the verification method for an AC, preferring a declared
207
+ * `Evidence:` clause over keyword inference (#938).
208
+ *
209
+ * - Evidence names a backtick-quoted command containing a unit-test
210
+ * token (`test`, `vitest`, `jest`) → `unit_test`.
211
+ * - Evidence names any other backtick-quoted command → `integration_test`.
212
+ * - Evidence is prose with no backtick command (e.g. "human review") →
213
+ * `manual`.
214
+ * - No evidence declared → falls back to {@link inferVerificationMethod}.
215
+ *
216
+ * @param description - The AC description text (evidence clause stripped)
217
+ * @param evidence - The declared evidence clause, if any
218
+ * @returns The resolved verification method
219
+ */
220
+ export function resolveVerificationMethod(description, evidence) {
221
+ if (evidence) {
222
+ const commandMatch = EVIDENCE_COMMAND_RE.exec(evidence);
223
+ if (commandMatch) {
224
+ return UNIT_TEST_COMMAND_RE.test(commandMatch[1])
225
+ ? "unit_test"
226
+ : "integration_test";
227
+ }
228
+ return "manual";
229
+ }
230
+ return inferVerificationMethod(description);
231
+ }
232
+ /**
233
+ * Keywords matching the CLAUDE.md "gate test" definition — a test whose job
234
+ * is to gate a claim that "a fixture exists, a skill section is present, a
235
+ * flag is wired" (#830, #939). Distinct from {@link VERIFICATION_KEYWORDS}:
236
+ * those classify *how* an AC is checked (unit/integration/browser/manual),
237
+ * this classifies *what kind of claim* the test makes, independent of
238
+ * verification method.
239
+ *
240
+ * A heuristic, not a hard classifier — same caveat {@link inferVerificationMethod}
241
+ * already carries. Over-firing sweeps ordinary tests into the gate-test
242
+ * population (inflating the mutation-verification gate's authoring burden);
243
+ * under-firing lets a real gate test slip through ungated, the exact defect
244
+ * class #830 exists to prevent.
245
+ */
246
+ /**
247
+ * Single-word gate-test keywords that commonly appear inflected/pluralized
248
+ * in evidence prose ("flags are wired", "fixtures exist", "the section
249
+ * presents..."). Matched with an optional trailing "s" (#946 AC-4) so
250
+ * anchoring to word boundaries doesn't lose those forms.
251
+ */
252
+ const GATE_TEST_PLURAL_KEYWORDS = ["fixture", "section", "flag", "present"];
253
+ /**
254
+ * Single-word gate-test keywords with no natural plural/inflection needed
255
+ * for this AC's evidence phrasing — matched as exact words only.
256
+ */
257
+ const GATE_TEST_SINGULAR_KEYWORDS = ["wired", "exists", "registered"];
258
+ /**
259
+ * Multi-word gate-test phrases, matched as substrings (already
260
+ * self-anchoring, same rationale as {@link VERIFICATION_KEYWORDS} phrases).
261
+ *
262
+ * The mutation-verification rule (CLAUDE.md, #830) IS the gate-test
263
+ * definition — an AC that already names its own mutation-verified record
264
+ * is self-identifying, even when it doesn't separately name a
265
+ * fixture/section/flag (e.g. "lint test fails when the §7 entry is
266
+ * deleted (mutation-verified)").
267
+ */
268
+ const GATE_TEST_PHRASE_KEYWORDS = [
269
+ "skill gate",
270
+ "mutation-verified",
271
+ "mutation test",
272
+ ];
273
+ const GATE_TEST_KEYWORD_MATCHERS = [
274
+ ...GATE_TEST_PLURAL_KEYWORDS.map((k) => keywordMatcher(k, true)),
275
+ ...GATE_TEST_SINGULAR_KEYWORDS.map((k) => keywordMatcher(k)),
276
+ ...GATE_TEST_PHRASE_KEYWORDS.map((k) => keywordMatcher(k)),
277
+ ];
278
+ /**
279
+ * Phrases that mark evidence as a human-review attestation rather than an
280
+ * automated gate test, even when a {@link GATE_TEST_KEYWORD_MATCHERS} term also
281
+ * appears (#939 QA finding: "reviewed manually, fixture exists in the demo
282
+ * env" false-positived on `fixture` alone). Checked first and short-circuits
283
+ * to `false` — a human sign-off is not the mutation-verifiable claim §6i
284
+ * gates on, regardless of which nouns it happens to mention.
285
+ */
286
+ const MANUAL_REVIEW_NEGATIVE_SIGNALS = [
287
+ "reviewed manually",
288
+ "manual review",
289
+ "human review",
290
+ "manually verified",
291
+ "manually confirmed",
292
+ ];
293
+ /**
294
+ * Whether a declared `Evidence:` clause describes a CLAUDE.md-style gate
295
+ * test — a fixture-exists / section-present / flag-wired assertion — rather
296
+ * than an ordinary behavioral unit/integration test or a human sign-off.
297
+ *
298
+ * @param evidence - The declared evidence clause text (from {@link splitEvidenceClause})
299
+ * @returns True when the evidence text matches the gate-test keyword set and
300
+ * does not read as a manual-review attestation
301
+ */
302
+ export function isGateTestEvidence(evidence) {
303
+ const lower = evidence.toLowerCase();
304
+ if (MANUAL_REVIEW_NEGATIVE_SIGNALS.some((signal) => lower.includes(signal))) {
305
+ return false;
306
+ }
307
+ return GATE_TEST_KEYWORD_MATCHERS.some((regex) => regex.test(evidence));
308
+ }
135
309
  /**
136
310
  * Parse a single line and extract AC if present
137
311
  *
@@ -146,12 +320,14 @@ function parseACLine(line) {
146
320
  if (match) {
147
321
  // Combine groups 2 and 3 for bold-wrapped format (Pattern 3)
148
322
  // where group 3 captures optional text after closing **
149
- const description = match[3]
323
+ const rawDescription = match[3]
150
324
  ? `${match[2].trim()} ${match[3].trim()}`.trim()
151
325
  : match[2].trim();
326
+ const { description, evidence } = splitEvidenceClause(rawDescription);
152
327
  return {
153
328
  id: match[1].toUpperCase(),
154
329
  description,
330
+ ...(evidence !== undefined ? { evidence } : {}),
155
331
  };
156
332
  }
157
333
  }
@@ -181,11 +357,11 @@ function parseACLine(line) {
181
357
  export function parseAcceptanceCriteria(issueBody) {
182
358
  const criteria = [];
183
359
  const seenIds = new Set();
184
- const push = (id, description) => {
360
+ const push = (id, description, evidence) => {
185
361
  if (seenIds.has(id))
186
362
  return;
187
363
  seenIds.add(id);
188
- criteria.push(createAcceptanceCriterion(id, description, inferVerificationMethod(description)));
364
+ criteria.push(createAcceptanceCriterion(id, description, resolveVerificationMethod(description, evidence), evidence));
189
365
  };
190
366
  // Split into lines and process each. `inAcSection` tracks whether the
191
367
  // current line falls under an `## Acceptance Criteria` heading; it toggles on
@@ -193,6 +369,10 @@ export function parseAcceptanceCriteria(issueBody) {
193
369
  // rule as `parseNonGoals` in ready-gate.ts). `bareCount` numbers synthesized
194
370
  // IDs for bare checkboxes in appearance order.
195
371
  const lines = issueBody.split("\n");
372
+ // Lines inside a fenced code block (#947) — an AC-authoring example shown
373
+ // in a fence must not be scanned as a real AC, or toggle the AC-section
374
+ // heading state, in either pass below.
375
+ const fenceMask = computeFenceMask(lines);
196
376
  let inAcSection = false;
197
377
  let bareCount = 0;
198
378
  // Pre-scan for explicit IDs anywhere in the body. Synthesis must skip IDs
@@ -201,12 +381,17 @@ export function parseAcceptanceCriteria(issueBody) {
201
381
  // synthesizes AC-1 first and the author's explicit AC-1 is silently dropped
202
382
  // by the first-occurrence dedupe.
203
383
  const explicitIds = new Set();
204
- for (const line of lines) {
205
- const parsed = parseACLine(line);
384
+ for (let i = 0; i < lines.length; i++) {
385
+ if (fenceMask[i])
386
+ continue;
387
+ const parsed = parseACLine(lines[i]);
206
388
  if (parsed)
207
389
  explicitIds.add(parsed.id);
208
390
  }
209
- for (const line of lines) {
391
+ for (let i = 0; i < lines.length; i++) {
392
+ const line = lines[i];
393
+ if (fenceMask[i])
394
+ continue;
210
395
  if (HEADING_RE.test(line)) {
211
396
  inAcSection = AC_HEADING_RE.test(line);
212
397
  continue;
@@ -215,14 +400,15 @@ export function parseAcceptanceCriteria(issueBody) {
215
400
  // so previously-parsable issues are unaffected (AC-4).
216
401
  const parsed = parseACLine(line);
217
402
  if (parsed) {
218
- push(parsed.id, parsed.description);
403
+ push(parsed.id, parsed.description, parsed.evidence);
219
404
  continue;
220
405
  }
221
406
  // Bare-checkbox fallback: only inside the AC section (AC-1/AC-2).
222
407
  if (inAcSection) {
223
408
  const bare = line.match(BARE_CHECKBOX_RE);
224
- const description = bare?.[1].trim();
225
- if (description) {
409
+ const bareText = bare?.[1].trim();
410
+ if (bareText) {
411
+ const { description, evidence } = splitEvidenceClause(bareText);
226
412
  // Synthesize `AC-<n>`, skipping any ID already taken by an explicit
227
413
  // marker — before OR after this line — so a synthesized ID can never
228
414
  // collide with (and be silently dropped against) a hand-written one.
@@ -230,7 +416,7 @@ export function parseAcceptanceCriteria(issueBody) {
230
416
  do {
231
417
  id = `AC-${++bareCount}`;
232
418
  } while (seenIds.has(id) || explicitIds.has(id));
233
- push(id, description);
419
+ push(id, description, evidence);
234
420
  }
235
421
  }
236
422
  }
@@ -31,3 +31,26 @@ export declare function parseWholeNumber(flag: string, opts: WholeNumberOptions)
31
31
  * `merge --watch` messages this originally shipped with do not change.
32
32
  */
33
33
  export declare function parsePositiveSeconds(flag: string): (value: string) => number;
34
+ /**
35
+ * Build a commander coercion for a `--models`/`--efforts`-shaped flag (#914).
36
+ *
37
+ * Validates the spec via `config-resolver.ts`'s `parsePhaseSpec` — a bare
38
+ * value applies to every phase, a comma list of `phase=value` pairs applies
39
+ * per phase, and a malformed spec (empty segment, mixed bare/pair form, or
40
+ * an unrecognized phase name) fails fast here rather than reaching
41
+ * `resolvePhasePolicies` silently. On success, returns the ORIGINAL raw spec
42
+ * string unchanged (not the parsed map) — `RunOptions.models`/`.efforts`
43
+ * stay plain strings, and `resolvePhasePolicies` re-parses for real when
44
+ * building `ExecutionConfig.phasePolicies`, so parsing has exactly one
45
+ * source of truth even though it runs twice.
46
+ *
47
+ * `allowedValues`, when given, additionally rejects any resolved value not
48
+ * in the set — used for `--efforts` (closed enum: `settings.ts:EFFORT_LEVELS`)
49
+ * so a typo like `--efforts exec=mediu` fails at the CLI boundary the same
50
+ * way it already does when set via `settings.json`, instead of only
51
+ * surfacing once the value reaches the SDK. `--models` passes no
52
+ * `allowedValues` — model aliases/IDs are intentionally unvalidated (they
53
+ * churn independently of sequant releases; the SDK errors clearly on a bad
54
+ * one).
55
+ */
56
+ export declare function parsePhaseSpecFlag(flag: string, phaseNames: string[], allowedValues?: readonly string[]): (value: string) => string;
@@ -8,6 +8,7 @@
8
8
  * @module
9
9
  */
10
10
  import { InvalidArgumentError } from "commander";
11
+ import { parsePhaseSpec } from "./workflow/config-resolver.js";
11
12
  /**
12
13
  * Build a commander coercion for a whole-number flag.
13
14
  *
@@ -44,3 +45,45 @@ export function parsePositiveSeconds(flag) {
44
45
  unitSingular: "second",
45
46
  });
46
47
  }
48
+ /**
49
+ * Build a commander coercion for a `--models`/`--efforts`-shaped flag (#914).
50
+ *
51
+ * Validates the spec via `config-resolver.ts`'s `parsePhaseSpec` — a bare
52
+ * value applies to every phase, a comma list of `phase=value` pairs applies
53
+ * per phase, and a malformed spec (empty segment, mixed bare/pair form, or
54
+ * an unrecognized phase name) fails fast here rather than reaching
55
+ * `resolvePhasePolicies` silently. On success, returns the ORIGINAL raw spec
56
+ * string unchanged (not the parsed map) — `RunOptions.models`/`.efforts`
57
+ * stay plain strings, and `resolvePhasePolicies` re-parses for real when
58
+ * building `ExecutionConfig.phasePolicies`, so parsing has exactly one
59
+ * source of truth even though it runs twice.
60
+ *
61
+ * `allowedValues`, when given, additionally rejects any resolved value not
62
+ * in the set — used for `--efforts` (closed enum: `settings.ts:EFFORT_LEVELS`)
63
+ * so a typo like `--efforts exec=mediu` fails at the CLI boundary the same
64
+ * way it already does when set via `settings.json`, instead of only
65
+ * surfacing once the value reaches the SDK. `--models` passes no
66
+ * `allowedValues` — model aliases/IDs are intentionally unvalidated (they
67
+ * churn independently of sequant releases; the SDK errors clearly on a bad
68
+ * one).
69
+ */
70
+ export function parsePhaseSpecFlag(flag, phaseNames, allowedValues) {
71
+ return (value) => {
72
+ let parsed;
73
+ try {
74
+ parsed = parsePhaseSpec(value, phaseNames);
75
+ }
76
+ catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ throw new InvalidArgumentError(`${flag}: ${message}`);
79
+ }
80
+ if (allowedValues) {
81
+ for (const v of Object.values(parsed)) {
82
+ if (!allowedValues.includes(v)) {
83
+ throw new InvalidArgumentError(`${flag}: '${v}' is not one of ${allowedValues.join("|")}.`);
84
+ }
85
+ }
86
+ }
87
+ return value;
88
+ };
89
+ }
@@ -117,6 +117,8 @@ export interface IssueSummary {
117
117
  loopTriggered?: boolean;
118
118
  prNumber?: number;
119
119
  prUrl?: string;
120
+ /** Set when PR creation was skipped for lack of commits ahead of base (#920). */
121
+ prSkippedReason?: string;
120
122
  failureReason?: string;
121
123
  qaVerdict?: string;
122
124
  unmetCount?: number;
@@ -1332,7 +1332,13 @@ function renderSummaryDetail(r, ctx) {
1332
1332
  .map((p) => (p.success ? c.green(p.name) : c.red(p.name)))
1333
1333
  .join(" → ");
1334
1334
  const pr = r.prNumber ? ` · PR #${r.prNumber}` : "";
1335
- return { summary: `${phaseSeq}${pr}`, extras: [] };
1335
+ // #920: a phase-restricted run (no PR, no failure) still needs the reason
1336
+ // stated — otherwise a passing spec-only run looks indistinguishable from
1337
+ // one where PR creation was simply never attempted for no stated reason.
1338
+ const extras = !r.prNumber && r.prSkippedReason
1339
+ ? [c.dim(`PR skipped — ${r.prSkippedReason}`)]
1340
+ : [];
1341
+ return { summary: `${phaseSeq}${pr}`, extras };
1336
1342
  }
1337
1343
  // Failed → multi-line detail.
1338
1344
  const reason = r.failureReason ?? "failure";
@@ -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
+ }