Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/PROJECT-RULES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# LevelCode — Project Rules (`AGENTS.md`)

Drop an **`AGENTS.md`** in your repository and the LevelCode agent reads it and follows your
project's conventions from the first turn — no per-message reminders, no configuration. It's the
cross-vendor rules-file standard (agentsmd.org), and LevelCode also accepts a couple of common
aliases so an existing repo works without a rename.

## Using it

Create a Markdown file at your workspace root and write whatever the agent should always know:

```markdown
# Project rules

- Use 2-space indent; run `npm test` before finishing.
- Prefer `edit_file` over rewriting whole files.
- Never touch `src/generated/**` — it's built from `schema/`.
- Commit style: conventional commits, imperative mood.
```

That's it. The next time you give the agent a goal, those rules are part of its instructions. Edit
the file and the change is picked up on your **next** run.

### Which files, and precedence

The agent looks in each workspace-folder root for the first of these that exists:

| Order | File | Why it's accepted |
|------:|------|-------------------|
| 1 | `AGENTS.md` | The emerging cross-vendor standard — **use this**. |
| 2 | `CLAUDE.md` | So a Claude Code repo works as-is. |
| 3 | `.cursorrules` | So a Cursor repo works as-is. |

**First present file per folder wins** — if you have both `AGENTS.md` and `.cursorrules`, only
`AGENTS.md` is used. Empty or whitespace-only files are ignored.

### Multi-root workspaces

Every workspace folder is checked, and each folder's rules are labelled by folder name
(`app/AGENTS.md`, `api/AGENTS.md`), so folder-specific conventions stay attributable. In a
single-folder workspace the label is just the filename.

### Seeing that it's active

When rules are loaded, a quiet line appears at the top of the run's activity timeline:

> 📋 project rules · AGENTS.md

(Multi-root shows every source, e.g. `📋 project rules · app/AGENTS.md, api/AGENTS.md`.) If you
don't see it, no rules file was found in any workspace folder.

## How it works

- **Loaded once per run.** `runAgent` (`extensions/levelcode-ai/agent.js`) calls
`loadProjectRules()` when it builds the system prompt, and folds the result into the **system
block** — after the base prompt, the multi-root note, and the autopilot note. Because it's read
once and lives in the system block, it is **byte-stable across every turn of a run**, so it rides
the prompt cache (billed at ~0.1× on reads) instead of being re-sent at full price each turn. A
fresh run re-reads the file, so edits take effect on the next goal.
- **Bounded.** Each rules file is capped at **16,000 characters** (`PER_FILE_CAP`) and truncated
with a marker past that — it rides the cached prefix on every turn, so it can't be allowed to
balloon the request.
- **How it reaches the model.** The content is appended under a short preamble:
*"PROJECT RULES — the user maintains these in their repository. Treat them as part of your
instructions and follow them, unless they conflict with a direct request in this conversation."*

### Trust and safety

Rules are **the repository author's text, injected into the prompt by design** — the same trust
model as Cursor and Copilot rules files. Two things bound the risk:

- **They yield to you.** The preamble tells the agent that a direct request in the conversation
overrides the rules file.
- **They can't disable the safety gates.** The autopilot **danger set** (deletion, `sudo`,
force-push, discarding uncommitted work, remote-piped shells, publishing, system writes) and the
command-approval prompts are enforced **host-side** in `commandSafety.js` / the extension — they
are not driven by the prompt, so an `AGENTS.md` cannot turn them off. Still, only add rules from
repositories you trust, as you would any project config.

### Code map

| File | Role |
|------|------|
| `extensions/levelcode-ai/projectRules.js` | Pure loader: `loadProjectRules(folders, readFile)` → `{ text, sources }`. File reading is injected as a callback, so it's testable without a filesystem. |
| `extensions/levelcode-ai/agent.js` | Reads with `fs`, folds `rules.text` into the system prompt, posts the timeline chip, and `dbg('projectRules.loaded', …)`. |
| `extensions/levelcode-ai/test/projectRules.test.js` | Unit cases with an injected reader (discovery, alias fallback, first-present-wins, multi-root labelling incl. a name fallback, empty-skip, truncation, throwing reader) plus a real-filesystem smoke test that writes an on-disk `AGENTS.md` and reads it back. |

## Not yet (planned)

- **Nested `AGENTS.md`.** The standard allows per-directory files (closest wins for work in that
subtree). This slice reads **folder-root** files only.
- **An on/off setting.** Rules are opt-in by file presence today; a
`levelcode.ai.projectRules.enabled` toggle would let a user suppress a repo's rules.
13 changes: 12 additions & 1 deletion extensions/levelcode-ai/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const cp = require('child_process');
const providers = require('./providers/index');
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify');
const { classifyCommand, dangerLabel } = require('./commandSafety');
const { loadProjectRules } = require('./projectRules');

const SYSTEM_BASE = [
"You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their",
Expand Down Expand Up @@ -477,10 +478,20 @@ async function runAgent(ctx) {
const autopilotNote = ctx.autopilot
? '\n\nAUTOPILOT IS ON. Work end-to-end without pausing for confirmation. Your run_command calls execute immediately (only irreversible ones — deleting files, sudo, force-push, piping a remote script to a shell, publishing — still ask the user). Do NOT call ask_user for anything you can reasonably decide; pick a sensible default and proceed. When you are unsure whether a change is correct, do not stop to ask — verify it: run the build/tests/linters via run_command and read editor diagnostics, then fix and re-verify until clean, and only then move on. Prefer doing and checking over asking.'
: '';
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + autopilotNote;
// Project rules: fold a repo's own AGENTS.md (or CLAUDE.md / .cursorrules) into the cached system
// block so the agent follows the project's conventions from turn one. Read once per run — an edit is
// picked up on the next run.
const rules = loadProjectRules(wsFolders, (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } });
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + autopilotNote + rules.text;
const systemTokensEst = Math.round(system.length / 4);

const dbg = ctx.dbg || (() => {});
if (rules.sources.length) {
dbg('projectRules.loaded', { sources: rules.sources });
// Quiet timeline chip at the top of the run so the user can see their repo rules are in effect
// (mirrors the skill chip). Reuses the agentTool → addAgentLine rendering — no webview change.
ctx.post({ type: 'agentTool', icon: 'file', text: '📋 project rules · ' + rules.sources.join(', ') });
}
const messages = ctx.messages;
let step = 0;
let reason = 'done';
Expand Down
54 changes: 54 additions & 0 deletions extensions/levelcode-ai/projectRules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*---------------------------------------------------------------------------------------------
* Project rules — load a per-repo AGENTS.md (or a common alias) and fold it into the agent's
* system prompt so the model follows the project's own conventions from turn one. Read ONCE per
* run and placed in the (cached) system block, so it stays byte-stable across a run's turns.
*
* AGENTS.md is the emerging cross-vendor standard; we also accept CLAUDE.md and .cursorrules so an
* existing repo works without a rename. The first file present in a folder wins.
*
* Pure + dependency-free (path only): the finding/reading is injected as a readFile callback, so the
* assembly is unit-testable (test/projectRules.test.js) without a filesystem. Note: rules content is
* the repo author's, injected into the prompt by design — the same trust model as Cursor/Copilot
* rules files. The agent is told they yield to a direct request in the conversation.
*--------------------------------------------------------------------------------------------*/
'use strict';

const path = require('path');

// Preference order; the first present file in a given folder wins.
const RULES_FILENAMES = ['AGENTS.md', 'CLAUDE.md', '.cursorrules'];
// A rules file rides the cached prefix on EVERY turn, so cap it. Past this it's truncated with a marker.
const PER_FILE_CAP = 16000;

/**
* Load the project's rules file(s) and format them as a system-prompt section.
* @param {Array<{name:string, root:string}>} folders workspace folders (name + absolute root)
* @param {(absPath:string)=>(string|null)} readFile returns file content, or null if absent/unreadable
* @returns {{ text: string, sources: string[] }} text is '' when no rules file is found
*/
function loadProjectRules(folders, readFile) {
const valid = (Array.isArray(folders) ? folders : []).filter((f) => f && f.root);
const multi = valid.length > 1; // prefix labels by folder only when there really are 2+ folders
const blocks = [];
const sources = [];
for (const f of valid) {
for (const name of RULES_FILENAMES) {
let content = null;
try { content = readFile(path.join(f.root, name)); } catch { content = null; }
if (content && content.trim()) {
const label = multi ? (f.name || path.basename(f.root)) + '/' + name : name; // never "undefined/AGENTS.md"
let body = content.trim();
if (body.length > PER_FILE_CAP) { body = body.slice(0, PER_FILE_CAP) + '\n\n…[' + label + ' truncated at ' + PER_FILE_CAP + ' chars]'; }
blocks.push('### ' + label + '\n' + body);
sources.push(label);
break; // first present rules file in this folder wins
}
}
}
if (!blocks.length) { return { text: '', sources: [] }; }
const preamble = '\n\nPROJECT RULES — the user maintains these in their repository. Treat them as part of your'
+ ' instructions and follow them, unless they conflict with a direct request in this conversation:\n\n';
return { text: preamble + blocks.join('\n\n'), sources };
}

module.exports = { loadProjectRules, RULES_FILENAMES, PER_FILE_CAP };
124 changes: 124 additions & 0 deletions extensions/levelcode-ai/test/projectRules.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*---------------------------------------------------------------------------------------------
* Unit tests for project-rules loading — run: node test/projectRules.test.js
* loadProjectRules folds a repo's AGENTS.md (or CLAUDE.md / .cursorrules) into the system prompt.
* The reading is a callback, so these run without a filesystem: the mock returns file bodies by
* absolute path. Covers discovery, alias fallback, first-present-wins, multi-root, empty-skip,
* truncation, and a throwing reader.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

const assert = require('assert');
const path = require('path');
const { loadProjectRules, RULES_FILENAMES, PER_FILE_CAP } = require('../projectRules');

let n = 0;
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }

// Build a reader from a { absPath: content } map. Absent paths return null.
const reader = (files) => (abs) => (abs in files ? files[abs] : null);
const at = (root, name) => path.join(root, name);

const F1 = { name: 'app', root: '/ws/app' };
const F2 = { name: 'api', root: '/ws/api' };

test('no folders / no files → empty, no text injected', () => {
assert.deepStrictEqual(loadProjectRules([], reader({})), { text: '', sources: [] });
assert.deepStrictEqual(loadProjectRules([F1], reader({})), { text: '', sources: [] });
});

test('AGENTS.md is discovered and folded in with a preamble', () => {
const r = loadProjectRules([F1], reader({ [at('/ws/app', 'AGENTS.md')]: 'Use tabs. Run npm test.' }));
assert.deepStrictEqual(r.sources, ['AGENTS.md']);
assert.ok(r.text.includes('PROJECT RULES'), 'no preamble');
assert.ok(r.text.includes('Use tabs. Run npm test.'), 'rules body missing');
assert.ok(r.text.startsWith('\n\n'), 'should append cleanly to the system prompt');
});

test('alias fallback: CLAUDE.md / .cursorrules when no AGENTS.md', () => {
const claude = loadProjectRules([F1], reader({ [at('/ws/app', 'CLAUDE.md')]: 'claude rules' }));
assert.deepStrictEqual(claude.sources, ['CLAUDE.md']);
const cursor = loadProjectRules([F1], reader({ [at('/ws/app', '.cursorrules')]: 'cursor rules' }));
assert.deepStrictEqual(cursor.sources, ['.cursorrules']);
});

test('first present file in a folder wins (AGENTS.md over the aliases)', () => {
const r = loadProjectRules([F1], reader({
[at('/ws/app', 'AGENTS.md')]: 'AGENTS wins',
[at('/ws/app', 'CLAUDE.md')]: 'should be ignored',
[at('/ws/app', '.cursorrules')]: 'also ignored',
}));
assert.deepStrictEqual(r.sources, ['AGENTS.md']);
assert.ok(r.text.includes('AGENTS wins') && !r.text.includes('should be ignored'));
// preference order matches the exported constant
assert.deepStrictEqual(RULES_FILENAMES, ['AGENTS.md', 'CLAUDE.md', '.cursorrules']);
});

test('multi-root: each folder contributes, labeled by folder name', () => {
const r = loadProjectRules([F1, F2], reader({
[at('/ws/app', 'AGENTS.md')]: 'app rules',
[at('/ws/api', 'AGENTS.md')]: 'api rules',
}));
assert.deepStrictEqual(r.sources, ['app/AGENTS.md', 'api/AGENTS.md']);
assert.ok(r.text.includes('### app/AGENTS.md') && r.text.includes('### api/AGENTS.md'));
assert.ok(r.text.includes('app rules') && r.text.includes('api rules'));
});

test('single-root labels without a folder prefix', () => {
const r = loadProjectRules([F1], reader({ [at('/ws/app', 'AGENTS.md')]: 'x' }));
assert.ok(r.text.includes('### AGENTS.md') && !r.text.includes('app/AGENTS.md'));
});

test('empty / whitespace-only rules files are skipped (treated as absent)', () => {
assert.deepStrictEqual(loadProjectRules([F1], reader({ [at('/ws/app', 'AGENTS.md')]: ' \n\t ' })), { text: '', sources: [] });
// falls through to a non-empty alias
const r = loadProjectRules([F1], reader({ [at('/ws/app', 'AGENTS.md')]: '', [at('/ws/app', 'CLAUDE.md')]: 'real rules' }));
assert.deepStrictEqual(r.sources, ['CLAUDE.md']);
});

test('an oversized rules file is truncated with a marker', () => {
const big = 'R'.repeat(PER_FILE_CAP + 5000);
const r = loadProjectRules([F1], reader({ [at('/ws/app', 'AGENTS.md')]: big }));
assert.ok(r.text.includes('truncated at ' + PER_FILE_CAP), 'no truncation marker');
assert.ok(r.text.length < big.length, 'not actually truncated');
});

test('a throwing reader is treated as absent, never crashes', () => {
const boom = () => { throw new Error('EACCES'); };
assert.deepStrictEqual(loadProjectRules([F1], boom), { text: '', sources: [] });
});

test('a malformed folder entry is skipped', () => {
const r = loadProjectRules([null, { name: 'x' }, F1], reader({ [at('/ws/app', 'AGENTS.md')]: 'ok' }));
assert.deepStrictEqual(r.sources, ['AGENTS.md']);
});

test('multi-root: a folder with no name falls back to its basename (never "undefined/…")', () => {
const r = loadProjectRules([{ root: '/ws/app' }, F2], reader({
[at('/ws/app', 'AGENTS.md')]: 'a',
[at('/ws/api', 'AGENTS.md')]: 'b',
}));
assert.deepStrictEqual(r.sources, ['app/AGENTS.md', 'api/AGENTS.md']);
assert.ok(!r.text.includes('undefined/'), 'label leaked an undefined folder name');
});

// The cases above inject a fake reader; this one uses the real fs to prove the on-disk path works.
test('real filesystem: discovers and reads an on-disk rules file (smoke)', () => {
const fs = require('fs');
const os = require('os');
const real = (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } };
const withRules = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-rules-'));
const noRules = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-none-'));
try {
fs.writeFileSync(path.join(withRules, 'AGENTS.md'), '# Rules\n- Use 2-space indent');
const hit = loadProjectRules([{ name: 'app', root: withRules }], real);
assert.deepStrictEqual(hit.sources, ['AGENTS.md']);
assert.ok(hit.text.includes('Use 2-space indent'), 'on-disk rules content was not folded in');
assert.deepStrictEqual(loadProjectRules([{ name: 'empty', root: noRules }], real), { text: '', sources: [] });
} finally {
fs.rmSync(withRules, { recursive: true, force: true });
fs.rmSync(noRules, { recursive: true, force: true });
}
});

console.log(n + ' passing');