From 7880859f31ee6b0d5e69d61218d349aa5729ae7f Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sun, 19 Jul 2026 08:55:23 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(ai):=20project=20rules=20=E2=80=94=20l?= =?UTF-8?q?oad=20AGENTS.md=20into=20the=20agent's=20system=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop an AGENTS.md (or CLAUDE.md / .cursorrules) in a repo and the agent follows it from turn one. Loaded once per run into the cached system block (byte-stable, so it rides the prompt cache), multi-root aware, first-present-file wins, capped at 16KB. A quiet timeline chip shows when rules are active. - projectRules.js: pure loader (file reading injected -> unit-testable) - agent.js: fold rules.text into the system prompt + the indicator chip - test/projectRules.test.js: 10 cases + a real-filesystem smoke test - docs/PROJECT-RULES.md: how it works, precedence, and the trust model Co-Authored-By: Claude Opus 4.8 --- docs/PROJECT-RULES.md | 93 ++++++++++++++++++ extensions/levelcode-ai/agent.js | 13 ++- extensions/levelcode-ai/projectRules.js | 54 +++++++++++ .../levelcode-ai/test/projectRules.test.js | 96 +++++++++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 docs/PROJECT-RULES.md create mode 100644 extensions/levelcode-ai/projectRules.js create mode 100644 extensions/levelcode-ai/test/projectRules.test.js diff --git a/docs/PROJECT-RULES.md b/docs/PROJECT-RULES.md new file mode 100644 index 0000000..f0daf6c --- /dev/null +++ b/docs/PROJECT-RULES.md @@ -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` | 10 unit cases (discovery, alias fallback, first-present-wins, multi-root, empty-skip, truncation, throwing reader) plus a real-filesystem smoke test. | + +## 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. diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 8799a08..96bdc7e 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -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", @@ -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'; diff --git a/extensions/levelcode-ai/projectRules.js b/extensions/levelcode-ai/projectRules.js new file mode 100644 index 0000000..b4cc5e1 --- /dev/null +++ b/extensions/levelcode-ai/projectRules.js @@ -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 + '/' + name : name; + 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 }; diff --git a/extensions/levelcode-ai/test/projectRules.test.js b/extensions/levelcode-ai/test/projectRules.test.js new file mode 100644 index 0000000..9ae4deb --- /dev/null +++ b/extensions/levelcode-ai/test/projectRules.test.js @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * 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']); +}); + +console.log(n + ' passing'); From 94fa4f97a1c44fff0a331b727af0457efba63a66 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sun, 19 Jul 2026 09:55:01 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(ai):=20address=20PR=20#21=20review=20?= =?UTF-8?q?=E2=80=94=20folder-name=20fallback=20+=20real-fs=20smoke=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projectRules: a folder entry with a root but no name produced "undefined/AGENTS.md" in multi-root labels; fall back to path.basename(f.root). - test: add the real-filesystem smoke test the doc referenced (writes an on-disk AGENTS.md and reads it back) — it was only run inline before — plus a case for the name fallback. 12 cases now. - docs: describe the test coverage accurately. Co-Authored-By: Claude Opus 4.8 --- docs/PROJECT-RULES.md | 2 +- extensions/levelcode-ai/projectRules.js | 2 +- .../levelcode-ai/test/projectRules.test.js | 28 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/PROJECT-RULES.md b/docs/PROJECT-RULES.md index f0daf6c..a6f3708 100644 --- a/docs/PROJECT-RULES.md +++ b/docs/PROJECT-RULES.md @@ -83,7 +83,7 @@ model as Cursor and Copilot rules files. Two things bound the risk: |------|------| | `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` | 10 unit cases (discovery, alias fallback, first-present-wins, multi-root, empty-skip, truncation, throwing reader) plus a real-filesystem smoke test. | +| `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) diff --git a/extensions/levelcode-ai/projectRules.js b/extensions/levelcode-ai/projectRules.js index b4cc5e1..02be2ae 100644 --- a/extensions/levelcode-ai/projectRules.js +++ b/extensions/levelcode-ai/projectRules.js @@ -36,7 +36,7 @@ function loadProjectRules(folders, readFile) { let content = null; try { content = readFile(path.join(f.root, name)); } catch { content = null; } if (content && content.trim()) { - const label = multi ? f.name + '/' + name : name; + 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); diff --git a/extensions/levelcode-ai/test/projectRules.test.js b/extensions/levelcode-ai/test/projectRules.test.js index 9ae4deb..33dfbd9 100644 --- a/extensions/levelcode-ai/test/projectRules.test.js +++ b/extensions/levelcode-ai/test/projectRules.test.js @@ -93,4 +93,32 @@ test('a malformed folder entry is skipped', () => { 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');