sequant 2.1.2 → 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.
@@ -218,6 +218,131 @@ export function formatDuration(seconds) {
218
218
  const secs = seconds % 60;
219
219
  return `${mins}m ${secs.toFixed(0)}s`;
220
220
  }
221
+ /**
222
+ * Check whether the exec phase produced any changes in the worktree.
223
+ * Returns true if HEAD has commits unique to it relative to origin/main
224
+ * OR uncommitted work is present.
225
+ *
226
+ * Uses `git rev-list --count origin/main..HEAD` (commits reachable from HEAD
227
+ * but not origin/main) instead of `git diff origin/main..HEAD`, because the
228
+ * two-dot diff also fires in reverse when origin/main has advanced past HEAD
229
+ * — on stale branches that would falsely report "has commits" even when the
230
+ * exec phase produced nothing, reintroducing the bug #534 is fixing.
231
+ *
232
+ * Fails open (returns true) on git errors — a missing origin ref is better
233
+ * diagnosed as a real zero-diff run than as a false phase failure.
234
+ *
235
+ * @internal Exported for testing only.
236
+ */
237
+ export function hasExecChanges(cwd) {
238
+ let commitsAhead;
239
+ try {
240
+ const count = execSync("git rev-list --count origin/main..HEAD", {
241
+ cwd,
242
+ stdio: "pipe",
243
+ })
244
+ .toString()
245
+ .trim();
246
+ commitsAhead = Number.parseInt(count, 10) > 0;
247
+ }
248
+ catch {
249
+ return true;
250
+ }
251
+ if (commitsAhead)
252
+ return true;
253
+ try {
254
+ const porcelain = execSync("git status --porcelain", { cwd, stdio: "pipe" })
255
+ .toString()
256
+ .trim();
257
+ return porcelain.length > 0;
258
+ }
259
+ catch {
260
+ return true;
261
+ }
262
+ }
263
+ /**
264
+ * Map a successful AgentPhaseResult to a PhaseResult, applying phase-specific
265
+ * guards that catch agent sessions which returned success without producing
266
+ * usable work (#534):
267
+ *
268
+ * - `qa`: fails when no parseable verdict is found (empty or malformed output).
269
+ * - `exec`: fails when no commits and no uncommitted changes exist.
270
+ *
271
+ * @internal Exported for testing only.
272
+ */
273
+ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds, cwd) {
274
+ const tails = {
275
+ stderrTail: agentResult.stderrTail,
276
+ stdoutTail: agentResult.stdoutTail,
277
+ exitCode: agentResult.exitCode,
278
+ };
279
+ if (phase === "qa") {
280
+ const verdict = agentResult.output
281
+ ? parseQaVerdict(agentResult.output)
282
+ : null;
283
+ const summary = agentResult.output
284
+ ? (parseQaSummary(agentResult.output) ?? undefined)
285
+ : undefined;
286
+ if (verdict &&
287
+ verdict !== "READY_FOR_MERGE" &&
288
+ verdict !== "NEEDS_VERIFICATION") {
289
+ return {
290
+ phase,
291
+ success: false,
292
+ durationSeconds,
293
+ error: `QA verdict: ${verdict}`,
294
+ sessionId: agentResult.sessionId,
295
+ output: agentResult.output,
296
+ verdict,
297
+ summary,
298
+ ...tails,
299
+ };
300
+ }
301
+ if (!verdict) {
302
+ // #534: a null verdict (empty or unparseable output) is not success.
303
+ return {
304
+ phase,
305
+ success: false,
306
+ durationSeconds,
307
+ error: "QA completed without a parseable verdict",
308
+ sessionId: agentResult.sessionId,
309
+ output: agentResult.output,
310
+ summary,
311
+ ...tails,
312
+ };
313
+ }
314
+ return {
315
+ phase,
316
+ success: true,
317
+ durationSeconds,
318
+ sessionId: agentResult.sessionId,
319
+ output: agentResult.output,
320
+ verdict,
321
+ summary,
322
+ ...tails,
323
+ };
324
+ }
325
+ if (phase === "exec" && !hasExecChanges(cwd)) {
326
+ // #534: an exec phase that produced nothing is not success.
327
+ return {
328
+ phase,
329
+ success: false,
330
+ durationSeconds,
331
+ error: "exec produced no changes (no commits, no uncommitted work)",
332
+ sessionId: agentResult.sessionId,
333
+ output: agentResult.output,
334
+ ...tails,
335
+ };
336
+ }
337
+ return {
338
+ phase,
339
+ success: true,
340
+ durationSeconds,
341
+ sessionId: agentResult.sessionId,
342
+ output: agentResult.output,
343
+ ...tails,
344
+ };
345
+ }
221
346
  /**
222
347
  * Get the prompt for a phase with the issue number substituted.
223
348
  * Selects self-contained prompts for non-Claude agents.
@@ -390,52 +515,8 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
390
515
  shutdownManager.removeAbortController(abortController);
391
516
  }
392
517
  const durationSeconds = (Date.now() - startTime) / 1000;
393
- // Map AgentPhaseResult to PhaseResult
394
- const tails = {
395
- stderrTail: agentResult.stderrTail,
396
- stdoutTail: agentResult.stdoutTail,
397
- exitCode: agentResult.exitCode,
398
- };
399
518
  if (agentResult.success) {
400
- // For QA phase, check the verdict to determine actual success
401
- // Agent "success" just means the execution completed — we need to parse the verdict
402
- if (phase === "qa" && agentResult.output) {
403
- const verdict = parseQaVerdict(agentResult.output);
404
- const summary = parseQaSummary(agentResult.output) ?? undefined;
405
- if (verdict &&
406
- verdict !== "READY_FOR_MERGE" &&
407
- verdict !== "NEEDS_VERIFICATION") {
408
- return {
409
- phase,
410
- success: false,
411
- durationSeconds,
412
- error: `QA verdict: ${verdict}`,
413
- sessionId: agentResult.sessionId,
414
- output: agentResult.output,
415
- verdict,
416
- summary,
417
- ...tails,
418
- };
419
- }
420
- return {
421
- phase,
422
- success: true,
423
- durationSeconds,
424
- sessionId: agentResult.sessionId,
425
- output: agentResult.output,
426
- verdict: verdict ?? undefined,
427
- summary,
428
- ...tails,
429
- };
430
- }
431
- return {
432
- phase,
433
- success: true,
434
- durationSeconds,
435
- sessionId: agentResult.sessionId,
436
- output: agentResult.output,
437
- ...tails,
438
- };
519
+ return mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds, cwd);
439
520
  }
440
521
  return {
441
522
  phase,
@@ -443,7 +524,9 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
443
524
  durationSeconds,
444
525
  error: agentResult.error,
445
526
  sessionId: agentResult.sessionId,
446
- ...tails,
527
+ stderrTail: agentResult.stderrTail,
528
+ stdoutTail: agentResult.stdoutTail,
529
+ exitCode: agentResult.exitCode,
447
530
  };
448
531
  }
449
532
  /**
@@ -65,6 +65,33 @@ export interface RunInit {
65
65
  /** Per-phase progress callback */
66
66
  onProgress?: ProgressCallback;
67
67
  }
68
+ /**
69
+ * Pure result of config resolution — no side effects, no services.
70
+ * Produced by `RunOrchestrator.resolveConfig()` and consumed by both
71
+ * `run()` (internally) and the CLI (for pre-run display).
72
+ */
73
+ export interface ResolvedRun {
74
+ /** Post-merge run options (defaults < settings < env < explicit) */
75
+ mergedOptions: RunOptions;
76
+ /** Execution config derived from mergedOptions */
77
+ config: ExecutionConfig;
78
+ /** Parsed + dep-sorted issue numbers (pre-state-guard) */
79
+ issueNumbers: number[];
80
+ /** Resolved batches if --batch specified, else null */
81
+ batches: number[][] | null;
82
+ /** Resolved base branch (CLI → settings → auto-detect → "main") */
83
+ baseBranch: string;
84
+ /** Stack from manifest */
85
+ stack: string;
86
+ /** True when phases will be auto-detected from issue labels */
87
+ autoDetectPhases: boolean;
88
+ /** True when worktree isolation is enabled */
89
+ worktreeIsolationEnabled: boolean;
90
+ /** True when JSON logging will be initialized */
91
+ logEnabled: boolean;
92
+ /** True when state tracking will be enabled */
93
+ stateEnabled: boolean;
94
+ }
68
95
  /**
69
96
  * Structured result of a full orchestrator run.
70
97
  */
@@ -101,6 +128,16 @@ export interface RunResult {
101
128
  export declare class RunOrchestrator {
102
129
  private readonly cfg;
103
130
  constructor(config: OrchestratorConfig);
131
+ /**
132
+ * Pure config resolution — no side effects.
133
+ *
134
+ * Produces a `ResolvedRun` containing merged options, execution config,
135
+ * parsed/sorted issue numbers, base branch, and display-only flags. Safe
136
+ * to call for preview purposes (e.g. CLI config display before run).
137
+ *
138
+ * `run()` uses this internally to avoid duplicating resolution logic.
139
+ */
140
+ static resolveConfig(init: RunInit, issueArgs: string[], batches?: number[][] | null): ResolvedRun;
104
141
  /**
105
142
  * Full lifecycle execution — the primary entry point for programmatic use.
106
143
  *
@@ -37,20 +37,21 @@ export class RunOrchestrator {
37
37
  this.cfg = config;
38
38
  }
39
39
  /**
40
- * Full lifecycle executionthe primary entry point for programmatic use.
40
+ * Pure config resolutionno side effects.
41
41
  *
42
- * Handles: config resolution services setup state guard →
43
- * issue discovery worktree creation execution → metrics → cleanup.
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.
44
47
  */
45
- static async run(init, issueArgs, batches) {
46
- const { options, settings, manifest, onProgress } = init;
47
- // ── Config resolution ──────────────────────────────────────────────
48
+ static resolveConfig(init, issueArgs, batches) {
49
+ const { options, settings, manifest } = init;
48
50
  const mergedOptions = resolveRunOptions(options, settings);
49
51
  const baseBranch = init.baseBranch ??
50
52
  options.base ??
51
53
  settings.run.defaultBase ??
52
54
  detectDefaultBranch(mergedOptions.verbose ?? false);
53
- // ── Parse issues ───────────────────────────────────────────────────
54
55
  let issueNumbers;
55
56
  let resolvedBatches = batches ?? null;
56
57
  if (mergedOptions.batch &&
@@ -67,6 +68,39 @@ export class RunOrchestrator {
67
68
  .map((i) => parseInt(i, 10))
68
69
  .filter((n) => !isNaN(n));
69
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;
70
104
  if (issueNumbers.length === 0) {
71
105
  return {
72
106
  results: [],
@@ -74,17 +108,11 @@ export class RunOrchestrator {
74
108
  exitCode: 0,
75
109
  worktreeMap: new Map(),
76
110
  issueInfoMap: new Map(),
77
- config: buildExecutionConfig(mergedOptions, settings, 0),
111
+ config,
78
112
  mergedOptions,
79
113
  logWriter: null,
80
114
  };
81
115
  }
82
- // Sort by dependencies
83
- if (issueNumbers.length > 1 && !resolvedBatches) {
84
- issueNumbers = sortByDependencies(issueNumbers);
85
- }
86
- // ── Build execution config ─────────────────────────────────────────
87
- const config = buildExecutionConfig(mergedOptions, settings, issueNumbers.length);
88
116
  // ── Services setup ─────────────────────────────────────────────────
89
117
  let logWriter = null;
90
118
  const shouldLog = !mergedOptions.noLog &&
@@ -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.2",
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": {