mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -37,6 +37,7 @@ describe("paseo config schema", () => {
|
|||||||
expect(
|
expect(
|
||||||
PaseoConfigSchema.parse({
|
PaseoConfigSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
|
title: { instructions: "Keep titles to a few words." },
|
||||||
branchName: { instructions: "Prefix branches with feat/." },
|
branchName: { instructions: "Prefix branches with feat/." },
|
||||||
commitMessage: { instructions: "Use imperative mood." },
|
commitMessage: { instructions: "Use imperative mood." },
|
||||||
pullRequest: { instructions: "Include risk notes." },
|
pullRequest: { instructions: "Include risk notes." },
|
||||||
@@ -44,6 +45,7 @@ describe("paseo config schema", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
|
title: { instructions: "Keep titles to a few words." },
|
||||||
branchName: { instructions: "Prefix branches with feat/." },
|
branchName: { instructions: "Prefix branches with feat/." },
|
||||||
commitMessage: { instructions: "Use imperative mood." },
|
commitMessage: { instructions: "Use imperative mood." },
|
||||||
pullRequest: { instructions: "Include risk notes." },
|
pullRequest: { instructions: "Include risk notes." },
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const PaseoMetadataGenerationEntrySchema = z
|
|||||||
|
|
||||||
export const PaseoMetadataGenerationSchema = z
|
export const PaseoMetadataGenerationSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
title: PaseoMetadataGenerationEntrySchema.optional(),
|
||||||
branchName: PaseoMetadataGenerationEntrySchema.optional(),
|
branchName: PaseoMetadataGenerationEntrySchema.optional(),
|
||||||
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
|
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
|
||||||
pullRequest: PaseoMetadataGenerationEntrySchema.optional(),
|
pullRequest: PaseoMetadataGenerationEntrySchema.optional(),
|
||||||
|
|||||||
@@ -1246,6 +1246,9 @@ describe("session checkout merge handling", () => {
|
|||||||
describe("session checkout commit handling", () => {
|
describe("session checkout commit handling", () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const PRE_CHANGE_COMMIT_PROMPT = `Write a concise git commit message for the changes below.
|
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'.
|
Return JSON only with a single field 'message'.
|
||||||
|
|
||||||
Files changed:
|
Files changed:
|
||||||
@@ -1436,13 +1439,13 @@ diff --git a/file.txt b/file.txt
|
|||||||
"commitMessage exists but instructions is whitespace-only",
|
"commitMessage exists but instructions is whitespace-only",
|
||||||
{ metadataGeneration: { commitMessage: { instructions: " \n\t " } } },
|
{ 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);
|
const prompt = await generateCommitPromptWithConfig(config);
|
||||||
|
|
||||||
expect(prompt).toBe(PRE_CHANGE_COMMIT_PROMPT);
|
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({
|
const prompt = await generateCommitPromptWithConfig({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
commitMessage: {
|
commitMessage: {
|
||||||
@@ -1451,21 +1454,18 @@ diff --git a/file.txt b/file.txt
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultRuleIndex = prompt.indexOf("Write a concise git commit message");
|
expect(prompt).toContain("Use conventional commits.\nAccept XML-ish <scope> text.");
|
||||||
const openTagIndex = prompt.indexOf("<user-instructions>");
|
expect(prompt).not.toContain("Concise, imperative mood, no trailing period.");
|
||||||
const noticeIndex = prompt.indexOf("override the guidelines above");
|
|
||||||
const userInstructionIndex = prompt.indexOf("Use conventional commits.");
|
const contractIndex = prompt.indexOf("Write a concise git commit message");
|
||||||
const closeTagIndex = prompt.indexOf("</user-instructions>");
|
const styleIndex = prompt.indexOf("Use conventional commits.");
|
||||||
const jsonContractIndex = prompt.indexOf("Return JSON only");
|
const jsonContractIndex = prompt.indexOf("Return JSON only");
|
||||||
const fileListIndex = prompt.indexOf("Files changed:");
|
const fileListIndex = prompt.indexOf("Files changed:");
|
||||||
const patchIndex = prompt.indexOf("diff --git");
|
const patchIndex = prompt.indexOf("diff --git");
|
||||||
|
|
||||||
expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
|
expect(contractIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(defaultRuleIndex).toBeLessThan(openTagIndex);
|
expect(contractIndex).toBeLessThan(styleIndex);
|
||||||
expect(openTagIndex).toBeLessThan(noticeIndex);
|
expect(styleIndex).toBeLessThan(jsonContractIndex);
|
||||||
expect(noticeIndex).toBeLessThan(userInstructionIndex);
|
|
||||||
expect(userInstructionIndex).toBeLessThan(closeTagIndex);
|
|
||||||
expect(closeTagIndex).toBeLessThan(jsonContractIndex);
|
|
||||||
expect(jsonContractIndex).toBeLessThan(fileListIndex);
|
expect(jsonContractIndex).toBeLessThan(fileListIndex);
|
||||||
expect(fileListIndex).toBeLessThan(patchIndex);
|
expect(fileListIndex).toBeLessThan(patchIndex);
|
||||||
});
|
});
|
||||||
@@ -1542,6 +1542,9 @@ diff --git a/file.txt b/file.txt
|
|||||||
describe("session checkout pull request creation", () => {
|
describe("session checkout pull request creation", () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const PRE_CHANGE_PULL_REQUEST_PROMPT = `Write a pull request title and body for the changes below.
|
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'.
|
Return JSON only with fields 'title' and 'body'.
|
||||||
|
|
||||||
Files changed:
|
Files changed:
|
||||||
@@ -1713,13 +1716,13 @@ diff --git a/file.txt b/file.txt
|
|||||||
"pullRequest exists but instructions is whitespace-only",
|
"pullRequest exists but instructions is whitespace-only",
|
||||||
{ metadataGeneration: { pullRequest: { instructions: " \n\t " } } },
|
{ 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);
|
const prompt = await generatePullRequestPromptWithConfig(config);
|
||||||
|
|
||||||
expect(prompt).toBe(PRE_CHANGE_PULL_REQUEST_PROMPT);
|
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({
|
const prompt = await generatePullRequestPromptWithConfig({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
@@ -1728,21 +1731,18 @@ diff --git a/file.txt b/file.txt
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultRuleIndex = prompt.indexOf("Write a pull request title and body");
|
expect(prompt).toContain("Use a terse title.\nKeep literal <ticket> text.");
|
||||||
const openTagIndex = prompt.indexOf("<user-instructions>");
|
expect(prompt).not.toContain("Clear, descriptive title; body explaining what changed and why.");
|
||||||
const noticeIndex = prompt.indexOf("override the guidelines above");
|
|
||||||
const userInstructionIndex = prompt.indexOf("Use a terse title.");
|
const contractIndex = prompt.indexOf("Write a pull request title and body");
|
||||||
const closeTagIndex = prompt.indexOf("</user-instructions>");
|
const styleIndex = prompt.indexOf("Use a terse title.");
|
||||||
const jsonContractIndex = prompt.indexOf("Return JSON only");
|
const jsonContractIndex = prompt.indexOf("Return JSON only");
|
||||||
const fileListIndex = prompt.indexOf("Files changed:");
|
const fileListIndex = prompt.indexOf("Files changed:");
|
||||||
const patchIndex = prompt.indexOf("diff --git");
|
const patchIndex = prompt.indexOf("diff --git");
|
||||||
|
|
||||||
expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
|
expect(contractIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(defaultRuleIndex).toBeLessThan(openTagIndex);
|
expect(contractIndex).toBeLessThan(styleIndex);
|
||||||
expect(openTagIndex).toBeLessThan(noticeIndex);
|
expect(styleIndex).toBeLessThan(jsonContractIndex);
|
||||||
expect(noticeIndex).toBeLessThan(userInstructionIndex);
|
|
||||||
expect(userInstructionIndex).toBeLessThan(closeTagIndex);
|
|
||||||
expect(closeTagIndex).toBeLessThan(jsonContractIndex);
|
|
||||||
expect(jsonContractIndex).toBeLessThan(fileListIndex);
|
expect(jsonContractIndex).toBeLessThan(fileListIndex);
|
||||||
expect(fileListIndex).toBeLessThan(patchIndex);
|
expect(fileListIndex).toBeLessThan(patchIndex);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4255,8 +4255,13 @@ export class Session {
|
|||||||
const prompt = await buildMetadataPrompt({
|
const prompt = await buildMetadataPrompt({
|
||||||
cwd,
|
cwd,
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
configKey: "commitMessage",
|
contract: "Write a concise git commit message for the changes below.",
|
||||||
before: "Write a concise git commit message for the changes below.",
|
styles: [
|
||||||
|
{
|
||||||
|
configKey: "commitMessage",
|
||||||
|
default: "Concise, imperative mood, no trailing period.",
|
||||||
|
},
|
||||||
|
],
|
||||||
after: [
|
after: [
|
||||||
"Return JSON only with a single field 'message'.",
|
"Return JSON only with a single field 'message'.",
|
||||||
"",
|
"",
|
||||||
@@ -4333,8 +4338,13 @@ export class Session {
|
|||||||
const prompt = await buildMetadataPrompt({
|
const prompt = await buildMetadataPrompt({
|
||||||
cwd,
|
cwd,
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
configKey: "pullRequest",
|
contract: "Write a pull request title and body for the changes below.",
|
||||||
before: "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: [
|
after: [
|
||||||
"Return JSON only with fields 'title' and 'body'.",
|
"Return JSON only with fields 'title' and 'body'.",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -17,16 +17,21 @@ import {
|
|||||||
} from "../utils/worktree-metadata.js";
|
} from "../utils/worktree-metadata.js";
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
const cleanupPaths: string[] = [];
|
||||||
const BRANCH_PROMPT_BASELINE = `Generate a git branch name for a coding agent based on the user prompt and attachments.
|
const BRANCH_PROMPT_BASELINE = `Generate a title and a git branch name for a coding agent from the user prompt and attachments.
|
||||||
Title: a terse, task-shaped label naming what the task is about (sentence case, max 80 characters).
|
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.
|
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.
|
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).
|
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".
|
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".
|
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.
|
Branch style:
|
||||||
The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.
|
A short, descriptive slug — a few lowercase words joined by hyphens.
|
||||||
|
|
||||||
Return JSON only with fields 'title' and 'branch'.
|
Return JSON only with fields 'title' and 'branch'.
|
||||||
|
|
||||||
User context:
|
User context:
|
||||||
@@ -209,36 +214,50 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
|||||||
"branchName exists but instructions is whitespace-only",
|
"branchName exists but instructions is whitespace-only",
|
||||||
{ metadataGeneration: { branchName: { instructions: " \n\t " } } },
|
{ 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);
|
const { prompt } = await generateBranchPromptWithConfig(config);
|
||||||
|
|
||||||
expect(prompt).toBe(BRANCH_PROMPT_BASELINE);
|
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({
|
const { prompt } = await generateBranchPromptWithConfig({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
branchName: {
|
title: { instructions: "anything" },
|
||||||
instructions: "Use the prefix mb/.",
|
branchName: { instructions: "anything" },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultRuleIndex = prompt.indexOf("No spaces, no uppercase");
|
expect(prompt).toContain(
|
||||||
const noticeIndex = prompt.indexOf("override the guidelines above");
|
"The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.",
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the branch slug validator fallback when instructions are present", async () => {
|
test("keeps the branch slug validator fallback when instructions are present", async () => {
|
||||||
|
|||||||
@@ -54,19 +54,30 @@ async function buildPrompt(
|
|||||||
return buildMetadataPrompt({
|
return buildMetadataPrompt({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
workspaceGitService: options.workspaceGitService,
|
workspaceGitService: options.workspaceGitService,
|
||||||
configKey: "branchName",
|
contract: [
|
||||||
before: [
|
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||||
"Generate a git branch name for a coding agent based on 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.",
|
||||||
"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.",
|
|
||||||
"The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.",
|
"The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.",
|
||||||
].join("\n"),
|
].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'.",
|
after: "Return JSON only with fields 'title' and 'branch'.",
|
||||||
trailing: `User context:\n${seed}`,
|
trailing: `User context:\n${seed}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +1,59 @@
|
|||||||
import { readPaseoConfigJson } from "./paseo-config-file.js";
|
import { readPaseoConfigJson } from "./paseo-config-file.js";
|
||||||
import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
|
import {
|
||||||
import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
|
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 {
|
export interface RepoRootResolver {
|
||||||
resolveRepoRoot: (cwd: string) => Promise<string>;
|
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 {
|
export interface BuildMetadataPromptOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
configKey: MetadataConfigKey;
|
contract: string;
|
||||||
before: string;
|
styles: MetadataStyleSection[];
|
||||||
after: string;
|
after: string;
|
||||||
trailing?: string;
|
trailing?: string;
|
||||||
workspaceGitService?: RepoRootResolver;
|
workspaceGitService?: RepoRootResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildMetadataPrompt(options: BuildMetadataPromptOptions): Promise<string> {
|
export async function buildMetadataPrompt(options: BuildMetadataPromptOptions): Promise<string> {
|
||||||
const instructions = await readProjectMetadataInstructions(options);
|
const overrides = await readProjectMetadataOverrides(options);
|
||||||
const head = isNonEmptyString(instructions)
|
const styleBlocks = options.styles.map((section) =>
|
||||||
? wrapWithUserInstructions(options.before, instructions, options.after)
|
renderStyleSection(section, overrides?.[section.configKey]?.instructions),
|
||||||
: `${options.before}\n${options.after}`;
|
);
|
||||||
|
const head = [options.contract, ...styleBlocks, options.after].join("\n\n");
|
||||||
return options.trailing ? `${head}\n\n${options.trailing}` : head;
|
return options.trailing ? `${head}\n\n${options.trailing}` : head;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readProjectMetadataInstructions(
|
function renderStyleSection(section: MetadataStyleSection, override: string | undefined): string {
|
||||||
options: Pick<BuildMetadataPromptOptions, "cwd" | "configKey" | "workspaceGitService">,
|
const body = isNonEmptyString(override) ? override.trim() : section.default;
|
||||||
): Promise<string | undefined> {
|
return section.label ? `${section.label}:\n${body}` : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProjectMetadataOverrides(
|
||||||
|
options: Pick<BuildMetadataPromptOptions, "cwd" | "workspaceGitService">,
|
||||||
|
): Promise<PaseoMetadataGeneration | undefined> {
|
||||||
if (!options.workspaceGitService) {
|
if (!options.workspaceGitService) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd);
|
const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd);
|
||||||
const json = readPaseoConfigJson(repoRoot);
|
const json = readPaseoConfigJson(repoRoot);
|
||||||
const config = PaseoConfigSchema.parse(json);
|
return PaseoConfigSchema.parse(json).metadataGeneration;
|
||||||
return config.metadataGeneration?.[options.configKey]?.instructions;
|
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
@@ -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 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.
|
- **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.
|
- **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.
|
- **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
|
## 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.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"metadataGeneration": {
|
"metadataGeneration": {
|
||||||
|
"title": { "instructions": "Keep titles to a few words, no leading verb." },
|
||||||
"branchName": { "instructions": "Use the format <type>/<scope>-<short-desc>." },
|
"branchName": { "instructions": "Use the format <type>/<scope>-<short-desc>." },
|
||||||
"commitMessage": { "instructions": "Follow Conventional Commits." },
|
"commitMessage": { "instructions": "Follow Conventional Commits." },
|
||||||
"pullRequest": { "instructions": "Include a Testing section in the body." }
|
"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.
|
||||||
|
|||||||
Reference in New Issue
Block a user