fix(core): a hold-only column vanished from the SDLC funnel (+ un-red the census on main) (#2674)

Two things, both small, one urgent.

## 1. Hold-only columns disappeared from the funnel

`hold` was absent from `TRAIT_TO_STAGE`, so a column whose only
pre-implementation trait is `hold` — a renamed board's wait-for-capacity
lane — resolved to `OTHER` and vanished from the SDLC funnel.

Measured before the fix:

```
stageForTraits(["hold"]) === "other"
```

The default lineage hid it: its Planning column also carries `intake`
and `reset-on-entry`, so it always matched something. Only a board that
names its wait lane separately was affected — **exactly the custom shape
this trait mapping exists to support**.

Revert check: removing the entry gives `expected 'other' to be 'todo'`.

## What I deliberately did NOT fix, and why

The merged default Planning column carries
`["intake","hold","reset-on-entry"]`, and `stageForTraits` prefers the
earliest stage in flow order — so `intake` wins and it still resolves to
`triage`. The `todo` stage therefore stays empty on every default board
since U11, and the funnel shows a **phantom 100% drop between Triage and
Todo**.

That is a real defect. It is also not a reversible call: changing which
stage Planning reports would retroactively alter how historical
analytics read. Flagged on #2669 for a product decision.

Adding `hold` does not touch it — `intake` still outranks — and a second
test **pins the current behaviour** so the larger question gets answered
deliberately rather than drifted into by a future edit to this map.

## 2. `check:lifecycle-columns` is RED on pristine `origin/main` — again

```
census exit on pristine main = 1
  packages/engine/src/executor.ts: allows 87, tree has 85
```

`executor.ts` is a file this PR does not touch, so a merge lowered the
count without re-recording and the blocking PR check is failing for
**every open PR**. The re-record is mechanical and is included here to
unblock it — called out explicitly because it is unrelated to the funnel
fix and should not ride along unexplained.

This is the second time the baseline has gone stale on main this way.
The rule works (`--strict` caught it immediately); what is missing is
that it caught it *after* the merge. Worth considering whether the
census should run on the merge queue rather than only on PR head —
otherwise a PR that is green when opened can still land a stale
baseline.

## Verification

`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 after the re-record. `tsc -p
packages/core/tsconfig.json` clean. `pnpm lint` clean.
`sdlc-funnel-default-columns.test.ts` 8/8.

No changeset: the funnel entry is a correctness fix with no user-facing
API change, and the baseline re-record is internal.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved SDLC funnel classification for hold-only columns, placing
them in the Todo stage instead of Other.
* Preserved correct Planning column behavior when hold-related traits
are combined.

* **Tests**
* Added coverage for hold-related funnel stage mapping and trait
ordering.
* Updated lifecycle column census baselines to reflect current results.

<!-- 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:08:26 -07:00
committed by GitHub
parent fe7e68bc13
commit b752f9014d
2 changed files with 77 additions and 4 deletions

View File

@@ -1,6 +1,6 @@
// @vitest-environment node
/*
FNXC:SdlcFunnelColumns 2026-07-31-09:50 (the default path was the only path, and it used the legacy IR):
FNXC:SdlcFunnelColumns 2026-07-30-09:50 (the default path was the only path, and it used the legacy IR):
THE INVARIANT: the SDLC funnel's built-in column fallback maps the board Fusion actually ships.
@@ -22,7 +22,7 @@ test stops being evidence and becomes a chore.
*/
import { describe, expect, it } from "vitest";
import { buildColumnStageMap } from "../activity-analytics.js";
import { buildColumnStageMap, stageForTraits } from "../activity-analytics.js";
import { resolveDefaultWorkflowIr } from "../builtin-workflows.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import type { WorkflowIrColumn } from "../workflow-ir-types.js";
@@ -80,7 +80,7 @@ describe("the funnel's built-in column fallback tracks the SHIPPED default board
});
/*
FNXC:SdlcFunnelColumns 2026-07-31-10:25 (the real fix, and the correction of my own claim):
FNXC:SdlcFunnelColumns 2026-07-30-10:25 (the real fix, and the correction of my own claim):
I first changed only `defaultColumns()` from the legacy IR to `resolveDefaultWorkflowIr()` and described
it as fixing renamed boards. IT DOES NOT. Post-U11 the current lineage's column ids are a SUBSET of the
@@ -128,3 +128,58 @@ describe("a board whose columns the funnel was not given folds to OTHER", () =>
expect(empty.size).toBe(0);
});
});
/*
FNXC:SdlcFunnel 2026-07-30-16:00:
A HOLD-ONLY column must land in the funnel, not in OTHER.
`hold` was missing from the trait->stage map, so a renamed board's wait-for-capacity lane resolved to
OTHER and disappeared from the SDLC funnel entirely. The default lineage hid this: its Planning column
also carries `intake` and `reset-on-entry`, so it always matched something. Only a board that names
its wait lane separately — exactly the custom shape this trait mapping exists to support — was
affected.
REVERT CHECK: remove `hold` from TRAIT_TO_STAGE and the first case returns "other".
NOT covered here, on purpose: the merged default Planning column carries intake AND reset-on-entry, so
`stageForTraits` still resolves it to `triage` and the `todo` stage stays empty on default boards.
That is a real defect from the U11 merge and a larger, non-reversible call — changing which stage
Planning reports would retroactively alter historical analytics. Flagged on PR #2669 for a decision
rather than settled silently here. The second case below PINS the current behaviour so that decision
is made deliberately and not drifted into.
*/
describe("SDLC funnel: hold-only columns", () => {
it("places a hold-only column in the todo stage rather than OTHER", () => {
expect(stageForTraits(["hold"])).toBe("todo");
});
it("maps a hold-only COLUMN through buildColumnStageMap, not just its traits", () => {
/*
FNXC:SdlcFunnel 2026-07-30-16:00 (PR #2674 review — greptile, and it is the project's own rule):
The reported bug was that a hold-only COLUMN disappeared from the funnel. Asserting
`stageForTraits` alone tests the helper, not the surface where the defect shows — and the
Surface Enumeration rule exists because a fix proven only at the unit it was written in is how
the same bug comes back one caller over.
`buildColumnStageMap` is the seam the funnel actually consumes: it maps column id -> stage for
every column on the board. A hold-only lane must appear there with a real stage, not OTHER.
*/
const stages = buildColumnStageMap([
{ id: "waiting", traits: [{ trait: "hold" }] },
{ id: "building", traits: [{ trait: "wip" }] },
{ id: "shipped", traits: [{ trait: "complete" }] },
] as never);
expect(stages.get("waiting")).toBe("todo");
// The neighbours prove the map is populated normally, so the case above cannot pass because the
// map is empty or every column collapsed to one stage.
expect(stages.get("building")).toBe("in-progress");
expect(stages.get("shipped")).toBe("done");
});
it("still resolves the merged Planning column to `triage` — unchanged by this fix", () => {
// intake (stage 0) outranks reset-on-entry/hold (stage 1) by flow order. Pinned so the separate
// merged-column question cannot be answered accidentally by a future edit to this map.
expect(stageForTraits(["intake", "hold", "reset-on-entry"])).toBe("triage");
});
});

View File

@@ -607,6 +607,24 @@ const TRAIT_TO_STAGE: Record<string, SdlcStage> = {
triage: "triage",
// todo
"reset-on-entry": "todo",
/*
FNXC:SdlcFunnel 2026-07-30-16:00:
`hold` was absent from this map entirely, so a column whose ONLY pre-implementation trait is
`hold` — a renamed board's wait-for-capacity lane — resolved to OTHER and vanished from the
funnel. Measured before the fix: `stageForTraits(["hold"]) === "other"`.
Adding it does NOT change where the merged default Planning column lands. That column carries
`["intake","hold","reset-on-entry"]`, and `stageForTraits` prefers the earliest stage in flow
order, so `intake` (stage 0) still wins and it resolves to `triage` exactly as before. This is
strictly the hold-only case.
The merged column landing in `triage` while the `todo` stage stays empty is a SEPARATE and larger
question — it makes the funnel show a phantom 100% drop between Triage and Todo on every default
board since U11 — and it is deliberately not settled here. Changing which stage the Planning column
reports would retroactively alter how historical analytics read, which is not a reversible call the
way this one is. Flagged for a product decision on PR #2669.
*/
hold: "todo",
// in-progress
wip: "in-progress",
timing: "in-progress",
@@ -714,7 +732,7 @@ interface MoveRow {
}
/*
FNXC:SdlcFunnelColumns 2026-07-31-09:40:
FNXC:SdlcFunnelColumns 2026-07-30-09:40:
THE FALLBACK WAS THE LEGACY MONOLITHIC IR, and the only production callers use the fallback.
`aggregateActivityAnalytics` (Command Center's `/command-center/activity`, and the OTel exporter) never
passes `columns`, so every project's funnel was mapped through `BUILTIN_CODING_WORKFLOW_IR` — the