Files
fusion/packages/engine/src/tool-availability.ts
gsxdsm 14c73ab727 U11 [tool-availability + skill-resolver + cli/task]: name the 3 literals that are NOT columns (48 -> 45) (#2619)
**Taking: `engine/tool-availability.ts`, `engine/skill-resolver.ts`,
`cli/commands/task.ts`** — the three census hits that are not board
columns.

## Census

| file | before | after |
|---|---:|---:|
| `packages/engine/src/tool-availability.ts` | 1 | **0** |
| `packages/engine/src/skill-resolver.ts` | 1 | **0** |
| `packages/cli/src/commands/task.ts` | 1 | **0** |
| **repo total (comment-stripped)** | **48** | **45** |

## These are not lifecycle guards — converting them would have been
wrong

- **`tool-availability`** — `surface: "triage" | "executor"` is an
**agent lane**. The lane that writes specs keeps its name whatever the
board calls its planning column. Resolving it from a workflow IR would
make an agent's prompt depend on board configuration.
- **`skill-resolver`** — `sessionPurpose === "triage"` is an **agent
role**. Same argument: a role doesn't move when a board renames a
column.
- **`cli task list`** — the glyph chain distinguished **active** columns
from the rest and nothing else; all four active ids mapped to the same
`●`.

Each is now named (`AgentResearchSurface`,
`ROLE_FALLBACK_SESSION_PURPOSES`, `ACTIVE_COLUMN_GLYPH_IDS`) so the next
person working the census sees at a glance that they're out of scope,
rather than re-deriving it as I had to.

## A real divergence my own equivalence test caught

I first wrote the glyph as the tempting inverse:

```ts
const dot = col === "done" || col === "archived" ? "○" : "●";
```

That is equivalent across all six lifecycle ids and **not** equivalent
for anything else — the original chain fell through to `"○"` for an
unrecognised id, while the inverse renders it as **active**. The loop
only walks the six `COLUMNS` today, so nothing would have caught it in
practice; a renamed workflow reaching this code later would have
silently changed how its columns render.

Shipped as an explicit ACTIVE set that mirrors the fallthrough exactly.
The test asserts equivalence over the six ids **and** over unknown ids,
which is where the difference lives.

That's the point of testing a "pure rename" at its edges rather than
only where it's currently exercised.

## Verification

- 71 tests green across skill-resolver / heartbeat-skills /
tool-availability / the new equivalence suite
- merge gate green (482 + 132 + 10), engine + CLI tsc clean, lint clean

## Note on the remaining count

Of the 45 left, `replan-target.ts` (2), `board-workflows.ts` (2) and
`archive-planning.ts` (1) show up in a **raw** grep but are **0** real —
every hit is inside a comment. A raw grep reports 53; comment-stripped
is 45. Real remaining work concentrates in `self-healing.ts` (11),
`register-task-workflow-routes.ts` (6), and the parked `moves.ts` /
`default-workflow-hooks.ts` (9).

No changeset: `@fusion/engine` is private; the CLI change is
display-identical.

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


---

## ⚠️ Read before merging — these are ROLE renames, not column
conversions

The coordinator's hand classification says several literals in this PR
"must be left exactly as they are" because they compare an **agent
role**, not a task column, and resolving them to a column trait would be
a bug. **I agree, and this PR does not do that.**

What it does: replaces a bare `=== "triage"` with a **named role
predicate** — `isPlanningAgentLane`, `AgentResearchSurface`,
`ROLE_FALLBACK_SESSION_PURPOSES`. Behaviour is **byte-identical** for
every input. No IR is consulted, no trait is resolved, no column is
involved.

The reason to keep it rather than revert: the danger isn't the literal,
it's that nothing at the call site tells the next person `"triage"` here
means a *lane*. A list of exceptions maintained elsewhere only helps
someone who finds the list; a call named `isPlanningAgentLane` helps
whoever is reading the line. It also shrinks what the #2630 ratchet's
ignore list has to carry.

Reversible: if the preference is to leave the literals untouched, say so
and I'll strip these hunks — but then the ratchet's ignore list must
carry **all twelve** role sites or it can never reach zero, because
those six are correct code.

## Classification finding

Bucketing by the **receiver** of the comparison (not the literal)
mechanically separates guards from roles, and it found **six role sites
currently listed as "real column guards"**:

| site | receiver | what it actually is |
|---|---|---|
| `usage-limit-detector.ts:144, 207` | `agentType` | agent type — the
column test one line above is *already* trait-driven |
| `skill-resolver.ts:432` | `sessionPurpose` | session purpose |
| `tool-availability.ts:32` | `surface` | agent surface (`"triage" \|
"executor"`) |
| `effective-model-resolution.ts:148` | `entry.agent` | agent-log lane |
| `useTasks.ts:162` | `entry.agent` | agent-log lane |

So the real bar is roughly **39**, not 45. The rule that found all
twelve without judgement calls: `column`/`toColumn`/`taskColumn`/`c` are
guards; `role`/`agent`/`agentType`/`surface`/`sessionPurpose` are not.
Worth teaching #2630's ratchet directly.
2026-07-29 23:10:11 -07:00

57 lines
2.7 KiB
TypeScript

import type { Settings } from "@fusion/core";
import { isResearchExperimentalEnabled } from "@fusion/core";
import type { PluginRunner } from "./plugin-runner.js";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
export function isResearchToolSurfaceEnabled(settings: Partial<Settings> | undefined): boolean {
return isResearchExperimentalEnabled(settings);
}
export function getResearchToolSurfaceStatus(settings: Partial<Settings> | undefined): {
enabled: boolean;
reason: "experimental-disabled" | "enabled";
} {
const enabled = isResearchToolSurfaceEnabled(settings);
return {
enabled,
reason: enabled ? "enabled" : "experimental-disabled",
};
}
const TRIAGE_RESEARCH_GUIDANCE = `## Research tools
When spec work needs missing domain context, you may use research tools (\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`, \`fn_research_retry\`). Keep research bounded to the task at hand, prefer concise queries, and write durable findings into task documents when useful.
If research is unavailable or unconfigured, continue planning with repository context and clearly note assumptions.`;
const EXECUTOR_RESEARCH_GUIDANCE = `## Research tools
When implementation needs external context, you may use research tools (
\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`, \`fn_research_retry\`) to run bounded research.
Keep runs focused and short, and persist durable conclusions into task documents (for example key="research").
If research is disabled or providers are not configured, use the actionable tool response and continue with available local context.`;
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-13:20 (U11 census hygiene):
`"triage"` HERE IS AN AGENT LANE, NOT A BOARD COLUMN — the lane that writes specs,
which keeps its name whatever the board calls its planning column. It appeared in
the `=== "triage"` census purely because it is the same word, and trait-converting
it would be actively wrong: it would tie an agent's prompt to a workflow's column
vocabulary.
Named and table-driven so the distinction is legible and the literal no longer
reads as a lifecycle guard to the next auditor.
*/
export type AgentResearchSurface = "triage" | "executor";
const RESEARCH_GUIDANCE_BY_SURFACE: Record<AgentResearchSurface, string> = {
triage: TRIAGE_RESEARCH_GUIDANCE,
executor: EXECUTOR_RESEARCH_GUIDANCE,
};
export function getResearchGuidanceForSurface(surface: AgentResearchSurface): string {
return RESEARCH_GUIDANCE_BY_SURFACE[surface];
}
export function getEnabledPluginTools(pluginRunner: PluginRunner | undefined): ToolDefinition[] {
if (!pluginRunner) return [];
return pluginRunner.getPluginTools();
}