sequant 2.1.1 → 2.2.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/bin/cli.js +1 -0
  4. package/dist/src/commands/init.d.ts +1 -0
  5. package/dist/src/commands/init.js +122 -3
  6. package/dist/src/commands/run-compat.d.ts +14 -0
  7. package/dist/src/commands/run-compat.js +12 -0
  8. package/dist/src/commands/run-display.d.ts +17 -0
  9. package/dist/src/commands/run-display.js +116 -0
  10. package/dist/src/commands/run.d.ts +4 -26
  11. package/dist/src/commands/run.js +47 -772
  12. package/dist/src/commands/status.js +24 -1
  13. package/dist/src/index.d.ts +11 -0
  14. package/dist/src/index.js +9 -0
  15. package/dist/src/lib/errors.d.ts +93 -0
  16. package/dist/src/lib/errors.js +97 -0
  17. package/dist/src/lib/settings.d.ts +236 -0
  18. package/dist/src/lib/settings.js +482 -37
  19. package/dist/src/lib/skill-version.d.ts +19 -0
  20. package/dist/src/lib/skill-version.js +68 -0
  21. package/dist/src/lib/templates.d.ts +1 -0
  22. package/dist/src/lib/templates.js +1 -1
  23. package/dist/src/lib/workflow/batch-executor.js +13 -5
  24. package/dist/src/lib/workflow/config-resolver.d.ts +50 -0
  25. package/dist/src/lib/workflow/config-resolver.js +167 -0
  26. package/dist/src/lib/workflow/error-classifier.d.ts +17 -7
  27. package/dist/src/lib/workflow/error-classifier.js +113 -15
  28. package/dist/src/lib/workflow/phase-executor.d.ts +31 -0
  29. package/dist/src/lib/workflow/phase-executor.js +143 -48
  30. package/dist/src/lib/workflow/run-log-schema.d.ts +12 -0
  31. package/dist/src/lib/workflow/run-log-schema.js +7 -1
  32. package/dist/src/lib/workflow/run-orchestrator.d.ts +161 -0
  33. package/dist/src/lib/workflow/run-orchestrator.js +510 -0
  34. package/dist/src/lib/workflow/worktree-manager.d.ts +4 -3
  35. package/dist/src/lib/workflow/worktree-manager.js +61 -11
  36. package/package.json +1 -1
  37. package/templates/skills/assess/SKILL.md +239 -77
  38. package/templates/skills/exec/SKILL.md +7 -68
  39. package/templates/skills/fullsolve/SKILL.md +303 -137
  40. package/templates/skills/qa/SKILL.md +42 -46
  41. package/templates/skills/qa/scripts/quality-checks.sh +47 -1
  42. package/templates/skills/spec/SKILL.md +183 -982
  43. package/templates/skills/spec/references/quality-checklist.md +75 -0
  44. package/templates/skills/test/SKILL.md +0 -27
  45. package/templates/skills/testgen/SKILL.md +0 -27
@@ -0,0 +1,510 @@
1
+ /**
2
+ * RunOrchestrator — CLI-free execution engine for sequant workflows.
3
+ *
4
+ * Owns the full lifecycle: config → issue discovery → dispatch → results.
5
+ * Importable and usable without Commander.js or CLI context.
6
+ *
7
+ * @module
8
+ */
9
+ import chalk from "chalk";
10
+ import { spawnSync } from "child_process";
11
+ import pLimit from "p-limit";
12
+ import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "./worktree-manager.js";
13
+ import { LogWriter } from "./log-writer.js";
14
+ import { StateManager } from "./state-manager.js";
15
+ import { ShutdownManager } from "../shutdown.js";
16
+ import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, } from "./batch-executor.js";
17
+ import { reconcileStateAtStartup } from "./state-utils.js";
18
+ import { getCommitHash } from "./git-diff-utils.js";
19
+ import { MetricsWriter } from "./metrics-writer.js";
20
+ import { determineOutcome } from "./metrics-schema.js";
21
+ import { getTokenUsageForRun } from "./token-utils.js";
22
+ import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
23
+ // ── Orchestrator ────────────────────────────────────────────────────────────
24
+ /**
25
+ * CLI-free workflow execution engine.
26
+ *
27
+ * Two usage modes:
28
+ * 1. Full lifecycle: `RunOrchestrator.run(init, issueNumbers)` — handles
29
+ * services, worktrees, state guard, execution, and metrics.
30
+ * 2. Low-level: `new RunOrchestrator(config).execute(issueNumbers)` — caller
31
+ * manages setup/teardown.
32
+ */
33
+ export class RunOrchestrator {
34
+ cfg;
35
+ constructor(config) {
36
+ this.validate(config);
37
+ this.cfg = config;
38
+ }
39
+ /**
40
+ * Pure config resolution — no side effects.
41
+ *
42
+ * Produces a `ResolvedRun` containing merged options, execution config,
43
+ * parsed/sorted issue numbers, base branch, and display-only flags. Safe
44
+ * to call for preview purposes (e.g. CLI config display before run).
45
+ *
46
+ * `run()` uses this internally to avoid duplicating resolution logic.
47
+ */
48
+ static resolveConfig(init, issueArgs, batches) {
49
+ const { options, settings, manifest } = init;
50
+ const mergedOptions = resolveRunOptions(options, settings);
51
+ const baseBranch = init.baseBranch ??
52
+ options.base ??
53
+ settings.run.defaultBase ??
54
+ detectDefaultBranch(mergedOptions.verbose ?? false);
55
+ let issueNumbers;
56
+ let resolvedBatches = batches ?? null;
57
+ if (mergedOptions.batch &&
58
+ mergedOptions.batch.length > 0 &&
59
+ !resolvedBatches) {
60
+ resolvedBatches = parseBatches(mergedOptions.batch);
61
+ issueNumbers = resolvedBatches.flat();
62
+ }
63
+ else if (resolvedBatches) {
64
+ issueNumbers = resolvedBatches.flat();
65
+ }
66
+ else {
67
+ issueNumbers = issueArgs
68
+ .map((i) => parseInt(i, 10))
69
+ .filter((n) => !isNaN(n));
70
+ }
71
+ if (issueNumbers.length > 1 && !resolvedBatches) {
72
+ issueNumbers = sortByDependencies(issueNumbers);
73
+ }
74
+ const config = buildExecutionConfig(mergedOptions, settings, issueNumbers.length);
75
+ const logEnabled = !mergedOptions.noLog &&
76
+ !config.dryRun &&
77
+ (mergedOptions.logJson ?? settings.run.logJson ?? false);
78
+ return {
79
+ mergedOptions,
80
+ config,
81
+ issueNumbers,
82
+ batches: resolvedBatches,
83
+ baseBranch,
84
+ stack: manifest.stack,
85
+ autoDetectPhases: mergedOptions.autoDetectPhases ?? false,
86
+ worktreeIsolationEnabled: mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0,
87
+ logEnabled,
88
+ stateEnabled: !config.dryRun,
89
+ };
90
+ }
91
+ /**
92
+ * Full lifecycle execution — the primary entry point for programmatic use.
93
+ *
94
+ * Handles: config resolution → services setup → state guard →
95
+ * issue discovery → worktree creation → execution → metrics → cleanup.
96
+ */
97
+ static async run(init, issueArgs, batches) {
98
+ const { manifest, onProgress, settings } = init;
99
+ // ── Config resolution ──────────────────────────────────────────────
100
+ const resolved = RunOrchestrator.resolveConfig(init, issueArgs, batches);
101
+ const { mergedOptions, config, baseBranch } = resolved;
102
+ let { issueNumbers } = resolved;
103
+ const resolvedBatches = resolved.batches;
104
+ if (issueNumbers.length === 0) {
105
+ return {
106
+ results: [],
107
+ logPath: null,
108
+ exitCode: 0,
109
+ worktreeMap: new Map(),
110
+ issueInfoMap: new Map(),
111
+ config,
112
+ mergedOptions,
113
+ logWriter: null,
114
+ };
115
+ }
116
+ // ── Services setup ─────────────────────────────────────────────────
117
+ let logWriter = null;
118
+ const shouldLog = !mergedOptions.noLog &&
119
+ !config.dryRun &&
120
+ (mergedOptions.logJson ?? settings.run.logJson);
121
+ if (shouldLog) {
122
+ const runConfig = {
123
+ phases: config.phases,
124
+ sequential: config.sequential,
125
+ qualityLoop: config.qualityLoop,
126
+ maxIterations: config.maxIterations,
127
+ chain: mergedOptions.chain,
128
+ qaGate: mergedOptions.qaGate,
129
+ };
130
+ try {
131
+ logWriter = new LogWriter({
132
+ logPath: mergedOptions.logPath ?? settings.run.logPath,
133
+ verbose: config.verbose,
134
+ startCommit: getCommitHash(process.cwd()),
135
+ });
136
+ await logWriter.initialize(runConfig);
137
+ }
138
+ catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ console.log(chalk.yellow(` ! Log initialization failed, continuing without logging: ${msg}`));
141
+ logWriter = null;
142
+ }
143
+ }
144
+ let stateManager = null;
145
+ if (!config.dryRun) {
146
+ stateManager = new StateManager({ verbose: config.verbose });
147
+ }
148
+ const shutdown = new ShutdownManager();
149
+ if (logWriter) {
150
+ const writer = logWriter;
151
+ shutdown.registerCleanup("Finalize run logs", async () => {
152
+ await writer.finalize();
153
+ });
154
+ }
155
+ // ── Pre-flight state guard ─────────────────────────────────────────
156
+ if (stateManager && !config.dryRun) {
157
+ try {
158
+ const reconcileResult = await reconcileStateAtStartup({
159
+ verbose: config.verbose,
160
+ });
161
+ if (reconcileResult.success && reconcileResult.advanced.length > 0) {
162
+ console.log(chalk.gray(` State reconciled: ${reconcileResult.advanced.map((n) => `#${n}`).join(", ")} → merged`));
163
+ }
164
+ }
165
+ catch (error) {
166
+ logNonFatalWarning(" ! State reconciliation failed, continuing...", error, config.verbose);
167
+ }
168
+ }
169
+ if (stateManager && !config.dryRun && !mergedOptions.force) {
170
+ const activeIssues = [];
171
+ for (const issueNumber of issueNumbers) {
172
+ try {
173
+ const issueState = await stateManager.getIssueState(issueNumber);
174
+ if (issueState &&
175
+ (issueState.status === "ready_for_merge" ||
176
+ issueState.status === "merged")) {
177
+ console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
178
+ }
179
+ else {
180
+ activeIssues.push(issueNumber);
181
+ }
182
+ }
183
+ catch (error) {
184
+ logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
185
+ activeIssues.push(issueNumber);
186
+ }
187
+ }
188
+ if (activeIssues.length < issueNumbers.length) {
189
+ issueNumbers = activeIssues;
190
+ if (issueNumbers.length === 0) {
191
+ console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
192
+ shutdown.dispose();
193
+ return {
194
+ results: [],
195
+ logPath: null,
196
+ exitCode: 0,
197
+ worktreeMap: new Map(),
198
+ issueInfoMap: new Map(),
199
+ config,
200
+ mergedOptions,
201
+ logWriter: null,
202
+ };
203
+ }
204
+ }
205
+ }
206
+ // ── Issue info + worktree setup ────────────────────────────────────
207
+ const issueInfoMap = new Map();
208
+ for (const issueNumber of issueNumbers) {
209
+ issueInfoMap.set(issueNumber, await getIssueInfo(issueNumber));
210
+ }
211
+ const useWorktreeIsolation = mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0;
212
+ let worktreeMap = new Map();
213
+ if (useWorktreeIsolation && !config.dryRun) {
214
+ const issueData = issueNumbers.map((num) => ({
215
+ number: num,
216
+ title: issueInfoMap.get(num)?.title || `Issue #${num}`,
217
+ }));
218
+ if (mergedOptions.chain) {
219
+ worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, baseBranch);
220
+ }
221
+ else {
222
+ worktreeMap = await ensureWorktrees(issueData, config.verbose, manifest.packageManager, baseBranch);
223
+ }
224
+ for (const [issueNum, worktree] of worktreeMap.entries()) {
225
+ if (!worktree.existed) {
226
+ shutdown.registerCleanup(`Cleanup worktree for #${issueNum}`, async () => {
227
+ spawnSync("git", ["worktree", "remove", "--force", worktree.path], {
228
+ stdio: "pipe",
229
+ });
230
+ });
231
+ }
232
+ }
233
+ }
234
+ // ── Execute ────────────────────────────────────────────────────────
235
+ let results = [];
236
+ try {
237
+ const orchestrator = new RunOrchestrator({
238
+ config,
239
+ options: mergedOptions,
240
+ issueInfoMap,
241
+ worktreeMap,
242
+ services: { logWriter, stateManager, shutdownManager: shutdown },
243
+ packageManager: manifest.packageManager,
244
+ baseBranch,
245
+ onProgress,
246
+ });
247
+ if (resolvedBatches) {
248
+ for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
249
+ const batch = resolvedBatches[batchIdx];
250
+ console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${resolvedBatches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
251
+ const batchResults = await orchestrator.execute(batch);
252
+ results.push(...batchResults);
253
+ const batchFailed = batchResults.some((r) => !r.success);
254
+ if (batchFailed && config.sequential) {
255
+ console.log(chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
256
+ break;
257
+ }
258
+ }
259
+ }
260
+ else {
261
+ results = await orchestrator.execute(issueNumbers);
262
+ }
263
+ // ── Finalize logs ──────────────────────────────────────────────
264
+ let logPath = null;
265
+ if (logWriter) {
266
+ logPath = await logWriter.finalize({
267
+ endCommit: getCommitHash(process.cwd()),
268
+ });
269
+ }
270
+ // ── Record metrics ─────────────────────────────────────────────
271
+ if (!config.dryRun && results.length > 0) {
272
+ try {
273
+ await RunOrchestrator.recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers);
274
+ }
275
+ catch (metricsError) {
276
+ logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
277
+ }
278
+ }
279
+ return {
280
+ results,
281
+ logPath,
282
+ exitCode: results.some((r) => !r.success) && !config.dryRun ? 1 : 0,
283
+ worktreeMap,
284
+ issueInfoMap,
285
+ config,
286
+ mergedOptions,
287
+ logWriter,
288
+ };
289
+ }
290
+ finally {
291
+ shutdown.dispose();
292
+ }
293
+ }
294
+ /**
295
+ * Execute workflow for the given issue numbers.
296
+ * Returns one IssueResult per issue.
297
+ */
298
+ async execute(issueNumbers) {
299
+ if (issueNumbers.length === 0) {
300
+ return [];
301
+ }
302
+ const batchCtx = this.buildBatchContext();
303
+ const { config } = this.cfg;
304
+ const options = this.cfg.options;
305
+ // Chain mode implies sequential
306
+ if (options.chain) {
307
+ config.sequential = true;
308
+ }
309
+ if (config.sequential) {
310
+ return this.executeSequential(issueNumbers, batchCtx, options);
311
+ }
312
+ return this.executeParallel(issueNumbers, batchCtx);
313
+ }
314
+ // ── Private helpers ─────────────────────────────────────────────────────
315
+ validate(config) {
316
+ if (!config.config) {
317
+ throw new Error("OrchestratorConfig.config is required");
318
+ }
319
+ if (!config.config.phases ||
320
+ !Array.isArray(config.config.phases) ||
321
+ config.config.phases.length === 0) {
322
+ throw new Error("OrchestratorConfig.config.phases must be a non-empty array");
323
+ }
324
+ }
325
+ buildBatchContext() {
326
+ const { config, options, issueInfoMap, worktreeMap, services } = this.cfg;
327
+ return {
328
+ config,
329
+ options,
330
+ issueInfoMap,
331
+ worktreeMap,
332
+ logWriter: services.logWriter ?? null,
333
+ stateManager: services.stateManager ?? null,
334
+ shutdownManager: services.shutdownManager,
335
+ packageManager: this.cfg.packageManager,
336
+ baseBranch: this.cfg.baseBranch,
337
+ onProgress: this.cfg.onProgress,
338
+ };
339
+ }
340
+ async executeSequential(issueNumbers, batchCtx, options) {
341
+ const results = [];
342
+ const shutdown = this.cfg.services.shutdownManager;
343
+ for (let i = 0; i < issueNumbers.length; i++) {
344
+ const issueNumber = issueNumbers[i];
345
+ if (shutdown?.shuttingDown) {
346
+ break;
347
+ }
348
+ const result = await this.executeOneIssue({
349
+ issueNumber,
350
+ batchCtx,
351
+ chain: options.chain
352
+ ? { enabled: true, isLast: i === issueNumbers.length - 1 }
353
+ : undefined,
354
+ });
355
+ results.push(result);
356
+ if (!result.success) {
357
+ if (options.qaGate && options.chain) {
358
+ const qaFailed = result.phaseResults.some((p) => p.phase === "qa" && !p.success);
359
+ if (qaFailed)
360
+ break;
361
+ }
362
+ break;
363
+ }
364
+ }
365
+ return results;
366
+ }
367
+ async executeParallel(issueNumbers, batchCtx) {
368
+ const limit = pLimit(this.cfg.config.concurrency);
369
+ const shutdown = this.cfg.services.shutdownManager;
370
+ const settledResults = await Promise.allSettled(issueNumbers.map((issueNumber) => limit(async () => {
371
+ if (shutdown?.shuttingDown) {
372
+ return {
373
+ issueNumber,
374
+ success: false,
375
+ phaseResults: [],
376
+ durationSeconds: 0,
377
+ loopTriggered: false,
378
+ };
379
+ }
380
+ return this.executeOneIssue({
381
+ issueNumber,
382
+ batchCtx: { ...batchCtx, onProgress: this.cfg.onProgress },
383
+ parallelIssueNumber: issueNumber,
384
+ });
385
+ })));
386
+ return settledResults.map((settled, i) => {
387
+ if (settled.status === "fulfilled") {
388
+ return settled.value;
389
+ }
390
+ return {
391
+ issueNumber: issueNumbers[i],
392
+ success: false,
393
+ phaseResults: [],
394
+ durationSeconds: 0,
395
+ loopTriggered: false,
396
+ };
397
+ });
398
+ }
399
+ async executeOneIssue(args) {
400
+ const { issueNumber, batchCtx, chain, parallelIssueNumber } = args;
401
+ const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, } = batchCtx;
402
+ const issueInfo = issueInfoMap.get(issueNumber) ?? {
403
+ title: `Issue #${issueNumber}`,
404
+ labels: [],
405
+ };
406
+ const worktreeInfo = worktreeMap.get(issueNumber);
407
+ if (logWriter) {
408
+ logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
409
+ }
410
+ const ctx = {
411
+ issueNumber,
412
+ title: issueInfo.title,
413
+ labels: issueInfo.labels,
414
+ config,
415
+ options,
416
+ services: { logWriter, stateManager, shutdownManager },
417
+ worktree: worktreeInfo
418
+ ? { path: worktreeInfo.path, branch: worktreeInfo.branch }
419
+ : undefined,
420
+ chain,
421
+ packageManager,
422
+ baseBranch,
423
+ onProgress,
424
+ };
425
+ const result = await runIssueWithLogging(ctx);
426
+ if (logWriter && result.prNumber && result.prUrl) {
427
+ logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
428
+ }
429
+ if (logWriter) {
430
+ logWriter.completeIssue(parallelIssueNumber);
431
+ }
432
+ return result;
433
+ }
434
+ static async recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers) {
435
+ const metricsWriter = new MetricsWriter({ verbose: config.verbose });
436
+ const totalDuration = results.reduce((sum, r) => sum + (r.durationSeconds ?? 0), 0);
437
+ const allPhases = new Set();
438
+ for (const result of results) {
439
+ for (const pr of result.phaseResults) {
440
+ const phase = pr.phase;
441
+ if ([
442
+ "spec",
443
+ "security-review",
444
+ "testgen",
445
+ "exec",
446
+ "test",
447
+ "qa",
448
+ "loop",
449
+ ].includes(phase)) {
450
+ allPhases.add(phase);
451
+ }
452
+ }
453
+ }
454
+ let totalFilesChanged = 0;
455
+ let totalLinesAdded = 0;
456
+ let totalQaIterations = 0;
457
+ for (const result of results) {
458
+ const wt = worktreeMap.get(result.issueNumber);
459
+ if (wt?.path) {
460
+ const s = getWorktreeDiffStats(wt.path);
461
+ totalFilesChanged += s.filesChanged;
462
+ totalLinesAdded += s.linesAdded;
463
+ }
464
+ if (result.loopTriggered) {
465
+ totalQaIterations += result.phaseResults.filter((p) => p.phase === "loop").length;
466
+ }
467
+ }
468
+ const cliFlags = [];
469
+ if (mergedOptions.sequential)
470
+ cliFlags.push("--sequential");
471
+ if (mergedOptions.chain)
472
+ cliFlags.push("--chain");
473
+ if (mergedOptions.qaGate)
474
+ cliFlags.push("--qa-gate");
475
+ if (mergedOptions.qualityLoop)
476
+ cliFlags.push("--quality-loop");
477
+ if (mergedOptions.testgen)
478
+ cliFlags.push("--testgen");
479
+ const tokenUsage = getTokenUsageForRun(undefined, true);
480
+ const passed = results.filter((r) => r.success).length;
481
+ await metricsWriter.recordRun({
482
+ issues: issueNumbers,
483
+ phases: Array.from(allPhases),
484
+ outcome: determineOutcome(passed, results.length),
485
+ duration: totalDuration,
486
+ model: process.env.ANTHROPIC_MODEL ?? "opus",
487
+ flags: cliFlags,
488
+ metrics: {
489
+ tokensUsed: tokenUsage.tokensUsed,
490
+ filesChanged: totalFilesChanged,
491
+ linesAdded: totalLinesAdded,
492
+ acceptanceCriteria: 0,
493
+ qaIterations: totalQaIterations,
494
+ inputTokens: tokenUsage.inputTokens || undefined,
495
+ outputTokens: tokenUsage.outputTokens || undefined,
496
+ cacheTokens: tokenUsage.cacheTokens || undefined,
497
+ },
498
+ });
499
+ if (config.verbose) {
500
+ console.log(chalk.gray(" Metrics recorded to .sequant/metrics.json"));
501
+ }
502
+ }
503
+ }
504
+ /** Log a non-fatal warning: one-line summary always, detail in verbose. */
505
+ export function logNonFatalWarning(message, error, verbose) {
506
+ console.log(chalk.yellow(message));
507
+ if (verbose) {
508
+ console.log(chalk.gray(` ${error}`));
509
+ }
510
+ }
@@ -156,11 +156,12 @@ export declare function ensureWorktreesChain(issues: Array<{
156
156
  title: string;
157
157
  }>, verbose: boolean, packageManager?: string, baseBranch?: string): Promise<Map<number, WorktreeInfo>>;
158
158
  /**
159
- * Create a checkpoint commit in the worktree after QA passes
160
- * This allows recovery in case later issues in the chain fail
159
+ * Create a checkpoint commit in the worktree after QA passes.
160
+ * Only stages files that were touched by the issue's commits (diff vs baseBranch).
161
+ * If unrelated dirty files exist, emits a warning and skips the checkpoint.
161
162
  * @internal Exported for testing
162
163
  */
163
- export declare function createCheckpointCommit(worktreePath: string, issueNumber: number, verbose: boolean): boolean;
164
+ export declare function createCheckpointCommit(worktreePath: string, issueNumber: number, verbose: boolean, baseBranch?: string): boolean;
164
165
  /**
165
166
  * Check if any lockfile changed during a rebase and re-run install if needed.
166
167
  * This prevents dependency drift when the lockfile was updated on main.
@@ -612,30 +612,80 @@ export async function ensureWorktreesChain(issues, verbose, packageManager, base
612
612
  return worktrees;
613
613
  }
614
614
  /**
615
- * Create a checkpoint commit in the worktree after QA passes
616
- * This allows recovery in case later issues in the chain fail
615
+ * Create a checkpoint commit in the worktree after QA passes.
616
+ * Only stages files that were touched by the issue's commits (diff vs baseBranch).
617
+ * If unrelated dirty files exist, emits a warning and skips the checkpoint.
617
618
  * @internal Exported for testing
618
619
  */
619
- export function createCheckpointCommit(worktreePath, issueNumber, verbose) {
620
- // Check if there are uncommitted changes
621
- const statusResult = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { stdio: "pipe" });
620
+ export function createCheckpointCommit(worktreePath, issueNumber, verbose, baseBranch) {
621
+ // Check if there are uncommitted changes.
622
+ // Use -z (NUL-terminated) so paths with unicode or special chars aren't quoted/escaped.
623
+ const statusResult = spawnSync("git", ["-C", worktreePath, "status", "--porcelain", "-z"], { stdio: "pipe" });
622
624
  if (statusResult.status !== 0) {
623
625
  if (verbose) {
624
626
  console.log(chalk.yellow(` ! Could not check git status for checkpoint`));
625
627
  }
626
628
  return false;
627
629
  }
628
- const hasChanges = statusResult.stdout.toString().trim().length > 0;
629
- if (!hasChanges) {
630
+ const statusRaw = statusResult.stdout.toString();
631
+ if (statusRaw.length === 0) {
630
632
  if (verbose) {
631
633
  console.log(chalk.gray(` 📌 No changes to checkpoint (already committed)`));
632
634
  }
633
635
  return true;
634
636
  }
635
- // Stage all changes
636
- const addResult = spawnSync("git", ["-C", worktreePath, "add", "-A"], {
637
- stdio: "pipe",
638
- });
637
+ // Parse NUL-separated porcelain entries. Each entry is "XY path".
638
+ // For renames/copies, the next entry is the old path and must be consumed.
639
+ const entries = statusRaw.split("\0").filter((e) => e.length > 0);
640
+ const dirtyFiles = [];
641
+ for (let i = 0; i < entries.length; i++) {
642
+ const entry = entries[i];
643
+ const xy = entry.slice(0, 2);
644
+ const path = entry.slice(3);
645
+ if (path)
646
+ dirtyFiles.push(path);
647
+ // Rename (R) and copy (C) entries are followed by the original path — skip it
648
+ if (xy[0] === "R" || xy[0] === "C") {
649
+ i++;
650
+ }
651
+ }
652
+ // Determine which files to stage.
653
+ // When baseBranch is provided (chain mode), scope to feature paths only.
654
+ // When baseBranch is absent (non-chain), treat all dirty files as in-scope.
655
+ let inScope;
656
+ if (baseBranch) {
657
+ const diffResult = spawnSync("git", ["-C", worktreePath, "diff", "--name-only", "-z", `${baseBranch}...HEAD`], { stdio: "pipe" });
658
+ const featurePaths = new Set();
659
+ if (diffResult.status === 0) {
660
+ diffResult.stdout
661
+ .toString()
662
+ .split("\0")
663
+ .filter((p) => p.length > 0)
664
+ .forEach((p) => featurePaths.add(p));
665
+ }
666
+ inScope = dirtyFiles.filter((f) => featurePaths.has(f));
667
+ const outOfScope = dirtyFiles.filter((f) => !featurePaths.has(f));
668
+ // AC-2: If unrelated dirty files exist, warn and skip checkpoint
669
+ if (outOfScope.length > 0) {
670
+ console.log(chalk.yellow(` ⚠ Skipping checkpoint for #${issueNumber}: ${outOfScope.length} unrelated dirty file(s) in worktree:`));
671
+ for (const f of outOfScope) {
672
+ console.log(chalk.yellow(` - ${f}`));
673
+ }
674
+ return false;
675
+ }
676
+ }
677
+ else {
678
+ // Non-chain mode: all dirty files are in-scope
679
+ inScope = dirtyFiles;
680
+ }
681
+ if (inScope.length === 0) {
682
+ if (verbose) {
683
+ console.log(chalk.gray(` 📌 No in-scope changes to checkpoint`));
684
+ }
685
+ return true;
686
+ }
687
+ // AC-1: Stage only in-scope feature paths
688
+ const addResult = spawnSync("git", ["-C", worktreePath, "add", "--", ...inScope], { stdio: "pipe" });
639
689
  if (addResult.status !== 0) {
640
690
  if (verbose) {
641
691
  console.log(chalk.yellow(` ! Could not stage changes for checkpoint`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequant",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Quantize your development workflow - Sequential AI phases with quality gates",
5
5
  "type": "module",
6
6
  "bin": {