From e22afece562a4182cc44a3f6f049f691220e3de7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 11 Jun 2026 20:53:01 -0700 Subject: [PATCH] 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 --- .../FN-6233-triage-threshold-settings.md | 7 + docs/settings-reference.md | 19 + docs/workflow-steps.md | 2 +- .../core/src/__tests__/agent-prompts.test.ts | 16 +- .../builtin-workflow-settings-triage.test.ts | 63 ++++ .../__tests__/settings-consistency.test.ts | 24 +- .../src/__tests__/settings-migration.test.ts | 12 +- .../__tests__/workflow-ir-settings.test.ts | 11 +- packages/core/src/agent-prompts.ts | 20 +- .../core/src/builtin-workflow-settings.ts | 154 +++++++- packages/core/src/index.ts | 7 +- packages/core/src/moved-settings.ts | 13 +- ...iage-planning-prompt-single-source.test.ts | 9 +- .../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(-) create mode 100644 .changeset/FN-6233-triage-threshold-settings.md create mode 100644 packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts create mode 100644 packages/engine/src/__tests__/triage-threshold-settings.test.ts diff --git a/.changeset/FN-6233-triage-threshold-settings.md b/.changeset/FN-6233-triage-threshold-settings.md new file mode 100644 index 0000000000..2ee0ca425c --- /dev/null +++ b/.changeset/FN-6233-triage-threshold-settings.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 1e8d0f188d..8243140d5b 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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 diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 761b52dc4e..b508da9152 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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 diff --git a/packages/core/src/__tests__/agent-prompts.test.ts b/packages/core/src/__tests__/agent-prompts.test.ts index a9d08d804f..f926f0c415 100644 --- a/packages/core/src/__tests__/agent-prompts.test.ts +++ b/packages/core/src/__tests__/agent-prompts.test.ts @@ -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", () => { diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts new file mode 100644 index 0000000000..4c35579cb5 --- /dev/null +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -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 = { + 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/); + }); +}); diff --git a/packages/core/src/__tests__/settings-consistency.test.ts b/packages/core/src/__tests__/settings-consistency.test.ts index 7c32d2196b..dc829a494f 100644 --- a/packages/core/src/__tests__/settings-consistency.test.ts +++ b/packages/core/src/__tests__/settings-consistency.test.ts @@ -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", () => { diff --git a/packages/core/src/__tests__/settings-migration.test.ts b/packages/core/src/__tests__/settings-migration.test.ts index 24735ea5f7..6f4e465f9c 100644 --- a/packages/core/src/__tests__/settings-migration.test.ts +++ b/packages/core/src/__tests__/settings-migration.test.ts @@ -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).not.toHaveProperty(setting.id); + } expect(MOVED_SETTINGS_KEYS.length).toBe(26); }); diff --git a/packages/core/src/__tests__/workflow-ir-settings.test.ts b/packages/core/src/__tests__/workflow-ir-settings.test.ts index a12300681c..722cf6d1fd 100644 --- a/packages/core/src/__tests__/workflow-ir-settings.test.ts +++ b/packages/core/src/__tests__/workflow-ir-settings.test.ts @@ -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 { diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index 58a70ef1e5..8de01a32f5 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -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. diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 2d85801ebc..3074c2595b 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -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): string { + let rendered = prompt; + const values = settings as Record; + 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; +} + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c0162d2034..fc4911f7f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/moved-settings.ts b/packages/core/src/moved-settings.ts index d0f7de9aed..a1be408899 100644 --- a/packages/core/src/moved-settings.ts +++ b/packages/core/src/moved-settings.ts @@ -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. */ diff --git a/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts index 84d70b22da..02714d0305 100644 --- a/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts +++ b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts @@ -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 } 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); }); }); diff --git a/packages/engine/src/__tests__/triage-threshold-settings.test.ts b/packages/engine/src/__tests__/triage-threshold-settings.test.ts new file mode 100644 index 0000000000..85bbfe63be --- /dev/null +++ b/packages/engine/src/__tests__/triage-threshold-settings.test.ts @@ -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/); + }); +}); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 1a4d091ac8..ba72e7f06d 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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"); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index c4c8c51e2d..b0c26d9d0c 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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 — \` (the \`\` 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//\` 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**: \`/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, agent logs). -- Authoritative task DB rows live at the **project root** SQLite file: \`/.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 - - - -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, diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index f42b80ab88..a7b3c4010c 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -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"],