Commit Graph

19 Commits

Author SHA1 Message Date
gsxdsm
083f8f5c5b fix(glasses): every card on a renamed board badged "todo", including cards in review (#3015)
## Every card on a renamed board badged `todo` — including cards in
review

```ts
export function statusBadge(column: Task["column"]): string {
  return COLUMN_BADGES[column] ?? "todo";
}
```

`COLUMN_BADGES` maps the six legacy ids to themselves. On a board whose
lanes are named anything else **every lookup misses**, so every card
badges `todo` — a card sitting in review tells the wearer it is
un-started, and the whole board carries one identical badge.

On a display with room for a single word, that is worse than an
unrecognised lane: it is a *confident wrong answer* rather than a
missing one.

Reached from `taskToCard` (the main card) and `notificationCard` (the
notification badge).

## Fix, and the dead weight it exposed

The badge **is** the column id, so the function now says so:

```ts
export function statusBadge(column: Task["column"]): string {
  return column;
}
```

That also retires `COLUMN_BADGES`. Once the fallback is the id, a table
mapping each legacy id to *itself* decides nothing — six lane literals
sat in this file doing no work. It was module-private with `statusBadge`
as its only consumer and it was a pure identity, so behaviour on legacy
boards is byte-identical: this is not a behaviour change riding along
with a cleanup, it is the dead weight the fix exposed, removed rather
than left as a decoy.

Mirrors `columnLabel` in the CLI (`COLUMN_LABELS[column] ?? column`) for
the same reason: a board that calls its lane `checking` should read
`checking`. No resolution needed; the id is in hand at the call site.

Note the census count for this file does **not** move — those six were
object keys, not comparisons, which is exactly the scope the census
documents for itself.

## This is a miss in my own #2968

That PR fixed the summary card's counts **in this same file** and never
looked one function further at the per-card badge those counts sit
above. Worth saying plainly, because it is the practical reminder behind
the census finding I have been repeating all run: *a file having had a
defect fixed is not evidence about its neighbours* — and here the
neighbour was nine lines away, in a function I had read.

## Revert proof

```
AssertionError: expected 'todo' to be 'checking'
      Tests  1 failed | 9 passed (10)
```

The paired case ("still badges the legacy ids exactly as before") passes
both ways by design — it guards against the fallback change altering
known boards, so I am not counting it as coverage of the defect.

## Verification (measured)

- plugin suite — **198 passed / 19 files**
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green

No changeset: the plugin is `private: true` and is not bundled into the
published CLI.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Status badges now accurately display a card’s actual lane, including
previously unrecognized lanes.
  * Preserved existing badge behavior for known lanes.
* **Tests**
* Added coverage to verify accurate lane reporting and legacy behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 00:47:02 -07:00
gsxdsm
eb9cd431c1 fix(glasses): settings silently discarded the operator's own lane names, so notifications never fired (#2973)
## The operator configures their lanes, and the plugin throws it away

`TaskColumn` was the closed legacy union and `COLUMN_SET` gated every
settings read against it:

```ts
.filter((value): value is TaskColumn => COLUMN_SET.has(value as TaskColumn));
```

**`notifyOnColumns` is a free-text string array in the schema** (`type:
"array", itemType: "string"`) — the UI invites any lane name. So an
operator on a renamed board types `checking`:

1. `getNotifyColumns` filters it out — not in the legacy five
2. `columns.length === 0`, so it substitutes `DEFAULT_NOTIFY_COLUMNS` =
`["in-review"]`
3. their board has no `in-review`
4. `notifier.ts` builds `new Set(getNotifyColumns(...))`, and
`diffSnapshots` tests `notifyOnColumns.has(task.column)`

**No notification ever fires.** The feature is silently off while the
setting reads as configured, and nothing surfaces an error.

`quickCaptureDefaultColumn` had the same shape with an extra irony: it
was rewritten to `todo` *before* `normalizeCaptureColumn` saw it — and
that function already validates against the board's **declared** columns
and falls back to the workflow's own intake lane. The pre-filter
destroyed the operator's answer immediately before the code that could
have honoured it.

## Fix

Validation is now **structural** (non-empty string) rather than
**vocabulary-based**. `TaskColumn` mirrors core's `ColumnId`
(`LegacyTaskColumn | (string & {})`), so legacy ids keep autocomplete
while custom ids are admitted. `COLUMN_SET` survives only as the
quick-capture dropdown's suggestions, not as a gate.

**Deliberately not resolving the board's columns.** That needs a
board-columns endpoint `FusionApiClient` does not have — a new read, not
a rename — and that gap is already recorded in this file by an earlier
audit. This change is orthogonal to it: accepting operator input
requires no resolution at all, which is why it doesn't wait on the
endpoint.

**Trade-off, taken knowingly:** a typo'd lane is now honoured and will
match no card. That is the milder failure. Before, a *correct* custom
lane was discarded categorically, so renamed boards could not use the
feature at all; now the only broken case is one the operator typed wrong
and can see in their own settings.

## Two existing assertions asserted the defect

```ts
expect(getNotifyColumns({ notifyOnColumns: ["nope"] })).toEqual(["in-review"]);
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "bad-column" })).toBe("todo");
```

I checked provenance before touching them: they date to the plugin's
original commits (FN-3738 / FN-3970), when the five ids genuinely were
the whole vocabulary. They carry no reasoning comment and no later
change defended custom-lane rejection as a contract — so this is a stale
assumption being corrected, not a peer's tested decision being
overwritten. Structural rejection (non-strings, blanks, whitespace-only)
and trimming are still asserted, because that part was always right.

## Revert proof

```
AssertionError: expected [ 'in-review' ] to deeply equal [ 'checking', 'shipped' ]
AssertionError: expected [ 'todo' ] to deeply equal [ 'todo', 'spaced' ]
      Tests  2 failed | 5 passed (7)
```

## Verification (measured)

- plugin suite — **196 passed / 19 files**
- `tsc --noEmit`, `eslint` — clean
- `lifecycle-column-census --strict`, `check-fnxc-future-dates` — green

No changeset: the plugin is `private: true` and is not bundled into the
published CLI.
2026-07-30 22:30:38 -07:00
gsxdsm
5d01164994 test(glasses): re-green 15 agent-action tests, and revive a guard that asserted nothing (#2969)
## 15 tests have been red on `main`

`startWork`, `requestReview` and `approvePlan` gained a third `moveTask`
argument, `{ moveSource: "user" }`. The assertions in
`agent-actions.test.ts` kept the two-argument form:

```
AssertionError: expected "vi.fn()" to be called with arguments: [ 'FN-1', 'building' ]
Received: [ "FN-1", "building", + { "moveSource": "user" } ]
      Tests  15 failed | 41 passed (56)
```

Reproduces on clean `origin/main`. This suite is outside the merge gate,
which is why it went red unnoticed.

## The part that is worse than staleness

Three of these are **negative** assertions, and they did not fail — they
went **dead**. `expect(fn).not.toHaveBeenCalledWith(id, column)` cannot
match a three-argument call, so it passes whether or not the forbidden
move happened. `requestReview`'s "never lands on the legacy `in-review`"
guard has been asserting nothing since the option landed.

Verified rather than reasoned about — a scratch case, run and then
deleted:

```ts
const fn = vi.fn();
fn("FN-1", "in-review", { moveSource: "user" });          // the forbidden move HAPPENED
expect(fn).not.toHaveBeenCalledWith("FN-1", "in-review");   // old form: passes anyway ✓
```

Both cases passed, confirming the old form is vacuous and the
three-argument form throws.

## Applied per action, not uniformly

Only **3 of 5** product `moveTask` calls take the option, and the split
is deliberate:

| action | source | why |
| --- | --- | --- |
| `startWork`, `requestReview`, `approvePlan` | `{ moveSource: "user" }`
| the wearer's tap is a human gesture, matching the dashboard move route
|
| `returnToAgent`, `retryTask` | default (engine) | per the Move-Task
contract a user-source move parks the row `userPaused`, defeating the
return/retry intent |

So `returnToAgent`/`retryTask` assertions are **correct as
two-argument** and are left untouched — including their negatives, which
genuinely assert because the real call is also two-argument.

Attribution was done by scoping each assertion to its enclosing `it(`
block, not to the nearest preceding call: the latter mis-attributes the
`await expect(startWork(...)).resolves` form, which reads as `expect`.
Blocks mixing a user-source and a default-source action were excluded
from rewriting (there were none).

I did **not** "fix" the 3/5 split. It is documented in the product with
its reasoning and it matches the Move-Task contract; changing it would
be a behavior change riding in a test-only commit.

## Verification (measured)

- `agent-actions.test.ts` — **56/56 passed** (was 15 failed / 41 passed)
- full plugin suite — **192 passed / 19 files** (was 180 passed, 15
failed)
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-fnxc-future-dates` — green

Tests only; no product file is touched. An FNXC note now records the
arity contract and which actions take which source, so the next
assertion added here doesn't reintroduce a dead guard.
2026-07-30 22:17:11 -07:00
gsxdsm
1c19540f50 fix(glasses): the summary card reported five hardcoded lanes, so a renamed board read as empty (#2968)
## The defect

`boardSummaryCardFromCounts` built its text from five hardcoded lane
ids:

```ts
`Triage ${counts.triage} Todo ${counts.todo} Doing ${counts["in-progress"]} Review ${counts["in-review"]} Done ${counts.done}`
```

`boardSummary` seeds exactly those five keys to `0`, then counts by real
`task.column`. On a board whose lanes are named anything else, **every
interpolated value is the seeded zero** — so the summary card, which is
the first card in every deck and the entire body of `GET
/board/summary`, reads:

```
Triage 0 Todo 0 Doing 0 Review 0 Done 0
```

…while the real work sits in lanes it never mentions. The wearer is told
the board is empty.

It is also wrong on the **default** board today: U11 (#2515) deleted the
`triage` lane, so `Triage 0` is permanently dead text — 9 of the 24
characters this display gets per line.

## Why there was no test

The only summary coverage in `cards.test.ts` exercised
`boardSummaryCard`, an **export no production file calls** (its sole
caller is that test). The function the deck actually ships had none.
That gap is why five hardcoded ids survived here.

## The fix, and one deliberate choice

The lanes are derived from `counts` instead of taken as an optional
resolved-lanes parameter.

That is on purpose. This program's recurring defect is precisely the
"optional lane answer + documented literal fallback" shape shipped
without wiring the caller — `unwired-lane-parameter-guard.test.ts`
documents **five live on `main` at once**, and in four of five the
parameter was unreachable because the caller held a larger defect.
`counts` is already keyed by real `task.column` values, so the
vocabulary is in hand with **no resolution, no new plumbing, and nothing
that can be left unwired**.

Zero-count lanes are dropped so the scarce line budget goes to lanes
with work; legacy ids keep their familiar labels and order, unknown ids
sort after them alphabetically so output is deterministic. `counts`
itself is untouched — the route returns it as the API body and consumers
still see every key.

## Revert proof

Restoring only `cards.ts` fails all three new cases, printing the defect
verbatim:

```
AssertionError: expected 'Triage 0 Todo 0 Doing 0 Review 0 Done…' to contain 'backlog 2'
AssertionError: expected 'Triage 0 Todo 1 Doing 0 Review 0 Done…' not to match /Triage/
AssertionError: expected 'Triage 0 Todo 0 Doing 0 Review 0 Done…' to be 'No active work'
      Tests  3 failed | 5 passed (8)
```

## Verification (measured)

- `cards.test.ts` — **8/8 passed**
- full plugin suite — **180 passed**, 19 files
- `eslint`, `tsc --noEmit` — clean
- `lifecycle-column-census --strict`, `check-sql-column-literals`,
`check-fnxc-future-dates` — green

No changeset: `@fusion-plugin-examples/even-realities-glasses` is
`private: true` and is not bundled into the published CLI.

## Pre-existing failure, NOT from this change

The plugin suite also has **15 failures in
`src/__tests__/agent-actions.test.ts`**. They reproduce identically on
clean `origin/main` with this branch stashed (`15 failed | 41 passed`),
so they are not caused by this PR and are not fixed by it. Flagging
separately — this suite is outside the merge gate, which is why it has
been red unnoticed.
2026-07-30 22:14:16 -07:00
gsxdsm
189f237a07 fix(security): make the approval and permission gates actually enforce (#2457)
## Why

An AI agent autonomously deleted a live task (FN-8609) via
`fn_task_delete`. Investigating that led to two audits, which found the
gate machinery is well-built and its *enforcement* well-tested — but the
*decision boundary* was unguarded and the tools that matter never
reached the gate at all.

The headline defect: all ~76 tools in `packages/cli/src/extension.ts`
are delivered through `hostExtensionPaths` → `DefaultResourceLoader`,
which never passes through `wrapToolsWithActionGate`. `fn_task_delete`
*was* classified as gated in `gating-classifications.ts` — that
classification was dead code. Roughly 15 destructive tools were ungated
even for an operator who had locked their policy all the way down.

Second: `POST /api/approvals/:id/decision` took the approver identity
from the request body, validated for shape only, with no check that the
decider was human or differed from the requester. Combined with a bash
tool whose command string was never inspected (only `params.cwd`), an
agent could read the daemon token from `~/.fusion/settings.json`, find
its own pending request, and approve it.

## Scope

This does **not** tighten defaults. The `unrestricted` preset is
byte-for-byte unchanged — `git diff` on `agent-permission-policy.ts` is
empty — and regression tests assert that an out-of-the-box install
behaves exactly as before. The bug was never "the default is too
permissive"; it was "strict policy doesn't enforce." This makes turning
security up actually work.

The one deliberate exception: the containment that stops an agent
escalating its *own* privileges (reading the daemon token / credentials,
calling the approvals API to self-approve) applies at every preset
including `unrestricted`. That is a privilege-escalation boundary rather
than a permission preference — if it only engaged under strict policy it
would not have prevented the incident that prompted this.

## What changed

8 bisectable commits:

- **Approval lifecycle** — self-approval blocked via server-derived
deciders; same-verdict replay 409s; decide re-reads and re-validates
inside the transaction; expiry TTLs; `markCompleted` ownership check;
session identity registry in core.
- **Engine gates enforce for real** — unclassified tools resolve to a
policy-governed category instead of hardcoded `allow`; missing-policy
fail-open closed; bash containment floor + exact-command approval
binding.
- **Dashboard decision routes** — stop trusting client-supplied actors
(decision, bypass-review, worktrunk → 403 on forged actors).
- **`fn serve` authenticated by default** — auto-mints a token following
the existing `fn dashboard` precedent; `--no-auth` opts out.
- **Sibling entry points closed** — user-sourced hard-cancel moves, ACP
execute-once approvals, plugin task-store gating.
- **pi-extension principal resolution** — the extension resolves the
acting principal and can withhold or policy-gate the previously ungated
destructive tools.
- **Root-cause bonus fix** — `findLatestByDedupeKey` was broken in
PostgreSQL backend mode (already-parsed jsonb fed through a string-only
parser), so approved-grant redemption **never matched in production**,
minting duplicate requests. This explains the live DB state of 17
approved / 0 completed. *(Also cherry-picked to `main` as `a9b30013bb`,
since it is an active production defect on its own.)*
- **Review follow-ups** (`627f1b1fa8`) — operator-configured
provisioning privilege and a configurable grant TTL; see below.

## Review follow-ups

**Provisioning privilege is operator-configured, not role-derived.**
`isCallerPrivileged` had gone from `caller.reportsTo == null` (every
top-level agent privileged — permanent escalation by creating a
manager-less agent) to `caller.role === "ceo"`, which swapped an
implicit rule for a magic string: any agent config can claim that role,
while an operator who genuinely wants a privileged agent had no
supported way to say so. Privilege now derives solely from
`agentProvisioning.trustedAgentIds` / `trustedRoles` and fails closed
when settings are unresolvable.

It is also no longer forwarded to `resolveAgentProvisioningPolicy` as
`isPrivileged`, because that flag short-circuits ahead of
`alwaysApproveDelete` — a trusted caller was bypassing delete approval
entirely. The policy applies the same trusted rules itself, in the right
order. The function now governs only the org-chart escape hatch (acting
outside your own direct reports).

**Grant TTL defaults to 1 hour and is configurable.** Approval →
redemption is not instantaneous: an operator approving from their phone,
an engine restart, a queued lane, or a task waiting on a worktree all
routinely exceeded 15 minutes, after which the grant expired and the
agent silently re-requested. One hour remains far short of the
"redeemable forever" hazard the TTL exists to bound. Override via
`FUSION_APPROVAL_GRANT_TTL_MS` or `configureApprovalRequestTtls()`;
invalid overrides are ignored rather than widening the window to
infinity or collapsing it to zero.

## Behavior changes requiring operator review before rollout

1. `fn serve` requires a bearer token by default (`--no-auth` opts out);
unauthenticated clients get 401.
2. Agents can no longer run withheld destructive tools
(`fn_task_delete`, `fn_task_bypass_review`,
mission/milestone/slice/feature/workflow deletes, `experiment_finalize`,
`skills_install`). Operators keep them via CLI/dashboard. **This is the
incident fix.**
3. Agents get provisioning privilege only when the operator lists them
in `agentProvisioning.trustedAgentIds` / `trustedRoles`; the
provisioning gate is now live in production. Previously-implicit
privilege (top-level position, or a `ceo` role) no longer grants
anything on its own.
4. Decision replay 409s (was 200); pending approvals expire after 24h,
approved grants after 1h (configurable); bash approvals bind per exact
command.
5. Forged/body actors on decision, bypass-review, worktrunk routes →
403; `archive-all-done` requires `{confirm:true}` (external scripts
affected).
6. `fn_secret_get` approvals grant exactly one reveal (previously
granted nothing and looped forever); ACP approvals are execute-once
(previously infinite reuse).
7. Bash containment denies token/credential/approvals-API commands in
all agent sessions at every preset.

## Verification

Independently re-run against the branch, not just self-reported:

- 5 typechecks (core, engine, cli, dashboard `tsconfig.json` +
`tsconfig.app.json`) — clean
- `pnpm lint` — clean
- `pnpm test:gate` — 379 passed
- `pnpm build --force` — green (a plain `pnpm build` skips packages as
unchanged and does **not** compile the branch)
- `pnpm check:changesets` — clean
- ~650 file-scoped tests including new negative-path suites for the
decision boundary, which previously had **zero** test coverage

`packages/engine/src/__tests__/plugin-runner.test.ts` fails 56/80 —
**verified pre-existing**, reproducing identically at base commit
`93a403af67` on `main`. Not in the merge gate.

### A mutation check that failed to fail

Worth recording, because it nearly shipped an untested security fix. The
first mutation check on the provisioning change reintroduced the `ceo`
hardcode and **all 17 tests still passed** — the tests asserted through
the policy path, which can no longer observe `isCallerPrivileged` at
all, precisely because `isPrivileged` is no longer forwarded there.
Org-chart cases that do exercise the function were added; the hardcode
now fails exactly 1 of 19, and restoring is green. A green mutation run
is only meaningful if the test can actually see the code under test.

## Known limitations (stated, not papered over)

- The bash containment floor is string-matching: a cost-raiser, not a
sandbox. Quoting, encoding, `$HOME`, symlinks, or an interpreter
one-liner can evade it. The durable protection is the decision route
refusing agent-originated deciders — the filter is the belt, not the
braces.
- Approval expiry is lazy (evaluated at decide/complete/redeem), not
swept, so an expired pending row stays visible in lists until touched.
- The extension's require-approval path returns a pending message but
cannot suspend a pi session mid-turn; engine-side pause hooks cover
engine lanes only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

## Summary by CodeRabbit

* **Security**
* Hardened approval and permission gating with server-side decider
attribution, self-approval blocking, ownership checks, replay/race
protection, and status/TTL enforcement.
* Added fail-closed behavior for sensitive/unclassified tools and
sandbox provisioning approvals.
* Blocked credential/approval access via bash containment; plugin
destructive task operations now require explicit permission.
* **New Features**
* `fn serve` now defaults to bearer-token auth, with `--no-auth` as the
explicit opt-out.
* **Bug Fixes**
* Improved task move-source attribution (`moveSource: "user"`) and
tightened dashboard archive/bypass confirmation and operator attribution
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:37 -07:00
gsxdsm
92d82b7a17 fix(guard): the unwired-lane check reported 0 because its question was too weak (#2852)
A guard nobody has proven can fail is a number, not a check. This one
was returning a clean `[]` while **18** real unwired lane declarations
sat on `main` — including one it was specifically built to catch.

## The escape that found it

`diffSnapshots` in the glasses plugin:

```ts
opts: { notifyOnColumns: ReadonlySet<ColumnId>; completeColumnsByTaskId?: ReadonlyMap<...> }
...
const completeColumns = opts.completeColumnsByTaskId?.get(task.id);
const isComplete = completeColumns ? completeColumns.has(task.column) : task.column === "done";
```

**No file anywhere builds that map.** The conversion was decorative —
the literal decided every real poll, so on a renamed board the wearer is
notified of every column transition *except the card finishing*, the one
they care about. The name was already in the guard's vocabulary list,
the declaration was exported and optional; it satisfied every condition
the guard checks, and the guard said nothing.

## Three independent blind spots

| # | blind spot | why it mattered |
|---|---|---|
| 1 | `SCANNED_PACKAGES` omitted `plugins/` | plugins hold lane logic
like anything else — this one resolves workflow IRs and decides what
"finished" means |
| 2 | inline options-object types were not walked | only bare parameters
and *named* interfaces were. Whether a lane answer arrives as a
parameter, an interface property, or an inline field is a style choice —
**a check evadable by a style choice is decorative** |
| 3 | the mention rule was `source.includes(parameter)` **anywhere** |
satisfied by coincidence for any ordinarily-named parameter |

Fixing 1 or 2 alone would still have missed it: **measured on `main`,
the guard found 0 unwired across 1753 files, and 0 again across 2114
once `plugins` was added**, because the shape was invisible too.

### On (3), I proved it on myself

Renaming the unwired parameter from `completeColumnsByTaskId` to
`completeColumns` — a better name, chosen for good reasons — **silenced
the guard instantly**, because 15 unrelated production files declare a
local called `completeColumns`. The check had not been satisfied; it had
been switched off by a rename. That is exactly the failure the code
comment two lines up condemns, committed one edit later.

The fix is the cause, not a name blocklist: a file that never references
`diffSnapshots` cannot be the thing that wires `diffSnapshots`'s
options. Still deliberately loose — a co-occurrence test, not call-graph
analysis — which keeps the low false-positive rate that makes the guard
bearable while removing a false **negative** that scaled with how
ordinary a parameter's name was.

## The 17 this uncovered

Tightening (3) surfaced 17 further unwired declarations across core,
engine and dashboard. **Spot-checked, not assumed**:
`buildUnblockWeightMap` in `task-priority.ts` declares `terminalColumns`
and `reviewColumns`, and the only files that pass either are its own
tests — the production caller silently uses the built-in `{done,
archived}` default. That is the inert-conversion shape this module
exists to name.

They span three other batches, so they are recorded as a **ratcheted
baseline** in the shape `scripts/lifecycle-column-census.mjs` already
uses here — keyed on `file + parameter` so an unrelated edit above them
cannot manufacture a failure. A new one fails immediately; these can
only leave the list. Listing them beats pretending for another week that
they do not exist.

## The glasses fix

`completeColumnsByTaskId` -> a flat `completeColumns` set, matching its
sibling `notifyOnColumns` in the same options object, resolved **once
per poll** by `notifier.ts` via `resolveProjectColumnsForRoles`.
Project-scoped and not per task because this runs on a polling timer
over the whole board — a per-card workflow read would scale with the
board on every tick. Best-effort: a failed resolve leaves the diff on
its documented legacy default rather than dropping a poll.

Still gated by `alsoNotifyOnDone`, which the production caller passes as
`false`, so it remains unobservable at runtime. Wired anyway: the day
someone enables the flag the resolution must already be right — and now
the guard will say so if the wiring is removed.

## Revert proofs (measured, one per fix)

| revert | failure |
|---|---|
| unwire `notifier.ts` | baseline gains `plugins/…/diff.ts
completeColumns` |
| drop the inline-options walk | "covers an INLINE options-object type"
fails `expected [] to deeply equal [ 'completeColumns' ]`, and the repo
scan loses the glasses entry |
| drop the owner scoping | the repo scan loses **all 17** pre-existing
entries |
| restore `task.column === "done"` | both new `diff.test.ts` cases fail
|

The new diff cases assert **both** directions — a card in the resolved
lane fires, and a card in the legacy `done` does *not* once the caller
resolved other lanes. The second is what proves the resolved set
replaces the default rather than being unioned with it.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` — clean
- guard suite — 9/9; full glasses plugin — 188 passed across 19 files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:24:33 -07:00
gsxdsm
21e688e1f9 fix(glasses): ?columns= silently returned the WHOLE board on a renamed board (#2849)
A lane-literal defect the census cannot see — the literals are
**Set/Array members**, not comparisons — in a live plugin, with **no
test coverage on the filter at all**. That absence is how the inversion
survived.

## The bug

```ts
const ALLOWED_COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"];

function parseColumns(raw) {
  const parsed = raw.split(",").filter(v => ALLOWED_COLUMNS.includes(v));
  return parsed.length ? new Set(parsed) : null;   // ← `null` ALSO means "no filter requested"
}
```

On a board whose lanes are named anything else, every requested id is
discarded, `parsed.length` is `0`, and the function returns `null` —
**the same value it returns when no filter was requested**. The caller
then does `columns ? all.filter(...) : all`, so the route answers `200`
with the **entire board**.

Asking for one column returns all of them. Nothing in the response says
the filter was dropped. The list also still named `triage`, a column U11
deleted, so it described a board that no longer exists in either
direction.

## The fix

No allow-list can be correct here and none is needed. Valid ids are
whatever the project's workflows declare, and `Task["column"]` is
already `ColumnId = Column | (string & {})` — open by construction.
Filtering directly on the requested ids needs **no resolution source at
all**, which is why this literal, unlike the display ordering in
`cards.ts` (documented DELIBERATE-LITERAL: this package depends on
`@fusion/plugin-sdk` only, so there is no IR or store to resolve from),
is a defect rather than a deferral.

**Deliberate behaviour change:** `?columns=nonsense` now returns an
**empty deck** instead of the whole board. "Show me column X" answered
with every column is not a lenient default — it is the bug wearing a
200.

## Revert proof (measured)

Restore the allow-list and **both** new cases fail:

```
FAIL > filters on a RENAMED lane instead of silently returning the whole board
  expected [ { id: 'summary', …(5) }, …(2) ] to have a length of 2 but got 3
FAIL > answers an unknown column with an EMPTY deck, not with everything
  expected [ { id: 'summary', …(5) }, …(2) ] to have a length of 1 but got 3
```

Both directions are asserted on purpose: a filter that matched *nothing*
would satisfy the renamed-lane case alone while being equally broken.

## Not changed, and why

`plugins/fusion-plugin-even-cards` carries the **identical** bug — I
wrote the fix there first. It was removed from the pnpm workspace
(`858bab2`, "remove `fusion-plugin-even-cards` from the active workspace
package list to avoid duplicate user-facing integrations") and its
README names this plugin as its replacement, so it is not built, tested,
or shipped by anything. Fixing it would only imply it still runs.
Reverted and left alone.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion-plugin-examples/even-realities-glasses`) —
clean
- full plugin suite — 188 passed across 19 files
- census `--strict` — exit 0 (unchanged: these literals are Set members,
invisible to it)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:04:43 -07:00
gsxdsm
d252c4e0cf fix(glasses): finished cards were crowding live work off the deck on a renamed board (#2854)
Not a labelling bug. `boardToDeck` filters finished cards out of a deck
that is **capped at `maxCards`**, so on a board whose complete lane is
named anything but `done`/`archived`, every finished card **consumes a
slot and displaces live work**. The wearer sees fewer live tasks the
more the team finishes — which reads as "nothing is happening", not as a
defect.

```ts
const active = tasks.filter((task) => task.column !== "archived" && task.column !== "done")
  .sort(...)
  .slice(0, Math.max(0, maxCards - 1));   // ← the cap is what makes this bite
```

## Correcting an earlier DELIBERATE-LITERAL call

This site was marked **DELIBERATE-LITERAL — no resolution source in this
package**. That reasoning was inherited from the **deprecated**
`fusion-plugin-even-cards`, which depends on `@fusion/plugin-sdk` alone.
**This** package lists `@fusion/core` as a runtime dependency and
already calls `resolveWorkflowIrById` / `resolveLifecycleColumns` in
`quick-capture.ts` and `agent-actions.ts`.

The half that *was* right: `cards.ts` genuinely cannot resolve anything
— it takes plain `Task` rows. So the lane answer becomes a parameter and
the **route** supplies it: one `listWorkflowDefinitions()` read per
request regardless of board size, matching how `?columns=` already
treats the board as a single pool. Best-effort, so a failed resolve
leaves the deck on its documented default rather than failing the
request — a slightly-wrong deck beats no deck on a pair of glasses.

`terminalColumns` is in the `unwired-lane-parameter` vocabulary, so an
unwired version of this parameter fails the build instead of sitting
here looking converted. (That guard only learned to see this class in
#2852.)

## Revert proof (measured)

```
FAIL > does not let a card in a RENAMED complete lane displace live work
  expected [ 'summary', 'FN-SHIPPED' ] to deeply equal [ 'summary', 'FN-LIVE' ]
```

`maxCards: 2` in the fixture is load-bearing — one summary card plus
exactly one task slot, so an unfiltered finished card **displaces** the
live one rather than merely joining it. A larger cap would let both
through and the case would pass either way.

The second case pins the degraded default (legacy `done`/`archived`
still filtered when the caller resolved nothing), since most boards
never rename anything.

## Not changed

`cards.ts:187` — `task.column === "in-review" ? "In review" : "Moved
in"` in the notification title. Genuinely cosmetic: a review card on a
renamed board reads "Moved in" instead of "In review". No slot is lost
and no decision is made from it, and `notificationCard` has no options
object to thread a lane answer through, so converting it means widening
a signature for a label. Left with the finding stated rather than
silently swept in.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion-plugin-examples/even-realities-glasses`) —
clean
- full plugin suite — 188 passed across 19 files
- unwired-lane guard — 6/6, no new entries
- census `--strict` — exit 0

Touches `board-routes.ts`, which #2849 also edits (different hunk —
`parseColumns` vs the handlers), so the two merge cleanly in either
order.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:02:11 -07:00
gsxdsm
ed6d54485b glasses plugin: the review actions could never resolve a review lane (4 guards + 3 invisible destinations) (#2816)
Four agent actions still keyed on literals, with three census-invisible
`moveTask` destinations between them. `agent-actions.ts` already had
`laneContext`/`destination` from an earlier partial conversion — these
were simply never migrated.

## Census

| file | main | here |
| --- | ---: | ---: |
| `plugins/fusion-plugin-even-realities-glasses/src/agent-actions.ts` |
4 | **0** |

Plus 3 hardcoded `moveTask` destinations the census cannot see
(`requestReview` → `in-review`, `returnToAgent` → `todo`, `retryTask` →
`todo`).

## The real finding: this plugin could never resolve a review lane

`resolveLifecycleColumns` keys its `review` role on the
**`mergeOrchestration` trait alone**. A board whose review column
carries only `merge-blocker` and/or `human-review` — the common custom
shape, since `merge` is opt-in — resolves **no review lane at all**.

So every review-gated action here (`requestReview`, `acceptReview`,
`returnToAgent`, `retryTask`) compared against `undefined` and **refused
every card**, and `requestReview` had nowhere to move one. This is not a
regression from converting them; it is why they *could not* be converted
with `lanes.review` as-is.

Converting the four guards without noticing would have shipped four
actions that fail closed on exactly the boards this program exists to
support — a conversion that looks complete, passes its suite, and makes
the plugin useless on a custom board.

**Widened in `laneContext`, not in the shared resolver.**
`resolveLifecycleColumns` is consumed well beyond this plugin, and its
`review` role deliberately means "the merge-orchestration column" for
the merge queue. The gap is already recorded in
`notification-renamed-lifecycle-columns.test.ts` and in #2807 —
reconciling the two definitions is a core-level decision, not one to
take from a plugin. `mergeBlocker` is preferred over `humanReview`
because a card cannot leave a merge-blocking column until the gate
clears, which is the closer analogue of the legacy `in-review`.

## The suite caught an over-reach of mine

My first version put a blanket `if (degraded) conflict(...)` at the top
of `retryTask`, which broke a pinned invariant the test names outright:
**"a degraded workflow does not block retries that move nothing."** The
status-only retry just clears fields; refusing it because the workflow
could not be read breaks a recovery that needs no lane at all.

Degraded now blocks only the branches that actually **move**. Same
reasoning applied to `acceptReview`, which also moves nothing. The
existing `startWork` convention — conflict on degraded — is right
precisely *because* it moves.

## Ordering

`returnToAgent` and `retryTask` now resolve their destination **before**
the field clear. Both cleared first, so a rejected move left the
assignee and status — or the worktree, branch and base refs — nulled
with the card exactly where it was. That is the fifth instance of this
half-applied shape in the audit, and it is rule 3 in the class doc.

## Revert results (measured, each independently)

| conversion | reverted → |
| --- | --- |
| `requestReview` destination | 1 failed — moves to the literal
`in-review`, which this workflow does not declare |
| `returnToAgent` destination | 1 failed — moves to the literal `todo`,
same |

Plus a non-vacuous companion: a renamed card *not* in the wip lane must
still be refused by `requestReview`, so a gate admitting everything
would not pass.

## Verification

- Plugin suite — **186/186**
- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `tsc` on the plugin — clean
- `pnpm lint`, `check:changesets`, census `--strict` — all clean (run
explicitly)
2026-07-30 12:55:38 -07:00
gsxdsm
4184fde08d batch-cli-plugins: 7 guards — 3 were a foreign enum, and fn pr create refused every card on a renamed board (#2775)
`batch-cli-plugins` — the u7 worker's mega-batch: `packages/cli` +
`plugins` + anything left.

## The batch is 7 guards, and 3 of them are not guards at all

The census's per-file list gives this batch seven sites. Reading them,
**three are a foreign vocabulary the census matches on the string
alone**:

| file | site | verdict |
|---|---|---|
| `plugins/fusion-plugin-reports/store/report-store.ts` | `next ===
"archived"` ×2 | **not a column** — `next` is a `ReportStatus` |
| `plugins/fusion-plugin-reports/store/report-types.ts` | `to ===
"failed" \|\| to === "archived"` | **not a column** — same enum, its own
terminal states |

The reports plugin has its own status lineage (`draft → generating →
review_* → approved → published`, plus `failed`/`archived`) that shares
two spellings with the lifecycle vocabulary. A report is not on a board
and has no workflow, so resolving an IR there would answer a question
nobody asked. All three are marked `DELIBERATE-LITERAL` with the reason
at the site.

**This cuts the other way from #2763.** That PR establishes the census
total as a *floor* (25 membership predicates it structurally cannot
see). This is the opposite error in the same number: a foreign enum
inflating it. The total is neither a ceiling nor a floor — it is an
estimate with error in both directions, and the per-file list is worth
reading before trusting a file's count.

## Converted (census before → after, per file)

| file | before | after |
|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | **0** |
| `plugins/…/even-realities-glasses/notifications/diff.ts` | 1 | **0** |
| `plugins/…/reports/store/report-store.ts` | 2 | **0** (deliberate) |
| `plugins/…/reports/store/report-types.ts` | 1 | **0** (deliberate) |

### `fn pr create` refused every card on a renamed board

The live defect in this batch. The gate was `task.column !==
"in-review"`, and its error told the operator to move the task to a
column their board does not have:

```
Error: Task must be in 'in-review' column to create a PR (current: signoff)
```

There is no way to satisfy that short of renaming the workflow back. Now
resolved through core's `resolveReviewColumns`, and the message names
the lanes that actually exist.

**The SET, not `lifecycle.review`.** A board may declare more than one
review lane, and a card parked in a `humanReview`-only lane is still a
card you can open a PR from. A single-id answer keeps refusing those —
the same narrowing #2728's review caught in the CLI retry gate, which is
why the test pins both lanes.

## Skipped, with the reason

**`plugins/fusion-plugin-even-cards` (2 guards) — blocked on packaging,
not on analysis.** The defect is real: `boardToDeck` filters with
`column !== "archived" && column !== "done"`, so on a renamed board
every finished card stays in the deck, fills `maxCards`, and pushes the
active cards off the display. The wearer sees a board that never
finishes anything.

I implemented the fix and **reverted it**: this plugin is not in
`pnpm-workspace.yaml` and depends only on `@fusion/plugin-sdk` — it has
no `@fusion/core` dependency, so the route cannot reach
`resolveTaskLifecycleColumns`. Adding one is a packaging change, which
this program's rules put out of scope. Shipping only the injected
parameter without a caller was the alternative, and that is precisely
the decorative conversion #2759 documents: the census would drop by 2
and the deck would keep the bug.

Flagged for whoever owns the plugin's dependency surface. The glasses
plugin next door *does* depend on `@fusion/core`, so this is a
one-plugin problem, not a plugin-wide one.

## Honest note on the glasses conversion

`diff.ts`'s completion branch is **currently unreachable** — the only
production caller (`notifier.ts`) passes `alsoNotifyOnDone: false`. So
that conversion changes nothing at runtime today. It is converted rather
than marked deliberate because the literal is not deliberate: it is
wrong, and would ship the bug the day someone turns the flag on. Stated
here rather than left for a reviewer to discover.

## Verification

- new CLI suite **4 passed**; `pr-command` + `pr-automerge-cleanup` +
`bin-pr-router` **35 passed**
- glasses plugin **181 passed (19 files)** · reports plugin **110 passed
(23 files)**
- `pnpm test:gate` — **158 / 10 / 487 / 71** · `pnpm lint` clean ·
`--strict` exits 0

**Revert proof, measured.** Restoring `if (task.column !== "in-review")`
fails 3 of the 4 new cases (`process.exit:1` on both renamed lanes, and
the refusal message reverts to naming `in-review`). The
unresolvable-workflow case keeps passing — it is the legacy path — so
the negative cases alone do not pin the fix and all four are required.

## Handoff to `batch-engine`

`packages/engine/src/project-engine.ts` **5 → 0** is finished, green,
and pushed as `handoff/project-engine-lanes-for-batch-engine`
(`34dbb35209`) for the capacity worker to cherry-pick — it is
engine-owned, not mine to land.

It fixes two live defects: a card that **had merged** reported as a
failed merge to `fn task merge` and the dashboard button (`merged:
finalTask?.column === "done"`), and the three post-finalize `column ===
"done" && mergeConfirmed` fast-path checks, which on a renamed board
sent an already-landed card down the bounce path — re-queued,
retry-counted, and in the capped branch parked `failed` with its merge
sitting on main. Plus `hasAutoHealableVerificationBufferFailure`, which
returned false for every card on a renamed board, so a buffer-overflow
verification failure was never auto-healed.

8 new tests, revert-proven (restoring the literal fails 4 of 8), gate
green.

---

## Completion pass (u7) — the batch is now closed

Two workers converged on this branch. I rebased onto the first-landed
commit rather than force-pushing over it, took its wording wherever the
conclusion was identical, and added what was missing.

### What this pass added

1. **`even-cards` (2 sites)** — the only in-scope file the first pass
left open. Marked DELIBERATE-LITERAL: the package depends on
`@fusion/plugin-sdk` only, and the SDK does not re-export the lifecycle
role helpers, so there is no IR, no store, and no trait flags to resolve
*from*. Fixing it properly means the SDK exposing role flags on the task
shape it hands plugins — a structural change, out of scope, and recorded
at the site as the correct home. Live consequence is cosmetic: a
finished card on a renamed board shows as active in the glasses deck.

2. **A red test in the `fn pr create` conversion.** The incoming version
rendered `Task must be in 'in-review' to create a PR`, dropping the word
`column`. `task.test.ts:3422` pins `must be in 'in-review' column`, so
that hunk failed `runTaskPrCreate > exits with error when task not in
in-review column`. Restoring the word makes the single-lane message
**byte-identical** to the pre-conversion one, which is what a vocabulary
conversion should be — the guard's own test now passes unmodified.
Marked at the site so it is not "simplified" back.

3. **Duplicate imports** — the two independent conversions each added
`resolveWorkflowIrForTask`/`resolveReviewColumns`, which does not
compile. Deduped in its own commit.

### Census

Measured with `--json` on `origin/main` and on this branch.

| file | before | after | action |
|---|---|---|---|
| `packages/cli/src/commands/pr.ts` | 1 | 0 | converted |
| `plugins/fusion-plugin-reports/src/store/report-types.ts` | 1 | 0 |
marked |
| `plugins/fusion-plugin-reports/src/store/report-store.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-cards/src/cards/board-cards.ts` | 2 | 0 |
marked |
| `plugins/fusion-plugin-even-realities-glasses/.../diff.ts` | 1 | 0 |
marked |

Backlog **415 → 408** (−7, exactly the in-scope count). Deliberate **40
→ 46** (+6 marked); 6 + 1 converted = 7. `--strict` exits 0. **Nothing
remains in `cli` + `plugins` + everything-else — there is no follow-up
batch behind this one.**

### One note on the `even-realities-glasses` site

Worth recording beyond "cannot resolve": its only production caller
(`notifier.ts:80`) passes `alsoNotifyOnDone: false`, so that arm is
**unreachable today**. Converting it could not have changed observed
behaviour either way.

### Verification (measured, on the merged branch)

- `pnpm --filter @runfusion/fusion exec tsc --noEmit` → exit 0
- `pnpm lint` → 0 errors
- CLI `task.test.ts` → 144 passed, including the `runTaskPrCreate` guard
test
- `@fusion-plugin-examples/reports` → 110 passed;
`even-realities-glasses` → 181 passed

**Pre-existing failures, not from this change:** the 5
`runTaskImportFromGitHub` / `runTaskImportGitHubInteractive` tests fail
identically on `origin/main` — verified by stashing this diff and
re-running (5 failed / 144 passed both ways).

---

## Census audit (unowned follow-on)

After closing the batch scope I audited whether the **392**
column-backlog number is inflated by foreign vocabularies — the class
this batch found in the reports plugin, where `"archived"` is a
`ReportStatus` rather than a board lane. If that class were widespread,
every remaining batch would be chasing sites that must not be converted.

**It is not. The number is real.** A receiver-level pass over all 392
column-category sites found exactly **3** false positives, all in
`plugins/fusion-plugin-reports` (`next`, a `ReportStatus`), all now
marked in this PR.

What was checked and cleared:

- **Property-reached foreign enums** (`step.status`, `feature.status`,
`mission.status`) — already correctly bucketed into the separate
`status` category (185), not the column backlog. Verified against
`merge-queue-ops.ts`: 11 lifecycle-spelled literals in the file, census
counts **1**, and that 1 is the genuine `.column` guard.
- **Bare step-status variables** (`status`, `currentStatus`,
`liveStatus` compared to `"done"`/`"skipped"`) — likewise excluded.
- **Every other receiver in the backlog** — `to`, `from`, `column`,
`fromColumn`, `toColumn`, `latestColumn`, `state`, `preArchiveColumn`.
All resolve to genuine task columns. `executor.ts`'s 15 sites were
spot-checked line by line: all 15 are real.

The gap the classifier genuinely cannot close is a foreign enum held in
a **bare variable** — the receiver name carries no type information, so
`next === "archived"` is indistinguishable from a lifecycle guard by AST
alone. That is why the reports sites need a marker rather than a
classifier fix, and it is now documented in
`lifecycle-column-census-ast.mjs`'s header alongside the measured scope,
so the remaining batches do not re-run this hunt.

Census tests: **43 passed**. The change is comment-only.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:38:39 -07:00
gsxdsm
dca20496f4 consolidate/u7: plugins to zero + 8 executor rebound guards + resume lanes (supersedes #2607, #2635, #2640) (#2644)
Consolidation branch for U7, per the new one-branch working mode.
**Supersedes #2607, #2635, #2640** — the three of my PRs that were stuck
on review threads. My other seven (#2602, #2605, #2606, #2611, #2621,
#2628, #2633) are green with **zero unresolved threads** and are
deliberately left alone for the merge sweep.

## What is in here, file by file

| file | change | guards before → after |
|---|---|---|
| `plugins/…/glasses/src/agent-actions.ts` | gates, destinations and
degraded-resolution refusal all resolve from the task's own workflow | 2
→ 0 |
| `plugins/…/glasses/src/quick-capture.ts` | accepted capture columns
come from the board; default no longer names the deleted column | 1 → 0
|
| `plugins/…/glasses/src/settings.ts` | quick-capture default was
`triage`, the column #2515 removed | (assignment, uncounted) |
| `plugins/…/dependency-graph/src/GraphTaskNode.tsx` | redundant column
condition deleted | 1 → 0 |
| `packages/engine/src/executor.ts` | 8 rebound guards compare the
resolved column; 4 resume-eligibility literals share one resolver | 151
→ 143 (+4 off-bar) |
| `packages/engine/src/__tests__/` | 4 new suites, 26 cases | — |

`plugins/` reaches **zero** column guards with this branch.

## The three threads it closes

**#2607 — five findings, all mine, all the same rule.** I kept
*qualifying* a legacy-id fallback instead of removing it:

| attempt | rule | hole review found |
|---|---|---|
| 1 | fall back to `todo` when the role is missing | moved cards to
phantom columns |
| 2 | …only if the workflow **declares** `todo` | aliased **review**
lane named `todo` |
| 3 | …and only if no other role is assigned to it | **traitless**
parking column named `todo` |

The qualifications were the mistake. Once `resolveLanes` returns a lane
set the workflow *has* a column vocabulary, so "no column carries the
hold trait" is a complete answer — refuse. `destination()` is two lines
now, with no aliasing surface left to qualify.

Plus a sixth, which is a genuinely different state: **degraded
resolution is indistinguishable from the default board.**
`resolveWorkflowIrForTask` is total by design — a missing definition
silently returns the *default* coding IR — so a card on a custom board
whose definition could not be read resolved to `todo`/`in-progress`.
`undefined` lanes cannot express that (it means "no workflow at all",
where the legacy ids *are* the answer). The actions now refuse with 409.
#2618 would replace this check with resolver provenance; it is not
merged, so this does not depend on it.

**#2635 — "seven rebound sites remain untested."** Fair; my "same shape"
note was an assertion, not coverage. Seven of the eight need a live
graph run to reach, so the *shape* is pinned instead: a static check
that no guard in front of a rebound move compares against a column
literal, with a vacuity case (the same detection run against the
original shape) and a match-count floor (≥8), because a guard reporting
success on zero matches is worse than no guard.

**#2640 — duplicate workflow resolution.** Framed as I/O; it is also a
correctness bug. Eligibility and re-entry are two halves of one decision
and resolved the workflow separately, so a workflow edit landing between
them has the halves reading *different boards*. Now one caller-owned
memo per decision — caller-owned because a process-lifetime cache would
have to guess when a mid-flight workflow edit invalidates it.

## Behavioural findings, not tidying

- **The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** `promotedFromPlannerColumn` was false
on a renamed board, so finished work resting in planning was never
promoted; the code fell through to a review handoff that role adjacency
rejects, and the card stayed stuck with its work complete.
- **Rebound guards could not see the column their own move targeted.**
U5b converted the move target; the eight `column !== "todo"` checks in
front of it were left literal, so on a renamed board the engine moved a
card into the column it was already in — and `moveTaskInternal` runs
reset-on-entry on every real move, so at the `preserveProgress: false`
site it reset step progress a second time.
- **The FN-1404 `task:move` audit row was lying**, recording `to:
"todo"` while the move target was resolved. A run-audit trail that
disagrees with the move it describes is worse than none. Not a
comparison, so no census counts it.
- **A task interrupted by an engine pause never resumed on a renamed
board** (off-bar, `in-review`/`in-progress` literals): four comparisons
decided one question and had to agree; two of them disagreed on a
renamed board, so re-entry silently never fired.

## Revert proofs, isolated per site

| reverted | result |
|---|---|
| `destination()` back to attempt 3 | 3 of 38 fail |
| degraded-resolution refusals removed | 2 of 42 fail |
| capture set back to the legacy five | 2 of 3 fail (renamed-board
suite) |
| forward exclusions → literals | 1 of 14 fails |
| missing-wip refusal removed | 2 of 14 fail |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| promotion target → `"in-progress"` | 3 of 7 fail |
| one rebound guard → `!== "todo"` | 1 of 3 fails (static shape) |
| resume lanes → legacy trio | 1 of 5 fails |

Every conversion is paired with a negative — a forward move, a
not-a-planner-lane card, a default-lineage card, an unresolvable
workflow — so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Commit discipline

Twelve commits, each one thing: the code move (`resolvePlannerLanes` out
of `triage.ts`) is separate from every behavior change, and each review
fix is its own commit with its own revert proof.

## Verification

- `pnpm test:gate` **71/71**
- 162/162 across the glasses plugin's 19 files; 26/26 across the four
new engine suites
- engine + glasses typecheck clean; `pnpm lint` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)


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

## Summary by CodeRabbit

* **Bug Fixes**
* Engine recovery and retries now work correctly with renamed or
customized workflow columns.
  * Tasks in manual-intake columns are no longer automatically planned.
* Agent actions and quick capture now respect each board’s declared
columns and lifecycle stages.
* Awaiting-approval tasks are recognized regardless of their current
column.
* Command Center SDLC funnel stages now accurately reflect customized
workflows.

* **Documentation**
* Added guidance for safely changing workflow-column logic and
interpreting lifecycle-column checks.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:52:55 -07:00
gsxdsm
a242f1b449 fix(FN-7952): migrate bundled plugins to PostgreSQL (#2111)
## Summary

Bundled plugins now persist shared runtime state in project-scoped
PostgreSQL tables instead of maintaining independent SQLite authority.
Reports, CLI Printing Press, Compound Engineering, Roadmap, Even
Realities, and WhatsApp all follow the same ownership and startup
contract as Fusion core.

## Design decisions

- Plugin schema hooks run through the host’s PostgreSQL owner and
enforce project isolation.
- The SDK exposes the host contract needed by bundled plugins without
importing engine internals.
- Legacy Roadmap ownership fixtures use the supported empty-owner
sentinel, preserving current composite primary/foreign keys while
exercising backfill behavior.
- The lockfile travels with the Even Realities PostgreSQL dependency so
packaged installs remain reproducible.

## Validation

- All six affected plugin builds pass.
- Affected plugin suites pass: 773 tests across Printing Press, Compound
Engineering, Even Realities, Reports, Roadmap, and WhatsApp.
- `pnpm test:gate` passes all 478 gate tests.
- This PR changes 40 files.

## Stack

- Depends on #2110 → #2109 → #2108.
- The documentation/release PR completes the stack.

Related: #2105


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

## Summary by CodeRabbit

* **Breaking Changes**
* PostgreSQL is now required for runtime storage; SQLite files are used
only as one-time migration inputs.
  * The legacy `FUSION_NO_EMBEDDED_PG` fallback has been removed.

* **New Features**
* Added project-isolated PostgreSQL storage for plugins, reports, tasks,
notifications, and other plugin data.
  * Added agent tools for reports and CLI service drafts.
  * Added PostgreSQL schema initialization support for plugin authors.

* **Bug Fixes**
  * Improved migration and recovery of legacy plugin state.
* Prevented cross-project data access and strengthened transactional
schema updates.

* **Documentation**
* Updated storage, migration, deployment, plugin authoring, CLI, and
dashboard guidance for PostgreSQL.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 00:27:59 -07:00
gsxdsm
0b1c8fb56b fix(ci): widen Column to ColumnId in plugins + drop unused import after #1403 widening
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:40:10 -07:00
Fusion
007efcab0b feat(FN-3970): consolidate Even plugin APIs into unified package with webho
Collapsed the Even plugin architecture into a single unified plugin by merging board card routes and replacing the transport stub with a real webhook implementation, removing the separate cards plugin from the workspace. The new unified plugin (`fusion-plugin-even-realities-glasses`) now exports con

Fusion-Task-Id: FN-3970
2026-05-11 06:47:42 -07:00
Fusion
c744ef83c2 fix(FN-3794): pass unload context and isolate WhatsApp sessions per project
- Extend PluginOnUnload to receive runtime context and wire ctx through plugin-loader unload hooks
- Scope WhatsApp chat plugin connections by project root to avoid cross-project session leakage
- Update plugin authoring docs and add a patch changeset for @runfusion/fusion
- Align plugin test suites across WhatsApp and example/runtime plugins with the new onUnload context contract

Fusion-Task-Id: FN-3794
2026-05-09 10:31:04 -07:00
Fusion
fe5148cc19 feat(FN-3744): add agent action routes, tools, and roadmap plugin extractio
Merges agent lifecycle tools (create_agent, delete_agent) with full API routes, web fetch tool implementation and tests, and full roadmap UI extraction into a bundled plugin — including schema hook, chat scroll fixes, and autostash orchestrator cleanup. Also adds mesh auth snapshot replication acros

Fusion-Task-Id: FN-3744
2026-05-08 19:59:43 -07:00
Fusion
089c139bad feat(FN-3743): add polling task notifications for even realities plugin
Commits merged:
- feat(FN-3743): add polling task notifications for even realities plugin

Files changed:
.../fusion-plugin-even-realities-glasses/README.md |  27 +++
 .../package.json                                   |   2 +
 .../src/__tests__/cards.test.ts                    |  82 +++------
 .../src/__tests__/diff.test.ts                     |  76 ++++++++
 .../src/__tests__/notification-card.test.ts        |  40 +++++
 .../src/__tests__/notification-routes.test.ts      |  78 ++++++++
 .../src/__tests__/notification-store.test.ts       |  71 ++++++++
 .../src/__tests__/notifier.test.ts                 | 138 +++++++++-----
 .../src/__tests__/transport.test.ts                |   4 +-
 .../src/agent-actions.ts                           |   4 +-
 .../src/cards.ts                                   | 127 ++++++++++---
 .../src/index.ts                                   |  22 ++-
 .../src/notifications/diff.ts                      |  77 ++++++++
 .../src/notifications/store.ts                     |  46 +++++
 .../src/notifications/types.ts                     |  19 ++
 .../src/notifier.ts                                | 199 +++++++++++++--------
 .../src/routes/notification-routes.ts              | 103 +++++++++++
 pnpm-lock.yaml                                     |  31 +++-
 18 files changed, 929 insertions(+), 217 deletions(-)

Fusion-Task-Id: FN-3743
2026-05-08 16:35:22 -07:00
Fusion
8c112ffda8 feat(FN-3742): unify agent action gating classifications across permanent a
Merges a significant refactor of agent action gating into a unified `gating-classifications.ts` module shared across permanent agents and executor gating logic, plus new glasses quick-capture support with dedicated routes and parser, and a fix restoring step progress visibility during task execution

Fusion-Task-Id: FN-3742
2026-05-08 13:59:14 -07:00
Fusion
cafaa68a5f feat(FN-3738): add fusion-plugin-even-realities-glasses plugin package with
Implements a new Fusion plugin package (`fusion-plugin-even-realities-glasses`) providing settings schema, a Fusion HTTP API client, cards, quick capture actions, a notifier, and transport stub — plus plugin routes and lifecycle hooks wired into the pi extension. The branch concludes with a small fi

Fusion-Task-Id: FN-3738
2026-05-08 12:05:46 -07:00