Skip to content

Commit bf56bb6

Browse files
committed
feat(prompt): anti-pattern rules, tool list, verify rule, git status, system-reminder explainer
Five targeted improvements to the main system prompt: 1. # What NOT to do — six concrete anti-patterns the model is otherwise prone to (over-engineering, defensive validation, scope creep, gratuitous comments, false completion claims). Concrete rules bind behavior; vague "be concise" doesn't. 2. # Available tools — auto-built from the registered tool list. Each tool gets a one-line description so the model doesn't have to discover surface area by trial and error. 3. # Verifying your work — explicit "run the verification step before reporting done, say so when you couldn't" rule. Counters the false-completion pattern. 4. # Conversation conventions — one line documenting that <system-reminder> tags are runtime-injected, not user-typed. Stops the model from sometimes treating them as user requests. 5. Environment block now includes git branch + dirty-file summary (best-effort, silently skipped if cwd isn't a git repo). Saves the model an exploratory tool call when the next move is git-related. Tools list flow: createAgent now builds the tool list first, passes name+description to buildSystemPrompt, then registers the same list with the Agent. No double-construction, no drift.
1 parent 5054b9f commit bf56bb6

4 files changed

Lines changed: 252 additions & 33 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.46",
3+
"version": "2.0.0-pre.47",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/agent/agent.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ export interface AgentBundle {
113113

114114
export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
115115
const cwd = opts.cwd ?? process.cwd();
116-
const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(cwd);
117116

118117
// Persisted model preference from `~/.codebase/config.json` (set via
119118
// `/model`) seeds the override when the caller hasn't passed one
@@ -173,6 +172,18 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
173172
}),
174173
};
175174

175+
// Build tools once so we can both register them with the Agent AND
176+
// inject a one-liner per tool into the system prompt — saves the model
177+
// from discovering tool surface area through trial and error.
178+
const tools = buildTools(toolContext);
179+
180+
const systemPrompt =
181+
opts.systemPrompt ??
182+
buildSystemPrompt({
183+
cwd,
184+
tools: tools.map((t) => ({ name: t.name, description: t.description })),
185+
});
186+
176187
// MEMORY.md gets concatenated onto the system prompt at agent creation.
177188
// Reload-after-save is a Phase 11 polish item.
178189
const fullSystemPrompt = systemPrompt + buildMemoryAddendum(memory);
@@ -181,7 +192,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
181192
initialState: {
182193
model,
183194
systemPrompt: fullSystemPrompt,
184-
tools: buildTools(toolContext),
195+
tools,
185196
messages: opts.initialMessages ?? resumed?.messages ?? [],
186197
},
187198
getApiKey: () => apiKey,

src/agent/system-prompt.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { describe, expect, it } from "vitest";
5+
import { buildSystemPrompt } from "./system-prompt.js";
6+
7+
describe("buildSystemPrompt", () => {
8+
it("includes the identity sentence at the top", () => {
9+
const out = buildSystemPrompt({ cwd: "/tmp" });
10+
expect(out.startsWith("You are codebase")).toBe(true);
11+
});
12+
13+
it("includes a 'What NOT to do' anti-pattern section", () => {
14+
const out = buildSystemPrompt({ cwd: "/tmp" });
15+
expect(out).toContain("# What NOT to do");
16+
// The specific bullets that should be present:
17+
expect(out).toMatch(/Don't add features.*beyond what was asked/);
18+
expect(out).toMatch(/Don't add error handling.*can't actually occur/);
19+
expect(out).toMatch(/Default to no comments/);
20+
});
21+
22+
it("includes the verification rule", () => {
23+
const out = buildSystemPrompt({ cwd: "/tmp" });
24+
expect(out).toContain("# Verifying your work");
25+
expect(out).toMatch(/never characterize unverified work as complete/i);
26+
});
27+
28+
it("explains system-reminder semantics so the model knows they're from the runtime", () => {
29+
const out = buildSystemPrompt({ cwd: "/tmp" });
30+
expect(out).toContain("<system-reminder>");
31+
expect(out).toMatch(/aren't typed by the user/i);
32+
});
33+
34+
it("includes the task-checklist policy", () => {
35+
const out = buildSystemPrompt({ cwd: "/tmp" });
36+
expect(out).toContain("Task checklist");
37+
expect(out).toMatch(/Exactly ONE task is in_progress/);
38+
});
39+
40+
it("inlines an 'Available tools' section when tools are passed", () => {
41+
const out = buildSystemPrompt({
42+
cwd: "/tmp",
43+
tools: [
44+
{ name: "read_file", description: "Read a file from disk." },
45+
{ name: "shell", description: "Run a shell command.\n\nMore detail follows on later lines." },
46+
],
47+
});
48+
expect(out).toContain("# Available tools");
49+
expect(out).toMatch(/- read_file Read a file from disk\./);
50+
// Only the first line of each description is included.
51+
expect(out).toMatch(/- shell Run a shell command\./);
52+
expect(out).not.toContain("More detail follows on later lines");
53+
});
54+
55+
it("omits the 'Available tools' section when no tools are passed", () => {
56+
const out = buildSystemPrompt({ cwd: "/tmp" });
57+
expect(out).not.toContain("# Available tools");
58+
});
59+
60+
it("includes an Environment block with cwd / platform / date", () => {
61+
const out = buildSystemPrompt({ cwd: "/home/test" });
62+
expect(out).toContain("# Environment");
63+
expect(out).toContain("- cwd: /home/test");
64+
expect(out).toMatch(/- date: \d{4}-\d{2}-\d{2}/);
65+
});
66+
67+
it("accepts a bare cwd string for backward compatibility", () => {
68+
const out = buildSystemPrompt("/legacy/cwd");
69+
expect(out).toContain("- cwd: /legacy/cwd");
70+
});
71+
72+
it("does NOT include git lines when cwd is not a git repo", () => {
73+
const tmp = mkdtempSync(join(tmpdir(), "no-git-"));
74+
try {
75+
const out = buildSystemPrompt({ cwd: tmp });
76+
expect(out).not.toMatch(/^- git branch:/m);
77+
expect(out).not.toMatch(/^- git status:/m);
78+
} finally {
79+
rmSync(tmp, { recursive: true, force: true });
80+
}
81+
});
82+
83+
it("includes git branch + status when cwd is this repo", () => {
84+
// This test repo IS a git repo, so the helper should find it.
85+
const out = buildSystemPrompt({ cwd: process.cwd() });
86+
expect(out).toMatch(/^- git branch: \S/m);
87+
// status is either "clean" or "N uncommitted changes"
88+
expect(out).toMatch(/^- git status: (clean|\d+ uncommitted)/m);
89+
});
90+
});

src/agent/system-prompt.ts

Lines changed: 148 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,154 @@
1+
import { spawnSync } from "node:child_process";
12
import { hostname, platform } from "node:os";
23

4+
export interface BuildSystemPromptOptions {
5+
cwd?: string;
6+
/**
7+
* Active tool list. When provided, an "Available tools" section is
8+
* inlined so the model doesn't have to discover tool surface area
9+
* through trial and error. Pass undefined to omit.
10+
*/
11+
tools?: ReadonlyArray<{ name: string; description: string }>;
12+
}
13+
314
/**
4-
* Phase 1 system prompt — minimal but useful. Phase 4 (glue) and Phase 6
5-
* (output styles) layer atop. The static prefix vs. dynamic suffix split
6-
* lands in Phase 7 along with prompt caching.
15+
* Top-level system prompt for the main agent. Layered as:
16+
* 1. Identity + verbs the agent can perform
17+
* 2. Behavior rules (anti-patterns to avoid, verification, system-reminder
18+
* semantics)
19+
* 3. Task-checklist policy when create_task/update_task are available
20+
* 4. Available tools, auto-built from the registered tool set
21+
* 5. Environment block (cwd, platform, date, optional git summary)
22+
*
23+
* Deliberately short — most agents on this codebase will see ~600-1000
24+
* tokens of prompt, not 6000+. The bullets are concrete things-to-avoid
25+
* rather than vibes-prompts; concrete rules bind model behavior, vague
26+
* ones don't.
727
*/
8-
export function buildSystemPrompt(cwd: string = process.cwd()): string {
9-
const lines = [
10-
"You are codebase, a CLI coding agent. You help with software engineering tasks in the user's terminal.",
11-
"",
12-
"Be concise. Prefer code over prose. When you don't have a tool to act, say what you would do.",
13-
"",
14-
"Task checklist (create_task / update_task):",
15-
" Use the task tools to keep a visible checklist whenever the user's request needs more than 2-3 steps,",
16-
" spans multiple files or commands, or the user gave you a numbered/bulleted list. The user sees this",
17-
" list update in real time and judges progress from it.",
18-
" Skip it for single trivial actions, pure Q&A, and one-off shell commands.",
19-
" Rules:",
20-
" - Create the full plan at the start of the work, one task per intended step.",
21-
" - Provide both an imperative title ('Add OAuth refresh') and an active_form ('Adding OAuth refresh').",
22-
" - Exactly ONE task is in_progress at any time. Flip the next one to in_progress BEFORE starting it,",
23-
" and mark it completed IMMEDIATELY after it finishes — never batch completions.",
24-
" - Never mark a task completed if it errored, tests are failing, or you couldn't finish.",
25-
" Keep it in_progress and create a follow-up task for whatever's blocking.",
26-
" - If you discover work mid-task that wasn't planned, append new tasks to the list.",
27-
" - Cancel tasks that turned out to be unnecessary; don't leave stale 'pending' items.",
28-
"",
29-
"Environment:",
30-
` cwd: ${cwd}`,
31-
` platform: ${platform()}`,
32-
` host: ${hostname()}`,
33-
` date: ${new Date().toISOString().slice(0, 10)}`,
34-
];
28+
export function buildSystemPrompt(opts: BuildSystemPromptOptions | string = {}): string {
29+
// Back-compat: callers used to pass `cwd: string` directly. Accept that
30+
// shape and lift it into the options object.
31+
const options: BuildSystemPromptOptions = typeof opts === "string" ? { cwd: opts } : opts;
32+
const cwd = options.cwd ?? process.cwd();
33+
const lines: string[] = [];
34+
35+
lines.push("You are codebase, a CLI coding agent. You help with software engineering tasks in the user's terminal.");
36+
lines.push("");
37+
lines.push("# Tone");
38+
lines.push("- Be concise. Prefer code over prose.");
39+
lines.push("- When you don't have a tool to act, say what you would do.");
40+
lines.push(
41+
"- Match the response shape to the task: a simple question gets a direct answer, not headers and sections.",
42+
);
43+
lines.push("");
44+
lines.push("# What NOT to do");
45+
lines.push(
46+
"- Don't add features, refactor, or invent abstractions beyond what was asked. A bug fix doesn't need surrounding cleanup; a one-shot operation doesn't need a helper. Three similar lines is better than a premature abstraction.",
47+
);
48+
lines.push(
49+
"- Don't add error handling, fallbacks, or input validation for scenarios that can't actually occur. Only validate at real system boundaries (user input, external API responses).",
50+
);
51+
lines.push(
52+
"- Don't add backwards-compatibility shims or feature flags for code you're rewriting in the same change. Just change it.",
53+
);
54+
lines.push(
55+
"- Default to no comments. Only add one when the *why* is non-obvious — a hidden constraint, a subtle invariant, a workaround for a specific bug. Identifiers explain *what*; comments shouldn't.",
56+
);
57+
lines.push(
58+
'- Don\'t reference the current task or fix in comments ("used by X", "added for the Y flow"). That belongs in the PR description; in code it rots.',
59+
);
60+
lines.push(
61+
"- Don't claim you ran something you didn't. If a test or build wasn't executed, say so explicitly rather than implying success.",
62+
);
63+
lines.push("");
64+
lines.push("# Verifying your work");
65+
lines.push(
66+
"- Before reporting a task as done, actually run the verification step: the test you wrote, the script you changed, the build you touched. If you couldn't run it, say so plainly — never characterize unverified work as complete.",
67+
);
68+
lines.push(
69+
"- If a check fails, fix the underlying cause rather than working around it (no --no-verify, no skipping tests, no commenting out asserts).",
70+
);
71+
lines.push("");
72+
lines.push("# Conversation conventions");
73+
lines.push(
74+
"- Tool results and user messages may contain `<system-reminder>` tags. Those are automatic and come from the runtime — they aren't typed by the user. Treat them as context, not requests.",
75+
);
76+
lines.push(
77+
"- Output outside of tool calls is shown directly to the user; tool calls are how you act on their environment.",
78+
);
79+
lines.push("");
80+
lines.push("# Task checklist (create_task / update_task)");
81+
lines.push(
82+
"Use the task tools to keep a visible checklist whenever the request needs more than 2-3 steps, spans multiple files or commands, or the user gave you a numbered/bulleted list. The user judges progress from this list in real time.",
83+
);
84+
lines.push("Skip the checklist for single trivial actions, pure Q&A, and one-off shell commands.");
85+
lines.push("Rules:");
86+
lines.push(" - Create the full plan at the start of the work, one task per intended step.");
87+
lines.push(
88+
" - Each task needs an imperative title ('Add OAuth refresh') and an active_form ('Adding OAuth refresh').",
89+
);
90+
lines.push(
91+
" - Exactly ONE task is in_progress at a time. Flip the next one to in_progress BEFORE starting it; mark it completed IMMEDIATELY after — never batch completions.",
92+
);
93+
lines.push(
94+
" - Never mark a task completed if it errored, tests are failing, or you couldn't finish. Keep it in_progress and append a follow-up task for whatever's blocking.",
95+
);
96+
lines.push(" - Append new tasks if you discover work mid-stream; cancel tasks that turned out to be unnecessary.");
97+
98+
if (options.tools && options.tools.length > 0) {
99+
lines.push("");
100+
lines.push("# Available tools");
101+
for (const t of options.tools) {
102+
lines.push(`- ${t.name}${firstLine(t.description)}`);
103+
}
104+
}
105+
106+
lines.push("");
107+
lines.push("# Environment");
108+
lines.push(`- cwd: ${cwd}`);
109+
lines.push(`- platform: ${platform()}`);
110+
lines.push(`- host: ${hostname()}`);
111+
lines.push(`- date: ${new Date().toISOString().slice(0, 10)}`);
112+
const gitSummary = readGitSummary(cwd);
113+
if (gitSummary) {
114+
for (const line of gitSummary) lines.push(line);
115+
}
116+
35117
return lines.join("\n");
36118
}
119+
120+
function firstLine(s: string): string {
121+
const idx = s.indexOf("\n");
122+
return idx === -1 ? s : s.slice(0, idx);
123+
}
124+
125+
/**
126+
* Cheap-and-best-effort git status summary for the environment block.
127+
* Saves the model an exploratory `git status` tool call at session start
128+
* when the next move is obviously git-related. Skipped silently if cwd
129+
* isn't a git repo, git isn't installed, or anything errors — this is
130+
* a nice-to-have, never load-bearing.
131+
*/
132+
function readGitSummary(cwd: string): string[] | null {
133+
try {
134+
const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8", timeout: 500 });
135+
if (branch.status !== 0) return null;
136+
const branchName = branch.stdout.trim();
137+
const dirty = spawnSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8", timeout: 500 });
138+
const lines: string[] = [`- git branch: ${branchName}`];
139+
const dirtyOut = dirty.stdout.trim();
140+
if (dirtyOut.length === 0) {
141+
lines.push("- git status: clean");
142+
} else {
143+
const dirtyLines = dirtyOut.split("\n");
144+
const count = dirtyLines.length;
145+
lines.push(`- git status: ${count} uncommitted change${count === 1 ? "" : "s"}`);
146+
// Show up to 3 file paths so the model can see *what* is dirty.
147+
for (const dl of dirtyLines.slice(0, 3)) lines.push(` ${dl}`);
148+
if (count > 3) lines.push(` … and ${count - 3} more`);
149+
}
150+
return lines;
151+
} catch {
152+
return null;
153+
}
154+
}

0 commit comments

Comments
 (0)