@synmux/claude-commit 0.1.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.
package/src/diff.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Splitting a unified git diff into chunks that fit a character budget.
3
+ *
4
+ * The packer is structure-aware: it prefers to break on file boundaries, then
5
+ * on hunk (`@@`) boundaries, and only falls back to raw line splitting for a
6
+ * single hunk that is itself larger than the budget. When a file section is
7
+ * split, its header (`diff --git ... / --- / +++`) is repeated at the top of
8
+ * every piece so each chunk remains a self-contained, interpretable diff.
9
+ */
10
+
11
+ const FILE_HEADER = "diff --git ";
12
+ const HUNK_HEADER = "@@";
13
+
14
+ /**
15
+ * Minimum per-piece character budget (after the repeated file header) below
16
+ * which we stop trying to subdivide a section. This guards against a
17
+ * misconfigured tiny `maxChars`, or a pathologically large file header,
18
+ * driving the line-splitter's budget to zero and shattering a hunk into
19
+ * one-character pieces — which would otherwise spawn a model request per
20
+ * character.
21
+ */
22
+ const MIN_SPLIT_BUDGET = 64;
23
+
24
+ /** Split a full diff into per-file sections. */
25
+ function splitFileSections(diff: string): string[] {
26
+ const lines = diff.split("\n");
27
+ const sections: string[] = [];
28
+ let current: string[] = [];
29
+ for (const line of lines) {
30
+ if (line.startsWith(FILE_HEADER) && current.length > 0) {
31
+ sections.push(current.join("\n"));
32
+ current = [line];
33
+ } else {
34
+ current.push(line);
35
+ }
36
+ }
37
+ if (current.length > 0) sections.push(current.join("\n"));
38
+ return sections;
39
+ }
40
+
41
+ /** Group the body of a file section (from the first `@@`) into hunks. */
42
+ function groupHunks(bodyLines: string[]): string[] {
43
+ const hunks: string[] = [];
44
+ let current: string[] = [];
45
+ for (const line of bodyLines) {
46
+ if (line.startsWith(HUNK_HEADER) && current.length > 0) {
47
+ hunks.push(current.join("\n"));
48
+ current = [line];
49
+ } else {
50
+ current.push(line);
51
+ }
52
+ }
53
+ if (current.length > 0) hunks.push(current.join("\n"));
54
+ return hunks;
55
+ }
56
+
57
+ /** Break a string into pieces of at most `maxLen` characters, preferring line boundaries. */
58
+ function breakByLines(text: string, maxLen: number): string[] {
59
+ const limit = Math.max(1, maxLen);
60
+ const lines = text.split("\n");
61
+ const pieces: string[] = [];
62
+ let current = "";
63
+ for (const line of lines) {
64
+ const addition = current === "" ? line.length : line.length + 1;
65
+ if (current !== "" && current.length + addition > limit) {
66
+ pieces.push(current);
67
+ current = "";
68
+ }
69
+ if (line.length > limit) {
70
+ // A single line longer than the budget: hard-split it.
71
+ if (current !== "") {
72
+ pieces.push(current);
73
+ current = "";
74
+ }
75
+ for (let i = 0; i < line.length; i += limit) {
76
+ pieces.push(line.slice(i, i + limit));
77
+ }
78
+ } else {
79
+ current = current === "" ? line : current + "\n" + line;
80
+ }
81
+ }
82
+ if (current !== "") pieces.push(current);
83
+ return pieces;
84
+ }
85
+
86
+ /** Break an oversized file section into units that each fit `maxChars`. */
87
+ function breakSection(section: string, maxChars: number): string[] {
88
+ if (section.length <= maxChars) return [section];
89
+
90
+ const lines = section.split("\n");
91
+ const firstHunk = lines.findIndex((l) => l.startsWith(HUNK_HEADER));
92
+ if (firstHunk === -1) {
93
+ // No hunks to split on (binary patch, pure rename, mode change): keep whole.
94
+ return [section];
95
+ }
96
+
97
+ const header = lines.slice(0, firstHunk).join("\n");
98
+ const headerLen = header.length + 1; // account for the joining newline
99
+
100
+ // If there isn't room for a meaningful piece after repeating the header,
101
+ // keep the section whole rather than exploding it into tiny fragments.
102
+ if (maxChars - headerLen < MIN_SPLIT_BUDGET) return [section];
103
+
104
+ const hunks = groupHunks(lines.slice(firstHunk));
105
+ const units: string[] = [];
106
+
107
+ for (const hunk of hunks) {
108
+ if (headerLen + hunk.length <= maxChars) {
109
+ units.push(header + "\n" + hunk);
110
+ } else {
111
+ for (const piece of breakByLines(hunk, maxChars - headerLen)) {
112
+ units.push(header + "\n" + piece);
113
+ }
114
+ }
115
+ }
116
+ return units;
117
+ }
118
+
119
+ /** Greedily pack pre-fitted units into as few chunks as possible. */
120
+ function packUnits(units: string[], maxChars: number): string[] {
121
+ const chunks: string[] = [];
122
+ let current = "";
123
+ for (const unit of units) {
124
+ if (current === "") {
125
+ current = unit;
126
+ continue;
127
+ }
128
+ if (current.length + 1 + unit.length <= maxChars) {
129
+ current = current + "\n" + unit;
130
+ } else {
131
+ chunks.push(current);
132
+ current = unit;
133
+ }
134
+ }
135
+ if (current !== "") chunks.push(current);
136
+ return chunks;
137
+ }
138
+
139
+ /**
140
+ * Split a unified diff into chunks no larger than `maxChars` characters.
141
+ *
142
+ * Returns an empty array for an empty diff, and a single-element array when the
143
+ * whole diff already fits.
144
+ */
145
+ export function splitDiff(diff: string, maxChars: number): string[] {
146
+ if (diff.trim() === "") return [];
147
+ if (diff.length <= maxChars) return [diff];
148
+
149
+ const units: string[] = [];
150
+ for (const section of splitFileSections(diff)) {
151
+ units.push(...breakSection(section, maxChars));
152
+ }
153
+ return packUnits(units, maxChars);
154
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Error type for user-facing, expected failures (printed without a stack trace). */
2
+ export class ClaudeCommitError extends Error {
3
+ override name = "ClaudeCommitError";
4
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The commit-message pipeline:
3
+ *
4
+ * diff ──split──▶ [chunk, chunk, ...] ──summary model──▶ [summary, ...]
5
+ * ──final model──▶ commit message(s)
6
+ *
7
+ * The summary model (default `sonnet[1m]`) reads each diff chunk and writes a
8
+ * factual summary; chunking keeps each request within the model's context
9
+ * window. The final model (default `haiku`) turns the summaries into the commit
10
+ * message(s), applying the configured formatting rules.
11
+ */
12
+ import { runPrompt } from "./agent";
13
+ import { splitDiff } from "./diff";
14
+ import { tokensToChars } from "./tokens";
15
+ import { ClaudeCommitError } from "./errors";
16
+ import {
17
+ buildFinalSystem,
18
+ buildFinalUser,
19
+ buildSummarySystem,
20
+ buildSummaryUser,
21
+ cleanMessage,
22
+ extractMessages,
23
+ MESSAGES_SCHEMA,
24
+ parseOptions,
25
+ } from "./prompts";
26
+ import type { Config } from "./types";
27
+
28
+ export interface GenerateProgress {
29
+ /** Called when a new phase of work begins (for spinner labels). */
30
+ onPhase?: (label: string) => void;
31
+ /** Receives streamed text of the final message as it is produced. */
32
+ onText?: (delta: string) => void;
33
+ }
34
+
35
+ export interface GenerateOptions {
36
+ /** Number of candidate messages to produce (interactive mode uses > 1). */
37
+ count?: number;
38
+ progress?: GenerateProgress;
39
+ abortController?: AbortController;
40
+ }
41
+
42
+ export interface GenerateResult {
43
+ /** Candidate commit messages (length 1 in non-interactive mode). */
44
+ messages: string[];
45
+ /** The intermediate summaries (useful for `--verbose`). */
46
+ summaries: string[];
47
+ /** Number of diff chunks the summary stage processed. */
48
+ chunkCount: number;
49
+ /** Total cost across all model calls, in USD. */
50
+ costUsd: number;
51
+ }
52
+
53
+ /** Run the full pipeline over a staged diff. */
54
+ export async function generateCommit(
55
+ diff: string,
56
+ config: Config,
57
+ options: GenerateOptions = {},
58
+ ): Promise<GenerateResult> {
59
+ const { count = 1, progress = {}, abortController } = options;
60
+
61
+ const maxChars = tokensToChars(config.maxChunkTokens, config.charsPerToken);
62
+ const chunks = splitDiff(diff, maxChars);
63
+ if (chunks.length === 0) {
64
+ throw new ClaudeCommitError("There are no staged changes to summarize.");
65
+ }
66
+
67
+ // Stage 1: summarize each chunk.
68
+ const summarySystem = buildSummarySystem();
69
+ const summaries: string[] = [];
70
+ let costUsd = 0;
71
+
72
+ for (let i = 0; i < chunks.length; i++) {
73
+ progress.onPhase?.(
74
+ chunks.length > 1
75
+ ? `Reading diff (part ${i + 1}/${chunks.length})`
76
+ : "Reading diff",
77
+ );
78
+ const result = await runPrompt(
79
+ buildSummaryUser(chunks[i]!, i, chunks.length),
80
+ {
81
+ model: config.models.summary,
82
+ system: summarySystem,
83
+ allowApiKey: config.allowApiKey,
84
+ ...(abortController ? { abortController } : {}),
85
+ },
86
+ );
87
+ summaries.push(result.text);
88
+ costUsd += result.costUsd;
89
+ }
90
+
91
+ // Stage 2: write the commit message(s) from the summaries.
92
+ //
93
+ // Prefer a structured (JSON-schema) response so parsing is robust regardless
94
+ // of how the model formats its prose. We try, in order: structured output
95
+ // with a temperature bump (for interactive variety), then structured output
96
+ // without it (for models that reject a temperature override), then plain text
97
+ // with delimiter parsing (for models that don't support structured output at
98
+ // all). Whichever succeeds first wins.
99
+ progress.onPhase?.(
100
+ count > 1 ? "Writing commit options" : "Writing commit message",
101
+ );
102
+
103
+ const baseOpts = {
104
+ model: config.models.final,
105
+ allowApiKey: config.allowApiKey,
106
+ ...(abortController ? { abortController } : {}),
107
+ };
108
+ const temperature =
109
+ count > 1 && config.interactiveTemperature != null
110
+ ? config.interactiveTemperature
111
+ : undefined;
112
+
113
+ const attempts: Array<{ structured: boolean; temperature?: number }> = [];
114
+ if (temperature != null) attempts.push({ structured: true, temperature });
115
+ attempts.push({ structured: true });
116
+ attempts.push({ structured: false });
117
+
118
+ let messages: string[] | null = null;
119
+ let lastError: unknown;
120
+ for (const attempt of attempts) {
121
+ try {
122
+ const result = await runPrompt(
123
+ buildFinalUser(summaries, count, attempt.structured),
124
+ {
125
+ ...baseOpts,
126
+ system: buildFinalSystem(config, attempt.structured),
127
+ ...(attempt.structured
128
+ ? {
129
+ outputFormat: {
130
+ type: "json_schema" as const,
131
+ schema: MESSAGES_SCHEMA,
132
+ },
133
+ }
134
+ : {}),
135
+ ...(attempt.temperature != null
136
+ ? { temperature: attempt.temperature }
137
+ : {}),
138
+ ...(!attempt.structured && progress.onText
139
+ ? { onText: progress.onText }
140
+ : {}),
141
+ },
142
+ );
143
+ costUsd += result.costUsd;
144
+ messages = attempt.structured
145
+ ? extractMessages(result.structured)
146
+ : count > 1
147
+ ? parseOptions(result.text)
148
+ : [result.text];
149
+ if (messages && messages.length > 0) break;
150
+ } catch (err) {
151
+ lastError = err;
152
+ // If the run was cancelled, stop retrying: the shared abort signal would
153
+ // make every remaining attempt fail immediately in the same way.
154
+ if (abortController?.signal.aborted) break;
155
+ }
156
+ }
157
+
158
+ const cleaned = (messages ?? [])
159
+ .map(cleanMessage)
160
+ .filter((m) => m.length > 0);
161
+ const deduped = dedupe(cleaned);
162
+ if (deduped.length === 0) {
163
+ if (lastError instanceof ClaudeCommitError) throw lastError;
164
+ throw new ClaudeCommitError("The model did not produce a commit message.");
165
+ }
166
+
167
+ return { messages: deduped, summaries, chunkCount: chunks.length, costUsd };
168
+ }
169
+
170
+ function dedupe(items: string[]): string[] {
171
+ const seen = new Set<string>();
172
+ const out: string[] = [];
173
+ for (const item of items) {
174
+ if (!seen.has(item)) {
175
+ seen.add(item);
176
+ out.push(item);
177
+ }
178
+ }
179
+ return out;
180
+ }
package/src/git.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Git operations, implemented with Bun's shell (`Bun.$`).
3
+ */
4
+ import { $ } from "bun";
5
+ import { ClaudeCommitError } from "./errors";
6
+ import type { FileChange } from "./types";
7
+
8
+ /**
9
+ * A git command failed. Subclasses {@link ClaudeCommitError} so the CLI prints
10
+ * it as a clean, user-facing error rather than a stack trace.
11
+ */
12
+ export class GitError extends ClaudeCommitError {
13
+ override name = "GitError";
14
+ }
15
+
16
+ /** Run a git command, returning stdout. Throws {@link GitError} on failure. */
17
+ async function git(args: string[]): Promise<string> {
18
+ let res;
19
+ try {
20
+ res = await $`git ${args}`.quiet().nothrow();
21
+ } catch (err) {
22
+ // Should not happen with `.nothrow()`, but never let a raw shell error leak.
23
+ throw new GitError(`Could not run git: ${(err as Error).message}`);
24
+ }
25
+ if (res.exitCode !== 0) {
26
+ const stderr = res.stderr.toString().trim();
27
+ throw new GitError(
28
+ stderr || `git ${args.join(" ")} exited with code ${res.exitCode}`,
29
+ );
30
+ }
31
+ return res.stdout.toString();
32
+ }
33
+
34
+ /** True if the current working directory is inside a git work tree. */
35
+ export async function isGitRepo(): Promise<boolean> {
36
+ try {
37
+ const res = await $`git rev-parse --is-inside-work-tree`.quiet().nothrow();
38
+ return res.exitCode === 0 && res.stdout.toString().trim() === "true";
39
+ } catch {
40
+ // git missing or unrunnable — treat as "not a usable repo".
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** Absolute path to the repository root. */
46
+ export async function getRepoRoot(): Promise<string> {
47
+ return (await git(["rev-parse", "--show-toplevel"])).trim();
48
+ }
49
+
50
+ /** The unified diff of staged changes (`git diff --cached`). */
51
+ export async function getStagedDiff(): Promise<string> {
52
+ return git(["diff", "--cached", "--no-color"]);
53
+ }
54
+
55
+ /** Parsed list of staged files with their status codes. */
56
+ export async function getStagedFiles(): Promise<FileChange[]> {
57
+ const out = await git(["diff", "--cached", "--name-status"]);
58
+ return out
59
+ .split("\n")
60
+ .map((line) => line.trim())
61
+ .filter(Boolean)
62
+ .map((line) => {
63
+ const parts = line.split("\t");
64
+ const status = parts[0] ?? "";
65
+ // For renames/copies (`R100\told\tnew`) the destination is the last field.
66
+ const path = parts[parts.length - 1] ?? "";
67
+ return { status, path };
68
+ });
69
+ }
70
+
71
+ /** Stage every change in the work tree (`git add -A`). */
72
+ export async function stageAll(): Promise<void> {
73
+ await git(["add", "-A"]);
74
+ }
75
+
76
+ /** A short one-line stat summary of staged changes (for display). */
77
+ export async function getStagedStat(): Promise<string> {
78
+ return (await git(["diff", "--cached", "--stat", "--no-color"])).trimEnd();
79
+ }
80
+
81
+ /**
82
+ * Create a commit with the given message. The message is piped to
83
+ * `git commit -F -` over stdin, so arbitrary content (leading dashes, multiple
84
+ * lines, special characters) is handled safely — and nothing touches disk, so
85
+ * there is no temp file to be raced or read by another user.
86
+ */
87
+ export async function commit(message: string): Promise<void> {
88
+ let proc;
89
+ try {
90
+ // `Bun.spawn` throws synchronously if `git` isn't on PATH.
91
+ proc = Bun.spawn(["git", "commit", "-F", "-"], {
92
+ stdin: new TextEncoder().encode(message),
93
+ // We surface our own confirmation, so discard git's stdout summary rather
94
+ // than leaving an unread pipe that could (in theory) fill and block.
95
+ stdout: "ignore",
96
+ stderr: "pipe",
97
+ });
98
+ } catch (err) {
99
+ throw new GitError(`Could not run git: ${(err as Error).message}`);
100
+ }
101
+ const exitCode = await proc.exited;
102
+ if (exitCode !== 0) {
103
+ const stderr = (await new Response(proc.stderr).text()).trim();
104
+ throw new GitError(stderr || `git commit exited with code ${exitCode}`);
105
+ }
106
+ }
107
+
108
+ /** The current branch name (or `HEAD` when detached). */
109
+ export async function getCurrentBranch(): Promise<string> {
110
+ return (await git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
111
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Prompt construction for the two-stage pipeline.
3
+ *
4
+ * Stage 1 (summary model): read a diff chunk and describe the change factually.
5
+ * Stage 2 (final model): turn the summaries into a commit message that obeys the
6
+ * configured formatting rules (conventional commits, gitmoji, template, body).
7
+ */
8
+ import type { Config } from "./types";
9
+
10
+ /** Sentinel separating candidate messages in interactive mode. */
11
+ export const OPTION_DELIMITER = "===OPTION===";
12
+
13
+ /** A compact gitmoji cheat-sheet to steer the model toward sensible choices. */
14
+ const GITMOJI_GUIDE = [
15
+ "✨ new feature",
16
+ "🐛 bug fix",
17
+ "📝 documentation",
18
+ "♻️ refactor",
19
+ "⚡️ performance",
20
+ "✅ tests",
21
+ "🔧 configuration / tooling",
22
+ "🎨 structure / formatting",
23
+ "🚚 move / rename",
24
+ "🔥 remove code or files",
25
+ "⬆️ upgrade dependencies",
26
+ "👷 CI build system",
27
+ "🚑️ critical hotfix",
28
+ "🔒️ security",
29
+ ].join(", ");
30
+
31
+ const CONVENTIONAL_TYPES =
32
+ "feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert";
33
+
34
+ /** System prompt for the diff-summarization stage. */
35
+ export function buildSummarySystem(): string {
36
+ return [
37
+ "You are an expert software engineer analyzing a git diff in preparation for writing a commit message.",
38
+ "Summarize the change factually and concisely: which files changed, what was added, removed or modified, and the apparent intent and impact of the change.",
39
+ "Focus on the substance of the change, not a line-by-line readout.",
40
+ "Do not write a commit message. Do not include code fences or the raw diff.",
41
+ "If you are told this is one part of a larger change, summarize only the part you are given.",
42
+ ].join(" ");
43
+ }
44
+
45
+ /** User prompt for a single diff chunk in the summarization stage. */
46
+ export function buildSummaryUser(
47
+ chunk: string,
48
+ index: number,
49
+ total: number,
50
+ ): string {
51
+ const preamble =
52
+ total > 1
53
+ ? `This is part ${index + 1} of ${total} of a larger diff. Summarize only this part:`
54
+ : "Summarize the following diff:";
55
+ return `${preamble}\n\n${chunk}`;
56
+ }
57
+
58
+ /**
59
+ * JSON schema for the final stage's structured output: a list of candidate
60
+ * commit messages. Requesting this makes parsing robust regardless of how the
61
+ * model chooses to format its prose.
62
+ */
63
+ export const MESSAGES_SCHEMA: Record<string, unknown> = {
64
+ type: "object",
65
+ properties: {
66
+ messages: {
67
+ type: "array",
68
+ description:
69
+ "The commit message(s), each a complete raw commit message string.",
70
+ items: { type: "string" },
71
+ },
72
+ },
73
+ required: ["messages"],
74
+ additionalProperties: false,
75
+ };
76
+
77
+ /** Pull the message list out of a structured-output object, or return null if malformed. */
78
+ export function extractMessages(structured: unknown): string[] | null {
79
+ if (
80
+ structured &&
81
+ typeof structured === "object" &&
82
+ Array.isArray((structured as { messages?: unknown }).messages)
83
+ ) {
84
+ const messages = (structured as { messages: unknown[] }).messages.filter(
85
+ (m): m is string => typeof m === "string",
86
+ );
87
+ if (messages.length > 0) return messages;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * System prompt for the final commit-message stage, encoding all formatting
94
+ * rules. When `structured` is true, the model returns its messages as JSON, so
95
+ * the "no markdown" guidance is scoped to each message's own text.
96
+ */
97
+ export function buildFinalSystem(config: Config, structured = false): string {
98
+ const rules: string[] = [
99
+ "You are an expert at writing clear, high-quality git commit messages.",
100
+ "You are given a summary of staged changes and must produce a commit message for them.",
101
+ ];
102
+
103
+ // Subject-line style.
104
+ if (config.conventionalCommits) {
105
+ rules.push(
106
+ `Format the subject line as a Conventional Commit: "type(scope): description". ` +
107
+ `Choose the most appropriate type from: ${CONVENTIONAL_TYPES}. ` +
108
+ `The scope is optional and should be a short noun for the affected area. ` +
109
+ `The description is in the imperative mood, lower case, with no trailing period.`,
110
+ );
111
+ } else {
112
+ rules.push(
113
+ 'Write the subject line in the imperative mood (e.g. "Add", not "Added" or "Adds"), ' +
114
+ "capitalized, concise (aim for 50 characters, 72 at most), with no trailing period.",
115
+ );
116
+ }
117
+
118
+ if (config.gitmoji) {
119
+ rules.push(
120
+ `Begin the subject line with a single appropriate gitmoji, followed by a space. ` +
121
+ `Pick from: ${GITMOJI_GUIDE}.` +
122
+ (config.conventionalCommits
123
+ ? ' Place the gitmoji before the conventional-commit type, e.g. "✨ feat: ...".'
124
+ : ""),
125
+ );
126
+ }
127
+
128
+ if (config.template) {
129
+ rules.push(
130
+ `The subject line MUST follow this exact template, substituting {message} with the commit description ` +
131
+ `(after applying the rules above to that description): "${config.template}".`,
132
+ );
133
+ }
134
+
135
+ if (config.multiline) {
136
+ rules.push(
137
+ "After the subject line, add one blank line and then a body that explains what changed and why. " +
138
+ 'Use concise bullet points ("- ...") when there are several distinct changes. Wrap body lines at about 72 characters.',
139
+ );
140
+ } else {
141
+ rules.push("Output only the single subject line. Do not include a body.");
142
+ }
143
+
144
+ if (config.customPrompt) {
145
+ rules.push(`Additional instructions from the user: ${config.customPrompt}`);
146
+ }
147
+
148
+ rules.push(
149
+ structured
150
+ ? "Each commit message must be the raw message text only — no surrounding quotes, no markdown, and no code fences."
151
+ : "Output ONLY the commit message itself: no surrounding quotes, no markdown, no code fences, no preamble, and no explanation.",
152
+ );
153
+
154
+ return rules.join("\n");
155
+ }
156
+
157
+ /**
158
+ * The shared instruction for requesting several distinct options.
159
+ *
160
+ * It insists each option be a COMPLETE message obeying the formatting rules —
161
+ * crucially the body when `multiline` is on. The previous wording asked the
162
+ * model to "vary the structure" of the options, which let it drop bodies to
163
+ * manufacture variety, so `multiline` appeared to be ignored in interactive
164
+ * mode even though the system prompt still required a body.
165
+ */
166
+ function multiOptionInstruction(count: number): string {
167
+ return (
168
+ `Produce exactly ${count} distinct commit-message options for this change. ` +
169
+ `Each option must be a complete commit message that independently obeys all the formatting rules above — ` +
170
+ `including the blank line and body when those rules ask for one. ` +
171
+ `Make the options genuinely different in wording and emphasis, but never drop the subject or a required body just to create variety.`
172
+ );
173
+ }
174
+
175
+ /**
176
+ * User prompt for the final stage.
177
+ *
178
+ * In `structured` mode the candidates are returned via {@link MESSAGES_SCHEMA}'s
179
+ * `messages` array. Otherwise, when `count` > 1, they are separated by
180
+ * {@link OPTION_DELIMITER} for text parsing.
181
+ */
182
+ export function buildFinalUser(
183
+ summaries: string[],
184
+ count = 1,
185
+ structured = false,
186
+ ): string {
187
+ const joined =
188
+ summaries.length === 1
189
+ ? summaries[0]!
190
+ : summaries.map((s, i) => `Part ${i + 1}:\n${s}`).join("\n\n");
191
+
192
+ const header =
193
+ summaries.length === 1
194
+ ? "Here is the summary of the staged changes:"
195
+ : "Here are summaries of the parts of the staged changes:";
196
+
197
+ if (structured) {
198
+ const ask =
199
+ count <= 1
200
+ ? `Produce a single commit message for this change and return it as the only element of the "messages" array.`
201
+ : `${multiOptionInstruction(count)} Return them in the "messages" array.`;
202
+ return `${header}\n\n${joined}\n\n${ask}`;
203
+ }
204
+
205
+ if (count <= 1) {
206
+ return `${header}\n\n${joined}`;
207
+ }
208
+
209
+ return (
210
+ `${header}\n\n${joined}\n\n${multiOptionInstruction(count)} ` +
211
+ `Output each option on its own, preceded by a line containing exactly "${OPTION_DELIMITER}" and nothing else. ` +
212
+ `Do not number the options or add any other text.`
213
+ );
214
+ }
215
+
216
+ /** Parse the multi-option response from the final stage into individual messages. */
217
+ export function parseOptions(text: string): string[] {
218
+ return text
219
+ .split(OPTION_DELIMITER)
220
+ .map((part) => part.trim())
221
+ .filter((part) => part.length > 0);
222
+ }
223
+
224
+ /** Strip stray formatting a model may add despite instructions (fences, wrapping quotes). */
225
+ export function cleanMessage(text: string): string {
226
+ let msg = text.trim();
227
+
228
+ // Remove a single wrapping fenced code block.
229
+ const fence = msg.match(/^```[^\n]*\n([\s\S]*?)\n?```$/);
230
+ if (fence) msg = fence[1]!.trim();
231
+
232
+ // Remove matching wrapping quotes only if the whole message is quoted.
233
+ if (msg.length >= 2) {
234
+ const first = msg[0];
235
+ const last = msg[msg.length - 1];
236
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
237
+ const inner = msg.slice(1, -1);
238
+ if (!inner.includes(first)) msg = inner.trim();
239
+ }
240
+ }
241
+
242
+ return msg;
243
+ }