From a08266c54f3e645a48c2e064f76a3095bd4a67dd Mon Sep 17 00:00:00 2001 From: Chris Romp Date: Sat, 11 Apr 2026 20:50:51 -0700 Subject: [PATCH 1/3] feat: load AGENTS.local.md for per-operator conventions - buildSystemMessage() reads AGENTS.local.md from working directory if present - Content is injected into custom_instructions alongside bridge instructions - Added AGENTS.local.md to .gitignore - Documented in AGENTS.md, README.md, docs/workspaces.md, and bridge-docs help - Renamed branch from docs/ to feat/ (includes code changes, not just docs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + AGENTS.md | 4 ++++ README.md | 4 ++-- docs/workspaces.md | 9 +++++++++ src/core/bridge-docs.ts | 7 +++++++ src/core/session-manager.ts | 36 ++++++++++++++++++++++++++++-------- 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index ccad407..a80ed33 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ config.json .playwright-cli .nano-banana-config.json generated_imgs/ +AGENTS.local.md diff --git a/AGENTS.md b/AGENTS.md index ce255e5..5388dde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,10 @@ All bots default to **it/its** pronouns. Bots are software, not people. When wri ## Key Conventions +### Local Instructions + +The bridge loads `AGENTS.local.md` from each bot's working directory if present. This file is gitignored and injected into sessions via `custom_instructions`. Use it for per-operator conventions (e.g., push policies, workflow preferences) that don't belong in the repo. + ### Channel Adapter Pattern New platforms implement `ChannelAdapter` (in `src/types.ts`). The Mattermost adapter (`src/channels/mattermost/adapter.ts`) is the reference implementation. diff --git a/README.md b/README.md index cc074d1..025e28c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ More screenshots [here](docs/screenshots.md). ## Features - **Multi-bot support** — Run multiple bot identities on the same platform (e.g., `@copilot` for admin, `@alice` for tasks) -- **Workspaces** — Each bot gets an isolated workspace with its own `AGENTS.md`, `.env` secrets, and `MEMORY.md` +- **Workspaces** — Each bot gets an isolated workspace with its own `AGENTS.md`, `.env` secrets, `MEMORY.md`, and optional `AGENTS.local.md` for per-operator conventions - **DM auto-discovery** — Just message a bot; no channel config needed for direct messages - **Streaming responses** — Edit-in-place message updates with throttling - **MCP & skills** — Auto-loads MCP servers and skill directories from Copilot config @@ -68,7 +68,7 @@ See the [Setup Guide — Running as a Service](docs/setup.md#running-as-a-servic | **Session** | | | | `/new` | | Start a fresh session | | `/stop` | `/cancel` | Stop the current task | -| `/reload` | | Reload session (re-reads AGENTS.md, workspace config) | +| `/reload` | | Reload session (re-reads AGENTS.md, AGENTS.local.md, workspace config) | | `/reload config` | | Hot-reload config.json (safe changes apply without restart) | | `/resume [id]` | | List past sessions, or resume one by ID | | `/model [name]` | `/models` | List models or switch model (fuzzy match) | diff --git a/docs/workspaces.md b/docs/workspaces.md index 48684dc..f4449f2 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -9,6 +9,7 @@ Workspaces are auto-created when the bridge starts and detects a bot without one ``` ~/.copilot-bridge/workspaces/agent-name/ ├── AGENTS.md # Agent instructions (auto-generated from template, customizable) +├── AGENTS.local.md # Local operator conventions (gitignored, optional) ├── MEMORY.md # Persistent memory across sessions (managed by the agent) ├── mcp-config.json # Workspace-specific MCP servers (optional, overrides global) └── .env # Environment variables loaded at session start @@ -18,6 +19,14 @@ Workspaces are auto-created when the bridge starts and detects a bot without one For group channels or project-specific DMs, override the workspace via `workingDirectory` in [config.json](configuration.md#channels). The same bot can serve multiple channels, each pointed at a different directory. +## AGENTS.local.md + +An optional, gitignored file for per-operator conventions (push policies, branching rules, workflow preferences). The bridge loads it from the working directory at session creation and injects its content into `custom_instructions` alongside bridge instructions. + +This file is **not** discovered by the Copilot SDK/CLI — the bridge handles it in `buildSystemMessage()`. It's intended for conventions that are personal to the operator rather than the project (which belong in `AGENTS.md`). + +To use it, create `AGENTS.local.md` in the working directory and add it to `.gitignore`. + ## Agent templates Templates in the repo define the baseline instructions for agents: diff --git a/src/core/bridge-docs.ts b/src/core/bridge-docs.ts index 59b8e13..c14ffdc 100644 --- a/src/core/bridge-docs.ts +++ b/src/core/bridge-docs.ts @@ -348,6 +348,7 @@ Each bot has a dedicated workspace directory (default: \`~/.copilot-bridge/works \`\`\` / ├── AGENTS.md # Agent instructions (read by Copilot CLI) +├── AGENTS.local.md # Local operator conventions (gitignored, optional) ├── .env # Environment variables (secrets, API tokens) ├── mcp-config.json # Workspace-specific MCP servers (optional) ├── agents/ # Named agent personas (*.agent.md) @@ -355,6 +356,12 @@ Each bot has a dedicated workspace directory (default: \`~/.copilot-bridge/works └── .agents/skills/ # Legacy skill location \`\`\` +## AGENTS.local.md + +Optional, gitignored file for per-operator conventions. Loaded by the bridge and injected into \`custom_instructions\` alongside bridge instructions. Use for preferences like push policies, branching rules, or workflow constraints that are personal to the operator rather than the project. + +Not loaded by the SDK/CLI directly — the bridge handles it in \`buildSystemMessage()\`. + ## .env Files - Loaded into shell environment at session start diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 2b3dc2c..38f4b1f 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -1602,9 +1602,13 @@ export class SessionManager { /** Build the system message config for session create/resume. * Appends bridge-specific instructions to the SDK's custom_instructions section - * so agents get channel communication context without polluting AGENTS.md. */ - private buildSystemMessage(): SystemMessageCustomizeConfig { - const content = [ + * so agents get channel communication context without polluting AGENTS.md. + * Also loads AGENTS.local.md from the working directory if present. */ + private buildSystemMessage(workingDirectory?: string): SystemMessageCustomizeConfig { + const parts: string[] = []; + + // Bridge-specific instructions + parts.push([ '', 'You are communicating through copilot-bridge, a messaging bridge to a chat platform (e.g., Mattermost, Slack).', '', @@ -1624,12 +1628,28 @@ export class SessionManager { '- When you have nothing meaningful to add to a conversation, call the `no_reply` tool instead of sending text', '- This is preferred over typing "NO_REPLY" or similar text responses', '', - ].join('\n'); + ].join('\n')); + + // Load AGENTS.local.md if present (gitignored, per-operator conventions) + if (workingDirectory) { + const localAgentsPath = path.join(workingDirectory, 'AGENTS.local.md'); + try { + if (fs.existsSync(localAgentsPath)) { + const localContent = fs.readFileSync(localAgentsPath, 'utf-8').trim(); + if (localContent) { + parts.push(`\n${localContent}\n`); + log.debug(`Loaded AGENTS.local.md from ${workingDirectory}`); + } + } + } catch (err) { + log.warn(`Failed to read AGENTS.local.md: ${err}`); + } + } return { mode: 'customize' as const, sections: { - custom_instructions: { action: 'append' as const, content }, + custom_instructions: { action: 'append' as const, content: parts.join('\n\n') }, }, }; } @@ -1696,7 +1716,7 @@ export class SessionManager { tools: customTools.length > 0 ? customTools : undefined, hooks, infiniteSessions: getConfig().infiniteSessions, - systemMessage: this.buildSystemMessage(), + systemMessage: this.buildSystemMessage(workingDirectory), }) ); }; @@ -1747,7 +1767,7 @@ export class SessionManager { tools: customTools.length > 0 ? customTools : undefined, hooks, infiniteSessions: getConfig().infiniteSessions, - systemMessage: this.buildSystemMessage(), + systemMessage: this.buildSystemMessage(workingDirectory), }) ); }; @@ -1857,7 +1877,7 @@ export class SessionManager { tools: customTools.length > 0 ? customTools : undefined, hooks, infiniteSessions: getConfig().infiniteSessions, - systemMessage: this.buildSystemMessage(), + systemMessage: this.buildSystemMessage(workingDirectory), }) ); From 3096876312bea5cc490d20528dc99dbfc4caa276 Mon Sep 17 00:00:00 2001 From: Chris Romp Date: Sat, 11 Apr 2026 21:15:19 -0700 Subject: [PATCH 2/3] fix: load AGENTS.local.md in inter-agent sessions Extract loadLocalInstructions() helper from buildSystemMessage() and also call it when building ephemeral inter-agent session system messages. This ensures operator-local conventions from AGENTS.local.md are respected in ask_agent calls, not just normal channel sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/session-manager.ts | 40 +++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 38f4b1f..d224d65 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -1600,6 +1600,25 @@ export class SessionManager { return await getWorkspacePath(botName); } + /** Load AGENTS.local.md from a workspace directory, wrapped in XML tags. + * Returns the wrapped content string, or undefined if the file doesn't exist or is empty. */ + private loadLocalInstructions(workingDirectory?: string): string | undefined { + if (!workingDirectory) return undefined; + const localAgentsPath = path.join(workingDirectory, 'AGENTS.local.md'); + try { + if (fs.existsSync(localAgentsPath)) { + const localContent = fs.readFileSync(localAgentsPath, 'utf-8').trim(); + if (localContent) { + log.debug(`Loaded AGENTS.local.md from ${workingDirectory}`); + return `\n${localContent}\n`; + } + } + } catch (err) { + log.warn(`Failed to read AGENTS.local.md: ${err}`); + } + return undefined; + } + /** Build the system message config for session create/resume. * Appends bridge-specific instructions to the SDK's custom_instructions section * so agents get channel communication context without polluting AGENTS.md. @@ -1631,19 +1650,9 @@ export class SessionManager { ].join('\n')); // Load AGENTS.local.md if present (gitignored, per-operator conventions) - if (workingDirectory) { - const localAgentsPath = path.join(workingDirectory, 'AGENTS.local.md'); - try { - if (fs.existsSync(localAgentsPath)) { - const localContent = fs.readFileSync(localAgentsPath, 'utf-8').trim(); - if (localContent) { - parts.push(`\n${localContent}\n`); - log.debug(`Loaded AGENTS.local.md from ${workingDirectory}`); - } - } - } catch (err) { - log.warn(`Failed to read AGENTS.local.md: ${err}`); - } + const localInstructions = this.loadLocalInstructions(workingDirectory); + if (localInstructions) { + parts.push(localInstructions); } return { @@ -1944,6 +1953,11 @@ export class SessionManager { if (agentDef) { systemParts.push(`\n--- Agent Definition: ${agentDef.name} ---\n${agentDef.content}`); } + // Load AGENTS.local.md from target bot's workspace if present + const localInstructions = this.loadLocalInstructions(targetWorkspace); + if (localInstructions) { + systemParts.push(localInstructions); + } // If the target has an ask_agent tool available, inject the chain context if (nextContext.depth < (iaConfig.maxDepth ?? 3)) { systemParts.push( From 588cbd0178d760ac446d80e125a9cfffe42edf2e Mon Sep 17 00:00:00 2001 From: Chris Romp Date: Sun, 12 Apr 2026 15:08:13 -0700 Subject: [PATCH 3/3] fix: address CCR feedback on AGENTS.local.md - Extract loadLocalInstructions() as exported function for testability - Suppress ENOENT errors from TOCTOU gap, use structured logging for unexpected errors - Add unit tests (8 cases: undefined/empty/whitespace/missing dir/ content wrapping/trimming) - Update /help reload text to mention AGENTS.local.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/command-handler.ts | 2 +- src/core/local-instructions.test.ts | 57 +++++++++++++++++++++++++++++ src/core/session-manager.ts | 43 +++++++++++----------- 3 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 src/core/local-instructions.test.ts diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index ab780ff..6a4b64c 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -916,7 +916,7 @@ export async function handleCommand(channelId: string, text: string, sessionInfo '**Session**', '`/new` — Start a new session', '`/stop` — Stop the current task (alias: `/cancel`)', - '`/reload` — Reload session (re-reads AGENTS.md, workspace config)', + '`/reload` — Reload session (re-reads AGENTS.md, AGENTS.local.md, workspace config)', '`/reload config` — Hot-reload config.json', '`/reload mcp` — Reload MCP servers (no session restart)', '`/reload skills` — Reload skills (no session restart)', diff --git a/src/core/local-instructions.test.ts b/src/core/local-instructions.test.ts new file mode 100644 index 0000000..5d1beac --- /dev/null +++ b/src/core/local-instructions.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { loadLocalInstructions } from './session-manager.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +describe('loadLocalInstructions', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-instructions-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns undefined when workingDirectory is undefined', () => { + expect(loadLocalInstructions(undefined)).toBeUndefined(); + }); + + it('returns undefined when workingDirectory is empty string', () => { + expect(loadLocalInstructions('')).toBeUndefined(); + }); + + it('returns undefined when AGENTS.local.md does not exist', () => { + expect(loadLocalInstructions(tmpDir)).toBeUndefined(); + }); + + it('returns undefined when AGENTS.local.md is empty', () => { + fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), ''); + expect(loadLocalInstructions(tmpDir)).toBeUndefined(); + }); + + it('returns undefined when AGENTS.local.md is whitespace-only', () => { + fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), ' \n\n '); + expect(loadLocalInstructions(tmpDir)).toBeUndefined(); + }); + + it('wraps content in tags', () => { + fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), '# My Rules\nDo the thing.'); + const result = loadLocalInstructions(tmpDir); + expect(result).toBe( + '\n# My Rules\nDo the thing.\n', + ); + }); + + it('trims leading/trailing whitespace from content', () => { + fs.writeFileSync(path.join(tmpDir, 'AGENTS.local.md'), '\n Hello \n\n'); + const result = loadLocalInstructions(tmpDir); + expect(result).toBe('\nHello\n'); + }); + + it('returns undefined for a nonexistent directory', () => { + expect(loadLocalInstructions('/tmp/nonexistent-dir-' + Date.now())).toBeUndefined(); + }); +}); diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index d224d65..6e3ac0a 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -468,6 +468,26 @@ export function extractPlanSummary(content: string): string { return '(empty plan)'; } +/** Load AGENTS.local.md from a workspace directory, wrapped in XML tags. + * Returns the wrapped content string, or undefined if the file doesn't exist or is empty. */ +export function loadLocalInstructions(workingDirectory?: string): string | undefined { + if (!workingDirectory) return undefined; + const localAgentsPath = path.join(workingDirectory, 'AGENTS.local.md'); + try { + if (fs.existsSync(localAgentsPath)) { + const localContent = fs.readFileSync(localAgentsPath, 'utf-8').trim(); + if (localContent) { + log.debug(`Loaded AGENTS.local.md from ${workingDirectory}`); + return `\n${localContent}\n`; + } + } + } catch (err: any) { + if (err?.code === 'ENOENT') return undefined; + log.warn(`Failed to read AGENTS.local.md from ${localAgentsPath}:`, err); + } + return undefined; +} + export class SessionManager { private bridge: CopilotBridge; private channelSessions = new Map(); // channelId → sessionId @@ -1600,25 +1620,6 @@ export class SessionManager { return await getWorkspacePath(botName); } - /** Load AGENTS.local.md from a workspace directory, wrapped in XML tags. - * Returns the wrapped content string, or undefined if the file doesn't exist or is empty. */ - private loadLocalInstructions(workingDirectory?: string): string | undefined { - if (!workingDirectory) return undefined; - const localAgentsPath = path.join(workingDirectory, 'AGENTS.local.md'); - try { - if (fs.existsSync(localAgentsPath)) { - const localContent = fs.readFileSync(localAgentsPath, 'utf-8').trim(); - if (localContent) { - log.debug(`Loaded AGENTS.local.md from ${workingDirectory}`); - return `\n${localContent}\n`; - } - } - } catch (err) { - log.warn(`Failed to read AGENTS.local.md: ${err}`); - } - return undefined; - } - /** Build the system message config for session create/resume. * Appends bridge-specific instructions to the SDK's custom_instructions section * so agents get channel communication context without polluting AGENTS.md. @@ -1650,7 +1651,7 @@ export class SessionManager { ].join('\n')); // Load AGENTS.local.md if present (gitignored, per-operator conventions) - const localInstructions = this.loadLocalInstructions(workingDirectory); + const localInstructions = loadLocalInstructions(workingDirectory); if (localInstructions) { parts.push(localInstructions); } @@ -1954,7 +1955,7 @@ export class SessionManager { systemParts.push(`\n--- Agent Definition: ${agentDef.name} ---\n${agentDef.content}`); } // Load AGENTS.local.md from target bot's workspace if present - const localInstructions = this.loadLocalInstructions(targetWorkspace); + const localInstructions = loadLocalInstructions(targetWorkspace); if (localInstructions) { systemParts.push(localInstructions); }