Decouple metadata prompt contract from overridable style

Metadata prompts (workspace title + branch, commit message, PR) mixed the
functional contract with style guidance in one block, and project-owner
instructions from paseo.json were appended after the defaults with an
"override where they conflict" notice — so custom wording competed with the
built-in style instead of replacing it.

Each prompt is now a non-overridable contract (what to produce, the JSON
shape, correctness/safety rules) plus style slots that paseo.json
instructions replace wholesale. Title and branch style are now separate
keys (metadataGeneration.title vs .branchName); previously branchName
instructions also shaped the title.
This commit is contained in:
Mohamed Boudra
2026-06-17 16:48:24 +07:00
parent 2bc48993a5
commit fc4c2aa367
10 changed files with 147 additions and 129 deletions

View File

@@ -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." },

View File

@@ -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(),

View File

@@ -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("<user-instructions>");
const noticeIndex = prompt.indexOf("override the guidelines above");
const userInstructionIndex = prompt.indexOf("Use conventional commits.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
expect(prompt).toContain("Use conventional commits.\nAccept XML-ish <scope> 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("<user-instructions>");
const noticeIndex = prompt.indexOf("override the guidelines above");
const userInstructionIndex = prompt.indexOf("Use a terse title.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
expect(prompt).toContain("Use a terse title.\nKeep literal <ticket> 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);
});

View File

@@ -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'.",
"",

View File

@@ -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("<user-instructions>");
const userInstructionIndex = prompt.indexOf("Use the prefix mb/.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
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 () => {

View File

@@ -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}`,
});

View File

@@ -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<string>;
}
// A style section carries the default guidance for one artifact. The project
// owner replaces it wholesale via paseo.json metadataGeneration.<configKey>.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<string> {
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<BuildMetadataPromptOptions, "cwd" | "configKey" | "workspaceGitService">,
): Promise<string | undefined> {
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<BuildMetadataPromptOptions, "cwd" | "workspaceGitService">,
): Promise<PaseoMetadataGeneration | undefined> {
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;
}

View File

@@ -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}
<user-instructions>
${overrideNotice}
Use conventional commits.
</user-instructions>
${afterBlock}`,
);
});
it("preserves multi-line instructions verbatim inside the block", () => {
const output = wrapWithUserInstructions(beforeBlock, "line1\nline2", afterBlock);
expect(output).toContain("line1\nline2");
});
});

View File

@@ -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>
${USER_INSTRUCTIONS_NOTICE}
${instructions}
</user-instructions>
${afterBlock}`;
}

View File

@@ -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 <type>/<scope>-<short-desc>." },
"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.