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:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user