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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "sequant",
10
10
  "description": "Structured workflow system for Claude Code - GitHub issue resolution with spec, exec, test, and QA phases. Includes 17 skills, MCP server with workflow tools, and pre/post-tool hooks.",
11
- "version": "2.1.0",
11
+ "version": "2.2.0",
12
12
  "author": {
13
13
  "name": "sequant-io",
14
14
  "email": "hello@sequant.io"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sequant",
3
3
  "description": "Structured workflow system for Claude Code - GitHub issue resolution with spec, exec, test, and QA phases",
4
- "version": "2.1.2",
4
+ "version": "2.2.0",
5
5
  "author": {
6
6
  "name": "sequant-io",
7
7
  "email": "hello@sequant.io"
package/dist/bin/cli.js CHANGED
@@ -86,6 +86,7 @@ program
86
86
  .option("--no-symlinks", "Use copies instead of symlinks for scripts/dev/ files")
87
87
  .option("--no-agents-md", "Skip AGENTS.md generation")
88
88
  .option("--mcp", "Add Sequant MCP server to detected clients (use with --yes)")
89
+ .option("--upgrade-skills", "Upgrade skill files from installed package templates (with diff preview)")
89
90
  .action(initCommand);
90
91
  program
91
92
  .command("update")
@@ -10,6 +10,7 @@ interface InitOptions {
10
10
  noSymlinks?: boolean;
11
11
  agentsMd?: boolean;
12
12
  mcp?: boolean;
13
+ upgradeSkills?: boolean;
13
14
  }
14
15
  export declare function initCommand(options: InitOptions): Promise<void>;
15
16
  export {};
@@ -2,7 +2,10 @@
2
2
  * sequant init - Initialize Sequant in a project
3
3
  */
4
4
  import chalk from "chalk";
5
+ import { diffLines } from "diff";
5
6
  import inquirer from "inquirer";
7
+ import { join, dirname } from "path";
8
+ import { readdir } from "fs/promises";
6
9
  import { ui, colors } from "../lib/cli-ui.js";
7
10
  import { detectStack, detectAllStacks, getStackConfig, detectPackageManager, getPackageManagerCommands, STACKS, } from "../lib/stacks.js";
8
11
  import { copyTemplates } from "../lib/templates.js";
@@ -70,6 +73,11 @@ function logDefault(label, value) {
70
73
  console.log(chalk.blue(`${label}: ${value} (default)`));
71
74
  }
72
75
  export async function initCommand(options) {
76
+ // Handle --upgrade-skills: update skill files from installed package templates
77
+ if (options.upgradeSkills) {
78
+ await upgradeSkills();
79
+ return;
80
+ }
73
81
  // Show banner
74
82
  console.log(ui.banner());
75
83
  console.log(colors.success("\nInitializing Sequant...\n"));
@@ -503,3 +511,113 @@ export async function initCommand(options) {
503
511
  }
504
512
  console.log(chalk.gray("\nDocumentation: https://github.com/sequant-io/sequant#readme\n"));
505
513
  }
514
+ /**
515
+ * Upgrade installed skill files from the sequant package's templates.
516
+ * Shows a diff preview for each changed file and asks for confirmation.
517
+ */
518
+ async function upgradeSkills() {
519
+ console.log(chalk.bold("\nUpgrading skills from package templates...\n"));
520
+ const installedDir = ".claude/skills";
521
+ if (!(await fileExists(installedDir))) {
522
+ console.log(chalk.red("No skills directory found. Run `sequant init` first."));
523
+ return;
524
+ }
525
+ // Resolve the package's templates/skills directory
526
+ const { getTemplatesDir } = await import("../lib/templates.js");
527
+ const templateSkillsDir = join(getTemplatesDir(), "skills");
528
+ // Collect all files from both directories
529
+ const changes = [];
530
+ const newFiles = [];
531
+ async function compareDir(templateDir, installedBaseDir, relativePrefix) {
532
+ let entries;
533
+ try {
534
+ entries = await readdir(templateDir, { withFileTypes: true });
535
+ }
536
+ catch {
537
+ return;
538
+ }
539
+ for (const entry of entries) {
540
+ const relPath = join(relativePrefix, entry.name);
541
+ const templatePath = join(templateDir, entry.name);
542
+ const installedPath = join(installedBaseDir, relPath);
543
+ if (entry.isDirectory()) {
544
+ await compareDir(templatePath, installedBaseDir, relPath);
545
+ }
546
+ else {
547
+ const templateContent = await readFile(templatePath);
548
+ const exists = await fileExists(installedPath);
549
+ if (!exists) {
550
+ newFiles.push({ path: relPath, content: templateContent });
551
+ }
552
+ else {
553
+ const installedContent = await readFile(installedPath);
554
+ if (installedContent !== templateContent) {
555
+ changes.push({
556
+ path: relPath,
557
+ installed: installedContent,
558
+ template: templateContent,
559
+ });
560
+ }
561
+ }
562
+ }
563
+ }
564
+ }
565
+ await compareDir(templateSkillsDir, installedDir, "");
566
+ if (changes.length === 0 && newFiles.length === 0) {
567
+ console.log(chalk.green("All skills are up to date."));
568
+ return;
569
+ }
570
+ // Show summary
571
+ console.log(chalk.bold("Changes found:"));
572
+ if (changes.length > 0) {
573
+ console.log(chalk.yellow(` Modified: ${changes.length} file(s)`));
574
+ }
575
+ if (newFiles.length > 0) {
576
+ console.log(chalk.green(` New: ${newFiles.length} file(s)`));
577
+ }
578
+ console.log();
579
+ // Show diffs for modified files
580
+ for (const change of changes) {
581
+ console.log(chalk.yellow(`--- ${change.path} ---`));
582
+ const diff = diffLines(change.installed, change.template);
583
+ for (const part of diff) {
584
+ if (part.added) {
585
+ process.stdout.write(chalk.green(part.value));
586
+ }
587
+ else if (part.removed) {
588
+ process.stdout.write(chalk.red(part.value));
589
+ }
590
+ // Skip unchanged lines in diff output
591
+ }
592
+ console.log();
593
+ }
594
+ // Show new files
595
+ for (const file of newFiles) {
596
+ console.log(chalk.green(`+++ ${file.path} (new)`));
597
+ }
598
+ // Ask for confirmation
599
+ const isInteractive = shouldUseInteractiveMode();
600
+ if (isInteractive) {
601
+ const { proceed } = await inquirer.prompt([
602
+ {
603
+ type: "confirm",
604
+ name: "proceed",
605
+ message: `Apply ${changes.length + newFiles.length} skill update(s)?`,
606
+ default: true,
607
+ },
608
+ ]);
609
+ if (!proceed) {
610
+ console.log(chalk.gray("Aborted."));
611
+ return;
612
+ }
613
+ }
614
+ // Apply changes
615
+ for (const change of changes) {
616
+ await writeFile(join(installedDir, change.path), change.template);
617
+ }
618
+ for (const file of newFiles) {
619
+ await ensureDir(dirname(join(installedDir, file.path)));
620
+ await writeFile(join(installedDir, file.path), file.content);
621
+ }
622
+ console.log(chalk.green(`\nUpgraded ${changes.length + newFiles.length} skill file(s).`));
623
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Display helpers for `sequant run` — pre-run config block + post-run summary.
3
+ *
4
+ * Kept separate from run.ts so the adapter stays thin (see AC-2 of #503).
5
+ */
6
+ import type { ResolvedRun, RunResult } from "../lib/workflow/run-orchestrator.js";
7
+ /**
8
+ * Print pre-run config block.
9
+ *
10
+ * Columnar alignment via 15-char label padding. Conditional rows only
11
+ * appear when non-default, matching the pre-#503 format.
12
+ */
13
+ export declare function displayConfig(r: ResolvedRun): void;
14
+ /**
15
+ * Print post-run summary: per-issue status, log path, reflection, tips.
16
+ */
17
+ export declare function displaySummary(result: RunResult): void;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Display helpers for `sequant run` — pre-run config block + post-run summary.
3
+ *
4
+ * Kept separate from run.ts so the adapter stays thin (see AC-2 of #503).
5
+ */
6
+ import chalk from "chalk";
7
+ import { ui, colors } from "../lib/cli-ui.js";
8
+ import { formatDuration } from "../lib/workflow/phase-executor.js";
9
+ import { analyzeRun, formatReflection } from "../lib/workflow/run-reflect.js";
10
+ /**
11
+ * Print pre-run config block.
12
+ *
13
+ * Columnar alignment via 15-char label padding. Conditional rows only
14
+ * appear when non-default, matching the pre-#503 format.
15
+ */
16
+ export function displayConfig(r) {
17
+ const pad = (label) => label.padEnd(15);
18
+ const row = (label, value) => console.log(chalk.gray(` ${pad(label)}${value}`));
19
+ row("Stack", r.stack);
20
+ if (r.autoDetectPhases) {
21
+ row("Phases", "auto-detect from labels");
22
+ }
23
+ else {
24
+ row("Phases", r.config.phases.join(" \u2192 "));
25
+ }
26
+ row("Mode", r.config.sequential
27
+ ? "sequential (stop-on-failure)"
28
+ : `parallel (concurrency: ${r.config.concurrency})`);
29
+ if (r.config.qualityLoop) {
30
+ row("Quality loop", `enabled (max ${r.config.maxIterations} iterations)`);
31
+ }
32
+ if (r.mergedOptions.testgen)
33
+ row("Testgen", "enabled");
34
+ if (r.config.noSmartTests)
35
+ row("Smart tests", "disabled");
36
+ if (r.config.dryRun) {
37
+ console.log(chalk.yellow(` ! DRY RUN - no actual execution`));
38
+ }
39
+ if (r.logEnabled)
40
+ row("Logging", "JSON");
41
+ if (r.stateEnabled)
42
+ row("State", "enabled");
43
+ if (r.mergedOptions.force) {
44
+ console.log(chalk.yellow(` ${pad("Force")}enabled (bypass state guard)`));
45
+ }
46
+ if (r.issueNumbers.length > 0) {
47
+ row("Issues", r.issueNumbers.map((n) => `#${n}`).join(", "));
48
+ }
49
+ if (r.worktreeIsolationEnabled) {
50
+ console.log(chalk.gray(` Worktree isolation: enabled`));
51
+ }
52
+ if (r.baseBranch) {
53
+ console.log(chalk.gray(` Base branch: ${r.baseBranch}`));
54
+ }
55
+ if (r.mergedOptions.chain) {
56
+ console.log(chalk.gray(` Chain mode: enabled (each issue branches from previous)`));
57
+ }
58
+ if (r.mergedOptions.qaGate) {
59
+ console.log(chalk.gray(` QA gate: enabled (chain waits for QA pass)`));
60
+ }
61
+ }
62
+ /**
63
+ * Print post-run summary: per-issue status, log path, reflection, tips.
64
+ */
65
+ export function displaySummary(result) {
66
+ const { results, logPath, config, mergedOptions } = result;
67
+ if (results.length === 0)
68
+ return;
69
+ const passed = results.filter((r) => r.success).length;
70
+ const failed = results.filter((r) => !r.success).length;
71
+ console.log("\n" + ui.divider());
72
+ console.log(colors.info(" Summary"));
73
+ console.log(ui.divider());
74
+ console.log(`\n ${colors.success(`${passed} passed`)} ${colors.muted("·")} ${colors.error(`${failed} failed`)}`);
75
+ for (const r of results) {
76
+ const status = r.success
77
+ ? ui.statusIcon("success")
78
+ : ui.statusIcon("error");
79
+ const duration = r.durationSeconds
80
+ ? colors.muted(` (${formatDuration(r.durationSeconds)})`)
81
+ : "";
82
+ const phases = r.phaseResults
83
+ .map((p) => (p.success ? colors.success(p.phase) : colors.error(p.phase)))
84
+ .join(" → ");
85
+ const loopInfo = r.loopTriggered ? colors.warning(" [loop]") : "";
86
+ const prInfo = r.prUrl ? colors.muted(` → PR #${r.prNumber}`) : "";
87
+ console.log(` ${status} #${r.issueNumber}: ${phases}${loopInfo}${prInfo}${duration}`);
88
+ }
89
+ console.log("");
90
+ if (logPath) {
91
+ console.log(colors.muted(` Log: ${logPath}`));
92
+ console.log("");
93
+ }
94
+ if (mergedOptions.reflect && results.length > 0) {
95
+ const reflection = analyzeRun({
96
+ results,
97
+ issueInfoMap: result.issueInfoMap,
98
+ runLog: result.logWriter?.getRunLog() ?? null,
99
+ config: { phases: config.phases, qualityLoop: config.qualityLoop },
100
+ });
101
+ const reflectionOutput = formatReflection(reflection);
102
+ if (reflectionOutput) {
103
+ console.log(reflectionOutput);
104
+ console.log("");
105
+ }
106
+ }
107
+ if (results.length > 1 && passed > 0 && !config.dryRun) {
108
+ console.log(colors.muted(" Tip: Verify batch integration before merging:"));
109
+ console.log(colors.muted(" sequant merge --check"));
110
+ console.log("");
111
+ }
112
+ if (config.dryRun) {
113
+ console.log(colors.warning(" ℹ️ This was a dry run. Use without --dry-run to execute."));
114
+ console.log("");
115
+ }
116
+ }
@@ -5,10 +5,9 @@ import { formatElapsedTime } from "../lib/phase-spinner.js";
5
5
  import { getSettings } from "../lib/settings.js";
6
6
  import { checkVersionCached, getVersionWarning } from "../lib/version-check.js";
7
7
  import { ui, colors } from "../lib/cli-ui.js";
8
- import { formatDuration } from "../lib/workflow/phase-executor.js";
9
8
  import { parseBatches } from "../lib/workflow/batch-executor.js";
10
9
  import { RunOrchestrator } from "../lib/workflow/run-orchestrator.js";
11
- import { analyzeRun, formatReflection } from "../lib/workflow/run-reflect.js";
10
+ import { displayConfig, displaySummary } from "./run-display.js";
12
11
  // Re-export public API for backwards compatibility
13
12
  export * from "./run-compat.js";
14
13
  /** Parse CLI args → validate → delegate to RunOrchestrator.run() → display summary. */
@@ -51,7 +50,16 @@ export async function runCommand(issues, options) {
51
50
  batches = parseBatches(options.batch);
52
51
  console.log(chalk.gray(` Batch mode: ${batches.map((b) => `[${b.join(", ")}]`).join(" → ")}`));
53
52
  }
54
- console.log(chalk.gray(` ${"Stack".padEnd(15)}${manifest.stack}`));
53
+ const init = {
54
+ options,
55
+ settings,
56
+ manifest: {
57
+ stack: manifest.stack,
58
+ packageManager: manifest.packageManager ?? "npm",
59
+ },
60
+ };
61
+ const resolved = RunOrchestrator.resolveConfig(init, issues, batches);
62
+ displayConfig(resolved);
55
63
  const onProgress = !options.quiet
56
64
  ? (issue, phase, event, extra) => {
57
65
  if (event === "start")
@@ -66,68 +74,8 @@ export async function runCommand(issues, options) {
66
74
  console.log(` ${colors.error("✖")} #${issue} ${phase}`);
67
75
  }
68
76
  : undefined;
69
- const result = await RunOrchestrator.run({
70
- options,
71
- settings,
72
- manifest: {
73
- stack: manifest.stack,
74
- packageManager: manifest.packageManager ?? "npm",
75
- },
76
- onProgress,
77
- }, issues, batches);
77
+ const result = await RunOrchestrator.run({ ...init, onProgress }, issues, batches);
78
78
  displaySummary(result);
79
79
  if (result.exitCode !== 0)
80
80
  process.exit(result.exitCode);
81
81
  }
82
- function displaySummary(result) {
83
- const { results, logPath, config, mergedOptions } = result;
84
- if (results.length === 0)
85
- return;
86
- const passed = results.filter((r) => r.success).length;
87
- const failed = results.filter((r) => !r.success).length;
88
- console.log("\n" + ui.divider());
89
- console.log(colors.info(" Summary"));
90
- console.log(ui.divider());
91
- console.log(`\n ${colors.success(`${passed} passed`)} ${colors.muted("·")} ${colors.error(`${failed} failed`)}`);
92
- for (const r of results) {
93
- const status = r.success
94
- ? ui.statusIcon("success")
95
- : ui.statusIcon("error");
96
- const duration = r.durationSeconds
97
- ? colors.muted(` (${formatDuration(r.durationSeconds)})`)
98
- : "";
99
- const phases = r.phaseResults
100
- .map((p) => (p.success ? colors.success(p.phase) : colors.error(p.phase)))
101
- .join(" → ");
102
- const loopInfo = r.loopTriggered ? colors.warning(" [loop]") : "";
103
- const prInfo = r.prUrl ? colors.muted(` → PR #${r.prNumber}`) : "";
104
- console.log(` ${status} #${r.issueNumber}: ${phases}${loopInfo}${prInfo}${duration}`);
105
- }
106
- console.log("");
107
- if (logPath) {
108
- console.log(colors.muted(` Log: ${logPath}`));
109
- console.log("");
110
- }
111
- if (mergedOptions.reflect && results.length > 0) {
112
- const reflection = analyzeRun({
113
- results,
114
- issueInfoMap: result.issueInfoMap,
115
- runLog: result.logWriter?.getRunLog() ?? null,
116
- config: { phases: config.phases, qualityLoop: config.qualityLoop },
117
- });
118
- const reflectionOutput = formatReflection(reflection);
119
- if (reflectionOutput) {
120
- console.log(reflectionOutput);
121
- console.log("");
122
- }
123
- }
124
- if (results.length > 1 && passed > 0 && !config.dryRun) {
125
- console.log(colors.muted(" Tip: Verify batch integration before merging:"));
126
- console.log(colors.muted(" sequant merge --check"));
127
- console.log("");
128
- }
129
- if (config.dryRun) {
130
- console.log(colors.warning(" ℹ️ This was a dry run. Use without --dry-run to execute."));
131
- console.log("");
132
- }
133
- }
@@ -10,6 +10,7 @@ import { StateManager } from "../lib/workflow/state-manager.js";
10
10
  import { rebuildStateFromLogs, cleanupStaleEntries, } from "../lib/workflow/state-utils.js";
11
11
  import { reconcileState, getNextActionHint, formatRelativeTime, } from "../lib/workflow/reconcile.js";
12
12
  import { getSettingsWithWarnings } from "../lib/settings.js";
13
+ import { getSkillVersions } from "../lib/skill-version.js";
13
14
  /**
14
15
  * Run reconciliation and display warnings.
15
16
  * Returns the reconcile result for use in display.
@@ -263,13 +264,26 @@ export async function statusCommand(options = {}) {
263
264
  console.log(chalk.yellow(` ${w.message}`));
264
265
  }
265
266
  }
266
- // Count skills
267
+ // Count skills and check versions
267
268
  const skillsDir = ".claude/skills";
268
269
  if (await fileExists(skillsDir)) {
269
270
  try {
270
271
  const skills = await readdir(skillsDir);
271
272
  const skillCount = skills.filter((s) => !s.startsWith(".")).length;
272
273
  console.log(chalk.gray(`Skills: ${skillCount}`));
274
+ // Show skill version info
275
+ const { getTemplatesDir } = await import("../lib/templates.js");
276
+ const { join } = await import("path");
277
+ const templateSkillsDir = join(getTemplatesDir(), "skills");
278
+ const skillVersions = await getSkillVersions(templateSkillsDir);
279
+ const outdated = skillVersions.filter((s) => s.updateAvailable);
280
+ if (outdated.length > 0) {
281
+ console.log(chalk.yellow(`\n Skill updates available (${outdated.length}):`));
282
+ for (const s of outdated) {
283
+ console.log(chalk.yellow(` ${s.name}: v${s.installedVersion} → v${s.templateVersion}`));
284
+ }
285
+ console.log(chalk.gray(" Run `sequant init --upgrade-skills` to update."));
286
+ }
273
287
  }
274
288
  catch {
275
289
  // Ignore errors
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Skill version utilities — reads version from SKILL.md YAML frontmatter
3
+ */
4
+ export interface SkillVersionInfo {
5
+ name: string;
6
+ installedVersion: string | null;
7
+ templateVersion: string | null;
8
+ updateAvailable: boolean;
9
+ }
10
+ /**
11
+ * Parse YAML frontmatter from a SKILL.md file and extract the version field.
12
+ * Returns null if no version is found or file doesn't exist.
13
+ */
14
+ export declare function parseSkillVersion(content: string): string | null;
15
+ /**
16
+ * Get version info for all installed skills, comparing installed (.claude/skills/)
17
+ * with template versions (from the sequant package's templates/skills/).
18
+ */
19
+ export declare function getSkillVersions(templateDir?: string): Promise<SkillVersionInfo[]>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Skill version utilities — reads version from SKILL.md YAML frontmatter
3
+ */
4
+ import { readdir } from "fs/promises";
5
+ import { join } from "path";
6
+ import { parse as parseYaml } from "yaml";
7
+ import { readFile, fileExists } from "./fs.js";
8
+ /**
9
+ * Parse YAML frontmatter from a SKILL.md file and extract the version field.
10
+ * Returns null if no version is found or file doesn't exist.
11
+ */
12
+ export function parseSkillVersion(content) {
13
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
14
+ if (!match)
15
+ return null;
16
+ try {
17
+ const frontmatter = parseYaml(match[1]);
18
+ return frontmatter?.version ?? frontmatter?.metadata?.version ?? null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /**
25
+ * Read version from a single skill's SKILL.md file.
26
+ */
27
+ async function readSkillVersion(skillDir) {
28
+ const skillPath = join(skillDir, "SKILL.md");
29
+ if (!(await fileExists(skillPath)))
30
+ return null;
31
+ const content = await readFile(skillPath);
32
+ return parseSkillVersion(content);
33
+ }
34
+ /**
35
+ * Get version info for all installed skills, comparing installed (.claude/skills/)
36
+ * with template versions (from the sequant package's templates/skills/).
37
+ */
38
+ export async function getSkillVersions(templateDir) {
39
+ const installedDir = ".claude/skills";
40
+ const results = [];
41
+ if (!(await fileExists(installedDir)))
42
+ return results;
43
+ try {
44
+ const entries = await readdir(installedDir, { withFileTypes: true });
45
+ for (const entry of entries) {
46
+ if (!entry.isDirectory() || entry.name.startsWith("."))
47
+ continue;
48
+ const installedVersion = await readSkillVersion(join(installedDir, entry.name));
49
+ let templateVersion = null;
50
+ if (templateDir) {
51
+ templateVersion = await readSkillVersion(join(templateDir, entry.name));
52
+ }
53
+ const updateAvailable = installedVersion !== null &&
54
+ templateVersion !== null &&
55
+ installedVersion !== templateVersion;
56
+ results.push({
57
+ name: entry.name,
58
+ installedVersion,
59
+ templateVersion,
60
+ updateAvailable,
61
+ });
62
+ }
63
+ }
64
+ catch {
65
+ // Ignore errors reading skills directory
66
+ }
67
+ return results.sort((a, b) => a.name.localeCompare(b.name));
68
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Template management - copy and process templates
3
3
  */
4
+ export declare function getTemplatesDir(): string;
4
5
  /**
5
6
  * Process template variables in content
6
7
  */
@@ -11,7 +11,7 @@ import { getStackConfig, getStackNotes, getMultiStackNotes } from "./stacks.js";
11
11
  import { isNativeWindows } from "./system.js";
12
12
  import { getProjectName } from "./project-name.js";
13
13
  // Get the package templates directory
14
- function getTemplatesDir() {
14
+ export function getTemplatesDir() {
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
  // Compiled structure: dist/src/lib/templates.js
17
17
  // So we need ../../../templates to reach project root templates/
@@ -747,7 +747,7 @@ export async function runIssueWithLogging(ctx) {
747
747
  }
748
748
  // Create checkpoint commit in chain mode after QA passes
749
749
  if (success && chainMode && worktreePath) {
750
- createCheckpointCommit(worktreePath, issueNumber, config.verbose);
750
+ createCheckpointCommit(worktreePath, issueNumber, config.verbose, baseBranch);
751
751
  }
752
752
  // Rebase onto the base branch before PR creation (unless --no-rebase)
753
753
  // This ensures the branch is up-to-date and prevents lockfile drift
@@ -11,6 +11,7 @@ import { ShutdownManager } from "../shutdown.js";
11
11
  import { PhaseSpinner } from "../phase-spinner.js";
12
12
  import { Phase, ExecutionConfig, PhaseResult, QaVerdict } from "./types.js";
13
13
  import type { QaSummary } from "./run-log-schema.js";
14
+ import type { AgentPhaseResult } from "./drivers/index.js";
14
15
  /**
15
16
  * Spec-specific retry configuration.
16
17
  * Spec failures have a higher failure rate (~8.6%) than other phases due to
@@ -40,6 +41,36 @@ export declare function parseQaSummary(output: string): QaSummary | null;
40
41
  * Format duration in human-readable format
41
42
  */
42
43
  export declare function formatDuration(seconds: number): string;
44
+ /**
45
+ * Check whether the exec phase produced any changes in the worktree.
46
+ * Returns true if HEAD has commits unique to it relative to origin/main
47
+ * OR uncommitted work is present.
48
+ *
49
+ * Uses `git rev-list --count origin/main..HEAD` (commits reachable from HEAD
50
+ * but not origin/main) instead of `git diff origin/main..HEAD`, because the
51
+ * two-dot diff also fires in reverse when origin/main has advanced past HEAD
52
+ * — on stale branches that would falsely report "has commits" even when the
53
+ * exec phase produced nothing, reintroducing the bug #534 is fixing.
54
+ *
55
+ * Fails open (returns true) on git errors — a missing origin ref is better
56
+ * diagnosed as a real zero-diff run than as a false phase failure.
57
+ *
58
+ * @internal Exported for testing only.
59
+ */
60
+ export declare function hasExecChanges(cwd: string): boolean;
61
+ /**
62
+ * Map a successful AgentPhaseResult to a PhaseResult, applying phase-specific
63
+ * guards that catch agent sessions which returned success without producing
64
+ * usable work (#534):
65
+ *
66
+ * - `qa`: fails when no parseable verdict is found (empty or malformed output).
67
+ * - `exec`: fails when no commits and no uncommitted changes exist.
68
+ *
69
+ * @internal Exported for testing only.
70
+ */
71
+ export declare function mapAgentSuccessToPhaseResult(phase: Phase, agentResult: AgentPhaseResult, durationSeconds: number, cwd: string): PhaseResult & {
72
+ sessionId?: string;
73
+ };
43
74
  /**
44
75
  * Get the prompt for a phase with the issue number substituted.
45
76
  * Selects self-contained prompts for non-Claude agents.