fix(plan): heal collapsed single-line plan bodies

The Plan pane could render an entire plan document as one giant H1 with
`##` markers showing inline (e.g. "…plan pack)## The goal, restatedYou
picked…") when a producer wrote the plan document body with its
paragraph/heading newlines collapsed onto a single line.

Two layers:

- Producer (source): the plan-authoring guidance modeled the body as a
  single-line `"# Plan\n\n…"` template, inviting flattened bodies. Add
  explicit formatting rules (each heading on its own line, blank line
  between blocks, JSON-safe encoding) and a multi-section example.
  Reinforce the same rule in the onboarding publish contract.

- Write-path guard (defense-in-depth): multilineTextSchema now runs
  normalizeMidLineHeadings after escaped-break normalization. It inserts
  a paragraph break before a mid-line ATX heading marker (`#{1,6} `) so a
  malformed single-line body self-heals into headings/paragraphs. Scoped
  to skip fenced code blocks and to leave inline `#` tokens (C#, F#,
  #hashtags, #123) untouched.

Unit tests cover the guard (mid-line `##` break, C#/F#/hashtag/code-fence
exclusions, leading-heading no-op).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 4.8 committed 2026-08-06 02:24:44 -07:00
1 parent 2ea22d6cea
commit 635de31df4
5 files changed
+146 -4

No files matched your search

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
multilineTextSchema,
normalizeEscapedLineBreaks,
normalizeMarkdownBody,
normalizeMidLineHeadings,
} from "./text.js";
describe("normalizeEscapedLineBreaks", () => {
it("converts JSON-escaped line breaks to real newlines", () => {
expect(normalizeEscapedLineBreaks("a\\nb\\r\\nc\\rd")).toBe("a\nb\nc\nd");
});
});
describe("normalizeMidLineHeadings", () => {
it("inserts a break before a mid-line ATX heading marker", () => {
expect(normalizeMidLineHeadings("plan pack)## The goal")).toBe("plan pack)\n\n## The goal");
});
it("heals every mid-line heading in a collapsed single-line body", () => {
const collapsed = "# Plan pack ## The goal, restated ### You picked";
expect(normalizeMidLineHeadings(collapsed)).toBe(
"# Plan pack \n\n## The goal, restated \n\n### You picked",
);
});
it("leaves a heading that is already at the start of a line alone", () => {
const body = "## The goal\n\nYou picked the shared plan pack.";
expect(normalizeMidLineHeadings(body)).toBe(body);
});
it("leaves a leading heading at the start of the body alone", () => {
expect(normalizeMidLineHeadings("# Title here")).toBe("# Title here");
});
it("does not touch inline `#` tokens like C# and F#", () => {
const body = "We ship both C# and F# services this quarter.";
expect(normalizeMidLineHeadings(body)).toBe(body);
});
it("does not touch a `#` glued to a following non-space (hashtags, issue refs)", () => {
const body = "See #123 and #roadmap for the plan.";
expect(normalizeMidLineHeadings(body)).toBe(body);
});
it("heals a two-hash marker even when preceded by a word char", () => {
expect(normalizeMidLineHeadings("restated## The goal")).toBe("restated\n\n## The goal");
});
it("ignores runs of 7+ hashes (not a valid heading)", () => {
const body = "border ####### divider";
expect(normalizeMidLineHeadings(body)).toBe(body);
});
it("does not rewrite `## ` comments inside fenced code blocks", () => {
const body = "```yaml\nfoo: bar ## inline comment\n```";
expect(normalizeMidLineHeadings(body)).toBe(body);
});
it("is a no-op when there is no `#` at all", () => {
const body = "Just some prose with no markers.";
expect(normalizeMidLineHeadings(body)).toBe(body);
});
});
describe("normalizeMarkdownBody / multilineTextSchema", () => {
it("runs escaped-break normalization before heading healing", () => {
// Producer sends JSON-escaped newlines plus a mid-line heading.
expect(normalizeMarkdownBody("Intro.\\n\\n## Section body)## Next")).toBe(
"Intro.\n\n## Section body)\n\n## Next",
);
});
it("multilineTextSchema self-heals a collapsed plan body on write", () => {
const parsed = multilineTextSchema.parse("plan pack)## The goal, restated");
expect(parsed).toBe("plan pack)\n\n## The goal, restated");
});
});
+61 -1
View File
@@ -7,4 +7,64 @@ export function normalizeEscapedLineBreaks(value: string): string {
.replace(/\\r/g, "\n");
}
export const multilineTextSchema = z.string().transform(normalizeEscapedLineBreaks);
const FENCE_LINE = /^(\s{0,3})(```+|~~~+)/;
// A mid-line ATX heading marker: 1–6 `#`, a space, then heading text. The
// capture keeps the char immediately before the run so we can tell a real
// (collapsed) heading from an inline `#` token like `C#`/`F#`.
const MID_LINE_HEADING = /([^\n#])(#{1,6})([ \t])/g;
/**
* Heal a single line that carries an ATX heading marker mid-line. A plan/doc
* body whose paragraph and heading newlines were collapsed to a single line
* (a producer bug) renders as one giant heading with `##` markers
* showing inline (e.g. "…plan pack)## The goal, restatedYou picked…"). Insert a
* hard paragraph break before each such marker so the body self-heals into
* headings + paragraphs.
*
* A lone `#` fused to an alphanumeric on its left (`C#`, `F#`, `A#`) is left
* alone — that is an inline token, not a heading. Markers with 2–6 hashes are
* always healed: `word## ` never occurs as legitimate inline prose.
*/
function healMidLineHeadings(line: string): string {
return line.replace(MID_LINE_HEADING, (match, before: string, hashes: string, space: string) => {
if (hashes.length === 1 && /[A-Za-z0-9]/.test(before)) return match;
return `${before}\n\n${hashes}${space}`;
});
}
/**
* Defense-in-depth for the Plan pane: insert a paragraph break before
* ATX heading markers that appear mid-line, so a malformed single-line markdown
* body still renders as headings/paragraphs. Fenced code blocks are skipped so
* inline `## ` comments inside code (YAML, shell, diffs) are never rewritten.
*/
export function normalizeMidLineHeadings(value: string): string {
if (!value.includes("#")) return value;
const lines = value.split("\n");
let inFence = false;
let fenceChar = "";
const out: string[] = [];
for (const line of lines) {
const fenceMatch = FENCE_LINE.exec(line);
if (fenceMatch) {
const marker = fenceMatch[2]!;
if (!inFence) {
inFence = true;
fenceChar = marker[0]!;
} else if (marker[0] === fenceChar) {
inFence = false;
fenceChar = "";
}
out.push(line);
continue;
}
out.push(inFence ? line : healMidLineHeadings(line));
}
return out.join("\n");
}
export function normalizeMarkdownBody(value: string): string {
return normalizeMidLineHeadings(normalizeEscapedLineBreaks(value));
}
export const multilineTextSchema = z.string().transform(normalizeMarkdownBody);
+1 -1
View File
@@ -35,7 +35,7 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to
- Use child issues for delegated work and wait for Paperclip wake events or comments instead of polling agents, sessions, or processes in a loop.
- Create child issues directly when ownership and scope are clear. Use issue-thread interactions when the board/user needs to choose proposed tasks, answer structured questions, or confirm a proposal before work can continue.
- Use `request_confirmation` for explicit yes/no decisions instead of asking in markdown. Before presenting a plan for review, you MUST complete this publish contract:
1. `PUT /issues/{id}/documents/plan` with `{ format: 'markdown', body, changeSummary }`.
1. `PUT /issues/{id}/documents/plan` with `{ format: 'markdown', body, changeSummary }`. The `body` renders as markdown: put every heading on its own line and separate blocks with a blank line (`\n\n` in JSON) — never glue headings and paragraphs onto one line.
2. Re-`GET /documents/plan`, assert it returns `200`, and capture its `latestRevisionId`.
3. Only then create `request_confirmation` with `target={ type: 'issue_document', key: 'plan', revisionId: latestRevisionId }` and `idempotencyKey=confirmation:{issueId}:plan:{revisionId}`.
4. Put the source issue in `in_review` and wait for acceptance before delegating implementation subtasks.
@@ -12,7 +12,7 @@ You are an agent at Paperclip company.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`.
- Use `request_confirmation` instead of asking for yes/no decisions in markdown. Before presenting a plan for review, you MUST complete this publish contract:
1. `PUT /issues/{id}/documents/plan` with `{ format: 'markdown', body, changeSummary }`.
1. `PUT /issues/{id}/documents/plan` with `{ format: 'markdown', body, changeSummary }`. The `body` renders as markdown: put every heading on its own line and separate blocks with a blank line (`\n\n` in JSON) — never glue headings and paragraphs onto one line.
2. Re-`GET /documents/plan`, assert it returns `200`, and capture its `latestRevisionId`.
3. Only then create `request_confirmation` with `target={ type: 'issue_document', key: 'plan', revisionId: latestRevisionId }` and `idempotencyKey=confirmation:{issueId}:plan:{revisionId}`.
4. Wait for acceptance before creating implementation subtasks.
+5 -1
View File
@@ -563,6 +563,8 @@ When asked to convert a plan into executable Paperclip tasks — depth, assignme
When asked to convert a plan into executable Paperclip tasks — depth, assignment, dependencies, parallelization — use the companion skill `paperclip-converting-plans-to-tasks`.
**Formatting matters.** The `body` is rendered as markdown in the Plan pane. Every heading (`#`, `##`, …) must start its own line, and paragraphs must be separated by a blank line. In JSON that means real paragraph breaks encoded as `\n\n` between blocks — never run headings and paragraphs together on a single line. A body like `"# Plan ## Goal You picked…"` (headings glued mid-line) renders as one giant H1 with literal `##` markers showing inline. When you build the request from a heredoc, encode the body with a JSON-safe tool (e.g. `jq -Rs .`) so real newlines are preserved rather than stripped.
Recommended API flow:
```bash
@@ -570,11 +572,13 @@ PUT /api/issues/{issueId}/documents/plan
{
"title": "Plan",
"format": "markdown",
"body": "# Plan\n\n[your plan here]",
"body": "# Plan\n\n## Goal\n\n[one paragraph per idea, blank line between blocks]\n\n## Approach\n\n- step one\n- step two",
"baseRevisionId": null
}
```
Note how each heading (`# Plan`, `## Goal`, `## Approach`) is separated from the surrounding text by `\n\n`. Keep that structure — a defense-in-depth guard on the write path will insert a break before a stray mid-line heading marker, but author the newlines correctly rather than relying on it.
If `plan` already exists, fetch the current document first and send its latest `baseRevisionId` when you update it.
## Key Endpoints (Hot Routes)