diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b2bd5..5821a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 `` 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 `` line directly above each new entry: -### Added + ## Kasper Inferred Instructions + old rule + + + rule 1 + + + rule 2 + + Migration is non-destructive: files written by older versions had a section-level `` 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 diff --git a/src/prompt-utils.ts b/src/prompt-utils.ts index bc90d44..29abd7e 100644 --- a/src/prompt-utils.ts +++ b/src/prompt-utils.ts @@ -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 `` 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 `` provenance comment + * recording when THIS entry was added). + * + * Shape after N applies: + * + * ## {sectionName} + * old rule + * + * + * rule added on the 15th + * + * + * rule added on the 16th + * + * Migration note: files written by older versions of kasper have a single + * section-level `` 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. * @@ -138,33 +154,37 @@ export function injectSectionContent( const sectionRegex = new RegExp( `((?:^|\\n)##\\s*${escapeRegex(sectionName)})[\\s\\S]*?(?=\\r?\\n##|$)`, ) - const provenance = `\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 = `\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(?:\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 } diff --git a/tests/agent-prompts.test.ts b/tests/agent-prompts.test.ts index cf76911..24d1886 100644 --- a/tests/agent-prompts.test.ts +++ b/tests/agent-prompts.test.ts @@ -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(/\nrule 1/) + expect(content).toMatch(/\nrule 2/) }) test("triple-apply still produces only ONE header (no accumulation bug)", async () => { @@ -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(/\\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(/ + // 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\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(/\nrule 1/) + expect(doc).toMatch(/\nrule 2/) }) test("triple-apply still produces only ONE header (no accumulation bug)", () => { diff --git a/tests/e2e/inject-accumulation.test.ts b/tests/e2e/inject-accumulation.test.ts index f8bab80..0b0a0f1 100644 --- a/tests/e2e/inject-accumulation.test.ts +++ b/tests/e2e/inject-accumulation.test.ts @@ -62,10 +62,6 @@ function countHeaders(content: string, sectionName: string): number { return (content.match(re) || []).length } -function countProvenanceLines(content: string): number { - return (content.match(//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( + `\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") diff --git a/tests/prompt-utils.test.ts b/tests/prompt-utils.test.ts index ee3193b..8142214 100644 --- a/tests/prompt-utils.test.ts +++ b/tests/prompt-utils.test.ts @@ -261,8 +261,9 @@ describe("injectSectionContent", () => { expect(r2.updated).toContain("rule 1") expect(r2.updated).toContain("rule 2") expect(countMatches(r2.updated, /^## Rules/gm)).toBe(1) - // Exactly ONE provenance line, not stacked - expect(countMatches(r2.updated, /` per apply. + // 2 applies here → 2 provenance lines (one for "rule 1", one for "rule 2"). + expect(countMatches(r2.updated, /\nsecond/) }) test("P) provenance line appears immediately after section header", () => { const existing = "## Rules\nfirst" const { updated } = injectSectionContent(existing, "Rules", "second", NOW) + // Per-addition shape: header → existing content → blank line → provenance → new content. const headerIdx = updated.indexOf("## Rules") + const existingIdx = updated.indexOf("first") const provIdx = updated.indexOf("\nrule A`), + ) + expect(doc).toMatch( + new RegExp(`\nrule B`), + ) + expect(doc).toMatch( + new RegExp(`\nrule C`), + ) + // Order is preserved (T1 before T2 before T3) + expect(doc.indexOf(T1.toISOString())).toBeLessThan( + doc.indexOf(T2.toISOString()), + ) + expect(doc.indexOf(T2.toISOString())).toBeLessThan( + doc.indexOf(T3.toISOString()), + ) }) - test("Q) repeated apply strips old provenance so it doesn't stack", () => { - // If the bug is reintroduced, every apply will add a provenance line at - // the top of the body, leading to N provenance lines after N applies. - let existing = "# Title\n\n## Rules\nold\n" - for (let i = 0; i < 5; i++) { - const r = injectSectionContent(existing, "Rules", `r${i}`, NOW) - existing = r.updated - } - expect(countMatches(existing, /` comment. + const afterHeader = doc.split("## Rules")[1] + expect(afterHeader).toMatch(/^\nold/) + }) + + test("T) migration: legacy file with section-level timestamp preserves it as legacy block timestamp", () => { + // Files written by older kasper versions have: + // ## Kasper Inferred Instructions + // + // + // The next apply must preserve the OLD_TS line and add a NEW provenance + // for the new entry — no destructive rewrite of the legacy block. + const OLD_TS = "2026-06-10T08:00:00.000Z" + const NEW_TS = new Date("2026-06-16T07:00:00.000Z") + const legacy = `# Project\n\n## Kasper Inferred Instructions\n\nold rule\n` + const result = injectSectionContent( + legacy, + "Kasper Inferred Instructions", + "new rule", + NEW_TS, + ).updated + + // Both timestamps are present + expect(result).toContain(OLD_TS) + expect(result).toContain(NEW_TS.toISOString()) + // Legacy block content is preserved + expect(result).toContain("old rule") + // New entry is present + expect(result).toContain("new rule") + // Only one header + expect(countMatches(result, /^## Kasper Inferred Instructions/gm)).toBe(1) + }) + + test("U) migration: a SECOND apply after migration uses per-addition for the new entry only", () => { + const T_OLD = "2026-06-10T08:00:00.000Z" + const T_NEW1 = new Date("2026-06-16T07:00:00.000Z") + const T_NEW2 = new Date("2026-06-16T08:00:00.000Z") + const legacy = `## Rules\n\nold\n` + let doc = injectSectionContent(legacy, "Rules", "first new", T_NEW1).updated + doc = injectSectionContent(doc, "Rules", "second new", T_NEW2).updated + + // The legacy timestamp is still there (it belongs to the legacy "old" block) + expect(doc).toContain(T_OLD) + // The two new entries each have their own timestamp + expect(countMatches(doc, /\nfirst new`), + ) + expect(doc).toMatch( + new RegExp(`\nsecond new`), + ) + }) + + test("V) per-addition: gap between header and first content stays constant across applies", () => { + // Regression for a body-normalization bug found while implementing this: + // if the body's leading newline isn't normalized, the gap between the + // section header and the first rule grows by 1 newline on every apply. + let doc = "## Rules\nold\n" + const apply = (s: string) => + injectSectionContent(s, "Rules", "x", NOW).updated + doc = apply(doc) + const after1 = doc + doc = apply(doc) + const after2 = doc + doc = apply(doc) + const after3 = doc + // The substring between `## Rules` and `old` should be the same in all 3. + const gap = (s: string) => (s.match(/## Rules([\s\S]*?)old/) || ["", ""])[1] + expect(gap(after1)).toBe(gap(after2)) + expect(gap(after2)).toBe(gap(after3)) }) })