diff --git a/packages/shared/src/validators/text.test.ts b/packages/shared/src/validators/text.test.ts new file mode 100644 index 00000000000..ee30f51d8db --- /dev/null +++ b/packages/shared/src/validators/text.test.ts @@ -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"); + }); +}); diff --git a/packages/shared/src/validators/text.ts b/packages/shared/src/validators/text.ts index 322597a9005..e00eade1155 100644 --- a/packages/shared/src/validators/text.ts +++ b/packages/shared/src/validators/text.ts @@ -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); diff --git a/server/src/onboarding-assets/ceo/AGENTS.md b/server/src/onboarding-assets/ceo/AGENTS.md index 6e09de67f39..d2ba87b1db5 100644 --- a/server/src/onboarding-assets/ceo/AGENTS.md +++ b/server/src/onboarding-assets/ceo/AGENTS.md @@ -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. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index a462c8c9d8c..98bf4762113 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -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. diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 981afa05f26..2839ef82630 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -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)