diff --git a/apps/web/lib/markdown/normalize-cache.test.ts b/apps/web/lib/markdown/normalize-cache.test.ts index 89aca89859..27ef3bd40d 100644 --- a/apps/web/lib/markdown/normalize-cache.test.ts +++ b/apps/web/lib/markdown/normalize-cache.test.ts @@ -1,4 +1,10 @@ /* eslint-disable max-lines */ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkBreaks from "remark-breaks"; +import remarkGemoji from "remark-gemoji"; +import remarkGfm from "remark-gfm"; import { beforeEach, describe, expect, it } from "vitest"; import { __MAX_CACHE_ENTRIES, @@ -15,6 +21,96 @@ const INTRO_TEXT = "Intro:"; const INNER_PROMPT_TEXT = "nested prompt"; const AFTER_NESTED_PROMPT = "After nested prompt."; const SAMPLE_PROSE_TEXT = "some explanation"; +const INTRO_AND_RULE_TEXT = "Intro and rule"; +const SELF_CONTAINED_TEXT = "That is self-contained."; +const FIRST_FUNCTION_TEXT = "func first() {}"; +const SECOND_FUNCTION_TEXT = "func second() {}"; +const BARE_WRAPPED_DOCUMENT = [ + INTRO_AND_RULE_TEXT, + "", + "---", + "", + "```", + "Open a pull request.", + "## Why", + "```go", + FIRST_FUNCTION_TEXT, + "```", + "More prose.", + "```go", + SECOND_FUNCTION_TEXT, + "```", + "Final prose.", + "```", + "", + "---", + "", + SELF_CONTAINED_TEXT, +].join("\n"); +const BARE_WRAPPED_OUTER_OPEN_INDEX = 4; +const BARE_WRAPPED_FIRST_NESTED_OPEN_INDEX = 7; +const BARE_WRAPPED_FIRST_NESTED_CLOSE_INDEX = 9; +const BARE_WRAPPED_SECOND_NESTED_OPEN_INDEX = 11; +const BARE_WRAPPED_SECOND_NESTED_CLOSE_INDEX = 13; +const BARE_WRAPPED_FINAL_PROSE_INDEX = 14; +const BARE_WRAPPED_OUTER_CLOSE_INDEX = 15; +const BARE_WRAPPED_INDENTED_LINE_INDEXES = [ + 2, + BARE_WRAPPED_OUTER_OPEN_INDEX, + BARE_WRAPPED_OUTER_CLOSE_INDEX, + 17, +]; +const INDEPENDENT_TAGGED_BLOCKS = [ + "```go", + FIRST_FUNCTION_TEXT, + "```", + "This paragraph separates two examples.", + "```go", + SECOND_FUNCTION_TEXT, + "```", +].join("\n"); +const RULE_DELIMITED_BARE_BLOCK_WITHOUT_NESTED_FENCES = [ + INTRO_AND_RULE_TEXT, + "", + "---", + "", + "```", + "plain content", + "```", + "", + "---", + "", + SELF_CONTAINED_TEXT, +].join("\n"); +const RULE_BOUNDED_INDEPENDENT_BARE_BLOCKS = [ + "---", + "```", + "## Fence syntax", + "```go", + FIRST_FUNCTION_TEXT, + "```", + "This paragraph separates two examples.", + "```", + SECOND_FUNCTION_TEXT, + "```", + "---", +].join("\n"); +const FIVE_BACKTICK_LITERAL_DOCUMENT = ["`````text", BARE_WRAPPED_DOCUMENT, "`````"].join("\n"); +const TWO_BARE_WRAPPED_DOCUMENTS = [BARE_WRAPPED_DOCUMENT, BARE_WRAPPED_DOCUMENT].join("\n\n"); + +function renderNormalizedMarkdown(input: string): string { + return renderToStaticMarkup( + createElement( + ReactMarkdown, + { remarkPlugins: [remarkGfm, remarkBreaks, remarkGemoji] }, + normalizeMarkdown(input), + ), + ); +} + +function countOccurrences(input: string, value: string): number { + return input.split(value).length - 1; +} describe("normalizeMarkdown", () => { it("leaves a markdown wrapper without nested fences unchanged", () => { @@ -49,6 +145,127 @@ describe("normalizeMarkdown", () => { expect(normalizeMarkdown(input)).toBe(expected); }); + + it("leaves an ambiguous bare document between rules unchanged", () => { + expect(normalizeMarkdown(BARE_WRAPPED_DOCUMENT)).toBe(BARE_WRAPPED_DOCUMENT); + }); + + it("renders an ambiguous bare document according to Markdown boundaries", () => { + const rendered = renderNormalizedMarkdown(BARE_WRAPPED_DOCUMENT); + + expect(countOccurrences(rendered, "
")).toBe(3);
+ expect(countOccurrences(rendered, " {
+ it("preserves independent tagged blocks without rule boundaries", () => {
+ expect(normalizeMarkdown(INDEPENDENT_TAGGED_BLOCKS)).toBe(INDEPENDENT_TAGGED_BLOCKS);
+ });
+
+ it("renders independent tagged blocks as two code blocks", () => {
+ const rendered = renderNormalizedMarkdown(INDEPENDENT_TAGGED_BLOCKS);
+
+ expect(countOccurrences(rendered, "")).toBe(2);
+ expect(rendered).toContain("This paragraph separates two examples.");
+ });
+
+ it("preserves independent bare blocks inside rule boundaries", () => {
+ expect(normalizeMarkdown(RULE_BOUNDED_INDEPENDENT_BARE_BLOCKS)).toBe(
+ RULE_BOUNDED_INDEPENDENT_BARE_BLOCKS,
+ );
+ });
+
+ it("preserves a valid five-backtick code block containing bare wrapper syntax", () => {
+ expect(normalizeMarkdown(FIVE_BACKTICK_LITERAL_DOCUMENT)).toBe(FIVE_BACKTICK_LITERAL_DOCUMENT);
+ });
+
+ it("renders a valid five-backtick code block as literal text", () => {
+ const rendered = renderNormalizedMarkdown(FIVE_BACKTICK_LITERAL_DOCUMENT);
+
+ expect(countOccurrences(rendered, "")).toBe(1);
+ expect(countOccurrences(rendered, " {
+ const normalized = normalizeMarkdown(TWO_BARE_WRAPPED_DOCUMENTS);
+
+ expect(normalized).toBe(TWO_BARE_WRAPPED_DOCUMENTS);
+ expect(normalizeMarkdown(normalized)).toBe(TWO_BARE_WRAPPED_DOCUMENTS);
+ });
+
+ it("leaves a rule-delimited bare block without nested fences unchanged", () => {
+ expect(normalizeMarkdown(RULE_DELIMITED_BARE_BLOCK_WITHOUT_NESTED_FENCES)).toBe(
+ RULE_DELIMITED_BARE_BLOCK_WITHOUT_NESTED_FENCES,
+ );
+ });
+
+ it("preserves CRLF line endings in an unchanged bare wrapper", () => {
+ const input = BARE_WRAPPED_DOCUMENT.replaceAll("\n", "\r\n");
+
+ expect(normalizeMarkdown(input)).toBe(input);
+ });
+
+ it("preserves an ambiguous glued outer close while retaining CRLF line endings", () => {
+ const inputLines = BARE_WRAPPED_DOCUMENT.replaceAll("\n", "\r\n").split("\n");
+ inputLines.splice(BARE_WRAPPED_FINAL_PROSE_INDEX, 2, "Final prose.```\r");
+ const input = inputLines.join("\n");
+
+ expect(normalizeMarkdown(input)).toBe(input);
+ });
+
+ it("preserves leading and trailing blank lines", () => {
+ const input = `\n\n${BARE_WRAPPED_DOCUMENT}\n\n`;
+
+ expect(normalizeMarkdown(input)).toBe(input);
+ });
+
+ it("preserves three-space indentation on an unchanged bare wrapper", () => {
+ const input = BARE_WRAPPED_DOCUMENT.split("\n")
+ .map((line, index) =>
+ BARE_WRAPPED_INDENTED_LINE_INDEXES.includes(index) ? ` ${line}` : line,
+ )
+ .join("\n");
+
+ expect(normalizeMarkdown(input)).toBe(input);
+ });
+
+ it("preserves longer nested fences in an unchanged bare wrapper", () => {
+ const input = BARE_WRAPPED_DOCUMENT.split("\n");
+ input[BARE_WRAPPED_FIRST_NESTED_OPEN_INDEX] = "````go";
+ input[BARE_WRAPPED_FIRST_NESTED_CLOSE_INDEX] = "````";
+ input[BARE_WRAPPED_SECOND_NESTED_OPEN_INDEX] = "````go";
+ input[BARE_WRAPPED_SECOND_NESTED_CLOSE_INDEX] = "````";
+
+ expect(normalizeMarkdown(input.join("\n"))).toBe(input.join("\n"));
+ });
+
+ it("leaves a rule-delimited wrapper with no outer close unchanged", () => {
+ const input = BARE_WRAPPED_DOCUMENT.split("\n");
+ input.splice(BARE_WRAPPED_OUTER_CLOSE_INDEX, 1);
+
+ expect(normalizeMarkdown(input.join("\n"))).toBe(input.join("\n"));
+ });
+
+ it("is idempotent for preserved bare-wrapper inputs", () => {
+ for (const input of [
+ BARE_WRAPPED_DOCUMENT,
+ INDEPENDENT_TAGGED_BLOCKS,
+ RULE_DELIMITED_BARE_BLOCK_WITHOUT_NESTED_FENCES,
+ ]) {
+ const normalized = normalizeMarkdown(input);
+ expect(normalizeMarkdown(normalized)).toBe(normalized);
+ }
+ });
});
describe("normalizeMarkdown wrapper edge cases", () => {
diff --git a/docs/plans/bare-markdown-wrapper/analysis.md b/docs/plans/bare-markdown-wrapper/analysis.md
new file mode 100644
index 0000000000..8052a43865
--- /dev/null
+++ b/docs/plans/bare-markdown-wrapper/analysis.md
@@ -0,0 +1,172 @@
+# Bare-wrapper ambiguity analysis
+
+## Finding
+
+No content-only heuristic can guarantee both requested outcomes for every valid
+Markdown message. A bare code block can contain headings and tagged-looking fence
+lines as literal text. Those characters do not encode the author's wrapper intent.
+
+The work remains blocked. A thematic-break boundary does not establish wrapper
+intent: the same bounded range can contain two complete independent blocks. No
+safe content-only discriminator is available for this task.
+
+## Source evidence
+
+- `apps/web/lib/markdown/normalize-cache.ts`:
+ `strengthenMarkdownWrapperFence` rejects a bare first opener through
+ `MARKDOWN_WRAPPER_OPEN_RE`.
+- The same helper uses `firstNonBlankLineIndex` and `lastNonBlankLineIndex`.
+ Introductory and trailing prose also prevent detection.
+- `markdownWrapperInnerFenceInfo` is a heuristic scan, not evidence of author intent.
+ `FENCE_OPENER_LINE_RE` can match text inside an already open code block.
+- A boundary search without an enclosing-fence parser also sees rules and fence
+ candidates inside a valid five-backtick code block.
+- `apps/web/components/shared/memoized-markdown.tsx` passes
+ `normalizeCached(content)` directly to `ReactMarkdown`.
+- Existing tests preserve Markdown samples followed by separate blocks and
+ preserve tagged wrappers with trailing prose outside the wrapper.
+
+## Compact reproduction
+
+Construct the raw string from the following lines. `BT3` means exactly three
+backticks, and `BT3go` means those backticks immediately followed by `go`.
+`EMPTY` means an empty line. These labels are not part of the input.
+
+```text
+Copy everything between the lines.
+EMPTY
+---
+EMPTY
+BT3
+Open a pull request.
+## Why
+BT3go
+func first() {}
+BT3
+More prose.
+BT3go
+func second() {}
+BT3
+Final prose.
+BT3
+EMPTY
+---
+EMPTY
+That is self-contained.
+```
+
+Desired interpretation: one raw Markdown code block between the rules.
+Observed interpretation: three code blocks. The first ends after `func first()`.
+The second contains `func second()`. The third consumes the final rule and outro.
+
+The pure transform leaves this input unchanged. This establishes both the
+reported symptom and the missing interior-range behavior.
+
+## Counterexample A: two complete legitimate blocks
+
+```text
+BT3
+## Fence syntax
+BT3go
+func first() {}
+BT3
+This paragraph separates two examples.
+BT3
+func second() {}
+BT3
+```
+
+The first block shows a Markdown syntax fragment, including a heading and a
+literal tagged opener. It ends at the first pure fence after `func first()`.
+The paragraph is normal text. The second bare block contains `func second()`.
+Both blocks have explicit closing fences.
+
+This input satisfies all three suggested signals:
+
+1. Its first and last nonblank lines are matching bare fences.
+2. A same-length tagged opener and pure closer occur strictly between them.
+3. It contains an ATX heading and prose between code samples.
+
+Strengthening the outermost fences merges two intended code blocks and their
+separating paragraph. Adding the reproduction's introduction and rules does not
+remove this ambiguity from an interior-range detector.
+
+## Counterexample A2: rule-bounded independent blocks
+
+```text
+---
+BT3
+## Fence syntax
+BT3go
+func first() {}
+BT3
+This paragraph separates two examples.
+BT3
+func second() {}
+BT3
+---
+```
+
+This input has the proposed thematic-break boundaries, but it is also two
+complete bare code blocks separated by a paragraph. The first code block contains
+the literal heading and tagged opener. Strengthening the first and last fences
+would merge both blocks and the paragraph, so the boundary is not a safe repair
+signal.
+
+A valid five-backtick text block can contain the complete compact reproduction as
+literal content. A raw-line scan would find its rules and inner fences and rewrite
+bytes that Markdown already treats as code text.
+
+## Counterexample B: requiring two tagged pairs
+
+```text
+BT3
+## Fence syntax
+BT3go
+func first() {}
+BT3
+More prose.
+BT3go
+func second() {}
+BT3
+Final prose.
+BT3
+```
+
+Under GFM, this is a bare syntax sample, a separate Go block, and an empty final
+code block. A fence without an explicit closer extends to the end of the input.
+Such input is valid Markdown, including during streaming.
+
+The alternative intended interpretation is one wrapper containing two tagged
+examples. Both interpretations use exactly the same bytes. Requiring two tagged
+pairs or prose between them cannot distinguish those intentions.
+
+Headings in these examples are meaningful literal documentation. They are not
+evidence that sibling blocks are nonsensical. Natural-language introduction text
+and horizontal rules also occur in legitimate example documents.
+
+## Read-only verification
+
+Installed `normalizeMarkdown`, React, and `react-markdown` produced the following
+results on 2026-09-11:
+
+| Input | Normalizer changes bytes | Rendered code blocks |
+| --- | --- | --- |
+| Compact reproduction | No | 3 |
+| Counterexample A | No | 2 |
+| Counterexample B | No | 3 |
+| Two independent tagged Go blocks | No | 2 |
+
+For counterexample A, the emitted HTML has a paragraph between two `pre`
+elements. The first code text contains the literal heading and tagged opener.
+Counterexample B and the independent Go case also ran with all production remark
+plugins: GFM, breaks, and gemoji.
+
+## Disposition
+
+The proposed boundary contract is rejected. Counterexample A2 proves that it
+violates the preservation guarantee, and the five-backtick case proves that a
+boundary scan can rewrite literal code content. The normalizer therefore keeps
+the existing tagged-wrapper behavior and leaves bare wrapper candidates unchanged.
+The work order remains blocked until a producer marker, parser-level intent
+signal, or other product decision supplies information that raw Markdown lacks.
diff --git a/docs/plans/bare-markdown-wrapper/plan.md b/docs/plans/bare-markdown-wrapper/plan.md
new file mode 100644
index 0000000000..02de24cdaf
--- /dev/null
+++ b/docs/plans/bare-markdown-wrapper/plan.md
@@ -0,0 +1,172 @@
+---
+created: 2026-09-11
+status: blocked
+requirements:
+ - REQ-UI-COMMENT-MARKDOWN-001
+system_design:
+ - ../../specs/ui/system-design/comment-markdown.md
+legacy_specs: []
+---
+
+# Implementation Plan: Bare Markdown Wrappers
+
+## Overview
+
+This package records the requested repair, the counterexamples that make raw
+Markdown wrapper intent ambiguous, and the resulting blocked implementation.
+
+The existing normalizer remains unchanged for bare wrapper candidates. A future
+implementation needs a producer marker, parser-level intent signal, or another
+accepted product contract before it can safely repair the reported document.
+
+The [analysis](analysis.md) contains concrete counterexamples, renderer evidence,
+and the written-analysis fallback.
+
+## Requirement and design reconciliation
+
+UI owns reusable Markdown presentation independently of task or agent state.
+The existing [requirements](../../specs/ui/requirements/comment-markdown.md)
+define GFM rendering under `AC-UI-COMMENT-MARKDOWN-001.1` and `.2`.
+The existing [design](../../specs/ui/system-design/comment-markdown.md) names
+`MemoizedMarkdown` as the shared renderer.
+
+The existing requirements and design define tagged whole-message wrapper repair,
+but they do not define a safe raw-Markdown signal for a bare wrapper. The proposed
+thematic-break contract was rejected because it merges valid independent blocks
+and can rewrite literal content inside an enclosing code block. No new acceptance
+criterion or system-design behavior is recorded until that product decision is
+resolved.
+
+## Scope
+
+### In scope
+
+- Record the ambiguity and renderer evidence in the analysis.
+- Add regression coverage for the exact preservation cases.
+- Keep the existing normalizer behavior until a safe contract is accepted.
+
+### Out of scope
+
+- Any bare-wrapper repair without an explicit intent signal.
+- Automatic changes to message producers, renderer APIs, or stored messages.
+- New parser dependencies, cache keys, cache limits, or telemetry.
+- Changes to tagged-wrapper eligibility or existing trailing-prose behavior.
+- Commits, pushes, PRs, and delegated implementation.
+
+## Technical approach
+
+`normalizeMarkdown` calls `strengthenMarkdownWrapperFence` before its glued-close
+repair. Tagged wrappers keep their existing whole-message eligibility. Bare
+wrapper candidates remain unchanged because the raw input does not identify
+whether matching fences are a wrapper or separate Markdown blocks.
+
+The supplied reproduction has a bare opener and surrounding prose. The normalizer
+must preserve its current Markdown interpretation until a product-level signal
+resolves the ambiguity.
+
+Keep any future repair inside `apps/web/lib/markdown/normalize-cache.ts`. Reuse the
+existing tagged-wrapper path and do not weaken separate-block preservation. If a
+future contract is accepted, it must preserve indentation, surrounding prose,
+interior text, and newline behavior.
+
+`normalizeCached` remains a raw-input-string LRU with a hard cap of 500 entries.
+No task, session, message, or viewport information enters its key.
+
+## ASCII UI preview
+
+UI-01: Existing chat message, desktop and phone. The requested grouping remains
+blocked because the raw input has two valid interpretations.
+The count and content of code blocks are structural requirements, not pixel sizes.
+
+```text
+Current interpretation Requested but unresolved
+Intro and rule Intro and rule
+[code: first portion] [one code block:
+Middle prose heading + literal fences
+[code: second Go example] both Go examples + prose]
+Final prose Rule and outro
+[code: rule and outro]
+```
+
+The same ambiguity applies on desktop and phone. This utility introduces no
+controls, navigation, touch behavior, scroll ownership, or viewport branches. The
+mobile-parity normalization exception permits targeted renderer coverage in the
+existing unit test file. No new Playwright file is planned.
+
+## Tests
+
+All permanent cases belong in `apps/web/lib/markdown/normalize-cache.test.ts`.
+The tests preserve the ambiguous inputs and prove that valid code content remains
+literal.
+
+| Requested behavior | Evidence |
+| --- | --- |
+| Bare wrapper candidate with two tagged Go blocks and surrounding prose | Byte-identical normalization and rendered Markdown boundaries remain unchanged |
+| Two independent tagged Go blocks | Byte-identical normalization and exactly two rendered code blocks with prose between |
+| Existing tagged Markdown wrappers | Existing suite passes, including sample boundaries and trailing-prose exclusions |
+| Glued closing fences | Standalone `func f() {}` plus glued fence, nested glued closes, and glued wrapper closes retain existing results |
+| Idempotence | Normalize the two-region bare fixture twice and compare with the unchanged first result |
+| Raw-key bounded LRU | Existing hit, miss, overflow, and recency tests pass; assert cap equals 500 and distinct raw inputs remain distinct cache entries |
+| Ambiguous syntax samples | Rule-bounded independent blocks, five-backtick literal content, and variants remain byte-identical |
+
+Also cover CRLF, leading/trailing blank lines, indentation from zero to three
+spaces, longer runs, missing closes, and bare blocks without nested tags.
+Reuse fixture constants to respect duplicate-string limits.
+
+### Rendered verification
+
+Use React `createElement`, `react-dom/server`, and `react-markdown` with
+`remark-gfm`, `remark-breaks`, and `remark-gemoji` inside the existing `.test.ts`.
+Assert code text and surrounding elements, not only fence counts.
+This is a parser/render integration check, not a claim of full browser E2E coverage.
+It exercises the viewport-independent transformation used by desktop and phone.
+
+## Work orders
+
+- [ ] [Task 01: Resolve bare-wrapper eligibility](task-01-resolve-wrapper-eligibility.md) (`blocked`)
+
+There is one sequential work order.
+
+## Verification results
+
+Read-only renderer probes on 2026-09-11 found:
+
+- Compact reproduction: normalization unchanged, three code blocks.
+- Counterexample A: normalization unchanged, two separate code blocks.
+- Two independent tagged Go blocks: normalization unchanged, two code blocks.
+- Two nested-looking pairs: normalization unchanged, three code blocks.
+
+These probes used installed React and react-markdown packages. The latter two
+also used all three production remark plugins. Permanent regression tests cover
+the preserved interpretations in the existing normalizer test file.
+Documentation validation on 2026-09-11:
+
+- `rtk proxy python3 scripts/lint-spec-files.test.py`: 36 tests passed.
+- `rtk proxy python3 scripts/lint-spec-files.py --all`: all specification files passed.
+- `rtk git diff --check -- docs/plans/bare-markdown-wrapper`: passed.
+- `rtk git status --short -- docs/plans/bare-markdown-wrapper`: new package present, untracked.
+
+Verification after restoring the safe boundary on 2026-09-12:
+
+- `pnpm --filter @kandev/web test -- --run lib/markdown/normalize-cache.test.ts`: 52 tests passed.
+- `pnpm --filter @kandev/web lint`: passed with zero warnings.
+- `pnpm --filter @kandev/web typecheck`: passed.
+- `pnpm --filter @kandev/web build:vite`: passed. Vite emitted existing chunk-size and ineffective dynamic-import warnings.
+- `python3 scripts/lint-spec-files.test.py`: 36 tests passed.
+- `python3 scripts/lint-spec-files.py --all`: all specification files passed.
+- `git diff --check`: passed.
+
+The full `pnpm --filter @kandev/web test` suite was started with the configured
+worker budget but produced no terminal report after 13 minutes, so it was
+stopped. The focused suite completed independently with a captured exit code of
+zero.
+
+The package remains uncommitted and blocked pending a product-level intent signal.
+
+## Risks and unresolved decision
+
+The thematic-break contract cannot be accepted as a compatibility boundary. A
+message with the same bytes can represent one wrapper, two independent blocks, or
+literal content inside a larger fence. Correct longer outer fences or an explicit
+producer marker remain possible future solutions, but producer changes remain
+outside this task.
diff --git a/docs/plans/bare-markdown-wrapper/task-01-resolve-wrapper-eligibility.md b/docs/plans/bare-markdown-wrapper/task-01-resolve-wrapper-eligibility.md
new file mode 100644
index 0000000000..e65f52f63c
--- /dev/null
+++ b/docs/plans/bare-markdown-wrapper/task-01-resolve-wrapper-eligibility.md
@@ -0,0 +1,121 @@
+---
+id: "01-resolve-wrapper-eligibility"
+title: "Resolve bare-wrapper eligibility"
+status: blocked
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-UI-COMMENT-MARKDOWN-001
+acceptance_criteria:
+ - AC-UI-COMMENT-MARKDOWN-001.1
+ - AC-UI-COMMENT-MARKDOWN-001.2
+system_design:
+ - ../../specs/ui/system-design/comment-markdown.md
+---
+
+# Task 01: Resolve bare-wrapper eligibility
+
+## Summary
+
+Resolve whether a bare wrapper can be repaired safely. The current analysis shows
+that raw Markdown does not carry enough intent information, so the work order
+records the preservation fallback and remains blocked.
+
+## In scope
+
+- Use the [analysis](analysis.md) to preserve the ambiguous cases.
+- Add the exact rule-bounded, enclosing-fence, and multi-region regressions.
+- Keep the existing normalizer behavior until a safe contract is accepted.
+
+## Out of scope
+
+- Adding a heuristic that narrows the separate-block preservation guarantee.
+- Defining a new requirement or system-design contract without a product decision.
+- Changing cache semantics, Markdown plugins, or renderer components.
+- New permanent test files or dependency changes.
+
+## Acceptance
+
+1. The analysis records why the reported input is ambiguous and names the missing
+ intent signal.
+2. The normalizer leaves the exact negative cases byte-identical, including valid
+ code content enclosed by a five-backtick fence and two disjoint regions.
+3. The plan and work order remain blocked until a product-level contract makes a
+ safe repair possible.
+
+## ASCII UI preview
+
+UI-01 uses the [shared desktop/phone preview](plan.md#ascii-ui-preview).
+
+```text
+Requested: intro -> one raw document code block -> outro
+Current safe behavior: first code block -> prose -> second code block
+```
+
+Content grouping supports `AC-UI-COMMENT-MARKDOWN-001.1` and `.2`.
+No layout or mobile interaction changes are proposed.
+
+## Verification
+
+Run documentation commands from the repository root after revising the package:
+
+```bash
+rtk proxy python3 scripts/lint-spec-files.test.py
+rtk proxy python3 scripts/lint-spec-files.py --all
+rtk git diff --check -- docs/specs docs/plans/bare-markdown-wrapper
+rtk git status --short -- docs/plans/bare-markdown-wrapper
+```
+
+Use TDD in the existing test file. First demonstrate each unsafe repair candidate
+fails its preservation regression. Then run this complete block from the repository
+root:
+
+```bash
+(cd apps && rtk pnpm install --frozen-lockfile)
+(cd apps && rtk pnpm --filter @kandev/web test -- normalize-cache)
+(cd apps && rtk pnpm --filter @kandev/web lint)
+rtk git diff --check
+```
+
+Installation is required once in a fresh worktree. Do not override the configured
+Vitest worker budget or its `NODE_ENV=test` setting. Changed-line warnings fail
+acceptance. Preserve the supplied frontend complexity and size limits.
+
+## Files in this package
+
+- `docs/plans/bare-markdown-wrapper/analysis.md`
+- `docs/plans/bare-markdown-wrapper/plan.md`
+- `docs/plans/bare-markdown-wrapper/task-01-resolve-wrapper-eligibility.md`
+- `apps/web/lib/markdown/normalize-cache.test.ts`
+
+## Dependencies
+
+An accepted producer marker, parser-level intent signal, or product decision is
+required. No dependency work order exists.
+
+## Risks
+
+The existing inner scan cannot establish author intent even inside the proposed
+boundary. Avoid changing existing tagged-wrapper behavior, and preserve separate
+bare blocks plus literal content inside enclosing fences.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirements](../../specs/ui/requirements/comment-markdown.md)
+- [System design](../../specs/ui/system-design/comment-markdown.md)
+- `apps/web/lib/markdown/normalize-cache.ts`
+- [Counterexamples and source evidence](analysis.md)
+- Existing normalizer helpers and tests, especially wrapper unchanged boundaries.
+- The user's explicit implementation request and the written-analysis fallback.
+
+## Results
+
+The rule-delimited repair was rejected after the exact preservation counterexample
+and enclosing-fence case failed. The production normalizer remains unchanged for
+bare candidates. Preservation regressions and final validation results are recorded
+in the plan; the work order is blocked pending a safe intent signal.