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

@@ -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({