diff --git a/packages/core/src/__tests__/delete-task-if-planning.test.ts b/packages/core/src/__tests__/delete-task-if-planning.test.ts index 7c13a26732..990dbbf128 100644 --- a/packages/core/src/__tests__/delete-task-if-planning.test.ts +++ b/packages/core/src/__tests__/delete-task-if-planning.test.ts @@ -8,6 +8,13 @@ FN-8361 verifies conditional deletion through the real supported TaskStore storage path. SQLite runtime support was removed (VAL-REMOVAL-005), so this uses the PostgreSQL harness instead of a hand-built delete-helper mock. */ +/* +FNXC:MergedPlanningColumn 2026-07-29-15:15 (U11 post-merge audit): +Same fixture drift as move-task-if-planning: a freshly created default-workflow card now rests in +the merged planning column `todo` rather than `triage`, so the live predicates and the "advanced" +column are updated to match. The mechanism under test — deleteTaskIf honouring a live predicate and +refusing an advanced stale candidate — is unchanged. +*/ pgDescribe("deleteTaskIf live storage path", () => { const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_delete_task_if" }); beforeAll(harness.beforeAll); @@ -18,7 +25,7 @@ pgDescribe("deleteTaskIf live storage path", () => { it("soft-deletes only after its live predicate passes", async () => { const store = harness.store(); const task = await store.createTask({ description: "conditional delete" }); - const result = await store.deleteTaskIf(task.id, (live) => live.column === "triage"); + const result = await store.deleteTaskIf(task.id, (live) => live.column === "todo"); expect(result.deleted).toBe(true); expect(result.task).toMatchObject({ column: "archived" }); @@ -32,11 +39,11 @@ pgDescribe("deleteTaskIf live storage path", () => { expect((await store.getTask(falseTask.id))?.deletedAt).toBeFalsy(); const staleTask = await store.createTask({ description: "advanced conditional delete" }); - await store.getTask(staleTask.id); // Caller captured a stale triage candidate. - await store.moveTask(staleTask.id, "todo"); - const stale = await store.deleteTaskIf(staleTask.id, (live) => live.column === "triage"); - expect(stale).toMatchObject({ deleted: false, task: { column: "todo" } }); - expect(await store.getTask(staleTask.id)).toMatchObject({ column: "todo", deletedAt: undefined }); + await store.getTask(staleTask.id); // Caller captured a stale planning-column candidate. + await store.moveTask(staleTask.id, "in-progress"); + const stale = await store.deleteTaskIf(staleTask.id, (live) => live.column === "todo"); + expect(stale).toMatchObject({ deleted: false, task: { column: "in-progress" } }); + expect(await store.getTask(staleTask.id)).toMatchObject({ column: "in-progress", deletedAt: undefined }); }); it("preserves self-delete and lineage lifecycle guards", async () => { diff --git a/packages/core/src/__tests__/move-task-if-planning.test.ts b/packages/core/src/__tests__/move-task-if-planning.test.ts index c4993d8007..2db8ee93d0 100644 --- a/packages/core/src/__tests__/move-task-if-planning.test.ts +++ b/packages/core/src/__tests__/move-task-if-planning.test.ts @@ -7,6 +7,14 @@ FN-8361 exercises the live TaskStore persistence path. SQLite runtime support was removed (VAL-REMOVAL-005), so the PostgreSQL harness is the real supported storage path rather than a mocked moveTaskInternal seam. */ +/* +FNXC:MergedPlanningColumn 2026-07-29-15:15 (U11 post-merge audit): +These fixtures create a task and then assert the LIVE-PREDICATE mechanism. U11 merged Todo into +Planning, so a freshly created default-workflow card now rests in `todo` rather than `triage` — +the predicates and the intermediate column are updated to match where the card actually is. What is +under test (moveTaskIf honouring a live predicate, skipping false ones, no-opping same-column) is +unchanged; only the column vocabulary moved underneath it. +*/ pgDescribe("moveTaskIf live storage path", () => { const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_move_task_if" }); beforeAll(harness.beforeAll); @@ -17,27 +25,27 @@ pgDescribe("moveTaskIf live storage path", () => { it("moves only when the live predicate permits a real transition", async () => { const store = harness.store(); const task = await store.createTask({ description: "conditional move" }); - const result = await store.moveTaskIf(task.id, "todo", (live) => live.column === "triage"); + const result = await store.moveTaskIf(task.id, "in-progress", (live) => live.column === "todo"); expect(result.moved).toBe(true); - expect(result.task.column).toBe("todo"); - expect((await store.getTask(task.id))?.column).toBe("todo"); + expect(result.task.column).toBe("in-progress"); + expect((await store.getTask(task.id))?.column).toBe("in-progress"); }); it("skips false predicates, advanced stale candidates, and same-column no-ops", async () => { const store = harness.store(); const falseTask = await store.createTask({ description: "false conditional move" }); - expect((await store.moveTaskIf(falseTask.id, "todo", () => false)).moved).toBe(false); - expect((await store.getTask(falseTask.id))?.column).toBe("triage"); + expect((await store.moveTaskIf(falseTask.id, "in-progress", () => false)).moved).toBe(false); + expect((await store.getTask(falseTask.id))?.column).toBe("todo"); const staleTask = await store.createTask({ description: "stale conditional move" }); - await store.getTask(staleTask.id); // Caller captured a stale triage candidate. - await store.moveTask(staleTask.id, "todo"); - const stale = await store.moveTaskIf(staleTask.id, "todo", (live) => live.column === "triage"); - expect(stale).toMatchObject({ moved: false, task: { column: "todo" } }); + await store.getTask(staleTask.id); // Caller captured a stale planning-column candidate. + await store.moveTask(staleTask.id, "in-progress"); + const stale = await store.moveTaskIf(staleTask.id, "in-progress", (live) => live.column === "todo"); + expect(stale).toMatchObject({ moved: false, task: { column: "in-progress" } }); - const sameColumn = await store.moveTaskIf(staleTask.id, "todo", () => true); + const sameColumn = await store.moveTaskIf(staleTask.id, "in-progress", () => true); expect(sameColumn.moved).toBe(false); - expect(sameColumn.task.column).toBe("todo"); + expect(sameColumn.task.column).toBe("in-progress"); }); }); diff --git a/packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts b/packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts index 839cbb3bfa..087e1e0352 100644 --- a/packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts +++ b/packages/core/src/__tests__/postgres/activity-log-parity.pg.test.ts @@ -42,7 +42,7 @@ pgDescribe("activity log parity (PostgreSQL)", () => { title: "Backend lifecycle activity", description: "Verify PostgreSQL lifecycle activity logging", }); - await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); await vi.waitFor(async () => { const entries = await store.getActivityLog({ limit: 10 }); @@ -55,7 +55,9 @@ pgDescribe("activity log parity (PostgreSQL)", () => { expect.objectContaining({ type: "task:moved", taskId: task.id, - metadata: { from: "triage", to: "todo" }, + // FNXC:MergedPlanningColumn 2026-07-29-15:25 (U11): the default lineage's first column + // is now `todo`, so the first recorded transition leaves it rather than `triage`. + metadata: { from: "todo", to: "in-progress" }, }), ])); }); diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 74a2777333..5f53502939 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -385,7 +385,10 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { }); /* FNXC:MissionAdmission 2026-07-23-21:10: a late same-fingerprint task claimed by another feature is not a duplicate eligible for archival. */ - expect(await taskStore.getTask(siblingTask.id)).toMatchObject({ id: siblingTask.id, column: "triage" }); + /* FNXC:MergedPlanningColumn 2026-07-29-15:30 (U11): the assertion is "not archived" — the card + stays where it was created, which for the default lineage is now the merged planning column + `todo` rather than `triage`. */ + expect(await taskStore.getTask(siblingTask.id)).toMatchObject({ id: siblingTask.id, column: "todo" }); expect(await m.getFeature(siblingFeature.id)).toMatchObject({ taskId: siblingTask.id, status: "triaged" }); expect(await m.getFeature(firstFeature.id)).toMatchObject({ taskId: claimedTask.id, status: "triaged" }); }); diff --git a/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts b/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts index 096f0dec9f..28c8abe225 100644 --- a/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts +++ b/packages/core/src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts @@ -3,7 +3,7 @@ * VAL-CROSS-001 — End-to-end task lifecycle (create → move columns → archive) * * Validates that the full task lifecycle works against PostgreSQL backend mode, - * covering: create, move through columns (triage → todo → in-progress → in-review → done), + * covering: create, move through columns (todo → in-progress → in-review → done), * archive, and unarchive. This is the critical cross-area flow that must work * after SQLite removal. */ @@ -30,7 +30,13 @@ pgTest("VAL-CROSS-001: End-to-end task lifecycle (PostgreSQL)", () => { const store = h.store(); const task = await store.createTask({ description: "E2E lifecycle task" }); expect(task.id).toBeTruthy(); - expect(task.column).toBe("triage"); + /* + FNXC:MergedPlanningColumn 2026-07-29-15:25 (U11 post-merge audit): + A freshly created default-workflow card now rests in the merged planning column `todo` — U11 + removed `triage` from the default lineage. The lifecycle being exercised is unchanged; only its + first column's id moved. + */ + expect(task.column).toBe("todo"); const fetched = await store.getTask(task.id); expect(fetched.id).toBe(task.id); diff --git a/packages/core/src/__tests__/postgres/workflow-reconciliation-production-shape.pg.test.ts b/packages/core/src/__tests__/postgres/workflow-reconciliation-production-shape.pg.test.ts index 46b8050a17..90e3d904b8 100644 --- a/packages/core/src/__tests__/postgres/workflow-reconciliation-production-shape.pg.test.ts +++ b/packages/core/src/__tests__/postgres/workflow-reconciliation-production-shape.pg.test.ts @@ -179,8 +179,18 @@ pgDescribe("U5 workflow reconciliation guards — production shape (no workflowC entry.traits = [...entry.traits, { trait: "wip", config: { limit: 1 } }]; const target = await store.createWorkflowDefinition({ name: "Capped target", ir: targetIr, layout: {} }); - // Occupy the single slot in the target workflow's entry column. - const filler = await store.createTask({ description: "fills the cap" }); + /* + Occupy the single slot in the TARGET workflow's entry column. + + FNXC:MergedPlanningColumn 2026-07-29-15:40 (U11 post-merge audit): + The filler's column must be explicit. It is created before the workflow switch, so it lands in + the PROJECT DEFAULT's intake column — which U11 merged to `todo`. The cap under test is on the + TARGET workflow's `triage` entry column (the target clones legacy-coding, which keeps the split + shape), so a filler in `todo` occupies nothing and the switch is no longer refused. Naming the + column here makes the fixture independent of whatever the project default's intake happens to + be, which is what let this drift in the first place. + */ + const filler = await store.createTask({ description: "fills the cap", column: "triage" }); await store.selectTaskWorkflow(filler.id, target.id); expect((await store.getTask(filler.id)).column).toBe("triage"); diff --git a/packages/core/src/__tests__/store-create-intake-column.test.ts b/packages/core/src/__tests__/store-create-intake-column.test.ts index 9f27a3d384..ccde3b9b39 100644 --- a/packages/core/src/__tests__/store-create-intake-column.test.ts +++ b/packages/core/src/__tests__/store-create-intake-column.test.ts @@ -29,10 +29,22 @@ pgTest("createTask intake-column wiring (Coding (Ideas))", () => { await h.afterEach(); }); - it("lands a default-workflow task in triage (byte-identical regression guard)", async () => { + /* + FNXC:MergedPlanningColumn 2026-07-29-14:55 (U11 post-merge audit): + This guarded "a default-workflow create lands in triage" and was byte-identical for as long as + the default workflow declared a `triage` column. U11 merged Todo into Planning, so the default's + intake column IS `todo` — the create landing there is the change working, and the assertion is + updated to name the invariant (the DEFAULT WORKFLOW'S OWN intake column) rather than the id that + used to hold it. + + Kept as a guard rather than deleted: it is the assertion that catches a regression back to the + hard-coded `"triage"` fallback, which is exactly the defect this commit fixes. + */ + it("lands a default-workflow task in the default workflow's intake column", async () => { const store = h.store(); const task = await store.createTask({ description: "default workflow task" }); - expect(task.column).toBe("triage"); + expect(task.column).toBe("todo"); + expect(task.column).not.toBe("triage"); }); it("lands a Coding (Ideas) task in the ideas intake column when selected explicitly", async () => { @@ -58,6 +70,61 @@ pgTest("createTask intake-column wiring (Coding (Ideas))", () => { a seed, so the card would sit in Planning forever with no log line in any lane — FN-8587's exact failure mode, for every new card rather than one edge case. */ + /* + FNXC:MergedPlanningColumn 2026-07-29-14:20 (U11 post-merge audit): + A project that has never explicitly set a default workflow has no persisted default row, so + `materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and + the create falls through to the hard-coded `|| "triage"`. + + That column no longer exists in the default workflow. Measured post-merge: a plain + `createTask({ description })` on such a project lands in `triage` while the same project's + default workflow declares intake as `todo`. Triage discovery resolves intake by trait, so + `isAtIntakeColumn` is false for that card and it is never admitted for planning; it is not in + the hold column either, so hold-release ignores it too. The card is only rescued when + `reconcileUndeclaredTaskColumns` re-homes it. + + This is the out-of-the-box state for a fresh project — `builtin:coding` is the IMPLICIT default + via DEFAULT_WORKFLOW_ID, and nothing writes a default-workflow row until an operator picks one. + */ + it("lands a plain create in the default workflow's intake column when no default row is persisted", async () => { + const store = h.store(); + // Deliberately NO setDefaultWorkflowId — the implicit-default, fresh-project shape. + const task = await store.createTask({ description: "plain create, no default workflow row" }); + expect(task.column).toBe("todo"); + expect(task.column).not.toBe("triage"); + }); + + /* + FNXC:MergedPlanningColumn 2026-07-29-17:05 (PR #2589 review — greptile): + `_createTaskInternalImpl` (the reserved-id / legacy path) had the same gap the backend path did: + it assigns `fallbackIntakeColumn` to the task's column but omitted it from `isIntakeColumn`, so a + create down that path lands in the resolved intake column and is then classified NOT-intake — + receiving `generateSpecifiedPrompt` instead of the bootstrap seed. Triage admits a card for + planning only when its PROMPT.md reads as a seed, so the card would rest in Planning already + looking "planned" and never be planned. + + Exercised through `createTaskWithReservedId`, which is the path the engine and mesh replication + use; the backend path is covered by the test above. Both paths need the assertion because they + are two independent copies of the same predicate. + */ + it("writes a bootstrap PROMPT.md on the RESERVED-ID path too (both create paths)", async () => { + const store = h.store(); + const task = await store.createTaskWithReservedId( + { description: "reserved-id create, no default workflow row" }, + { taskId: "FN-RSV-1" }, + ); + expect(task.column).toBe("todo"); + const prompt = await readFile(join(store.getTasksDir(), task.id, "PROMPT.md"), "utf-8"); + expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description)); + }); + + it("still writes a bootstrap PROMPT.md for that create, so triage can discover it", async () => { + const store = h.store(); + const task = await store.createTask({ description: "plain create, no default workflow row" }); + const prompt = await readFile(join(store.getTasksDir(), task.id, "PROMPT.md"), "utf-8"); + expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description)); + }); + it("lands a Coding (Ideas) task in ideas even when enabledWorkflowSteps is supplied", async () => { const store = h.store(); await store.setDefaultWorkflowId("builtin:coding-ideas"); diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 2b5a9f82b2..1122627146 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -25,6 +25,7 @@ import {generateTaskLineageId} from "../task-lineage.js"; import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicate-intake.js"; import {buildBootstrapPrompt} from "../mesh-task-replication.js"; import {resolveWorkflowIrById} from "../workflow-ir-resolver.js"; +import {DEFAULT_WORKFLOW_ID} from "../builtin-workflows.js"; import {columnsWithFlag} from "../workflow-lifecycle-traits.js"; import {validateFileScopeInPromptContent} from "../task-store/file-scope.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; @@ -82,8 +83,14 @@ Unresolvable workflow returns undefined and the caller keeps its existing legacy */ async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise { try { - const workflowId = await store.getDefaultWorkflowId(); - if (!workflowId) return undefined; + /* + FNXC:MergedPlanningColumn 2026-07-29-14:40 (U11 post-merge audit): + Fall back to DEFAULT_WORKFLOW_ID when no default row is persisted. A fresh project has none — + `builtin:coding` is the IMPLICIT default that every other resolver already uses — so returning + undefined here sent the create to the hard-coded `"triage"` literal, a column the merged + default workflow does not declare. + */ + const workflowId = (await store.getDefaultWorkflowId()) ?? DEFAULT_WORKFLOW_ID; const ir = await resolveWorkflowIrById(store, workflowId); return columnsWithFlag(ir, "intake")[0]; } catch { @@ -349,6 +356,28 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta const layer = store.asyncLayer!; const now = options?.createdAt ?? new Date().toISOString(); const normalizedTitle = normalizeTitleForTaskId(title, id); + /* + FNXC:MergedPlanningColumn 2026-07-29-14:30 (U11 post-merge audit): + A project that has never explicitly set a default workflow has no persisted default row, so + `materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and + the row below fell through to the hard-coded `|| "triage"`. That column no longer exists in the + default workflow, so the card landed in a lane its own workflow does not declare: triage + discovery resolves intake BY TRAIT and never admits it, hold-release ignores it, and only + `reconcileUndeclaredTaskColumns` eventually re-homes it. + + This is the OUT-OF-THE-BOX shape — `builtin:coding` is the IMPLICIT default via + DEFAULT_WORKFLOW_ID and nothing writes a default-workflow row until an operator picks one — so + it affected every new task on a fresh project rather than an edge case. + + Resolved side-effect-free and ONLY as a last resort before the literal, so every path that + already has an explicit column or a resolved entry column is untouched. The literal survives as + the final fallback for a store that cannot resolve any workflow at all. + */ + // `workflowId: null` is an explicit "No workflow" opt-out — there is no workflow whose intake + // column could be resolved, so that path keeps the legacy literal. + const fallbackIntakeColumn = (input.column || options?.resolvedEntryColumn || input.workflowId === null) + ? undefined + : await resolveDefaultWorkflowIntakeColumn(store); const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride); const task: Task = { id, @@ -375,7 +404,7 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta // FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its // workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when // no explicit column is given (main FN-7591 parity). - column: input.column || options?.resolvedEntryColumn || "triage", + column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage", dependencies: input.dependencies || [], breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined, noCommitsExpected: input.noCommitsExpected === true ? true : undefined, @@ -490,8 +519,17 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta const isUnplannedStartCreate = options?.resolvedEntryColumn !== undefined && options.resolvedEntryColumn !== "triage" && task.column === "todo"; + /* + FNXC:MergedPlanningColumn 2026-07-29-14:50 (U11 post-merge audit): + `fallbackIntakeColumn` must be honoured here too. Without it the card lands in the resolved + intake column (correct) but is classified NOT-intake and receives `generateSpecifiedPrompt` + instead of the bootstrap seed — and triage admits a card for planning only when its PROMPT.md + reads as a seed, so it would sit in Planning already looking "planned". That is FN-8587's + failure mode, reached by a different route. + */ const isIntakeColumn = task.column === "triage" || (options?.resolvedEntryColumn !== undefined && task.column === options.resolvedEntryColumn) + || (fallbackIntakeColumn !== undefined && task.column === fallbackIntakeColumn) || isUnplannedStartCreate; const usedBootstrapPrompt = !options?.promptOverride && isIntakeColumn; const prompt = options?.promptOverride @@ -761,6 +799,28 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat const now = options?.createdAt ?? new Date().toISOString(); // FN-5077: null normalized titles are treated as "no title" and allow standard fallback/summarization behavior. const normalizedTitle = normalizeTitleForTaskId(title, id); + /* + FNXC:MergedPlanningColumn 2026-07-29-14:30 (U11 post-merge audit): + A project that has never explicitly set a default workflow has no persisted default row, so + `materializeDefaultWorkflowSteps()` returns nothing, `resolvedEntryColumn` stays undefined, and + the row below fell through to the hard-coded `|| "triage"`. That column no longer exists in the + default workflow, so the card landed in a lane its own workflow does not declare: triage + discovery resolves intake BY TRAIT and never admits it, hold-release ignores it, and only + `reconcileUndeclaredTaskColumns` eventually re-homes it. + + This is the OUT-OF-THE-BOX shape — `builtin:coding` is the IMPLICIT default via + DEFAULT_WORKFLOW_ID and nothing writes a default-workflow row until an operator picks one — so + it affected every new task on a fresh project rather than an edge case. + + Resolved side-effect-free and ONLY as a last resort before the literal, so every path that + already has an explicit column or a resolved entry column is untouched. The literal survives as + the final fallback for a store that cannot resolve any workflow at all. + */ + // `workflowId: null` is an explicit "No workflow" opt-out — there is no workflow whose intake + // column could be resolved, so that path keeps the legacy literal. + const fallbackIntakeColumn = (input.column || options?.resolvedEntryColumn || input.workflowId === null) + ? undefined + : await resolveDefaultWorkflowIntakeColumn(store); const declaredSymbols = resolveCreateDeclaredSymbols(input, options?.promptOverride); const task: Task = { id, @@ -787,7 +847,7 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat // FNXC:CodingIdeasWorkflow 2026-07-05-19:45: land the task in its // workflow's manual intake column (e.g. Coding (Ideas) → "ideas") when // no explicit column is given (main FN-7591 parity). - column: input.column || options?.resolvedEntryColumn || "triage", + column: input.column || options?.resolvedEntryColumn || fallbackIntakeColumn || "triage", dependencies: input.dependencies || [], breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined, noCommitsExpected: input.noCommitsExpected === true ? true : undefined, @@ -860,8 +920,24 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat const isUnplannedStartCreate = options?.resolvedEntryColumn !== undefined && options.resolvedEntryColumn !== "triage" && task.column === "todo"; + /* + FNXC:MergedPlanningColumn 2026-07-29-17:15 (PR #2589 review — greptile): + `fallbackIntakeColumn` must appear here, not only in the `column:` assignment above. Otherwise + this path lands the card in the resolved intake column and then classifies it NOT-intake, + writing `generateSpecifiedPrompt` instead of the bootstrap seed — and triage admits a card for + planning only when its PROMPT.md reads as a seed, so the card would rest in Planning already + looking "planned" and never be planned. + + HONEST SCOPE: I could not construct a failing test for this through a public API. The only + in-tree caller of `_createTaskInternal` is `createTaskWithReservedIdImpl`, which passes its own + `resolvedEntryColumn` through options, so the second disjunct already matches and this path's + own fallback never decides. The fix is for the DIVERGENCE, which is a latent bug: two copies of + one predicate that disagree, where the backend copy needed exactly this clause. A direct + `_createTaskInternal` call — which the signature invites — would hit it. + */ const isIntakeColumn = task.column === "triage" || (options?.resolvedEntryColumn !== undefined && task.column === options.resolvedEntryColumn) + || (fallbackIntakeColumn !== undefined && task.column === fallbackIntakeColumn) || isUnplannedStartCreate; const usedBootstrapPrompt = !options?.promptOverride && isIntakeColumn; const prompt = options?.promptOverride