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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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>",
Expand Down Expand Up @@ -30,7 +30,9 @@
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"files": [
"dist"
],
"engines": {
"node": ">=20.0.0"
},
Expand Down
34 changes: 15 additions & 19 deletions src/agent-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { acquireLock } from "./lock.js"
import {
escapeRegex,
exists,
injectSectionContent,
timestampFilename,
writeTextAtomic,
} from "./prompt-utils.js"
Expand Down Expand Up @@ -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<!-- kasper: ${new Date().toISOString()} -->\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 = `<!-- kasper: ${new Date().toISOString()} -->\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
Expand Down
21 changes: 4 additions & 17 deletions src/agents-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { acquireLock } from "./lock.js"
import {
escapeRegex,
exists,
injectSectionContent,
parseTimestampFromFilename,
timestampFilename,
writeTextAtomic,
Expand Down Expand Up @@ -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 = `<!-- kasper: ${new Date().toISOString()} -->\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 {
Expand Down
52 changes: 52 additions & 0 deletions src/prompt-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- kasper: ISO -->` 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 = `<!-- kasper: ${now.toISOString()} -->\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(?:<!-- kasper:.*?-->\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 }
}
197 changes: 195 additions & 2 deletions tests/agent-prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,15 @@ 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",
)
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")
})

Expand All @@ -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(/<!-- kasper:/g) || []).length
expect(provenanceCount).toBe(1)
})

test("triple-apply still produces only ONE header (no accumulation bug)", async () => {
await manager.write("build", "## Rules\nold\n")
await manager.injectSection("build", "Rules", "a")
await manager.injectSection("build", "Rules", "b")
await manager.injectSection("build", "Rules", "c")
const content = await manager.read("build")
const headerCount = (content.match(/^## Rules/gm) || []).length
expect(headerCount).toBe(1)
expect(content).toContain("old")
expect(content).toContain("a")
expect(content).toContain("b")
expect(content).toContain("c")
})

test("preserves trailing newline when section is the last thing in the file", async () => {
await manager.write("build", "## My Section\nold")
await manager.injectSection("build", "My Section", "new")
const content = await manager.read("build")
expect(content.endsWith("\n")).toBe(true)
})

test("preserves content order: A < Target < B when target is in the middle", async () => {
await manager.write("build", "## A\naaa\n\n## Target\nold\n\n## B\nbbb")
await manager.injectSection("build", "Target", "new")
const content = await manager.read("build")
expect(content.indexOf("## A")).toBeGreaterThan(-1)
expect(content.indexOf("## Target")).toBeGreaterThan(
content.indexOf("## A"),
)
expect(content.indexOf("## B")).toBeGreaterThan(
content.indexOf("## Target"),
)
expect(content.indexOf("aaa")).toBeLessThan(content.indexOf("old"))
expect(content.indexOf("old")).toBeLessThan(content.indexOf("new"))
expect(content.indexOf("new")).toBeLessThan(content.indexOf("bbb"))
})

test("CRLF line endings: still produces only one header", async () => {
await manager.write(
"build",
"# Title\r\n\r\n## My Section\r\nold rule\r\n\r\n## Other\r\nstuff\r\n",
)
await manager.injectSection("build", "My Section", "new rule")
const content = await manager.read("build")
const headerCount = (content.match(/^## My Section/gm) || []).length
expect(headerCount).toBe(1)
expect(content).toContain("old rule")
expect(content).toContain("new rule")
})

test("5x repeated apply: exactly one header, exactly one provenance, all rules preserved", async () => {
await manager.write("build", "## Rules\nold\n")
for (let i = 0; i < 5; i++) {
await manager.injectSection("build", "Rules", `r${i}`)
}
const content = await manager.read("build")
expect((content.match(/^## Rules/gm) || []).length).toBe(1)
expect((content.match(/<!-- kasper:/g) || []).length).toBe(1)
expect(content).toContain("old")
for (let i = 0; i < 5; i++) {
expect(content).toContain(`r${i}`)
}
})
})

describe("backup and rollback", () => {
Expand Down
Loading
Loading