feat(FN-679): regenerate PROMPT.md on task title/description updates
- Implement automatic PROMPT.md regeneration in updateTask() when title or description changes - Add comprehensive tests for PROMPT.md sync behavior in store.test.ts - Create changeset for the PROMPT.md sync fix - Remove multi-project migration changeset (superseded) - Update dashboard components and styles for setup wizard and provider icons
This commit is contained in:
@@ -101,7 +101,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
mergeDetails TEXT,
|
||||
breakIntoSubtasks INTEGER DEFAULT 0,
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]',
|
||||
modifiedFiles TEXT DEFAULT '[]'
|
||||
modifiedFiles TEXT DEFAULT '[]',
|
||||
sliceId TEXT
|
||||
);
|
||||
|
||||
-- Config table (single row with project settings)
|
||||
|
||||
@@ -1099,6 +1099,226 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask — PROMPT.md regeneration", () => {
|
||||
it("regenerates PROMPT.md when title is updated", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Verify initial PROMPT.md
|
||||
const initialPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(initialPrompt).toContain(`# ${task.id}`);
|
||||
expect(initialPrompt).toContain("Test task");
|
||||
|
||||
// Update title
|
||||
await store.updateTask(task.id, { title: "New Title" });
|
||||
|
||||
// Verify PROMPT.md was regenerated with new title
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toContain(`# ${task.id}: New Title`);
|
||||
expect(updatedPrompt).toContain("Test task"); // Description preserved
|
||||
});
|
||||
|
||||
it("regenerates PROMPT.md when description is updated", async () => {
|
||||
const task = await store.createTask({ description: "Old description", column: "todo" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Verify initial PROMPT.md
|
||||
const initialPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(initialPrompt).toContain("Old description");
|
||||
|
||||
// Update description
|
||||
await store.updateTask(task.id, { description: "New description" });
|
||||
|
||||
// Verify PROMPT.md was regenerated with new description
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toContain("New description");
|
||||
});
|
||||
|
||||
it("preserves existing steps when regenerating PROMPT.md", async () => {
|
||||
const task = await store.createTask({ description: "Task with steps", column: "todo" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Write custom steps to PROMPT.md
|
||||
const customPrompt = `# ${task.id}: Task with steps
|
||||
|
||||
**Created:** ${task.createdAt.split("T")[0]}
|
||||
**Size:** M
|
||||
|
||||
## Mission
|
||||
|
||||
Task with steps
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1: Custom Step
|
||||
|
||||
- [ ] Custom action 1
|
||||
- [ ] Custom action 2
|
||||
|
||||
### Step 2: Another Custom Step
|
||||
|
||||
- [ ] Another action
|
||||
`;
|
||||
await writeFile(join(dir, "PROMPT.md"), customPrompt);
|
||||
|
||||
// Update title
|
||||
await store.updateTask(task.id, { title: "Updated Title" });
|
||||
|
||||
// Verify custom steps are preserved
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toContain(`# ${task.id}: Updated Title`);
|
||||
expect(updatedPrompt).toContain("### Step 1: Custom Step");
|
||||
expect(updatedPrompt).toContain("- [ ] Custom action 1");
|
||||
expect(updatedPrompt).toContain("### Step 2: Another Custom Step");
|
||||
expect(updatedPrompt).toContain("- [ ] Another action");
|
||||
});
|
||||
|
||||
it("preserves file scope when regenerating PROMPT.md", async () => {
|
||||
const task = await store.createTask({ description: "Task with file scope", column: "todo" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Write PROMPT.md with custom file scope
|
||||
const customPrompt = `# ${task.id}: Task with file scope
|
||||
|
||||
**Created:** ${task.createdAt.split("T")[0]}
|
||||
**Size:** M
|
||||
|
||||
## Mission
|
||||
|
||||
Task with file scope
|
||||
|
||||
## File Scope
|
||||
|
||||
- \`src/store.ts\`
|
||||
- \`src/db.ts\`
|
||||
`;
|
||||
await writeFile(join(dir, "PROMPT.md"), customPrompt);
|
||||
|
||||
// Update description
|
||||
await store.updateTask(task.id, { description: "Updated description" });
|
||||
|
||||
// Verify file scope is preserved
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toContain("Updated description");
|
||||
expect(updatedPrompt).toContain("## File Scope");
|
||||
expect(updatedPrompt).toContain("`src/store.ts`");
|
||||
expect(updatedPrompt).toContain("`src/db.ts`");
|
||||
});
|
||||
|
||||
it("preserves dependencies section when regenerating PROMPT.md", async () => {
|
||||
const task = await store.createTask({ description: "Task with deps", column: "todo", dependencies: ["KB-001"] });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Verify initial PROMPT.md has dependencies
|
||||
const initialPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(initialPrompt).toContain("## Dependencies");
|
||||
expect(initialPrompt).toContain("- **Task:** KB-001");
|
||||
|
||||
// Update title
|
||||
await store.updateTask(task.id, { title: "Updated Title" });
|
||||
|
||||
// Verify dependencies section is preserved
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toContain("## Dependencies");
|
||||
expect(updatedPrompt).toContain("- **Task:** KB-001");
|
||||
});
|
||||
|
||||
it("preserves acceptance criteria section when regenerating PROMPT.md", async () => {
|
||||
const task = await store.createTask({ description: "Task with acceptance criteria", column: "todo" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Write PROMPT.md with acceptance criteria
|
||||
const customPrompt = `# ${task.id}: Task with acceptance criteria
|
||||
|
||||
**Created:** ${task.createdAt.split("T")[0]}
|
||||
**Size:** M
|
||||
|
||||
## Mission
|
||||
|
||||
Task with acceptance criteria
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
- [ ] Criterion 3
|
||||
`;
|
||||
await writeFile(join(dir, "PROMPT.md"), customPrompt);
|
||||
|
||||
// Update description
|
||||
await store.updateTask(task.id, { description: "Updated description" });
|
||||
|
||||
// Verify acceptance criteria is preserved
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toContain("Updated description");
|
||||
expect(updatedPrompt).toContain("## Acceptance Criteria");
|
||||
expect(updatedPrompt).toContain("- [ ] Criterion 1");
|
||||
expect(updatedPrompt).toContain("- [ ] Criterion 2");
|
||||
expect(updatedPrompt).toContain("- [ ] Criterion 3");
|
||||
});
|
||||
|
||||
it("updates simple PROMPT.md for triage tasks", async () => {
|
||||
const task = await store.createTask({ description: "Triage task", column: "triage" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Verify initial simple format
|
||||
const initialPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(initialPrompt).toBe(`# ${task.id}\n\nTriage task\n`);
|
||||
|
||||
// Update title
|
||||
await store.updateTask(task.id, { title: "Updated Title" });
|
||||
|
||||
// Verify simple format is maintained but updated
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toBe(`# ${task.id}: Updated Title\n\nTriage task\n`);
|
||||
});
|
||||
|
||||
it("updates description in simple PROMPT.md for triage tasks", async () => {
|
||||
const task = await store.createTask({ title: "My Task", description: "Original desc", column: "triage" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Verify initial simple format
|
||||
const initialPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(initialPrompt).toBe(`# ${task.id}: My Task\n\nOriginal desc\n`);
|
||||
|
||||
// Update description
|
||||
await store.updateTask(task.id, { description: "Updated desc" });
|
||||
|
||||
// Verify simple format is maintained but updated
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toBe(`# ${task.id}: My Task\n\nUpdated desc\n`);
|
||||
});
|
||||
|
||||
it("does not regenerate PROMPT.md when explicit prompt is provided", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Update with explicit prompt
|
||||
const customPrompt = "# Custom\n\nCustom prompt content";
|
||||
await store.updateTask(task.id, { title: "Updated Title", prompt: customPrompt });
|
||||
|
||||
// Verify the explicit prompt was used, not regenerated
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toBe(customPrompt);
|
||||
});
|
||||
|
||||
it("does not regenerate PROMPT.md when neither title nor description changes", async () => {
|
||||
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
|
||||
// Write custom PROMPT.md
|
||||
const customPrompt = `# ${task.id}\n\n**Created:** 2024-01-01\n**Size:** L\n\n## Mission\n\nTest task\n\n## Custom Section\n\nCustom content\n`;
|
||||
await writeFile(join(dir, "PROMPT.md"), customPrompt);
|
||||
|
||||
// Update worktree only
|
||||
await store.updateTask(task.id, { worktree: "/tmp/worktree" });
|
||||
|
||||
// Verify PROMPT.md was not changed
|
||||
const updatedPrompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
expect(updatedPrompt).toBe(customPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent log persistence", () => {
|
||||
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
@@ -992,6 +992,26 @@ 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)
|
||||
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`;
|
||||
} else {
|
||||
// Structured format for other columns - preserve sections
|
||||
newPrompt = this.regeneratePrompt(task, existingPrompt);
|
||||
}
|
||||
|
||||
await writeFile(promptPath, newPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
if (movedToTriage) {
|
||||
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column });
|
||||
}
|
||||
@@ -2598,6 +2618,62 @@ ${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.
|
||||
|
||||
Reference in New Issue
Block a user