FN-8659: preserve custom task specification sections

Preserve custom planning sections while reinjecting the original task description.

- Align unmarked original-description bodies before selecting a terminator.
- Anchor inserted descriptions before custom H2 sections and retain safe fallback behavior.
- Add regression coverage, workflow documentation, and a patch changeset.

Files changed:
 ...fn-8659-original-description-custom-sections.md |   7 ++
 docs/workflow-steps.md                             |   2 +
 .../__tests__/original-description-policy.test.ts  | 114 ++++++++++++++++-
 packages/core/src/original-description-policy.ts   | 135 ++++++++++++++++++---
 4 files changed, 235 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-8659
Fusion-Task-Lineage: 7f047a31-3750-4545-b743-bfac9546c55b
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-31 22:38:52 -07:00
parent 5596d915ab
commit ee77a8d3fa
4 changed files with 235 additions and 23 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve custom plan sections when Fusion refreshes the original task description.
category: fix
dev: Uses positional alignment with safe/unsafe partial handling for unmarked descriptions and anchors inserts before the first H2.

View File

@@ -102,6 +102,8 @@ The executor logs `[skill-load]` only when the **named** skill has no viable dis
Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect built-ins, tune built-in prompts, duplicate workflows, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.
Customized planning prompts may add their own `##` sections to generated task specifications. Fusion preserves those sections when it refreshes the verbatim **Original Description** block after planning, so custom prompt structure can appear alongside the built-in specification sections.
<!--
FNXC:Workflows 2026-06-28-09:50:
Pure-v1 custom graphs remain rollback-compatible by upgrading to trait-less default columns. Capacity dispatch after the workflow-columns cutover is therefore an explicit v2 authoring requirement, not an implicit v1 upgrade side effect.

View File

@@ -8,6 +8,7 @@ Also covers embedded-H2 operator text so description updates cannot duplicate or
corrupt PROMPT.md when the raw request contains lines like `## Required behavior`.
*/
import { describe, expect, it } from "vitest";
import { computePlanApprovalFingerprint } from "../plan-approval.js";
import {
ORIGINAL_DESCRIPTION_END_MARKER,
ORIGINAL_DESCRIPTION_HEADING,
@@ -177,10 +178,117 @@ describe("original description policy", () => {
expect(twice.split("## Extra section from operator").length - 1).toBe(1);
});
it("treats unmarked planner sections ending at structural headings only", () => {
it("uses the first H2 for an unmarked section when operator text is unavailable", () => {
const bodyWithUnknownH2 = "Intro\n\n## Required behavior\n\n- do the thing";
const unmarked = sampleSpec({ withOriginal: true, originalBody: bodyWithUnknownH2, marked: false });
// Extract must include ## Required behavior (not a structural heading).
expect(extractOriginalDescriptionBody(unmarked)).toBe(bodyWithUnknownH2);
expect(extractOriginalDescriptionBody(unmarked)).toBe("Intro");
});
it("preserves a custom section after an unmarked non-empty description", () => {
const prompt = `# Task: FN-8659\n\n## Original Description\n\n${SAMPLE_DESC}\n\n## Product Overview\n\nCustom planner context.\n\n## Before → After Transformation\n\n- before\n`;
const once = applyOriginalDescription(prompt, SAMPLE_DESC);
expect(once).toContain("## Product Overview\n\nCustom planner context.");
expect(applyOriginalDescription(once, SAMPLE_DESC)).toBe(once);
});
it("preserves a custom section after an unmarked empty description", () => {
const prompt = "# Task: FN-8659\n\n## Original Description\n\n\n## Product Overview\n\nCustom planner context.\n\n## Mission\n\nShip it.\n";
const once = applyOriginalDescription(prompt, " ");
expect(once).toContain("## Product Overview\n\nCustom planner context.");
expect(applyOriginalDescription(once, " ")).toBe(once);
});
it("preserves a custom section whose title collides with operator prose", () => {
const description = "Operator context:\n## Product Overview\nKeep this as prose.";
const prompt = `# Task: FN-8659\n\n## Original Description\n\n${description}\n\n## Product Overview\n\nCustom planner context.\n\n## Mission\n\nShip it.\n`;
const once = applyOriginalDescription(prompt, description);
expect(once).toContain(`${ORIGINAL_DESCRIPTION_START_MARKER}\n${description}\n${ORIGINAL_DESCRIPTION_END_MARKER}`);
expect(once).toContain("## Product Overview\n\nCustom planner context.");
expect(applyOriginalDescription(once, description)).toBe(once);
});
it("inserts Original Description above a leading custom section", () => {
const prompt = "# Task: FN-8659\n\n## Product Overview\n\nCustom planner context.\n\n## Mission\n\nShip it.\n";
const once = applyOriginalDescription(prompt, SAMPLE_DESC);
expect(once.indexOf(ORIGINAL_DESCRIPTION_HEADING)).toBeLessThan(once.indexOf("## Product Overview"));
expect(once).toContain("## Product Overview\n\nCustom planner context.");
expect(applyOriginalDescription(once, SAMPLE_DESC)).toBe(once);
});
it("keeps marker-bounded sections unchanged", () => {
const marked = sampleSpec({ withOriginal: true, originalBody: SAMPLE_DESC, marked: true });
expect(applyOriginalDescription(marked, SAMPLE_DESC)).toBe(marked);
});
it("uses the allowlist-priority tiebreak after alignment failure", () => {
const prompt = "# Task: FN-8659\n\n## Original Description\n\nPlanner rewrite has no matching opening line.\n\n## Mission\n\nEarlier lower-priority heading.\n\n## Before → After Transformation\n\nChosen by allowlist priority.\n";
const once = applyOriginalDescription(prompt, "Operator opening line.");
// FNXC:OriginalDescriptionInPrompt 2026-08-01-05:18: Historical priority selects the later
// Before heading, so the earlier Mission remains in the unmarked body being replaced.
expect(once).not.toContain("Earlier lower-priority heading.");
expect(once).toContain("## Before → After Transformation\n\nChosen by allowlist priority.");
expect(once.indexOf(ORIGINAL_DESCRIPTION_END_MARKER)).toBeLessThan(
once.indexOf("## Before → After Transformation"),
);
expect(applyOriginalDescription(once, "Operator opening line.")).toBe(once);
});
it("does not trust unsafe partial alignment before an embedded operator H2", () => {
const description = "Opening line.\n\n## Mission\n\nThis is operator prose.";
const prompt = "# Task: FN-8659\n\n## Original Description\n\nOpening line.\n\nPlanner changed this line.\n\n## Mission\n\nThis is operator prose.\n\n## Product Overview\n\nCustom planner context.\n\n## Before → After Transformation\n\nStructural boundary.\n";
const once = applyOriginalDescription(prompt, description);
// The unmatched description still has an H2, so the legacy tiebreak—not Mission—sets the bound.
expect(once.indexOf(ORIGINAL_DESCRIPTION_END_MARKER)).toBeLessThan(
once.indexOf("## Before → After Transformation"),
);
expect(once).not.toContain("Planner changed this line.");
expect(applyOriginalDescription(once, description)).toBe(once);
});
it("uses safe partial alignment after every embedded operator H2 was consumed", () => {
const description = "Opening line.\n\n## Mission\n\nOperator prose after the H2.";
const prompt = "# Task: FN-8659\n\n## Original Description\n\nOpening line.\n\n## Mission\n\nPlanner divergence after the embedded H2.\n\n## Product Overview\n\nCustom planner context.\n\n## Before → After Transformation\n\nStructural boundary.\n";
const once = applyOriginalDescription(prompt, description);
expect(once).toContain("## Product Overview\n\nCustom planner context.");
expect(once.indexOf(ORIGINAL_DESCRIPTION_END_MARKER)).toBeLessThan(
once.indexOf("## Product Overview"),
);
expect(applyOriginalDescription(once, description)).toBe(once);
});
it("keeps an embedded operator H2 inside the aligned body before a custom section", () => {
const description = "Opening line.\n\n## Mission\n\nThis is operator prose.";
const prompt = `# Task: FN-8659\n\n## Original Description\n\n${description}\n\n## Product Overview\n\nCustom planner context.\n\n## Before → After Transformation\n\nStructural boundary.\n`;
const once = applyOriginalDescription(prompt, description);
expect(extractOriginalDescriptionBody(once, description)).toBe(description);
expect(once).toContain("## Product Overview\n\nCustom planner context.");
expect(applyOriginalDescription(once, description)).toBe(once);
});
it("anchors insertion above a colliding leading custom heading", () => {
const description = "Operator context:\n## Product Overview\nKeep this as prose.";
const prompt = "# Task: FN-8659\n\n## Product Overview\n\nCustom planner context.\n\n## Mission\n\nShip it.\n";
const once = applyOriginalDescription(prompt, description);
expect(once.indexOf(ORIGINAL_DESCRIPTION_HEADING)).toBeLessThan(once.indexOf("## Product Overview"));
expect(once).toContain("## Product Overview\n\nCustom planner context.");
expect(applyOriginalDescription(once, description)).toBe(once);
});
it("keeps hygiene-before-fingerprint stable across a second pass", () => {
const planner = `# Task: FN-8659\n\n## Original Description\n\n${SAMPLE_DESC}\n\n## Product Overview\n\nCustom planner context.\n\n## Mission\n\nShip it.\n`;
const once = applyOriginalDescription(planner, SAMPLE_DESC);
const twice = applyOriginalDescription(once, SAMPLE_DESC);
expect(computePlanApprovalFingerprint(twice)).toBe(computePlanApprovalFingerprint(once));
expect(twice).toBe(once);
});
});

View File

@@ -19,6 +19,19 @@ description updates, replaced only a prefix while leaving the old suffix — dup
corrupting PROMPT.md. Section bounds use HTML markers when present, else only known
structural PROMPT headings (Mission, File Scope, Steps, …), so embedded H2s stay inside
the Original Description body.
FNXC:OriginalDescriptionInPrompt 2026-08-01-05:18:
Custom workflow plan-node sections are first-class: hygiene must neither swallow nor reorder them.
For an unmarked section, precedence is: (1) an empty description ends at the first following H2;
(2a) full normalized positional alignment ends at the first H2 at/after the aligned body; (2b) a
non-empty matching prefix does the same only when its unmatched description suffix has no H2; (2c)
a prefix whose unmatched suffix contains an H2 is unsafe and falls through because that next document
H2 may be embedded operator prose, so terminating there would truncate the operator body; (2d) no
alignment also falls through; (3) those failure cases retain the allowlist-priority (not document-order)
tiebreak; (4) no selected heading runs to end-of-document. Heading-title evidence is not primary: a
custom heading can also appear in operator prose and title skipping would destroy that section. Empty
operator text has no embedded-H2 risk, so it bypasses the allowlist. INSERT has no body to align and
therefore changed from allowlist-preferred placement to anchoring before the first document H2.
*/
export const ORIGINAL_DESCRIPTION_HEADING = "## Original Description";
@@ -83,7 +96,7 @@ export function applyOriginalDescription(
}
const wantedBody = (originalDescription ?? "").trimEnd();
const existingBody = extractOriginalDescriptionBody(promptMarkdown);
const existingBody = extractOriginalDescriptionBody(promptMarkdown, originalDescription);
// Idempotent when the section already carries the exact operator text.
if (existingBody !== null && existingBody.trimEnd() === wantedBody) {
// Still rewrite when markers are missing so later updates stay H2-safe.
@@ -94,14 +107,17 @@ export function applyOriginalDescription(
const section = buildOriginalDescriptionSection(originalDescription);
if (existingBody !== null || hasOriginalDescriptionHeading(promptMarkdown)) {
return replaceOriginalDescriptionSection(promptMarkdown, section);
return replaceOriginalDescriptionSection(promptMarkdown, section, originalDescription);
}
return insertOriginalDescriptionNearTop(promptMarkdown, section);
}
/** Returns the body under `## Original Description`, or null when the section is absent. */
export function extractOriginalDescriptionBody(content: string): string | null {
const range = findOriginalDescriptionRange(content);
export function extractOriginalDescriptionBody(
content: string,
originalDescription?: string,
): string | null {
const range = findOriginalDescriptionRange(content, originalDescription);
if (!range) {
return null;
}
@@ -121,10 +137,11 @@ function hasOriginalDescriptionMarkers(content: string): boolean {
/**
* Absolute [start, end) range of the Original Description section and its body text.
* Prefer HTML markers; fall back to the next known structural PROMPT heading.
* Prefer HTML markers; otherwise align the known operator body before using legacy structure.
*/
function findOriginalDescriptionRange(
content: string,
originalDescription?: string,
): { sectionStart: number; sectionEnd: number; body: string } | null {
const match = content.match(/^##\s+Original Description\s*$/m);
if (!match || match.index === undefined) {
@@ -153,12 +170,12 @@ function findOriginalDescriptionRange(
return { sectionStart, sectionEnd: absoluteEnd, body };
}
// Unmarked (planner-written): end at preferred following structural heading.
const structuralOffset = findPreferredSectionTerminatorOffset(afterHeader);
// Unmarked planner output: preserve arbitrary custom H2 sections after the aligned body.
const terminatorOffset = findUnmarkedSectionTerminatorOffset(afterHeader, originalDescription);
const sectionEnd =
structuralOffset === -1 ? content.length : headerEnd + structuralOffset;
terminatorOffset === -1 ? content.length : headerEnd + terminatorOffset;
const body = afterHeader
.slice(0, structuralOffset === -1 ? undefined : structuralOffset)
.slice(0, terminatorOffset === -1 ? undefined : terminatorOffset)
.replace(/^\n+/, "")
.trimEnd();
return { sectionStart, sectionEnd, body };
@@ -180,8 +197,93 @@ function findPreferredSectionTerminatorOffset(text: string): number {
return -1;
}
function replaceOriginalDescriptionSection(content: string, section: string): string {
const range = findOriginalDescriptionRange(content);
type NormalizedLine = { value: string; endOffset: number };
/**
* Normalize markdown lines for body alignment while retaining each normalized line's source end.
* Blank runs coalesce so harmless planner formatting does not defeat alignment.
*/
function normalizeLines(content: string): NormalizedLine[] {
const lines: NormalizedLine[] = [];
let offset = 0;
let pendingBlank: NormalizedLine | undefined;
while (offset < content.length) {
const newline = content.indexOf("\n", offset);
const lineEnd = newline === -1 ? content.length : newline;
const rawLine = content.slice(offset, lineEnd).replace(/\r$/, "");
const nextOffset = newline === -1 ? content.length : newline + 1;
const line = { value: rawLine.trim(), endOffset: nextOffset };
if (!line.value) {
// Leading/trailing blanks are discarded; interior runs become one blank line.
if (lines.length > 0) pendingBlank = line;
} else {
if (pendingBlank) lines.push(pendingBlank);
pendingBlank = undefined;
lines.push(line);
}
offset = nextOffset;
}
return lines;
}
function isH2Heading(line: string): boolean {
return /^##\s+\S.*$/.test(line);
}
function findFirstH2AtOrAfter(text: string, offset: number): number {
const candidates = /^##\s+\S.*$/gm;
for (const match of text.matchAll(candidates)) {
if (match.index !== undefined && match.index >= offset) return match.index;
}
return -1;
}
/**
* Find an unmarked section boundary using the operator body before legacy heading tiebreaks.
*/
function findUnmarkedSectionTerminatorOffset(
text: string,
originalDescription?: string,
): number {
const descriptionLines = normalizeLines(originalDescription ?? "");
if (descriptionLines.length === 0) {
return findFirstH2AtOrAfter(text, 0);
}
const documentLines = normalizeLines(text);
let matched = 0;
while (
matched < descriptionLines.length &&
matched < documentLines.length &&
descriptionLines[matched].value === documentLines[matched].value
) {
matched += 1;
}
const unmatchedDescriptionHasH2 = descriptionLines
.slice(matched)
.some((line) => isH2Heading(line.value));
const alignmentIsSafe = matched === descriptionLines.length ||
(matched > 0 && !unmatchedDescriptionHasH2);
if (alignmentIsSafe) {
const alignEnd = documentLines[matched - 1].endOffset;
return findFirstH2AtOrAfter(text, alignEnd);
}
// Preserve historical allowlist-priority behavior only when alignment cannot prove a boundary.
const preferredOffset = findPreferredSectionTerminatorOffset(text);
return preferredOffset !== -1 ? preferredOffset : findFirstH2AtOrAfter(text, 0);
}
function replaceOriginalDescriptionSection(
content: string,
section: string,
originalDescription?: string,
): string {
const range = findOriginalDescriptionRange(content, originalDescription);
if (!range) {
return content;
}
@@ -197,17 +299,10 @@ function replaceOriginalDescriptionSection(content: string, section: string): st
}
/**
* Insert before the preferred following structural section so the block sits under
* title/metadata. Unknown H2s are ignored. Falls back to the first H2, then append.
* Insert before the first structural H2 so custom sections retain their document order.
* There is no existing body here, so range alignment deliberately does not apply.
*/
function insertOriginalDescriptionNearTop(content: string, section: string): string {
const structuralOffset = findPreferredSectionTerminatorOffset(content);
if (structuralOffset !== -1) {
const before = content.slice(0, structuralOffset).trimEnd();
const after = content.slice(structuralOffset);
return `${before}\n\n${section.trimEnd()}\n\n${after}`;
}
const firstH2 = content.search(/^##\s+/m);
if (firstH2 !== -1) {
const before = content.slice(0, firstH2).trimEnd();