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");
|
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 () => {
|
it("persists sourceIssue on create and reload", async () => {
|
||||||
const sourceIssue = createSourceIssueFixture();
|
const sourceIssue = createSourceIssueFixture();
|
||||||
const created = await store.createTask({
|
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
|
* 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.
|
* 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
|
// Update cache if watcher is active
|
||||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
const heading = task.title ? `${id}: ${task.title}` : id;
|
|
||||||
const prompt = task.column === "triage"
|
const prompt = task.column === "triage"
|
||||||
? `# ${heading}\n\n${task.description}\n`
|
? buildBootstrapPrompt(id, task.title, task.description)
|
||||||
: this.generateSpecifiedPrompt(task);
|
: this.generateSpecifiedPrompt(task);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
await writeFile(join(dir, "PROMPT.md"), prompt);
|
await writeFile(join(dir, "PROMPT.md"), prompt);
|
||||||
@@ -2848,6 +2927,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const dir = this.taskDir(id);
|
const dir = this.taskDir(id);
|
||||||
const task = await this.readTaskJson(dir);
|
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) {
|
if (updates.nodeId !== undefined) {
|
||||||
const validation = validateNodeOverrideChange(task, updates.nodeId ?? null);
|
const validation = validateNodeOverrideChange(task, updates.nodeId ?? null);
|
||||||
if (!validation.allowed) {
|
if (!validation.allowed) {
|
||||||
@@ -3122,23 +3210,57 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
|
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)) {
|
if (updates.prompt === undefined && (updates.title !== undefined || updates.description !== undefined)) {
|
||||||
const promptPath = join(dir, "PROMPT.md");
|
const promptPath = join(dir, "PROMPT.md");
|
||||||
if (existsSync(promptPath)) {
|
if (existsSync(promptPath)) {
|
||||||
const existingPrompt = await readFile(promptPath, "utf-8");
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (isBootstrapPromptStub(existingPrompt, task.id, preUpdateTitle, preUpdateDescription)) {
|
||||||
|
const newPrompt = buildBootstrapPrompt(task.id, task.title, task.description);
|
||||||
await writeFile(promptPath, newPrompt);
|
await writeFile(promptPath, newPrompt);
|
||||||
|
} else {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6025,62 +6147,6 @@ ${deps}
|
|||||||
${notificationsSection}`;
|
${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.
|
* Synchronous version of getSettings for internal use.
|
||||||
* Returns project-level settings merged with defaults.
|
* Returns project-level settings merged with defaults.
|
||||||
|
|||||||
@@ -1974,15 +1974,23 @@ export class TriageProcessor {
|
|||||||
taskUpdates.reviewLevel = parseInt(reviewMatch[1], 10);
|
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);
|
const promptDeclaredTitle = extractPromptDeclaredTitle(written, task.id);
|
||||||
if (promptDeclaredTitle) {
|
|
||||||
taskUpdates.title = promptDeclaredTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.store.updateTask(task.id, taskUpdates);
|
await this.store.updateTask(task.id, taskUpdates);
|
||||||
|
|
||||||
if (settings.requirePlanApproval) {
|
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(
|
await this.store.logEntry(
|
||||||
task.id,
|
task.id,
|
||||||
options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval",
|
options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval",
|
||||||
@@ -1993,6 +2001,10 @@ export class TriageProcessor {
|
|||||||
|
|
||||||
await this.store.moveTask(task.id, "todo");
|
await this.store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
|
if (promptDeclaredTitle) {
|
||||||
|
await this.store.updateTask(task.id, { title: promptDeclaredTitle });
|
||||||
|
}
|
||||||
|
|
||||||
if (options.recoveryLogAction) {
|
if (options.recoveryLogAction) {
|
||||||
await this.store.logEntry(task.id, options.recoveryLogAction);
|
await this.store.logEntry(task.id, options.recoveryLogAction);
|
||||||
planLog.log(`✓ ${task.id} recovered and moved to todo`);
|
planLog.log(`✓ ${task.id} recovered and moved to todo`);
|
||||||
|
|||||||
Reference in New Issue
Block a user