fix(triage): stop clobbering freshly-written PROMPT.md specs on title sync

`TriageProcessor.finalizeApprovedTask` (added in FN-3056) called
`store.updateTask({title})` while the task was still in column='triage',
which triggered a pre-existing regen path in `TaskStore.updateTask` that
overwrote the agent's just-written specification with the bootstrap stub
(`# {id}: {title}\n\n{description}\n`). Tasks shipped to `todo` (and
through to `done`) with empty 70–200 byte specs while the executor only
saw the original one-line user description. The same regen path also
silently dropped `## Review Level` / `## Frontend UX Criteria` and any
section outside a fixed whitelist whenever a non-triage task's title or
description was edited.

Replaces the regen with wrapper-shape-exact stub detection (compare to
the bytes `createTask` would have written for the pre-update title and
description) plus surgical edits for real specs: title changes splice
only the leading `# ...` heading, description changes rewrite only the
body of `## Mission`, and every other section is preserved verbatim.
`finalizeApprovedTask` now applies the prompt-declared title after
`moveTask("todo")` as defense in depth. New regression tests cover real
specs surviving title sync, long bootstrap stubs, stubs whose body
contains `##` markdown or `**Created:**` text, and the end-to-end
triage finalize sequence on a real `TaskStore`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-02 23:41:45 -07:00
parent cdb8539795
commit d73070c135
4 changed files with 359 additions and 73 deletions

View File

@@ -0,0 +1,16 @@
---
"@runfusion/fusion": patch
---
Fix triage finalization clobbering its own freshly-written PROMPT.md spec, and fix the older title/description-driven regen path silently dropping `## Review Level` / `## Frontend UX Criteria` and any other sections outside a fixed whitelist. Tasks have been shipping to `todo` (and through to `done`) with empty 70200 byte specs while the executor agent only saw the original one-line user description; tasks that survived that bug could still come out of triage with their review level reset to 0 and frontend guidance dropped.
**Root causes.**
- FN-3056 (May 2) added `taskUpdates.title = promptDeclaredTitle` to `TriageProcessor.finalizeApprovedTask` and called `store.updateTask(task.id, taskUpdates)` while `task.column` was still `'triage'`. A pre-existing block in `TaskStore.updateTask` rewrote PROMPT.md to the bootstrap stub `# {id}: {title}\n\n{description}\n` whenever title/description changed on a triage-column task, overwriting the agent's just-written 6 KB spec with a 150-byte stub before `moveTask` ran.
- The non-triage branch of the same regen block called `regeneratePrompt`, which rebuilt the file from a fixed section whitelist (`Dependencies`, `Steps`, `File Scope`, `Acceptance Criteria`, `Notifications`). Any section the triage prompt emits outside that whitelist — `## Review Level`, `## Frontend UX Criteria`, custom assessment scoring, anything ad-hoc — was silently dropped on every title or description edit.
**Fixes.**
- `packages/core/src/store.ts`: title/description sync is now wrapper-shape-exact, not content-inspecting. The bootstrap stub detector compares the on-disk file against the exact bytes `createTask` would have written for the *pre-update* title/description (shared `buildBootstrapPrompt` helper), so it never inspects the description body. This is robust to imported issue bodies that contain `## Repro`, `**Created:**`, etc. — earlier heuristic checks (size caps, `##` header presence, `**Created:**` / `**Size:**` markers) misclassified those as real specs. Stub files keep getting fully rewritten so the displayed title/description stay in sync. Real specs get surgical edits only: title changes splice the leading `# ...` heading line and preserve the existing heading style (triage's `# Task: {id} - {title}` vs createTask's `# {id}: {title}`); description changes rewrite only the body of `## Mission`, leaving every other section verbatim. Description-only edits with no `## Mission` section are a no-op rather than a wholesale rebuild. The `regeneratePrompt` whitelist function is removed.
- `packages/engine/src/triage.ts`: `finalizeApprovedTask` applies the prompt-declared title *after* `moveTask("todo")` so the column transition happens before any title-driven regen could fire — defense in depth alongside the store-level guard. The `requirePlanApproval` branch folds the title into its existing `awaiting-approval` update.
- New regression tests in `packages/core/src/__tests__/store.test.ts`: the original bug (real spec on a triage task survives a title change), the false-negative cases (long bootstrap stubs and stubs whose description body contains `##` markdown headings or `**Created:**` / `**Size:**` text are still detected and rewritten), the secondary regression (`## Review Level` and `## Frontend UX Criteria` survive a non-triage title edit), and an end-to-end test that mirrors the exact `TriageProcessor.finalizeApprovedTask` sequence (write spec → updateTask without title → moveTask("todo") → updateTask({title})) on a real `TaskStore` to catch any future regression along the actual finalize path.

View File

@@ -3659,6 +3659,198 @@ describe("TaskStore", () => {
expect(updated.title).toBe("Updated title");
});
it("does not clobber a real PROMPT.md spec when title changes on a triage task", async () => {
// Regression: triage finalization called updateTask({title}) while column
// was still 'triage', and the regen path rewrote PROMPT.md back to the
// bootstrap stub — shipping empty specs to the executor.
const task = await createTestTask();
const realSpec = [
`# Task: ${task.id} - Some refactor`,
"",
"**Created:** 2026-05-02",
"**Size:** M",
"",
"## Mission",
"",
"Do the thing.",
"",
"## Steps",
"",
"- [ ] Step 1",
"- [ ] Step 2",
"",
].join("\n");
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(join(dir, "PROMPT.md"), realSpec);
await store.updateTask(task.id, { title: "Some refactor" });
const onDisk = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(onDisk).toBe(realSpec);
});
it("still rewrites the bootstrap stub when title changes on a triage task", async () => {
const task = await createTestTask();
const dir = join(rootDir, ".fusion", "tasks", task.id);
// Confirm createTask seeded the bootstrap stub.
const initial = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(initial.startsWith(`# ${task.id}`)).toBe(true);
expect(/^##\s/m.test(initial)).toBe(false);
await store.updateTask(task.id, { title: "New Title" });
const onDisk = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(onDisk).toBe(`# ${task.id}: New Title\n\n${task.description}\n`);
});
it("rewrites a long bootstrap stub when title changes (structural detection, not size-based)", async () => {
// Regression: a length-based stub detector treated stubs from long
// descriptions (e.g. imported issue bodies) as real specs, so subsequent
// edits left the displayed heading stale.
const longDescription = "Lorem ipsum dolor sit amet. ".repeat(40); // ~1100 bytes
const created = await store.createTask({ description: longDescription });
const dir = join(rootDir, ".fusion", "tasks", created.id);
const initial = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(initial.length).toBeGreaterThan(1000);
expect(/^##\s/m.test(initial)).toBe(false);
await store.updateTask(created.id, { title: "Now With Title" });
const onDisk = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(onDisk).toBe(`# ${created.id}: Now With Title\n\n${longDescription}\n`);
});
it("rewrites a stub whose description body contains markdown headings or metadata-like text", async () => {
// Regression: a content-inspecting detector (rejecting any body with
// `##` headers or `**Created:**` / `**Size:**` markers) misclassified
// imported GitHub issue bodies as real specs. Detection must compare to
// the bootstrap wrapper shape, not inspect the description content.
const importedDescription = [
"## Repro",
"",
"1. Open the dashboard.",
"2. Click the thing.",
"",
"## Expected",
"",
"Thing happens.",
"",
"**Created:** 2026-04-01 by automation",
"**Size:** unspecified",
].join("\n");
const created = await store.createTask({ description: importedDescription });
const dir = join(rootDir, ".fusion", "tasks", created.id);
await store.updateTask(created.id, { title: "Issue with markdown body" });
const onDisk = await readFile(join(dir, "PROMPT.md"), "utf-8");
// The stub was rewritten — heading reflects the new title and the body
// is the (markdown-containing) description verbatim.
expect(onDisk).toBe(`# ${created.id}: Issue with markdown body\n\n${importedDescription}\n`);
});
it("survives the triage finalize sequence end-to-end (move-to-todo + title sync)", async () => {
// Mirrors what TriageProcessor.finalizeApprovedTask does on a real
// TaskStore: spec lands on disk, non-title metadata is applied with the
// task still in triage, the task moves to todo, and finally the prompt-
// declared title is synced. A regression in either the bootstrap stub
// detector or the real-spec edit path would surface as a corrupted or
// truncated PROMPT.md after this sequence.
const created = await store.createTask({
description: "raw user description containing ## a markdown heading",
});
const dir = join(rootDir, ".fusion", "tasks", created.id);
const realSpec = [
`# Task: ${created.id} - Refactor the renderer`,
"",
"**Created:** 2026-05-02",
"**Size:** M",
"",
"## Review Level: 2 (Plan and Code)",
"",
"**Score:** 5/8",
"",
"## Mission",
"",
"Refactor the renderer to use the new pipeline.",
"",
"## Frontend UX Criteria",
"",
"- Component must remain accessible at 320px width",
"",
"## Steps",
"",
"- [ ] Extract pipeline",
"",
].join("\n");
// Triage agent would have written this via the `write` tool.
await writeFile(join(dir, "PROMPT.md"), realSpec);
// Reproduce finalizeApprovedTask's exact sequence:
// 1. Apply non-title metadata while still in triage.
await store.updateTask(created.id, { status: null });
// 2. Move to todo.
await store.moveTask(created.id, "todo");
// 3. Sync prompt-declared title.
await store.updateTask(created.id, { title: "Refactor the renderer" });
const onDisk = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(onDisk).toContain("## Review Level: 2 (Plan and Code)");
expect(onDisk).toContain("## Frontend UX Criteria");
expect(onDisk).toContain("- Component must remain accessible at 320px width");
expect(onDisk).toContain("## Steps");
expect(onDisk).toContain("- [ ] Extract pipeline");
expect(onDisk.split("\n")[0]).toBe(`# Task: ${created.id} - Refactor the renderer`);
const reloaded = await store.getTask(created.id);
expect(reloaded.column).toBe("todo");
expect(reloaded.title).toBe("Refactor the renderer");
});
it("preserves Review Level / Frontend UX Criteria sections when title changes on a non-triage task", async () => {
// Regression: the previous regenerate-from-whitelist path quietly dropped
// any section not in {Dependencies, Steps, File Scope, Acceptance,
// Notifications}. Triage emits `## Review Level: N` and may emit
// `## Frontend UX Criteria`; both must survive a metadata edit.
const task = await createTestTask();
await store.moveTask(task.id, "todo");
const realSpec = [
`# Task: ${task.id} - Original title`,
"",
"**Created:** 2026-05-02",
"**Size:** M",
"",
"## Review Level: 2 (Plan and Code)",
"",
"**Score:** 5/8",
"",
"## Mission",
"",
"Do the thing.",
"",
"## Frontend UX Criteria",
"",
"- Component must remain accessible at 320px width",
"",
"## Steps",
"",
"- [ ] Step 1",
"",
].join("\n");
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(join(dir, "PROMPT.md"), realSpec);
await store.updateTask(task.id, { title: "Renamed task" });
const onDisk = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(onDisk).toContain("## Review Level: 2 (Plan and Code)");
expect(onDisk).toContain("## Frontend UX Criteria");
expect(onDisk).toContain("- Component must remain accessible at 320px width");
expect(onDisk).toContain("## Steps");
// Heading is rewritten in the original triage style.
expect(onDisk.split("\n")[0]).toBe(`# Task: ${task.id} - Renamed task`);
});
it("persists sourceIssue on create and reload", async () => {
const sourceIssue = createSourceIssueFixture();
const created = await store.createTask({

View File

@@ -247,6 +247,86 @@ function compactTaskActivityLog(entries: TaskLogEntry[]): TaskLogEntry[] {
}));
}
/**
* Build the exact PROMPT.md bytes that `createTask` writes for a triage task.
* Single source of truth so the stub-detection comparison below stays in sync
* with the bootstrap shape.
*/
function buildBootstrapPrompt(taskId: string, title: string | undefined, description: string): string {
const heading = title ? `${taskId}: ${title}` : taskId;
return `# ${heading}\n\n${description}\n`;
}
/**
* Detect whether a PROMPT.md body is the auto-generated bootstrap stub
* (`# heading\n\n<description>\n`) that `createTask` writes for triage tasks,
* versus a real specification produced by triage or planning.
*
* Detection is wrapper-shape-exact: the on-disk content is compared against
* the exact bytes `createTask` would have written for the *pre-update*
* title/description. Earlier heuristic detectors (size caps, `##` header
* presence, `**Created:**` / `**Size:**` markers) misfired on imported issue
* bodies that contain `## Repro`, `**Created:** ...`, etc. — those are real
* stubs but look like real specs to a content-inspecting check. By matching
* against the wrapper produced from the previous title/description, we are
* robust to anything the description itself contains.
*/
function isBootstrapPromptStub(
content: string,
taskId: string,
preUpdateTitle: string | undefined,
preUpdateDescription: string,
): boolean {
return content === buildBootstrapPrompt(taskId, preUpdateTitle, preUpdateDescription);
}
/**
* Replace just the leading `# ...` heading line of a PROMPT.md body, leaving
* every other section untouched. Used when a metadata edit (title or
* description change) needs to keep the displayed heading in sync without
* disturbing the rest of a real specification.
*
* If the file does not start with a `#` heading, it is returned verbatim —
* the caller has no clean place to splice the heading and the spec's content
* is more important to preserve than the displayed title (task.json is the
* canonical source for title/description anyway).
*/
function rewriteHeadingLine(content: string, newHeading: string): string {
const match = content.match(/^#[^\n]*\n?/);
if (!match) {
return content;
}
const trailingNewline = match[0].endsWith("\n") ? "\n" : "";
return `# ${newHeading}${trailingNewline}${content.slice(match[0].length)}`;
}
/**
* Replace the body of the `## Mission` section with `newDescription`, leaving
* every other section untouched. Used to propagate `task.description` edits
* into a real spec without disturbing custom sections (Review Level, Frontend
* UX Criteria, File Scope, Acceptance Criteria, etc.) that a section-whitelist
* regen would silently drop.
*
* Returns the original content unchanged if there is no `## Mission` section.
*/
function rewriteMissionSection(content: string, newDescription: string): string {
const missionMatch = content.match(/^##\s+Mission\s*$/m);
if (!missionMatch || missionMatch.index === undefined) {
return content;
}
const headerEnd = missionMatch.index + missionMatch[0].length;
const rest = content.slice(headerEnd);
// Find the next `## ` heading (start of next section). The match position is
// relative to `rest`, so we re-anchor to the absolute offset.
const nextHeading = rest.search(/\n##\s/);
const sectionEndAbsolute = nextHeading === -1 ? content.length : headerEnd + nextHeading;
const before = content.slice(0, headerEnd);
const after = content.slice(sectionEndAbsolute);
// Reconstruct: header line + blank line + new description + blank line +
// trailing content (which begins with the newline before the next heading).
return `${before}\n\n${newDescription}\n${after}`;
}
/**
* Canonicalizes a settings object by stripping legacy fields that are no longer valid
* and rewriting legacy path values left over from the kb → fn rename.
@@ -2179,9 +2259,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task });
const heading = task.title ? `${id}: ${task.title}` : id;
const prompt = task.column === "triage"
? `# ${heading}\n\n${task.description}\n`
? buildBootstrapPrompt(id, task.title, task.description)
: this.generateSpecifiedPrompt(task);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), prompt);
@@ -2848,6 +2927,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Capture title/description before mutation so the PROMPT.md stub
// detector below can compare against the exact wrapper bytes that the
// pre-edit task would have produced. This is what makes detection
// robust to descriptions that contain `##` headings or `**Created:**`
// text (e.g. imported GitHub issue bodies) — we never inspect the
// description content, only the wrapper shape.
const preUpdateTitle = task.title;
const preUpdateDescription = task.description;
if (updates.nodeId !== undefined) {
const validation = validateNodeOverrideChange(task, updates.nodeId ?? null);
if (!validation.allowed) {
@@ -3122,23 +3210,57 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
}
// Regenerate PROMPT.md when title or description changes (but not when explicit prompt update)
// Sync PROMPT.md when title or description changes (but not when explicit
// prompt update — that already wrote the new content above).
//
// Two distinct cases:
//
// (a) Bootstrap stub — the auto-generated `# heading\n\n<desc>\n` block
// `createTask` writes. Rewrite the whole file from the new title +
// description so the human-visible stub stays in sync.
//
// (b) Real specification (any `##` section header, or the `**Created:**`
// / `**Size:**` metadata the triage prompt format requires). Do NOT
// rebuild the file from a section whitelist — earlier regressions
// either clobbered the spec entirely (FN-3056 + the previous
// `regeneratePrompt` path while column='triage') or silently dropped
// `## Review Level` / `## Frontend UX Criteria` and other custom
// sections (the same regen call on column!='triage'), which left the
// executor with reset review levels and missing UX guidance. Instead
// just splice the leading `#` heading line so the displayed title
// stays in sync with task.json; the body is preserved verbatim.
//
// task.json remains the canonical source for title/description fields.
// PROMPT.md is only ever fully rewritten via explicit `updates.prompt`.
if (updates.prompt === undefined && (updates.title !== undefined || updates.description !== undefined)) {
const promptPath = join(dir, "PROMPT.md");
if (existsSync(promptPath)) {
const existingPrompt = await readFile(promptPath, "utf-8");
let newPrompt: string;
if (task.column === "triage") {
// Simple format for triage tasks: # heading\n\ndescription
const heading = task.title ? `${task.id}: ${task.title}` : task.id;
newPrompt = `# ${heading}\n\n${task.description}\n`;
if (isBootstrapPromptStub(existingPrompt, task.id, preUpdateTitle, preUpdateDescription)) {
const newPrompt = buildBootstrapPrompt(task.id, task.title, task.description);
await writeFile(promptPath, newPrompt);
} else {
// Structured format for other columns - preserve sections
newPrompt = this.regeneratePrompt(task, existingPrompt);
// Real spec — surgical edits only. Each section we propagate to is
// edited in place; everything else (Review Level, Frontend UX
// Criteria, custom sections from triage) is preserved verbatim.
let next = existingPrompt;
if (updates.title !== undefined) {
// Match the existing heading style: triage emits
// `# Task: {id} - {title}`; createTask uses `# {id}: {title}`.
const triageStyle = /^#\s+Task:\s+[A-Z]+-\d+\s+-\s+/m.test(existingPrompt);
const heading = triageStyle
? (task.title ? `Task: ${task.id} - ${task.title}` : `Task: ${task.id}`)
: (task.title ? `${task.id}: ${task.title}` : task.id);
next = rewriteHeadingLine(next, heading);
}
if (updates.description !== undefined) {
next = rewriteMissionSection(next, task.description);
}
if (next !== existingPrompt) {
await writeFile(promptPath, next);
}
}
await writeFile(promptPath, newPrompt);
}
}
@@ -6025,62 +6147,6 @@ ${deps}
${notificationsSection}`;
}
/**
* Regenerate PROMPT.md when task title or description changes.
* Preserves existing sections (Dependencies, Steps, File Scope, etc.) from the original prompt,
* while updating the heading and Mission section with new values.
*/
private regeneratePrompt(task: Task, existingPrompt: string): string {
// Generate the new heading
const heading = task.title ? `${task.id}: ${task.title}` : task.id;
// Helper to extract a section by heading name
const extractSection = (sectionName: string): string | null => {
const regex = new RegExp(`^##\\s+${sectionName}\\s*$`, "m");
const match = existingPrompt.match(regex);
if (!match) return null;
const startIdx = match.index! + match[0].length;
const rest = existingPrompt.slice(startIdx);
// Find next ## heading (any level) or end of string
const nextHeading = rest.search(/\n##\\s/);
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
return section.trim();
};
// Extract preserved sections
const depsSection = extractSection("Dependencies");
const stepsSection = extractSection("Steps");
const fileScopeSection = extractSection("File Scope");
const acceptanceSection = extractSection("Acceptance Criteria");
const notificationsSection = extractSection("Notifications");
// Reconstruct PROMPT.md with preserved sections
let result = `# ${heading}\n\n**Created:** ${task.createdAt.split("T")[0]}\n**Size:** ${task.size || "M"}\n\n## Mission\n\n${task.description}\n`;
if (depsSection !== null) {
result += `\n## Dependencies\n\n${depsSection}\n`;
}
if (stepsSection !== null) {
result += `\n## Steps\n\n${stepsSection}\n`;
}
if (fileScopeSection !== null) {
result += `\n## File Scope\n\n${fileScopeSection}\n`;
}
if (acceptanceSection !== null) {
result += `\n## Acceptance Criteria\n\n${acceptanceSection}\n`;
}
if (notificationsSection !== null) {
result += `\n## Notifications\n\n${notificationsSection}\n`;
}
return result;
}
/**
* Synchronous version of getSettings for internal use.
* Returns project-level settings merged with defaults.

View File

@@ -1974,15 +1974,23 @@ export class TriageProcessor {
taskUpdates.reviewLevel = parseInt(reviewMatch[1], 10);
}
// Apply non-title metadata first. The title is held back and applied AFTER
// the column transition (see below) because store.updateTask regenerates
// PROMPT.md when title/description change, and the triage-stub regen path
// would overwrite the freshly-written specification while column='triage'.
// The store now also guards that regen against real specs, but we keep this
// ordering as defense in depth so a future change to the guard can't
// resurrect the regression.
const promptDeclaredTitle = extractPromptDeclaredTitle(written, task.id);
if (promptDeclaredTitle) {
taskUpdates.title = promptDeclaredTitle;
}
await this.store.updateTask(task.id, taskUpdates);
if (settings.requirePlanApproval) {
await this.store.updateTask(task.id, { status: "awaiting-approval" });
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
if (promptDeclaredTitle) {
approvalUpdates.title = promptDeclaredTitle;
}
await this.store.updateTask(task.id, approvalUpdates);
await this.store.logEntry(
task.id,
options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval",
@@ -1993,6 +2001,10 @@ export class TriageProcessor {
await this.store.moveTask(task.id, "todo");
if (promptDeclaredTitle) {
await this.store.updateTask(task.id, { title: promptDeclaredTitle });
}
if (options.recoveryLogAction) {
await this.store.logEntry(task.id, options.recoveryLogAction);
planLog.log(`${task.id} recovered and moved to todo`);