diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ac7c7..74b2bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## [1.1.2] - 2026-06-16 + +A patch release. Fixes a regression introduced in 1.1.0 where successive `/kasper apply` invocations on a project whose `AGENTS.md` (or agent prompt) had a `# Title` and intro paragraph BEFORE the `## Kasper Inferred Instructions` section produced nested `## Kasper Inferred Instructions` headers and lost earlier improvements on every apply. + +### Fixed + +- **`injectSection` accumulation broke when the target section was preceded by other content** — `agents-md.ts:injectSection` and `agent-prompts.ts:injectSection` shared a `bodyStrip` regex anchored at `^##` that required a literal `\r?\n` after the header name. `sectionRegex` captured a leading `\n` via `(?:^|\n)##...` whenever the section was not at the start of the file, so `match[0]` started with `\n## Section` and `bodyStrip.replace()` was a no-op. The un-stripped header was then re-emitted by the subsequent `existing.replace(sectionRegex, ...)`, producing a nested duplicate header on every apply. Real-world AGENTS.md / agent-prompt files always start with a `# Title` and intro paragraph, so the bug was triggered on first use for every user. The fix extracts `injectSectionContent()` as a pure helper in `src/prompt-utils.ts` that uses `match[0].slice(match[1].length)` to extract the body (robust to leading newlines, missing-newline-at-EOF, and CRLF line endings) and strips the optional existing provenance line so it does not stack on repeated applies. Both managers now delegate to the helper, eliminating the duplicated buggy logic. The helper always produces a file that ends with a single trailing newline. + +### Added + +- **End-to-end regression test** — `tests/e2e/inject-accumulation.test.ts` (run with `OPENCODE_E2E=1 bun test tests/e2e/inject-accumulation.test.ts`) exactly reproduces the bug-report steps: a realistic AGENTS.md with `# My Project` + intro + `## Kasper Inferred Instructions` + `## Conventions`, three `injectSection` calls back-to-back (mirroring three `/kasper apply` invocations), and an assertion that the file ends with exactly ONE `## Kasper Inferred Instructions` header and contains all three improvements. The test fails on the original buggy code with `Expected: 1, Received: 4`, proving it would have caught the original bug. A parallel test exercises the same scenario on `AgentPromptManager` with a YAML-frontmatter agent prompt, and a third covers the freshly-created-file path. The 1.1.0 release's unit tests covered only the case where the target section was the first thing in the file, which is why the bug slipped through. + ## [1.1.1] - 2026-06-11 A patch release. Fixes a false-positive on every startup, removes a startup-time hang in the worst case, and corrects a misleading example in the README. diff --git a/package.json b/package.json index 34bebb7..2b7e173 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@atonev/opencode-kasper", - "version": "1.1.1", + "version": "1.1.2", "type": "module", "description": "OpenCode plugin that monitors agent sessions, scores adherence to user instructions via LLM-as-judge, and injects corrective instructions into AGENTS.md and per-agent prompt files.", "author": "andrejtonev <29177572+andrejtonev@users.noreply.github.com>", @@ -30,7 +30,9 @@ "types": "./dist/index.d.ts" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, diff --git a/src/agent-prompts.ts b/src/agent-prompts.ts index fbca29c..a497bcb 100644 --- a/src/agent-prompts.ts +++ b/src/agent-prompts.ts @@ -9,6 +9,7 @@ import { acquireLock } from "./lock.js" import { escapeRegex, exists, + injectSectionContent, timestampFilename, writeTextAtomic, } from "./prompt-utils.js" @@ -278,27 +279,22 @@ export class AgentPromptManager { let updated: string if (injectMode === "inline") { updated = appendInlineImprovement(existing, content.trim()) + } else if (!existing.trim()) { + // Empty file: create with frontmatter + a fresh section. + const sectionBlock = `## ${sectionName}\n\n${content.trim()}\n` + const frontmatter = `---\nmode: ${mode}\n---\n\n` + updated = `${frontmatter + sectionBlock}\n` } else { - const header = `## ${sectionName}` - const sectionRegex = new RegExp( - `((?:^|\\n)##\\s*${escapeRegex(sectionName)})[\\s\\S]*?(?=\\r?\\n##|$)`, + // Section-mode on a non-empty file: delegate to the shared helper. + // The helper handles accumulation when the section already exists and + // appends a new section when it does not. In both cases the file ends + // with a trailing newline. + const result = injectSectionContent( + existing, + sectionName, + content.trim(), ) - const provenance = `\n` - const sectionBlock = `${header}\n${provenance}${content.trim()}\n` - - if (!existing.trim()) { - const frontmatter = `---\nmode: ${mode}\n---\n\n` - updated = `${frontmatter + sectionBlock}\n` - } else if (sectionRegex.test(existing)) { - updated = existing.replace( - sectionRegex, - `$1\n${provenance}${content.trim()}`, - ) - } else { - const eofSection = `${sectionBlock}\n` - const trimmed = existing.trimEnd() - updated = trimmed ? `${trimmed}\n\n${eofSection}` : eofSection - } + updated = result.updated } let backupPath: string | undefined diff --git a/src/agents-md.ts b/src/agents-md.ts index 77058ce..573ecc3 100644 --- a/src/agents-md.ts +++ b/src/agents-md.ts @@ -11,6 +11,7 @@ import { acquireLock } from "./lock.js" import { escapeRegex, exists, + injectSectionContent, parseTimestampFromFilename, timestampFilename, writeTextAtomic, @@ -170,23 +171,9 @@ export class AgentsMdManager { sectionName: string, content: string, ): string { - const header = this.sectionHeader(sectionName) - const sectionRegex = new RegExp( - `((?:^|\\n)##\\s*${escapeRegex(sectionName)})[\\s\\S]*?(?=\\r?\\n##|$)`, - ) - const provenance = `\n` - const sectionBlock = `${header}\n${provenance}${content.trim()}\n` - - if (sectionRegex.test(existing)) { - return existing.replace( - sectionRegex, - `$1\n${provenance}${content.trim()}`, - ) - } - - const eofSection = `${sectionBlock}\n` - const trimmed = existing.trimEnd() - return trimmed ? `${trimmed}\n\n${eofSection}` : eofSection + // Delegates to the shared helper. Kept as a method on AgentsMdManager + // because the existing public API is `(existing, sectionName, content)`. + return injectSectionContent(existing, sectionName, content).updated } removeSection(existing: string, sectionName: string): string { diff --git a/src/prompt-utils.ts b/src/prompt-utils.ts index 268695d..bc90d44 100644 --- a/src/prompt-utils.ts +++ b/src/prompt-utils.ts @@ -117,3 +117,55 @@ export async function writeTextAtomic( : new Error(String(lastError ?? "atomic write failed")) }) } + +/** + * Inject content into a markdown section. If the section already exists, the + * existing body is preserved and the new content is appended after a blank + * line. A provenance comment `` is always written directly + * after the section header so the section's "last updated" timestamp is + * visible without scanning the body. + * + * Always produces a file that ends with a single trailing newline. + * + * Pure function (no I/O) so it is trivially testable. + */ +export function injectSectionContent( + existing: string, + sectionName: string, + newContent: string, + now: Date = new Date(), +): { updated: string; existed: boolean } { + const sectionRegex = new RegExp( + `((?:^|\\n)##\\s*${escapeRegex(sectionName)})[\\s\\S]*?(?=\\r?\\n##|$)`, + ) + const provenance = `\n` + + const match = existing.match(sectionRegex) + if (match) { + // match[1] is the captured header (including the optional leading \n). + // Slice it off the front of match[0] to get the body — this is robust to + // the body starting with a newline (when the section is not at the start + // of the file) or directly after the header (EOF case). + const headerMatched = match[1] + const body = match[0].slice(headerMatched.length) + // Strip the optional provenance line at the start of the body so we + // don't stack timestamps on every apply. + const bodyStripped = body.replace(/^\r?\n(?:\r?\n)?/, "") + const existingBody = bodyStripped.trim() + const finalContent = existingBody + ? `${existingBody}\n\n${newContent.trim()}` + : newContent.trim() + const updated = existing.replace( + sectionRegex, + `${headerMatched}\n${provenance}${finalContent}\n`, + ) + return { updated, existed: true } + } + + // Section does not exist — append it at the end of the file. + const header = `## ${sectionName}` + const sectionBlock = `${header}\n${provenance}${newContent.trim()}\n` + const trimmed = existing.trimEnd() + const updated = trimmed ? `${trimmed}\n\n${sectionBlock}` : sectionBlock + return { updated, existed: false } +} diff --git a/tests/agent-prompts.test.ts b/tests/agent-prompts.test.ts index 671e442..cf76911 100644 --- a/tests/agent-prompts.test.ts +++ b/tests/agent-prompts.test.ts @@ -68,7 +68,7 @@ describe("AgentPromptManager", () => { expect(content).toContain("be faster") }) - test("replaces existing section with same name", async () => { + test("accumulates content in existing section", async () => { await manager.write( "build", "## Kasper Rules\nold rule\n\n## Other\nstuff", @@ -76,7 +76,7 @@ describe("AgentPromptManager", () => { await manager.injectSection("build", "Kasper Rules", "new rule") const content = await manager.read("build") expect(content).toContain("new rule") - expect(content).not.toContain("old rule") + expect(content).toContain("old rule") expect(content).toContain("## Other") }) @@ -87,6 +87,199 @@ describe("AgentPromptManager", () => { expect(content).toContain("## Other") expect(content).toContain("## New Section") }) + + test("preserves existing section body and adds new content", async () => { + await manager.write( + "build", + "## Kasper Rules\nold rule\n\n## Other\nstuff", + ) + await manager.injectSection( + "build", + "Kasper Rules", + "new rule", + true, // backupEnabled + 20, // maxBackups + "subagent", // mode + "section", // injectMode + ) + const content = await manager.read("build") + expect(content).toContain("old rule") + expect(content).toContain("new rule") + expect(content).toContain("## Other") + }) + + test("accumulates multiple improvements", async () => { + await manager.write("build", "") + await manager.injectSection( + "build", + "Rules", + "rule 1", + true, + 20, + "subagent", + "section", + ) + await manager.injectSection( + "build", + "Rules", + "rule 2", + true, + 20, + "subagent", + "section", + ) + const content = await manager.read("build") + expect(content).toContain("rule 1") + expect(content).toContain("rule 2") + }) + + test("default call accumulates existing content", async () => { + await manager.write( + "build", + "## Kasper Rules\nold rule\n\n## Other\nstuff", + ) + await manager.injectSection("build", "Kasper Rules", "new rule") + const content = await manager.read("build") + expect(content).toContain("new rule") + expect(content).toContain("old rule") + }) + + // ---- Regression tests for the critical regex-anchoring bug (Issue #1) ---- + + test("does NOT produce nested headers when section is preceded by frontmatter", async () => { + // The most common real-world case for an agent prompt file: it + // starts with YAML frontmatter and a description. + const frontmatter = [ + "---", + "description: My agent", + "mode: subagent", + "---", + "", + "# My Agent", + "", + "Follow these rules:", + " 1. Be polite", + " 2. Be thorough", + "", + "## Kasper Inferred Instructions", + "old rule", + "", + ].join("\n") + await manager.write("build", frontmatter) + await manager.injectSection( + "build", + "Kasper Inferred Instructions", + "new rule", + ) + const content = await manager.read("build") + // Critical: exactly ONE ## Kasper Inferred Instructions header + const headerCount = ( + content.match(/^## Kasper Inferred Instructions/gm) || [] + ).length + expect(headerCount).toBe(1) + expect(content).toContain("old rule") + expect(content).toContain("new rule") + // Frontmatter and pre-section content preserved + expect(content).toContain("description: My agent") + expect(content).toContain("Follow these rules:") + expect(content).toContain("Be polite") + // Chronological order + expect(content.indexOf("old rule")).toBeLessThan( + content.indexOf("new rule"), + ) + }) + + test("does NOT produce nested headers when section is preceded by # Title and intro", async () => { + const existing = + "# My Build Agent\n\nIntro paragraph.\n\n## Kasper Inferred Instructions\nold rule\n\n## Other\nstuff" + await manager.write("build", existing) + await manager.injectSection( + "build", + "Kasper Inferred Instructions", + "new rule", + ) + const content = await manager.read("build") + const headerCount = ( + content.match(/^## Kasper Inferred Instructions/gm) || [] + ).length + expect(headerCount).toBe(1) + expect(content).toContain("Intro paragraph.") + expect(content).toContain("old rule") + expect(content).toContain("new rule") + }) + + test("double-apply produces only ONE provenance line (no stacking)", async () => { + await manager.write("build", "# Title\n\nintro\n\n## Rules\nold\n") + await manager.injectSection("build", "Rules", "rule 1") + await manager.injectSection("build", "Rules", "rule 2") + const content = await manager.read("build") + const provenanceCount = (content.match(/