mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user