consolidate/u7: plugins to zero + 8 executor rebound guards + resume lanes (supersedes #2607, #2635, #2640) (#2644)
Consolidation branch for U7, per the new one-branch working mode. **Supersedes #2607, #2635, #2640** — the three of my PRs that were stuck on review threads. My other seven (#2602, #2605, #2606, #2611, #2621, #2628, #2633) are green with **zero unresolved threads** and are deliberately left alone for the merge sweep. ## What is in here, file by file | file | change | guards before → after | |---|---|---| | `plugins/…/glasses/src/agent-actions.ts` | gates, destinations and degraded-resolution refusal all resolve from the task's own workflow | 2 → 0 | | `plugins/…/glasses/src/quick-capture.ts` | accepted capture columns come from the board; default no longer names the deleted column | 1 → 0 | | `plugins/…/glasses/src/settings.ts` | quick-capture default was `triage`, the column #2515 removed | (assignment, uncounted) | | `plugins/…/dependency-graph/src/GraphTaskNode.tsx` | redundant column condition deleted | 1 → 0 | | `packages/engine/src/executor.ts` | 8 rebound guards compare the resolved column; 4 resume-eligibility literals share one resolver | 151 → 143 (+4 off-bar) | | `packages/engine/src/__tests__/` | 4 new suites, 26 cases | — | `plugins/` reaches **zero** column guards with this branch. ## The three threads it closes **#2607 — five findings, all mine, all the same rule.** I kept *qualifying* a legacy-id fallback instead of removing it: | attempt | rule | hole review found | |---|---|---| | 1 | fall back to `todo` when the role is missing | moved cards to phantom columns | | 2 | …only if the workflow **declares** `todo` | aliased **review** lane named `todo` | | 3 | …and only if no other role is assigned to it | **traitless** parking column named `todo` | The qualifications were the mistake. Once `resolveLanes` returns a lane set the workflow *has* a column vocabulary, so "no column carries the hold trait" is a complete answer — refuse. `destination()` is two lines now, with no aliasing surface left to qualify. Plus a sixth, which is a genuinely different state: **degraded resolution is indistinguishable from the default board.** `resolveWorkflowIrForTask` is total by design — a missing definition silently returns the *default* coding IR — so a card on a custom board whose definition could not be read resolved to `todo`/`in-progress`. `undefined` lanes cannot express that (it means "no workflow at all", where the legacy ids *are* the answer). The actions now refuse with 409. #2618 would replace this check with resolver provenance; it is not merged, so this does not depend on it. **#2635 — "seven rebound sites remain untested."** Fair; my "same shape" note was an assertion, not coverage. Seven of the eight need a live graph run to reach, so the *shape* is pinned instead: a static check that no guard in front of a rebound move compares against a column literal, with a vacuity case (the same detection run against the original shape) and a match-count floor (≥8), because a guard reporting success on zero matches is worse than no guard. **#2640 — duplicate workflow resolution.** Framed as I/O; it is also a correctness bug. Eligibility and re-entry are two halves of one decision and resolved the workflow separately, so a workflow edit landing between them has the halves reading *different boards*. Now one caller-owned memo per decision — caller-owned because a process-lifetime cache would have to guess when a mid-flight workflow edit invalidates it. ## Behavioural findings, not tidying - **The last-resort recovery for completed-but-stranded work did not exist off the default lineage.** `promotedFromPlannerColumn` was false on a renamed board, so finished work resting in planning was never promoted; the code fell through to a review handoff that role adjacency rejects, and the card stayed stuck with its work complete. - **Rebound guards could not see the column their own move targeted.** U5b converted the move target; the eight `column !== "todo"` checks in front of it were left literal, so on a renamed board the engine moved a card into the column it was already in — and `moveTaskInternal` runs reset-on-entry on every real move, so at the `preserveProgress: false` site it reset step progress a second time. - **The FN-1404 `task:move` audit row was lying**, recording `to: "todo"` while the move target was resolved. A run-audit trail that disagrees with the move it describes is worse than none. Not a comparison, so no census counts it. - **A task interrupted by an engine pause never resumed on a renamed board** (off-bar, `in-review`/`in-progress` literals): four comparisons decided one question and had to agree; two of them disagreed on a renamed board, so re-entry silently never fired. ## Revert proofs, isolated per site | reverted | result | |---|---| | `destination()` back to attempt 3 | 3 of 38 fail | | degraded-resolution refusals removed | 2 of 42 fail | | capture set back to the legacy five | 2 of 3 fail (renamed-board suite) | | forward exclusions → literals | 1 of 14 fails | | missing-wip refusal removed | 2 of 14 fail | | `promotedFromPlannerColumn` → literals | 3 of 7 fail | | promotion target → `"in-progress"` | 3 of 7 fail | | one rebound guard → `!== "todo"` | 1 of 3 fails (static shape) | | resume lanes → legacy trio | 1 of 5 fails | Every conversion is paired with a negative — a forward move, a not-a-planner-lane card, a default-lineage card, an unresolvable workflow — so neither "always fire" nor "never fire" can pass for "resolve the role". ## Commit discipline Twelve commits, each one thing: the code move (`resolvePlannerLanes` out of `triage.ts`) is separate from every behavior change, and each review fix is its own commit with its own revert proof. ## Verification - `pnpm test:gate` **71/71** - 162/162 across the glasses plugin's 19 files; 26/26 across the four new engine suites - engine + glasses typecheck clean; `pnpm lint` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Engine recovery and retries now work correctly with renamed or customized workflow columns. * Tasks in manual-intake columns are no longer automatically planned. * Agent actions and quick capture now respect each board’s declared columns and lifecycle stages. * Awaiting-approval tasks are recognized regardless of their current column. * Command Center SDLC funnel stages now accurately reflect customized workflows. * **Documentation** * Added guidance for safely changing workflow-column logic and interpreting lifecycle-column checks. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,14 +70,44 @@ export function GraphTaskNode({
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-03:40 (U11 #2515 audit):
|
||||
Keyed on `column === "triage"`, this went permanently FALSE for default-lineage
|
||||
cards once U11 merged Todo into Planning and dropped the `triage` id — an
|
||||
awaiting-approval card sits in `todo` now. The node then stopped showing the
|
||||
awaiting-approval state AND fell through to `isActive`, rendering a card that is
|
||||
blocked on a human as if it were running.
|
||||
|
||||
The column condition is DELETED rather than converted, because it was always
|
||||
redundant: `awaiting-approval` is written only by the plan-approval gate and the
|
||||
replan-cap park, both of which act on a card in the planning lane, so the status
|
||||
alone is the signal. Deleting it is also the only option that needs no resolution —
|
||||
this is a synchronous React render, where an IR lookup is not available.
|
||||
*/
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-11:50 (PR #2644 review, greptile P1):
|
||||
A STALE APPROVAL STATUS MUST NOT HIDE A RUNNING CARD. Dropping the column condition made the
|
||||
awaiting-approval signal status-only, which is right for a planning-lane card — but `awaiting-approval`
|
||||
is DURABLE, so a card that carries it into an execution lane was rendered as not-active: no active
|
||||
styling, no execution-status indicator, no current-step metadata, while it was plainly running.
|
||||
|
||||
So the suppression now yields to an execution SIGNAL rather than to a column name. If the card shows
|
||||
execution activity, it is active and the stale approval status is residue; if it does not, the approval
|
||||
state is the truth. That ordering needs no IR lookup, which matters here — this is a synchronous React
|
||||
render.
|
||||
|
||||
The `in-progress` literal is pre-existing and NOT on the triage bar; converting it needs the board's
|
||||
column traits, which this component is not given. Left with this note rather than half-converted.
|
||||
*/
|
||||
const hasExecutionSignal = task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string);
|
||||
const isAwaitingApproval = task.status === "awaiting-approval" && !hasExecutionSignal;
|
||||
const isActive =
|
||||
!globalPaused &&
|
||||
!isFailed &&
|
||||
!isPaused &&
|
||||
!isStuck &&
|
||||
!isAwaitingApproval &&
|
||||
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
hasExecutionSignal;
|
||||
|
||||
const hasValidCurrentStep =
|
||||
typeof task.currentStep === "number" &&
|
||||
|
||||
@@ -217,3 +217,684 @@ describe("retryTask", () => {
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-03:20 (U11 #2515 audit — unowned plugin sites):
|
||||
|
||||
These operator actions gated on `column === "triage"`. U11 (#2515) merged Todo into
|
||||
Planning KEEPING the id `todo` and DELETING `triage`, so on the default lineage:
|
||||
|
||||
approvePlan — REFUSED for every card. An awaiting-approval card now sits in `todo`,
|
||||
the gate demands `triage`, so the operator's approve action from the
|
||||
glasses surface fails with a conflict on a perfectly valid card.
|
||||
retryTask — its triage-retry branch never fires, so a stuck/needs-replan card
|
||||
cannot be retried from the glasses at all.
|
||||
startWork — SURVIVES, because it already accepted `todo` as well.
|
||||
|
||||
That asymmetry is the tell: the one gate written to accept both ids kept working, and
|
||||
the two written against a single id broke. `plugins/` is in no unit's file list and no
|
||||
drift-review assignment.
|
||||
|
||||
The fix accepts the PRE-IMPLEMENTATION LANE rather than one id, resolving the task's
|
||||
own workflow when the plugin's store can (it depends on `@fusion/core`) and falling
|
||||
back to both legacy ids when it cannot. The fallback is why these cases assert the
|
||||
default vocabulary too.
|
||||
*/
|
||||
describe("post-U11 planning-column gates", () => {
|
||||
it("approvePlan accepts an awaiting-approval card in the MERGED planning column", async () => {
|
||||
// Pre-fix: conflict. The card is valid and the operator's action just failed.
|
||||
const deps = createDeps(makeTask({ column: "todo", status: "awaiting-approval" }));
|
||||
|
||||
const result = await approvePlan({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(result.task.column).toBe("todo");
|
||||
expect(deps.updateTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("approvePlan still accepts a legacy `triage` card (migration window)", async () => {
|
||||
const deps = createDeps(makeTask({ column: "triage", status: "awaiting-approval" }));
|
||||
|
||||
await expect(approvePlan({ taskId: "FN-1" }, deps as never)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("approvePlan still REFUSES a card that has left the planning lane", async () => {
|
||||
// The other side, so "always accepts" cannot pass for "accepts the lane".
|
||||
const deps = createDeps(makeTask({ column: "in-progress", status: "awaiting-approval" }));
|
||||
|
||||
await expectInputError(approvePlan({ taskId: "FN-1" }, deps as never), 409);
|
||||
});
|
||||
|
||||
it("retryTask reaches its planning-lane branch for a card in the MERGED column", async () => {
|
||||
// Pre-fix the branch was unreachable for default-lineage cards, so a stuck card
|
||||
// could not be retried from this surface at all.
|
||||
const deps = createDeps(makeTask({ column: "todo", status: "stuck-killed", stuckKillCount: 2 }));
|
||||
|
||||
await retryTask({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.updateTask).toHaveBeenCalledWith("FN-1", expect.objectContaining({ status: "needs-replan" }));
|
||||
});
|
||||
|
||||
it("startWork keeps accepting both ids (it already did — the control)", async () => {
|
||||
for (const column of ["todo", "triage"]) {
|
||||
const deps = createDeps(makeTask({ column, status: null }));
|
||||
await expect(startWork({ taskId: "FN-1" }, deps as never)).resolves.toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-05:10 (PR #2607 review — greptile P1 x2):
|
||||
|
||||
Two over-reaches in my own first version, both found by review:
|
||||
|
||||
1. DESTINATIONS stayed literal while the GATES were converted. On a renamed workflow
|
||||
that is WORSE than the original bug: the gate now admits the card and then moves it
|
||||
into a column the workflow does not declare. Half a conversion moved the failure
|
||||
from "refuses valid work" to "puts work where nothing renders it".
|
||||
|
||||
2. The legacy-id acceptance was UNSCOPED, so a workflow naming its review or wip lane
|
||||
`triage`/`todo` had those cards authorized as planning work.
|
||||
|
||||
These drive a store that CAN resolve a workflow, which the default fixture cannot — the
|
||||
plugin store is narrowed, so without the workflow methods every earlier case silently
|
||||
exercised the legacy fallback rather than the resolved path.
|
||||
*/
|
||||
function createResolvingDeps(task: FakeTask, ir: unknown) {
|
||||
const base = createDeps(task);
|
||||
const selection = { workflowId: "wf-custom", stepIds: [] };
|
||||
return {
|
||||
...base,
|
||||
taskStore: {
|
||||
...base.taskStore,
|
||||
getTaskWorkflowSelection: () => selection,
|
||||
getTaskWorkflowSelectionAsync: async () => selection,
|
||||
getWorkflowDefinition: async () => ({ ir }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const renamedIr = {
|
||||
version: "v2", id: "wf-custom", name: "renamed", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "checking", name: "Review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] },
|
||||
{ id: "shipped", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
/** A workflow that assigns the LEGACY id `todo` to its REVIEW lane. Legal, and not planning. */
|
||||
const todoIsReviewIr = {
|
||||
version: "v2", id: "wf-custom", name: "todo-is-review", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
/*
|
||||
The `merge` trait is REQUIRED for this to be a resolvable review lane, and that
|
||||
detail is the finding: `resolveLifecycleColumns` derives `review` from `merge`, not
|
||||
from `merge-blocker`/`human-review`. My first fixture omitted it, so `review` came
|
||||
back undefined, `declaredIds` did not contain `todo`, and the legacy acceptance
|
||||
applied — the test failed and was RIGHT to.
|
||||
|
||||
(That gap — a column whose traits map to no role being invisible to a role-only
|
||||
check — is CLOSED below by reading the IR's declared column ids directly. It did not
|
||||
need a core change after all; it needed me to read an input that was already in
|
||||
reach. See "a declared column is declared even when it carries no role".)
|
||||
*/
|
||||
{ id: "todo", name: "Review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }, { trait: "merge" }] },
|
||||
{ id: "shipped", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
describe("resolved lanes drive destinations, not just gates", () => {
|
||||
it("startWork moves a renamed card to the workflow's OWN wip column", async () => {
|
||||
// Pre-fix: admitted, then moved to the literal `in-progress` — a column this
|
||||
// workflow does not declare.
|
||||
const deps = createResolvingDeps(makeTask({ column: "backlog", status: null }), renamedIr);
|
||||
|
||||
const result = await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "building");
|
||||
expect(result.task.column).toBe("building");
|
||||
});
|
||||
|
||||
it("approvePlan moves a renamed card to the workflow's OWN hold column", async () => {
|
||||
const deps = createResolvingDeps(
|
||||
makeTask({ column: "backlog", status: "awaiting-approval" }),
|
||||
renamedIr,
|
||||
);
|
||||
|
||||
await approvePlan({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "backlog");
|
||||
});
|
||||
|
||||
it("REFUSES a card in a legacy-named column the workflow assigns to REVIEW", async () => {
|
||||
/*
|
||||
The aliasing case. Unscoped, `todo` counted as a planning lane and startWork would
|
||||
have pulled a card out of review and into wip — skipping the review entirely.
|
||||
*/
|
||||
const deps = createResolvingDeps(makeTask({ column: "todo", status: null }), todoIsReviewIr);
|
||||
|
||||
await expectInputError(startWork({ taskId: "FN-1" }, deps as never), 409);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still accepts a legacy id the workflow does not use at all (migration window)", async () => {
|
||||
// `renamedIr` declares no `todo`, so a pre-U11 row resting there is an orphan and
|
||||
// still means "planning".
|
||||
const deps = createResolvingDeps(makeTask({ column: "todo", status: null }), renamedIr);
|
||||
|
||||
await expect(startWork({ taskId: "FN-1" }, deps as never)).resolves.toBeTruthy();
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "building");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-07:20 (PR #2607 review, second P1 — greptile):
|
||||
|
||||
TWO THINGS THE ROLE-ONLY VERSION GOT WRONG, both of which I had written down as
|
||||
limitations rather than fixed. Recording a gap you can close is just a nicer way of
|
||||
leaving it open.
|
||||
|
||||
1. A DECLARED COLUMN IS DECLARED EVEN WHEN IT CARRIES NO ROLE. Building the
|
||||
declared set from the six resolved roles left a trait-less column named `todo`
|
||||
invisible, so the legacy acceptance claimed it as a planning lane and `startWork`
|
||||
would pull a card out of it. The IR lists its own columns; read that instead.
|
||||
|
||||
2. A MISSING ROLE IS NOT A LICENCE TO INVENT A COLUMN. `destination` fell back to
|
||||
`todo`/`in-progress` unconditionally, so a valid workflow that simply omits the
|
||||
role had `moveTask` called with a column that does not exist on that board. An
|
||||
action with nowhere legitimate to send the card is not configured for this
|
||||
workflow; 409 says that, a move to a phantom column does not.
|
||||
*/
|
||||
/** A column named with a legacy id but carrying NO lifecycle trait. Legal, and not planning. */
|
||||
const inertTodoIr = {
|
||||
version: "v2", id: "wf-custom", name: "inert-todo", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "todo", name: "Parking", traits: [] },
|
||||
{ id: "building", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "shipped", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
/** A workflow with NO wip lane at all — nowhere for `startWork` to legitimately send a card. */
|
||||
const noWipIr = {
|
||||
version: "v2", id: "wf-custom", name: "no-wip", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "shipped", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
describe("a declared column is declared even when it carries no role", () => {
|
||||
it("refuses start-work on an inert column named `todo`", async () => {
|
||||
// Pre-fix: `todo` was absent from the role-derived set, the legacy acceptance
|
||||
// applied, and the card was pulled out of the operator's parking column.
|
||||
const deps = createResolvingDeps(makeTask({ id: "FN-1", column: "todo", status: null }), inertTodoIr);
|
||||
|
||||
await expect(startWork({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still admits the workflow's REAL planning column", async () => {
|
||||
// The other half of the pair: "never a planning lane" must not be able to pass
|
||||
// for "reads the IR".
|
||||
const deps = createResolvingDeps(makeTask({ id: "FN-2", column: "backlog", status: null }), inertTodoIr);
|
||||
|
||||
await startWork({ taskId: "FN-2" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-2", "building");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a missing destination role conflicts instead of inventing a column", () => {
|
||||
it("refuses start-work when the workflow declares no wip lane", async () => {
|
||||
// Pre-fix: moved to the literal `in-progress`, which this workflow does not declare.
|
||||
const deps = createResolvingDeps(makeTask({ id: "FN-3", column: "backlog", status: null }), noWipIr);
|
||||
|
||||
await expect(startWork({ taskId: "FN-3" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses approve-plan when the workflow declares no hold lane", async () => {
|
||||
const holdlessIr = {
|
||||
version: "v2", id: "wf-custom", name: "no-hold", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Planning", traits: [{ trait: "intake" }] },
|
||||
{ id: "building", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "shipped", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
const deps = createResolvingDeps(makeTask({ id: "FN-4", column: "backlog", status: "awaiting-approval" }), holdlessIr);
|
||||
|
||||
await expect(approvePlan({ taskId: "FN-4" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("STILL uses the legacy id when the workflow genuinely declares it (migration window)", async () => {
|
||||
// The fallback is not deleted, it is scoped: a pre-U11 board really does have
|
||||
// `todo`, and refusing there would break the migration this program is mid-way
|
||||
// through. `todo` here carries the hold trait, so it is a real destination.
|
||||
const migrationIr = {
|
||||
version: "v2", id: "wf-custom", name: "pre-u11", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
|
||||
{ id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "in-progress", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
],
|
||||
};
|
||||
const deps = createResolvingDeps(makeTask({ id: "FN-5", column: "triage", status: "awaiting-approval" }), migrationIr);
|
||||
|
||||
await approvePlan({ taskId: "FN-5" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-5", "todo");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-19:25 (PR #2607 review — fourth finding):
|
||||
|
||||
"DECLARED SOMEWHERE" IS NOT "DECLARED FOR THIS ROLE", and this is the FOURTH time I have made the
|
||||
legacy-aliasing mistake in this file. `declared` holds every column id the workflow has, so a board
|
||||
that declares no hold column but names its REVIEW lane `todo` satisfied `declared.has("todo")` —
|
||||
and `approvePlan` moved an approved plan straight into REVIEW, skipping implementation. Worse than
|
||||
the refusal it replaced, which is the recurring signature of a half-applied rule.
|
||||
|
||||
The rule, stated the same way as for the gate: a legacy id may stand in only for a role the
|
||||
workflow leaves EMPTY. If the board has assigned that id to another lifecycle role, it means
|
||||
something else there.
|
||||
*/
|
||||
const HOLDLESS_TODO_IS_REVIEW_IR = {
|
||||
version: "v2", id: "wf-custom", name: "holdless-todo-review", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Planning", traits: [{ trait: "intake" }] },
|
||||
{ id: "building", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "todo", name: "Review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }, { trait: "merge" }] },
|
||||
{ id: "shipped", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
describe("a legacy destination may only fill a role the workflow leaves empty", () => {
|
||||
it("refuses approve-plan rather than moving the plan into a lane named `todo` that is REVIEW", async () => {
|
||||
// Pre-fix: moved to `todo` — this board's review lane — so an approved plan skipped
|
||||
// implementation entirely.
|
||||
const deps = createResolvingDeps(
|
||||
makeTask({ column: "backlog", status: "awaiting-approval" }),
|
||||
HOLDLESS_TODO_IS_REVIEW_IR,
|
||||
);
|
||||
|
||||
await expect(approvePlan({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still uses the legacy id when the workflow declares it and assigns it to NO other role", async () => {
|
||||
// The migration case the fallback exists for: a pre-U11 board really does have `todo` as its
|
||||
// hold lane. Scoping the fallback must not delete it.
|
||||
const migrationIr = {
|
||||
version: "v2", id: "wf-custom", name: "pre-u11", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
|
||||
{ id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "in-progress", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
],
|
||||
};
|
||||
const deps = createResolvingDeps(makeTask({ column: "triage", status: "awaiting-approval" }), migrationIr);
|
||||
|
||||
await approvePlan({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "todo");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-21:45 (PR #2607 review — FIFTH finding, one rule):
|
||||
|
||||
A ROLELESS COLUMN NAMED `todo` IS STILL NOT THE HOLD LANE. My previous revision scoped the legacy
|
||||
fallback to "declared and not assigned to another role", and review found the remaining hole
|
||||
immediately: a TRAITLESS parking column named `todo` is assigned to no role, so no role check can
|
||||
see it, and `approvePlan` moved an approved plan into a column that implements nothing.
|
||||
|
||||
The qualifications were themselves the mistake. Once `resolveLanes` returns a lane set the workflow
|
||||
HAS a column vocabulary, so "no column carries the hold trait" is a complete answer — refuse. The
|
||||
legacy id survives only when the workflow cannot be resolved at all, which is the migration case.
|
||||
|
||||
Five attempts at one rule. These cases pin all four shapes it has to get right at once.
|
||||
*/
|
||||
const ROLELESS_TODO_IR = {
|
||||
version: "v2", id: "wf-custom", name: "roleless-todo", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Planning", traits: [{ trait: "intake" }] },
|
||||
{ id: "todo", name: "Parking", traits: [] },
|
||||
{ id: "building", name: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "shipped", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
describe("a legacy id is not a destination once the workflow speaks columns", () => {
|
||||
it("refuses approve-plan rather than parking the plan in a TRAITLESS column named `todo`", async () => {
|
||||
// Pre-fix: `assignedElsewhere` was false (no role owns a traitless column), so the fallback
|
||||
// returned `todo` and an approved plan landed in the operator's parking column.
|
||||
const deps = createResolvingDeps(
|
||||
makeTask({ column: "backlog", status: "awaiting-approval" }),
|
||||
ROLELESS_TODO_IR,
|
||||
);
|
||||
|
||||
await expect(approvePlan({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still starts work, because THAT role is declared on the same board", async () => {
|
||||
// The paired positive: refusing a missing role must not become refusing everything. This board
|
||||
// has no hold lane but does have a wip lane, so start-work is legitimate.
|
||||
const deps = createResolvingDeps(makeTask({ column: "backlog", status: null }), ROLELESS_TODO_IR);
|
||||
|
||||
await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "building");
|
||||
});
|
||||
|
||||
it("keeps the legacy destination when the workflow cannot be resolved at all", async () => {
|
||||
// No lane set means no basis to decide, and a pre-U11 board really does use these ids —
|
||||
// refusing here would break the migration rather than protect it. `createDeps` supplies a
|
||||
// store with no workflow readers, which is exactly that case.
|
||||
const deps = createDeps(makeTask({ column: "todo", status: "awaiting-approval" }));
|
||||
|
||||
await approvePlan({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "todo");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-00:35 (PR #2607 review — sixth finding):
|
||||
|
||||
DEGRADED RESOLUTION LOOKS EXACTLY LIKE THE DEFAULT BOARD. `resolveWorkflowIrForTask` is TOTAL by
|
||||
design: a missing workflow definition or a failed read silently returns the DEFAULT coding IR. So a
|
||||
card on a CUSTOM board whose definition could not be loaded resolved to `todo`/`in-progress`, and
|
||||
these actions rejected a valid custom planning card or moved it to a column its own workflow does not
|
||||
declare.
|
||||
|
||||
`undefined` lanes cannot express this — that means "no workflow at all", where the legacy ids ARE the
|
||||
answer. This is the third state: "this board HAS a vocabulary and we could not read it", where acting
|
||||
on someone else's vocabulary is the one thing we must not do.
|
||||
*/
|
||||
function createDegradedDeps(task: FakeTask) {
|
||||
const base = createDeps(task);
|
||||
const selection = { workflowId: "wf-custom", stepIds: [] };
|
||||
return {
|
||||
...base,
|
||||
taskStore: {
|
||||
...base.taskStore,
|
||||
getTaskWorkflowSelection: () => selection,
|
||||
getTaskWorkflowSelectionAsync: async () => selection,
|
||||
// The definition is GONE — the exact state the resolver papers over with the default IR.
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("a card whose custom workflow cannot be read is refused, not treated as default", () => {
|
||||
it("refuses start-work rather than moving to the DEFAULT board's wip column", async () => {
|
||||
// Pre-fix: lanes resolved to the default coding IR, so this moved the card to `in-progress` —
|
||||
// a column the custom workflow may not declare at all.
|
||||
const deps = createDegradedDeps(makeTask({ column: "backlog", status: null }));
|
||||
|
||||
await expect(startWork({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses approve-plan the same way", async () => {
|
||||
const deps = createDegradedDeps(makeTask({ column: "backlog", status: "awaiting-approval" }));
|
||||
|
||||
await expect(approvePlan({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT refuse when the workflow resolves properly (the paired positive)", async () => {
|
||||
// "Refuse on degraded" must not become "refuse whenever a selection exists".
|
||||
const deps = createResolvingDeps(makeTask({ column: "backlog", status: null }), renamedIr);
|
||||
|
||||
await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "building");
|
||||
});
|
||||
|
||||
it("does NOT refuse a store that cannot answer at all (the migration case)", async () => {
|
||||
// No workflow readers means "no workflow at all", where the legacy ids are the answer.
|
||||
// Refusing here would break every pre-U11 board instead of protecting a custom one.
|
||||
const deps = createDeps(makeTask({ column: "todo", status: null }));
|
||||
|
||||
await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-02:35 (PR #2644 review, greptile P1):
|
||||
|
||||
ONE SNAPSHOT PER ACTION. The degraded probe, the lane resolution and the declared-column read used to
|
||||
consult the workflow independently, so a workflow edited or deleted mid-action could combine a
|
||||
NOT-degraded verdict with fallback lanes, or lanes from one revision with declarations from another.
|
||||
The action then conflicted on a card that was fine, or moved it toward a column the current workflow
|
||||
no longer has.
|
||||
|
||||
Same fix as the executor's resume lanes: the halves of one decision must read one snapshot.
|
||||
*/
|
||||
describe("an action reads the workflow once, not three times", () => {
|
||||
function countingDeps(task: FakeTask, ir: unknown) {
|
||||
const base = createDeps(task);
|
||||
const selection = { workflowId: "wf-custom", stepIds: [] };
|
||||
const reads = { definition: 0 };
|
||||
return {
|
||||
...base,
|
||||
reads,
|
||||
taskStore: {
|
||||
...base.taskStore,
|
||||
getTaskWorkflowSelection: () => selection,
|
||||
getTaskWorkflowSelectionAsync: async () => selection,
|
||||
getWorkflowDefinition: async () => {
|
||||
reads.definition += 1;
|
||||
return { ir };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("reads the custom definition once per action", async () => {
|
||||
const deps = countingDeps(makeTask({ column: "backlog", status: null }), renamedIr);
|
||||
|
||||
await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
// One read backs the degraded verdict, the lanes AND the declared columns. Three reads was the
|
||||
// bug: they could disagree with each other.
|
||||
expect((deps as unknown as { reads: { definition: number } }).reads.definition).toBe(1);
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "building");
|
||||
});
|
||||
|
||||
it("refuses when the definition read throws mid-action", async () => {
|
||||
// A store that cannot answer during a MOVE is degraded: refusing is the safe direction, and it
|
||||
// must not silently fall back to the default board's lanes.
|
||||
const deps = countingDeps(makeTask({ column: "backlog", status: null }), renamedIr);
|
||||
(deps.taskStore as unknown as Record<string, unknown>).getWorkflowDefinition = async () => {
|
||||
throw new Error("workflow store unavailable");
|
||||
};
|
||||
|
||||
await expect(startWork({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-08:20 (PR #2644 review — the identity check was worse than the
|
||||
bug it replaced, and my own fixture hid that):
|
||||
|
||||
WHAT THE DEGRADED STATE MUST PROVE: that the definition READ succeeded — not that the resolved IR
|
||||
identifies as the selected workflow. A persisted custom workflow's stored IR usually carries NO `id`,
|
||||
and its `name` is a DISPLAY name ("Six Column Shape"), not the selection id ("wf_7"). My identity check
|
||||
therefore marked every valid custom board degraded and refused every action on it: a wrong answer in the
|
||||
COMMON case, replacing a wrong answer in a rare one.
|
||||
|
||||
MY TEST PASSED BY COINCIDENCE. `renamedIr` happens to carry `id: "wf-custom"`, matching its selection
|
||||
id, so the identity check looked correct. Third time in this PR that a fixture stood in for the property
|
||||
under test — so the custom-workflow fixtures below now deliberately carry NO id and a display name
|
||||
unlike the selection id, which is the shape a real persisted workflow has.
|
||||
|
||||
The three branches, and what each one can actually fail on:
|
||||
- CUSTOM selection: `getWorkflowDefinition(id)` must return a row with an `ir`. A missing row or a
|
||||
throw is exactly the state `resolveWorkflowIrForTask` papers over with the default IR.
|
||||
- BUILTIN selection: resolves through the in-process catalog, so there is no read to fail — but an
|
||||
UNKNOWN builtin id would fall back to the default, so the id must be a real builtin.
|
||||
- NO selection: nothing to mismatch; the default IS the answer.
|
||||
*/
|
||||
describe("degraded means the definition read failed, not that the IR looks different", () => {
|
||||
/** The shape a PERSISTED custom workflow actually has: no `id`, display name unlike the id. */
|
||||
const persistedCustomIr = {
|
||||
version: "v2", name: "Six Column Shape", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
|
||||
{ id: "queued", name: "Queued", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }] },
|
||||
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
function customDeps(task: FakeTask, definition: unknown, workflowId = "wf_7") {
|
||||
const base = createDeps(task);
|
||||
const selection = { workflowId, stepIds: [] };
|
||||
return {
|
||||
...base,
|
||||
taskStore: {
|
||||
...base.taskStore,
|
||||
getTaskWorkflowSelection: () => selection,
|
||||
getTaskWorkflowSelectionAsync: async () => selection,
|
||||
getWorkflowDefinition: async () => definition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("acts on a persisted custom workflow whose IR has no id and a display name", async () => {
|
||||
// Pre-fix: refused. Every custom board was unusable through the glasses actions.
|
||||
const deps = customDeps(makeTask({ column: "backlog", status: null }), { id: "wf_7", ir: persistedCustomIr });
|
||||
|
||||
await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "building");
|
||||
});
|
||||
|
||||
it("refuses when the custom definition row is missing", async () => {
|
||||
// The state the resolver papers over with the DEFAULT coding IR — the one thing that can silently
|
||||
// substitute another board's vocabulary.
|
||||
const deps = customDeps(makeTask({ column: "backlog", status: null }), undefined);
|
||||
|
||||
await expect(startWork({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
expect(deps.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the definition read throws", async () => {
|
||||
const deps = customDeps(makeTask({ column: "backlog", status: null }), undefined);
|
||||
(deps.taskStore as unknown as Record<string, unknown>).getWorkflowDefinition = async () => {
|
||||
throw new Error("workflow store unavailable");
|
||||
};
|
||||
|
||||
await expect(startWork({ taskId: "FN-1" }, deps as never)).rejects.toThrow(/not allowed/);
|
||||
});
|
||||
|
||||
it("does NOT refuse when there is no selection at all", async () => {
|
||||
const deps = createDeps(makeTask({ column: "todo", status: null }));
|
||||
|
||||
await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-15:30 (PR #2644 review, CodeRabbit — MAJOR, my regression):
|
||||
|
||||
DEGRADED GATES ONLY THE LANE-DEPENDENT BRANCH. I put the refusal at the top of `retryTask`, which also
|
||||
blocked the plain failure-retry below — a branch that reads no lanes and only clears status. So a FAILED
|
||||
card became un-retryable whenever its workflow definition could not be read, which is precisely the state
|
||||
an operator is trying to retry out of.
|
||||
|
||||
The refusal exists to stop a MOVE onto another board's vocabulary. A branch that performs no move has
|
||||
nothing to be wrong about, so gating it was cost without benefit — the kind of over-application that makes
|
||||
a safety check read as breakage.
|
||||
*/
|
||||
describe("a degraded workflow does not block retries that move nothing", () => {
|
||||
function degradedDeps(task: FakeTask) {
|
||||
const base = createDeps(task);
|
||||
const selection = { workflowId: "wf-gone", stepIds: [] };
|
||||
return {
|
||||
...base,
|
||||
taskStore: {
|
||||
...base.taskStore,
|
||||
getTaskWorkflowSelection: () => selection,
|
||||
getTaskWorkflowSelectionAsync: async () => selection,
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("retries a FAILED card even when the workflow definition cannot be read", async () => {
|
||||
// Pre-fix: 409. The one action an operator has for a failed card was refused because of a workflow
|
||||
// read that this branch never consults.
|
||||
const deps = degradedDeps(makeTask({ column: "in-progress", status: "failed" }));
|
||||
|
||||
await retryTask({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.updateTask).toHaveBeenCalledWith("FN-1", expect.objectContaining({ status: null, error: null }));
|
||||
});
|
||||
|
||||
it("still refuses the PLANNING-lane retry when the workflow cannot be read", async () => {
|
||||
/*
|
||||
The paired positive for the refusal: this branch writes `needs-replan` and clears the worktree based on
|
||||
the card being in a planner lane, so acting on another board's lanes is exactly the mistake to avoid.
|
||||
A degraded read leaves the card untouched.
|
||||
*/
|
||||
const deps = degradedDeps(makeTask({ column: "todo", status: "failed" }));
|
||||
|
||||
await retryTask({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
const updates = (deps.updateTask as unknown as { mock: { calls: unknown[][] } }).mock.calls.map((c) => c[1]);
|
||||
expect(updates.some((u) => (u as { status?: string })?.status === "needs-replan")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-16:00 (PR #2644 review, CodeRabbit — recorded, not fixed):
|
||||
|
||||
A STORED STRING IR COSTS A SECOND READ. The reviewer is right that the lanes can then come from a different
|
||||
revision than the degraded verdict, and my excuse ("deliberate and confined to that shape") was not a
|
||||
reason.
|
||||
|
||||
I tried parsing the row instead. The parse succeeds in isolation and `resolveLifecycleColumns` returns the
|
||||
right roles for the parsed IR — verified directly — but the action still refused in this harness, so
|
||||
something between the parse and the lane check differs from the resolver path and I could not identify it
|
||||
within this PR. I will not ship an unexplained change into the code path that decides whether an operator's
|
||||
action is refused.
|
||||
|
||||
What is pinned instead is the CURRENT contract for this shape, which had no coverage at all: a stored
|
||||
string IR still resolves lanes and the action proceeds. That is the part a future one-read fix must keep.
|
||||
*/
|
||||
describe("a stored string IR still resolves lanes", () => {
|
||||
it("starts work on a board whose definition row holds its IR as a string", async () => {
|
||||
const base = createDeps(makeTask({ column: "todo", status: null }));
|
||||
const selection = { workflowId: "builtin:coding", stepIds: [] };
|
||||
const deps = {
|
||||
...base,
|
||||
taskStore: {
|
||||
...base.taskStore,
|
||||
getTaskWorkflowSelection: () => selection,
|
||||
getTaskWorkflowSelectionAsync: async () => selection,
|
||||
},
|
||||
};
|
||||
|
||||
await startWork({ taskId: "FN-1" }, deps as never);
|
||||
|
||||
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-19:40 (PR #2607 review — CodeRabbit):
|
||||
|
||||
The reviewer is right that my "accepts a column the default workflow declares" case was VACUOUS:
|
||||
`in-progress` belongs to both the legacy five and the declared set, so it passed before the change
|
||||
as well. The only non-vacuous case in that suite was the `triage` REJECTION — which proves half the
|
||||
claim (the set is no longer the hand-listed five) and not the other half (it is the BOARD's set).
|
||||
|
||||
Proving the other half needs control of the resolved workflow, so this file mocks it. Kept separate
|
||||
because the mock is module-wide and the sibling suite deliberately exercises the real default
|
||||
lineage.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
/** A board whose columns carry the standard traits under names the legacy five never contained. */
|
||||
const RENAMED_IR = {
|
||||
version: "v2",
|
||||
id: "wf-renamed",
|
||||
name: "renamed",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
|
||||
{ id: "queued", name: "Queued", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }] },
|
||||
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
};
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, resolveDefaultWorkflowIr: () => RENAMED_IR };
|
||||
});
|
||||
|
||||
const { runQuickCapture } = await import("../quick-capture.js");
|
||||
|
||||
function deps(defaultColumn = "queued") {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
created,
|
||||
taskStore: {
|
||||
createTask: async (input: Record<string, unknown>) => {
|
||||
created.push(input);
|
||||
return { id: "FN-1", column: input.column, description: input.description, updatedAt: "2026-07-30T00:00:00.000Z" };
|
||||
},
|
||||
},
|
||||
pluginId: "glasses",
|
||||
defaultColumn,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("quick capture accepts a RENAMED board's own columns", () => {
|
||||
it("accepts a column that appears nowhere in the legacy five", async () => {
|
||||
// THE NON-VACUOUS CASE. Pre-fix this was rejected with 400 "invalid column" — an operator
|
||||
// saying "put it in checking" was refused for a column their own board declares.
|
||||
const d = deps();
|
||||
|
||||
await runQuickCapture({ text: "ship the thing", column: "checking" }, d);
|
||||
|
||||
expect((d as unknown as { created: Array<{ column?: string }> }).created[0]?.column).toBe("checking");
|
||||
});
|
||||
|
||||
it("rejects a legacy id this board does NOT declare", async () => {
|
||||
// The mirror: `in-progress` was accepted by the hand-listed five and is not a column here, so
|
||||
// forwarding it would have failed at the server after the voice interaction appeared to work.
|
||||
await expect(runQuickCapture({ text: "ship it", column: "in-progress" }, deps())).rejects.toThrow(
|
||||
/invalid column/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a column no board declares", async () => {
|
||||
// Paired negative: "accept everything" must not pass for "read the workflow".
|
||||
await expect(runQuickCapture({ text: "ship it", column: "nonsense" }, deps())).rejects.toThrow(
|
||||
/invalid column/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ function getRoute() {
|
||||
function createCtx(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
pluginId: "fusion-plugin-even-realities-glasses",
|
||||
// `triage` was removed from the default lineage by #2515; capture now falls to the
|
||||
// workflow's own intake column rather than creating a card in a column it does not declare.
|
||||
settings: { apiKey: "secret", quickCaptureDefaultColumn: "triage" },
|
||||
taskStore: {
|
||||
createTask: vi.fn(async (input) => ({ id: "FN-100", ...input, title: "write the spec", column: input.column })),
|
||||
@@ -66,7 +68,7 @@ describe("quickCaptureRoutes", () => {
|
||||
expect(createTask).toHaveBeenCalledTimes(1);
|
||||
expect(createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
description: expect.stringContaining("write the spec"),
|
||||
source: expect.objectContaining({ sourceMetadata: expect.objectContaining({ channel: "glasses-quick-capture" }) }),
|
||||
}),
|
||||
|
||||
@@ -46,7 +46,16 @@ describe("quick-capture parsing", () => {
|
||||
expect(() => parseUtterance(" ")).toThrowError(/empty utterance/);
|
||||
});
|
||||
|
||||
it("creates task with default column and channel metadata", async () => {
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-09:00 (PR #2644 review): this case used to assert the card was
|
||||
created in `triage` because that was the configured default. `triage` is the column #2515 DELETED from
|
||||
the default lineage, so that assertion pinned a card being created into a column its own workflow does
|
||||
not declare — the defect greptile flagged, encoded as an expectation.
|
||||
|
||||
The configured default is now validated like any other column: not declared -> fall to the workflow's
|
||||
own INTAKE column, which is where a new card belongs. Hence `todo`.
|
||||
*/
|
||||
it("creates task in the workflow's intake when the configured default is not declared", async () => {
|
||||
const createTask = vi.fn(async (input) => ({ id: "FN-1", ...input, title: "t", column: input.column }));
|
||||
await runQuickCapture(
|
||||
{ text: "hey fusion, write docs" },
|
||||
@@ -54,7 +63,7 @@ describe("quick-capture parsing", () => {
|
||||
);
|
||||
expect(createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
source: expect.objectContaining({
|
||||
sourceMetadata: expect.objectContaining({ channel: "glasses-quick-capture" }),
|
||||
}),
|
||||
@@ -78,3 +87,325 @@ describe("quick-capture parsing", () => {
|
||||
).rejects.toThrowError(GlassesInputError);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-13:20 (Phase C convergence — quick capture):
|
||||
|
||||
The accepted capture columns are the BOARD's, not a hand-listed five. The old list was wrong in
|
||||
both directions after U11 (#2515): it accepted `triage`, which the default board no longer
|
||||
declares (so the create failed at the server, at the far end of a voice interaction), and it
|
||||
rejected every column of a renamed or custom board.
|
||||
|
||||
Note on severity, corrected from my own PR description: this was never SILENT substitution —
|
||||
`runQuickCapture` compares the normalized value against the request and throws 400 on a
|
||||
mismatch. An unusable column was visibly rejected. The defect is the accept/reject SET.
|
||||
*/
|
||||
describe("quick capture accepts the columns the board actually declares", () => {
|
||||
function deps(defaultColumn = "todo") {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
created,
|
||||
taskStore: {
|
||||
createTask: async (input: Record<string, unknown>) => {
|
||||
created.push(input);
|
||||
return { id: "FN-1", column: input.column, description: input.description, updatedAt: "2026-07-30T00:00:00.000Z" };
|
||||
},
|
||||
},
|
||||
pluginId: "glasses",
|
||||
defaultColumn,
|
||||
} as never;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-19:45 (PR #2607 review — CodeRabbit): this case is
|
||||
VACUOUS with respect to the change and is kept only as a smoke test for the happy path —
|
||||
`in-progress` belongs to both the legacy five and the declared set, so it passed before the fix
|
||||
too. The non-vacuous half (a column the legacy five never contained IS accepted) needs control of
|
||||
the resolved workflow and lives in `quick-capture-renamed-board.test.ts`.
|
||||
*/
|
||||
it("accepts a column the default workflow declares (smoke; see the renamed-board suite)", async () => {
|
||||
const d = deps();
|
||||
|
||||
await runQuickCapture({ text: "ship the thing", column: "in-progress" }, d);
|
||||
|
||||
expect((d as unknown as { created: Array<{ column?: string }> }).created[0]?.column).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("rejects `triage` now that the default lineage no longer declares it", async () => {
|
||||
// Pre-fix this was ACCEPTED and forwarded, and the server rejected the create — the
|
||||
// failure surfaced after the voice interaction had already succeeded from the operator's
|
||||
// point of view.
|
||||
await expect(runQuickCapture({ text: "ship it", column: "triage" }, deps())).rejects.toThrow(/invalid column/);
|
||||
});
|
||||
|
||||
it("rejects a column no workflow declares", async () => {
|
||||
// The paired negative: "accept everything" must not pass for "read the workflow".
|
||||
await expect(runQuickCapture({ text: "ship it", column: "nonsense" }, deps())).rejects.toThrow(/invalid column/);
|
||||
});
|
||||
|
||||
it("uses the configured default when no column is requested", async () => {
|
||||
const d = deps("todo");
|
||||
|
||||
await runQuickCapture({ text: "ship it" }, d);
|
||||
|
||||
expect((d as unknown as { created: Array<{ column?: string }> }).created[0]?.column).toBe("todo");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-06:20 (PR #2644 review — third revision of this rule):
|
||||
|
||||
ACCEPT ONLY THE COLUMNS OF THE WORKFLOW THE NEW CARD WILL ACTUALLY USE, which is the project's DEFAULT
|
||||
workflow. The two earlier versions were both wrong, in opposite directions:
|
||||
|
||||
v1: the builtin default IR -> rejected a custom board's own columns ("put it in checking" -> 400).
|
||||
v2: the union of ALL workflows -> accepted `checking` from workflow B while the card lands on
|
||||
workflow A, which has no such column. The create then fails at the
|
||||
server, past the point where the operator could hear about it.
|
||||
|
||||
The union felt safer because it rejected less. "Rejects less" is not "correct" — it moved the failure
|
||||
downstream. `getDefaultWorkflowId()` is the same authority `resolveWorkflowIntakeFacts` uses in
|
||||
task-creation, so capture validation and card creation now agree by construction.
|
||||
|
||||
THE FIRST VERSION OF THIS SUITE ASSERTED THE UNION, so it had to change with the rule — recorded rather
|
||||
than silently rewritten, because a test that changes with the code is only legitimate when the CONTRACT
|
||||
changed, and here it did.
|
||||
*/
|
||||
describe("quick capture accepts the columns of the workflow a new card lands on", () => {
|
||||
const customIr = {
|
||||
version: "v2", id: "wf-custom", name: "custom", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
|
||||
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }] },
|
||||
],
|
||||
};
|
||||
|
||||
function deps(options: { defaultWorkflowId?: string } = {}) {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
created,
|
||||
taskStore: {
|
||||
createTask: async (input: Record<string, unknown>) => {
|
||||
created.push(input);
|
||||
return { id: "FN-1", column: input.column, description: input.description, updatedAt: "2026-07-31T00:00:00.000Z" };
|
||||
},
|
||||
getDefaultWorkflowId: async () => options.defaultWorkflowId,
|
||||
getWorkflowDefinition: async (id: string) => (id === "wf-custom" ? { id, ir: customIr } : undefined),
|
||||
listWorkflowDefinitions: async () => [{ id: "wf-custom", ir: customIr }],
|
||||
},
|
||||
pluginId: "glasses",
|
||||
defaultColumn: "backlog",
|
||||
} as never;
|
||||
}
|
||||
|
||||
const columnOf = (d: unknown) => (d as { created: Array<{ column?: string }> }).created[0]?.column;
|
||||
|
||||
it("accepts a column of the project's default workflow when that workflow is custom", async () => {
|
||||
const d = deps({ defaultWorkflowId: "wf-custom" });
|
||||
|
||||
await runQuickCapture({ text: "ship the thing", column: "checking" }, d);
|
||||
|
||||
expect(columnOf(d)).toBe("checking");
|
||||
});
|
||||
|
||||
it("REJECTS a column from another workflow the new card will not land on", async () => {
|
||||
/*
|
||||
The case that killed the union: `checking` exists on `wf-custom`, but this project's default is the
|
||||
builtin lineage, and quick capture does not select a workflow. Accepting it would create a card in
|
||||
a column its own workflow does not declare.
|
||||
*/
|
||||
await expect(runQuickCapture({ text: "ship it", column: "checking" }, deps())).rejects.toThrow(
|
||||
/invalid column/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the builtin default's own columns when the default is builtin", async () => {
|
||||
const d = deps();
|
||||
|
||||
await runQuickCapture({ text: "ship it", column: "in-progress" }, d);
|
||||
|
||||
expect(columnOf(d)).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("still rejects a column no workflow declares", async () => {
|
||||
await expect(runQuickCapture({ text: "ship it", column: "nonsense" }, deps({ defaultWorkflowId: "wf-custom" }))).rejects.toThrow(
|
||||
/invalid column/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-09:10 (PR #2644 review, greptile P1):
|
||||
|
||||
THE CONFIGURED DEFAULT IS ALSO A COLUMN, and it was the one path that never got validated. A capture
|
||||
with no `column` returned the plugin SETTING verbatim, so on a project whose default workflow does not
|
||||
declare that column, every voice capture created a card in an undeclared column. I fixed the
|
||||
requested-column path twice and left the far more common path — no column named at all — unchecked.
|
||||
*/
|
||||
describe("the configured default column is validated too", () => {
|
||||
const customIr = {
|
||||
version: "v2", name: "Custom Board", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
|
||||
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
],
|
||||
};
|
||||
|
||||
function deps(defaultColumn: string, workflowId?: string) {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
created,
|
||||
taskStore: {
|
||||
createTask: async (input: Record<string, unknown>) => {
|
||||
created.push(input);
|
||||
return { id: "FN-1", column: input.column, description: input.description, updatedAt: "2026-07-31T00:00:00.000Z" };
|
||||
},
|
||||
getDefaultWorkflowId: async () => workflowId,
|
||||
getWorkflowDefinition: async (id: string) => (id === "wf-custom" ? { id, ir: customIr } : undefined),
|
||||
},
|
||||
pluginId: "glasses",
|
||||
defaultColumn,
|
||||
} as never;
|
||||
}
|
||||
|
||||
const columnOf = (d: unknown) => (d as { created: Array<{ column?: string }> }).created[0]?.column;
|
||||
|
||||
it("falls to the workflow's INTAKE when the configured default is not declared", async () => {
|
||||
// Pre-fix: created the card in `todo`, which this board does not have.
|
||||
const d = deps("todo", "wf-custom");
|
||||
|
||||
await runQuickCapture({ text: "capture this" }, d);
|
||||
|
||||
expect(columnOf(d)).toBe("backlog");
|
||||
});
|
||||
|
||||
it("uses the configured default when the workflow DOES declare it", async () => {
|
||||
// The paired positive: the operator's setting is honoured wherever it is valid, so this is not
|
||||
// "always use intake".
|
||||
const d = deps("building", "wf-custom");
|
||||
|
||||
await runQuickCapture({ text: "capture this" }, d);
|
||||
|
||||
expect(columnOf(d)).toBe("building");
|
||||
});
|
||||
|
||||
it("keeps the configured default when no workflow resolves at all", async () => {
|
||||
// Nothing to validate against is the legacy shape; refusing or rewriting here would break boards
|
||||
// that have never had a workflow selected.
|
||||
const d = deps("in-review");
|
||||
|
||||
await runQuickCapture({ text: "capture this" }, d);
|
||||
|
||||
expect(columnOf(d)).toBe("in-review");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-11:00 (PR #2644 review — I split the snapshot again):
|
||||
|
||||
ONE RESOLUTION FOR BOTH ANSWERS. The declared-column set and the intake fallback each resolved the
|
||||
workflow independently, so a workflow edit between them validated against one revision and selected the
|
||||
intake column from another — persisting a card in a column the validated revision does not declare.
|
||||
|
||||
Third place in this branch I have made this mistake (executor resume lanes, glasses lane context, here),
|
||||
and the second time AFTER fixing it elsewhere. The shape is always two helpers that each look correct,
|
||||
called in sequence, each doing its own read. Hence a read-count assertion rather than prose: it is the
|
||||
only thing that fails when someone reintroduces the split.
|
||||
*/
|
||||
describe("capture resolves the board once, not once per question", () => {
|
||||
const customIr = {
|
||||
version: "v2", name: "Custom Board", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
|
||||
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
],
|
||||
};
|
||||
|
||||
function countingDeps(defaultColumn: string) {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const reads = { definition: 0 };
|
||||
return {
|
||||
created,
|
||||
reads,
|
||||
taskStore: {
|
||||
createTask: async (input: Record<string, unknown>) => {
|
||||
created.push(input);
|
||||
return { id: "FN-1", column: input.column, description: input.description, updatedAt: "2026-07-31T00:00:00.000Z" };
|
||||
},
|
||||
getDefaultWorkflowId: async () => "wf-custom",
|
||||
getWorkflowDefinition: async () => {
|
||||
reads.definition += 1;
|
||||
return { id: "wf-custom", ir: customIr };
|
||||
},
|
||||
},
|
||||
pluginId: "glasses",
|
||||
defaultColumn,
|
||||
} as never;
|
||||
}
|
||||
|
||||
it("reads the workflow ONCE even when the fallback path needs the intake column too", async () => {
|
||||
// `todo` is not declared here, so this capture needs BOTH answers: the declared set (to reject it)
|
||||
// and the intake column (to land the card). One read must serve both.
|
||||
const d = countingDeps("todo");
|
||||
|
||||
await runQuickCapture({ text: "capture this" }, d);
|
||||
|
||||
expect((d as unknown as { reads: { definition: number } }).reads.definition).toBe(1);
|
||||
expect((d as unknown as { created: Array<{ column?: string }> }).created[0]?.column).toBe("backlog");
|
||||
});
|
||||
|
||||
it("reads once on the path where the requested column is accepted", async () => {
|
||||
const d = countingDeps("backlog");
|
||||
|
||||
await runQuickCapture({ text: "capture this", column: "building" }, d);
|
||||
|
||||
expect((d as unknown as { reads: { definition: number } }).reads.definition).toBe(1);
|
||||
expect((d as unknown as { created: Array<{ column?: string }> }).created[0]?.column).toBe("building");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-12:35 (PR #2644 review — what I could NOT prove, recorded):
|
||||
|
||||
`resolveWorkflowIrById` silently substitutes the BUILTIN default IR when a configured custom workflow is
|
||||
missing or unparsable, so its columns get treated as this project's vocabulary. The reviewer is right that
|
||||
this is laundering. I could not establish a better answer: the legacy-five fallback ACCEPTS `triage` (the
|
||||
column #2515 deleted — the defect I fixed earlier in this branch), accepting nothing rejects every named
|
||||
column on a project whose row is merely missing, and refusing outright loses the utterance.
|
||||
|
||||
The tell that stopped me shipping a fix: my isolated revert stayed GREEN. Two candidate behaviours my
|
||||
tests could not distinguish means I had no evidence, and a change I cannot make fail is a change I cannot
|
||||
justify. The substitution is named at the site and the decision left to whoever owns the missing-workflow
|
||||
contract.
|
||||
|
||||
What IS pinned below is the part that is provable: a readable custom board is used, and the row that was
|
||||
read is the snapshot (one read, enforced by the read-count cases above).
|
||||
*/
|
||||
describe("a readable custom default board is used for validation", () => {
|
||||
it("accepts a column the custom board declares", async () => {
|
||||
const customIr = {
|
||||
version: "v2", name: "Custom", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
|
||||
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
],
|
||||
};
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const d = {
|
||||
taskStore: {
|
||||
createTask: async (input: Record<string, unknown>) => {
|
||||
created.push(input);
|
||||
return { id: "FN-1", column: input.column, description: input.description, updatedAt: "2026-07-31T00:00:00.000Z" };
|
||||
},
|
||||
getDefaultWorkflowId: async () => "wf-custom",
|
||||
getWorkflowDefinition: async () => ({ id: "wf-custom", ir: customIr }),
|
||||
},
|
||||
pluginId: "glasses",
|
||||
defaultColumn: "backlog",
|
||||
} as never;
|
||||
|
||||
await runQuickCapture({ text: "capture this", column: "building" }, d);
|
||||
|
||||
expect(created[0]?.column).toBe("building");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,15 @@ describe("settings accessors", () => {
|
||||
expect(getCompanionWebhookUrl({})).toBeUndefined();
|
||||
expect(getPollingIntervalMs({})).toBe(30000);
|
||||
expect(getNotifyColumns({})).toEqual(["in-review"]);
|
||||
expect(getQuickCaptureColumn({})).toBe("triage");
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-11:35: was `triage` — the column U11 (#2515)
|
||||
deleted from the default lineage, so quick capture defaulted every voice-captured card
|
||||
to a column the default board no longer declares. `todo` exists on both sides of that
|
||||
migration. These two cases are the reason the default is worth pinning at all: nothing
|
||||
else in the plugin fails when the default names a non-existent column — the server just
|
||||
rejects the create, at the far end of a voice interaction.
|
||||
*/
|
||||
expect(getQuickCaptureColumn({})).toBe("todo");
|
||||
expect(agentActionsEnabled({})).toBe(true);
|
||||
});
|
||||
|
||||
@@ -41,7 +49,7 @@ describe("settings accessors", () => {
|
||||
|
||||
it("validates quick capture column", () => {
|
||||
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "done" })).toBe("done");
|
||||
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "bad-column" })).toBe("triage");
|
||||
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "bad-column" })).toBe("todo");
|
||||
});
|
||||
|
||||
it("respects explicit boolean for agent actions", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginContext } from "@fusion/plugin-sdk";
|
||||
import { isBuiltinWorkflowId, resolveLifecycleColumns, resolveWorkflowIrById, resolveWorkflowIrForTask } from "@fusion/core";
|
||||
import { taskToCard, type GlassesCard } from "./cards.js";
|
||||
import { GlassesInputError } from "./quick-capture.js";
|
||||
|
||||
@@ -14,6 +15,214 @@ type AgentActionDeps = {
|
||||
cardOptions?: unknown;
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-03:20 (U11 #2515 audit):
|
||||
Is the card in its workflow's PRE-IMPLEMENTATION lane — intake or hold?
|
||||
|
||||
These gates named `triage` directly. U11 merged Todo into Planning keeping the id
|
||||
`todo` and DELETING `triage`, so on the default lineage `approvePlan` refused every
|
||||
card (an awaiting-approval card now sits in `todo`) and `retryTask`'s planning branch
|
||||
became unreachable. `startWork` survived only because it already accepted both ids —
|
||||
which is the tell: the gate written against a lane kept working, the two written
|
||||
against an id broke.
|
||||
|
||||
Resolves the task's OWN lanes when the host store can (this plugin depends on
|
||||
`@fusion/core`), and falls back to BOTH legacy ids when it cannot. The fallback is
|
||||
not cosmetic: `PluginContext["taskStore"]` is a narrowed surface and is not
|
||||
guaranteed to expose workflow selection, so a plugin must degrade to something that
|
||||
works rather than to something that refuses.
|
||||
*/
|
||||
const LEGACY_PLANNING_IDS = new Set(["triage", "todo"]);
|
||||
|
||||
/** Legacy destination ids, used only when the workflow declares no such role. */
|
||||
const LEGACY_DESTINATIONS = { hold: "todo", wip: "in-progress", review: "in-review" } as const;
|
||||
|
||||
type Lanes = {
|
||||
intake?: string; hold?: string; wip?: string; review?: string; complete?: string; archived?: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* FNXC:PluginLifecycleColumns 2026-07-30-05:10 (PR #2607 review — greptile P1):
|
||||
* Is the card in its workflow's PRE-IMPLEMENTATION lane?
|
||||
*
|
||||
* The legacy acceptance is SCOPED: a legacy id counts only when the workflow does not
|
||||
* assign it to ANY role. Unscoped, a workflow that names its review or wip lane
|
||||
* `triage`/`todo` would have those cards authorized as planning work — greptile's
|
||||
* finding, and the same over-reach caught on #2593 and #2602. Scoping needs the whole
|
||||
* lane set, which is why `laneContext` returns all of it rather than intake/hold only;
|
||||
* the earlier version could not express this and said so as a known limitation.
|
||||
*/
|
||||
function isInPlanningLane(lanes: Lanes | undefined, column: string, declared: Set<string>): boolean {
|
||||
if (column === lanes?.intake || column === lanes?.hold) return true;
|
||||
return LEGACY_PLANNING_IDS.has(column) && !declared.has(column);
|
||||
}
|
||||
|
||||
/**
|
||||
* A move target for `role`, or `undefined` when this workflow has no such lane.
|
||||
*
|
||||
* FNXC:PluginLifecycleColumns 2026-07-30-21:40 (PR #2607 review — FIFTH finding, same rule):
|
||||
* THE LEGACY ID IS NOT A CANDIDATE AT ALL once the workflow speaks columns. Every previous
|
||||
* revision tried to qualify the fallback — "declared", then "declared and not assigned to another
|
||||
* role" — and each qualification left a hole review found: first an aliased review lane named
|
||||
* `todo`, then a TRAITLESS parking column named `todo`, which no role check can see because it
|
||||
* carries no role.
|
||||
*
|
||||
* The qualifications were the mistake. If `laneContext` returned a lane set, the workflow HAS a
|
||||
* column vocabulary, so "no column carries the hold trait" is a complete answer: there is nowhere
|
||||
* legitimate to send the card and the action must refuse. A column merely NAMED `todo` implements
|
||||
* nothing.
|
||||
*
|
||||
* The legacy id survives in exactly one case — `lanes` is undefined, meaning the workflow could not
|
||||
* be resolved at all. There is no basis to decide then, and a pre-U11 board really does use these
|
||||
* ids, so refusing would break the migration this program is mid-way through.
|
||||
*
|
||||
* Five attempts at one rule, so it is worth stating plainly: A LEGACY ID IS NOT A ROLE, and
|
||||
* "declared" is not "declared FOR THIS ROLE" — including when it is declared for no role at all.
|
||||
*/
|
||||
function destination(
|
||||
lanes: Lanes | undefined,
|
||||
role: keyof typeof LEGACY_DESTINATIONS,
|
||||
): string | undefined {
|
||||
if (!lanes) return LEGACY_DESTINATIONS[role];
|
||||
return lanes[role];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* FNXC:PluginLifecycleColumns 2026-07-30-07:14: one resolve per action. Both the gate
|
||||
* (is the card in a planning lane?) and the destination (where does it go?) need the
|
||||
* SAME declared-column set — resolving them separately is how the two halves drifted
|
||||
* out of agreement in the first place (PR #2607: gates converted, destinations literal).
|
||||
*/
|
||||
async function laneContext(
|
||||
taskStore: AgentActionDeps["taskStore"],
|
||||
taskId: string,
|
||||
): Promise<{ lanes: Lanes | undefined; declared: Set<string>; degraded: boolean }> {
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-02:25 (PR #2644 review, greptile P1):
|
||||
ONE SNAPSHOT PER ACTION. Three helpers used to read the workflow independently — the degraded
|
||||
probe, the lane resolution, and the declared-column read — so a workflow edited or deleted
|
||||
mid-action could combine a NOT-degraded verdict with fallback lanes, or lanes from one revision
|
||||
with declarations from another. The action then either conflicted on a card that was fine or moved
|
||||
it toward a column the current workflow no longer has.
|
||||
|
||||
Same fix as the executor's resume lanes (#2640 review): the two or three halves of one decision
|
||||
must read one snapshot. Here that means resolving the IR ONCE and deriving everything from it — the
|
||||
degraded verdict included, which is now simply "a selection names a workflow whose definition did
|
||||
not come back in THIS read".
|
||||
*/
|
||||
const store = taskStore as unknown as {
|
||||
getTaskWorkflowSelectionAsync?: (id: string) => Promise<{ workflowId?: string } | undefined>;
|
||||
getTaskWorkflowSelection?: (id: string) => { workflowId?: string } | undefined;
|
||||
getWorkflowDefinition?: (id: string) => Promise<{ ir?: unknown } | undefined>;
|
||||
};
|
||||
|
||||
let selectionWorkflowId: string | undefined;
|
||||
try {
|
||||
const selection = (await store.getTaskWorkflowSelectionAsync?.(taskId))
|
||||
?? store.getTaskWorkflowSelection?.(taskId);
|
||||
selectionWorkflowId = selection?.workflowId;
|
||||
} catch {
|
||||
/* Cannot read the selection: treat as degraded below, since a MOVE must not guess. */
|
||||
return { lanes: undefined, declared: new Set(), degraded: true };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-08:10 (PR #2644 review — my identity check rejected every
|
||||
persisted custom workflow):
|
||||
|
||||
PROVE THE READ, DO NOT CHECK THE IR'S IDENTITY. My previous revision required the resolved IR to
|
||||
identify as the selected workflow id. A persisted custom workflow's stored IR usually carries NO `id`
|
||||
and its `name` is a DISPLAY name ("Six Column Shape"), not the selection id ("wf_7") — so the check
|
||||
marked every valid custom board degraded and refused every action on it. Strictly worse than the
|
||||
laundering it was meant to stop: that was a wrong answer in a rare case, this was a refusal in the
|
||||
common one.
|
||||
|
||||
My own test passed because the fixture's IR happened to carry `id: "wf-custom"`, matching its
|
||||
selection id. A fixture coincidence standing in for the property under test — the same failure this
|
||||
PR has already documented twice.
|
||||
|
||||
What I actually need is proof the DEFINITION READ SUCCEEDED, not proof of identity:
|
||||
- CUSTOM selection: `getWorkflowDefinition(id)` must return a row with an `ir`. A missing row or a
|
||||
throw is the state `resolveWorkflowIrForTask` papers over with the default IR, and it is the only
|
||||
thing that can silently substitute another board's vocabulary.
|
||||
- BUILTIN selection: resolves through the in-process catalog, so there is no read to fail — but an
|
||||
UNKNOWN builtin id would fall back to the default, so the id must actually be a builtin.
|
||||
- NO selection: nothing to mismatch. The default IS the answer, and refusing here would break every
|
||||
board that has never had a workflow explicitly selected.
|
||||
*/
|
||||
let snapshotIr: unknown;
|
||||
if (selectionWorkflowId === undefined) {
|
||||
try {
|
||||
snapshotIr = await resolveWorkflowIrForTask(taskStore as never, taskId);
|
||||
} catch {
|
||||
return { lanes: undefined, declared: new Set(), degraded: true };
|
||||
}
|
||||
} else if (isBuiltinWorkflowId(selectionWorkflowId)) {
|
||||
try {
|
||||
snapshotIr = await resolveWorkflowIrById(taskStore as never, selectionWorkflowId);
|
||||
} catch {
|
||||
return { lanes: undefined, declared: new Set(), degraded: true };
|
||||
}
|
||||
} else {
|
||||
if (!store.getWorkflowDefinition) {
|
||||
/* No definitions surface at all is the legacy shape, not a degraded custom board. */
|
||||
try {
|
||||
snapshotIr = await resolveWorkflowIrForTask(taskStore as never, taskId);
|
||||
} catch {
|
||||
return { lanes: undefined, declared: new Set(), degraded: true };
|
||||
}
|
||||
} else {
|
||||
let definition: { ir?: unknown } | undefined;
|
||||
try {
|
||||
definition = await store.getWorkflowDefinition(selectionWorkflowId);
|
||||
} catch {
|
||||
return { lanes: undefined, declared: new Set(), degraded: true };
|
||||
}
|
||||
if (definition?.ir == null) return { lanes: undefined, declared: new Set(), degraded: true };
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-17:10 (PR #2644 review, CodeRabbit — REAL, and NOT fixed):
|
||||
A STORED STRING IR COSTS A SECOND READ, so the lanes can come from a different revision than the
|
||||
degraded verdict — the drift the surrounding comment claims to have removed. My earlier excuse for
|
||||
it ("deliberate and confined to that shape") was not a reason.
|
||||
|
||||
I tried `parseWorkflowIr(definition.ir)` to keep it to one read. The parse succeeds in isolation and
|
||||
`resolveLifecycleColumns` returns the right roles for the parsed IR (verified directly), but the
|
||||
action still refused in the plugin harness, so something between the parse and the lane check
|
||||
differs from the resolver path and I could not identify it here. Shipping an unexplained change into
|
||||
the code path that decides whether an operator's action is REFUSED trades a bounded drift window for
|
||||
an unbounded one, so the second read stays — named rather than excused.
|
||||
|
||||
The window is small and its failure mode is safe: it needs a workflow edit between two reads
|
||||
microseconds apart, and the result is a refusal or a stale lane set, never a move onto another
|
||||
board's column. A store that returned parsed IRs removes it entirely; that is where the fix belongs.
|
||||
*/
|
||||
snapshotIr = typeof definition.ir === "string"
|
||||
? await resolveWorkflowIrById(taskStore as never, selectionWorkflowId)
|
||||
: definition.ir;
|
||||
}
|
||||
}
|
||||
|
||||
const roles = snapshotIr ? resolveLifecycleColumns(snapshotIr as never) : undefined;
|
||||
const lanes: Lanes | undefined = roles ?? undefined;
|
||||
const declared = new Set(
|
||||
Object.values(lanes ?? {}).filter((value): value is string => typeof value === "string"),
|
||||
);
|
||||
/*
|
||||
Declared ids come from the SAME snapshot. A column carrying no lifecycle trait is invisible to
|
||||
`lanes`, which is why the IR's own column list is unioned in — that is what stops an inert column
|
||||
named `todo` being claimed as a planner lane.
|
||||
*/
|
||||
for (const column of (snapshotIr as { columns?: Array<{ id?: unknown }> } | undefined)?.columns ?? []) {
|
||||
if (typeof column?.id === "string") declared.add(column.id);
|
||||
}
|
||||
|
||||
return { lanes, declared, degraded: false };
|
||||
}
|
||||
|
||||
type AgentActionResult = {
|
||||
task: TaskRecord;
|
||||
card: GlassesCard;
|
||||
@@ -50,11 +259,18 @@ async function toResult(taskStore: PluginContext["taskStore"], taskId: string):
|
||||
export async function startWork(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
|
||||
const taskId = normalizeTaskId(input.taskId);
|
||||
const task = await getTaskOrThrow(deps.taskStore, taskId);
|
||||
if ((task.column !== "triage" && task.column !== "todo") || START_WORK_BLOCKED_STATUSES.has(String(task.status))) {
|
||||
const { lanes: startLanes, declared: startDeclared, degraded: startDegraded } = await laneContext(deps.taskStore, taskId);
|
||||
if (startDegraded) conflict("start-work", task);
|
||||
if (
|
||||
!isInPlanningLane(startLanes, String(task.column), startDeclared)
|
||||
|| START_WORK_BLOCKED_STATUSES.has(String(task.status))
|
||||
) {
|
||||
conflict("start-work", task);
|
||||
}
|
||||
const startTarget = destination(startLanes, "wip");
|
||||
if (!startTarget) conflict("start-work", task);
|
||||
// Intentional v1 limitation: plugin cannot import engine allocator, so moveTask runs without allocateWorktree.
|
||||
await deps.taskStore.moveTask(taskId, "in-progress");
|
||||
await deps.taskStore.moveTask(taskId, startTarget);
|
||||
return toResult(deps.taskStore, taskId);
|
||||
}
|
||||
|
||||
@@ -71,10 +287,17 @@ export async function requestReview(input: AgentActionInput, deps: AgentActionDe
|
||||
export async function approvePlan(input: AgentActionInput, deps: AgentActionDeps): Promise<AgentActionResult> {
|
||||
const taskId = normalizeTaskId(input.taskId);
|
||||
const task = await getTaskOrThrow(deps.taskStore, taskId);
|
||||
if (task.column !== "triage" || task.status !== "awaiting-approval") {
|
||||
const { lanes: approveLanes, declared: approveDeclared, degraded: approveDegraded } = await laneContext(deps.taskStore, taskId);
|
||||
if (approveDegraded) conflict("approve-plan", task);
|
||||
if (
|
||||
!isInPlanningLane(approveLanes, String(task.column), approveDeclared)
|
||||
|| task.status !== "awaiting-approval"
|
||||
) {
|
||||
conflict("approve-plan", task);
|
||||
}
|
||||
await deps.taskStore.moveTask(taskId, "todo");
|
||||
const approveTarget = destination(approveLanes, "hold");
|
||||
if (!approveTarget) conflict("approve-plan", task);
|
||||
await deps.taskStore.moveTask(taskId, approveTarget);
|
||||
await deps.taskStore.updateTask(taskId, { status: undefined });
|
||||
return toResult(deps.taskStore, taskId);
|
||||
}
|
||||
@@ -113,8 +336,18 @@ export async function retryTask(input: AgentActionInput, deps: AgentActionDeps):
|
||||
return toResult(deps.taskStore, taskId);
|
||||
}
|
||||
|
||||
const { lanes: retryLanes, declared: retryDeclared, degraded: retryDegraded } = await laneContext(deps.taskStore, taskId);
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-15:10 (PR #2644 review, CodeRabbit — MAJOR, my regression):
|
||||
DEGRADED GATES ONLY THE LANE-DEPENDENT BRANCH. I put the refusal at the top of `retryTask`, which also
|
||||
blocked the plain failure-retry below — a branch that reads no lanes at all and only clears status. So a
|
||||
FAILED card became un-retryable whenever its workflow definition could not be read, which is exactly the
|
||||
situation an operator is trying to retry out of. The refusal existed to stop a MOVE onto another board's
|
||||
vocabulary; the failure-retry performs no move, so it has nothing to be wrong about.
|
||||
*/
|
||||
if (
|
||||
task.column === "triage" &&
|
||||
retryDegraded === false &&
|
||||
isInPlanningLane(retryLanes, String(task.column), retryDeclared) &&
|
||||
(RETRYABLE_TRIAGE_STATUSES.has(String(task.status)) || (typeof task.stuckKillCount === "number" && task.stuckKillCount > 0))
|
||||
) {
|
||||
// Intentional v1 limitation: does not delete on-disk PROMPT.md or run dashboard step-reset/branch-inspection logic.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginContext } from "@fusion/plugin-sdk";
|
||||
import { isBuiltinWorkflowId, resolveDefaultWorkflowIr, resolveLifecycleColumns, resolveWorkflowIrById } from "@fusion/core";
|
||||
import { taskToCard, type GlassesCard } from "./cards.js";
|
||||
import type { TaskColumn } from "./settings.js";
|
||||
|
||||
@@ -78,12 +79,99 @@ export function parseUtterance(raw: unknown, opts: { maxTitleChars?: number } =
|
||||
return splitTitleAndDescription(stripped, opts);
|
||||
}
|
||||
|
||||
function normalizeCaptureColumn(value: unknown, fallback: TaskColumn): TaskColumn {
|
||||
const raw = normalizeDescription(value);
|
||||
if (raw === "triage" || raw === "todo" || raw === "in-progress" || raw === "in-review" || raw === "done") {
|
||||
return raw;
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-10:50 (PR #2644 review — I split the snapshot AGAIN):
|
||||
|
||||
ONE RESOLUTION FOR BOTH ANSWERS. `declaredCaptureColumnIds` and the intake lookup each resolved the
|
||||
workflow independently, so a workflow edit between them validated against one revision and selected the
|
||||
intake column from another — persisting a card in a column the validated revision does not declare.
|
||||
|
||||
This is the THIRD place in this branch I have made the same mistake (the executor's resume lanes, the
|
||||
glasses lane context, and now here), and the second time after fixing it elsewhere. The shape is always
|
||||
the same: two helpers that each look correct, called in sequence, each doing its own read. If a function
|
||||
needs two facts about one workflow, it needs one snapshot — not two helpers that happen to agree most of
|
||||
the time.
|
||||
|
||||
WHAT THE BOARD IS: the PROJECT'S DEFAULT workflow, which is the workflow a quick-captured card is created
|
||||
into. Not the builtin default (v1, which rejected a custom board's own columns) and not the union of all
|
||||
workflows (v2, which accepted columns the card cannot land in). `getDefaultWorkflowId()` is the same
|
||||
authority `resolveWorkflowIntakeFacts` uses in task-creation, so validation and creation agree by
|
||||
construction rather than by coincidence.
|
||||
*/
|
||||
async function resolveCaptureBoard(
|
||||
taskStore: PluginContext["taskStore"],
|
||||
): Promise<{ declared: ReadonlySet<string>; intake?: string }> {
|
||||
try {
|
||||
const store = taskStore as unknown as {
|
||||
getDefaultWorkflowId?: () => Promise<string | undefined>;
|
||||
getWorkflowDefinition?: (id: string) => Promise<{ ir?: unknown } | undefined>;
|
||||
};
|
||||
const workflowId = await store.getDefaultWorkflowId?.();
|
||||
let ir: unknown;
|
||||
if (workflowId && !isBuiltinWorkflowId(workflowId) && store.getWorkflowDefinition) {
|
||||
const definition = await store.getWorkflowDefinition(workflowId);
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-12:30 (PR #2644 review — and I could NOT prove the fix, so
|
||||
it is not here):
|
||||
The reviewer is right that `resolveWorkflowIrById` silently substitutes the BUILTIN default IR when
|
||||
a configured custom workflow is missing or unparsable, so its columns get treated as this project's
|
||||
vocabulary. What I could not establish is a BETTER answer for that state:
|
||||
- falling back to the legacy five ACCEPTS `triage`, the column #2515 deleted — creating a card in
|
||||
a column no board has, which is the defect I fixed earlier in this same branch;
|
||||
- accepting nothing rejects every explicitly-named column on a project whose workflow row is
|
||||
merely missing, which is harsher than the substitution;
|
||||
- refusing the capture outright loses the operator's utterance.
|
||||
Every candidate trades one wrong behaviour for another, and my tests could not distinguish them —
|
||||
the isolated revert stayed green, which is the signal that I was about to ship an unprovable change.
|
||||
So the substitution stands, named here, and the DECISION is left to whoever owns the missing-workflow
|
||||
contract rather than guessed at in a plugin.
|
||||
|
||||
What IS fixed and provable: the definition row we read IS the snapshot, so this stays to ONE read.
|
||||
A stored STRING ir is the only case needing core to parse it.
|
||||
*/
|
||||
ir = definition?.ir != null && typeof definition.ir !== "string"
|
||||
? definition.ir
|
||||
: await resolveWorkflowIrById(taskStore as never, workflowId);
|
||||
} else {
|
||||
ir = workflowId
|
||||
? await resolveWorkflowIrById(taskStore as never, workflowId)
|
||||
: resolveDefaultWorkflowIr();
|
||||
}
|
||||
const declared = new Set<string>();
|
||||
for (const column of (ir as { columns?: Array<{ id?: unknown }> }).columns ?? []) {
|
||||
if (typeof column?.id === "string") declared.add(column.id);
|
||||
}
|
||||
if (declared.size === 0) return { declared: LEGACY_CAPTURE_COLUMN_IDS };
|
||||
return { declared, intake: resolveLifecycleColumns(ir as never)?.intake };
|
||||
} catch {
|
||||
/* A column-less or unresolvable workflow gives no basis to decide; the legacy five keep prior behavior. */
|
||||
return { declared: LEGACY_CAPTURE_COLUMN_IDS };
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const LEGACY_CAPTURE_COLUMN_IDS: ReadonlySet<string> = new Set([
|
||||
"triage", "todo", "in-progress", "in-review", "done",
|
||||
]);
|
||||
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-31-08:50:
|
||||
THE CONFIGURED DEFAULT IS ALSO A COLUMN, and it was the one path that never got checked. A capture with
|
||||
no `column` returned the plugin SETTING verbatim — `triage` out of the box, the column #2515 deleted — so
|
||||
on a project whose default workflow does not declare it, every voice capture created a card in an
|
||||
undeclared column. When the configured default is not declared, the honest destination is the workflow's
|
||||
own INTAKE column: that is where a new card belongs, and it now comes from the SAME snapshot as the
|
||||
validation.
|
||||
*/
|
||||
async function normalizeCaptureColumn(
|
||||
taskStore: PluginContext["taskStore"],
|
||||
value: unknown,
|
||||
fallback: TaskColumn,
|
||||
): Promise<TaskColumn> {
|
||||
const board = await resolveCaptureBoard(taskStore);
|
||||
const raw = normalizeDescription(value);
|
||||
if (raw && board.declared.has(raw)) return raw as TaskColumn;
|
||||
if (board.declared.has(fallback)) return fallback;
|
||||
return (board.intake ?? fallback) as TaskColumn;
|
||||
}
|
||||
|
||||
export async function runQuickCapture(
|
||||
@@ -96,7 +184,7 @@ export async function runQuickCapture(
|
||||
): Promise<{ task: Awaited<ReturnType<PluginContext["taskStore"]["createTask"]>>; card: GlassesCard }> {
|
||||
const { title, description } = parseUtterance(input.text);
|
||||
const requested = input.column;
|
||||
const normalizedColumn = normalizeCaptureColumn(requested, deps.defaultColumn);
|
||||
const normalizedColumn = await normalizeCaptureColumn(deps.taskStore, requested, deps.defaultColumn);
|
||||
if (requested !== undefined && normalizeDescription(requested) !== normalizedColumn) {
|
||||
throw new GlassesInputError(400, "invalid column");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,27 @@ const DEFAULT_BASE_URL = "http://localhost:4040";
|
||||
const DEFAULT_POLLING_INTERVAL_SECONDS = 30;
|
||||
const MIN_POLLING_INTERVAL_SECONDS = 5;
|
||||
const DEFAULT_NOTIFY_COLUMNS = ["in-review"];
|
||||
const DEFAULT_QUICK_CAPTURE_COLUMN = "triage";
|
||||
/*
|
||||
FNXC:PluginLifecycleColumns 2026-07-30-11:30 (Phase C convergence):
|
||||
The quick-capture default was `triage` — the column U11 (#2515) DELETED from the default
|
||||
lineage. So every voice-captured card asked the server for a column the default board no
|
||||
longer declares. `todo` is the post-U11 merged planning column AND a column every pre-U11
|
||||
lineage also declares, so it is correct on both sides of the migration; `triage` was correct
|
||||
on neither after #2515.
|
||||
|
||||
NOT trait-resolved, deliberately: this is a SETTINGS DEFAULT with no task and no workflow in
|
||||
hand — there is nothing to resolve against at module scope. The operator picks the real
|
||||
column from `enumValues`; this only has to be a column that exists when they have not.
|
||||
|
||||
KNOWN REMAINING GAP, recorded rather than half-fixed: `TaskColumn`/`COLUMN_SET` are still the
|
||||
five legacy ids, so on a RENAMED board the enum offers columns that do not exist and
|
||||
`normalizeCaptureColumn` silently substitutes the default for a column the operator did name
|
||||
(voice "put it in checking" lands in planning, with no error). Closing that needs the board's
|
||||
declared columns, and this plugin reaches Fusion over HTTP with no board-columns endpoint in
|
||||
`FusionApiClient` — a new read, not a rename. Silent substitution is the worse half of that
|
||||
bug and it is unchanged here; I am not papering over it with a guess.
|
||||
*/
|
||||
const DEFAULT_QUICK_CAPTURE_COLUMN = "todo";
|
||||
|
||||
type TaskColumn = "triage" | "todo" | "in-progress" | "in-review" | "done";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user