FN-6233: add typed triage policy settings

Render triage planning thresholds from typed workflow settings instead of hard-coded prompt constants.

- Add typed triage threshold/default workflow settings, migration coverage, exports, and docs.
- Render built-in triage prompt placeholders before engine prompt execution and tests.
- Remove the duplicate standard triage prompt from engine in favor of the core workflow IR source.
- Add regression coverage for default and customized triage policy rendering.

Files changed:
 .changeset/FN-6233-triage-threshold-settings.md    |   7 +
 docs/settings-reference.md                         |  19 ++
 docs/workflow-steps.md                             |   2 +-
 packages/core/src/__tests__/agent-prompts.test.ts  |  16 +-
 .../builtin-workflow-settings-triage.test.ts       |  63 ++++
 .../src/__tests__/settings-consistency.test.ts     |  24 +-
 .../core/src/__tests__/settings-migration.test.ts  |  12 +-
 .../src/__tests__/workflow-ir-settings.test.ts     |  11 +-
 packages/core/src/agent-prompts.ts                 |  20 +-
 packages/core/src/builtin-workflow-settings.ts     | 154 ++++++++-
 packages/core/src/index.ts                         |   7 +-
 packages/core/src/moved-settings.ts                |  13 +-
 .../triage-planning-prompt-single-source.test.ts   |   9 +-
 .../__tests__/triage-threshold-settings.test.ts    |  84 +++++
 packages/engine/src/__tests__/triage.test.ts       |  17 +-
 packages/engine/src/triage.ts                      | 343 +--------------------
 packages/engine/vitest.config.ts                   |   5 +-
 17 files changed, 423 insertions(+), 383 deletions(-)

Fusion-Task-Id: FN-6233

Fusion-Task-Lineage: b2a986df-fa37-4297-a359-84d8463b0867
This commit is contained in:
gsxdsm
2026-06-11 20:53:01 -07:00
parent 905d5bf849
commit e22afece56
17 changed files with 423 additions and 383 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
Add workflow-native typed settings for triage/spec policy thresholds and routing defaults. The built-in defaults preserve current behavior: size bands remain S <2h, M 2-4h, L 4-8h; subtask signals use the canonical planning-prompt values of step threshold 7 and packages/modules threshold 3; file-scope/remediation thresholds remain 20 and 30.
These triage policy settings are new workflow settings, not moved project settings, so they are excluded from the U4 `MOVED_SETTINGS_KEYS` tombstone while still resolving through workflow effective settings.

View File

@@ -236,6 +236,25 @@ These groups moved out of project settings and into workflow settings (built-in
| **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` |
| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) |
### Workflow-native triage policy settings
The built-in workflows also declare triage/spec policy settings that were **not** moved from project settings. They are workflow-native declarations: they never lived in `DEFAULT_PROJECT_SETTINGS`, are not `MOVED_SETTINGS_KEYS`, and resolve only through the workflow effective-settings path.
| Setting | Default | Purpose |
|---|---:|---|
| `triageSizeSmallMaxHours` | `2` | Size S upper hour boundary (`S (<2h)`). |
| `triageSizeMediumMaxHours` | `4` | Size M upper hour boundary (`M (2-4h)`). |
| `triageSizeLargeMaxHours` | `8` | Size L upper hour boundary; XL starts at `8h+`. |
| `triageSubtaskStepThreshold` | `7` | Canonical “MORE THAN 7 implementation steps” split-consideration threshold. |
| `triageSubtaskLargeStepSignal` | `9` | Broad-scope signal for large tasks whose plan reaches 9+ steps. |
| `triageSubtaskAdditiveStepSignal` | `12` | Additive partitioning signal for 12+ implementation steps. |
| `triageSubtaskPackageThreshold` | `3` | Canonical package/module breadth threshold (“MORE THAN 3 different packages/modules”). |
| `triageSubtaskFileScopeThreshold` | `20` | File Scope entry count that signals broad work. |
| `triageSubtaskRemediationBatchThreshold` | `30` | Large remediation batch threshold. |
| `triageNoCommitsDecisionVerbs` | all seven built-ins | Decision-only verbs: Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report. |
| `triageDecisionOnlyWorkflowId` | `builtin:quick-fix` | Preferred workflow for decision-only/no-commit tasks. |
| `triageDefaultWorkflowId` | `builtin:coding` | Default workflow for standard coding tasks. |
In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor,
and Reviewer dropdown controls for the default workflow. The modal's primary
**Save** action persists pending default-workflow model lane overrides; there is no

View File

@@ -40,7 +40,7 @@ The default built-in catalog entry `builtin:coding` is backed by the canonical `
`builtin:stepwise-coding` is a separate graph variant backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review/rework as authored graph structure.
During triage/planning sessions, agents can call `fn_workflow_list` to discover available built-in and custom workflows and read their descriptions before routing work. They can call `fn_workflow_select` to select a workflow for the task being specified, or pass `workflow_id` when creating child tasks with `fn_task_create`; decision-only or investigation tasks can also set `noCommitsExpected` / `**No commits expected:** true` when no code changes are expected.
During triage/planning sessions, agents can call `fn_workflow_list` to discover available built-in and custom workflows and read their descriptions before routing work. They can call `fn_workflow_select` to select a workflow for the task being specified, or pass `workflow_id` when creating child tasks with `fn_task_create`; decision-only or investigation tasks can also set `noCommitsExpected` / `**No commits expected:** true` when no code changes are expected. The built-in triage thresholds, decision-only verb list, and default routing IDs are workflow-native typed settings resolved from the selected workflow.
#### Runtime invariant criterion

View File

@@ -9,6 +9,7 @@ import {
getTemplatesForRole,
} from "../agent-prompts.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { renderTriagePolicyPlaceholders } from "../builtin-workflow-settings.js";
import { resolvePlanningPromptFromIr } from "../workflow-ir-resolver.js";
import type { AgentPromptsConfig, AgentPromptTemplate } from "../types.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
@@ -272,10 +273,17 @@ describe("resolveAgentPrompt", () => {
expect(triageSource).not.toMatch(/export const (?!FAST_TRIAGE_SYSTEM_PROMPT)[A-Z_]*TRIAGE[A-Z_]*SYSTEM_PROMPT\s*=/);
expect(planningPrompt).toBe(corePrompt);
expect(corePrompt).toContain("**Broad-scope decomposition signals:**");
expect(corePrompt).toContain("step count would reach 9 or more");
expect(corePrompt).toContain("would reach 12 or more");
expect(corePrompt).toContain("20 or more entries");
expect(corePrompt).toContain("at or above 30 items");
expect(corePrompt).toContain("step count would reach {{triageSubtaskLargeStepSignal}} or more");
expect(corePrompt).toContain("would reach {{triageSubtaskAdditiveStepSignal}} or more");
expect(corePrompt).toContain("{{triageSubtaskFileScopeThreshold}} or more entries");
expect(corePrompt).toContain("at or above {{triageSubtaskRemediationBatchThreshold}} items");
const renderedPrompt = renderTriagePolicyPlaceholders(corePrompt, {});
expect(renderedPrompt).toContain("step count would reach 9 or more");
expect(renderedPrompt).toContain("would reach 12 or more");
expect(renderedPrompt).toContain("20 or more entries");
expect(renderedPrompt).toContain("at or above 30 items");
expect(renderedPrompt).not.toContain("{{");
});
it("resolves custom planning prompts and ignores IRs without planning prompts", () => {

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_MOVED_WORKFLOW_SETTINGS,
BUILTIN_TRIAGE_POLICY_SETTINGS,
BUILTIN_WORKFLOW_SETTINGS,
renderTriagePolicyPlaceholders,
} from "../builtin-workflow-settings.js";
const expectedDefaults: Record<string, { type: string; default: unknown }> = {
triageSizeSmallMaxHours: { type: "number", default: 2 },
triageSizeMediumMaxHours: { type: "number", default: 4 },
triageSizeLargeMaxHours: { type: "number", default: 8 },
triageSubtaskStepThreshold: { type: "number", default: 7 },
triageSubtaskLargeStepSignal: { type: "number", default: 9 },
triageSubtaskAdditiveStepSignal: { type: "number", default: 12 },
triageSubtaskPackageThreshold: { type: "number", default: 3 },
triageSubtaskFileScopeThreshold: { type: "number", default: 20 },
triageSubtaskRemediationBatchThreshold: { type: "number", default: 30 },
triageNoCommitsDecisionVerbs: {
type: "multi-enum",
default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"],
},
triageDecisionOnlyWorkflowId: { type: "enum", default: "builtin:quick-fix" },
triageDefaultWorkflowId: { type: "enum", default: "builtin:coding" },
};
describe("workflow-native triage policy settings", () => {
it("declares behavior-equivalent typed defaults outside the moved-key catalog", () => {
const triageById = new Map(BUILTIN_TRIAGE_POLICY_SETTINGS.map((setting) => [setting.id, setting]));
const fullIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((setting) => setting.id));
const movedIds = new Set(BUILTIN_MOVED_WORKFLOW_SETTINGS.map((setting) => setting.id));
expect(BUILTIN_TRIAGE_POLICY_SETTINGS).toHaveLength(Object.keys(expectedDefaults).length);
for (const [id, expected] of Object.entries(expectedDefaults)) {
const setting = triageById.get(id);
expect(setting, `${id} should be declared`).toBeDefined();
expect(setting?.type).toBe(expected.type);
expect(setting?.default).toStrictEqual(expected.default);
expect(fullIds.has(id), `${id} should be in the full built-in catalog`).toBe(true);
expect(movedIds.has(id), `${id} should not be in the moved-key catalog`).toBe(false);
}
});
it("renders placeholders from resolved settings and rejects dangling tokens", () => {
const prompt = [
"Size S (<{{triageSizeSmallMaxHours}}h)",
"MORE THAN {{triageSubtaskStepThreshold}} implementation steps",
"verbs: {{triageNoCommitsDecisionVerbs}}",
].join("\n");
const rendered = renderTriagePolicyPlaceholders(prompt, {
triageSizeSmallMaxHours: 1,
triageSubtaskStepThreshold: 5,
triageNoCommitsDecisionVerbs: ["Audit", "Confirm"],
} as never);
expect(rendered).toContain("Size S (<1h)");
expect(rendered).toContain("MORE THAN 5 implementation steps");
expect(rendered).toContain("verbs: Audit, Confirm");
expect(rendered).not.toContain("{{");
expect(() => renderTriagePolicyPlaceholders("{{unknownTriageToken}}", {})).toThrow(/Unresolved triage policy placeholder/);
});
});

View File

@@ -10,7 +10,10 @@
*/
import { describe, it, expect } from "vitest";
import { MOVED_SETTINGS_KEYS } from "../moved-settings.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import {
BUILTIN_TRIAGE_POLICY_SETTINGS,
BUILTIN_WORKFLOW_SETTINGS,
} from "../builtin-workflow-settings.js";
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_PROJECT_SETTINGS,
@@ -39,18 +42,29 @@ describe("settings consistency (U5)", () => {
}
});
it("(b) MOVED_SETTINGS_KEYS and BUILTIN_WORKFLOW_SETTINGS declaration ids are exactly equal sets", () => {
it("(b) every built-in declaration is either moved or workflow-native triage policy", () => {
const declIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id));
const moved = new Set(movedKeys);
const native = new Set(BUILTIN_TRIAGE_POLICY_SETTINGS.map((s) => s.id));
// Every moved key has a declaration.
for (const key of moved) {
expect(declIds.has(key), `moved key '${key}' has no BUILTIN_WORKFLOW_SETTINGS declaration`).toBe(true);
}
// Every declaration is a moved key.
// Every declaration is either a moved key or an explicitly workflow-native triage setting.
for (const id of declIds) {
expect(moved.has(id), `declaration '${id}' is missing from MOVED_SETTINGS_KEYS`).toBe(true);
expect(
moved.has(id) || native.has(id),
`declaration '${id}' must be in MOVED_SETTINGS_KEYS or BUILTIN_TRIAGE_POLICY_SETTINGS`,
).toBe(true);
}
expect(moved.size).toBe(declIds.size);
for (const id of native) {
expect(moved.has(id), `native triage setting '${id}' must not be in MOVED_SETTINGS_KEYS`).toBe(false);
expect(PROJECT_SETTINGS_KEYS as readonly string[], `native triage setting '${id}' must not be project schema key`).not.toContain(id);
expect(GLOBAL_SETTINGS_KEYS as readonly string[], `native triage setting '${id}' must not be global schema key`).not.toContain(id);
expect(Object.keys(DEFAULT_PROJECT_SETTINGS), `native triage setting '${id}' must not be project default`).not.toContain(id);
expect(Object.keys(DEFAULT_GLOBAL_SETTINGS), `native triage setting '${id}' must not be global default`).not.toContain(id);
}
expect(declIds.size).toBe(moved.size + native.size);
});
it("(c) every moved key is absent from GLOBAL_SETTINGS_KEYS / PROJECT_SETTINGS_KEYS and their predicates", () => {

View File

@@ -22,6 +22,7 @@ import {
SETTINGS_MIGRATION_VERSION,
SETTINGS_MIGRATION_MARKER_KEY,
} from "../moved-settings.js";
import { BUILTIN_TRIAGE_POLICY_SETTINGS } from "../builtin-workflow-settings.js";
import { resolveEffectiveSettingsById, type WorkflowSettingsResolverStore } from "../workflow-settings-resolver.js";
import { DEFAULT_PROJECT_SETTINGS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
@@ -155,7 +156,16 @@ describe("settings hard-move migration (U4)", () => {
expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerModelId", undefined);
expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackProvider", undefined);
expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackModelId", undefined);
// 26 keys after removing buildTimeoutMs plus the summarizer lane from the catalog.
// 26 keys after removing buildTimeoutMs plus the summarizer lane from the moved catalog.
expect(MOVED_SETTINGS_KEYS.length).toBe(26);
});
it("workflow-native triage policy settings are excluded from moved/project schemas", () => {
for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) {
expect(MOVED_SETTINGS_KEYS, `${setting.id} is workflow-native, not a moved key`).not.toContain(setting.id);
expect(PROJECT_SETTINGS_KEYS, `${setting.id} must not be a project schema key`).not.toContain(setting.id);
expect(DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).not.toHaveProperty(setting.id);
}
expect(MOVED_SETTINGS_KEYS.length).toBe(26);
});

View File

@@ -7,7 +7,10 @@ import {
} from "../workflow-ir.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { getBuiltinWorkflow } from "../builtin-workflows.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import {
BUILTIN_MOVED_WORKFLOW_SETTINGS,
BUILTIN_WORKFLOW_SETTINGS,
} from "../builtin-workflow-settings.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import type {
WorkflowIrV2,
@@ -208,7 +211,7 @@ describe("parseWorkflowIr — workflow settings declarations (U1)", () => {
});
describe("built-in workflow settings parity anchor (U1, R4)", () => {
it("the built-in coding workflow declares the full moved-key catalog", () => {
it("the built-in coding workflow declares the full workflow settings catalog", () => {
const builtin = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const declaredIds = new Set((builtin.settings ?? []).map((s) => s.id));
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
@@ -224,7 +227,7 @@ describe("built-in workflow settings parity anchor (U1, R4)", () => {
// Post-U4 hard-move: every catalog key has been REMOVED from
// DEFAULT_PROJECT_SETTINGS (the type-vs-schema split keeps the type field but
// drops the default literal), so the legacy object no longer carries them.
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
for (const setting of BUILTIN_MOVED_WORKFLOW_SETTINGS) {
expect(Object.prototype.hasOwnProperty.call(legacy, setting.id)).toBe(false);
}
// The declaration defaults are now the single source of truth; pin the legacy
@@ -249,7 +252,7 @@ describe("built-in workflow settings parity anchor (U1, R4)", () => {
reflectionEnabled: false,
// Per-phase model lanes have undefined legacy defaults → declaration omits default.
};
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
for (const setting of BUILTIN_MOVED_WORKFLOW_SETTINGS) {
if (Object.prototype.hasOwnProperty.call(expectedDefaults, setting.id)) {
expect(setting.default).toStrictEqual(expectedDefaults[setting.id]);
} else {

View File

@@ -402,8 +402,8 @@ When the task includes \`breakIntoSubtasks: true\`, first decide whether it shou
For tasks you assess as Size M or L, consider whether splitting into 2-5 child tasks would improve execution quality. Default to keeping the task whole; only split when the work is genuinely large or has clearly independent deliverables.
**Consider splitting when ANY of these apply:**
- The task will require MORE THAN 7 implementation steps
- The task affects MORE THAN 3 different packages/modules with distinct concerns (a typed field change that naturally touches core types + store + UI + tests is NOT 4 distinct concerns — it's one coherent change)
- The task will require MORE THAN {{triageSubtaskStepThreshold}} implementation steps
- The task affects MORE THAN {{triageSubtaskPackageThreshold}} different packages/modules with distinct concerns (a typed field change that naturally touches core types + store + UI + tests is NOT 4 distinct concerns — it's one coherent change)
- Any single step would take more than 1-2 hours to complete
- The task has multiple clearly independent deliverables that could be developed and shipped in parallel by different people
@@ -416,10 +416,10 @@ For tasks you assess as Size M or L, consider whether splitting into 2-5 child t
- If you decide not to split an M/L task, proceed with a normal PROMPT.md specification
**Broad-scope decomposition signals:**
- Size L tasks, especially when the planned step count would reach 9 or more.
- Plans whose implementation-step count would reach 12 or more (additive signal — counts even when the surrounding "more than 7/10 steps" threshold above has not yet fired).
- Tasks whose declared \`## File Scope\` would list 20 or more entries.
- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above 30 items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying.
- Size L tasks, especially when the planned step count would reach {{triageSubtaskLargeStepSignal}} or more.
- Plans whose implementation-step count would reach {{triageSubtaskAdditiveStepSignal}} or more (additive signal — counts even when the surrounding step-count threshold above has not yet fired).
- Tasks whose declared \`## File Scope\` would list {{triageSubtaskFileScopeThreshold}} or more entries.
- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above {{triageSubtaskRemediationBatchThreshold}} items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying.
- When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph.
## Triage tools
@@ -445,7 +445,7 @@ When ALL of the following are true, include this metadata line in the header blo
- Add this exact line: **No commits expected:** true
Set it only when all of these conditions hold:
- Title/mission starts with decision verbs like "Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", or "Investigate and report"
- Title/mission starts with decision verbs like {{triageNoCommitsDecisionVerbs}}
- Acceptance criteria are strictly observational (record findings, log a decision, update task log/docs) with no required code/config/file mutations
- Task description explicitly says things like "no code changes expected" or "the deliverable is the recorded decision"
@@ -463,7 +463,7 @@ Anti-heuristics (bias to false-negative when ambiguous):
- Always include a testing step and a documentation step
- For tasks whose primary deliverable is documentation (updating docs, writing README, API references), include an explicit step or checkbox instructing the executor to save the final documentation content via \`fn_task_document_write\`
- Include a "Do NOT" section with project-appropriate guardrails
- Size assessment: S (<2h), M (2-4h), L (4-8h). Split if XL (8h+)
- Size assessment: S (<{{triageSizeSmallMaxHours}}h), M ({{triageSizeSmallMaxHours}}-{{triageSizeMediumMaxHours}}h), L ({{triageSizeMediumMaxHours}}-{{triageSizeLargeMaxHours}}h). Split if XL ({{triageSizeLargeMaxHours}}h+)
- Review level scoring: Blast radius (0-2), Pattern novelty (0-2), Security (0-2), Reversibility (0-2)
- 0-1 → Level 0, 2-3 → Level 1, 4-5 → Level 2, 6-8 → Level 3
@@ -476,8 +476,8 @@ package.json when explicit commands are provided.
## Workflow Routing
- Call \`fn_workflow_list\` to discover available workflows before selecting a routing path, and read each workflow description as the routing signal.
- For investigation, audit, research, or decision-only tasks that produce no code changes, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met, then select an appropriate lightweight workflow.
- For decision-only tasks (Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report), prefer \`builtin:quick-fix\` or a custom investigation workflow when one is available.
- For standard coding tasks, \`builtin:coding\` is the default and is usually appropriate.
- For decision-only tasks ({{triageNoCommitsDecisionVerbs}}), prefer \`{{triageDecisionOnlyWorkflowId}}\` or a custom investigation workflow when one is available.
- For standard coding tasks, \`{{triageDefaultWorkflowId}}\` is the default and is usually appropriate.
- Use \`fn_workflow_select\` to set the workflow on the current task, or pass \`workflow_id\` to \`fn_task_create\` when creating subtasks.
- Match the task nature to the workflow description; descriptions are authoritative for routing decisions.

View File

@@ -1,5 +1,21 @@
import type { Settings } from "./types.js";
import type { WorkflowSettingDefinition } from "./workflow-ir-types.js";
/**
* Built-in workflow settings catalog.
*
* `BUILTIN_MOVED_WORKFLOW_SETTINGS` is the U4 moved-key catalog: keys that
* formerly lived in `DEFAULT_PROJECT_SETTINGS` and are tombstoned by
* `MOVED_SETTINGS_KEYS`. Keep those defaults byte-equal to the legacy literals.
*
* `BUILTIN_TRIAGE_POLICY_SETTINGS` is workflow-native triage/spec policy. These
* keys never lived in `DEFAULT_PROJECT_SETTINGS`, are NOT part of the U4
* hard-move migration, must never be added to `MOVED_SETTINGS_KEYS`, and must
* not appear in project/global settings schemas. Canonical values are inherited
* from the post-FN-6232 planning prompt: subtask step threshold `7` (not the
* older engine copy) and packages/modules threshold `3`.
*/
/**
* The moved-key catalog declared as workflow settings (U1, R4).
*
@@ -23,7 +39,7 @@ import type { WorkflowSettingDefinition } from "./workflow-ir-types.js";
* in project settings.
* - merge-cluster keys + `maxConcurrent` — owned by the columns/traits track.
*/
export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
// ── Step execution ─────────────────────────────────────────────────────
{
id: "workflowStepTimeoutMs",
@@ -230,3 +246,139 @@ export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
description: "Fallback model id for the validation phase.",
},
];
export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [
{
id: "triageSizeSmallMaxHours",
name: "Triage size S max hours",
type: "number",
default: 2,
description: "Upper hour boundary for Size S triage guidance (S is below this value).",
},
{
id: "triageSizeMediumMaxHours",
name: "Triage size M max hours",
type: "number",
default: 4,
description: "Upper hour boundary for Size M triage guidance.",
},
{
id: "triageSizeLargeMaxHours",
name: "Triage size L max hours",
type: "number",
default: 8,
description: "Upper hour boundary for Size L triage guidance; larger work should split as XL.",
},
{
id: "triageSubtaskStepThreshold",
name: "Triage subtask step threshold",
type: "number",
default: 7,
description: "Implementation-step count above which triage should consider splitting an M/L task.",
},
{
id: "triageSubtaskLargeStepSignal",
name: "Triage large-step signal",
type: "number",
default: 9,
description: "Planned step count that is a broad-scope decomposition signal for Size L tasks.",
},
{
id: "triageSubtaskAdditiveStepSignal",
name: "Triage additive step signal",
type: "number",
default: 12,
description: "Implementation-step count that independently signals possible partitioning.",
},
{
id: "triageSubtaskPackageThreshold",
name: "Triage package/module threshold",
type: "number",
default: 3,
description: "Distinct package/module count above which triage should consider splitting coherent M/L work.",
},
{
id: "triageSubtaskFileScopeThreshold",
name: "Triage file-scope threshold",
type: "number",
default: 20,
description: "File Scope entry count that signals broad work likely needing partitioning.",
},
{
id: "triageSubtaskRemediationBatchThreshold",
name: "Triage remediation batch threshold",
type: "number",
default: 30,
description: "Quantified remediation batch size that strongly signals subsystem partitioning.",
},
{
id: "triageNoCommitsDecisionVerbs",
name: "Triage no-commits decision verbs",
type: "multi-enum",
default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"],
options: [
{ value: "Decide", label: "Decide" },
{ value: "Evaluate", label: "Evaluate" },
{ value: "Verify", label: "Verify" },
{ value: "Confirm", label: "Confirm" },
{ value: "Audit", label: "Audit" },
{ value: "Review whether", label: "Review whether" },
{ value: "Investigate and report", label: "Investigate and report" },
],
description: "Decision-only title/mission verbs used when deciding whether a task expects no commits.",
},
{
id: "triageDecisionOnlyWorkflowId",
name: "Triage decision-only workflow",
type: "enum",
default: "builtin:quick-fix",
options: [
{ value: "builtin:quick-fix", label: "Quick fix" },
{ value: "builtin:coding", label: "Coding" },
],
description: "Preferred workflow id for decision-only or investigation tasks that expect no code changes.",
},
{
id: "triageDefaultWorkflowId",
name: "Triage default workflow",
type: "enum",
default: "builtin:coding",
options: [
{ value: "builtin:coding", label: "Coding" },
{ value: "builtin:quick-fix", label: "Quick fix" },
],
description: "Default workflow id for standard coding tasks.",
},
];
export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
...BUILTIN_MOVED_WORKFLOW_SETTINGS,
...BUILTIN_TRIAGE_POLICY_SETTINGS,
];
const TRIAGE_POLICY_DEFAULTS = new Map(
BUILTIN_TRIAGE_POLICY_SETTINGS.map((setting) => [setting.id, setting.default]),
);
function formatTriagePolicyValue(id: string, value: unknown): string {
if (id === "triageNoCommitsDecisionVerbs") {
const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id);
return (Array.isArray(verbs) ? verbs : []).map((verb) => String(verb)).join(", ");
}
return String(value ?? TRIAGE_POLICY_DEFAULTS.get(id) ?? "");
}
export function renderTriagePolicyPlaceholders(prompt: string, settings: Partial<Settings>): string {
let rendered = prompt;
const values = settings as Record<string, unknown>;
for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) {
const token = new RegExp(`\\{\\{${setting.id}\\}\\}`, "g");
rendered = rendered.replace(token, formatTriagePolicyValue(setting.id, values[setting.id] ?? setting.default));
}
const leftover = rendered.match(/\{\{[^}]+\}\}/);
if (leftover) {
throw new Error(`Unresolved triage policy placeholder: ${leftover[0]}`);
}
return rendered;
}

View File

@@ -105,7 +105,12 @@ export type {
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
export { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js";
export { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
export {
BUILTIN_WORKFLOW_SETTINGS,
BUILTIN_MOVED_WORKFLOW_SETTINGS,
BUILTIN_TRIAGE_POLICY_SETTINGS,
renderTriagePolicyPlaceholders,
} from "./builtin-workflow-settings.js";
export {
MOVED_SETTINGS_KEYS,
SETTINGS_MIGRATION_VERSION,

View File

@@ -4,9 +4,10 @@
* `MOVED_SETTINGS_KEYS` is the single, authoritative record of the settings keys
* that left `DEFAULT_PROJECT_SETTINGS` and now live exclusively as **workflow
* setting values** per `(workflowId, projectId)`. It is derived directly from the
* built-in workflow declaration catalog (`BUILTIN_WORKFLOW_SETTINGS`) so the move
* has exactly one source of truth — a key is "moved" iff a built-in workflow
* declares it. Adding/removing a key from the catalog automatically reflows the
* moved workflow declaration catalog (`BUILTIN_MOVED_WORKFLOW_SETTINGS`) so the move
* has exactly one source of truth. Workflow-native declarations (for example
* triage policy thresholds) are deliberately excluded from this tombstone.
* Adding/removing a key from the moved catalog automatically reflows the
* tombstone list, the migration write target, and the stale-writer guard.
*
* What the tombstone shields (KTD-5, R8):
@@ -35,7 +36,7 @@
* setting and is intentionally ABSENT from this list.
*/
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
import { BUILTIN_MOVED_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/**
* The version of the per-project settings hard-move migration. Persisted per
@@ -49,11 +50,11 @@ export const SETTINGS_MIGRATION_VERSION = 1;
export const SETTINGS_MIGRATION_MARKER_KEY = "settingsMigrationVersion";
/**
* The definitive moved-key catalog — derived from the built-in workflow
* The definitive moved-key catalog — derived from the moved workflow
* declarations so it cannot drift from them. Frozen so callers cannot mutate it.
*/
export const MOVED_SETTINGS_KEYS: readonly string[] = Object.freeze(
BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id),
BUILTIN_MOVED_WORKFLOW_SETTINGS.map((s) => s.id),
);
/** Set form for O(1) membership checks on the hot write path. */

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Settings, Task, TaskDetail, TaskStore, WorkflowIr } from "@fusion/core";
import {
BUILTIN_CODING_WORKFLOW_IR,
renderTriagePolicyPlaceholders,
resolveAgentPrompt,
resolvePlanningPromptFromIr,
} from "@fusion/core";
@@ -107,6 +108,8 @@ async function captureBasePrompt(task: Task, store: TaskStore): Promise<string>
}
const canonicalPlanningPrompt = resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR)!;
const renderedCanonicalPlanningPrompt = renderTriagePolicyPlaceholders(canonicalPlanningPrompt, {});
const renderedDefaultTriagePrompt = renderTriagePolicyPlaceholders(resolveAgentPrompt("triage"), {});
describe("triage planning prompt single source", () => {
beforeEach(() => {
@@ -119,14 +122,14 @@ describe("triage planning prompt single source", () => {
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }),
});
await expect(captureBasePrompt(task, store)).resolves.toBe(canonicalPlanningPrompt);
await expect(captureBasePrompt(task, store)).resolves.toBe(renderedCanonicalPlanningPrompt);
});
it("uses the built-in workflow IR planning prompt when no workflow is selected", async () => {
const task = createTask({ id: "FN-6232-NO-SELECTION", executionMode: "standard" });
const store = createStore(task);
await expect(captureBasePrompt(task, store)).resolves.toBe(canonicalPlanningPrompt);
await expect(captureBasePrompt(task, store)).resolves.toBe(renderedCanonicalPlanningPrompt);
});
it("preserves user triage prompt override precedence", async () => {
@@ -174,6 +177,6 @@ describe("triage planning prompt single source", () => {
}),
});
await expect(captureBasePrompt(task, store)).resolves.toBe(resolveAgentPrompt("triage"));
await expect(captureBasePrompt(task, store)).resolves.toBe(renderedDefaultTriagePrompt);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, it, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
BUILTIN_CODING_WORKFLOW_IR,
renderTriagePolicyPlaceholders,
resolveEffectiveSettingsById,
resolvePlanningPromptFromIr,
TaskStore,
} from "@fusion/core";
const cleanupDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
cleanupDirs.push(dir);
return dir;
}
afterEach(() => {
while (cleanupDirs.length) {
rmSync(cleanupDirs.pop()!, { recursive: true, force: true });
}
});
function builtinPlanningPrompt(): string {
const prompt = resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR);
if (!prompt) throw new Error("builtin:coding planning prompt missing");
return prompt;
}
describe("triage threshold workflow settings", () => {
it("renders behavior-equivalent defaults into the built-in planning prompt", () => {
const rendered = renderTriagePolicyPlaceholders(builtinPlanningPrompt(), {});
expect(rendered).toContain("MORE THAN 7 implementation steps");
expect(rendered).toContain("MORE THAN 3 different packages/modules");
expect(rendered).toContain("9 or more");
expect(rendered).toContain("12 or more");
expect(rendered).toContain("20 or more entries");
expect(rendered).toContain("at or above 30 items");
expect(rendered).toContain("S (<2h), M (2-4h), L (4-8h). Split if XL (8h+)");
expect(rendered).toContain("Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report");
expect(rendered).toContain("prefer `builtin:quick-fix`");
expect(rendered).toContain("`builtin:coding` is the default");
expect(rendered).not.toContain("{{");
});
it("reflects stored workflow overrides in effective settings and rendered prompt", async () => {
const rootDir = makeTempDir("fn-6233-triage-root-");
const globalDir = makeTempDir("fn-6233-triage-global-");
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
try {
const projectId = store.getWorkflowSettingsProjectId();
await store.updateWorkflowSettingValues("builtin:coding", projectId, { triageSubtaskStepThreshold: 3 });
const effective = await resolveEffectiveSettingsById(store, "builtin:coding", projectId);
expect(effective.triageSubtaskStepThreshold).toBe(3);
const rendered = renderTriagePolicyPlaceholders(builtinPlanningPrompt(), effective);
expect(rendered).toContain("MORE THAN 3 implementation steps");
expect(rendered).not.toContain("MORE THAN 7 implementation steps");
expect(rendered).not.toContain("{{");
} finally {
store.close();
}
});
it("keeps migrated threshold numbers out of the triage prompt assembly code path", async () => {
const source = await readFile(new URL("../triage.ts", import.meta.url), "utf8");
const promptAssembly = source.slice(
source.indexOf("const workflowPlanningPrompt"),
source.indexOf("const triageSystemPromptFinal"),
);
expect(promptAssembly).toContain("renderTriagePolicyPlaceholders");
expect(promptAssembly).not.toMatch(/\b(?:7|9|12|20|30)\b/);
expect(promptAssembly).not.toMatch(/builtin:quick-fix|builtin:coding/);
expect(promptAssembly).not.toMatch(/Decide|Evaluate|Verify|Confirm|Audit|Review whether|Investigate and report/);
});
});

View File

@@ -1,9 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import { renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
import {
TriageProcessor,
TRIAGE_SYSTEM_PROMPT,
FAST_TRIAGE_SYSTEM_PROMPT,
buildSpecificationPrompt,
readAttachmentContents,
@@ -23,6 +22,7 @@ const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({
}));
const TRIAGE_POLICY_PROMPT = resolveAgentPrompt("triage");
const RENDERED_TRIAGE_POLICY_PROMPT = renderTriagePolicyPlaceholders(TRIAGE_POLICY_PROMPT, {});
vi.mock("../reviewer.js", () => ({
reviewStep: mockReviewStep,
@@ -635,11 +635,12 @@ describe("canonical triage policy prompt", () => {
);
});
it("includes explicit subtask breakdown thresholds", () => {
expect(TRIAGE_POLICY_PROMPT).toContain("MORE THAN 7 implementation steps");
expect(TRIAGE_POLICY_PROMPT).toContain(
it("includes explicit rendered subtask breakdown thresholds", () => {
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain("MORE THAN 7 implementation steps");
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"MORE THAN 3 different packages/modules",
);
expect(TRIAGE_POLICY_PROMPT).toContain("MORE THAN {{triageSubtaskStepThreshold}} implementation steps");
});
it("biases toward keeping tasks whole and acknowledges coordination overhead", () => {
@@ -676,7 +677,7 @@ describe("FN-5893 invariant regression wording", () => {
const missingSectionRevisePattern =
/For bug fixes and UI-affordance add\/remove tasks, the spec MUST include a `## Surface Enumeration` section\. During self-review via `fn_review_spec\(\)`, treat a missing section on a bug-fix or UI-affordance add\/remove spec as a blocking REVISE\./;
for (const prompt of [TRIAGE_POLICY_PROMPT, TRIAGE_SYSTEM_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
expect(prompt).toContain("## Surface Enumeration");
expect(prompt).toMatch(missingSectionRevisePattern);
expect(prompt).toContain("docs/testing.md");
@@ -709,7 +710,7 @@ describe("FN-5893 invariant regression wording", () => {
});
it("defines the FN-6229 Symptom Verification contract in standard and fast prompts", () => {
for (const prompt of [TRIAGE_POLICY_PROMPT, TRIAGE_SYSTEM_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
expect(prompt).toContain("## Symptom Verification");
expect(prompt).toContain("Use the exact heading `## Symptom Verification`");
expect(prompt).toContain("**Original symptom** — what the user/issue reported was broken");
@@ -759,7 +760,7 @@ describe("fast-mode triage", () => {
});
it("documents workflow routing in standard and fast prompts", () => {
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
for (const prompt of [RENDERED_TRIAGE_POLICY_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
expect(prompt).toContain("## Workflow Routing");
expect(prompt).toContain("fn_workflow_list");
expect(prompt).toContain("fn_workflow_select");

View File

@@ -13,6 +13,7 @@ import {
getTaskDuplicateLineage,
parseExplicitDuplicateMarker,
resolveAgentPrompt,
renderTriagePolicyPlaceholders,
resolveTaskPlanningPrompt,
resolvePersistAgentThinkingLog,
compareTaskPriority,
@@ -88,339 +89,6 @@ import { archiveAsGhostBug } from "./self-healing.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
export const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "fn", an AI-orchestrated task board.
## Your Role
You are the specification quality gate for implementation success.
Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously in a fresh context with zero memory of this conversation.
The quality of your spec directly determines execution quality, review churn, and merge risk.
## What you receive
- A raw task title and optional description (the user's rough idea)
- Access to the project's files so you can understand context
## What you produce
Write a complete PROMPT.md specification to the given path using the write tool.
## PROMPT.md Format
Follow this structure exactly:
\`\`\`markdown
# Task: {ID} - {Name}
**Created:** {YYYY-MM-DD}
**Size:** {S | M | L}
## Review Level: {0-3} ({None | Plan Only | Plan and Code | Full})
**Assessment:** {1-2 sentences explaining the score}
**Score:** {N}/8 — Blast radius: {N}, Pattern novelty: {N}, Security: {N}, Reversibility: {N}
## Mission
{One paragraph: what you're building and why it matters}
## Surface Enumeration
{Required for bug-fix tasks and UI-affordance add/remove tasks (adding, removing, or restructuring icons, buttons, chevrons/arrows, toggles, badges, menu entries, click targets): a checklist enumerating every surface the fixed invariant must hold across. Include every provider/bridge for streaming and agent paths; desktop AND mobile breakpoints; empty/undefined/duplicate/populated data states; and every hook/component/module that shares the affected logic. For UI-affordance add/remove tasks, enumerate every component that renders the affordance by searching the codebase for the icon/class/testid — not just the component the user pointed at. Explicitly check for leftover shells after removal (empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels) across both desktop and mobile breakpoints. Use the canonical checklist in docs/testing.md as the starting point.}
## Symptom Verification
{Required for bug-class/bug-fix tasks only; feature/docs/non-bug tasks do not need this section. Use the exact heading \`## Symptom Verification\` and include: (1) **Original symptom** — what the user/issue reported was broken; (2) **Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure; (3) **Assertion it is gone** — the executor's final verification must reproduce that original failure condition and assert it no longer occurs via a real automated test. Green build/tests alone are insufficient without symptom-based acceptance.}
## Dependencies
- **None**
{OR}
- **Task:** {ID} ({what must be complete})
## Context to Read First
{List specific files the worker should read before starting — only what's needed}
## File Scope
{List files/directories the task will create or modify — be specific}
- \`path/to/file.ext\`
- \`path/to/directory/*\`
## Steps
> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed
> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps
> that are genuinely independent of their immediate predecessor; an unannotated step is
> assumed to depend on the one before it (fully sequential). Be conservative — only mark a
> step independent when it truly does not read or modify the prior step's output.
### Step 0: Preflight
- [ ] Required files and paths exist
- [ ] Dependencies satisfied
### Step 1: {Name}
- [ ] {Specific, verifiable outcome}
- [ ] {Specific, verifiable outcome}
- [ ] Run targeted tests for changed files, asserting the invariant across all known surfaces (enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states)
For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section:
- [ ] Providers / bridges / execution paths touched by the invariant
- [ ] Desktop + mobile breakpoints / platforms that exercise the behavior
- [ ] Empty / undefined / duplicate / populated data states
- [ ] Shared hooks / components / modules / helpers reusing the logic
- [ ] Every component that renders the affordance (search the codebase for the icon/class/testid, not just the one the user pointed at)
- [ ] Leftover shells after removal — empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels — are explicitly checked and fixed/hidden
For bug-class/bug-fix tasks, add and fill in the exact \`## Symptom Verification\` section:
- [ ] **Original symptom** — what the user/issue reported was broken
- [ ] **Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure
- [ ] **Assertion it is gone** — final verification reproduces the original failure condition and asserts it no longer occurs via a real automated test; green build/tests alone are insufficient
**Artifacts:**
- \`path/to/file\` (new | modified)
### Step {N-1}: Testing & Verification
> ZERO failures allowed for checks required by this task's quality gates. Run impacted/package-scoped verification first; run workspace-wide suites only when the task or workflow explicitly requires them, or during final integration after impacted checks pass.
> If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope, make those fixes as part of this task.
- [ ] Run lint check (\`pnpm lint\`)
- [ ] Run impacted tests
- [ ] Run project typecheck if available
- [ ] Fix all failures
- [ ] Build passes
### Step {N}: Documentation & Delivery
- [ ] Update relevant documentation
- [ ] Save documentation deliverables as task documents via \`fn_task_document_write\` (key="docs", content=...)
- [ ] Out-of-scope findings created as new tasks via \`fn_task_create\` tool
## Documentation Requirements
**Must Update:**
- \`path/to/doc.md\` — {what to add/change}
**Check If Affected:**
- \`path/to/doc.md\` — {update if relevant}
## Completion Criteria
- [ ] All steps complete
- [ ] Lint passing
- [ ] All tests passing
- [ ] Typecheck passing (if available)
- [ ] Documentation updated
## Git Commit Convention
Commits at step boundaries. All commits include the task ID:
- **Step completion:** \`feat({ID}): complete Step N — <short summary>\` (the \`<short summary>\` is required — use a concrete 5–10 word description)
- **Bug fixes:** \`fix({ID}): description\` (short, concrete summary required)
- **Tests:** \`test({ID}): description\` (short, concrete summary required)
Good examples:
- \`feat(FN-1234): complete Step 2 — add retry guard for workflow step timeouts\`
- \`test(FN-1234): add regression tests for paused-session cleanup\`
Bad example:
- \`feat(FN-1234): complete Step 2\`
## Do NOT
- Expand task scope
- Skip tests
- Refuse necessary fixes just because they touch files outside the initial File Scope
- Commit without the task ID prefix
- Remove, delete, or gut modules, settings, interfaces, exports, or test files outside the File Scope
- Remove features as "cleanup" — if something seems unused, create a task via \`fn_task_create\`
## Changeset Requirements
If this task REMOVES existing functionality (deleting modules, settings, API endpoints, or exports), a changeset file is REQUIRED:
- Create \`.changeset/{task-id}-removal.md\` explaining what was removed and why
- This is mandatory for any net-negative change (more deletions than additions to existing files)
\`\`\`
## Testing requirements
The Testing & Verification step MUST require REAL automated tests — actual test
files with assertions that run via a test runner. Typechecks and builds are NOT
tests. Manual verification is NOT a test.
- Each implementation step should include writing tests for the code being changed
- For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix or UI-affordance add/remove spec as a blocking REVISE.
- For bug fixes and UI-affordance add/remove tasks, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers; every component that renders the affordance; leftover shells after removal.
- For bug fixes and UI-affordance add/remove tasks, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, empty/undefined/populated data states, and for UI-affordance changes every component rendering the affordance plus leftover shells after removal — not just the reported repro (see FN-5787/FN-5789/FN-5803, FN-5751, and FN-6115/FN-6118/FN-6123)
- For bug-class/bug-fix tasks, the spec MUST include a \`## Symptom Verification\` section with **Original symptom**, **Exact reproduction**, and **Assertion it is gone**. The final verification step must perform symptom-based acceptance: reproduce the original failure and prove it is gone with a real automated test. Green build/tests alone are insufficient. Feature/docs/non-bug tasks are not required to carry \`## Symptom Verification\`.
- The final Testing step runs lint, impacted/package-scoped tests first, and project typecheck when the repo exposes one. Run workspace-wide suites only when explicitly required by the task/workflow or during final integration after impacted checks pass.
- Specs must instruct executors to fix lint failures and quality-gate failures directly, even when the required edits extend beyond the original File Scope
- If the project has no test framework, the Testing step must include setting one up
as part of this task (not just skipping tests)
## Duplicate check
Before writing a spec, first call \`fn_task_list\` to see active tasks, then call \`fn_task_search\` with 2-4 distinct keyword phrases from the task title and description (for example file paths, error symptoms, and symbol names).
For any likely match in \`done\` or \`archived\`, call \`fn_task_get\` to inspect details before deciding.
If a task already covers the same work (even if worded differently), do NOT
write a PROMPT.md. Instead, write a single line to the output file:
\`DUPLICATE: {existing-task-id}\`
## Dependency awareness
When you plan to list a task in the \`## Dependencies\` section, first call \`fn_task_get\` on that task ID to read its PROMPT.md.
Use what you learn — file scope, APIs, patterns, completion criteria — to make the new spec accurate: reference the right paths, avoid conflicting assumptions, and describe what the dependency must deliver before this task starts.
If the dependency task has no PROMPT.md yet (not yet specified), note that in the Dependencies section.
## Triage subtask breakdown
When the task includes \`breakIntoSubtasks: true\`, first decide whether it should be split.
- Split only when the work is meaningfully decomposable into 2-5 independently executable child tasks.
- If splitting: use the \`fn_task_create\` tool to create child tasks in triage, include clear descriptions and dependencies between them, then stop. Do NOT write a PROMPT.md for the parent task.
- **CRITICAL — subtask dependencies:** the parent task is deleted once all subtasks are created. \`dependencies\` on a new subtask may ONLY reference sibling subtasks you have created earlier in this same split (or unrelated existing tasks). **Never depend on the parent task's id.** If a child conceptually "waits for the parent's remaining work", create a sibling subtask that does that work and depend on the sibling instead. The \`fn_task_create\` tool will reject parent-id dependencies with an error.
- If not splitting: proceed with a normal PROMPT.md specification.
## Proactive Subtask Breakdown for M/L Tasks
For tasks you assess as Size M or L, consider whether splitting into 2-5 child tasks would improve execution quality. Default to keeping the task whole; only split when the work is genuinely large or has clearly independent deliverables.
**Consider splitting when ANY of these apply:**
- The task will require more than 10 implementation steps
- The task affects more than 5 different packages/modules with distinct concerns (a typed field change that naturally touches core types + store + UI + tests is NOT 4 distinct concerns — it's one coherent change)
- Any single step would take more than 3-4 hours to complete
- The task has multiple clearly independent deliverables that could be developed and shipped in parallel by different people
**Splitting guidance:**
- Even when \`breakIntoSubtasks\` is not set to \`true\`, apply these thresholds proactively
- Keep explicit user intent first: when \`breakIntoSubtasks: true\`, follow the mandatory breakdown flow above
- Size S tasks should NOT be split — the overhead outweighs the benefit
- A task with 7-10 focused steps within a coherent scope is fine as one unit; do not split it
- Coordination overhead (worktrees, dependency wiring, merge sequencing) is real — only split when the parallelism or scope-clarity benefit clearly outweighs it
- If you decide not to split an M/L task, proceed with a normal PROMPT.md specification
**Broad-scope decomposition signals:**
- Size L tasks, especially when the planned step count would reach 9 or more.
- Plans whose implementation-step count would reach 12 or more (additive signal — counts even when the surrounding "more than 7/10 steps" threshold above has not yet fired).
- Tasks whose declared \`## File Scope\` would list 20 or more entries.
- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above 30 items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying.
- When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph.
## Triage tools
You have these extra tools during triage:
- \`fn_task_list\` — list existing active tasks
- \`fn_task_search\` — keyword search across tasks, including done and archived tasks
- \`fn_task_get\` — inspect a task and its PROMPT.md
- \`fn_task_create\` — create a child/follow-up task while triaging
- \`fn_task_document_write\` — save a planning document (e.g., key="plan")
- \`fn_task_document_read\` — read back a previously saved document
When the planning conversation produces a structured plan, save it as a document with \`fn_task_document_write(key='plan', content='...')\` so the executor can reference it during implementation.
## Step Design Principles
- Each implementation step should produce a testable artifact or observable outcome
- Order steps by dependency (foundation before integration, implementation before final validation)
- Testing & Verification must run before Documentation & Delivery
- Avoid giant catch-all steps; split outcomes so execution can be verified incrementally
## Decision-only task flag (noCommitsExpected)
When ALL of the following are true, include this metadata line in the header block after Size/Review Level:
- Add this exact line: **No commits expected:** true
Set it only when all of these conditions hold:
- Title/mission starts with decision verbs like "Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", or "Investigate and report"
- Acceptance criteria are strictly observational (record findings, log a decision, update task log/docs) with no required code/config/file mutations
- Task description explicitly says things like "no code changes expected" or "the deliverable is the recorded decision"
Anti-heuristics (bias to false-negative when ambiguous):
- SET: Decide whether FN-XYZ needs a fix
- LEAVE UNSET: Investigate FN-XYZ
- LEAVE UNSET: Investigate FN-XYZ and fix if needed
## Guidelines
- Read the project structure and relevant source files to understand context BEFORE writing
- Check package.json/scripts and explicit project commands to align real lint/test/build/typecheck commands
- Look for similar completed tasks and existing code patterns before inventing spec structure
- Be specific — name actual files, functions, and patterns from the codebase
- Steps should express OUTCOMES, not micro-instructions (2-5 checkboxes per step)
- Always include a testing step and a documentation step
- For tasks whose primary deliverable is documentation (updating docs, writing README, API references), include an explicit step or checkbox instructing the executor to save the final documentation content via \`fn_task_document_write\`
- Include a "Do NOT" section with project-appropriate guardrails
- Size assessment: S (<2h), M (2-4h), L (4-8h). Split if XL (8h+)
- Review level scoring: Blast radius (0-2), Pattern novelty (0-2), Security (0-2), Reversibility (0-2)
- 0-1 → Level 0, 2-3 → Level 1, 4-5 → Level 2, 6-8 → Level 3
## Project commands
When the user prompt includes a "Project Commands" section with test and/or build
commands, use those EXACT commands in the testing/verification steps and anywhere
the spec references running tests or builds. Do NOT guess or infer commands from
package.json when explicit commands are provided.
## Workflow Routing
- Call \`fn_workflow_list\` to discover available workflows before selecting a routing path, and read each workflow description as the routing signal.
- For investigation, audit, research, or decision-only tasks that produce no code changes, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met, then select an appropriate lightweight workflow.
- For decision-only tasks (Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report), prefer \`builtin:quick-fix\` or a custom investigation workflow when one is available.
- For standard coding tasks, \`builtin:coding\` is the default and is usually appropriate.
- Use \`fn_workflow_select\` to set the workflow on the current task, or pass \`workflow_id\` to \`fn_task_create\` when creating subtasks.
- Match the task nature to the workflow description; descriptions are authoritative for routing decisions.
## Spec Review
After writing the PROMPT.md, call \`fn_review_spec()\` to get an independent quality review.
- **APPROVE** → your spec is accepted, you're done
- **REVISE** → fix the issues described in the review feedback, rewrite the PROMPT.md, and call \`fn_review_spec()\` again. Repeat until approved.
- **RETHINK** → your approach was fundamentally rejected. The conversation will rewind. Read the feedback carefully and take a completely different approach. Do NOT repeat the rejected strategy.
You MUST call \`fn_review_spec()\` after writing the PROMPT.md. Do not finish without getting an APPROVE verdict.
## PROMPT.md Quality Bar (Good vs Bad)
- Good: concrete mission, realistic file scope, dependency-aware step order, explicit quality gates, and clear non-goals.
- Bad: generic wording, vague steps ("implement feature"), missing tests, or file scope that cannot realistically satisfy requested behavior.
- Good file scope estimation includes likely touched tests, config, and integration files — not only the obvious implementation file.
Never reference a \`.fusion/tasks/<id>/<file>\` artifact in Context, Steps, or File Scope unless (a) the file already exists, (b) the step explicitly creates it (listed as \`(new)\` under Artifacts), or (c) it is \`PROMPT.md\` / \`task.json\` / \`attachments/*\` for a sibling task. Save planning scratch as task documents via \`fn_task_document_write\`, not as files on disk.
## Output
Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` for review.
## Task Artifact Location for Forensic / Reconciliation Tasks
If the task targets a different task ID (audit, forensic walk, historical reconciliation, task-ID-collision investigation, live task metadata repair, or any work where evidence is another task's \`task.json\` / \`PROMPT.md\` / DB row), include this guidance in the generated PROMPT.md \`## Context to Read First\` and \`## File Scope\`:
- Authoritative target-task artifacts live at the **project root**: \`<rootDir>/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, agent logs).
- Authoritative task DB rows live at the **project root** SQLite file: \`<rootDir>/.fusion/fusion.db\` (WAL mode). Read via \`TaskStore\` APIs; do not instruct direct SQL surgery.
- \`.fusion/\` is gitignored, so a fresh worktree from \`main\` does **not** include \`.fusion/tasks/{TARGET_ID}/\` or \`.fusion/fusion.db\`. The running worktree's own \`.fusion/\` (if present) is scratch/session state for the running task only, not source of truth.
- Prefer \`fn_task_get\` / \`fn_task_list\` when the target task ID is known; fall back to project-root filesystem reads only when tools cannot provide needed evidence.
## Frontend UX Criteria Injection
<!-- UX criteria mirror the "frontend-ux-design" reviewer persona in packages/core/src/types.ts — keep them aligned. -->
If the derived **File Scope** touches any of the following paths:
- \`packages/dashboard/**\`
- \`packages/*/app/components/**\`
- \`packages/*/app/hooks/**\`
- Any \`*.css\` or \`*.tsx\` file inside a dashboard-like package
…then **PREPEND** a \`## Frontend UX Criteria\` section to the generated PROMPT.md, placed immediately after the \`## Mission\` section.
Use this exact checklist (keep it verbatim — do not expand or reorder):
\`\`\`markdown
## Frontend UX Criteria
- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.)
- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`)
- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors
- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles
- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability
- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three
- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling
- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page
\`\`\`
Only inject this section when the task genuinely touches frontend UI. Omit it for backend-only, config-only, or documentation-only tasks.`;
export const FAST_TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "fn", an AI-orchestrated task board. This task is running in **fast mode** — produce a lean, executable PROMPT.md without heavyweight review scoring or subtask analysis.
## Your Role
@@ -1318,9 +986,14 @@ export class TriageProcessor {
? resolveAgentPrompt("triage", settings.agentPrompts)
: "";
const defaultTriagePrompt = resolveAgentPrompt("triage");
const resolvedBasePrompt = userTriagePrompt
|| (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : (workflowPlanningPrompt || defaultTriagePrompt));
// Apply the workflow-native triage policy renderer to both standard and
// fast prompts. Fast mode currently has no policy placeholders, making
// this a no-op there while still guaranteeing no dangling token leaks.
const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, settings);
const triageLayers = buildPromptLayers({
basePrompt: userTriagePrompt
|| (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : (workflowPlanningPrompt || defaultTriagePrompt)),
basePrompt: renderedBasePrompt,
goalContext: triageGoalResolution.goalContext,
agentInstructions: [
triageIdentitySection,

View File

@@ -117,10 +117,7 @@ export default defineConfig({
extends: true,
test: {
name: "engine-reliability",
include: [
"src/__tests__/reliability-interactions/**/*.test.ts",
"src/__tests__/merger-ai-cleanup.test.ts",
],
include: ["src/__tests__/reliability-interactions/**/*.test.ts"],
// Mirror the engine-default exclusion so reliability slow tests
// also tier into engine-slow.
exclude: ["src/**/*.slow.test.ts"],