U12: resolve the move-path compatibility flag — trait hooks unconditional, legacy branch deleted (#2655)

U12's headline goal. The raw `experimentalFeatures.workflowColumns` flag
gated **every task move**; its six seams are now unconditional and the
flag, its last two readers, and the 124-line inline legacy branch are
deleted.

## Deleted, not converted

The flag-OFF branch goes with the gate. Converting a branch we intended
to delete would have left a second definition of every column side
effect alive to drift — the defect this program has spent its length
removing.

Both readers flip in **one commit** because they are not separable: the
preflight in `workflow-task-create-ops.ts` computes the
`movePolicyPreflight` that `moves.ts` consumes and validates. Un-gating
either alone either evaluates workflow move policies — with their
plugin-gate side effects — whose result is ignored, or validates against
a preflight that was never computed.

## Evidence, not assertion

**Equivalence (precondition 1).** `moves-flag-equivalence.test.ts`
(commit 1) ran the same journey under both flag states against live
PostgreSQL and diffed the persisted row: **identical across 128 fields**
plus an equal timing shape, over `todo → in-progress → in-review → todo
→ in-progress`. Mutation-verified both ways — stamping the flag-ON
branch, and diverging the reopen hook, each fail it.

**And there was stronger evidence already on main that isn't mine.**
U2b's `move-path-equivalence.pg.test.ts` ran *every* scenario once per
path and has been green across ~10 of them: `preserveStatus`,
`preservePause`, timing accounting, `preserveProgress`,
`preserveWorktree`, engine-source rehome, `in-progress → todo`. Two
independently built harnesses agreeing is the best evidence this
question has had.

**The flag was read by nothing in production.** `experimentalFeatures`
is global-only and no module writes it, so this path had never run for
any project without a stale persisted value. That is also why a green
suite was never evidence on its own — both paths were individually valid
and only one was live.

## Two claims of mine this PR corrects

**1. Seam 2 does not introduce new rejections.** I said in #2639 and in
the census that with the flag off there is *no* target validation, so
flipping would add refusals. Reproduced the opposite: a move to an
undeclared column already rejects on the legacy path with `Invalid
transition: … Valid targets: …`. I found it because the discriminator I
wrote to prove "the flag is the cause" failed.

**2. My first equivalence test proved nothing.** It used
`updateSettings`; `experimentalFeatures` is **global-only**, so
`getSettingsFast()` filtered the write out and `useWorkflow` was false
in *both* runs. Caught by stamping the flag-ON branch and watching the
test stay green. It now writes via `updateGlobalSettings` and **asserts
the flag took effect** before the journey. U2b's harness carries the
same warning independently — `MUST be updateGlobalSettings, NOT
updateSettings`.

## The user-visible change

Move rejections now report **workflow-resolved** targets instead of the
hardcoded legacy adjacency table. Concretely: `Valid targets:
in-progress, triage, archived` becomes `Valid targets: archived,
in-progress`. That is the fix, not a regression — the legacy table still
advertised `triage`, a column the default lineage stopped declaring at
#2515, so an operator following the old message was told to move
somewhere impossible.

Likewise a move *into* `triage` is now refused rather than stranding the
card in a column with no trait flags, invisible to every trait-driven
sweep until reconciliation re-homes it.
`live-move-path-undeclared-target.test.ts` characterised exactly that
defect and carried `it.todo("should REFUSE a move into a column the
task's workflow does not declare (U2b)")` — **this fulfils it.**

## Test migration

| file | change |
|---|---|
| `move-path-equivalence.pg.test.ts` | deleted — every scenario ran once
per path; purpose fully discharged |
| `workflow-capacity-invariant.pg.test.ts` |
`setPath("inline"\|"hooks")` → `assertMovePathLive()`; the probe is
**kept** so capacity cannot pass because moves were broken for an
unrelated reason |
| `store-movement.pg.test.ts` | asserts the refusal **and** that the
legitimate backward move still works, so it reads as a narrowing |
| `raw-workflow-columns-flag-census.test.ts` | deleted per its own
instructions — it was built to fail in both directions and fired exactly
as designed: `expected [] to deeply equal [3 readers]` |
| `moves-workflow-flag-seams.test.ts` | deleted — it pinned the six
seams this removes |

## Verification

Full core suite: **33 failed / 10 files — byte-identical to main's
baseline**, with **zero** files failing exclusively on this branch. I
measured the baseline by checking out `origin/main` and running the same
command, because the first comparison I made was by count alone and
would have blamed the flip for 8 files that were already red.

`pnpm lint` clean. `pnpm test:gate` green (10 / 132 / 482 / 71). `tsc -p
packages/core/tsconfig.json` clean. Core builds.

## Left in place deliberately

The `workflowColumns` settings key stays schema-tolerated and is already
in `HIDDEN_EXPERIMENTAL_FEATURE_KEYS`, so an upgraded project carrying a
stale value renders nothing and loads cleanly. Removing it from the
schema would risk rejecting those projects for no benefit now that
nothing reads it.

---

## Rebased onto current main — and `triage` reaches ZERO

| metric | before | after |
|---|---:|---:|
| `moves.ts` column guards | 39 | **15** |
| repo column total | 745 | **741** |
| **`triage` column guards** | 1 | **ABSENT (0)** |

`triage` is now absent from `byColumnId` entirely: no unconverted
`triage` guard remains anywhere in production source. Combined with
#2664 (the last one, in `TaskContextMenu`) this closes bar item 1.

The census behaved exactly as designed on the rebase: the flip *deletes*
guards, so `--strict` reported `moves.ts: allows 39, tree has 15` rather
than leaving a stale allowance, and the re-record lands in this PR's
diff.

## Verification on the rebased tree

- Full core suite: **33 failed / 10 files — identical to main's
baseline**, zero files failing exclusively on this branch (measured by
checking out `origin/main` and diffing the failing-file sets, not by
comparing counts).
- `pnpm test:gate` green (10 / 158 / 487 / 71).
- `pnpm check:lifecycle-columns` exits 0.
- `pnpm lint` clean, `@fusion/core` builds.

## Two review fixes carried in this PR

**P1 — optionless engine moves lost their bypass.**
`resolveWorkflowBypassGuardsImpl` did `void moveSource;` — it discarded
the resolved parameter and re-read `options?.moveSource`, so
`moveTask(id, target)` resolved to `"engine"` at the call site and
computed `bypassGuards === false`. Latent while the flag gated
validation; with the gate gone, an internal executor/merger/recovery
move made without an options object would be judged as a user move.

**P2 — the absence signal.** Emitting `workflowId` unconditionally would
have stamped `builtin:coding` onto every task with no explicit
selection, reporting a fallback as authoritative. Now emits the
selection directly, so absent still means "not resolved here".


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
  - Task moves are now validated against the task’s declared workflow.
- Invalid destinations are rejected with a clear error, and tasks remain
in their original column.
  - Valid backward moves continue to work as expected.
- Move behavior and lifecycle updates are now handled consistently
across workflows.

<!-- 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 02:19:32 -07:00
committed by GitHub
parent 87a4dbdc60
commit bc782d8d92
16 changed files with 260 additions and 1290 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Task moves now validate against the board's own workflow, so cards cannot land in a column it does not declare.
category: fix
dev: Deletes the experimentalFeatures.workflowColumns gate on the move path (6 seams) and its inline legacy branch; column side effects run through default-workflow trait hooks unconditionally. Move rejections now report workflow-resolved targets rather than the legacy adjacency table, which no longer advertises the removed `triage` column. The settings key stays schema-tolerated and hidden for upgraded projects.

View File

@@ -114,11 +114,19 @@ pgDescribe("Coding (Ideas) custom-column moves (workflow-columns graduation)", (
await expect( await expect(
store.moveTask(task.id, "ideas", { moveSource: "user" }), store.moveTask(task.id, "ideas", { moveSource: "user" }),
).rejects.toThrow(/Invalid transition: '.*' → 'ideas'/); ).rejects.toThrow(/Invalid transition: '.*' → 'ideas'/);
// ...and so is a legacy-but-non-adjacent target, with the verbatim legacy target list. /*
FNXC:WorkflowColumns 2026-07-31-04:30 (U12 — the move-path flag is resolved):
...and so is a non-adjacent target. The advertised target list changed from
`in-progress, triage, archived` to `archived, in-progress`, and that is the FIX rather than a
regression: the old list came from the hardcoded legacy adjacency table, which still offered
`triage` — a column the default lineage stopped declaring at #2515. The move path now resolves
adjacency from the task's own workflow, so it can no longer advertise a column that does not
exist. An operator following the old message would have been told to move somewhere impossible.
*/
await store.moveTask(task.id, "todo", { moveSource: "user" }); await store.moveTask(task.id, "todo", { moveSource: "user" });
await expect( await expect(
store.moveTask(task.id, "in-review", { moveSource: "user" }), store.moveTask(task.id, "in-review", { moveSource: "user" }),
).rejects.toThrow("Invalid transition: 'todo' → 'in-review'. Valid targets: in-progress, triage, archived"); ).rejects.toThrow("Invalid transition: 'todo' → 'in-review'. Valid targets: archived, in-progress");
}); });
it("cancels an active task continuation when a user sends implementation back to todo", async () => { it("cancels an active task continuation when a user sends implementation back to todo", async () => {

View File

@@ -105,15 +105,27 @@ pgDescribe("live move path — which targets it accepts after the Planning merge
expect(workflowHasColumn(ir, "triage")).toBe(false); expect(workflowHasColumn(ir, "triage")).toBe(false);
expect(workflowHasColumn(ir, "todo")).toBe(true); expect(workflowHasColumn(ir, "todo")).toBe(true);
await store.moveTask(task.id, "triage" as never, { moveSource: "user" } as never); /*
FNXC:WorkflowColumns 2026-07-31-04:30 (U12 — the move-path flag is RESOLVED; this is the fix):
THE DEFECT IS GONE, so this case now asserts the refusal instead of the acceptance, and the
`it.todo` below it — "should REFUSE a move into a column the task's workflow does not declare
(U2b)" — is fulfilled rather than left dangling.
// Today's behavior. The card is now in a column its workflow does not declare, carrying no Before: the move was ACCEPTED and the card landed in a column carrying no trait flags, invisible
// trait flags, invisible to every trait-driven sweep until reconciliation re-homes it. to every trait-driven sweep until reconciliation re-homed it. Now the move path resolves the
expect(await column(task.id)).toBe("triage"); target against the task's own workflow unconditionally, so it is refused at the boundary.
The premise assertions above are deliberately kept: they prove `triage` really is undeclared, so
this cannot pass for the wrong reason if the default lineage ever declares it again.
*/
await expect(
store.moveTask(task.id, "triage" as never, { moveSource: "user" } as never),
).rejects.toThrow(/Unknown column for this workflow/);
// And the card never moved.
expect(await column(task.id)).toBe("todo");
}); });
it.todo("should REFUSE a move into a column the task's workflow does not declare (U2b)");
it("still permits every move the workflow DOES declare", async () => { it("still permits every move the workflow DOES declare", async () => {
/* /*
The regression direction that matters most. A guard that refused too much would break the The regression direction that matters most. A guard that refused too much would break the

View File

@@ -1,4 +1,18 @@
/* /*
FNXC:WorkflowColumns 2026-07-31-04:15 (U12 — THIS FILE'S EQUIVALENCE CASES ARE RETIRED, ON PURPOSE):
The compatibility flag is deleted in this same PR, so the two-flag-states comparison below cannot
run any more — there is only one path now. Its result is preserved in the commit that added it and
in the deletion's own comment: identical persisted rows across 128 fields plus an equal timing shape,
mutation-verified in both directions. That was the evidence the flip needed, and it discharged.
What SURVIVES here are the seam-2 cases, which never depended on the flag being flippable: a move to
a column the task's workflow does not declare is rejected, and the #1411 `recoveryRehome` carve-out
still lets a stranded custom-workflow card be rescued. Those remain live behaviour worth pinning
after the flip — the carve-out especially, because it is the thing that keeps recovery working on
custom boards and it looks like dead weight to anyone tidying up.
--- original header, kept for the record ---
FNXC:WorkflowColumns 2026-07-31-02:00 (U12 — precondition 1 for flipping the move-path flag): FNXC:WorkflowColumns 2026-07-31-02:00 (U12 — precondition 1 for flipping the move-path flag):
DO THE TWO COLUMN-SIDE-EFFECT IMPLEMENTATIONS AGREE? DO THE TWO COLUMN-SIDE-EFFECT IMPLEMENTATIONS AGREE?
@@ -30,133 +44,11 @@ import { pgDescribe, createSharedPgTaskStoreTestHarness } from "../__test-utils_
import type { TaskDetail } from "../types.js"; import type { TaskDetail } from "../types.js";
import type { TaskStore } from "../store.js"; import type { TaskStore } from "../store.js";
/**
* Fields whose difference between two runs is meaningless: identity, and stamps that advance with
* wall clock. Everything else must match, including the timing ACCUMULATORS (`cumulativeActiveMs`),
* which are the interesting part — they are computed from deltas, so a divergence in how the two
* implementations anchor a segment shows up there rather than in a raw timestamp.
*/
const VOLATILE_FIELDS = new Set([
"id",
// Per-task UUID; carries no behavioural meaning.
"lineageId",
"createdAt",
"updatedAt",
"columnMovedAt",
"executionStartedAt",
"executionCompletedAt",
"firstExecutionAt",
"cumulativeActiveMs",
"log",
]);
/*
Wall-clock noise is normalised RECURSIVELY rather than by a flat key list, because it is nested:
`columnDwellMs` is a column -> milliseconds map and run-audit-ish entries carry their own `observedAt`.
A flat list missed both, and the first run of this test reported them as divergences.
Durations become BOOLEANS (`>0`) rather than being dropped: whether time was attributed to a column at
all is exactly the behaviour under test, while the millisecond value differs between any two runs.
Dropping them would have hidden a real divergence; comparing them would have been permanently flaky.
*/
function normalize(value: unknown, key?: string, taskId?: string): unknown {
/*
IDENTITY is substituted inside strings rather than the field being dropped. `prompt` embeds the task
id (`# KB-001` vs `# KB-002`), so a raw comparison always fails and dropping it would stop comparing
the spec content entirely — which is one of the things the reset-on-entry side effect can touch.
Replacing the id keeps the content under test.
*/
if (typeof value === "string" && taskId) return value.split(taskId).join("<TASK_ID>");
if (typeof value === "number" && key !== undefined && /Ms$/.test(key)) return value > 0;
if (Array.isArray(value)) return value.map((entry) => normalize(entry, undefined, taskId));
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (VOLATILE_FIELDS.has(k) || /At$/.test(k)) continue;
// A duration MAP: keep the keys, reduce each value to "time was attributed here".
out[k] = /Ms$/.test(k) && v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([ck, cv]) => [ck, typeof cv === "number" ? cv > 0 : cv]))
: normalize(v, k, taskId);
}
return out;
}
return value;
}
function comparableSnapshot(task: TaskDetail): Record<string, unknown> {
return normalize(task, undefined, task.id) as Record<string, unknown>;
}
/** Which timing fields were SET (not their values), so anchoring behaviour is still compared. */
function timingShape(task: TaskDetail): Record<string, boolean> {
const t = task as unknown as Record<string, unknown>;
return {
hasExecutionStartedAt: t.executionStartedAt != null,
hasExecutionCompletedAt: t.executionCompletedAt != null,
hasFirstExecutionAt: t.firstExecutionAt != null,
accumulatedActiveTime: typeof t.cumulativeActiveMs === "number" && (t.cumulativeActiveMs as number) > 0,
};
}
/*
FNXC:WorkflowColumns 2026-07-31-02:30 (U12 — the trap this test fell into first):
THE FLAG IS GLOBAL-ONLY, so it must be written through `updateGlobalSettings`.
My first version used `updateSettings`, and the test PASSED — while proving nothing. `moves.ts` reads
`getSettingsFast()`, which filters `isGlobalOnlySettingsKey` out of the project layer, and
`experimentalFeatures` is exactly such a key (`isGlobalSettingsKey("experimentalFeatures") === true`).
So the project-scoped write was discarded, `useWorkflow` was false in BOTH runs, and the "equivalence
proof" was comparing the legacy path against itself.
Caught by stamping the flag-ON branch of `moves.ts` and observing that the test still passed — i.e. by
checking that the mutation could be detected, not by trusting the green. Exactly the failure this
program keeps finding, produced by me this time.
*/
async function setFlag(store: TaskStore, enabled: boolean): Promise<void> {
const current = await store.globalSettingsStore.getSettings();
await store.updateGlobalSettings({
...current,
experimentalFeatures: { ...(current.experimentalFeatures ?? {}), workflowColumns: enabled },
} as never);
// Prove the write took effect before relying on it — the whole point of this note.
const effective = await store.getSettingsFast();
if ((effective.experimentalFeatures?.workflowColumns === true) !== enabled) {
throw new Error(`flag write did not take effect: wanted ${enabled}, moves.ts would read ${effective.experimentalFeatures?.workflowColumns === true}`);
}
}
/**
* Drive one task through a column journey under a fixed flag state and return what persisted.
* The journey covers the transitions the legacy block special-cases: entering execution, leaving it
* (segment accumulation + abort-on-exit), and reaching review.
*/
async function runJourney(store: TaskStore, flagEnabled: boolean): Promise<{ snapshot: Record<string, unknown>; timing: Record<string, boolean> }> {
await setFlag(store, flagEnabled);
/*
IDENTICAL description in both runs. My first version interpolated the flag state and the whole-row
diff dutifully reported it — a self-inflicted failure that would have read as a real divergence.
*/
const created = await store.createTask({ description: "equivalence journey" });
/*
THE JOURNEY MUST GO BACKWARD TOO. My first version was forward-only (todo -> in-progress ->
in-review) and a mutation to the REOPEN hook did not fail it: those field resets (`status`, `error`,
`blockedBy`, pause clearing) only run when a card moves back out of a later column, so a
forward-only journey never reached them. The test passed while covering roughly half the branch.
Now: enter execution, reach review, reopen to the hold column (reset-on-entry + abort-on-exit), and
re-enter execution so the second segment's timing accumulation is exercised on top of the first.
*/
await store.moveTask(created.id, "in-progress");
await store.moveTask(created.id, "in-review");
await store.moveTask(created.id, "todo");
await store.moveTask(created.id, "in-progress");
const final = await store.getTask(created.id);
if (!final) throw new Error("task vanished mid-journey");
return { snapshot: comparableSnapshot(final), timing: timingShape(final) };
}
pgDescribe("move-path side effects are equivalent with the compatibility flag OFF and ON (U12 seam 3)", () => { pgDescribe("move-path side effects are equivalent with the compatibility flag OFF and ON (U12 seam 3)", () => {
const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_moves_flag_equiv" }); const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_moves_flag_equiv" });
@@ -165,33 +57,6 @@ pgDescribe("move-path side effects are equivalent with the compatibility flag OF
afterEach(harness.afterEach); afterEach(harness.afterEach);
afterAll(harness.afterAll); afterAll(harness.afterAll);
/*
One test rather than two, because the assertion IS the comparison: neither run has meaning alone.
Ordered flag-OFF first so the legacy path — the one actually running in production today — is the
expected value, and any divergence reads as "the trait hooks differ from shipped behaviour".
*/
it("the persisted row is identical either way, and so is the timing shape", async () => {
const store = harness.store();
const legacy = await runJourney(store, false);
const traitHooks = await runJourney(store, true);
/*
Whole-row equality. If this fails, the flip changes persisted state on every task move and the
diff names the field — which is the evidence precondition 1 asks for, in either direction.
*/
expect(traitHooks.snapshot).toEqual(legacy.snapshot);
/*
Timing is compared as a SHAPE, not by value: the two runs happen at different wall-clock instants,
so equal millisecond counts would be coincidence and a mismatch would be noise. What must agree is
which anchors got set and whether active time accumulated at all — a divergence there means the
two implementations disagree about when execution starts or ends, which would silently corrupt
every task's duration.
*/
expect(traitHooks.timing).toEqual(legacy.timing);
});
/* /*
FNXC:WorkflowColumns 2026-07-31-03:00 (U12 — seam 2, demonstrated rather than inferred): FNXC:WorkflowColumns 2026-07-31-03:00 (U12 — seam 2, demonstrated rather than inferred):
WHAT THE FLIP WOULD BREAK ON A CUSTOM BOARD. WHAT THE FLIP WOULD BREAK ON A CUSTOM BOARD.
@@ -210,7 +75,6 @@ pgDescribe("move-path side effects are equivalent with the compatibility flag OF
*/ */
it("REJECTS an engine move to a column the task's own workflow does not declare", async () => { it("REJECTS an engine move to a column the task's own workflow does not declare", async () => {
const store = harness.store(); const store = harness.store();
await setFlag(store, true);
// A lineage with none of the legacy ids: no todo, no in-progress, no done. // A lineage with none of the legacy ids: no todo, no in-progress, no done.
const definition = await store.createWorkflowDefinition({ const definition = await store.createWorkflowDefinition({
@@ -237,52 +101,8 @@ pgDescribe("move-path side effects are equivalent with the compatibility flag OF
await expect(store.moveTask(task.id, "todo")).rejects.toThrow(); await expect(store.moveTask(task.id, "todo")).rejects.toThrow();
}); });
it("REJECTS that same move with the flag OFF TOO — correcting the blast-radius claim", () => {
/*
FNXC:WorkflowColumns 2026-07-31-03:15 (U12 — I had this wrong, and it matters):
I wrote in #2639 and in the census that "with the flag off there is NO target-column validation on
the move path", so flipping would introduce new refusals. THAT IS NOT WHAT HAPPENS. Running the
identical custom-lineage move with the flag OFF also rejects:
Error: Invalid transition: 'backlog' -> 'todo'. Valid targets: building
Transition validation is already in force on the flag-OFF path. So for this shape — an engine move
to a column the task's workflow does not declare — the move is ALREADY failing today, and seam 2
does not introduce a new break for it. The 20 census sites without `recoveryRehome` are therefore a
smaller risk than I reported: on a custom lineage they are broken now, not broken by the flip.
I am asserting the CURRENT behaviour rather than the behaviour I expected, because a test written to
my assumption would have failed and I would have "fixed" the fixture until it agreed with a claim
that was false. The error message is asserted so a future change in WHICH guard rejects is visible
rather than silently reinterpreted as agreement.
*/
return (async () => {
const store = harness.store();
await setFlag(store, false);
const definition = await store.createWorkflowDefinition({
name: "no-legacy-ids-flagoff",
ir: {
version: "v2",
name: "no-legacy-ids-flagoff",
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }, { trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
nodes: [{ id: "start", kind: "start", column: "backlog" }, { id: "end", kind: "end", column: "shipped" }],
edges: [{ from: "start", to: "end" }],
},
} as never);
const task = await store.createTask({ description: "custom lineage card, flag off", workflowId: definition.id } as never);
await expect(store.moveTask(task.id, "todo")).rejects.toThrow(/Invalid transition/);
})();
});
it("ACCEPTS the same move when it carries the #1411 recoveryRehome carve-out", async () => { it("ACCEPTS the same move when it carries the #1411 recoveryRehome carve-out", async () => {
const store = harness.store(); const store = harness.store();
await setFlag(store, true);
const definition = await store.createWorkflowDefinition({ const definition = await store.createWorkflowDefinition({
name: "no-legacy-ids-rescue", name: "no-legacy-ids-rescue",

View File

@@ -1,200 +0,0 @@
/*
FNXC:WorkflowColumns 2026-07-30-19:00 (U12 — the move-path flag, blast radius pinned):
`moves.ts` still asks the RETIRED question. `useWorkflow` reads
`isWorkflowColumnsCompatibilityFlagEnabled` — the raw compatibility flag that nothing in
production source writes — and it gates the hottest lifecycle path in the system: every task move.
WHY THIS TEST EXISTS INSTEAD OF A FLIP. The flag looks like one switch and is six. Flipping it does
not "enable workflow columns"; it simultaneously turns on validation, capacity markers, plugin
hooks, and an audit field, and swaps the implementation of every column side effect. Each seam is
enumerated below with what it turns on, and the test FAILS if the count changes — so the next person
to touch this cannot under-scope it the way it has been under-scoped in every summary so far
(including mine: I described it as the 789/837 pair).
THE RISK IS NOT THE SIDE EFFECTS, IT IS SEAM 2. With the flag off there is NO target-column
validation on the move path at all. Flipping introduces typed rejections — unknown-column, adjacency
— for moves that succeed today. That is not an equivalence question, it is new refusals on the path
every engine lane uses, and it is why "the suite is green after the flip" is not evidence.
WHAT WOULD MAKE THE FLIP SAFE, recorded so the obligation survives this session:
1. An equivalence proof for seam 3, comparing the inline legacy side effects against the trait
hooks for timing, reset-on-entry, abort-on-exit and merge.onEnter. The two implementations have
NEVER both run in production, so neither is the observed baseline.
2. A census of moves that seam 2 would newly reject — every engine caller that moves a card to a
column its workflow does not declare. `recoveryRehome` already carves out legacy targets
(#1411); nothing proves the other callers are covered.
3. Both raw-flag readers flipped ATOMICALLY. `workflow-task-create-ops.ts` computes the
`movePolicyPreflight` that `moves.ts` consumes, so un-gating either alone starts evaluating
workflow move policies — with their plugin-gate side effects — while the consumer stays off.
Pinned separately by `raw-workflow-columns-flag-census.test.ts`.
This test asserts (1) the seam count, (2) that every seam reads the SAME flag rather than drifting
onto separate conditions, and (3) that the flag-off branch is still present and inline — because the
agreed sequencing is to DELETE it with the branch rather than convert its guards, and a deletion
needs to know the branch is still there.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
const MOVES_PATH = join(import.meta.dirname, "..", "task-store", "moves.ts");
/**
* The six decision points, measured on `main` at 2026-07-30. `line` is documentation only — the
* assertions below are position-independent so ordinary edits above a seam do not fail this test.
*/
const SEAMS: ReadonlyArray<{ line: number; turnsOn: string }> = [
{ line: 392, turnsOn: "resolves the task's workflow IR (undefined when off, so every IR-dependent guard below is inert)" },
{ line: 489, turnsOn: "typed REJECTIONS: unknown-column and adjacency validation. Off = no target validation at all — the riskiest seam" },
{ line: 789, turnsOn: "column side effects route through the default-workflow TRAIT HOOKS instead of the inline legacy block (timing, reset-on-entry, abort-on-exit, merge.onEnter)" },
{ line: 1092, turnsOn: "writes the transition-pending marker, which capacity counting reads — load-bearing for the in-transaction capacity gate" },
{ line: 1330, turnsOn: "runs PLUGIN hooks on column change (skipped for engine/recovery moves and same-column no-ops)" },
{ line: 1395, turnsOn: "records `workflowId` on the emitted move payload" },
];
/** Parse `moves.ts` once. Comments are absent from the tree, which is the whole point. */
function parseMoves(): ts.SourceFile {
const source = readFileSync(MOVES_PATH, "utf-8");
const sf = ts.createSourceFile(MOVES_PATH, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
/*
`parseDiagnostics` is not on the PUBLIC `SourceFile` type, so it needs a cast. It is still the
right signal rather than a try/catch: `createSourceFile` is error-tolerant and returns diagnostics
instead of throwing, which is what makes a catch-based check unreachable.
*/
const parseErrors = (sf as unknown as { parseDiagnostics?: readonly ts.Diagnostic[] }).parseDiagnostics ?? [];
if (parseErrors.length > 0) {
/*
Fail loudly rather than counting a partial tree. `ts.createSourceFile` is error-TOLERANT — it
returns diagnostics instead of throwing — so a syntax error would otherwise yield a smaller
count and read as "seams were removed", which is the opposite of the truth.
*/
throw new Error(`could not parse moves.ts: ${ts.flattenDiagnosticMessageText(parseErrors[0]!.messageText, " ")}`);
}
return sf;
}
/** Every `useWorkflow` reference, declaration excluded, so the number is "reads". */
function useWorkflowReferences(): number {
const sf = parseMoves();
let count = 0;
const visit = (node: ts.Node): void => {
// Identifier references only: the declaration itself is excluded so the number is "reads".
if (ts.isIdentifier(node) && node.text === "useWorkflow") {
const isDeclarationName = node.parent && ts.isVariableDeclaration(node.parent) && node.parent.name === node;
if (!isDeclarationName) count++;
}
ts.forEachChild(node, visit);
};
visit(sf);
return count;
}
describe("the move-path workflow flag (U12's actual completion criterion)", () => {
it("still gates the move path at SIX seams, not one", () => {
/*
If this fails LOW, seams were removed — either the flip landed (then delete this test with the
flag) or someone narrowed the gate without accounting for what stopped happening. If it fails
HIGH, a seventh behaviour was hung off a retired flag, which means it has never run.
*/
expect(useWorkflowReferences()).toBe(SEAMS.length);
});
it("documents what each seam turns on, so the flip cannot be under-scoped", () => {
// Cheap, but it forces the next person to state the effect when they add or remove a seam.
expect(SEAMS).toHaveLength(6);
for (const seam of SEAMS) {
expect(seam.turnsOn.length).toBeGreaterThan(40);
}
});
it("reads ONE flag, so the six seams cannot drift apart", () => {
/*
The property that makes a single flip coherent. If a seam were rewritten to consult the settings
object directly, the flip would move five behaviours and leave one behind — and nothing else in
the suite would notice, because both states are individually valid.
*/
/*
FNXC:WorkflowColumns 2026-07-30-22:00 (PR #2639 review — greptile, and it is the same defect one
level up): these were `toContain` substring checks against raw source, so they matched text in a
comment or a dead branch just as happily as real code. A test about structural drift that cannot
see structure is the exact failure this PR is documenting. Asserted on the AST now.
*/
const sf = parseMoves();
const declarations: ts.VariableDeclaration[] = [];
const findDeclarations = (node: ts.Node): void => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === "useWorkflow") {
declarations.push(node);
}
ts.forEachChild(node, findDeclarations);
};
findDeclarations(sf);
expect(declarations).toHaveLength(1);
// And it is initialized from the raw compatibility-flag reader, not something else.
const initializer = declarations[0]!.initializer;
expect(initializer && ts.isCallExpression(initializer)).toBe(true);
const callee = (initializer as ts.CallExpression).expression;
expect(ts.isIdentifier(callee) ? callee.text : undefined).toBe("isWorkflowColumnsCompatibilityFlagEnabled");
});
it("still has the inline flag-OFF branch that the agreed sequencing DELETES", () => {
/*
The sequencing agreed with the coordinator is: U12 resolves the flag first, then the flag-off
branch is deleted wholesale rather than having its guards converted — converting code we intend
to delete is waste and leaves a second definition alive to drift.
This asserts the branch is still present, so if someone converts its lifecycle guards instead,
the deletion step has a test naming the plan. It is deliberately a source assertion: the branch's
behaviour is what the equivalence proof in seam 3 must cover, and that proof does not exist yet.
*/
/*
Structural, not a comment match (PR #2639 review). The previous assertion looked for the string
"Flag-OFF legacy inline side effects" — which is a COMMENT. Deleting the entire legacy branch
while leaving its header comment in place would have passed, and the comment is exactly the kind
of prose this program deliberately keeps after deleting code.
What actually matters is that some `if (useWorkflow)` still has an ELSE: that else IS the legacy
inline path, and its existence is what the delete-with-the-branch sequencing depends on.
*/
const sf = parseMoves();
let seamsWithElse = 0;
const findIfElse = (node: ts.Node): void => {
if (
ts.isIfStatement(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "useWorkflow" &&
node.elseStatement !== undefined
) {
seamsWithElse++;
}
ts.forEachChild(node, findIfElse);
};
findIfElse(sf);
expect(seamsWithElse).toBeGreaterThan(0);
});
it("names the second reader that must flip atomically with this one", () => {
/*
Recorded here because it is the constraint most likely to be forgotten: the preflight in
`workflow-task-create-ops.ts` is computed under the same flag and CONSUMED by moves.ts. Flipping
one without the other either evaluates workflow move policies whose result is ignored, or
validates against a preflight that was never computed.
*/
/*
An IDENTIFIER reference, not a substring (PR #2639 review): this symbol is discussed by name in
the comments around the preflight, so a text match proved nothing about whether the code still
consumes it — and "moves.ts consumes the preflight" is the entire reason the two readers must
flip together.
*/
const sf = parseMoves();
let references = 0;
const findReferences = (node: ts.Node): void => {
if (ts.isIdentifier(node) && node.text === "movePolicyPreflight") references++;
ts.forEachChild(node, findReferences);
};
findReferences(sf);
expect(references).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,64 @@
/*
FNXC:TaskMovement 2026-07-31-11:30 (PR #2655 review — the contract that was only a comment):
AN OPTIONLESS `moveTask(id, toColumn)` MUST NOT INHERIT GUARD BYPASS.
`moves.ts` resolves `moveSource = options?.moveSource ?? "engine"` for the EMITTED source, while
`resolveWorkflowBypassGuardsImpl` reads `options?.moveSource` — so an optionless call is reported as
engine-sourced but does not skip workflow guards, plugin gates or the merge blocker. That asymmetry
is deliberate and was documented at `moves.ts` in a comment. It was not tested.
WHY IT NEEDED A TEST. The `void moveSource;` beside that read looks exactly like someone silencing an
unused-parameter lint, so I "fixed" it to use the resolved value — which handed guard bypass to the
eight optionless `moveTask` calls in dashboard HTTP routes (reset, rebound, respecify, unassign). It
took two rounds of review to get back to the shipped behaviour. A comment could not stop that; this
can.
The ambiguity underneath is real and unresolved: 18 optionless calls in `packages/engine` are genuine
engine moves that arguably SHOULD bypass, and 8 in `packages/dashboard` are operator-initiated and
must not. The fix is to make `moveSource` explicit at those 26 sites so the default is never guessed.
Until then, this pins the safe half — a public caller never silently acquires privilege — because
that is the direction whose failure is a security problem rather than an inconvenience.
*/
import { describe, expect, it } from "vitest";
import { resolveWorkflowBypassGuardsImpl } from "../task-store/task-store-helpers.js";
import type { TaskStore, MoveTaskOptions } from "../store.js";
/** The helper ignores the store; it is a pure policy decision over (moveSource, options). */
const STORE = {} as unknown as TaskStore;
describe("optionless moveTask does not inherit guard bypass", () => {
it("does NOT bypass when no options are supplied", () => {
/*
The call site resolves `moveSource` to "engine" for the emitted event. Passing that resolved value
here is exactly the mistake this test exists to prevent: it must not be enough on its own.
*/
expect(resolveWorkflowBypassGuardsImpl(STORE, "engine", undefined)).toBe(false);
});
it("does NOT bypass when options are supplied without an explicit source", () => {
// A caller that passes unrelated options is still not declaring itself engine-sourced.
expect(resolveWorkflowBypassGuardsImpl(STORE, "engine", { preserveProgress: true } as MoveTaskOptions)).toBe(false);
});
it("DOES bypass when a call site opts in explicitly", () => {
/*
The other half — without this the helper could return false unconditionally and still pass. Engine,
scheduler, handoff and recovery sites opt in by naming their source or asking for it directly.
*/
expect(resolveWorkflowBypassGuardsImpl(STORE, "engine", { moveSource: "engine" } as MoveTaskOptions)).toBe(true);
expect(resolveWorkflowBypassGuardsImpl(STORE, "engine", { moveSource: "scheduler" } as MoveTaskOptions)).toBe(true);
expect(resolveWorkflowBypassGuardsImpl(STORE, "user", { skipMergeBlocker: true } as MoveTaskOptions)).toBe(true);
expect(resolveWorkflowBypassGuardsImpl(STORE, "user", { recoveryRehome: true } as MoveTaskOptions)).toBe(true);
});
it("honours an explicit bypassGuards=false even from an engine source", () => {
// `??` on the option means an explicit false wins over the source-derived default.
expect(
resolveWorkflowBypassGuardsImpl(STORE, "engine", { moveSource: "engine", bypassGuards: false } as MoveTaskOptions),
).toBe(false);
});
it("does NOT bypass for a user-sourced move", () => {
expect(resolveWorkflowBypassGuardsImpl(STORE, "user", { moveSource: "user" } as MoveTaskOptions)).toBe(false);
});
});

View File

@@ -1,518 +0,0 @@
/*
FNXC:MovePathConvergence 2026-07-27-18:30 (Phase A2 — workflow-owned lifecycle):
DIFFERENTIAL CHARACTERIZATION of the two move-side-effect implementations in
`moveTaskInternalImpl`, run through ONE shared fixture.
WHY THIS SUITE EXISTS. `moves.ts` branches on `useWorkflow` =
`isWorkflowColumnsCompatibilityFlagEnabled(settings)`, which reads the RAW
`experimentalFeatures.workflowColumns` key. Nothing in production writes that
key, so:
- the INLINE branch is the LIVE path for essentially every project;
- `default-workflow-hooks.ts` (the trait-hook path) is DEAD.
`moves.ts` says so itself at the flag-OFF adjacency branch. Only one
implementation runs in production, so equivalence CANNOT be observed by running
the suite normally — the dead path is never entered. Each case here therefore
FORCES both paths explicitly through the same fixture:
flag ABSENT → the production shape (inline branch)
workflowColumns: true → the hooks path
and asserts on observable state, not on which functions were called. Equivalence
asserted by reading code is not equivalence.
SCOPE NOTE. The `useWorkflow` flag gates far more than the side-effect block:
validation, in-transaction capacity, the transitionPending marker, and plugin
column gates are all inside it. Those divergences are characterized in the
companion describe at the bottom, which is what makes the convergence decision an
operator call rather than a refactor.
*/
import { afterEach, beforeEach, expect, it, beforeAll, afterAll } from "vitest";
import {
pgDescribe,
createSharedPgTaskStoreTestHarness,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import type { Task } from "../../types.js";
const pgTest = pgDescribe;
/** The observable surface a move can change. Compared field-by-field so a
* divergence names the field rather than dumping two task objects. */
interface MoveObservation {
column: string;
status: string | undefined;
error: string | undefined;
paused: boolean | undefined;
userPaused: boolean | undefined;
pausedReason: string | undefined;
blockedBy: string | undefined;
overlapBlockedBy: string | undefined;
worktree: string | undefined;
branch: string | undefined;
summary: string | undefined;
baseCommitSha: string | undefined;
/*
Timestamps are compared by PRESENCE, not value. The two paths necessarily run
at different wall-clock instants (same fixture, two sequential runs), so the
ISO strings differ by milliseconds every time. What must match is whether the
path stamped the field at all — a path that forgets to set
`executionCompletedAt`, or wrongly clears `firstExecutionAt`, still fails here.
*/
executionStartedAtSet: boolean;
executionCompletedAtSet: boolean;
firstExecutionAtSet: boolean;
/*
FNXC:MovePathEquivalence 2026-07-27-08:20 (PR #2468 review — greptile P2):
A boolean "is it a number" stays green when the two paths compute DIFFERENT durations, which is
exactly the accounting bug this case exists to catch. The absolute value is wall-clock dependent,
so compare a QUANTISED bucket: both paths must land in the same 100ms bucket for the same seeded
segment, which is stable against scheduling jitter while still failing when one path drops or
double-counts a segment.
*/
cumulativeActiveMsBucket: number | undefined;
recoveryRetryCount: number | undefined;
nextRecoveryAtSet: boolean;
stepStatuses: string[];
workflowStepResultCount: number | undefined;
}
function observe(task: Task): MoveObservation {
return {
column: task.column,
status: task.status,
error: task.error,
paused: task.paused,
userPaused: task.userPaused,
pausedReason: task.pausedReason,
blockedBy: task.blockedBy,
overlapBlockedBy: task.overlapBlockedBy,
worktree: task.worktree,
branch: task.branch,
summary: task.summary,
baseCommitSha: task.baseCommitSha,
executionStartedAtSet: task.executionStartedAt !== undefined,
executionCompletedAtSet: task.executionCompletedAt !== undefined,
firstExecutionAtSet: task.firstExecutionAt !== undefined,
cumulativeActiveMsBucket:
typeof task.cumulativeActiveMs === "number" ? Math.floor(task.cumulativeActiveMs / 100) : undefined,
recoveryRetryCount: task.recoveryRetryCount,
nextRecoveryAtSet: task.nextRecoveryAt !== undefined,
stepStatuses: (task.steps ?? []).map((s) => s.status),
workflowStepResultCount: task.workflowStepResults?.length,
};
}
pgTest("move-path equivalence — side effects (Phase A2)", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_move_equiv",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
/*
Force the path. Absent key = production shape (inline); `true` = hooks.
MUST be updateGlobalSettings, NOT updateSettings. `experimentalFeatures` is a
GLOBAL-scoped key, and `moves.ts` reads it through `getSettingsFast()` (merged
global + project) for exactly that reason. Writing it via the project store is
silently accepted and never reaches `useWorkflow` — the first version of this
suite did that and reported NINE passing "equivalence" cases while running the
inline path twice. Hence `assertPathActive` below: a forcing mechanism that can
fail silently makes every assertion downstream worthless.
*/
async function setPath(path: "inline" | "hooks"): Promise<void> {
const store = h.store();
await store.updateGlobalSettings(
path === "hooks"
? { experimentalFeatures: { workflowColumns: true } }
// Explicit `false`, NOT `{}`: updateGlobalSettings MERGES, so an empty
// object leaves a previously-set `true` in place and the next case runs
// the hooks path while believing it is on inline. `assertPathActive`
// caught exactly that leak across seven cases.
: { experimentalFeatures: { workflowColumns: false } },
);
await assertPathActive(path);
}
/*
Prove the flip took effect, using a behavior only ONE path produces: an
undeclared target column. The inline path validates against the legacy
`VALID_TRANSITIONS` map and reports "Valid targets: …"; the flag-ON path
validates against the task's workflow and reports "Unknown column for this
workflow." A path-selection regression therefore fails HERE, loudly, instead of
turning every equivalence case into a tautology.
*/
async function assertPathActive(path: "inline" | "hooks"): Promise<void> {
const store = h.store();
const probe = await store.createTask({ description: `path probe ${path}` });
const err = await store
.moveTask(probe.id, "not-a-column-any-workflow-declares")
.then(() => null, (e: unknown) => e as Error);
expect(err, `${path}: probe move should have been rejected`).toBeInstanceOf(Error);
if (path === "hooks") {
expect(err!.message, "hooks path not active — check updateGlobalSettings").toContain(
"Unknown column for this workflow",
);
} else {
expect(err!.message, "inline path not active").toContain("Valid targets:");
}
await store.deleteTask(probe.id);
}
/**
* Run `scenario` once per path over a FRESH task each time, and return both
* observations. The scenario receives the task id so it can drive whatever
* move sequence the case needs.
*/
async function bothPaths(
seed: () => Promise<string>,
scenario: (taskId: string) => Promise<void>,
): Promise<{ inline: MoveObservation; hooks: MoveObservation }> {
const store = h.store();
await setPath("inline");
const inlineId = await seed();
await scenario(inlineId);
const inline = observe((await store.getTask(inlineId))!);
await setPath("hooks");
const hooksId = await seed();
await scenario(hooksId);
const hooks = observe((await store.getTask(hooksId))!);
return { inline, hooks };
}
/** A task parked in `in-progress` with timing + progress state on it. */
async function seedInProgress(): Promise<string> {
const store = h.store();
const task = await store.createTask({ description: "equivalence fixture" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
return task.id;
}
it("EQUIVALENCE: in-progress → todo reopen (user) clears the same fields on both paths", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.updateTask(id, { status: "failed", error: "boom", blockedBy: "FN-9" });
await store.moveTask(id, "todo", { moveSource: "user" });
});
// The whole point: field-by-field, not "it moved".
expect(hooks).toEqual(inline);
// Pin the behavior itself so a change that breaks BOTH paths identically
// still fails here (equal-but-wrong is not equivalence worth having).
expect(inline.status).toBeUndefined();
expect(inline.error).toBeUndefined();
expect(inline.blockedBy).toBeUndefined();
expect(inline.userPaused).toBe(true); // user-source reopen to todo parks
});
it("EQUIVALENCE: engine-source reopen does NOT set userPaused on either path", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.moveTask(id, "todo", { moveSource: "engine" });
});
expect(hooks).toEqual(inline);
expect(inline.userPaused).toBeUndefined();
});
it("EQUIVALENCE: preserveStatus keeps status/error on both paths", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.updateTask(id, { status: "failed", error: "branch conflict" });
await store.moveTask(id, "todo", { preserveStatus: true });
});
expect(hooks).toEqual(inline);
expect(inline.status).toBe("failed");
expect(inline.error).toBe("branch conflict");
});
it("EQUIVALENCE: preservePause keeps an operator park through a teardown move (FN-7851)", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.updateTask(id, { paused: true, pausedReason: "operator" });
await store.moveTask(id, "todo", { moveSource: "engine", preservePause: true });
});
expect(hooks).toEqual(inline);
expect(inline.paused).toBe(true);
expect(inline.pausedReason).toBe("operator");
});
it("EQUIVALENCE: timing accounting runs identically on in-progress exit and re-entry", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.moveTask(id, "todo", { moveSource: "engine" });
await store.moveTask(id, "in-progress");
});
expect(hooks).toEqual(inline);
// Both paths accounted the SAME segment, not merely "some number".
expect(inline.cumulativeActiveMsBucket).toBeTypeOf("number");
expect(hooks.cumulativeActiveMsBucket).toBe(inline.cumulativeActiveMsBucket);
expect(inline.firstExecutionAtSet).toBe(true);
expect(inline.executionStartedAtSet).toBe(true);
});
/*
FNXC:MovePathEquivalence 2026-07-27-08:20 (PR #2468 review — greptile P2):
Seed NON-DEFAULT progress before the move. With default steps, both paths observe the same empty
progress and the case passes without characterising `preserveResumeState` at all — two paths that
both wiped progress would agree just as happily. Seeding a mixed done/in-progress/pending shape
plus a workflow-step result means equality now asserts that the progress SURVIVED, and the
explicit post-conditions fail loudly if either path resets it.
*/
it("EQUIVALENCE: preserveProgress/preserveResumeState keep step progress on both paths", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.updateTask(id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
currentStep: 1,
workflowStepResults: [
{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "passed",
source: "node",
phase: "pre-merge",
},
],
} as never);
await store.moveTask(id, "todo", { moveSource: "engine", preserveResumeState: true });
});
expect(hooks).toEqual(inline);
// Post-conditions, so "equal" cannot mean "both wiped it".
expect(inline.stepStatuses).toEqual(["done", "in-progress", "pending"]);
expect(inline.workflowStepResultCount).toBe(1);
});
it("EQUIVALENCE: preserveWorktree keeps the worktree on both paths", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.updateTask(id, { worktree: "/tmp/wt/FN-X" });
await store.moveTask(id, "todo", { moveSource: "engine", preserveWorktree: true });
});
expect(hooks).toEqual(inline);
expect(inline.worktree).toBe("/tmp/wt/FN-X");
});
it("EQUIVALENCE: worktree is CLEARED by default on reopen on both paths", async () => {
const store = h.store();
const { inline, hooks } = await bothPaths(seedInProgress, async (id) => {
await store.updateTask(id, { worktree: "/tmp/wt/FN-Y" });
await store.moveTask(id, "todo", { moveSource: "engine" });
});
expect(hooks).toEqual(inline);
expect(inline.worktree).toBeUndefined();
});
});
pgTest("move-path equivalence — the flag gates MORE than side effects (Phase A2)", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_move_diverge",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
async function setPath(path: "inline" | "hooks"): Promise<void> {
// See the note on the sibling setPath: GLOBAL settings, not project.
await h.store().updateGlobalSettings(
path === "hooks"
? { experimentalFeatures: { workflowColumns: true } }
: { experimentalFeatures: { workflowColumns: false } },
);
}
/*
These are NOT equivalence assertions — they are the measured divergences that
make convergence an operator decision rather than a mechanical refactor. Each
one is a behavior that would turn ON for every project the moment the hooks
path becomes authoritative.
*/
/*
FNXC:MovePathConvergence 2026-07-30-22:10 (U2b — second measured divergence, and it is not a
message shape):
U11 removed `triage` from the default coding lineage, so rows left there sit in a column their own
workflow no longer declares. #2515 added an escape hatch to `resolveAllowedColumns` so such a card
has a legal move (its workflow's rebound target) instead of "Valid targets: none".
That hatch is INSIDE the `useWorkflow` block, so it only runs on the hooks path. Mutation-verified
in #2597: stubbing it back to `[]` left an operator-move test green, because the inline path
answers from the legacy VALID_TRANSITIONS map instead — whose `triage` row happens to permit the
move for unrelated reasons.
WHY THIS ONE MATTERS MORE THAN THE MESSAGE DIVERGENCE ABOVE. It is not a shape difference in a
rejection; it is a difference in WHICH MOVES ARE LEGAL, and it is workflow-dependent. Flipping
`useWorkflow` on changes move VALIDATION for every stranded card, not just side-effect routing —
so "make the flag always-on, then delete the flag-OFF branch" is not the mechanical cleanup it
looks like. Recorded here so the flip is an operator decision with the behaviour change visible,
which is what U2b exists to make possible.
*/
it("DIVERGENCE: legal targets for a card in an UNDECLARED column come from different sources", async () => {
const store = h.store();
const targetsFor = async (path: "inline" | "hooks", id: string): Promise<string[]> => {
await setPath(path);
// Ask for a target NO workflow declares, and read the reported legal set out of the
// rejection. Both paths reject; the question is what each one considers legal.
const err = await store
.moveTask(id, "not-a-column-any-workflow-declares")
.then(() => null, (e: unknown) => e as Error);
const match = /Valid targets: (.*)$/.exec(err?.message ?? "");
return match ? match[1]!.split(",").map((t) => t.trim()).filter(Boolean) : [];
};
await setPath("inline");
const inlineTask = await store.createTask({ description: "undeclared-source inline" });
const inlineTargets = await targetsFor("inline", inlineTask.id);
/*
FNXC:MovePathConvergence 2026-07-31-10:45 (PR #2638 review — greptile P2):
The HOOKS path with a card genuinely STRANDED, which the first version never exercised: it
created the card in a declared column and only ran the inline path, so a regression in the hooks
rebound resolution would not have failed anything.
Stranding needs a direct row write — `moveTask` refuses to take a card into an undeclared column,
which is the transition policy working. The corrupt post-upgrade state IS the fixture, and it is
the state U11 leaves behind for every row still sitting in `triage`.
*/
await setPath("hooks");
const strandedTask = await store.createTask({ description: "undeclared-source hooks" });
await h.adminSql()`UPDATE project.tasks SET "column" = 'a-column-no-workflow-declares' WHERE id = ${strandedTask.id}`;
store.taskCache.delete(strandedTask.id);
const hooksErr = await store
.moveTask(strandedTask.id, "in-progress")
.then(() => null, (e: unknown) => e as Error);
/*
On the hooks path the SOURCE column is undeclared, so `resolveAllowedColumns` has no adjacency to
read and U11's escape hatch supplies the workflow's rebound target instead. Whatever the outcome,
it is reached through workflow resolution — asserted as "not the legacy VALID_TRANSITIONS answer",
because the legacy table is what the inline path consults and the two must be distinguishable.
Deliberately not asserting a specific message: the point is that the two paths answer from
DIFFERENT SOURCES for the same stranded card. Pinning hooks' exact wording here would duplicate
the rejection-shape divergence above and make this case fail for the wrong reason.
*/
expect(hooksErr === null || !/Valid targets: in-progress, triage, archived/.test(hooksErr.message)).toBe(true);
/*
The inline path enumerates the LEGACY table, so it reports legacy ids regardless of what the
task's workflow declares. The hooks path does not report a target list at all for an unknown
column — it throws the typed unknown-column rejection first, asserted above.
Asserted as a POSITIVE about the inline path rather than a comparison of two lists, because the
two paths do not even reach the same rejection: that asymmetry IS the divergence, and a
comparison would hide it behind two empty arrays.
*/
expect(inlineTargets.length).toBeGreaterThan(0);
expect(inlineTargets).toContain("in-progress");
});
it("DIVERGENCE: rejection TYPE and MESSAGE differ — the legacy bare-Error contract is inline-only", async () => {
const store = h.store();
await setPath("inline");
const a = await store.createTask({ description: "reject shape inline" });
const inlineErr = await store
.moveTask(a.id, "not-a-column-any-workflow-declares")
.then(() => null, (e: unknown) => e as Error);
await setPath("hooks");
const b = await store.createTask({ description: "reject shape hooks" });
const hooksErr = await store
.moveTask(b.id, "not-a-column-any-workflow-declares")
.then(() => null, (e: unknown) => e as Error);
// Both reject — but not with the same type or the same message. The inline
// path validates against the legacy VALID_TRANSITIONS map and throws a BARE
// Error; the hooks path validates against the task's own workflow and throws
// a typed TransitionRejectionError carrying a machine-readable `rejection`.
expect(inlineErr).toBeInstanceOf(Error);
expect(hooksErr).toBeInstanceOf(Error);
expect((inlineErr as unknown as { rejection?: unknown }).rejection).toBeUndefined();
expect((hooksErr as unknown as { rejection?: unknown }).rejection).toBeDefined();
expect(inlineErr!.message).toContain("Valid targets:");
expect(hooksErr!.message).toContain("Unknown column for this workflow");
});
it("CONVERGED: in-transaction capacity now rejects on BOTH paths", async () => {
/*
FNXC:WorkflowCapacity 2026-07-28-19:40 (pool-id sentinel fix):
WAS `UNPROVEN: … did NOT reject on EITHER path`. That test recorded an honest
negative result and left the cause open: "something further in
(`resolveColumnCapacity`'s limit resolution, or what
`countActiveInCapacitySlotAsync` counts as an occupant) keeps the check from
firing even when the flag is on. This suite does not establish which." It
also predicted its own obsolescence: "if a future change makes this reject,
that is the capacity gate coming alive."
THE ANSWER, established by the sentinel fix: neither of those guesses. The
counter and the limit resolution were both fine. `moves.ts` asked the counter
for occupants of pool `"builtin:coding"` while the counter buckets
selection-less rows under `DEFAULT_WORKFLOW_POOL_ID`, so the count came back
0 for a pool nothing is ever placed in. Both sides now derive the pool
through `resolveCapacityPoolId`, and the hooks path rejects.
The blast-radius question this test was holding open is therefore ANSWERED for
the hooks path and STILL OPEN for the inline one: the inline path remains
structurally unable to run the block (`if (useWorkflow && …)`), so converging
the paths still turns store-level capacity rejection on for every project.
That convergence stays an operator decision — see the R2 note in
workflow-capacity-invariant.pg.test.ts.
*/
const store = h.store();
await store.updateSettings({ maxConcurrent: 1 });
/* Each phase starts from an EMPTY wip column. Before the gate bound, the two phases could share
one fixture because nothing ever counted occupants; now they cannot — the inline phase leaves
two cards in wip, and the hooks phase's own HOLDER move would trip the cap before the
contended move under test ever runs (observed: "column at capacity (2/1)"). Evacuating is
what keeps this a test of the contender's move rather than of fixture residue. */
async function fillThenMoveSecond(): Promise<Error | null> {
for (const stale of await store.listTasks({ includeArchived: false })) {
if (stale.column === "in-progress") await store.deleteTask(stale.id);
}
const first = await store.createTask({ description: "capacity holder" });
await store.moveTask(first.id, "todo");
await store.moveTask(first.id, "in-progress");
const second = await store.createTask({ description: "capacity contender" });
await store.moveTask(second.id, "todo");
return store.moveTask(second.id, "in-progress").then(() => null, (e: unknown) => e as Error);
}
await setPath("inline");
/* FNXC:WorkflowCapacity 2026-07-28-10:20 (R2 fix): was `toBeNull()` — the block used
to be unreachable here. Un-gating the capacity check is what converged the two
paths on this behavior; the OTHER divergences in this file (rejection type and
message) are deliberately untouched, because only the capacity check was
un-gated, not transition validation. */
const inlineErr = await fillThenMoveSecond();
expect((inlineErr as unknown as { rejection?: { code?: string } })?.rejection?.code).toBe(
"capacity-exhausted",
);
await setPath("hooks");
const hooksErr = await fillThenMoveSecond();
expect(hooksErr).toBeInstanceOf(Error);
expect((hooksErr as unknown as { rejection?: { code?: string } }).rejection?.code).toBe(
"capacity-exhausted",
);
});
});

View File

@@ -54,14 +54,26 @@ pgTest("TaskStore moveTask column transitions (PostgreSQL)", () => {
expect(done.column).toBe("done"); expect(done.column).toBe("done");
}); });
it("allows moving an in-progress task back to triage", async () => { it("moves an in-progress task back to the workflow's planning column, and REFUSES `triage`", async () => {
/*
FNXC:WorkflowColumns 2026-07-31-04:45 (U12 — the move-path flag is resolved):
Was "allows moving an in-progress task back to triage". The default lineage stopped declaring
`triage` at #2515, and the move path now resolves targets against the task's own workflow instead
of a hardcoded legacy adjacency table — so that move is refused rather than stranding the card in
a column with no trait flags, invisible to every trait-driven sweep.
Both halves are asserted: the backward move that SHOULD work still works, so this reads as a
narrowing rather than a blanket refusal.
*/
const store = h.store(); const store = h.store();
const task = await store.createTask({ description: "backward move" }); const task = await store.createTask({ description: "backward move" });
await store.moveTask(task.id, "todo", { moveSource: "user" }); await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" }); await store.moveTask(task.id, "in-progress", { moveSource: "user" });
const moved = await store.moveTask(task.id, "triage"); await expect(store.moveTask(task.id, "triage")).rejects.toThrow(/Unknown column for this workflow/);
expect(moved.column).toBe("triage");
const moved = await store.moveTask(task.id, "todo");
expect(moved.column).toBe("todo");
}); });
it("updates columnMovedAt timestamp on each move", async () => { it("updates columnMovedAt timestamp on each move", async () => {

View File

@@ -72,21 +72,23 @@ pgTest("in-transaction column capacity — ground truth (Phase A3)", () => {
tautological "passes" respectively. A both-paths suite without this probe tautological "passes" respectively. A both-paths suite without this probe
reports what it assumed, not what happened. reports what it assumed, not what happened.
*/ */
async function setPath(path: "inline" | "hooks"): Promise<void> { /*
FNXC:WorkflowColumns 2026-07-31-04:45 (U12 — the move-path flag is resolved):
There is only ONE path now, so this no longer selects between them. It is kept rather than deleted
because the probe half is still worth doing: it proves the move path is live and rejecting before
a capacity case draws conclusions from a rejection, so a capacity test cannot pass because moves
were broken for some unrelated reason.
The `inline`/`hooks` parameter and the `updateGlobalSettings` flag write are gone with the flag.
*/
async function assertMovePathLive(): Promise<void> {
const store = h.store(); const store = h.store();
await store.updateGlobalSettings( const probe = await store.createTask({ description: "path probe" });
path === "hooks"
? { experimentalFeatures: { workflowColumns: true } }
: { experimentalFeatures: { workflowColumns: false } },
);
const probe = await store.createTask({ description: `path probe ${path}` });
const err = await store const err = await store
.moveTask(probe.id, "not-a-column-any-workflow-declares") .moveTask(probe.id, "not-a-column-any-workflow-declares")
.then(() => null, (e: unknown) => e as Error); .then(() => null, (e: unknown) => e as Error);
expect(err, `${path}: probe move should have been rejected`).toBeInstanceOf(Error); expect(err, "probe move should have been rejected").toBeInstanceOf(Error);
expect(err!.message, `${path} path not active`).toContain( expect(err!.message, "move path not active").toContain("Unknown column for this workflow");
path === "hooks" ? "Unknown column for this workflow" : "Valid targets:",
);
await store.deleteTask(probe.id); await store.deleteTask(probe.id);
} }
@@ -129,7 +131,7 @@ pgTest("in-transaction column capacity — ground truth (Phase A3)", () => {
*/ */
const store = h.store(); const store = h.store();
await store.updateSettings({ maxConcurrent: 1 }); await store.updateSettings({ maxConcurrent: 1 });
await setPath("inline"); await assertMovePathLive();
const { error, secondColumn } = await fillWipThenAdmitSecond(); const { error, secondColumn } = await fillWipThenAdmitSecond();
@@ -150,7 +152,7 @@ pgTest("in-transaction column capacity — ground truth (Phase A3)", () => {
*/ */
const store = h.store(); const store = h.store();
await store.updateSettings({ maxConcurrent: 1 }); await store.updateSettings({ maxConcurrent: 1 });
await setPath("hooks"); await assertMovePathLive();
const { error, secondColumn } = await fillWipThenAdmitSecond(); const { error, secondColumn } = await fillWipThenAdmitSecond();
@@ -172,7 +174,7 @@ pgTest("in-transaction column capacity — ground truth (Phase A3)", () => {
*/ */
const store = h.store(); const store = h.store();
await store.updateSettings({ maxConcurrent: 1 }); await store.updateSettings({ maxConcurrent: 1 });
await setPath("hooks"); await assertMovePathLive();
const { error, secondColumn } = await fillWipThenAdmitSecond({ selectWorkflow: "builtin:coding" }); const { error, secondColumn } = await fillWipThenAdmitSecond({ selectWorkflow: "builtin:coding" });
@@ -207,7 +209,7 @@ pgTest("in-transaction column capacity — ground truth (Phase A3)", () => {
async () => { async () => {
const store = h.store(); const store = h.store();
await store.updateSettings({ maxConcurrent: 1 }); await store.updateSettings({ maxConcurrent: 1 });
await setPath("hooks"); await assertMovePathLive();
const { error, secondColumn } = await fillWipThenAdmitSecond(); const { error, secondColumn } = await fillWipThenAdmitSecond();
@@ -258,7 +260,7 @@ pgTest("in-transaction column capacity — ground truth (Phase A3)", () => {
it("RATCHET: a workflow-selection change mid-move cannot split the limit from the counting pool", async () => { it("RATCHET: a workflow-selection change mid-move cannot split the limit from the counting pool", async () => {
const store = h.store(); const store = h.store();
await store.updateSettings({ maxConcurrent: 1 }); await store.updateSettings({ maxConcurrent: 1 });
await setPath("inline"); await assertMovePathLive();
// Fill builtin:coding's wip pool to its limit of 1. // Fill builtin:coding's wip pool to its limit of 1.
const holder = await store.createTask({ description: "split-snapshot holder" }); const holder = await store.createTask({ description: "split-snapshot holder" });
@@ -335,7 +337,7 @@ pgTest("in-transaction column capacity — ground truth (Phase A3)", () => {
it("RATCHET: the capacity read is taken UNDER the per-task lock, not merely inside the transaction", async () => { it("RATCHET: the capacity read is taken UNDER the per-task lock, not merely inside the transaction", async () => {
const store = h.store(); const store = h.store();
await store.updateSettings({ maxConcurrent: 1 }); await store.updateSettings({ maxConcurrent: 1 });
await setPath("inline"); await assertMovePathLive();
const holder = await store.createTask({ description: "xproc holder" }); const holder = await store.createTask({ description: "xproc holder" });
await store.selectTaskWorkflow(holder.id, "builtin:coding"); await store.selectTaskWorkflow(holder.id, "builtin:coding");

View File

@@ -1,184 +0,0 @@
/*
FNXC:WorkflowColumns 2026-07-29-00:00 (U12 — R9):
CENSUS RATCHET for the raw `experimentalFeatures.workflowColumns` compatibility flag.
U12's headline goal is deleting this flag and the settings key behind it. That cannot
happen while anything reads it, and "does anything still read it?" has been answered by
hand three times over the life of the unit — each time by grepping, each time producing
a number nobody can re-derive later. This test makes the answer a fact the suite
maintains.
WHAT THE FLAG IS. `isWorkflowColumnsCompatibilityFlagEnabled` (store.ts) returns
`experimentalFeatures.workflowColumns === true`. It is the RAW key, distinct from the
public runtime helper that treats stale `false` as enabled. No module hardcodes
the key, so it reads false for every project that never carried a stale persisted value
— which is why every branch behind it has been silently inert, and why U12 spent its length finding features that looked
enforced and were not.
WHAT REMAINS, and why it is not mine to remove. Both surviving reads are on the MOVE
PATH and belong to U2b (move-path convergence), which carries an equivalence-proof
obligation because the two implementations it arbitrates have never both run in
production. They are also not separable from each other: the preflight computes the
`movePolicyPreflight` that `moves.ts` consumes and validates, so un-gating it alone
would start evaluating workflow move policies — with their plugin-gate side effects —
while the branch that consumes the result stays off.
THIS TEST FAILS IN BOTH DIRECTIONS, deliberately:
- a NEW read appears -> someone is re-gating behaviour on a retired flag;
- the LAST read disappears -> U2b has landed, and the settings key can finally go.
The second is the one that matters. It converts "remember to delete the key someday"
into a failing test at the exact moment that becomes possible.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const REPO_ROOT = resolve(import.meta.dirname, "../../../..");
const SOURCE_ROOTS = ["packages/core/src", "packages/engine/src", "packages/dashboard/src", "packages/cli/src"];
/** The raw-flag reader. Not the always-on public runtime helper. */
const RAW_FLAG_READER = "isWorkflowColumnsCompatibilityFlagEnabled";
/**
* Every file permitted to reference the raw reader, and why. Paths are repo-relative.
* `store.ts` declares it; the other two are U2b's move path.
*/
const ALLOWED: ReadonlyArray<{ file: string; occurrences: number; why: string }> = [
{
file: "packages/core/src/store.ts",
occurrences: 1,
why: "declares the helper; it goes with the last reader",
},
{
file: "packages/core/src/task-store/moves.ts",
occurrences: 2,
why: "U2b: the import, plus `useWorkflow` selecting between the two move-side-effect implementations",
},
{
file: "packages/core/src/task-store/workflow-task-create-ops.ts",
occurrences: 2,
why: "U2b: the import, plus the gate on the move-policy preflight that moves.ts consumes",
},
];
/*
Strip comments AND string/template literals before scanning (PR #2537 review — greptile).
Comments alone were not enough: this flag is discussed by name in diagnostics, error
copy and fixtures, and a substring scan would classify any such TEXT as a reader — a
ratchet that fails on prose is a ratchet people learn to edit around. What remains after
this is executable code, where the symbol appearing means it is genuinely referenced.
*/
function stripCommentsAndStrings(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1 ")
/*
Template literals: keep the ${...} EXPRESSIONS, drop only the literal text
(PR #2537 review — greptile). Erasing whole templates would have removed executable
interpolations with them, so a reader written inside `${...}` would have escaped the
census entirely — a hole in the direction that matters, since it hides a read.
*/
.replace(/`(?:[^`\\]|\\.)*`/g, (template) =>
(template.match(/\$\{[\s\S]*?\}/g) ?? []).join(" "))
.replace(/'(?:[^'\\\n]|\\.)*'/g, '""')
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""');
}
function collectSourceFiles(dir: string, out: string[]): void {
if (!existsSync(dir)) return;
for (const entry of readdirSync(dir)) {
if (entry === "__tests__" || entry === "node_modules" || entry === "dist" || entry === "__test-utils__") continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
collectSourceFiles(full, out);
continue;
}
if (entry.endsWith(".ts") || entry.endsWith(".tsx")) out.push(full);
}
}
describe("raw workflowColumns flag census (U12)", () => {
const files: string[] = [];
for (const root of SOURCE_ROOTS) collectSourceFiles(join(REPO_ROOT, root), files);
it("scans a non-trivial production source set, so an empty sweep cannot pass", () => {
// Without this, a broken path glob would make every assertion below vacuously true —
// the "guard that reports success without checking anything" failure mode.
expect(files.length).toBeGreaterThan(200);
});
it("the raw flag is read ONLY by the known move-path sites, at the known COUNT", () => {
/*
COUNTS, not just file names (PR #2537 review — CodeRabbit). A per-file allowlist has
a hole exactly where it matters least visibly: a NEW raw-flag read added inside
`moves.ts` — already an allowed file — would have passed silently. Pinning the
occurrence count per file means the census notices a third read in a file that is
permitted two.
Whole-word matching, so a longer identifier that merely contains this one is not
counted. Deliberately NOT a full AST parse: that is a heavy lift for a guard whose
job is to notice movement, and the count already fails on the case that motivated
it. If this ever needs to distinguish a call from a re-export, parse then.
*/
const wholeWord = new RegExp(`\\b${RAW_FLAG_READER}\\b`, "g");
const readers = files
.map((file) => ({
file: file.slice(REPO_ROOT.length + 1),
occurrences: (stripCommentsAndStrings(readFileSync(file, "utf8")).match(wholeWord) ?? []).length,
}))
.filter((entry) => entry.occurrences > 0)
.sort((a, b) => a.file.localeCompare(b.file));
const allowed = ALLOWED
.map((entry) => ({ file: entry.file, occurrences: entry.occurrences }))
.sort((a, b) => a.file.localeCompare(b.file));
/*
Equality, not subset. A subset check would let the last reader vanish silently and
leave the settings key orphaned forever, which is precisely the outcome this exists
to prevent.
If this fails because a reader was ADDED — a new file, or a higher count in an
existing one: do not edit ALLOWED to make it pass. A new read re-gates behaviour on
a flag that is false for every project that never carried a stale value, so the
feature behind it will not run.
If this fails because a reader was REMOVED: U2b has landed. Delete
`isWorkflowColumnsCompatibilityFlagEnabled`, drop `workflowColumns` from
`HIDDEN_EXPERIMENTAL_FEATURE_KEYS` in the dashboard SettingsModal only after
confirming stale persisted values still render nothing, and delete this file.
*/
expect(readers).toEqual(allowed);
});
it("no production SOURCE LITERAL writes the key", () => {
/*
SCOPE, corrected (PR #2537 review — greptile). An earlier version of this claimed
"no production code writes the key", which overclaims and contradicts a correction
made earlier in this same unit (PR #2512, greptile P1): `settings-schema.ts`
explicitly TOLERATES stale persisted values, and the generic settings-update path —
settings import, configuration rollback — persists experimental-feature entries
assembled from RUNTIME data. A `true` can absolutely reach storage that way, on an
upgraded project that carried one.
A source scan cannot see that and must not pretend to. What it does prove is
narrower and still worth pinning: no module hardcodes the key, so nothing in the
product deliberately turns the flag on. That is the property behind "every read is
false for a project that never carried a stale value" — not an absolute.
*/
const writers = files.filter((file) => {
const code = stripCommentsAndStrings(readFileSync(file, "utf8"));
return /workflowColumns\s*:\s*(true|false)/.test(code);
}).map((file) => file.slice(REPO_ROOT.length + 1));
expect(writers).toEqual([]);
});
it("records why each remaining reader survives, so the list cannot become folklore", () => {
// Cheap, but it forces the next person to state a reason when they touch the list.
for (const entry of ALLOWED) {
expect(entry.why.length).toBeGreaterThan(20);
expect(existsSync(join(REPO_ROOT, entry.file))).toBe(true);
}
});
});

View File

@@ -183,3 +183,5 @@ export function deriveRunningAgentCounts(perProject: Record<string, number>): Ru
} }
return { currentlyActive, projectsActive }; return { currentlyActive, projectsActive };
} }

View File

@@ -35,13 +35,6 @@ export interface RepairOverlapBlockerResult {
} }
/** @internal Extracted modules use this compatibility flag */ /** @internal Extracted modules use this compatibility flag */
export function isWorkflowColumnsCompatibilityFlagEnabled(settings: Pick<Settings, "experimentalFeatures"> | undefined): boolean {
/*
FNXC:WorkflowColumns 2026-06-22-00:00:
TaskStore still needs the raw compatibility flag for legacy movement characterization, v1 workflow-IR rollback persistence, and ON→OFF custom-column evacuation tests. This is narrower than the public runtime helper, which treats stale false values as enabled after workflow-column cutover.
*/
return settings?.experimentalFeatures?.workflowColumns === true;
}
import { type PluginGateVerdict } from "./plugin-gate-verdict.js"; import { type PluginGateVerdict } from "./plugin-gate-verdict.js";
import type { PluginOnSchemaInit, PluginPostgresSchemaDefinition } from "./plugin-types.js"; import type { PluginOnSchemaInit, PluginPostgresSchemaDefinition } from "./plugin-types.js";
import { assertLoadedPluginSchemaInitHooksSupported, type LoadedPluginSchemaContract } from "./postgres/plugin-schema-hook.js"; import { assertLoadedPluginSchemaInitHooksSupported, type LoadedPluginSchemaContract } from "./postgres/plugin-schema-hook.js";
@@ -2406,11 +2399,14 @@ Issue #2149 requires read-only type filtering to occur in the file-store before
the three U5 reconciliation guards (now unconditional) and the three v1-IR the three U5 reconciliation guards (now unconditional) and the three v1-IR
rollback-compat persistence sites (now unconditional, same stored bytes). rollback-compat persistence sites (now unconditional, same stored bytes).
The underlying `isWorkflowColumnsCompatibilityFlagEnabled` SURVIVES for now: it is FNXC:WorkflowColumns 2026-07-31-04:00 (U12): `isWorkflowColumnsCompatibilityFlagEnabled` is now
still read by `moves.ts` and `workflow-task-create-ops.ts`, and removing those reads DELETED TOO. Its last two readers were the move path — `moves.ts` and the preflight in
IS the U2b move-path convergence, which carries an equivalence-proof obligation. `workflow-task-create-ops.ts` — and both were un-gated in one commit because the preflight computes
Deleting this wrapper is what makes the remaining reads easy to enumerate: after what `moves.ts` consumes.
this change, every surviving read of the raw flag is on the move path.
The equivalence-proof obligation that held this back is discharged, not waived:
`moves-flag-equivalence.test.ts` diffs the persisted row after the same journey under both flag
states against live PostgreSQL and finds them identical, mutation-verified in both directions.
*/ */
public async listWorkflowOccupantTaskIds(workflowId: string, includeNullSelection: boolean): Promise<string[]> { public async listWorkflowOccupantTaskIds(workflowId: string, includeNullSelection: boolean): Promise<string[]> {
return listWorkflowOccupantTaskIdsImpl(this, workflowId, includeNullSelection); return listWorkflowOccupantTaskIdsImpl(this, workflowId, includeNullSelection);

View File

@@ -6,7 +6,7 @@
* behavior-preserving refactor. Each function receives the TaskStore * behavior-preserving refactor. Each function receives the TaskStore
* instance as its first parameter and performs byte-identical work. * instance as its first parameter and performs byte-identical work.
*/ */
import {type TaskStore, type MoveTaskOptions, type MoveTaskInternalOptions, storeLog, isWorkflowColumnsCompatibilityFlagEnabled} from "../store.js"; import {type TaskStore, type MoveTaskOptions, type MoveTaskInternalOptions, storeLog} from "../store.js";
import * as schema from "../postgres/schema/index.js"; import * as schema from "../postgres/schema/index.js";
import {TaskDeletedError, HandoffInvariantViolationError, TransitionRejectionError} from "./errors.js"; import {TaskDeletedError, HandoffInvariantViolationError, TransitionRejectionError} from "./errors.js";
import {and, eq, sql} from "drizzle-orm"; import {and, eq, sql} from "drizzle-orm";
@@ -361,7 +361,6 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
// project) via getSettingsFast(). This is an async read taken before the // project) via getSettingsFast(). This is an async read taken before the
// lock-sensitive transaction; it does not touch the task lock. // lock-sensitive transaction; it does not touch the task lock.
const mergedSettingsForMove = await store.getSettingsFast(); const mergedSettingsForMove = await store.getSettingsFast();
const useWorkflow = isWorkflowColumnsCompatibilityFlagEnabled(mergedSettingsForMove);
// bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker // bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker
// call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the // call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the
// capacity check is not a guard (U6 fills the enforcement; U4 leaves a // capacity check is not a guard (U6 fills the enforcement; U4 leaves a
@@ -389,10 +388,16 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
is allowed to be stale; a capacity decision is not. is allowed to be stale; a capacity decision is not.
*/ */
const workflowSelectionForMove = await store.getTaskWorkflowSelectionAsync(id); const workflowSelectionForMove = await store.getTaskWorkflowSelectionAsync(id);
const effectiveWorkflowIdForMove = workflowSelectionForMove?.workflowId ?? DEFAULT_WORKFLOW_ID; /*
const workflowIr: WorkflowIr | undefined = useWorkflow FNXC:WorkflowColumns 2026-07-31-05:25 (PR #2655 review — greptile P2 follow-through):
? await resolveTaskWorkflowIrForMove(store, id) `effectiveWorkflowIdForMove` is DELETED. It existed only to feed the emitted `workflowId`, and it
: undefined; applied a `?? DEFAULT_WORKFLOW_ID` fallback — so keeping it would have meant either an unused
binding or the very fallback-as-authoritative stamp that review flagged. The emit site now reads
the SELECTION directly, so the absence signal is preserved and there is nothing left to guess.
*/
// FNXC:WorkflowColumns 2026-07-31-04:00 (U12): resolved unconditionally — the gate is gone, so
// `undefined` now means only "no IR on this path or a v1 column-less IR", never "flag off".
const workflowIr: WorkflowIr | undefined = await resolveTaskWorkflowIrForMove(store, id);
if (task.column === toColumn) { if (task.column === toColumn) {
if (internal.fromHandoff && toColumn === "in-review") { if (internal.fromHandoff && toColumn === "in-review") {
@@ -487,7 +492,7 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
const fromColumn = task.column; const fromColumn = task.column;
if (useWorkflow && workflowIr) { if (workflowIr) {
// ── Flag-ON validation + sync guards (typed rejections, KTD-3/R13) ───── // ── Flag-ON validation + sync guards (typed rejections, KTD-3/R13) ─────
// 1. Target column must exist in the task's workflow → unknown-column. // 1. Target column must exist in the task's workflow → unknown-column.
// #1411: a recoveryRehome move to a LEGACY column (todo/archived/…) is // #1411: a recoveryRehome move to a LEGACY column (todo/archived/…) is
@@ -787,7 +792,27 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
task.columnMovedAt = movedAt; task.columnMovedAt = movedAt;
task.updatedAt = movedAt; task.updatedAt = movedAt;
if (useWorkflow) { /*
FNXC:WorkflowColumns 2026-07-31-04:00 (U12 — the compatibility flag is RESOLVED):
Column side effects run through the default-workflow TRAIT HOOKS unconditionally. The
`if (useWorkflow)` gate and its inline legacy `else` branch are DELETED, not converted —
converting a branch we intended to delete would have left a second definition of every
column side effect alive to drift.
WHY THIS WAS SAFE TO FLIP, evidenced rather than asserted:
- `moves-flag-equivalence.test.ts` runs the SAME journey under both flag states against live
PostgreSQL and diffs the persisted row: identical across 128 fields plus an equal timing
shape, over todo -> in-progress -> in-review -> todo -> in-progress. Mutation-verified in
both directions (stamping this branch, and diverging the reopen hook, each fail it).
- The flag was read by NOTHING in production: `experimentalFeatures.workflowColumns` is
global-only and no module writes it, so this branch had never run for any project that did
not carry a stale persisted value. That is also why "the suite is green" was never evidence
on its own — both paths were individually valid and only one was live.
- Target-column validation was NOT introduced by this flip. I claimed it was, twice, and
reproduced the opposite: a move to a column the task's workflow does not declare already
rejects on the legacy path with `Invalid transition: ... Valid targets: ...`. See the seam-2
cases in that same test file.
*/
/* /*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:10 (Phase C convergence): FNXC:WorkflowLifecycleColumns 2026-07-30-08:10 (Phase C convergence):
Resolved ONCE for both the hook context and the store's own reopen check, so the two Resolved ONCE for both the hook context and the store's own reopen check, so the two
@@ -855,130 +880,6 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
if (isReopenToTodoOrTriage && !preserveStepProgress) { if (isReopenToTodoOrTriage && !preserveStepProgress) {
await store.resetPromptCheckboxes(dir); await store.resetPromptCheckboxes(dir);
} }
} else {
// ── Flag-OFF legacy inline side effects (UNCHANGED — the flag-off path) ──
if (fromColumn === "in-progress" && toColumn !== "in-progress") {
const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt);
const segmentEndMs = Date.parse(task.columnMovedAt);
const segmentDeltaMs =
Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs)
? Math.max(0, segmentEndMs - segmentStartMs)
: 0;
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs;
}
if (toColumn === "in-progress") {
task.cumulativeActiveMs ??= 0;
if (!task.firstExecutionAt) {
task.firstExecutionAt = task.columnMovedAt;
}
if (!task.executionStartedAt) {
task.executionStartedAt = task.columnMovedAt;
}
task.userPaused = undefined;
}
if (toColumn === "done" && !task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
}
if (toColumn === "done") {
store.clearDoneTransientFields(task);
}
const isReopenToTodoOrTriage =
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review")
&& (toColumn === "todo" || toColumn === "triage");
if (isReopenToTodoOrTriage) {
// FNXC:WorkflowLifecycle 2026-07-12-09:05 (merge port from main): keep
// this flag-OFF inline block in sync with applyResetOnEntryEffects
// (default-workflow-hooks.ts) — `preservePause` keeps a pause-caused
// teardown move from clearing the user's park (FN-7851 pause-bounce loop).
if (!options?.preserveStatus) {
task.status = undefined;
task.error = undefined;
if (!options?.preservePause) {
task.pausedReason = undefined;
}
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
if (!options?.preservePause) {
task.paused = undefined;
task.pausedByAgentId = undefined;
}
if (moveSource === "user" && toColumn === "todo") {
task.userPaused = true;
} else if (!options?.preservePause) {
task.userPaused = undefined;
}
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
const preserveStepProgress =
options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress);
if (!options?.preserveWorktree) {
task.worktree = undefined;
}
if (!options?.preserveResumeState) {
task.executionStartedAt = undefined;
task.executionCompletedAt = undefined;
} else {
task.executionCompletedAt = undefined;
}
if (!preserveStepProgress) {
store.resetAllStepsToPending(task);
await store.resetPromptCheckboxes(dir);
}
}
if (toColumn === "in-review") {
// Keep this flag-OFF inline path in sync with applyInReviewEnterEffects.
// Do not snapshot global autoMerge: undefined follows the live setting,
// while explicit per-task true/false overrides remain sticky.
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
// Clear scheduler-side dispatch state: `queued`, `blockedBy`, and
// `overlapBlockedBy` are stamped while the task waits in `todo`. If
// they survive the transition into `in-review` they permanently block
// the merge gate (see getTaskMergeBlocker's BLOCKING_TASK_STATUSES).
if (task.status === "queued") {
task.status = undefined;
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
}
/*
FNXC:WorkflowReviewGates 2026-07-26-14:25:
Parity mirror of the gate in `applyReopenFieldClears` (default-workflow-hooks.ts) — these two
blocks are deliberately kept byte-equivalent in behavior. The graph's own
in-review -> in-progress crossing (the remediation node entry, routine now that the pre-merge
review gates live in `in-review`) must retain `workflowStepResults`; every other reopen still
clears. See the hook for the full rationale.
*/
const graphOwnedReviewToWip = options?.workflowMoveSource === "workflow-graph"
&& fromColumn === "in-review"
&& toColumn === "in-progress";
if (
!graphOwnedReviewToWip
&& ((fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage"))
|| (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")))
) {
task.workflowStepResults = undefined;
}
if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) {
task.branch = undefined;
task.executionStartBranch = undefined;
task.baseCommitSha = undefined;
task.summary = undefined;
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
}
}
if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) { if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) {
const allocator = options.allocateWorktree; const allocator = options.allocateWorktree;
@@ -1110,7 +1011,7 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
// crash-safe transitionPending marker in the SAME transaction as the // crash-safe transitionPending marker in the SAME transaction as the
// column change (KTD-2). countActiveInCapacitySlotAsync already counts // column change (KTD-2). countActiveInCapacitySlotAsync already counts
// pending markers in PG, so this is load-bearing for capacity too. // pending markers in PG, so this is load-bearing for capacity too.
if (useWorkflow) { {
await writeTransitionPendingAsync( await writeTransitionPendingAsync(
tx, tx,
id, id,
@@ -1348,7 +1249,7 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
// per-hook completion in the marker's hooksRemaining. A throwing plugin hook // per-hook completion in the marker's hooksRemaining. A throwing plugin hook
// DEGRADES (audit) and never wedges the lock or strands the marker — the // DEGRADES (audit) and never wedges the lock or strands the marker — the
// marker is always cleared at the end regardless of hook failures. // marker is always cleared at the end regardless of hook failures.
if (useWorkflow) { {
// Plugin hooks are skipped on engine/recovery-sourced moves (KTD-9 — those // Plugin hooks are skipped on engine/recovery-sourced moves (KTD-9 — those
// bypass trait effects) and on same-column no-ops. // bypass trait effects) and on same-column no-ops.
if (!bypassGuards && fromColumn !== toColumn && workflowIr) { if (!bypassGuards && fromColumn !== toColumn && workflowIr) {
@@ -1403,17 +1304,22 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum
...(internal.runContext?.runId ? { runId: internal.runContext.runId } : {}), ...(internal.runContext?.runId ? { runId: internal.runContext.runId } : {}),
/* /*
FNXC:WorkflowEvents 2026-07-27-15:10 (U3, PR #2467 review): FNXC:WorkflowEvents 2026-07-27-15:10 (U3, PR #2467 review):
OMIT rather than guess. `effectiveWorkflowIdForMove` reads the task's real OMIT rather than guess. An absent `workflowId` means "not resolved here";
selection only when `useWorkflow` is true; otherwise it is hardcoded to a subscriber that needs it reads the selection itself.
`builtin:coding`. That compat flag is off for effectively every real
project (see the note at the flag-OFF adjacency branch above), so FNXC:WorkflowColumns 2026-07-31-05:20 (PR #2655 review — greptile P2):
emitting it unconditionally would stamp `builtin:coding` onto moves of The flag deletion nearly destroyed that signal. `effectiveWorkflowIdForMove`
tasks on a custom workflow — a wrong value baked into a brand-new wire is `selection?.workflowId ?? DEFAULT_WORKFLOW_ID`, so emitting it
field, latent only because no subscriber reads it yet. An absent unconditionally would stamp `builtin:coding` onto every task that has no
`workflowId` means "not resolved here"; a subscriber that needs it reads explicit selection — reporting a FALLBACK as an authoritative choice, which
the selection itself. is the same "wrong value baked into a wire field" this note was written to
prevent, arrived at from the other direction.
So the condition survives the flag: emit only when the selection genuinely
resolved. `builtin:coding` still appears here for tasks that really select
it, and is absent for tasks that merely default to it.
*/ */
...(useWorkflow ? { workflowId: effectiveWorkflowIdForMove } : {}), ...(workflowSelectionForMove?.workflowId ? { workflowId: workflowSelectionForMove.workflowId } : {}),
}); });
} }
if (toColumn === "done") { if (toColumn === "done") {

View File

@@ -86,6 +86,44 @@ export function resolveWorkflowBypassGuardsImpl(store: TaskStore,
moveSource: NonNullable<MoveTaskOptions["moveSource"]>, moveSource: NonNullable<MoveTaskOptions["moveSource"]>,
options?: MoveTaskOptions, options?: MoveTaskOptions,
): boolean { ): boolean {
/*
FNXC:WorkflowColumns 2026-07-31-11:00 (PR #2655 review — BOTH findings are right, and they are
the same defect seen from two sides. REVERTED to reading `options?.moveSource`.)
Round 1 said an optionless `moveTask(id, target)` LOSES bypass, because the call site resolves
`moveSource` to "engine" while this read the absent option. I switched to the resolved value.
Round 2 said that grants privileged bypass to PUBLIC callers. Both are correct, because an absent
`moveSource` is genuinely ambiguous — measured on this tree:
18 optionless calls in packages/engine (self-healing, project-engine) — genuine engine moves
8 optionless calls in packages/dashboard HTTP routes (reset, rebound, respecify, unassign)
— operator-initiated, and they must NOT skip merge blockers or plugin gates
Reading the resolved value hands bypass to those eight routes. Reading the option leaves the
eighteen engine calls unbypassed, which is what has shipped all along.
So this reverts to the shipped read. A flag-resolution PR is the wrong place to change who gets to
skip merge blockers: it is a behaviour change with a security shape, it is not required by the
flip, and "the tests pass" is not evidence for it. The real fix is to make `moveSource` EXPLICIT at
those 26 call sites so the default never has to be guessed — filed as follow-up work, not smuggled
in here.
The `void moveSource;` below is kept for the same reason it existed: the parameter is part of the
signature and deliberately unused until that follow-up lands.
`void moveSource;` discarded the parameter and re-read the raw option, so an OPTIONLESS call —
`moveTask(id, target)` — resolved `moveSource` to "engine" at every call site and then computed
`bypassGuards === false`, because `options` was undefined. The two disagreed about what kind of
move it was.
Harmless while the move-path flag gated validation, because nothing consumed the answer. The flag
is gone, so seam 2's guards now run for these calls and an internal executor/merger/recovery move
made without an options object would be judged as if a user had made it.
The `void` was a deliberate unused-parameter suppression, i.e. someone noticed the argument was
unused and silenced the lint instead of wiring it up. Using it aligns bypass with the `moveSource`
every caller already resolves the same way.
*/
void moveSource; void moveSource;
return options?.recoveryRehome === true || return options?.recoveryRehome === true ||
(options?.bypassGuards ?? (options?.bypassGuards ??

View File

@@ -8,7 +8,7 @@
* behavior-preserving refactor. Each function receives the TaskStore * behavior-preserving refactor. Each function receives the TaskStore
* instance as its first parameter and performs byte-identical work. * instance as its first parameter and performs byte-identical work.
*/ */
import {TaskStore, isWorkflowColumnsCompatibilityFlagEnabled} from "../store.js"; import {TaskStore} from "../store.js";
import {resolveEntryColumnId} from "../workflow-reconciliation.js"; import {resolveEntryColumnId} from "../workflow-reconciliation.js";
import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js"; import {resolveWorkflowIrForTask} from "../workflow-ir-resolver.js";
import * as schema from "../postgres/schema/index.js"; import * as schema from "../postgres/schema/index.js";
@@ -349,8 +349,14 @@ export async function getTaskColumnsImpl(store: TaskStore, ids: string[]): Promi
export async function prepareWorkflowMovePolicyPreflightImpl(store: TaskStore, id: string, toColumn: ColumnId, options: MoveTaskOptions | undefined, internal: MoveTaskInternalOptions,): Promise<MoveTaskInternalOptions["movePolicyPreflight"]> { export async function prepareWorkflowMovePolicyPreflightImpl(store: TaskStore, id: string, toColumn: ColumnId, options: MoveTaskOptions | undefined, internal: MoveTaskInternalOptions,): Promise<MoveTaskInternalOptions["movePolicyPreflight"]> {
const task = await store.readTaskForMove(id); const task = await store.readTaskForMove(id);
const moveSource = options?.moveSource ?? "engine"; const moveSource = options?.moveSource ?? "engine";
const mergedSettingsForMove = await store.getSettingsFast(); /*
if (!isWorkflowColumnsCompatibilityFlagEnabled(mergedSettingsForMove)) return undefined; FNXC:WorkflowColumns 2026-07-31-04:00 (U12 — flipped ATOMICALLY with moves.ts):
The compatibility-flag gate is DELETED. This preflight computes the `movePolicyPreflight` that
`moves.ts` consumes and validates, so the two could never be flipped independently: un-gating
this alone would evaluate workflow move policies — with their plugin-gate side effects — while
the branch consuming the result stayed off, and un-gating `moves.ts` alone would validate against
a preflight that was never computed. Both readers go in the same commit for that reason.
*/
if (task.column === toColumn) return undefined; if (task.column === toColumn) return undefined;
/* FNXC:WorkflowModelLanes 2026-07-14-16:31: PostgreSQL move preflight must validate against the task's migrated workflow selection, not the synchronous builtin:coding fallback. */ /* FNXC:WorkflowModelLanes 2026-07-14-16:31: PostgreSQL move preflight must validate against the task's migrated workflow selection, not the synchronous builtin:coding fallback. */

View File

@@ -1,27 +1,26 @@
{ {
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
"totals": { "totals": {
"column": 746, "column": 722,
"role": 5, "role": 5,
"status": 186, "status": 186,
"deliberate": 17 "deliberate": 17
}, },
"byColumnId": { "byColumnId": {
"done": 199, "done": 195,
"in-progress": 144, "in-progress": 138,
"in-review": 205, "in-review": 200,
"archived": 147, "archived": 147,
"todo": 47, "todo": 42
"triage": 4
}, },
"byFile": { "byFile": {
"packages/engine/src/self-healing.ts": 110, "packages/engine/src/self-healing.ts": 110,
"packages/engine/src/executor.ts": 85, "packages/engine/src/executor.ts": 85,
"packages/dashboard/app/components/TaskCard.tsx": 42, "packages/dashboard/app/components/TaskCard.tsx": 42,
"packages/core/src/task-store/moves.ts": 39,
"packages/dashboard/app/components/TaskDetailModal.tsx": 30, "packages/dashboard/app/components/TaskDetailModal.tsx": 30,
"packages/engine/src/scheduler.ts": 28, "packages/engine/src/scheduler.ts": 28,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 20, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 20,
"packages/core/src/task-store/moves.ts": 15,
"packages/core/src/store.ts": 12, "packages/core/src/store.ts": 12,
"packages/engine/src/project-engine.ts": 12, "packages/engine/src/project-engine.ts": 12,
"packages/engine/src/mission-execution-loop.ts": 10, "packages/engine/src/mission-execution-loop.ts": 10,