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:
16
.changeset/triage-stub-clobber-fix.md
Normal file
16
.changeset/triage-stub-clobber-fix.md
Normal 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 70–200 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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user