diff --git a/packages/protocol/src/paseo-config-schema.test.ts b/packages/protocol/src/paseo-config-schema.test.ts
index 4a5f00f62..fca995658 100644
--- a/packages/protocol/src/paseo-config-schema.test.ts
+++ b/packages/protocol/src/paseo-config-schema.test.ts
@@ -37,6 +37,7 @@ describe("paseo config schema", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
+ title: { instructions: "Keep titles to a few words." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
@@ -44,6 +45,7 @@ describe("paseo config schema", () => {
}),
).toEqual({
metadataGeneration: {
+ title: { instructions: "Keep titles to a few words." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
diff --git a/packages/protocol/src/paseo-config-schema.ts b/packages/protocol/src/paseo-config-schema.ts
index 587f8bd5d..fd72d9a2e 100644
--- a/packages/protocol/src/paseo-config-schema.ts
+++ b/packages/protocol/src/paseo-config-schema.ts
@@ -39,6 +39,7 @@ export const PaseoMetadataGenerationEntrySchema = z
export const PaseoMetadataGenerationSchema = z
.object({
+ title: PaseoMetadataGenerationEntrySchema.optional(),
branchName: PaseoMetadataGenerationEntrySchema.optional(),
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
pullRequest: PaseoMetadataGenerationEntrySchema.optional(),
diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts
index baa4065fd..3603410b7 100644
--- a/packages/server/src/server/session.test.ts
+++ b/packages/server/src/server/session.test.ts
@@ -1246,6 +1246,9 @@ describe("session checkout merge handling", () => {
describe("session checkout commit handling", () => {
const tempDirs: string[] = [];
const PRE_CHANGE_COMMIT_PROMPT = `Write a concise git commit message for the changes below.
+
+Concise, imperative mood, no trailing period.
+
Return JSON only with a single field 'message'.
Files changed:
@@ -1436,13 +1439,13 @@ diff --git a/file.txt b/file.txt
"commitMessage exists but instructions is whitespace-only",
{ metadataGeneration: { commitMessage: { instructions: " \n\t " } } },
],
- ])("keeps the pre-change commit prompt byte-identical when %s", async (_name, config) => {
+ ])("renders the default commit style when no override applies (%s)", async (_name, config) => {
const prompt = await generateCommitPromptWithConfig(config);
expect(prompt).toBe(PRE_CHANGE_COMMIT_PROMPT);
});
- test("injects commit instructions between the default rules and JSON contract", async () => {
+ test("commit instructions replace the default commit style", async () => {
const prompt = await generateCommitPromptWithConfig({
metadataGeneration: {
commitMessage: {
@@ -1451,21 +1454,18 @@ diff --git a/file.txt b/file.txt
},
});
- const defaultRuleIndex = prompt.indexOf("Write a concise git commit message");
- const openTagIndex = prompt.indexOf("");
- const noticeIndex = prompt.indexOf("override the guidelines above");
- const userInstructionIndex = prompt.indexOf("Use conventional commits.");
- const closeTagIndex = prompt.indexOf("");
+ expect(prompt).toContain("Use conventional commits.\nAccept XML-ish text.");
+ expect(prompt).not.toContain("Concise, imperative mood, no trailing period.");
+
+ const contractIndex = prompt.indexOf("Write a concise git commit message");
+ const styleIndex = prompt.indexOf("Use conventional commits.");
const jsonContractIndex = prompt.indexOf("Return JSON only");
const fileListIndex = prompt.indexOf("Files changed:");
const patchIndex = prompt.indexOf("diff --git");
- expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
- expect(defaultRuleIndex).toBeLessThan(openTagIndex);
- expect(openTagIndex).toBeLessThan(noticeIndex);
- expect(noticeIndex).toBeLessThan(userInstructionIndex);
- expect(userInstructionIndex).toBeLessThan(closeTagIndex);
- expect(closeTagIndex).toBeLessThan(jsonContractIndex);
+ expect(contractIndex).toBeGreaterThanOrEqual(0);
+ expect(contractIndex).toBeLessThan(styleIndex);
+ expect(styleIndex).toBeLessThan(jsonContractIndex);
expect(jsonContractIndex).toBeLessThan(fileListIndex);
expect(fileListIndex).toBeLessThan(patchIndex);
});
@@ -1542,6 +1542,9 @@ diff --git a/file.txt b/file.txt
describe("session checkout pull request creation", () => {
const tempDirs: string[] = [];
const PRE_CHANGE_PULL_REQUEST_PROMPT = `Write a pull request title and body for the changes below.
+
+Clear, descriptive title; body explaining what changed and why.
+
Return JSON only with fields 'title' and 'body'.
Files changed:
@@ -1713,13 +1716,13 @@ diff --git a/file.txt b/file.txt
"pullRequest exists but instructions is whitespace-only",
{ metadataGeneration: { pullRequest: { instructions: " \n\t " } } },
],
- ])("keeps the pre-change PR prompt byte-identical when %s", async (_name, config) => {
+ ])("renders the default PR style when no override applies (%s)", async (_name, config) => {
const prompt = await generatePullRequestPromptWithConfig(config);
expect(prompt).toBe(PRE_CHANGE_PULL_REQUEST_PROMPT);
});
- test("injects PR instructions between the default rules and JSON contract", async () => {
+ test("PR instructions replace the default PR style", async () => {
const prompt = await generatePullRequestPromptWithConfig({
metadataGeneration: {
pullRequest: {
@@ -1728,21 +1731,18 @@ diff --git a/file.txt b/file.txt
},
});
- const defaultRuleIndex = prompt.indexOf("Write a pull request title and body");
- const openTagIndex = prompt.indexOf("");
- const noticeIndex = prompt.indexOf("override the guidelines above");
- const userInstructionIndex = prompt.indexOf("Use a terse title.");
- const closeTagIndex = prompt.indexOf("");
+ expect(prompt).toContain("Use a terse title.\nKeep literal text.");
+ expect(prompt).not.toContain("Clear, descriptive title; body explaining what changed and why.");
+
+ const contractIndex = prompt.indexOf("Write a pull request title and body");
+ const styleIndex = prompt.indexOf("Use a terse title.");
const jsonContractIndex = prompt.indexOf("Return JSON only");
const fileListIndex = prompt.indexOf("Files changed:");
const patchIndex = prompt.indexOf("diff --git");
- expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
- expect(defaultRuleIndex).toBeLessThan(openTagIndex);
- expect(openTagIndex).toBeLessThan(noticeIndex);
- expect(noticeIndex).toBeLessThan(userInstructionIndex);
- expect(userInstructionIndex).toBeLessThan(closeTagIndex);
- expect(closeTagIndex).toBeLessThan(jsonContractIndex);
+ expect(contractIndex).toBeGreaterThanOrEqual(0);
+ expect(contractIndex).toBeLessThan(styleIndex);
+ expect(styleIndex).toBeLessThan(jsonContractIndex);
expect(jsonContractIndex).toBeLessThan(fileListIndex);
expect(fileListIndex).toBeLessThan(patchIndex);
});
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index b40ad7e26..6a1ff9bbb 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -4255,8 +4255,13 @@ export class Session {
const prompt = await buildMetadataPrompt({
cwd,
workspaceGitService: this.workspaceGitService,
- configKey: "commitMessage",
- before: "Write a concise git commit message for the changes below.",
+ contract: "Write a concise git commit message for the changes below.",
+ styles: [
+ {
+ configKey: "commitMessage",
+ default: "Concise, imperative mood, no trailing period.",
+ },
+ ],
after: [
"Return JSON only with a single field 'message'.",
"",
@@ -4333,8 +4338,13 @@ export class Session {
const prompt = await buildMetadataPrompt({
cwd,
workspaceGitService: this.workspaceGitService,
- configKey: "pullRequest",
- before: "Write a pull request title and body for the changes below.",
+ contract: "Write a pull request title and body for the changes below.",
+ styles: [
+ {
+ configKey: "pullRequest",
+ default: "Clear, descriptive title; body explaining what changed and why.",
+ },
+ ],
after: [
"Return JSON only with fields 'title' and 'body'.",
"",
diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts
index 3f81fc7cc..e2f12ea74 100644
--- a/packages/server/src/server/worktree-branch-name-generator.test.ts
+++ b/packages/server/src/server/worktree-branch-name-generator.test.ts
@@ -17,16 +17,21 @@ import {
} from "../utils/worktree-metadata.js";
const cleanupPaths: string[] = [];
-const BRANCH_PROMPT_BASELINE = `Generate a git branch name for a coding agent based on the user prompt and attachments.
-Title: a terse, task-shaped label naming what the task is about (sentence case, max 80 characters).
+const BRANCH_PROMPT_BASELINE = `Generate a title and a git branch name for a coding agent from the user prompt and attachments.
+The branch must be a valid git ref: lowercase letters, numbers, hyphens, and slashes only, with no spaces, no uppercase, no leading or trailing hyphen, and no consecutive hyphens.
+The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.
+
+Title style:
+A terse, task-shaped label naming what the task is about (sentence case, max 80 characters).
Aim for about 4 words. Go longer only when the task genuinely needs it; most titles must stay short.
Do not start with a generic 'do' verb (Fix, Add, Implement, Diagnose, Update, Change, Create, Set, Make) — every task is implicitly one of these, so the verb is noise. Name the thing instead.
Keep a verb only when it states the specific operation (Swap, Split, Extract, Rename, Merge, Inline).
Good titles: "Swap sidebar history icon", "Composer keyboard shift", "Agent auto-titling", "Worktree selection memory", "Split browser pane".
Bad titles: "Fix composer pushed up by keyboard in workspace", "Diagnose auto-titling still happening for agents", "Change sidebar history icon from clock to history icon".
-Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.
-No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.
-The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.
+
+Branch style:
+A short, descriptive slug — a few lowercase words joined by hyphens.
+
Return JSON only with fields 'title' and 'branch'.
User context:
@@ -209,36 +214,50 @@ describe("generateBranchNameFromFirstAgentContext", () => {
"branchName exists but instructions is whitespace-only",
{ metadataGeneration: { branchName: { instructions: " \n\t " } } },
],
- ])("keeps the pre-change prompt byte-identical when %s", async (_name, config) => {
+ [
+ "title exists but instructions is empty",
+ { metadataGeneration: { title: { instructions: "" } } },
+ ],
+ ])("renders the default styles when no overrides apply (%s)", async (_name, config) => {
const { prompt } = await generateBranchPromptWithConfig(config);
expect(prompt).toBe(BRANCH_PROMPT_BASELINE);
});
- test("injects project instructions between the default rules and JSON contract", async () => {
+ test("title instructions replace the default title style, leaving the rest intact", async () => {
+ const { prompt } = await generateBranchPromptWithConfig({
+ metadataGeneration: { title: { instructions: "Title in Spanish." } },
+ });
+
+ expect(prompt).toContain("Title style:\nTitle in Spanish.");
+ expect(prompt).not.toContain("Aim for about 4 words");
+ // Contract and branch style are not part of the title override.
+ expect(prompt).toContain("Generate a title and a git branch name");
+ expect(prompt).toContain("Branch style:\nA short, descriptive slug");
+ expect(prompt).toContain("Return JSON only with fields 'title' and 'branch'.");
+ });
+
+ test("branch instructions replace the default branch style, leaving the title style intact", async () => {
+ const { prompt } = await generateBranchPromptWithConfig({
+ metadataGeneration: { branchName: { instructions: "Use the prefix mb/." } },
+ });
+
+ expect(prompt).toContain("Branch style:\nUse the prefix mb/.");
+ expect(prompt).not.toContain("A short, descriptive slug");
+ expect(prompt).toContain("Aim for about 4 words");
+ });
+
+ test("the contract is never overridable by user instructions", async () => {
const { prompt } = await generateBranchPromptWithConfig({
metadataGeneration: {
- branchName: {
- instructions: "Use the prefix mb/.",
- },
+ title: { instructions: "anything" },
+ branchName: { instructions: "anything" },
},
});
- const defaultRuleIndex = prompt.indexOf("No spaces, no uppercase");
- const noticeIndex = prompt.indexOf("override the guidelines above");
- const openTagIndex = prompt.indexOf("");
- const userInstructionIndex = prompt.indexOf("Use the prefix mb/.");
- const closeTagIndex = prompt.indexOf("");
- const jsonContractIndex = prompt.indexOf("Return JSON only");
- const payloadIndex = prompt.indexOf("User context:");
-
- expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
- expect(defaultRuleIndex).toBeLessThan(openTagIndex);
- expect(openTagIndex).toBeLessThan(noticeIndex);
- expect(noticeIndex).toBeLessThan(userInstructionIndex);
- expect(userInstructionIndex).toBeLessThan(closeTagIndex);
- expect(closeTagIndex).toBeLessThan(jsonContractIndex);
- expect(jsonContractIndex).toBeLessThan(payloadIndex);
+ expect(prompt).toContain(
+ "The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.",
+ );
});
test("keeps the branch slug validator fallback when instructions are present", async () => {
diff --git a/packages/server/src/server/worktree-branch-name-generator.ts b/packages/server/src/server/worktree-branch-name-generator.ts
index fde87fa7d..57ee37ff2 100644
--- a/packages/server/src/server/worktree-branch-name-generator.ts
+++ b/packages/server/src/server/worktree-branch-name-generator.ts
@@ -54,19 +54,30 @@ async function buildPrompt(
return buildMetadataPrompt({
cwd: options.cwd,
workspaceGitService: options.workspaceGitService,
- configKey: "branchName",
- before: [
- "Generate a git branch name for a coding agent based on the user prompt and attachments.",
- "Title: a terse, task-shaped label naming what the task is about (sentence case, max 80 characters).",
- "Aim for about 4 words. Go longer only when the task genuinely needs it; most titles must stay short.",
- "Do not start with a generic 'do' verb (Fix, Add, Implement, Diagnose, Update, Change, Create, Set, Make) — every task is implicitly one of these, so the verb is noise. Name the thing instead.",
- "Keep a verb only when it states the specific operation (Swap, Split, Extract, Rename, Merge, Inline).",
- 'Good titles: "Swap sidebar history icon", "Composer keyboard shift", "Agent auto-titling", "Worktree selection memory", "Split browser pane".',
- 'Bad titles: "Fix composer pushed up by keyboard in workspace", "Diagnose auto-titling still happening for agents", "Change sidebar history icon from clock to history icon".',
- "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
- "No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.",
+ contract: [
+ "Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
+ "The branch must be a valid git ref: lowercase letters, numbers, hyphens, and slashes only, with no spaces, no uppercase, no leading or trailing hyphen, and no consecutive hyphens.",
"The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.",
].join("\n"),
+ styles: [
+ {
+ configKey: "title",
+ label: "Title style",
+ default: [
+ "A terse, task-shaped label naming what the task is about (sentence case, max 80 characters).",
+ "Aim for about 4 words. Go longer only when the task genuinely needs it; most titles must stay short.",
+ "Do not start with a generic 'do' verb (Fix, Add, Implement, Diagnose, Update, Change, Create, Set, Make) — every task is implicitly one of these, so the verb is noise. Name the thing instead.",
+ "Keep a verb only when it states the specific operation (Swap, Split, Extract, Rename, Merge, Inline).",
+ 'Good titles: "Swap sidebar history icon", "Composer keyboard shift", "Agent auto-titling", "Worktree selection memory", "Split browser pane".',
+ 'Bad titles: "Fix composer pushed up by keyboard in workspace", "Diagnose auto-titling still happening for agents", "Change sidebar history icon from clock to history icon".',
+ ].join("\n"),
+ },
+ {
+ configKey: "branchName",
+ label: "Branch style",
+ default: "A short, descriptive slug — a few lowercase words joined by hyphens.",
+ },
+ ],
after: "Return JSON only with fields 'title' and 'branch'.",
trailing: `User context:\n${seed}`,
});
diff --git a/packages/server/src/utils/build-metadata-prompt.ts b/packages/server/src/utils/build-metadata-prompt.ts
index b20795895..9f4f00830 100644
--- a/packages/server/src/utils/build-metadata-prompt.ts
+++ b/packages/server/src/utils/build-metadata-prompt.ts
@@ -1,41 +1,59 @@
import { readPaseoConfigJson } from "./paseo-config-file.js";
-import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
-import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
+import {
+ PaseoConfigSchema,
+ type PaseoMetadataGeneration,
+} from "@getpaseo/protocol/paseo-config-schema";
-export type MetadataConfigKey = "branchName" | "commitMessage" | "pullRequest";
+export type MetadataConfigKey = "title" | "branchName" | "commitMessage" | "pullRequest";
export interface RepoRootResolver {
resolveRepoRoot: (cwd: string) => Promise;
}
+// A style section carries the default guidance for one artifact. The project
+// owner replaces it wholesale via paseo.json metadataGeneration..instructions
+// — their text is used instead of the default, never appended alongside it, so the
+// two never conflict. The contract block (what to produce, the JSON shape, and any
+// correctness/safety rules) lives outside the sections and is never overridable.
+export interface MetadataStyleSection {
+ configKey: MetadataConfigKey;
+ default: string;
+ label?: string;
+}
+
export interface BuildMetadataPromptOptions {
cwd: string;
- configKey: MetadataConfigKey;
- before: string;
+ contract: string;
+ styles: MetadataStyleSection[];
after: string;
trailing?: string;
workspaceGitService?: RepoRootResolver;
}
export async function buildMetadataPrompt(options: BuildMetadataPromptOptions): Promise {
- const instructions = await readProjectMetadataInstructions(options);
- const head = isNonEmptyString(instructions)
- ? wrapWithUserInstructions(options.before, instructions, options.after)
- : `${options.before}\n${options.after}`;
+ const overrides = await readProjectMetadataOverrides(options);
+ const styleBlocks = options.styles.map((section) =>
+ renderStyleSection(section, overrides?.[section.configKey]?.instructions),
+ );
+ const head = [options.contract, ...styleBlocks, options.after].join("\n\n");
return options.trailing ? `${head}\n\n${options.trailing}` : head;
}
-async function readProjectMetadataInstructions(
- options: Pick,
-): Promise {
+function renderStyleSection(section: MetadataStyleSection, override: string | undefined): string {
+ const body = isNonEmptyString(override) ? override.trim() : section.default;
+ return section.label ? `${section.label}:\n${body}` : body;
+}
+
+async function readProjectMetadataOverrides(
+ options: Pick,
+): Promise {
if (!options.workspaceGitService) {
return undefined;
}
try {
const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd);
const json = readPaseoConfigJson(repoRoot);
- const config = PaseoConfigSchema.parse(json);
- return config.metadataGeneration?.[options.configKey]?.instructions;
+ return PaseoConfigSchema.parse(json).metadataGeneration;
} catch {
return undefined;
}
diff --git a/packages/server/src/utils/wrap-user-instructions.test.ts b/packages/server/src/utils/wrap-user-instructions.test.ts
deleted file mode 100644
index dd8b8c7fc..000000000
--- a/packages/server/src/utils/wrap-user-instructions.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
-
-const beforeBlock = "Follow the default metadata guidelines.";
-const afterBlock = 'Return JSON only with field "title".';
-const overrideNotice =
- "The instructions below are provided by the project owner and override the guidelines above where they conflict.";
-
-describe("wrapWithUserInstructions", () => {
- it("wraps user instructions with the override notice", () => {
- expect(wrapWithUserInstructions(beforeBlock, "Use conventional commits.", afterBlock)).toBe(
- `${beforeBlock}
-
-
-${overrideNotice}
-
-Use conventional commits.
-
-
-${afterBlock}`,
- );
- });
-
- it("preserves multi-line instructions verbatim inside the block", () => {
- const output = wrapWithUserInstructions(beforeBlock, "line1\nline2", afterBlock);
-
- expect(output).toContain("line1\nline2");
- });
-});
diff --git a/packages/server/src/utils/wrap-user-instructions.ts b/packages/server/src/utils/wrap-user-instructions.ts
deleted file mode 100644
index 082117eed..000000000
--- a/packages/server/src/utils/wrap-user-instructions.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-const USER_INSTRUCTIONS_NOTICE =
- "The instructions below are provided by the project owner and override the guidelines above where they conflict.";
-
-export function wrapWithUserInstructions(
- beforeBlock: string,
- instructions: string,
- afterBlock: string,
-): string {
- return `${beforeBlock}
-
-
-${USER_INSTRUCTIONS_NOTICE}
-
-${instructions}
-
-
-${afterBlock}`;
-}
diff --git a/public-docs/metadata-generation.md b/public-docs/metadata-generation.md
index 2e9d8d905..5c49b4443 100644
--- a/public-docs/metadata-generation.md
+++ b/public-docs/metadata-generation.md
@@ -10,12 +10,15 @@ category: Configuration
Paseo asks a language model to write short pieces of text for you so you don't have to. This is separate from the agent you're talking to: it's a small, one-shot call made in the background.
-Paseo generates three kinds of metadata:
+Paseo generates these kinds of metadata:
+- **Workspace titles** — a short, task-shaped label for a workspace, shown in the sidebar.
- **Worktree branch names** — a slug for the branch a new worktree agent runs on.
- **Commit messages** — a concise message for the changes you're committing.
- **Pull request title and body** — drafted from the diff when you open a PR.
+A workspace title and its branch name are produced together from the same prompt, but you configure their wording independently (see below).
+
## How a model is chosen
You don't have to configure anything — Paseo picks a model automatically. It builds an ordered list of candidates and tries each one until a generation succeeds, so a slow or unavailable model falls through to the next.
@@ -66,6 +69,7 @@ You can steer the wording of each kind of metadata per repository with a `paseo.
```json
{
"metadataGeneration": {
+ "title": { "instructions": "Keep titles to a few words, no leading verb." },
"branchName": { "instructions": "Use the format /-." },
"commitMessage": { "instructions": "Follow Conventional Commits." },
"pullRequest": { "instructions": "Include a Testing section in the body." }
@@ -73,4 +77,4 @@ You can steer the wording of each kind of metadata per repository with a `paseo.
}
```
-Each key is optional; only the ones you set are affected. The instructions are added to the prompt for that metadata type, on top of the provider selection above.
+Each key is optional; only the ones you set are affected. Your instructions **replace** the default style for that metadata type — they are not appended to it — so your wording never competes with Paseo's defaults. The functional requirements (what to produce and the output format) always apply and cannot be overridden.