feat(FN-760): use source task labels for refinement titles

- Derive readable refinement titles from source task title or first line of description instead of raw task ID
- Fall back to task ID only when both title and description are empty/whitespace
- Collapse internal whitespace in description-derived labels for clean output
- Update PROMPT.md heading to match the new readable refinement title
- Add comprehensive tests for title precedence, whitespace handling, and edge cases
- Document refinement naming behavior in README
- Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
gsxdsm
2026-04-02 22:48:00 -07:00
parent 89b780e90f
commit d8cc666964
4 changed files with 125 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Refinement tasks now use the source task's title (or description when untitled) instead of the raw task ID for readable names like "Refinement: Fix login bug" instead of "Refinement: FN-741".

View File

@@ -145,6 +145,8 @@ fn task pause FN-001 # Pause automation for task
fn task unpause FN-001 # Resume automation for task
```
**Refinement tasks** created with `fn task refine` inherit a readable label from the source task. The title follows the format `Refinement: {source label}` where the source label is the original task's title (if set), otherwise the first line of the original description. This keeps refinement task names meaningful even when the source task has no explicit title.
**GitHub Integration:**
```bash
fn task import owner/repo # Import all open issues

View File

@@ -2910,7 +2910,8 @@ Task with acceptance criteria
expect(refined.id).not.toBe(task.id);
expect(refined.id).toMatch(/^FN-\d+$/);
expect(refined.column).toBe("triage");
expect(refined.title).toBe(`Refinement: ${task.id}`);
// Untitled source: uses first line of description as readable label
expect(refined.title).toBe("Refinement: Original task");
});
it("creates refinement from in-review task", async () => {
@@ -2922,7 +2923,8 @@ Task with acceptance criteria
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.column).toBe("triage");
expect(refined.title).toBe(`Refinement: ${task.id}`);
// Untitled source: uses first line of description as readable label
expect(refined.title).toBe("Refinement: Original task");
});
it("throws error when refining task in triage", async () => {
@@ -2979,8 +2981,8 @@ Task with acceptance criteria
expect(refined.title).toBe("Refinement: My Feature");
});
it("sets correct title format without original title (uses ID)", async () => {
const task = await store.createTask({ description: "Original task" });
it("sets correct title format without original title (uses description fallback)", async () => {
const task = await store.createTask({ description: "Fix the login bug" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
@@ -2988,7 +2990,8 @@ Task with acceptance criteria
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe(`Refinement: ${task.id}`);
// Falls back to first line of description when no title
expect(refined.title).toBe("Refinement: Fix the login bug");
});
it("description includes feedback and refines reference", async () => {
@@ -3120,11 +3123,102 @@ Task with acceptance criteria
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(detail.prompt).toContain(`Refinement: ${task.id}`);
// Untitled source: uses first line of description
expect(detail.prompt).toContain("Refinement: Original task");
expect(detail.prompt).toContain("Need improvements");
expect(detail.prompt).toContain(`Refines: ${task.id}`);
});
it("uses first non-empty line of description when title is absent", async () => {
const task = await store.createTask({
description: "Use source task labels for refinement titles\n\nThis is a longer description.",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe("Refinement: Use source task labels for refinement titles");
});
it("collapses internal whitespace in description fallback", async () => {
const task = await store.createTask({
description: "Fix the \t spacing issue in UI",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "More feedback");
expect(refined.title).toBe("Refinement: Fix the spacing issue in UI");
});
it("skips leading blank lines in multi-line description", async () => {
const task = await store.createTask({
description: "\n \n \nFirst real line of description\nSecond line",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Feedback");
expect(refined.title).toBe("Refinement: First real line of description");
});
it("falls back to task ID when description has no non-empty lines", async () => {
// Create a task with a valid description, then update to all-whitespace
// (createTask rejects all-whitespace descriptions, but updates could produce this edge case)
const task = await store.createTask({ description: "Valid description" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.updateTask(task.id, { description: " \n \n\t\n" });
const refined = await store.refineTask(task.id, "Feedback");
expect(refined.title).toBe(`Refinement: ${task.id}`);
});
it("PROMPT.md heading matches the refinement title", async () => {
const task = await store.createTask({
title: "My Feature",
description: "Some description",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(refined.title).toBe("Refinement: My Feature");
expect(detail.prompt).toMatch(/^# Refinement: My Feature\n/);
});
it("PROMPT.md heading uses description fallback when untitled", async () => {
const task = await store.createTask({
description: "Fix the login bug on settings page",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(refined.title).toBe("Refinement: Fix the login bug on settings page");
expect(detail.prompt).toMatch(/^# Refinement: Fix the login bug on settings page\n/);
});
it("throws ENOENT when source task does not exist", async () => {
await expect(store.refineTask("KB-999", "Feedback")).rejects.toThrow();
});

View File

@@ -793,10 +793,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const newId = await this.allocateId();
const now = new Date().toISOString();
// Derive a readable source label for the refinement title.
// Precedence: title → first non-empty line of description (collapsed) → task ID
let sourceLabel: string;
if (sourceTask.title?.trim()) {
sourceLabel = sourceTask.title.trim();
} else {
const firstLine = sourceTask.description
.split("\n")
.map((line: string) => line.trim())
.find((line: string) => line.length > 0);
if (firstLine) {
sourceLabel = firstLine.replace(/\s+/g, " ");
} else {
sourceLabel = sourceTask.id;
}
}
// Create new refinement task
const newTask: Task = {
id: newId,
title: `Refinement: ${sourceTask.title || sourceTask.id}`,
title: `Refinement: ${sourceLabel}`,
description: `${feedback.trim()}\n\nRefines: ${id}`,
column: "triage",
dependencies: [id], // Refinement depends on the original being complete