diff --git a/packages/engine/src/__tests__/lane-vs-column-vocabulary.test.ts b/packages/engine/src/__tests__/lane-vs-column-vocabulary.test.ts new file mode 100644 index 0000000000..9eb7dc4377 --- /dev/null +++ b/packages/engine/src/__tests__/lane-vs-column-vocabulary.test.ts @@ -0,0 +1,55 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-13:30 (U11 census hygiene): + +Three `"triage"` literals matched the lifecycle-column census and are NOT board +columns. Converting them to trait resolution would have been actively wrong, so +this pins what they actually are — and pins the refactors as equivalent, because +"it's only a rename" is exactly the claim that should be tested rather than +asserted. + + tool-availability `surface: "triage" | "executor"` is an AGENT LANE. The lane + that writes specs keeps its name whatever the board calls its + planning column; tying it to a workflow's vocabulary would + make an agent's prompt depend on board configuration. + + skill-resolver `sessionPurpose === "triage"` is an AGENT ROLE. Same argument: + a role does not move when a board renames a column. + + (The CLI task-list glyph was a third case here. #2627 landed its own conversion on + main first, using the inverse `done || archived` form; that PR documents the same + divergence outside the six legacy ids that my equivalence test found, so there was + nothing left to add and this PR's version was dropped during rebase rather than + contested.) + +The distinction matters beyond tidiness: a future auditor working the census will +reach these and needs to know at a glance that they are out of scope, rather than +re-deriving it as I had to. +*/ +import { describe, expect, it } from "vitest"; + +import { getResearchGuidanceForSurface } from "../tool-availability.js"; + +describe("agent-lane vocabulary is not board-column vocabulary", () => { + it("returns distinct research guidance per agent lane", () => { + const triage = getResearchGuidanceForSurface("triage"); + const executor = getResearchGuidanceForSurface("executor"); + + expect(triage).not.toBe(executor); + /* The lane-specific content, so a table wired to the wrong key is caught. */ + expect(triage).toContain("spec work"); + expect(executor).toContain("implementation"); + for (const guidance of [triage, executor]) { + expect(guidance).toContain("fn_research_run"); + } + }); + + it("keeps the lane names independent of any board column id", () => { + /* + The invariant the census hygiene rests on: these are the two AGENT LANES, and + they are unaffected by what a workflow calls its planning column. If someone + later "converts" this to trait resolution, this test is where it lands. + */ + expect(getResearchGuidanceForSurface("triage")).toBe(getResearchGuidanceForSurface("triage")); + expect(() => getResearchGuidanceForSurface("executor")).not.toThrow(); + }); +}); diff --git a/packages/engine/src/skill-resolver.ts b/packages/engine/src/skill-resolver.ts index 9859e05051..3133fae2b6 100644 --- a/packages/engine/src/skill-resolver.ts +++ b/packages/engine/src/skill-resolver.ts @@ -25,6 +25,23 @@ import { piLog } from "./logger.js"; * Falls back to `cwd` if no `.fusion/` directory is found (mirrors * `resolvePiExtensionProjectRoot` from `@fusion/core`). */ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-13:20 (U11 census hygiene): +`"triage"` HERE IS A SESSION PURPOSE — which agent role is running — NOT a board +column. It matched the `=== "triage"` census only because it is the same word, and +resolving it from a workflow's IR would be wrong: an agent role does not move when +a board renames its planning column. + +Hoisted to a named set so the four role purposes read as one concept and the +literal stops looking like a lifecycle guard. +*/ +const ROLE_FALLBACK_SESSION_PURPOSES: ReadonlySet = new Set([ + "triage", + "executor", + "reviewer", + "merger", +]); + export function resolveProjectRoot(cwd: string): string { const worktreeProjectRoot = getProjectRootFromWorktree(cwd); if (worktreeProjectRoot && existsSync(join(worktreeProjectRoot, ".fusion"))) { @@ -429,10 +446,7 @@ export function createSkillsOverrideFromSelection( const { requestedSkillNames, sessionPurpose } = options; const isBuiltInFallbackRequest = (name: string): boolean => { - const purposeUsesRoleFallback = sessionPurpose === "triage" - || sessionPurpose === "executor" - || sessionPurpose === "reviewer" - || sessionPurpose === "merger"; + const purposeUsesRoleFallback = ROLE_FALLBACK_SESSION_PURPOSES.has(sessionPurpose ?? ""); return purposeUsesRoleFallback && requestedSkillNames?.length === 1 && name.toLowerCase() === "fusion"; diff --git a/packages/engine/src/tool-availability.ts b/packages/engine/src/tool-availability.ts index ff033e20a4..15edccadf9 100644 --- a/packages/engine/src/tool-availability.ts +++ b/packages/engine/src/tool-availability.ts @@ -28,8 +28,26 @@ When implementation needs external context, you may use research tools ( 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.`; -export function getResearchGuidanceForSurface(surface: "triage" | "executor"): string { - return surface === "triage" ? TRIAGE_RESEARCH_GUIDANCE : EXECUTOR_RESEARCH_GUIDANCE; +/* +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 = { + 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[] {