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
21 changes: 16 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@ 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.
A patch release. Builds on the `injectSection` accumulation fix (see the prior commit on the `fix/injectSection-accumulate` branch) by changing the `<!-- kasper: ISO -->` provenance comment from a single section-level timestamp to a per-addition timestamp attached directly above each new entry.

### Fixed
### Changed

- **`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.
- **Provenance comments are now per-addition, not section-level** — `injectSectionContent()` used to write a single `<!-- kasper: ISO -->` line directly under the section header on every apply, overwriting the previous timestamp. The most recent improvement's timestamp therefore masqueraded as the section's creation time, which made it impossible to tell when each individual rule was added by reading just the file. The new shape attaches a `<!-- kasper: ISO -->` line directly above each new entry:

### Added
## Kasper Inferred Instructions
old rule

<!-- kasper: 2026-06-15T10:00:00Z -->
rule 1

<!-- kasper: 2026-06-16T07:00:00Z -->
rule 2

Migration is non-destructive: files written by older versions had a section-level `<!-- kasper: ISO -->` line under the header. The new helper preserves that line verbatim (it now reads as the timestamp for the pre-existing rules block above it) and attaches new entries with their own per-addition timestamp from then on. Regression tests `U) migration: a SECOND apply after migration uses per-addition for the new entry only` and `V) per-addition: gap between header and first content stays constant across applies` in `tests/prompt-utils.test.ts` cover the migration case and the body-normalization fix that was caught during this change.

### Notes

- **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.
- The accumulation fix itself (slice-based body extraction, no nested headers) is on the `fix/injectSection-accumulate` branch. This branch assumes that fix is already in place — `fix/per-addition-provenance` is intended to be merged AFTER `fix/injectSection-accumulate`.

## [1.1.1] - 2026-06-11

Expand Down
52 changes: 36 additions & 16 deletions src/prompt-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,26 @@ export async function writeTextAtomic(

/**
* 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.
* existing body is preserved and the new content is appended as a new entry
* (preceded by a blank line and a `<!-- kasper: ISO -->` provenance comment
* recording when THIS entry was added).
*
* Shape after N applies:
*
* ## {sectionName}
* old rule
*
* <!-- kasper: 2026-06-15T10:00:00Z -->
* rule added on the 15th
*
* <!-- kasper: 2026-06-16T07:00:00Z -->
* rule added on the 16th
*
* Migration note: files written by older versions of kasper have a single
* section-level `<!-- kasper: ISO -->` line directly under the header. That
* line is preserved verbatim (it now reads as the timestamp for the
* pre-existing rules block). New applies attach their own per-entry
* provenance line as described above.
*
* Always produces a file that ends with a single trailing newline.
*
Expand All @@ -138,33 +154,37 @@ export function injectSectionContent(
const sectionRegex = new RegExp(
`((?:^|\\n)##\\s*${escapeRegex(sectionName)})[\\s\\S]*?(?=\\r?\\n##|$)`,
)
Comment on lines +154 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Section header prefix match 🐞 Bug ≡ Correctness

injectSectionContent() matches ## <sectionName> without requiring end-of-line, so injecting into
e.g. "Rules" can accidentally match and rewrite a different heading like "## Ruleset", corrupting
the section header/body and appending content to the wrong place.
Agent Prompt
## Issue description
`injectSectionContent()` uses a section regex that matches the provided `sectionName` as a prefix of a heading line. This can cause the function to select and rewrite the wrong section (and even mutate the heading text) when headings share prefixes (e.g., `## Rules` vs `## Ruleset`).

## Issue Context
The helper is now the shared implementation for both AGENTS.md and agent prompt injections, so this bug becomes a centralized correctness risk.

## Fix Focus Areas
- src/prompt-utils.ts[154-180]
- src/agents-md.ts[179-183]

## Suggested fix
- Update the section-regex to match the *entire* header line for the target section name (e.g., require `\s*(?:\r?\n|$)` after the escaped name, or use a `^...$` multiline header match) so `Rules` cannot match `Ruleset`.
- Ensure the capture group used as `headerMatched` includes the full header line (ideally including its line ending) so the body slicing/rebuild remains correct.
- Apply the same “exact header” rule to `removeSection()` for consistency.
- Add a regression test covering prefix-collision (existing `## Ruleset` should not be modified when injecting into sectionName `Rules`; a new `## Rules` section should be created instead).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const provenance = `<!-- kasper: ${now.toISOString()} -->\n`
// Per-addition provenance. We attach this to each new entry, not to the
// section header, so a future reader can see WHEN each rule was added.
const entry = `<!-- kasper: ${now.toISOString()} -->\n${newContent.trim()}\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).
// of the file) or directly after the header (EOF case). Any pre-existing
// section-level provenance line from older kasper versions is preserved
// verbatim as part of `body`.
const headerMatched = match[1]
// body is everything after the header line. It may start with \n and end
// with trailing whitespace. Normalize it to "just the content" so we can
// rebuild a stable shape every apply (otherwise the gap between header
// and the first rule grows by 1 newline on every apply).
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 bodyContent = body.replace(/^[\r\n]+|[\r\n]+$/g, "")
const finalContent = bodyContent ? `${bodyContent}\n\n${entry}` : entry
const updated = existing.replace(
sectionRegex,
`${headerMatched}\n${provenance}${finalContent}\n`,
`${headerMatched}\n${finalContent}`,
)
return { updated, existed: true }
}

// Section does not exist — append it at the end of the file.
// Section does not exist — create it with a single per-entry provenance
// line, identical in shape to the accumulate case.
const header = `## ${sectionName}`
const sectionBlock = `${header}\n${provenance}${newContent.trim()}\n`
const sectionBlock = `${header}\n\n${entry}`
const trimmed = existing.trimEnd()
const updated = trimmed ? `${trimmed}\n\n${sectionBlock}` : sectionBlock
return { updated, existed: false }
Expand Down
73 changes: 69 additions & 4 deletions tests/agent-prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,17 @@ describe("AgentPromptManager", () => {
expect(content).toContain("new rule")
})

test("double-apply produces only ONE provenance line (no stacking)", async () => {
test("double-apply produces ONE provenance line per apply (per-addition provenance)", 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")
// Per-addition provenance: 2 applies → 2 provenance lines.
const provenanceCount = (content.match(/<!-- kasper:/g) || []).length
expect(provenanceCount).toBe(1)
expect(provenanceCount).toBe(2)
// Each provenance line should appear IMMEDIATELY before its entry's content.
expect(content).toMatch(/<!-- kasper:[^>]+-->\nrule 1/)
expect(content).toMatch(/<!-- kasper:[^>]+-->\nrule 2/)
})

test("triple-apply still produces only ONE header (no accumulation bug)", async () => {
Expand Down Expand Up @@ -267,19 +271,80 @@ describe("AgentPromptManager", () => {
expect(content).toContain("new rule")
})

test("5x repeated apply: exactly one header, exactly one provenance, all rules preserved", async () => {
test("5x repeated apply: exactly one header, 5 provenance lines, 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)
// Per-addition provenance: 5 applies → 5 provenance lines.
expect((content.match(/<!-- kasper:/g) || []).length).toBe(5)
expect(content).toContain("old")
for (let i = 0; i < 5; i++) {
expect(content).toContain(`r${i}`)
// Each rule has its own provenance line directly above it.
expect(content).toMatch(new RegExp(`<!-- kasper:[^>]+-->\\nr${i}\\b`))
}
})

test("per-addition: each entry's timestamp is directly above its content", async () => {
await manager.write(
"build",
"---\nmode: subagent\n---\n\n# Build\n\n## Kasper Inferred Instructions\nold rule\n",
)
// No way to inject different timestamps via the public API (it uses
// `new Date()`), so we just verify the SHAPE: provenance directly
// above each entry, in order, no section-level provenance.
await manager.injectSection(
"build",
"Kasper Inferred Instructions",
"rule A",
)
await manager.injectSection(
"build",
"Kasper Inferred Instructions",
"rule B",
)
await manager.injectSection(
"build",
"Kasper Inferred Instructions",
"rule C",
)
const content = await manager.read("build")
// 3 provenance lines, all at entry-level
expect((content.match(/<!-- kasper:/g) || []).length).toBe(3)
// No provenance line directly under the section header (the old
// section-level format had this; the new per-addition format does not).
const afterHeader = content.split("## Kasper Inferred Instructions")[1]
expect(afterHeader).toMatch(/^\nold rule/)
})

test("migration: legacy section-level timestamp is preserved on the next apply", async () => {
// A file written by the previous version of kasper has:
// ## Kasper Inferred Instructions
// <!-- kasper: OLD_TS -->
// old rule
// The new helper must preserve the OLD_TS line and append a new
// per-addition provenance for the new entry — no destructive rewrite.
const OLD_TS = "2026-06-10T08:00:00.000Z"
await manager.write(
"build",
`## Kasper Inferred Instructions\n<!-- kasper: ${OLD_TS} -->\nold rule\n`,
)
await manager.injectSection(
"build",
"Kasper Inferred Instructions",
"new rule",
)
const content = await manager.read("build")
// Legacy timestamp is preserved
expect(content).toContain(OLD_TS)
// New entry has its own provenance
expect((content.match(/<!-- kasper:/g) || []).length).toBe(2)
expect(content).toContain("old rule")
expect(content).toContain("new rule")
})
})

describe("backup and rollback", () => {
Expand Down
8 changes: 6 additions & 2 deletions tests/agents-md.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,16 @@ describe("AgentsMdManager", () => {
expect(totalHeaders).toBe(3)
})

test("double-apply produces only ONE provenance line (no stacking)", () => {
test("double-apply produces ONE provenance line per apply (per-addition provenance)", () => {
let doc = "# Title\n\nintro\n\n## Rules\nold\n"
doc = manager.injectSection(doc, "Rules", "rule 1")
doc = manager.injectSection(doc, "Rules", "rule 2")
// Per-addition provenance: 2 applies → 2 provenance lines (one per entry).
const provenanceCount = (doc.match(/<!-- kasper:/g) || []).length
expect(provenanceCount).toBe(1)
expect(provenanceCount).toBe(2)
// Each provenance line should appear IMMEDIATELY before its entry's content.
expect(doc).toMatch(/<!-- kasper:[^>]+-->\nrule 1/)
expect(doc).toMatch(/<!-- kasper:[^>]+-->\nrule 2/)
})

test("triple-apply still produces only ONE header (no accumulation bug)", () => {
Expand Down
21 changes: 15 additions & 6 deletions tests/e2e/inject-accumulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,6 @@ function countHeaders(content: string, sectionName: string): number {
return (content.match(re) || []).length
}

function countProvenanceLines(content: string): number {
return (content.match(/<!-- kasper:/g) || []).length
}

describe.skipIf(!ENABLED)(
"e2e: injectSection accumulation — reproduces issue, verifies fix",
() => {
Expand Down Expand Up @@ -139,8 +135,21 @@ describe.skipIf(!ENABLED)(
expect(after).toContain(imp)
}

// ── Tertiary: only ONE provenance line, not stacked.
expect(countProvenanceLines(after)).toBe(1)
// ── Tertiary: per-addition provenance — ONE line per apply, in order,
// each directly above the entry it belongs to. (3 applies → 3 lines.)
const provenanceLines = after.match(/<!-- kasper: [^>]+-->/g) || []
expect(provenanceLines.length).toBe(3)
for (const imp of threeImprovements) {
// Each rule must have a provenance line directly above it in the file.
const re = new RegExp(
`<!-- kasper: [^>]+-->\n${imp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
)
expect(after).toMatch(re)
}
// No section-level provenance directly under the header (the OLD format
// had this; the new per-addition format does not).
const afterHeader = after.split("## Kasper Inferred Instructions")[1]
expect(afterHeader).toMatch(/^\nold improvement/)

// ── Quaternary: order is chronological (oldest first, newest last).
const idxOld = after.indexOf("old improvement from a prior apply")
Expand Down
Loading
Loading