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:
gsxdsm
2026-07-30 00:52:55 -07:00
committed by GitHub
parent 76e92f33c4
commit dca20496f4
27 changed files with 2551 additions and 65 deletions

View File

@@ -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" &&

View File

@@ -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");
});
});

View File

@@ -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/,
);
});
});

View File

@@ -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" }) }),
}),

View File

@@ -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");
});
});

View File

@@ -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", () => {

View File

@@ -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.

View File

@@ -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");
}

View File

@@ -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";