From bbbf1db06dba1c38d0253c88b9ca8a7d6c57daa1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 10:47:22 -0700 Subject: [PATCH 1/9] refactor: plan package organization and refresh line-count baseline Add multi-wave god-file organization plan. U1 baseline hygiene: prune graduated/stale ceilings (db.ts), tighten store.ts to live thin-facade size, and one-time re-ratchet organic growth so check:line-count is truthful. --- ...refactor-package-code-organization-plan.md | 503 ++++++++++++++++++ scripts/line-count-baseline.json | 193 +++---- 2 files changed, 601 insertions(+), 95 deletions(-) create mode 100644 docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md diff --git a/docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md b/docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md new file mode 100644 index 0000000000..0ec4764659 --- /dev/null +++ b/docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md @@ -0,0 +1,503 @@ +--- +title: "refactor: Organize package internals and split god-files" +type: refactor +status: active +date: 2026-07-14 +deepened: 2026-07-14 +--- + +# refactor: Organize package internals and split god-files + +## Summary + +A multi-wave, behavior-preserving program to make `@fusion/core`, `@fusion/engine`, `@fusion/dashboard`, and `@runfusion/fusion` (CLI) easier to navigate: split oversized modules into domain-named folders, continue established extraction patterns (TaskStore facade, route registrars, merger satellites, App hooks-first), and graduate files under the 2,000-line line-count ratchet without re-ratcheting ceilings as a substitute for structure. + +--- + +## Problem Frame + +Individual packages already have clear *package* boundaries, but *inside* the large packages navigation is hard. Flat `src/` trees and multi-thousand-line god-files force every change through the same monofiles. **Measure live LOC, not only baseline ceilings** — the ratchet file is often stale high *or* under-grown. Current worst offenders: + +| Area | Examples (approx live / baseline ceiling) | +|------|-------------------------------------------| +| Core | `store.ts` ~2.5k thin facade (baseline still 17,371 — already extracted) · `types.ts` ~8k · `db.ts` ~278 stub (baseline 5,924 stale) · debt in `task-store/remaining-ops-1..10` | +| Engine | `executor.ts` ~19k · `merger.ts` ~12k · `self-healing.ts` ~12k · `agent-heartbeat.ts` ~5.5k | +| Dashboard | `app/api/legacy.ts` ~11.5k · large components 3–5k · residual `routes.ts` / `register-git-github.ts` | +| CLI | `extension.ts` ~5.5k · `dashboard-tui/app.tsx` ~4.6k | + +The repo already *wants* this outcome: `scripts/check-file-line-count.mjs` caps new files at 2,000 and ratchets grandfathered files downward; FNXC history on that script states wholesale god-file shrink is dedicated follow-up work. Prior work established templates (`docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md` completed; `packages/core/src/task-store/`; `packages/dashboard/src/routes/register-*`; `merger-*.ts`; `auto-recovery-handlers/`). The remaining problem is incomplete application at scale—plus intermediate debt such as `task-store/remaining-ops-1..10` (and `*-ops-2` ordinal dumps) that recreated opacity under a new name. + +**Glossary:** A **unit** (U1–U9) is a roadmap work item. A **wave** is one landable PR/slice (a unit may contain multiple waves). A **slice** is an ordered peel inside a unit (e.g. U4 Slice A then B). R6/R13 “wave’s file set” means the landable PR’s files. + +This plan is **not** a product feature, public API redesign, or cross-package ownership redraw. Success is navigability + ratchet progress + green behavior contracts. + +--- + +## Requirements + +### Behavior and contracts + +- R1. All waves are behavior-preserving: no intentional functional, UX, lifecycle, or API-contract change. Compile-only fixes limited to import paths and re-exports; no access-modifier, async/sync, or control-flow “cleanups” while organizing. +- R2. Public package barrels stay stable: `@fusion/core`, `@fusion/engine`, dashboard `routes.ts` entry, `app/api.ts`, merger/self-healing/executor primary module paths used by tests. Temporary re-export shims are allowed; permanent dual homes are not. +- R3. Characterization oracles for each touched god-file stay green. Each unit names an **oracle family** (paths or suite prefixes); the implementer finalizes the exact file list for the symbols moved in that wave. File-scoped verification is required; for pure moves, also run the god-file’s highest-signal existing suites, not only tests that reverse-import empty new files. +- R4. FNXC requirement comments move with the owning behavior (dated, greppable). Facades must not keep the only copy of requirement text after extraction. + +### Structure and ratchet + +- R5. Every **new** file is ≤ 2,000 lines. Prefer domain-named modules and folders; **forbid** new `remaining-ops-N`, `*-ops-N` / `*-2` ordinal dumps, `*-misc.ts`, or similarly opaque files. Oversize domains split by subdomain name, not ordinal. +- R6. Every **touched** grandfathered file’s baseline ceiling ratchets **down** (or the entry is removed when under cap) after a peel. Graduation uses a reviewed baseline edit that **must not increase** any path’s ceiling outside the wave’s file set — **except** U1’s one-time scoped re-ratchet of organic growth (KTD10). +- R7. Folder organization follows existing conventions per package layer (see KTDs)—extend patterns already in-tree rather than inventing a new global layout. + +### Safety invariants + +- R8. Route registrar **mount order** is frozen unless a wave’s goal is explicitly a documented reordering with precedence characterization. Splits must not reorder registrars as a side-effect. +- R9. Dashboard lazy-view inventory (`lazy()` non-underscore consts + AGENTS “Lazy-Loaded Heavy Views” + `lazy-loaded-views-docs.test.ts`) is untouched by organize waves. Eager FOUC CSS imports stay at the documented site. +- R10. Static `@fusion/*` imports only; no dynamic engine import tricks; no React/CSS re-exports from server plugin barrels. +- R11. Cross-package direction preserved: core ↛ engine/dashboard; engine DI via existing `setCreateFnAgent` (and similar) only. +- R12. No changeset for pure internal refactors of private packages. `@runfusion/fusion` gets a changeset only if a wave changes published CLI surface (not expected). + +### Wave hygiene + +- R13. Before landing a hot-file wave, rebase/sync main and apply the **extract-vs-semantics protocol**: keep the structural call site; **port main’s semantics into the new module**; never `--ours`/`--theirs`; run the union of both sides’ targeted suites. +- R14. `pnpm check:line-count` is a hard wave exit criterion (even though it is opt-in for normal pretest). +- R15. Existing `remaining-ops-*` modules are only allowed as **migration sources** to domain-named targets; each wave that touches them must shrink or delete at least one, never add another. + +--- + +## Key Technical Decisions + +- KTD1. **Program shape = multi-wave roadmap, not one mega-PR.** Waves are file-disjoint where possible, landable independently, and ordered so core symbol moves complete before dependent engine/dashboard rebases. Prefer many small extractions over one un-rebaseable split. +- KTD2. **Reuse five extraction patterns already proven in-repo** (pick by surface; do not invent a sixth style): + 1. **Facade + `*Impl`** — `TaskStore` / `task-store/` (public class stays; bodies move). + 2. **Registrar + shared context** — `routes.ts` + `routes/register-*` + `ApiRoutesContext`. + 3. **Satellite modules + re-export** — `merger.ts` → `merger-*.ts` (deep test mocks keep parent path). + 4. **Hooks-first UI** — completed App.tsx playbook: hooks, utils, presentational components, transient shims. + 5. **Handler folder + dispatcher** — policy trees (`auto-recovery-handlers/`) when methods cluster by reconcile domain (self-healing). +- KTD3. **Domain-named modules only; ban extending `remaining-ops` and ordinal dump suffixes.** Intermediate numeric dump files are technical debt. New extracts must name the domain (`merge-queue`, `archive-lifecycle`, `workflow-definitions`, `executor/prompt`, `self-healing/workspace`, etc.). When a domain exceeds 2,000 lines, split by **subdomain** (`merge-queue-lease.ts`, `merge-queue-cleanup.ts`), never by ordinal (`*-ops-2`, `*-2`, `remaining-*`). Existing `remaining-ops-*` and `*-ops-2` / `*-2` files are **migration sources only**, not templates. Migrating *out of* them is in-scope for core waves. +- KTD4. **Import stability strategy:** package barrels and established deep entrypoints are permanent. Mechanical moves keep temporary re-exports on the old path until a follow-up re-point wave. Track shims in each landable PR description as `old path → new path → delete-when` (no separate standing ledger file). Drop shims only when consumers are re-pointed; do not leave dual homes indefinitely. +- KTD5. **Graduation policy is ratchet-down-only after U1.** Prefer hand-editing `scripts/line-count-baseline.json` for touched paths over full `--update`. If full `--update` is used, the PR must show no ceiling increases outside the wave’s file set. +- KTD6. **Facade vs dump metrics.** For `store.ts`, the facade is already ~2.5k thin wrappers — primary progress is **domain migration of `remaining-ops-*` / ordinal dumps**, not re-fighting a 17k monofile. Elsewhere, facade/parent LOC decline remains first-class: satellites under cap while the parent stays huge is partial progress only. Thin shells may stay slightly over 2,000 only when explicitly marked and on a path toward graduation. +- KTD7. **Characterization-first on engine hot files** (executor, self-healing, merger, heartbeat). Pin existing focused suites green before moves; pure free functions peel first (lowest risk); class method clusters second. +- KTD8. **Production-first; test mega-files co-located only when required.** Test splits use harness + sibling suites (FN-7035 style) when a production split forces co-located test moves, or when a wave’s navigation goal includes a specific suite. Bulk “split all 55 test offenders” is deferred follow-up. +- KTD9. **Cycle budget:** impl/domain modules may import pure helpers and types; facade may import impls; domain impls must not re-enter the same facade methods that would re-import the domain (except documented DI seams). Prefer passing narrow deps over `import { TaskStore } from "../store.js"` cycles. +- KTD10. **Baseline hygiene is wave zero (two operations).** (1) **Prune / tighten stale-high** entries where live LOC is far below ceiling (`db.ts`, `store.ts`, graduated files). (2) **One-time scoped re-ratchet** of paths that already grew past their ceiling (e.g. executor, self-healing, types, legacy, extension) to current live counts — FN-7046-style repair so `pnpm check:line-count` is truthful before peels. After U1, ceilings never rise again except by mistake (forbidden); peels only ratchet down. + +--- + +## High-Level Technical Design + +### Wave lifecycle (every unit) + +```mermaid +flowchart LR + A[God file on baseline] --> B[Domain extract ≤2000] + B --> C[Facade / re-export shims] + C --> D[Oracle suites + typecheck] + D --> E{Under 2000?} + E -->|yes| F[Ratchet-down baseline] + E -->|no| G[Next domain peel] + D --> H[Main churn] + H --> I[Port semantics into new home] + I --> D +``` + +### Pattern selection by package layer + +```mermaid +flowchart TB + subgraph Core + TS[types.ts] -->|domain barrels| TDir[types/*.ts] + ST[store.ts facade] -->|named *Impl| TStore[task-store/domain-*.ts] + end + subgraph Engine + EX[executor.ts] -->|pure first then lifecycle| EDir[executor/*.ts] + SH[self-healing.ts] -->|reconcile clusters| SHDir[self-healing/*.ts] + MG[merger.ts] -->|continue satellites| MSat[merger-*.ts] + HB[agent-heartbeat.ts] -->|prompts + helpers| HBSat[agent-heartbeat-*.ts] + end + subgraph Dashboard + LG[app/api/legacy.ts] -->|domain client API| Api[app/api/*.ts] + RT[routes.ts] -->|more registrars| Reg[routes/register-*.ts] + UI[Large views] -->|hooks-first| Hooks[app/hooks + components] + end + subgraph CLI + EXT[extension.ts] -->|tool families| Tools[extension/*.ts] + TUI[dashboard-tui/app.tsx] -->|views| TuiViews[dashboard-tui/views] + end +``` + +### Target folder sketches (directional — implementer may adjust names) + +```text +packages/core/src/ + types/ # domain type modules; types.ts becomes barrel + task-store/ # existing; replace remaining-ops-* with domain names over waves +packages/engine/src/ + executor/ # pure helpers, dispatch, worktree, step-session, completion + self-healing/ # startup, dependency, in-review, merge, workspace, surfacing +packages/dashboard/app/api/ + tasks.ts agents.ts settings.ts missions.ts ... # client fetch surface +packages/cli/src/ + extension/ # tool-family registrars + commands/dashboard-tui/views/ +``` + +Public import paths remain the parent module or package barrel until shim cutover. + +### Hot-file conflict protocol (R13) + +On rebase/merge conflict for an extracted block: + +1. Keep the **new structure** (call site / helper path). +2. Port **incoming semantics** into the helper’s body (not the old monofile copy). +3. Never resolve with `--ours` / `--theirs` alone. +4. Re-run the **union** of both branches’ oracle suites + package typecheck. +5. Fix FNXC/docs the move falsified. + +Reference: `docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md`. + +### Graduation baseline edit (R6 / KTD5) + +Preferred: hand-edit only keys for files this wave touched (lower ceiling or delete entry). +If `node scripts/check-file-line-count.mjs --update` is used: diff `scripts/line-count-baseline.json` and reject any ceiling **increase** for paths outside the wave. + +--- + +## Scope Boundaries + +**In scope** + +- Behavior-preserving organization inside `packages/core`, `packages/engine`, `packages/dashboard`, `packages/cli`. +- Domain folders, god-file splits, facade/shim stability, ratchet graduation, baseline hygiene. +- Migrating `task-store/remaining-ops-*` toward named domains. +- Co-located test harness/sibling splits when forced by production moves or when a wave explicitly targets a suite. + +**Out of scope (non-goals)** + +- Cross-package ownership redraws (e.g. moving engine logic into core or vice versa beyond existing DI). +- Public product features, UX redesign, settings schema changes. +- Plugin packages (`plugins/*`) except incidental import-path fixes if a consumer path breaks. +- Replacing the line-count cap policy or moving `check:line-count` into the merge gate (optional future). +- Wholesale split of all 55 oversized test files as a standalone campaign. +- Semantic “cleanups” bundled into organize waves (error-path merges, backendMode guard changes, soft-delete matrix edits). + +### Deferred to Follow-Up Work + +- Full test mega-file campaign (all oversized `*.test.ts(x)`). +- Permanent shim removal waves after consumer re-points (PR description `old → new → delete-when` history). +- Thin-shell graduation of residual facades that remain slightly over 2,000 after domain peels. +- Compounding a `docs/solutions/architecture-patterns/` entry for the organization playbook after first waves land. +- `dev-server-*` vs `devserver-*` consolidation (`docs/dev-server-module-boundary-audit.md`) — naming hygiene, not god-file size. +- Re-point pure-function unit tests off App/merger re-export shims from prior plans. +- **Mid-tier production peels (pain-ranked, not this program’s top-N):** `mission-store.ts`, `central-core.ts`, `agent-store.ts`, `agent-tools.ts`, `project-engine.ts`, `scheduler.ts`, `triage.ts`, `github.ts`, `mission-routes.ts`, `cli/commands/dashboard.ts`, and other grandfathered files outside U2–U9. They stay on the ratchet until a follow-up plan. + +--- + +## Phased Delivery + +| Phase | Units | Intent | +|-------|-------|--------| +| A — Foundations | U1 | Conventions, baseline truth, scoreboard | +| B — Core | U2, U3 | Types navigability + TaskStore domain peels | +| C — Engine | U4, U5, U6 | Executor / self-healing / merger+heartbeat | +| D — Surfaces | U7, U8, U9 | Dashboard API/routes · UI monofiles · CLI peels | + +Phases B–D can partially overlap only when file ownership is disjoint **and** no shared symbol is mid-move. Default: finish a core symbol wave before engine rebases that depend on it. U6 may land merger and heartbeat as **separate waves** with independent exits. + +--- + +## Implementation Units + +### U1. Foundations: conventions, baseline hygiene, wave checklist + +- **Goal:** Make the program’s rules and scoreboard accurate before large peels; give every later unit a shared exit checklist. +- **Requirements:** R5, R6, R10, R14, R15 (policy); R12; KTD10 +- **Dependencies:** None +- **Files:** + - `scripts/line-count-baseline.json` (modify — prune stale-high + one-time re-ratchet organic growth per KTD10) + - `docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md` (this plan — already present) +- **Approach:** + - Measure live LOC for all baseline entries. + - **Prune/tighten stale-high** (`db.ts`, `store.ts` → actual ~2.5k, any graduated ≤2,000 removed). + - **One-time scoped re-ratchet** of paths already over ceiling (executor, self-healing, types, legacy, extension, …) to current live counts so the check is truthful. + - After U1, no further ceiling increases; peels only ratchet down. + - Encode the per-wave checklist (below) into implementer practice for U2+. +- **Patterns to follow:** FNXC history in `scripts/check-file-line-count.mjs`; FN-7046/7050 scoped baseline repairs. +- **Test scenarios:** + - Happy path: after baseline edit, `pnpm check:line-count` is green against the truthful scoreboard. + - Edge: JSON diff shows only intentional prune/tighten and documented re-ratchets; no silent ceiling creep on untouched paths beyond the U1 growth list. + - Error: removing an entry still >2,000 without a split fails the check — keep grandfathered until peeled. +- **Verification:** `pnpm check:line-count` green; baseline reflects live sizes. +- **Per-wave checklist (apply U2–U9):** + 1. Oracle family for the wave green (unit-specific). + 2. Package typecheck for touched package(s). + 3. Mock-path grep for moved modules (`vi.mock`, deep relative imports). + 4. For core graph moves: re-audit `packages/core/src/index.gate.ts` / engine-core gate consumers if barrel closure changes. + 5. Route mount order unchanged (dashboard). + 6. Lazy inventory untouched (dashboard UI). + 7. FNXC ownership map: comments live with behavior. + 8. New files ≤2,000; touched baseline ceilings ratchet **down** only (post-U1). + 9. Hot-file conflict protocol if rebased against main. + 10. PR description lists shims: `old path → new path → delete-when`. + +--- + +### U2. Core: split `types.ts` into domain barrels + +- **Goal:** Make domain types findable under `packages/core/src/types/` while `types.ts` remains the stable re-export surface (including the **dashboard Vite `@fusion/core` browser alias**). +- **Requirements:** R1, R2, R5, R6, R10, R11 +- **Dependencies:** U1 +- **Files:** + - `packages/core/src/types.ts` (modify → thin barrel; **must remain** the browser-safe alias target) + - `packages/core/src/types/*.ts` (new — domain clusters: e.g. task/board, agent/permission, merge/settings, workflow/work-item, messaging — names chosen by natural export clusters) + - `packages/core/src/index.ts` (modify only if re-export paths require it; prefer stable) + - `packages/core/src/index.gate.ts` (verify closure) + - Oracles: core typecheck; dashboard package typecheck/build (Vite alias surface); existing type-importing suites +- **Approach:** Peel by domain clusters already partially started (`planner-overseer-state`, capacity, gitlab-config, mcp-config re-exports). Keep every symbol the dashboard imports via the Vite alias as **type-only or pure/browser-safe** re-exports on `types.ts` — never pull Node-only stores into that surface. Prefer pure type/interface/const moves into domain modules. +- **Execution note:** Low risk — still run core + dashboard typecheck so the browser alias contract is proven. +- **Patterns to follow:** Existing partial peels re-exported from `types.ts` (see FNXC on `types.ts` for Vite alias); package barrel style in `index.ts`. +- **Test scenarios:** + - Happy path: every previously exported symbol remains importable from `@fusion/core` / `./types.js` with identical types. + - Edge: circular type imports between domains — resolve via shared `types/common` or one-way deps only; browser alias still resolves without Node-only deps. + - Integration: engine and dashboard packages typecheck against the barrel; dashboard client build does not pull store/engine into the browser bundle via the alias. +- **Verification:** Core + dashboard typecheck green; baseline ceiling for `types.ts` ratchets down; new type modules ≤2,000 each. + +--- + +### U3. Core: first domain peels out of `remaining-ops-*` (TaskStore facade) + +- **Goal:** Land **first domain peels** from opaque `remaining-ops-*` (and related ordinal dumps) into domain-named `task-store/` modules; eliminate ≥1 dump file and shrink the dump surface. The facade stays on `TaskStore` and already mostly delegates — further facade thinning is secondary. +- **Requirements:** R1–R4, R5–R7, R11, R13–R15 +- **Dependencies:** U1; prefer U2 landed if types move affect store signatures +- **Files:** + - `packages/core/src/store.ts` (modify only as import/wrapper thinness requires) + - `packages/core/src/task-store/*` (create domain modules; delete emptied `remaining-ops-N.ts` / ordinal dumps when fully migrated) + - `packages/core/src/task-store/index.ts` (update module map FNXC) + - Oracles: `packages/core/src/__tests__/store-settings.test.ts` plus soft-delete / move / merge-queue suites under `packages/core/src/__tests__/` matching the domain peeled + - `scripts/line-count-baseline.json` (ratchet touched paths) +- **Approach:** + - Publish a **first-wave symbol map** before coding: functions, current dump file, target module, co-moved callees (especially cross-dump imports). + - Prefer one cohesive domain first (e.g. merge-queue **or** workflow-definitions). + - Move `*Impl` functions; update facade imports; delete empty dump files. + - Do not add `remaining-ops-11` or `*-ops-3`; split by subdomain name if over 2,000. + - Remaining dumps continue in follow-up waves of this unit or a later plan — U3 is **not** “finish all ten dumps in one land.” +- **Execution note:** Characterization-first: green the relevant store suites **before** each peel; after peel re-run same suites. Rebase protocol if `store.ts` / dump files conflict with main. +- **Patterns to follow:** Existing `moves.ts`, `file-scope.ts`, `lifecycle*.ts` style; `task-store/index.ts` FNXC decomposition notes. +- **Test scenarios:** + - Happy path: suites for the peeled domain still pass (e.g. merge-queue lease/enqueue if that domain). + - Edge: soft-delete write blocks, dependency cycle rejection still pass when those symbols move. + - Error: TaskNotFound / soft-deleted write paths still throw the same error classes. + - Integration: no new store↔impl import cycle; gate bundle still loads if core entry changes. +- **Verification:** Oracles green; ≥1 `remaining-ops-*` (or ordinal dump) fully eliminated; dump LOC net-down; baseline ratchet-down; `pnpm check:line-count`. + +--- + +### U4. Engine: peel `executor.ts` pure helpers, then first lifecycle folders + +- **Goal:** Introduce `packages/engine/src/executor/` (or satellite `executor-*.ts` if flatter is clearer for first slice) for free-function clusters already at module top; then extract 1–2 lifecycle clusters from `TaskExecutor` without changing runtime behavior. +- **Requirements:** R1–R4, R5–R7, R10, R13, R14 +- **Dependencies:** U1; U3 if store symbols used by peels moved (usually independent) +- **Files:** + - `packages/engine/src/executor.ts` (modify — re-export + thinner body) + - `packages/engine/src/executor/*.ts` or `executor-*.ts` (new) + - Oracles: `packages/engine/src/__tests__/executor-*.test.ts` focused set for peels (prompt/refusal/requeue signature/worktree/task-done as applicable); helpers in `executor-test-helpers.ts` + - `scripts/line-count-baseline.json` +- **Approach:** + - **Slice A (required first):** pure exports — prompt/refusal/requeue signatures, browser probe, workflow feedback path helpers — move with re-exports from `executor.ts`. + - **Slice B:** one lifecycle cluster (e.g. worktree acquisition **or** step-session **or** completion handoff) only after Slice A green. + - Keep public symbols re-exported from `executor.ts` for deep `vi.mock("../executor.js")` stability. +- **Execution note:** Characterization-first. Do not change dissent patterns, refusal classification, or requeue constants “while here.” +- **Patterns to follow:** Merger satellite re-export pattern; workflow-native primitives split (policy vs side effects) in `docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md`. +- **Test scenarios:** + - Happy path: existing executor suites for moved pure functions assert same outputs for same inputs. + - Edge: requeue loop signature progress detection unchanged; no-commit eligibility heuristics unchanged. + - Error: invalid assistant continuation and transient missing task.json classifiers unchanged. + - Integration: step-session / task-done paths still pass focused executor tests after lifecycle peel. +- **Verification:** Oracles green; `executor.ts` ceiling ratchets down; new modules ≤2,000; check:line-count. + +--- + +### U5. Engine: split `self-healing.ts` into domain handler modules + +- **Goal:** Organize `SelfHealingManager` reconcile/recover/surface methods into `packages/engine/src/self-healing/` (or `self-healing-*.ts`) by domain so AGENTS run-audit inventory maps to files. +- **Requirements:** R1–R4, R5–R7, R13, R14 +- **Dependencies:** U1; prefer after U4 if shared helpers overlap, else independent +- **Files:** + - `packages/engine/src/self-healing.ts` (facade / re-exports / manager orchestration) + - `packages/engine/src/self-healing/*.ts` (new — e.g. startup, dependency, in-review, merge-status, workspace, surfacing, temp-worktree) + - Oracles: per domain peel, name 1–3 focused files (`self-healing-*.test.ts`, `in-review-unmet-dependency-reconcile.test.ts`, workspace reconcile tests, meta-archive guards) **or** a specific describe block inside `self-healing.test.ts` — do not treat “whole 9k suite optional” as zero coverage. + - Optionally start a sibling suite only if extracting forces test navigability for a cluster + - `scripts/line-count-baseline.json` +- **Approach:** Mirror `auto-recovery-handlers/` (pattern 5): group methods by reconcile family; inject manager deps rather than free-floating globals; keep `SelfHealingManager` public class on the stable path or re-exported. Preserve existing cycle-break patterns (e.g. dynamic merger import, workspace-land predicates). +- **Execution note:** Characterization-first; hot-file rebase protocol. Do not merge distinct recovery outcomes into one branch. +- **Patterns to follow:** `auto-recovery-handlers/`; AGENTS run-audit event inventory as domain map. +- **Test scenarios:** + - Happy path: representative reconcile methods still emit expected run-audit event names/metadata shapes (ids/counts only). + - Edge: `autoMerge:false` / user-paused / triple-proof no-action paths still no-op. + - Error: unrecoverable parks remain parked; budgets not reset incorrectly. + - Integration: startup recovery vs steady-state sweep distinction preserved. +- **Verification:** Focused self-healing oracles green; line-count ratchet; manager file shrinks. + +--- + +### U6. Engine: continue merger satellites + heartbeat peel + +- **Goal:** Further reduce `merger.ts` and `agent-heartbeat.ts` via additional satellites (verification/autostash/commit ceremony leftovers; heartbeat prompts + recovery helpers). +- **Requirements:** R1–R4, R5–R7, R13, R14 +- **Dependencies:** U1; can parallel U4/U5 if file-disjoint +- **Files:** + - `packages/engine/src/merger.ts` + new/extended `merger-*.ts` + - `packages/engine/src/agent-heartbeat.ts` + `agent-heartbeat-prompts.ts` / helper satellites + - Oracles: `merger-merge-lifecycle`, `merger-verification`, `merger-conflict-resolution`, `merger-diff-scope` (and helpers); `heartbeat-monitor`, `heartbeat-executor`, `heartbeat-error-recovery`, `agent-heartbeat-*` + - `scripts/line-count-baseline.json` +- **Approach:** Same satellite + re-export pattern already used for merger AI/diff-volume/overlap/etc. Heartbeat: peel prompt constants and pure recovery helpers first. +- **Patterns to follow:** Existing `merger-*.ts` set; heartbeat pure helpers near file bottom already half-extracted. +- **Test scenarios:** + - Happy path: merge lifecycle + verification suites green. + - Edge: diff-volume gate and overlap guard behavior unchanged. + - Error: conflict classification and transient merge error classifier paths unchanged. + - Heartbeat: error-recovery budget metadata and park reasons unchanged. +- **Verification:** Oracles green; both baselines ratchet down; new files ≤2,000. + +--- + +### U7. Dashboard API: split `legacy.ts` client surface + continue route peels + +- **Goal:** Make client API and server route registration navigable: domain modules under `app/api/`; further split oversized registrars (`register-git-github.ts`, residual helpers in `routes.ts`) without changing mount order. +- **Requirements:** R1–R4, R5–R9, R13, R14 +- **Dependencies:** U1; independent of engine units if no shared symbol moves +- **Files:** + - `packages/dashboard/app/api/legacy.ts` → domain modules + **re-export shims on `legacy.ts`** + - `packages/dashboard/app/api.ts` (barrel stability — continues `export *` from legacy) + - `packages/dashboard/src/routes.ts`, `packages/dashboard/src/routes/register-*.ts`, `packages/dashboard/src/routes/README.md` (module map update) + - Oracles: `packages/dashboard/app/api/__tests__/legacy-*.test.ts`, `packages/dashboard/src/__tests__/routes-*.test.ts`, `routes/__tests__/register-*.test.ts` + - `scripts/line-count-baseline.json` +- **Approach:** + - Client: group exports by domain (tasks, agents, settings/memory, missions, git/github, remote, etc.). **Freeze both** `app/api.ts` and `app/api/legacy.ts` as stable import paths until a named re-point wave — many hooks deep-import `api/legacy` today. Domain modules are implementation only. + - Server: peel remaining residual handlers into registrars; split `register-git-github.ts` into git plumbing vs GitHub issue/PR if still oversized; **do not reorder mounts**. +- **Patterns to follow:** `packages/dashboard/src/routes/README.md`; `app/api.ts` re-export style. +- **Test scenarios:** + - Happy path: helpers still produce same paths/methods via `api.ts` **and** `api/legacy` imports; route registration tests pass. + - Edge: project-scoping helpers (`getScopedStore`) still applied on moved handlers. + - Error: `ApiRequestError` / duplicate-candidate error classes still thrown as today. + - Integration: mount-order sensitive routes still match the more-specific registrar first (spot-check overlapping patterns if any). +- **Verification:** Oracles green; ceilings ratchet; README registrar map updated; mount order unchanged; both client entrypaths still resolve. + +--- + +### U8. Dashboard UI monofile peels (App.tsx playbook) + +- **Goal:** Apply the completed App.tsx playbook to **1–2** highest-pain UI monofiles chosen at execution by live LOC + ownership (candidates: `AgentDetailView`, `TaskDetailModal`, `WorkflowNodeEditor`, `ChatView`, `MissionManager`). +- **Requirements:** R1–R5, R6, R7, R9, R14 +- **Dependencies:** U1; prefer U7 first if those components tightly couple to API module paths mid-move +- **Files:** + - Selected `packages/dashboard/app/components/*.tsx` + new hooks/subcomponents under `app/hooks/` / `app/components/` + - Component `__tests__/*` oracles for those surfaces + - `scripts/line-count-baseline.json` +- **Approach:** Hooks-first + presentational extract; preserve lazy inventory; transient re-export shims only if pure helpers are currently imported from the monofile. Do **not** include CLI work here (see U9). +- **Execution note:** Browser smoke after build when visual shell/modals move (worktree-safe; never port 4040). +- **Patterns to follow:** Completed App.tsx plan (`docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md`). +- **Test scenarios:** + - Happy path: component behavior contracts for extracted surfaces remain green. + - Edge: mobile + desktop breakpoints covered by existing tests where present. + - Error: modal error/empty states unchanged. +- **Verification:** Oracles green; selected UI files under or toward 2,000; check:line-count; lazy inventory test untouched/green. + +--- + +### U9. CLI: extension tool-family peels + dashboard-tui views + +- **Goal:** First-wave CLI organization: peel `extension.ts` into tool-family modules and/or continue `dashboard-tui/app.tsx` into views — land as separate waves with independent exits. +- **Requirements:** R1–R5, R6, R7, R14 +- **Dependencies:** U1; independent of U8 +- **Files:** + - `packages/cli/src/extension.ts` → `packages/cli/src/extension/*.ts` with re-exports from `extension.ts` + - `packages/cli/src/commands/dashboard-tui/app.tsx` → views/components under `dashboard-tui/` + - Oracles: `packages/cli/src/__tests__/extension.test.ts`, `extension-task-tools.test.ts`, `dashboard-tui/__tests__/*` as matching the peel + - `scripts/line-count-baseline.json` +- **Approach:** Registrar composition like routes — `extension.ts` stays the public entry. TUI continues the existing controller/state folder split into views. Either target may land alone; both are not required for unit close. +- **Patterns to follow:** Dashboard route registrars; existing `dashboard-tui/controller.ts` + `state.ts`. +- **Test scenarios:** + - Happy path: tool registration still exposes the same tool names/schemas; TUI behavior contracts green for extracted views. + - Edge: extension host mock paths / `vi.mock` of extension entry still resolve via re-exports. + - Error: command failure messaging unchanged for peels that touch error surfaces. +- **Verification:** Oracles green for the landed wave(s); entry file(s) ratchet down; check:line-count. + +--- + +## Risk Analysis & Mitigation + +| Risk | Mitigation | +|------|------------| +| Silent semantic revert on rebase | Hot-file protocol (R13); never take a single merge side | +| `remaining-ops` opacity recreates itself | Ban new dumps (R15, KTD3); delete sources as domains land | +| Full `--update` re-grandfathers growth | Ratchet-down-only policy (KTD5) | +| Facade never shrinks | KTD6 metric; thin-wrapper collapse required | +| False green from too-narrow file-scoped tests | Oracle lists per unit; run god-file’s known suites | +| Circular imports explode | KTD9 cycle budget; prefer deps injection | +| Mount-order / lazy inventory breakage | R8, R9 explicit freezes | +| Merge gate misses non-blocking suite failures | Treat named oracles as required even if non-gate | +| Parallel agents on same monofile | File-disjoint waves; mass-migration fleet lesson | + +--- + +## System-Wide Impact + +- **Developers / agents:** Primary beneficiaries — shorter files, domain folders, clearer ownership. +- **CI / ratchet:** Baseline file changes in graduation PRs; opt-in check becomes required for these waves. +- **Runtime users:** No intentional product change; residual risk is regression from incomplete port on rebase. +- **Published CLI:** Unchanged surface expected; private package refactors only. +- **Downstream tests/mocks:** Deep relative `vi.mock` paths stay valid via re-exports until shim cutover. + +--- + +## Success Metrics + +- Navigability: top god-files either graduated (<2,000 and off baseline) or on a clear multi-domain folder map with continuous LOC decline. +- Ratchet: U1 makes the scoreboard truthful; post-U1 no wave increases any baseline ceiling outside its file set. +- Safety: zero intentional behavior changes; oracle families green per wave. +- Debt: net reduction in `remaining-ops-*` / ordinal dumps across U3 waves; PR-tracked shims shrink over time. + +--- + +## Open Questions + +### Deferred to implementation + +- Exact domain file names under `types/`, `executor/`, `self-healing/`, and which 1–2 UI monofiles U8 selects first (choose by live LOC + ownership at execution). +- Whether first executor layout is a folder (`executor/`) vs flat `executor-*.ts` satellites (both valid; folder preferred if ≥4 modules). +- Which first domain U3 peels (merge-queue vs workflow-definitions vs other) after the symbol map. +- How aggressively to split `self-healing.test.ts` when peels land (only if suite blocks navigation for a cluster). + +### Resolved defaults (from planning) + +- Multi-wave program across core → engine → dashboard/cli. +- Both folder organization and file splits. +- Production-first; tests co-located when needed. +- Mechanical moves only; characterization-first on engine hot files. + +--- + +## Documentation Plan + +- Update `packages/dashboard/src/routes/README.md` registrar map when registrars change (U7). +- Update `packages/core/src/task-store/index.ts` FNXC module map as domains replace `remaining-ops` (U3). +- After first successful waves, run `/ce-compound` to capture the organization playbook under `docs/solutions/architecture-patterns/` (currently a documented gap). +- Do not expand AGENTS package structure section unless a permanent convention (e.g. `executor/` folder) becomes the default—prefer solutions entry first. + +--- + +## Sources & Research + +- Repo patterns: TaskStore `task-store/`, routes registrars + README, merger satellites, App.tsx completed plan, auto-recovery-handlers, line-count ratchet script FNXC history. +- Institutional: extract-vs-semantics merge conflicts; workflow-native runtime primitives; characterization / surface matrix lessons; plugin registration drift / mock graph hazards; lazy-load static import constraints. +- Prior audits: `docs/codebase-improvement-audit.md`, `docs/gap-analysis.md` (LOC numbers stale; hotspot list still directionally valid). +- External research: **skipped** — strong local patterns; no unsettled library/architecture choice. + +--- + +## Assumptions + +- Confirmed user scope: multi-wave, pain-ranked packages, folder + split, production-first. +- Implementers will land units as multiple PRs/commits as needed; the plan does not prescribe git choreography. +- Engine-core gate membership may need awareness when moving symbols that gate tests import, but gate *policy* is unchanged. diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index b58e323196..28d000b3dc 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -1,101 +1,104 @@ { - "packages/cli/src/__tests__/extension.test.ts": 4186, - "packages/cli/src/bin.ts": 2070, - "packages/cli/src/commands/__tests__/dashboard.test.ts": 3376, - "packages/cli/src/commands/__tests__/serve.test.ts": 2100, - "packages/cli/src/commands/__tests__/task.test.ts": 3439, - "packages/cli/src/commands/dashboard-tui/app.tsx": 4681, - "packages/cli/src/commands/dashboard.ts": 3000, - "packages/cli/src/extension.ts": 4704, - "packages/core/src/__tests__/agent-store.test.ts": 3003, - "packages/core/src/__tests__/central-core.test.ts": 3263, - "packages/core/src/__tests__/db.test.ts": 3606, - "packages/core/src/__tests__/mission-store.test.ts": 4525, - "packages/core/src/__tests__/plugin-loader.test.ts": 2783, - "packages/core/src/__tests__/store-settings.test.ts": 2249, - "packages/core/src/agent-store.ts": 2946, - "packages/core/src/central-core.ts": 3854, - "packages/core/src/db.ts": 5924, - "packages/core/src/mission-store.ts": 4390, - "packages/core/src/store.ts": 17371, - "packages/core/src/types.ts": 7522, - "packages/dashboard/app/api/legacy.ts": 10865, - "packages/dashboard/app/components/AgentDetailView.tsx": 5400, - "packages/dashboard/app/components/AgentsView.tsx": 2147, - "packages/dashboard/app/components/ChatView.tsx": 4075, - "packages/dashboard/app/components/GitManagerModal.tsx": 3387, - "packages/dashboard/app/components/ListView.tsx": 2464, - "packages/dashboard/app/components/MissionManager.tsx": 5042, - "packages/dashboard/app/components/ModelOnboardingModal.tsx": 3212, - "packages/dashboard/app/components/PlanningModeModal.tsx": 3531, - "packages/dashboard/app/components/QuickEntryBox.tsx": 2288, - "packages/dashboard/app/components/SettingsModal.tsx": 3505, - "packages/dashboard/app/components/TaskCard.tsx": 2544, - "packages/dashboard/app/components/TaskDetailModal.tsx": 4636, - "packages/dashboard/app/components/TerminalModal.tsx": 2313, - "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4868, - "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2817, - "packages/dashboard/app/components/__tests__/App.test.tsx": 4437, - "packages/dashboard/app/components/__tests__/ChatView.test.tsx": 5822, - "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3530, - "packages/dashboard/app/components/__tests__/ListView.test.tsx": 4349, - "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2202, - "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 4679, - "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 3002, - "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4850, - "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121, - "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2558, - "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917, - "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2476, - "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5707, - "packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx": 2905, - "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 3724, - "packages/dashboard/app/hooks/__tests__/useChat.test.ts": 4097, - "packages/dashboard/app/hooks/__tests__/useTasks.test.ts": 2582, - "packages/dashboard/src/__tests__/chat-manager.test.ts": 2636, - "packages/dashboard/src/__tests__/file-service.test.ts": 2123, + "packages/cli/src/__tests__/extension.test.ts": 4558, + "packages/cli/src/bin.ts": 2307, + "packages/cli/src/commands/__tests__/dashboard.test.ts": 3709, + "packages/cli/src/commands/__tests__/serve.test.ts": 2250, + "packages/cli/src/commands/__tests__/task.test.ts": 3585, + "packages/cli/src/commands/dashboard-tui/app.tsx": 4674, + "packages/cli/src/commands/dashboard.ts": 3645, + "packages/cli/src/commands/task.ts": 2373, + "packages/cli/src/extension.ts": 5575, + "packages/core/src/agent-store.ts": 3475, + "packages/core/src/async-mission-store-queries.ts": 2067, + "packages/core/src/central-core.ts": 4474, + "packages/core/src/index.gate.ts": 2157, + "packages/core/src/index.ts": 2388, + "packages/core/src/mission-store.ts": 4364, + "packages/core/src/postgres/sqlite-migrator.ts": 2068, + "packages/core/src/store.ts": 2631, + "packages/core/src/types.ts": 8104, + "packages/dashboard/app/api/legacy.ts": 11627, + "packages/dashboard/app/components/AgentDetailView.tsx": 5552, + "packages/dashboard/app/components/AgentsView.tsx": 2254, + "packages/dashboard/app/components/ChatView.tsx": 3850, + "packages/dashboard/app/components/GitManagerModal.tsx": 3560, + "packages/dashboard/app/components/ListView.tsx": 3087, + "packages/dashboard/app/components/MissionManager.tsx": 5010, + "packages/dashboard/app/components/ModelOnboardingModal.tsx": 3590, + "packages/dashboard/app/components/PlanningModeModal.tsx": 3783, + "packages/dashboard/app/components/QuickEntryBox.tsx": 2518, + "packages/dashboard/app/components/SettingsModal.tsx": 4572, + "packages/dashboard/app/components/TaskCard.tsx": 3856, + "packages/dashboard/app/components/TaskDetailModal.tsx": 6114, + "packages/dashboard/app/components/TerminalModal.tsx": 3322, + "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 5683, + "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2845, + "packages/dashboard/app/components/__tests__/App.test.tsx": 4567, + "packages/dashboard/app/components/__tests__/Board.test.tsx": 2107, + "packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx": 2395, + "packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx": 2622, + "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3788, + "packages/dashboard/app/components/__tests__/ListView.test.tsx": 5198, + "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2377, + "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 5140, + "packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx": 2079, + "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 4040, + "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 5337, + "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 7115, + "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2891, + "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 3340, + "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2812, + "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 9176, + "packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx": 2931, + "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 4353, + "packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx": 2062, + "packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts": 2458, + "packages/dashboard/app/hooks/__tests__/useChat.test.ts": 5355, + "packages/dashboard/app/hooks/__tests__/useTasks.test.ts": 3712, + "packages/dashboard/src/__tests__/chat-manager.test.ts": 3695, + "packages/dashboard/src/__tests__/file-service.test.ts": 2310, "packages/dashboard/src/__tests__/github.test.ts": 2319, - "packages/dashboard/src/__tests__/plugin-routes.test.ts": 2069, - "packages/dashboard/src/__tests__/routes-agents.test.ts": 4921, - "packages/dashboard/src/__tests__/routes-auth.test.ts": 3980, - "packages/dashboard/src/__tests__/routes-automation.test.ts": 2393, - "packages/dashboard/src/__tests__/routes-github.test.ts": 2855, - "packages/dashboard/src/__tests__/routes-nodes-sync.test.ts": 2479, - "packages/dashboard/src/__tests__/routes-planning.test.ts": 4468, - "packages/dashboard/src/__tests__/routes-settings.test.ts": 3259, - "packages/dashboard/src/__tests__/routes-tasks-ops.test.ts": 4247, - "packages/dashboard/src/__tests__/routes-tasks.test.ts": 2696, - "packages/dashboard/src/__tests__/server.test.ts": 3177, - "packages/dashboard/src/__tests__/usage.test.ts": 4328, - "packages/dashboard/src/chat.ts": 2197, + "packages/dashboard/src/__tests__/routes-auth.test.ts": 5510, + "packages/dashboard/src/__tests__/routes-automation.test.ts": 3068, + "packages/dashboard/src/__tests__/routes-github.test.ts": 3169, + "packages/dashboard/src/__tests__/routes-planning.test.ts": 5119, + "packages/dashboard/src/__tests__/routes-tasks.test.ts": 2949, + "packages/dashboard/src/__tests__/usage.test.ts": 5261, + "packages/dashboard/src/chat.ts": 2861, "packages/dashboard/src/github.ts": 4587, - "packages/dashboard/src/mission-routes.ts": 3968, - "packages/dashboard/src/planning.ts": 2866, - "packages/dashboard/src/routes.ts": 5321, - "packages/dashboard/src/routes/register-git-github.ts": 5879, + "packages/dashboard/src/mission-routes.ts": 3895, + "packages/dashboard/src/planning.ts": 3364, + "packages/dashboard/src/routes.ts": 5600, + "packages/dashboard/src/routes/register-git-github.ts": 5984, "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2382, - "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3902, - "packages/dashboard/src/server.ts": 2467, - "packages/engine/src/__tests__/executor-prompt.test.ts": 2573, - "packages/engine/src/__tests__/executor-step-session.test.ts": 3779, - "packages/engine/src/__tests__/executor-worktree.test.ts": 2536, - "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4162, - "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3408, + "packages/dashboard/src/routes/register-task-workflow-routes.ts": 5366, + "packages/dashboard/src/server.ts": 2860, + "packages/dashboard/src/usage.ts": 2398, + "packages/engine/src/__tests__/cron-runner.test.ts": 2326, + "packages/engine/src/__tests__/executor-prompt.test.ts": 2685, + "packages/engine/src/__tests__/executor-worktree.test.ts": 2723, + "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4295, + "packages/engine/src/__tests__/heartbeat-scheduler.test.ts": 3018, + "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3693, "packages/engine/src/__tests__/merger-verification.test.ts": 3163, - "packages/engine/src/__tests__/mission-execution-loop.test.ts": 2463, - "packages/engine/src/__tests__/pi-create-fn-agent.test.ts": 2300, - "packages/engine/src/__tests__/project-engine.test.ts": 3238, - "packages/engine/src/__tests__/self-healing.test.ts": 9739, - "packages/engine/src/__tests__/step-session-executor.test.ts": 2911, - "packages/engine/src/__tests__/triage.test.ts": 4673, - "packages/engine/src/agent-heartbeat.ts": 4660, - "packages/engine/src/agent-tools.ts": 3986, - "packages/engine/src/executor.ts": 16750, - "packages/engine/src/merger.ts": 12886, - "packages/engine/src/pi.ts": 2507, - "packages/engine/src/project-engine.ts": 4030, - "packages/engine/src/scheduler.ts": 3186, - "packages/engine/src/self-healing.ts": 11091, - "packages/engine/src/triage.ts": 2787, - "plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx": 2583 + "packages/engine/src/__tests__/mission-execution-loop.test.ts": 2904, + "packages/engine/src/__tests__/pi-create-fn-agent.test.ts": 3067, + "packages/engine/src/__tests__/project-engine.test.ts": 3482, + "packages/engine/src/__tests__/restart.integration.test.ts": 2013, + "packages/engine/src/__tests__/self-healing.test.ts": 11148, + "packages/engine/src/__tests__/step-session-executor.test.ts": 3217, + "packages/engine/src/__tests__/triage.test.ts": 6605, + "packages/engine/src/agent-heartbeat.ts": 5495, + "packages/engine/src/agent-tools.ts": 4793, + "packages/engine/src/executor.ts": 19328, + "packages/engine/src/merger-ai.ts": 2148, + "packages/engine/src/merger.ts": 12487, + "packages/engine/src/pi.ts": 2799, + "packages/engine/src/project-engine.ts": 5051, + "packages/engine/src/scheduler.ts": 3370, + "packages/engine/src/self-healing.ts": 12183, + "packages/engine/src/triage.ts": 3133, + "packages/pi-claude-cli/src/__tests__/provider.test.ts": 2028, + "plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx": 2583, + "scripts/__tests__/test-changed.test.mjs": 2116 } From 6b14fd53c3b56d53baf595a0c5c46bdb44ddfe1f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 10:48:57 -0700 Subject: [PATCH 2/9] refactor(core): peel types.ts into domain barrels under types/ Extract board, merge-queue, execution/UI, merge-policy, and workflow-step domains while keeping types.ts as the browser-safe @fusion/core Vite alias with local imports so residual interfaces still resolve. Ratchet types.ts ceiling to live LOC. --- packages/core/src/types.ts | 1222 ++++--------------- packages/core/src/types/board.ts | 73 ++ packages/core/src/types/execution-and-ui.ts | 156 +++ packages/core/src/types/merge-policy.ts | 246 ++++ packages/core/src/types/merge-queue.ts | 167 +++ packages/core/src/types/workflow-steps.ts | 395 ++++++ scripts/line-count-baseline.json | 2 +- 7 files changed, 1265 insertions(+), 996 deletions(-) create mode 100644 packages/core/src/types/board.ts create mode 100644 packages/core/src/types/execution-and-ui.ts create mode 100644 packages/core/src/types/merge-policy.ts create mode 100644 packages/core/src/types/merge-queue.ts create mode 100644 packages/core/src/types/workflow-steps.ts diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 87fa7187c3..58f52ebfa0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -32,1007 +32,239 @@ export { export type { GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput } from "./gitlab-config.js"; export { validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed } from "./settings-validation.js"; -/** - * Valid thinking effort levels for AI agent sessions, controlling the cost/quality tradeoff of reasoning. - * Includes extra-high for maximum-effort requests on reasoning-capable models. - * - * FNXC:Settings-ThinkingLevel 2026-06-19-14:55: - * The central thinking-level enum must expose `xhigh` so UI settings and API validation can pass maximum reasoning requests through to CLI adapters. Runtime adapters map `xhigh` to `high` for non-Opus models and `max` for Opus models. - */ -export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; -export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; - -/** - * The legacy default-workflow column set. Workflow-aware task movement resolves - * valid columns from each task's workflow definition (the default workflow's - * column IDs are byte-identical to these — KTD-1). New code should prefer the - * workflow-resolved path (`resolveAllowedColumns` / `workflowHasColumn` in - * `workflow-transitions.ts`) and trait predicates over string equality; this - * enum remains the canonical id set for the built-in default workflow. - */ -export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const; -/** - * The closed legacy column union — still the correct type for default-workflow - * column ids. Movement entry points accept the wider {@link ColumnId}; runtime - * code validates ids against the task's resolved workflow. - */ -export type Column = (typeof COLUMNS)[number]; - -/** - * Column identifier accepted at task-movement entry points (KTD-1). - * Equals the legacy `Column` union for autocomplete purposes, but admits - * workflow-defined custom column ids; runtime paths validate the id against the - * task's resolved workflow. - */ -export type ColumnId = Column | (string & {}); - -export const DEFAULT_COLUMN: Column = "triage"; - -/** - * Tests membership against the closed legacy column enum. Note: under the - * workflowColumns flag, column validity is workflow-scoped — flag-aware code - * should use `workflowHasColumn(ir, columnId)` (`workflow-transitions.ts`); - * this remains correct for the flag-OFF path and default-workflow ids. - */ -export function isColumn(value: unknown): value is Column { - return typeof value === "string" && (COLUMNS as readonly string[]).includes(value); -} - -/** - * @deprecated (workflowColumns, U12) Coerces an arbitrary value to a legacy - * column, DISCARDING workflow-defined custom column ids — lossy under the - * flag. Resolve and validate against the task's workflow instead. Retained - * for the legacy flag-OFF path while the flag exists. - */ -export function normalizeColumn(value: unknown, fallback: Column = DEFAULT_COLUMN): Column { - return isColumn(value) ? value : fallback; -} - -/** Ordered task-priority levels for the core task domain contract. */ -export const TASK_PRIORITIES = ["low", "normal", "high", "urgent"] as const; -export type TaskPriority = (typeof TASK_PRIORITIES)[number]; - -/** - * Default task priority used for legacy rows/entries and create flows when - * callers omit the priority field. - */ -export const DEFAULT_TASK_PRIORITY: TaskPriority = "normal"; - -export const MERGE_REQUEST_STATES = [ - "queued", - "running", - "retrying", - "succeeded", - "exhausted", - "cancelled", - "manual-required", -] as const; - -export type MergeRequestState = (typeof MERGE_REQUEST_STATES)[number]; - -export const WORKFLOW_WORK_ITEM_KINDS = [ - "task", - "merge", - "retry", - "manual-hold", - "recovery", -] as const; - -export type WorkflowWorkItemKind = (typeof WORKFLOW_WORK_ITEM_KINDS)[number]; - -export const WORKFLOW_WORK_ITEM_STATES = [ - "runnable", - "running", - "held", - "retrying", - "manual-required", - "succeeded", - "failed", - "cancelled", - "exhausted", -] as const; - -export type WorkflowWorkItemState = (typeof WORKFLOW_WORK_ITEM_STATES)[number]; - -export interface WorkflowWorkItem { - id: string; - runId: string; - taskId: string; - nodeId: string; - kind: WorkflowWorkItemKind; - state: WorkflowWorkItemState; - attempt: number; - retryAfter: string | null; - leaseOwner: string | null; - leaseExpiresAt: string | null; - lastError: string | null; - blockedReason: string | null; - createdAt: string; - updatedAt: string; -} - -export interface WorkflowWorkItemUpsertInput { - id?: string; - runId: string; - taskId: string; - nodeId: string; - kind: WorkflowWorkItemKind; - state?: WorkflowWorkItemState; - attempt?: number; - retryAfter?: string | null; - leaseOwner?: string | null; - leaseExpiresAt?: string | null; - lastError?: string | null; - blockedReason?: string | null; - now?: string; -} - -export interface WorkflowWorkItemTransitionPatch { - attempt?: number; - retryAfter?: string | null; - leaseOwner?: string | null; - leaseExpiresAt?: string | null; - lastError?: string | null; - blockedReason?: string | null; - now?: string; -} - -export interface WorkflowWorkItemDueFilter { - now?: string; - limit?: number; - kinds?: WorkflowWorkItemKind[]; - states?: WorkflowWorkItemState[]; -} - -export interface MergeRequestWorkflowProjectionOptions { - runId?: string; - nodeId?: string; - now?: string; -} - -export interface MergeQueueEntry { - taskId: string; - enqueuedAt: string; - priority: TaskPriority; - leasedBy: string | null; - leasedAt: string | null; - leaseExpiresAt: string | null; - attemptCount: number; - lastError: string | null; -} - -export interface MergeRequestRecord { - taskId: string; - state: MergeRequestState; - createdAt: string; - updatedAt: string; - attemptCount: number; - lastError: string | null; -} - -export interface CompletionHandoffMarker { - taskId: string; - acceptedAt: string; - source: string; -} - -export interface MergeQueueEnqueueOptions { - priority?: TaskPriority; - now?: string; -} - -export interface MergeQueueAcquireOptions { - leaseDurationMs: number; - now?: string; - /** If provided, the lease attempt targets this specific task first. - * The task must be unexpired/available; otherwise falls back to normal queue-head selection. */ - targetTaskId?: string; -} - -export type MergeQueueReleaseOutcome = - | { kind: "success" } - | { kind: "failure"; error: string }; - -export interface HandoffEvidence { - /** Reason text recorded on the run-audit event (for example "fn_task_done"). */ - reason: string; - /** Optional run id captured for forensics. */ - runId?: string; - /** Optional agent id captured for forensics. */ - agentId?: string; -} - -export interface HandoffToReviewOptions { - ownerAgentId: string | null; - evidence: HandoffEvidence; - moveOptions?: { - preserveResumeState?: boolean; - preserveProgress?: boolean; - preserveWorktree?: boolean; - preserveStatus?: boolean; - moveSource?: "user" | "engine"; - skipMergeBlocker?: boolean; - }; - /** Inject a clock for tests. */ - now?: string; -} - -/** - * Dashboard high-fan-out blocker threshold. A blocker is considered high impact - * when at least this many active todo tasks are waiting on it. - */ -export const HIGH_FANOUT_BLOCKER_TODO_THRESHOLD = 5; - -/** - * Default age gate (ms) before a high fan-out blocker is escalated in dashboards. - */ -export const STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS = 2 * 60 * 60 * 1000; - -/** - * Execution mode for task implementation. - * Controls how the executor agent approaches the task: - * - "standard": Full execution with complete review workflow (default) - * - "fast": Expedited execution with minimal overhead for simple tasks - */ -export const EXECUTION_MODES = ["standard", "fast"] as const; -export type ExecutionMode = (typeof EXECUTION_MODES)[number]; - -/** Default execution mode for new tasks */ -export const DEFAULT_EXECUTION_MODE: ExecutionMode = "standard"; /* - * FNXC:PlannerOversight 2026-07-04-00:00: - * Per-task override of the workflow-native `plannerOversightLevel` setting - * (declared in `BUILTIN_OVERSIGHT_SETTINGS`, packages/core/src/builtin-workflow-settings.ts). - * When a task sets this field, it wins over the workflow's effective oversight - * value; unset (NULL in storage) means "inherit the workflow default". Values, - * order, and default here must stay in sync with `BUILTIN_OVERSIGHT_SETTINGS`. - */ -export const PLANNER_OVERSIGHT_LEVELS = ["off", "observe", "steer", "autonomous"] as const; -export type PlannerOversightLevel = (typeof PLANNER_OVERSIGHT_LEVELS)[number]; -export const DEFAULT_PLANNER_OVERSIGHT_LEVEL: PlannerOversightLevel = "autonomous"; - -/** Controls whether triage should require completion documentation artifacts in task specs. */ -export const COMPLETION_DOCUMENTATION_MODES = ["off", "changeset", "changelog"] as const; -export type CompletionDocumentationMode = (typeof COMPLETION_DOCUMENTATION_MODES)[number]; - -/** Theme mode for light/dark/system preference */ -export const THEME_MODES = ["dark", "light", "system"] as const; -export type ThemeMode = (typeof THEME_MODES)[number]; - -/** Color theme options for the dashboard */ -export const COLOR_THEMES = [ - "default", - "ocean", - "forest", - "sunset", - "zen", - "berry", - "high-contrast", - "industrial", - "monochrome", - "slate", - "ash", - // FNXC:DashboardTheming 2026-06-19-15:36: "air" is the source-of-truth id for the minimal, borderless, paper-like dashboard theme; keep bootstrap validators and theme options in sync with this union. - "air", - "graphite", - "silver", - "solarized", - "factory", - "factory-mono", - "ayu", - "one-dark", - "nord", - "dracula", - "gruvbox", - "tokyo-night", - "catppuccin-mocha", - "github-dark", - "everforest", - "rose-pine", - "kanagawa", - "night-owl", - "palenight", - "monokai-pro", - "slime", - "brutalist", - "neon-city", - "parchment", - "terminal", - "glass", - // FNXC:DashboardTheming 2026-07-01-00:00: Glass Silver is the silver/gray frosted sibling of Glass; keep this id in lockstep with dashboard/desktop validators and selector metadata so persisted explicit choices survive startup. - "glass-silver", - "horizon", - "vitesse", - "outrun", - "snazzy", - "porple", - "espresso", - "mars", - "poimandres", - "ember", - "rust", - "copper", - "foundry", - "carbon", - "sandstone", - "lagoon", - "frost", - "lavender", - "neon-bloom", - "sepia", - "shadcn", - // FNXC:DashboardTheming 2026-06-30-00:00: Shadcn Ember is the default for unset installs; keep it adjacent to the shadcn base so dashboard options and bootstrap validators preserve published theme order while explicit legacy ids remain valid. - "shadcn-ember", - // FNXC:DashboardTheming 2026-06-20-18:20: FN-6816 adds the user-customizable shadcn variant; keep this union in lockstep with dashboard theme options, swatches, theme-data base blocks, and the shadcn custom color token list. - "shadcn-custom", - // FNXC:DashboardTheming 2026-06-19-16:07: FN-6756 extends the published color-theme union with shadcn-family accent variants; keep dashboard theme options, bootstrap validation, swatches, and theme-data token blocks in lockstep with this ordered list. - // FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 renames the grayscale-base mono theme to shadcn-mono-red and adds the remaining mono accent variants; keep Shadcn Gray adjacent to Shadcn Black so the color-family order stays stable with FN-6814. - // FNXC:DashboardTheming 2026-06-21-00:00: FN-6815 adds shadcn-gray-blue as the slate-neutral blue-gray sibling; keep it adjacent to Shadcn Gray so the published union mirrors dashboard option order. - "shadcn-blue", - "shadcn-green", - "shadcn-red", - "shadcn-purple", - "shadcn-pink", - "shadcn-orange", - "shadcn-yellow", - "shadcn-mono-red", - "shadcn-mono-blue", - "shadcn-mono-green", - "shadcn-mono-purple", - "shadcn-mono-pink", - "shadcn-mono-orange", - "shadcn-mono-yellow", - "shadcn-black", - "shadcn-gray", - "shadcn-gray-blue", -] as const; -export type ColorTheme = (typeof COLOR_THEMES)[number]; - -/** UI locales supported across the dashboard and terminal UI. `en` is the - * source-of-truth language and the fallback for all others. Adding a locale - * here (plus translated catalogs) is the only code change a new language - * needs — see `@fusion/i18n`. zh-CN and zh-TW are independent catalogs and - * are never auto-converted between scripts. */ -export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "fr", "es", "ko"] as const; -export type Locale = (typeof SUPPORTED_LOCALES)[number]; -/** Source-of-truth language and the fallback for all locales. */ -export const DEFAULT_LOCALE: Locale = "en"; - -/** Narrow an arbitrary value to a supported `Locale`. */ -export function isLocale(value: unknown): value is Locale { - return ( - typeof value === "string" && - (SUPPORTED_LOCALES as readonly string[]).includes(value) - ); -} - -export type PrStatus = "open" | "closed" | "merged" | "draft"; -export type MergeStrategy = "direct" | "pull-request"; -export type MergeIntegrationWorktreeMode = - | "reuse-task-worktree" - | "cwd-integration-branch" // explicit opt-in; surfaces a warning at startup. See FN-5348. - | "cwd-main"; // legacy alias for cwd-integration-branch; deprecated. Normalized at read time. - -let warnedLegacyCwdMain = false; - -export function __resetLegacyCwdMainWarningForTests(): void { - warnedLegacyCwdMain = false; -} - -export function normalizeMergeIntegrationWorktreeMode( - value: unknown, -): MergeIntegrationWorktreeMode { - if (value === "reuse-task-worktree" || value === "cwd-integration-branch") { - return value; - } - - if (value === "cwd-main") { - if (!warnedLegacyCwdMain) { - warnedLegacyCwdMain = true; - console.warn("[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch"); - } - return "cwd-integration-branch"; - } - - return "reuse-task-worktree"; -} +FNXC:CodeOrganization 2026-07-15-00:00: +Domain peels live under types/*.ts. Import locally so residual interfaces in this +barrel can reference them, then re-export so the Vite @fusion/core alias and +package consumers keep stable import paths. +*/ +import { + THINKING_LEVELS, + COLUMNS, + DEFAULT_COLUMN, + isColumn, + normalizeColumn, + TASK_PRIORITIES, + DEFAULT_TASK_PRIORITY, +} from "./types/board.js"; +import type { ThinkingLevel, Column, ColumnId, TaskPriority } from "./types/board.js"; +export { + THINKING_LEVELS, + COLUMNS, + DEFAULT_COLUMN, + isColumn, + normalizeColumn, + TASK_PRIORITIES, + DEFAULT_TASK_PRIORITY, +}; +export type { ThinkingLevel, Column, ColumnId, TaskPriority }; + +import { + MERGE_REQUEST_STATES, + WORKFLOW_WORK_ITEM_KINDS, + WORKFLOW_WORK_ITEM_STATES, +} from "./types/merge-queue.js"; +import type { + MergeRequestState, + WorkflowWorkItemKind, + WorkflowWorkItemState, + WorkflowWorkItem, + WorkflowWorkItemUpsertInput, + WorkflowWorkItemTransitionPatch, + WorkflowWorkItemDueFilter, + MergeRequestWorkflowProjectionOptions, + MergeQueueEntry, + MergeRequestRecord, + CompletionHandoffMarker, + MergeQueueEnqueueOptions, + MergeQueueAcquireOptions, + MergeQueueReleaseOutcome, + HandoffEvidence, + HandoffToReviewOptions, +} from "./types/merge-queue.js"; +export { + MERGE_REQUEST_STATES, + WORKFLOW_WORK_ITEM_KINDS, + WORKFLOW_WORK_ITEM_STATES, +}; +export type { + MergeRequestState, + WorkflowWorkItemKind, + WorkflowWorkItemState, + WorkflowWorkItem, + WorkflowWorkItemUpsertInput, + WorkflowWorkItemTransitionPatch, + WorkflowWorkItemDueFilter, + MergeRequestWorkflowProjectionOptions, + MergeQueueEntry, + MergeRequestRecord, + CompletionHandoffMarker, + MergeQueueEnqueueOptions, + MergeQueueAcquireOptions, + MergeQueueReleaseOutcome, + HandoffEvidence, + HandoffToReviewOptions, +}; -export const DIRECT_MERGE_COMMIT_STRATEGIES = ["auto", "always-squash", "always-rebase"] as const; -export type DirectMergeCommitStrategy = (typeof DIRECT_MERGE_COMMIT_STRATEGIES)[number]; +import { + HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, + STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, + EXECUTION_MODES, + DEFAULT_EXECUTION_MODE, + PLANNER_OVERSIGHT_LEVELS, + DEFAULT_PLANNER_OVERSIGHT_LEVEL, + COMPLETION_DOCUMENTATION_MODES, + THEME_MODES, + COLOR_THEMES, + SUPPORTED_LOCALES, + DEFAULT_LOCALE, + isLocale, +} from "./types/execution-and-ui.js"; +import type { + ExecutionMode, + PlannerOversightLevel, + CompletionDocumentationMode, + ThemeMode, + ColorTheme, + Locale, +} from "./types/execution-and-ui.js"; +export { + HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, + STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, + EXECUTION_MODES, + DEFAULT_EXECUTION_MODE, + PLANNER_OVERSIGHT_LEVELS, + DEFAULT_PLANNER_OVERSIGHT_LEVEL, + COMPLETION_DOCUMENTATION_MODES, + THEME_MODES, + COLOR_THEMES, + SUPPORTED_LOCALES, + DEFAULT_LOCALE, + isLocale, +}; +export type { + ExecutionMode, + PlannerOversightLevel, + CompletionDocumentationMode, + ThemeMode, + ColorTheme, + Locale, +}; -export const MERGE_ADVANCE_AUTO_SYNC_MODES = ["off", "ff-only", "stash-and-ff"] as const; -export type MergeAdvanceAutoSyncMode = (typeof MERGE_ADVANCE_AUTO_SYNC_MODES)[number]; -export function normalizeMergeAdvanceAutoSyncMode(value: unknown): MergeAdvanceAutoSyncMode { - return value === "off" || value === "ff-only" || value === "stash-and-ff" ? value : "stash-and-ff"; -} -/** How merge conflicts are resolved when the AI agent can't (or shouldn't) decide. - * - * Both `smart-*` strategies share the same cascade: pre-merge fetch + - * fast-forward of local main from origin (graceful degrade on failure), - * then AI, then auto-resolve lock/generated/trivial files. They differ only - * in the final per-file fallback when conflicts remain: - * - * - "smart-prefer-main" (default): fall back to `-X ours` so main's state - * wins. Best when concurrent tasks could regress just-merged sibling work. - * - "smart-prefer-branch": fall back to `-X theirs` so the task branch wins. - * Best when one agent at a time is dominant and you trust their output. - * - "ai-only": run AI on every attempt; never silently prefer one side. - * - "abort": run AI once; if conflict remains, fail the merge so a human - * can resolve it. - * - * Legacy values `"smart"` and `"prefer-main"` are accepted for backwards - * compatibility and normalized via {@link normalizeMergeConflictStrategy}. - * `"smart"` maps to `"smart-prefer-branch"` (its historical fallback) and - * `"prefer-main"` maps to `"smart-prefer-main"`. */ -export type MergeConflictStrategy = - | "smart-prefer-main" - | "smart-prefer-branch" - | "ai-only" - | "abort" - /** @deprecated use "smart-prefer-branch" */ - | "smart" - /** @deprecated use "smart-prefer-main" */ - | "prefer-main"; - -/** Canonical (post-migration) values that the merger actually dispatches on. */ -export type CanonicalMergeConflictStrategy = Exclude< +import { + __resetLegacyCwdMainWarningForTests, + normalizeMergeIntegrationWorktreeMode, + DIRECT_MERGE_COMMIT_STRATEGIES, + MERGE_ADVANCE_AUTO_SYNC_MODES, + normalizeMergeAdvanceAutoSyncMode, + normalizeMergeConflictStrategy, + MERGE_STRATEGY_OVERLAP_BEHAVIORS, + normalizeMergeStrategyOverlapBehavior, + POST_MERGE_AUDIT_MODES, + normalizePostMergeAuditMode, + MERGE_AUDIT_AUTO_RECOVERY_MODES, + normalizeMergeAuditAutoRecovery, + MERGER_MODES, + normalizeMergerMode, + AUTO_RECOVERY_MODES, + normalizeAutoRecovery, +} from "./types/merge-policy.js"; +import type { + PrStatus, + MergeStrategy, + MergeIntegrationWorktreeMode, + DirectMergeCommitStrategy, + MergeAdvanceAutoSyncMode, MergeConflictStrategy, - "smart" | "prefer-main" ->; - -/** Translate legacy `mergeConflictStrategy` values into their canonical form. - * Pass-through for already-canonical values; defaults to "smart-prefer-main" - * when the input is undefined. */ -export function normalizeMergeConflictStrategy( - value: MergeConflictStrategy | undefined, -): CanonicalMergeConflictStrategy { - switch (value) { - case "smart": - return "smart-prefer-branch"; - case "prefer-main": - return "smart-prefer-main"; - case undefined: - return "smart-prefer-main"; - default: - return value; - } -} - -export const MERGE_STRATEGY_OVERLAP_BEHAVIORS = [ - "flip-to-prefer-branch", - "warn-only", - "ignore", -] as const; - -export type MergeStrategyOverlapBehavior = (typeof MERGE_STRATEGY_OVERLAP_BEHAVIORS)[number]; - -export function normalizeMergeStrategyOverlapBehavior( - value: unknown, -): MergeStrategyOverlapBehavior { - return typeof value === "string" - && (MERGE_STRATEGY_OVERLAP_BEHAVIORS as readonly string[]).includes(value) - ? value as MergeStrategyOverlapBehavior - : "flip-to-prefer-branch"; -} - -export const POST_MERGE_AUDIT_MODES = ["block", "warn", "off"] as const; - -/** Controls how the merger reacts to a dirty post-merge audit (FN-4333). */ -export type PostMergeAuditMode = (typeof POST_MERGE_AUDIT_MODES)[number]; - -export function normalizePostMergeAuditMode(value: unknown): PostMergeAuditMode { - return typeof value === "string" - && (POST_MERGE_AUDIT_MODES as readonly string[]).includes(value) - ? (value as PostMergeAuditMode) - : "block"; -} - -export const MERGE_AUDIT_AUTO_RECOVERY_MODES = ["deterministic-only", "programmatic", "ai-assisted", "off"] as const; - -/** Controls how aggressively the merger tries to auto-recover from audit blocks (FN-4315). */ -export type MergeAuditAutoRecoveryMode = (typeof MERGE_AUDIT_AUTO_RECOVERY_MODES)[number]; - -export function normalizeMergeAuditAutoRecovery(value: unknown): MergeAuditAutoRecoveryMode { - return typeof value === "string" - && (MERGE_AUDIT_AUTO_RECOVERY_MODES as readonly string[]).includes(value) - ? (value as MergeAuditAutoRecoveryMode) - : "ai-assisted"; -} - -export const MERGER_MODES = ["ai", "deterministic"] as const; - -/** - * Merge execution path (FN-5633). - * - "ai" (default): the standalone AI merge path — a clean-room worktree where - * an AI agent merges the task branch and an AI reviewer audits it (with - * corrective retries) before a fast-forward landing. Bypasses the legacy - * scaffolding entirely. - * - "deterministic": **DEPRECATED (master-plan U0, 2026-06-21) and INERT.** Once - * routed to the legacy `aiMergeTask` pipeline; now ignored — every merge uses - * the unified "ai" path (`runAiMerge`). The value is retained (not removed) to - * avoid a breaking `@runfusion/fusion` type change, and the engine logs a - * one-time deprecation warning when it observes a resolved "deterministic". - * - * FNXC:MergerUnification 2026-06-21-19:05: `merger.mode` is published surface, so - * the type and the `MergerSettings.mode` field stay; only the "deterministic" - * VALUE is deprecated/inert. Removing the type is a separate breaking change. - */ -export type MergerMode = (typeof MERGER_MODES)[number]; - -export function normalizeMergerMode(value: unknown): MergerMode { - return typeof value === "string" && (MERGER_MODES as readonly string[]).includes(value) - ? (value as MergerMode) - : "ai"; -} - -/** Settings for the AI merge path (FN-5633). */ -export interface MergerSettings { - /** - * Which merge path to use. Default: "ai". - * @deprecated master-plan U0 (2026-06-21): the value is inert — every merge now - * uses the unified AI merge path (`runAiMerge`). Field retained as published - * surface; "deterministic" only triggers a one-time deprecation warning. - */ - mode?: MergerMode; - /** How many AI corrective rounds before landing the best result (advisory) or - * hard-failing (blocking). Default: 3. The reviewer uses the project's - * validator/reviewer model lane — there is no merge-specific model setting. */ - maxReviewPasses?: number; - /** Dangerous compatibility escape hatch for the AI merge landing path. - * When true (default for resolved project settings), Fusion restores the legacy - * stash → fast-forward → restore behavior when the checked-out integration - * worktree is dirty. Set false to explicitly opt out and fail closed before - * unrelated local edits can be reintroduced after landing. */ - allowDirtyLocalCheckoutSync?: boolean; -} - -export const AUTO_RECOVERY_MODES = ["off", "deterministic-only", "programmatic", "ai-assisted"] as const; - -export type AutoRecoveryMode = (typeof AUTO_RECOVERY_MODES)[number]; - -export type AutoRecoveryFailureClass = - | "file-scope-invariant" - | "post-squash-audit-blocker" - | "branch-cross-contamination" - | "branch-conflict-tripwire" - | "branch-conflict-recovery-exhausted" - | "branch-conflict-unrecoverable" - | "message-delivery-failure"; - -export interface AutoRecoverySettings { - mode: AutoRecoveryMode; - perClass?: Partial>; - maxRetries?: number; -} - -export function normalizeAutoRecovery(value: unknown): AutoRecoverySettings { - const fallback: AutoRecoverySettings = { mode: "deterministic-only", maxRetries: 3 }; - if (!value || typeof value !== "object") return fallback; - - const candidate = value as { - mode?: unknown; - perClass?: unknown; - maxRetries?: unknown; - }; - const mode = typeof candidate.mode === "string" && (AUTO_RECOVERY_MODES as readonly string[]).includes(candidate.mode) - ? candidate.mode as AutoRecoveryMode - : fallback.mode; - const perClass = typeof candidate.perClass === "object" && candidate.perClass - ? Object.fromEntries( - Object.entries(candidate.perClass as Record) - .filter(([k, v]) => ( - [ - "file-scope-invariant", - "post-squash-audit-blocker", - "branch-cross-contamination", - "branch-conflict-tripwire", - "branch-conflict-recovery-exhausted", - "branch-conflict-unrecoverable", - "message-delivery-failure", - ].includes(k) - && typeof v === "string" - && (AUTO_RECOVERY_MODES as readonly string[]).includes(v) - )), - ) as Partial> - : undefined; - const maxRetries = typeof candidate.maxRetries === "number" && Number.isFinite(candidate.maxRetries) - ? Math.max(0, Math.floor(candidate.maxRetries)) - : fallback.maxRetries; - - return { mode, perClass, maxRetries }; -} -/** Policy for handling task execution when the selected node is unavailable/unhealthy. */ -export type UnavailableNodePolicy = "block" | "fallback-local"; - -export type OwningNodeHandoffPolicy = "block" | "reassign-to-local" | "reassign-any-healthy"; - -export interface ModelPreset { - id: string; - name: string; - executorProvider?: string; - executorModelId?: string; - validatorProvider?: string; - validatorModelId?: string; -} - -/** A reusable workflow step definition that can run after task implementation. */ -/** Execution mode for a workflow step. */ -export type WorkflowStepMode = "prompt" | "script"; -export type WorkflowStepToolMode = "readonly" | "coding"; -export type WorkflowStepGateMode = "gate" | "advisory"; - -/** Lifecycle phase for workflow step execution. */ -export type WorkflowStepPhase = "pre-merge" | "post-merge"; - -export interface WorkflowStep { - /** Unique identifier (e.g., "WS-001") */ - id: string; - /** Built-in template source ID when this step was materialized from a template. */ - templateId?: string; - /** Display name (e.g., "Documentation Review") */ - name: string; - /** Short description for UI display */ - description: string; - /** Execution mode — "prompt" runs an AI agent, "script" runs a named project script */ - mode: WorkflowStepMode; - /** Lifecycle phase — "pre-merge" runs before merge (default), "post-merge" runs after merge success */ - phase?: WorkflowStepPhase; - /** Gate behavior — gate blocks merge/auto-revive on failure, advisory records non-blocking findings. */ - gateMode: WorkflowStepGateMode; - /** Full agent prompt to execute when this step runs (used when mode is "prompt") */ - prompt: string; - /** Tool set available to prompt-mode workflow agents. Defaults to readonly. */ - toolMode?: WorkflowStepToolMode; - /** Name of a skill to load into this step's session (e.g. - * "compound-engineering:ce-work"). When set, the step session loads the named - * skill (discovery + selection) and the engine injects the Fusion workflow-step - * conventions preamble. Only meaningful for skill-executor graph nodes. */ - skillName?: string; - /** - * Browser capability requested by prompt-mode steps. When true, the executor - * loads the agent-browser navigation skill when available, preflights the - * `agent-browser` CLI, and records browser-verification activity in the agent - * log. Ignored for script-mode steps. - */ - requiresBrowser?: boolean; - /** Name of a script from project settings `scripts` map to execute (required when mode is "script") */ - scriptName?: string; - /** Whether this step is available for selection on new tasks */ - enabled: boolean; - /** When true, this step is automatically pre-selected when creating new tasks. - * Users can still deselect it — this only controls the initial default state. */ - defaultOn?: boolean; - /** AI model provider override for the workflow step agent (e.g., "anthropic"). - * Must be set together with `modelId`. When both model fields are undefined, - * the executor uses global settings defaults. Only used when mode is "prompt". */ - modelProvider?: string; - /** AI model ID override for the workflow step agent (e.g., "claude-sonnet-4-5"). - * Must be set together with `modelProvider`. When both model fields are undefined, - * the executor uses global settings defaults. Only used when mode is "prompt". */ - modelId?: string; - /** - * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: - * Workflow IR nodes may pin reasoning effort independently from the model pair so authors can inherit the model while overriding thinking level. Runtime precedence is node/step `thinkingLevel` > task `thinkingLevel` > settings `defaultThinkingLevel`. - */ - thinkingLevel?: ThinkingLevel; - /** (workflow-editor-consolidation U1/U2, KTD-1/KTD-3) when this legacy step has - * been migrated into a fragment WorkflowDefinition, the fragment's id is stamped - * here so the lazy step migration is idempotent (already-stamped rows are - * skipped). Stored in the `migrated_fragment_id` column. */ - migratedFragmentId?: string; - /** ISO-8601 timestamp of creation */ - createdAt: string; - /** ISO-8601 timestamp of last update */ - updatedAt: string; -} - -/** Input for creating a new workflow step. */ -/** Event types that can trigger ntfy notifications */ -export type NtfyNotificationEvent = - | "in-review" - | "merged" - | "failed" - | "awaiting-approval" - | "awaiting-user-review" - | "planning-awaiting-input" - | "cli-agent-awaiting-input" - | "gridlock" - | "board-stall-unrecovered" - | "db-corruption-detected" - | "fallback-used" - | "memory-dreams-processed" - | "token-budget" - | "message:agent-to-user" - | "message:agent-to-agent" - | "message:room" - | "oauth-token-expired" - | "task-created" - | "workflow-notify"; - -/** Known notification event types. Providers may support additional custom events. */ -export const NOTIFICATION_EVENTS = [ - "in-review", - "merged", - "failed", - "awaiting-approval", - "awaiting-user-review", - "planning-awaiting-input", - /* - * FNXC:ToolPermissionNotifications 2026-06-27-00:00: - * CLI tool-permission requests are a distinct user-facing notification event from plan approval. Operators must be able to enable external alerts when a terminal-backed agent waits for human input. - */ - "cli-agent-awaiting-input", - "gridlock", - "board-stall-unrecovered", - "db-corruption-detected", - "fallback-used", - "memory-dreams-processed", - "token-budget", - "message:agent-to-user", - "message:agent-to-agent", - "message:room", - "oauth-token-expired", - "task-created", - "workflow-notify", -] as const; - -/** Notification event type. Known events plus provider-specific custom events. */ -export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number] | (string & {}); - -/** Standard payload shape shared across notification providers. */ -export interface NotificationPayload { - taskId?: string; - taskTitle?: string; - taskDescription?: string; - event: NotificationEvent; - timestamp?: string; - metadata?: Record; -} - -/** Declarative notification provider configuration persisted in settings. */ -export interface NotificationProviderConfig { - id: string; - name: string; - enabled: boolean; - config: Record; -} - -export interface CustomProvider { - id: string; - name: string; - apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses"; - baseUrl: string; - apiKey?: string; - /** - * OpenAI-compatible opt-in for providers that explicitly support the `developer` role. - * Omitted/false forces legacy `system` role emission to avoid provider 400s. - */ - supportsDeveloperRole?: boolean; - /** - * FNXC:ProviderAuth 2026-07-08-00:00: - * FN-7689: opt-in for custom `openai-compatible`/`openai-responses` gateways that proxy an - * Anthropic-format backend (e.g. `usai/claude_4_6_sonnet`). When true, registered - * `openai-completions` models get pi-ai's `compat.cacheControlFormat = "anthropic"`, which makes - * pi-ai emit Anthropic-style `cache_control` breakpoints on the system prompt, last - * conversation message, and last tool. Without this, pi-ai's `detectCompat` only auto-enables - * caching for OpenRouter `anthropic/*` models, so a generic custom gateway re-bills the entire - * context prefix uncached every turn (measured cachedTokens=0/cacheWriteTokens=0 across 243 - * runs, ~327.5:1 input:output ratio). Default off — never force cache_control on gateways that - * did not opt in, since non-Anthropic-compatible backends (Together, Fireworks, etc.) can 400 on - * unexpected `cache_control` fields. Inert for `anthropic-compatible` (already auto-caches) and - * `google-generative-ai` (no cache_control concept). - */ - anthropicPromptCaching?: boolean; - models?: { id: string; name: string }[]; -} - -export interface WorkflowStepInput { - /** Built-in template source ID when creating a concrete step from a template. */ - templateId?: string; - name: string; - description: string; - /** Execution mode — defaults to "prompt" if not specified */ - mode?: WorkflowStepMode; - /** Lifecycle phase — defaults to "pre-merge" if not specified */ - phase?: WorkflowStepPhase; - /** Gate behavior — defaults by mode (prompt: advisory, script: gate) when omitted. */ - gateMode?: WorkflowStepGateMode; - /** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */ - prompt?: string; - /** Tool set available to prompt-mode workflow agents. Defaults to readonly. */ - toolMode?: WorkflowStepToolMode; - /** Name of a skill to load into this step's session (e.g. - * "compound-engineering:ce-work"). See `WorkflowStep.skillName`. */ - skillName?: string; - /** Script name from project settings (required when mode is "script"). - * Must reference a named script in `settings.scripts` — no raw commands. */ - scriptName?: string; - /** Defaults to true if not specified */ - enabled?: boolean; - /** When true, this step is automatically pre-selected when creating new tasks. - * Users can still deselect — this only controls the initial default state. */ - defaultOn?: boolean; - /** AI model provider override. Must be set together with modelId. Only used when mode is "prompt". */ - modelProvider?: string; - /** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */ - modelId?: string; - /** Optional per-node reasoning-effort override; inherits from task/settings when omitted. */ - thinkingLevel?: ThinkingLevel; - /** (workflow-editor-consolidation U2, KTD-3) fragment id stamped when this step - * was migrated into a fragment WorkflowDefinition. Set by the migration only. */ - migratedFragmentId?: string; -} - -/** Result of a workflow step execution on a task. */ -export interface WorkflowStepResult { - /** ID of the workflow step that ran (e.g., "WS-001") */ - workflowStepId: string; - /** Name of the workflow step at execution time */ - workflowStepName: string; - /** Lifecycle phase at execution time */ - phase?: WorkflowStepPhase; - /** Runtime source for distinguishing graph-authored node progress from optional-toggle checks. */ - source?: "optional-group" | "node"; - /** Execution status */ - status: "passed" | "failed" | "advisory_failure" | "skipped" | "pending"; - /** Output from the workflow step agent (findings, errors, etc.) */ - output?: string; - /** - * Machine-readable verdict from prompt-mode structured output. - * Absent for script-mode steps and legacy prose-only prompt outputs. - */ - verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE"; - /** - * Optional notes from prompt-mode structured output. - * Absent for script-mode steps and legacy prose-only prompt outputs. - */ - notes?: string; - /** ISO-8601 timestamp when the step started */ - startedAt?: string; - /** ISO-8601 timestamp when the step completed */ - completedAt?: string; - /* - * FNXC:ReviewLaneBypass 2026-07-09-00:00: - * A privileged operator can bypass a `status:"failed"` pre-merge review step - * (leading real-world cause: the Runfusion/Fusion#1946 `(no feedback captured)` - * no-verdict dispatch defect) so a card stranded solely by that failure can - * advance to merge (FN-7720). The bypass REWRITES this result's `status` to a - * terminal, non-blocking value (`"skipped"`) and stamps the fields below as an - * explicit audit trail — it never fabricates a reviewer `verdict`. Only the - * `getTaskMergeBlocker` "task has failed pre-merge workflow steps" reason is - * cleared; every other merge-blocker condition (paused, incomplete steps, - * blocking task status, still-`pending` pre-merge steps) is untouched. - */ - /** Operator identity that performed the bypass, if this result was bypassed. */ - bypassedBy?: string; - /** ISO-8601 timestamp when the bypass was applied. */ - bypassedAt?: string; - /** Mandatory operator-supplied justification for the bypass. */ - bypassReason?: string; - /** The `status` this result carried immediately before the bypass rewrote it (always `"failed"` for the supported bypass path). */ - bypassedFromStatus?: WorkflowStepResult["status"]; - /** The `verdict` (if any) this result carried immediately before the bypass, preserved for audit only — never promoted to `verdict`. */ - bypassedFromVerdict?: WorkflowStepResult["verdict"]; - /* - * FNXC:WorkflowStepResults 2026-07-09-00:10: - * FN-7727: self-healing recovery re-runs a failed pre-merge review node - * (`code-review`, `code-review-remediation`, `plan-review`, - * `browser-verification`) in place, and the recorder upsert previously - * REPLACED the prior `status:"failed"` entry — erasing its captured - * `output`/`notes`/`verdict`/timestamps forever (the diagnostic trail - * FN-7642 worked to capture, and the history FN-7720's bypass affordance - * needs to show). `priorAttempts` preserves a BOUNDED, single-level history - * of prior terminal-failure (`failed`/`advisory_failure`) attempts on the - * surviving entry — snapshots never carry their own nested `priorAttempts`, - * so history cannot grow unbounded. This field is READ-ONLY history: it - * never participates in merge-blocking (`getTaskMergeBlocker`), self-healing - * recovery selection (`latestFailedPreMergeStep`), or progress/timing - * computation — only the current (this) entry's fields do. Written by the - * shared `upsertWorkflowStepResult` helper (`workflow-step-results.ts`). - */ - /** Bounded, single-level history of prior terminal-failure attempts this entry replaced. Read-only; never affects merge-blocking or recovery selection. */ - priorAttempts?: WorkflowStepResult[]; -} - -/** - * Lifecycle status of one persisted step instance (step-inversion U4, KTD-6). - * - `pending` — expanded but not yet started. - * - `in-progress` — actively executing inside its foreach sub-walk. - * - `awaiting-integration` — work complete on a parallel-mode branch, waiting - * for the ordered integration stage (KTD-11; unused at concurrency 1). - * - `completed` — terminal success (integrated in parallel mode). - * - `failed` — terminal failure. - */ -export type WorkflowRunStepInstanceStatus = - | "pending" - | "in-progress" - | "awaiting-integration" - | "completed" - | "failed"; - -/** - * Persisted run-state for one expanded step instance inside a foreach region - * (step-inversion U4, KTD-6). One row per `(taskId, runId, foreachNodeId, - * stepIndex)`; mirrors the `workflow_run_branches` posture. Resume reconstructs - * the instance set from `pinnedStepCount` + per-instance `currentNodeId` / - * `reworkCount`. `baselineSha` / `checkpointId` are the RETHINK reset anchors - * (previously in-memory, lost on restart). `branchName` / `integratedAt` and the - * `awaiting-integration` status serve parallel mode (KTD-11); null/unused at - * concurrency 1. This is the core row shape; the engine-side instance model is - * separate and engine-owned. - */ -export interface WorkflowRunStepInstance { - taskId: string; - runId: string; - /** Node id of the foreach region that expanded this instance. */ - foreachNodeId: string; - /** Zero-based index of the step this instance runs. */ - stepIndex: number; - /** Step count pinned at expansion; resume fails on mismatch with live steps[]. */ - pinnedStepCount: number; - /** Current sub-walk node id for the in-flight instance; null when not started. */ - currentNodeId?: string | null; - status: WorkflowRunStepInstanceStatus; - /** Git sha the RETHINK reset rewinds to; null when no baseline captured. */ - baselineSha?: string | null; - /** Session checkpoint to rewind to on RETHINK; null when none captured. */ - checkpointId?: string | null; - /** Number of rework cycles consumed against the rework budget. */ - reworkCount: number; - /** Per-instance branch name in worktree-isolation mode (KTD-11); null otherwise. */ - branchName?: string | null; - /** ISO-8601 timestamp the instance branch was integrated (KTD-11); null otherwise. */ - integratedAt?: string | null; - /** ISO-8601 timestamp of the last write to this row. */ - updatedAt: string; -} + CanonicalMergeConflictStrategy, + MergeStrategyOverlapBehavior, + PostMergeAuditMode, + MergeAuditAutoRecoveryMode, + MergerMode, + MergerSettings, + AutoRecoveryMode, + AutoRecoveryFailureClass, + AutoRecoverySettings, + UnavailableNodePolicy, + OwningNodeHandoffPolicy, +} from "./types/merge-policy.js"; +export { + __resetLegacyCwdMainWarningForTests, + normalizeMergeIntegrationWorktreeMode, + DIRECT_MERGE_COMMIT_STRATEGIES, + MERGE_ADVANCE_AUTO_SYNC_MODES, + normalizeMergeAdvanceAutoSyncMode, + normalizeMergeConflictStrategy, + MERGE_STRATEGY_OVERLAP_BEHAVIORS, + normalizeMergeStrategyOverlapBehavior, + POST_MERGE_AUDIT_MODES, + normalizePostMergeAuditMode, + MERGE_AUDIT_AUTO_RECOVERY_MODES, + normalizeMergeAuditAutoRecovery, + MERGER_MODES, + normalizeMergerMode, + AUTO_RECOVERY_MODES, + normalizeAutoRecovery, +}; +export type { + PrStatus, + MergeStrategy, + MergeIntegrationWorktreeMode, + DirectMergeCommitStrategy, + MergeAdvanceAutoSyncMode, + MergeConflictStrategy, + CanonicalMergeConflictStrategy, + MergeStrategyOverlapBehavior, + PostMergeAuditMode, + MergeAuditAutoRecoveryMode, + MergerMode, + MergerSettings, + AutoRecoveryMode, + AutoRecoveryFailureClass, + AutoRecoverySettings, + UnavailableNodePolicy, + OwningNodeHandoffPolicy, +}; -/* -FNXC:WorkflowStepTemplate 2026-06-25-00:00: -U6 deleted the built-in step-template catalog array (the former value export). The -`WorkflowStepTemplate` SHAPE is KEPT because plugin-contributed step templates still use -it (they feed the -workflow-editor optional-group palette via `getPluginWorkflowStepTemplates`). It is no -longer backed by any built-in catalog: the former built-in `browser-verification` / -`code-review` literals now live inlined in their optional-group node builders -(`builtin-browser-verification-group.ts` / `builtin-code-review-group.ts`). -*/ -/** A workflow step template shape used by plugin-contributed steps (palette entries). */ -export interface WorkflowStepTemplate { - /** Unique template identifier (e.g., "documentation-review") */ - id: string; - /** Display name (e.g., "Documentation Review") */ - name: string; - /** Short description for UI */ - description: string; - /** Full agent prompt template */ - prompt: string; - /** Execution mode for plugin-contributed templates; defaults to prompt. */ - mode?: WorkflowStepMode; - /** Task lifecycle phase for plugin-contributed templates; defaults to pre-merge. */ - phase?: "pre-merge" | "post-merge"; - /** Script name for script-mode plugin templates. */ - scriptName?: string; - /** Tool set available when the template runs as a prompt-mode step. */ - toolMode?: WorkflowStepToolMode; - /** Failure behavior for materialized steps from this template. */ - gateMode?: WorkflowStepGateMode; - /** Whether this template should be auto-selected for new tasks. */ - defaultOn?: boolean; - /** AI model provider override for prompt-mode templates. */ - modelProvider?: string; - /** AI model ID override for prompt-mode templates. */ - modelId?: string; - /** Optional per-node reasoning-effort override for prompt-mode templates. */ - thinkingLevel?: ThinkingLevel; - /** Grouping category (e.g., "Quality", "Security") */ - category: string; - /** Optional icon identifier for UI (e.g., "file-text", "shield") */ - icon?: string; - /** Optional default enabled state for plugin-provided templates. */ - enabled?: boolean; -} +import { NOTIFICATION_EVENTS } from "./types/workflow-steps.js"; +import type { + ModelPreset, + WorkflowStepMode, + WorkflowStepToolMode, + WorkflowStepGateMode, + WorkflowStepPhase, + WorkflowStep, + NtfyNotificationEvent, + NotificationEvent, + NotificationPayload, + NotificationProviderConfig, + CustomProvider, + WorkflowStepInput, + WorkflowStepResult, + WorkflowRunStepInstanceStatus, + WorkflowRunStepInstance, + WorkflowStepTemplate, +} from "./types/workflow-steps.js"; +export { NOTIFICATION_EVENTS }; +export type { + ModelPreset, + WorkflowStepMode, + WorkflowStepToolMode, + WorkflowStepGateMode, + WorkflowStepPhase, + WorkflowStep, + NtfyNotificationEvent, + NotificationEvent, + NotificationPayload, + NotificationProviderConfig, + CustomProvider, + WorkflowStepInput, + WorkflowStepResult, + WorkflowRunStepInstanceStatus, + WorkflowRunStepInstance, + WorkflowStepTemplate, +}; export type PrConflictState = "clean" | "conflicting" | "behind" | "blocked" | "unknown"; diff --git a/packages/core/src/types/board.ts b/packages/core/src/types/board.ts new file mode 100644 index 0000000000..828614f29c --- /dev/null +++ b/packages/core/src/types/board.ts @@ -0,0 +1,73 @@ +/** + * Board column, priority, and thinking-level domain types for the Fusion core contract. + * + * FNXC:CodeOrganization 2026-07-15-00:00: + * Extracted from types.ts barrel so domain types are navigable while types.ts remains the + * browser-safe @fusion/core Vite alias re-export surface. + */ + +/** + * Valid thinking effort levels for AI agent sessions, controlling the cost/quality tradeoff of reasoning. + * Includes extra-high for maximum-effort requests on reasoning-capable models. + * + * FNXC:Settings-ThinkingLevel 2026-06-19-14:55: + * The central thinking-level enum must expose `xhigh` so UI settings and API validation can pass maximum reasoning requests through to CLI adapters. Runtime adapters map `xhigh` to `high` for non-Opus models and `max` for Opus models. + */ +export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; +export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; + +/** + * The legacy default-workflow column set. Workflow-aware task movement resolves + * valid columns from each task's workflow definition (the default workflow's + * column IDs are byte-identical to these — KTD-1). New code should prefer the + * workflow-resolved path (`resolveAllowedColumns` / `workflowHasColumn` in + * `workflow-transitions.ts`) and trait predicates over string equality; this + * enum remains the canonical id set for the built-in default workflow. + */ +export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const; +/** + * The closed legacy column union — still the correct type for default-workflow + * column ids. Movement entry points accept the wider {@link ColumnId}; runtime + * code validates ids against the task's resolved workflow. + */ +export type Column = (typeof COLUMNS)[number]; + +/** + * Column identifier accepted at task-movement entry points (KTD-1). + * Equals the legacy `Column` union for autocomplete purposes, but admits + * workflow-defined custom column ids; runtime paths validate the id against the + * task's resolved workflow. + */ +export type ColumnId = Column | (string & {}); + +export const DEFAULT_COLUMN: Column = "triage"; + +/** + * Tests membership against the closed legacy column enum. Note: under the + * workflowColumns flag, column validity is workflow-scoped — flag-aware code + * should use `workflowHasColumn(ir, columnId)` (`workflow-transitions.ts`); + * this remains correct for the flag-OFF path and default-workflow ids. + */ +export function isColumn(value: unknown): value is Column { + return typeof value === "string" && (COLUMNS as readonly string[]).includes(value); +} + +/** + * @deprecated (workflowColumns, U12) Coerces an arbitrary value to a legacy + * column, DISCARDING workflow-defined custom column ids — lossy under the + * flag. Resolve and validate against the task's workflow instead. Retained + * for the legacy flag-OFF path while the flag exists. + */ +export function normalizeColumn(value: unknown, fallback: Column = DEFAULT_COLUMN): Column { + return isColumn(value) ? value : fallback; +} + +/** Ordered task-priority levels for the core task domain contract. */ +export const TASK_PRIORITIES = ["low", "normal", "high", "urgent"] as const; +export type TaskPriority = (typeof TASK_PRIORITIES)[number]; + +/** + * Default task priority used for legacy rows/entries and create flows when + * callers omit the priority field. + */ +export const DEFAULT_TASK_PRIORITY: TaskPriority = "normal"; diff --git a/packages/core/src/types/execution-and-ui.ts b/packages/core/src/types/execution-and-ui.ts new file mode 100644 index 0000000000..48078225ef --- /dev/null +++ b/packages/core/src/types/execution-and-ui.ts @@ -0,0 +1,156 @@ +/** + * Execution modes, planner oversight, theme, and locale domain types. + * + * FNXC:CodeOrganization 2026-07-15-00:00: + * Extracted from types.ts; re-exported from the browser-safe types barrel. + */ + +/** + * Dashboard high-fan-out blocker threshold. A blocker is considered high impact + * when at least this many active todo tasks are waiting on it. + */ +export const HIGH_FANOUT_BLOCKER_TODO_THRESHOLD = 5; + +/** + * Default age gate (ms) before a high fan-out blocker is escalated in dashboards. + */ +export const STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS = 2 * 60 * 60 * 1000; + +/** + * Execution mode for task implementation. + * Controls how the executor agent approaches the task: + * - "standard": Full execution with complete review workflow (default) + * - "fast": Expedited execution with minimal overhead for simple tasks + */ +export const EXECUTION_MODES = ["standard", "fast"] as const; +export type ExecutionMode = (typeof EXECUTION_MODES)[number]; + +/** Default execution mode for new tasks */ +export const DEFAULT_EXECUTION_MODE: ExecutionMode = "standard"; + +/* + * FNXC:PlannerOversight 2026-07-04-00:00: + * Per-task override of the workflow-native `plannerOversightLevel` setting + * (declared in `BUILTIN_OVERSIGHT_SETTINGS`, packages/core/src/builtin-workflow-settings.ts). + * When a task sets this field, it wins over the workflow's effective oversight + * value; unset (NULL in storage) means "inherit the workflow default". Values, + * order, and default here must stay in sync with `BUILTIN_OVERSIGHT_SETTINGS`. + */ +export const PLANNER_OVERSIGHT_LEVELS = ["off", "observe", "steer", "autonomous"] as const; +export type PlannerOversightLevel = (typeof PLANNER_OVERSIGHT_LEVELS)[number]; +export const DEFAULT_PLANNER_OVERSIGHT_LEVEL: PlannerOversightLevel = "autonomous"; + +/** Controls whether triage should require completion documentation artifacts in task specs. */ +export const COMPLETION_DOCUMENTATION_MODES = ["off", "changeset", "changelog"] as const; +export type CompletionDocumentationMode = (typeof COMPLETION_DOCUMENTATION_MODES)[number]; + +/** Theme mode for light/dark/system preference */ +export const THEME_MODES = ["dark", "light", "system"] as const; +export type ThemeMode = (typeof THEME_MODES)[number]; + +/** Color theme options for the dashboard */ +export const COLOR_THEMES = [ + "default", + "ocean", + "forest", + "sunset", + "zen", + "berry", + "high-contrast", + "industrial", + "monochrome", + "slate", + "ash", + // FNXC:DashboardTheming 2026-06-19-15:36: "air" is the source-of-truth id for the minimal, borderless, paper-like dashboard theme; keep bootstrap validators and theme options in sync with this union. + "air", + "graphite", + "silver", + "solarized", + "factory", + "factory-mono", + "ayu", + "one-dark", + "nord", + "dracula", + "gruvbox", + "tokyo-night", + "catppuccin-mocha", + "github-dark", + "everforest", + "rose-pine", + "kanagawa", + "night-owl", + "palenight", + "monokai-pro", + "slime", + "brutalist", + "neon-city", + "parchment", + "terminal", + "glass", + // FNXC:DashboardTheming 2026-07-01-00:00: Glass Silver is the silver/gray frosted sibling of Glass; keep this id in lockstep with dashboard/desktop validators and selector metadata so persisted explicit choices survive startup. + "glass-silver", + "horizon", + "vitesse", + "outrun", + "snazzy", + "porple", + "espresso", + "mars", + "poimandres", + "ember", + "rust", + "copper", + "foundry", + "carbon", + "sandstone", + "lagoon", + "frost", + "lavender", + "neon-bloom", + "sepia", + "shadcn", + // FNXC:DashboardTheming 2026-06-30-00:00: Shadcn Ember is the default for unset installs; keep it adjacent to the shadcn base so dashboard options and bootstrap validators preserve published theme order while explicit legacy ids remain valid. + "shadcn-ember", + // FNXC:DashboardTheming 2026-06-20-18:20: FN-6816 adds the user-customizable shadcn variant; keep this union in lockstep with dashboard theme options, swatches, theme-data base blocks, and the shadcn custom color token list. + "shadcn-custom", + // FNXC:DashboardTheming 2026-06-19-16:07: FN-6756 extends the published color-theme union with shadcn-family accent variants; keep dashboard theme options, bootstrap validation, swatches, and theme-data token blocks in lockstep with this ordered list. + // FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 renames the grayscale-base mono theme to shadcn-mono-red and adds the remaining mono accent variants; keep Shadcn Gray adjacent to Shadcn Black so the color-family order stays stable with FN-6814. + // FNXC:DashboardTheming 2026-06-21-00:00: FN-6815 adds shadcn-gray-blue as the slate-neutral blue-gray sibling; keep it adjacent to Shadcn Gray so the published union mirrors dashboard option order. + "shadcn-blue", + "shadcn-green", + "shadcn-red", + "shadcn-purple", + "shadcn-pink", + "shadcn-orange", + "shadcn-yellow", + "shadcn-mono-red", + "shadcn-mono-blue", + "shadcn-mono-green", + "shadcn-mono-purple", + "shadcn-mono-pink", + "shadcn-mono-orange", + "shadcn-mono-yellow", + "shadcn-black", + "shadcn-gray", + "shadcn-gray-blue", +] as const; +export type ColorTheme = (typeof COLOR_THEMES)[number]; + +/** UI locales supported across the dashboard and terminal UI. `en` is the + * source-of-truth language and the fallback for all others. Adding a locale + * here (plus translated catalogs) is the only code change a new language + * needs — see `@fusion/i18n`. zh-CN and zh-TW are independent catalogs and + * are never auto-converted between scripts. */ +export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "fr", "es", "ko"] as const; +export type Locale = (typeof SUPPORTED_LOCALES)[number]; +/** Source-of-truth language and the fallback for all locales. */ +export const DEFAULT_LOCALE: Locale = "en"; + +/** Narrow an arbitrary value to a supported `Locale`. */ +export function isLocale(value: unknown): value is Locale { + return ( + typeof value === "string" && + (SUPPORTED_LOCALES as readonly string[]).includes(value) + ); +} diff --git a/packages/core/src/types/merge-policy.ts b/packages/core/src/types/merge-policy.ts new file mode 100644 index 0000000000..77bc5880eb --- /dev/null +++ b/packages/core/src/types/merge-policy.ts @@ -0,0 +1,246 @@ +/** + * Merger strategy, conflict, audit, and auto-recovery policy types + normalizers. + * + * FNXC:CodeOrganization 2026-07-15-00:00: + * Extracted from types.ts; re-exported from the browser-safe types barrel. + */ + +export type PrStatus = "open" | "closed" | "merged" | "draft"; +export type MergeStrategy = "direct" | "pull-request"; +export type MergeIntegrationWorktreeMode = + | "reuse-task-worktree" + | "cwd-integration-branch" // explicit opt-in; surfaces a warning at startup. See FN-5348. + | "cwd-main"; // legacy alias for cwd-integration-branch; deprecated. Normalized at read time. + +let warnedLegacyCwdMain = false; + +export function __resetLegacyCwdMainWarningForTests(): void { + warnedLegacyCwdMain = false; +} + +export function normalizeMergeIntegrationWorktreeMode( + value: unknown, +): MergeIntegrationWorktreeMode { + if (value === "reuse-task-worktree" || value === "cwd-integration-branch") { + return value; + } + + if (value === "cwd-main") { + if (!warnedLegacyCwdMain) { + warnedLegacyCwdMain = true; + console.warn("[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch"); + } + return "cwd-integration-branch"; + } + + return "reuse-task-worktree"; +} + +export const DIRECT_MERGE_COMMIT_STRATEGIES = ["auto", "always-squash", "always-rebase"] as const; +export type DirectMergeCommitStrategy = (typeof DIRECT_MERGE_COMMIT_STRATEGIES)[number]; + +export const MERGE_ADVANCE_AUTO_SYNC_MODES = ["off", "ff-only", "stash-and-ff"] as const; +export type MergeAdvanceAutoSyncMode = (typeof MERGE_ADVANCE_AUTO_SYNC_MODES)[number]; +export function normalizeMergeAdvanceAutoSyncMode(value: unknown): MergeAdvanceAutoSyncMode { + return value === "off" || value === "ff-only" || value === "stash-and-ff" ? value : "stash-and-ff"; +} +/** How merge conflicts are resolved when the AI agent can't (or shouldn't) decide. + * + * Both `smart-*` strategies share the same cascade: pre-merge fetch + + * fast-forward of local main from origin (graceful degrade on failure), + * then AI, then auto-resolve lock/generated/trivial files. They differ only + * in the final per-file fallback when conflicts remain: + * + * - "smart-prefer-main" (default): fall back to `-X ours` so main's state + * wins. Best when concurrent tasks could regress just-merged sibling work. + * - "smart-prefer-branch": fall back to `-X theirs` so the task branch wins. + * Best when one agent at a time is dominant and you trust their output. + * - "ai-only": run AI on every attempt; never silently prefer one side. + * - "abort": run AI once; if conflict remains, fail the merge so a human + * can resolve it. + * + * Legacy values `"smart"` and `"prefer-main"` are accepted for backwards + * compatibility and normalized via {@link normalizeMergeConflictStrategy}. + * `"smart"` maps to `"smart-prefer-branch"` (its historical fallback) and + * `"prefer-main"` maps to `"smart-prefer-main"`. */ +export type MergeConflictStrategy = + | "smart-prefer-main" + | "smart-prefer-branch" + | "ai-only" + | "abort" + /** @deprecated use "smart-prefer-branch" */ + | "smart" + /** @deprecated use "smart-prefer-main" */ + | "prefer-main"; + +/** Canonical (post-migration) values that the merger actually dispatches on. */ +export type CanonicalMergeConflictStrategy = Exclude< + MergeConflictStrategy, + "smart" | "prefer-main" +>; + +/** Translate legacy `mergeConflictStrategy` values into their canonical form. + * Pass-through for already-canonical values; defaults to "smart-prefer-main" + * when the input is undefined. */ +export function normalizeMergeConflictStrategy( + value: MergeConflictStrategy | undefined, +): CanonicalMergeConflictStrategy { + switch (value) { + case "smart": + return "smart-prefer-branch"; + case "prefer-main": + return "smart-prefer-main"; + case undefined: + return "smart-prefer-main"; + default: + return value; + } +} + +export const MERGE_STRATEGY_OVERLAP_BEHAVIORS = [ + "flip-to-prefer-branch", + "warn-only", + "ignore", +] as const; + +export type MergeStrategyOverlapBehavior = (typeof MERGE_STRATEGY_OVERLAP_BEHAVIORS)[number]; + +export function normalizeMergeStrategyOverlapBehavior( + value: unknown, +): MergeStrategyOverlapBehavior { + return typeof value === "string" + && (MERGE_STRATEGY_OVERLAP_BEHAVIORS as readonly string[]).includes(value) + ? value as MergeStrategyOverlapBehavior + : "flip-to-prefer-branch"; +} + +export const POST_MERGE_AUDIT_MODES = ["block", "warn", "off"] as const; + +/** Controls how the merger reacts to a dirty post-merge audit (FN-4333). */ +export type PostMergeAuditMode = (typeof POST_MERGE_AUDIT_MODES)[number]; + +export function normalizePostMergeAuditMode(value: unknown): PostMergeAuditMode { + return typeof value === "string" + && (POST_MERGE_AUDIT_MODES as readonly string[]).includes(value) + ? (value as PostMergeAuditMode) + : "block"; +} + +export const MERGE_AUDIT_AUTO_RECOVERY_MODES = ["deterministic-only", "programmatic", "ai-assisted", "off"] as const; + +/** Controls how aggressively the merger tries to auto-recover from audit blocks (FN-4315). */ +export type MergeAuditAutoRecoveryMode = (typeof MERGE_AUDIT_AUTO_RECOVERY_MODES)[number]; + +export function normalizeMergeAuditAutoRecovery(value: unknown): MergeAuditAutoRecoveryMode { + return typeof value === "string" + && (MERGE_AUDIT_AUTO_RECOVERY_MODES as readonly string[]).includes(value) + ? (value as MergeAuditAutoRecoveryMode) + : "ai-assisted"; +} + +export const MERGER_MODES = ["ai", "deterministic"] as const; + +/** + * Merge execution path (FN-5633). + * - "ai" (default): the standalone AI merge path — a clean-room worktree where + * an AI agent merges the task branch and an AI reviewer audits it (with + * corrective retries) before a fast-forward landing. Bypasses the legacy + * scaffolding entirely. + * - "deterministic": **DEPRECATED (master-plan U0, 2026-06-21) and INERT.** Once + * routed to the legacy `aiMergeTask` pipeline; now ignored — every merge uses + * the unified "ai" path (`runAiMerge`). The value is retained (not removed) to + * avoid a breaking `@runfusion/fusion` type change, and the engine logs a + * one-time deprecation warning when it observes a resolved "deterministic". + * + * FNXC:MergerUnification 2026-06-21-19:05: `merger.mode` is published surface, so + * the type and the `MergerSettings.mode` field stay; only the "deterministic" + * VALUE is deprecated/inert. Removing the type is a separate breaking change. + */ +export type MergerMode = (typeof MERGER_MODES)[number]; + +export function normalizeMergerMode(value: unknown): MergerMode { + return typeof value === "string" && (MERGER_MODES as readonly string[]).includes(value) + ? (value as MergerMode) + : "ai"; +} + +/** Settings for the AI merge path (FN-5633). */ +export interface MergerSettings { + /** + * Which merge path to use. Default: "ai". + * @deprecated master-plan U0 (2026-06-21): the value is inert — every merge now + * uses the unified AI merge path (`runAiMerge`). Field retained as published + * surface; "deterministic" only triggers a one-time deprecation warning. + */ + mode?: MergerMode; + /** How many AI corrective rounds before landing the best result (advisory) or + * hard-failing (blocking). Default: 3. The reviewer uses the project's + * validator/reviewer model lane — there is no merge-specific model setting. */ + maxReviewPasses?: number; + /** Dangerous compatibility escape hatch for the AI merge landing path. + * When true (default for resolved project settings), Fusion restores the legacy + * stash → fast-forward → restore behavior when the checked-out integration + * worktree is dirty. Set false to explicitly opt out and fail closed before + * unrelated local edits can be reintroduced after landing. */ + allowDirtyLocalCheckoutSync?: boolean; +} + +export const AUTO_RECOVERY_MODES = ["off", "deterministic-only", "programmatic", "ai-assisted"] as const; + +export type AutoRecoveryMode = (typeof AUTO_RECOVERY_MODES)[number]; + +export type AutoRecoveryFailureClass = + | "file-scope-invariant" + | "post-squash-audit-blocker" + | "branch-cross-contamination" + | "branch-conflict-tripwire" + | "branch-conflict-recovery-exhausted" + | "branch-conflict-unrecoverable" + | "message-delivery-failure"; + +export interface AutoRecoverySettings { + mode: AutoRecoveryMode; + perClass?: Partial>; + maxRetries?: number; +} + +export function normalizeAutoRecovery(value: unknown): AutoRecoverySettings { + const fallback: AutoRecoverySettings = { mode: "deterministic-only", maxRetries: 3 }; + if (!value || typeof value !== "object") return fallback; + + const candidate = value as { + mode?: unknown; + perClass?: unknown; + maxRetries?: unknown; + }; + const mode = typeof candidate.mode === "string" && (AUTO_RECOVERY_MODES as readonly string[]).includes(candidate.mode) + ? candidate.mode as AutoRecoveryMode + : fallback.mode; + const perClass = typeof candidate.perClass === "object" && candidate.perClass + ? Object.fromEntries( + Object.entries(candidate.perClass as Record) + .filter(([k, v]) => ( + [ + "file-scope-invariant", + "post-squash-audit-blocker", + "branch-cross-contamination", + "branch-conflict-tripwire", + "branch-conflict-recovery-exhausted", + "branch-conflict-unrecoverable", + "message-delivery-failure", + ].includes(k) + && typeof v === "string" + && (AUTO_RECOVERY_MODES as readonly string[]).includes(v) + )), + ) as Partial> + : undefined; + const maxRetries = typeof candidate.maxRetries === "number" && Number.isFinite(candidate.maxRetries) + ? Math.max(0, Math.floor(candidate.maxRetries)) + : fallback.maxRetries; + + return { mode, perClass, maxRetries }; +} +/** Policy for handling task execution when the selected node is unavailable/unhealthy. */ +export type UnavailableNodePolicy = "block" | "fallback-local"; + +export type OwningNodeHandoffPolicy = "block" | "reassign-to-local" | "reassign-any-healthy"; diff --git a/packages/core/src/types/merge-queue.ts b/packages/core/src/types/merge-queue.ts new file mode 100644 index 0000000000..c3b63943e0 --- /dev/null +++ b/packages/core/src/types/merge-queue.ts @@ -0,0 +1,167 @@ +/** + * Merge-queue, merge-request, and workflow work-item domain types. + * + * FNXC:CodeOrganization 2026-07-15-00:00: + * Extracted from types.ts; re-exported from the browser-safe types barrel. + */ + +import type { TaskPriority } from "./board.js"; + +export const MERGE_REQUEST_STATES = [ + "queued", + "running", + "retrying", + "succeeded", + "exhausted", + "cancelled", + "manual-required", +] as const; + +export type MergeRequestState = (typeof MERGE_REQUEST_STATES)[number]; + +export const WORKFLOW_WORK_ITEM_KINDS = [ + "task", + "merge", + "retry", + "manual-hold", + "recovery", +] as const; + +export type WorkflowWorkItemKind = (typeof WORKFLOW_WORK_ITEM_KINDS)[number]; + +export const WORKFLOW_WORK_ITEM_STATES = [ + "runnable", + "running", + "held", + "retrying", + "manual-required", + "succeeded", + "failed", + "cancelled", + "exhausted", +] as const; + +export type WorkflowWorkItemState = (typeof WORKFLOW_WORK_ITEM_STATES)[number]; + +export interface WorkflowWorkItem { + id: string; + runId: string; + taskId: string; + nodeId: string; + kind: WorkflowWorkItemKind; + state: WorkflowWorkItemState; + attempt: number; + retryAfter: string | null; + leaseOwner: string | null; + leaseExpiresAt: string | null; + lastError: string | null; + blockedReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WorkflowWorkItemUpsertInput { + id?: string; + runId: string; + taskId: string; + nodeId: string; + kind: WorkflowWorkItemKind; + state?: WorkflowWorkItemState; + attempt?: number; + retryAfter?: string | null; + leaseOwner?: string | null; + leaseExpiresAt?: string | null; + lastError?: string | null; + blockedReason?: string | null; + now?: string; +} + +export interface WorkflowWorkItemTransitionPatch { + attempt?: number; + retryAfter?: string | null; + leaseOwner?: string | null; + leaseExpiresAt?: string | null; + lastError?: string | null; + blockedReason?: string | null; + now?: string; +} + +export interface WorkflowWorkItemDueFilter { + now?: string; + limit?: number; + kinds?: WorkflowWorkItemKind[]; + states?: WorkflowWorkItemState[]; +} + +export interface MergeRequestWorkflowProjectionOptions { + runId?: string; + nodeId?: string; + now?: string; +} + +export interface MergeQueueEntry { + taskId: string; + enqueuedAt: string; + priority: TaskPriority; + leasedBy: string | null; + leasedAt: string | null; + leaseExpiresAt: string | null; + attemptCount: number; + lastError: string | null; +} + +export interface MergeRequestRecord { + taskId: string; + state: MergeRequestState; + createdAt: string; + updatedAt: string; + attemptCount: number; + lastError: string | null; +} + +export interface CompletionHandoffMarker { + taskId: string; + acceptedAt: string; + source: string; +} + +export interface MergeQueueEnqueueOptions { + priority?: TaskPriority; + now?: string; +} + +export interface MergeQueueAcquireOptions { + leaseDurationMs: number; + now?: string; + /** If provided, the lease attempt targets this specific task first. + * The task must be unexpired/available; otherwise falls back to normal queue-head selection. */ + targetTaskId?: string; +} + +export type MergeQueueReleaseOutcome = + | { kind: "success" } + | { kind: "failure"; error: string }; + +export interface HandoffEvidence { + /** Reason text recorded on the run-audit event (for example "fn_task_done"). */ + reason: string; + /** Optional run id captured for forensics. */ + runId?: string; + /** Optional agent id captured for forensics. */ + agentId?: string; +} + +export interface HandoffToReviewOptions { + ownerAgentId: string | null; + evidence: HandoffEvidence; + moveOptions?: { + preserveResumeState?: boolean; + preserveProgress?: boolean; + preserveWorktree?: boolean; + preserveStatus?: boolean; + moveSource?: "user" | "engine"; + skipMergeBlocker?: boolean; + }; + /** Inject a clock for tests. */ + now?: string; +} diff --git a/packages/core/src/types/workflow-steps.ts b/packages/core/src/types/workflow-steps.ts new file mode 100644 index 0000000000..1854f727b2 --- /dev/null +++ b/packages/core/src/types/workflow-steps.ts @@ -0,0 +1,395 @@ +/** + * Workflow step templates, run instances, notifications, and model-preset types. + * + * FNXC:CodeOrganization 2026-07-15-00:00: + * Extracted from types.ts; re-exported from the browser-safe types barrel. + */ + +import type { ThinkingLevel } from "./board.js"; + +export interface ModelPreset { + id: string; + name: string; + executorProvider?: string; + executorModelId?: string; + validatorProvider?: string; + validatorModelId?: string; +} + +/** A reusable workflow step definition that can run after task implementation. */ +/** Execution mode for a workflow step. */ +export type WorkflowStepMode = "prompt" | "script"; +export type WorkflowStepToolMode = "readonly" | "coding"; +export type WorkflowStepGateMode = "gate" | "advisory"; + +/** Lifecycle phase for workflow step execution. */ +export type WorkflowStepPhase = "pre-merge" | "post-merge"; + +export interface WorkflowStep { + /** Unique identifier (e.g., "WS-001") */ + id: string; + /** Built-in template source ID when this step was materialized from a template. */ + templateId?: string; + /** Display name (e.g., "Documentation Review") */ + name: string; + /** Short description for UI display */ + description: string; + /** Execution mode — "prompt" runs an AI agent, "script" runs a named project script */ + mode: WorkflowStepMode; + /** Lifecycle phase — "pre-merge" runs before merge (default), "post-merge" runs after merge success */ + phase?: WorkflowStepPhase; + /** Gate behavior — gate blocks merge/auto-revive on failure, advisory records non-blocking findings. */ + gateMode: WorkflowStepGateMode; + /** Full agent prompt to execute when this step runs (used when mode is "prompt") */ + prompt: string; + /** Tool set available to prompt-mode workflow agents. Defaults to readonly. */ + toolMode?: WorkflowStepToolMode; + /** Name of a skill to load into this step's session (e.g. + * "compound-engineering:ce-work"). When set, the step session loads the named + * skill (discovery + selection) and the engine injects the Fusion workflow-step + * conventions preamble. Only meaningful for skill-executor graph nodes. */ + skillName?: string; + /** + * Browser capability requested by prompt-mode steps. When true, the executor + * loads the agent-browser navigation skill when available, preflights the + * `agent-browser` CLI, and records browser-verification activity in the agent + * log. Ignored for script-mode steps. + */ + requiresBrowser?: boolean; + /** Name of a script from project settings `scripts` map to execute (required when mode is "script") */ + scriptName?: string; + /** Whether this step is available for selection on new tasks */ + enabled: boolean; + /** When true, this step is automatically pre-selected when creating new tasks. + * Users can still deselect it — this only controls the initial default state. */ + defaultOn?: boolean; + /** AI model provider override for the workflow step agent (e.g., "anthropic"). + * Must be set together with `modelId`. When both model fields are undefined, + * the executor uses global settings defaults. Only used when mode is "prompt". */ + modelProvider?: string; + /** AI model ID override for the workflow step agent (e.g., "claude-sonnet-4-5"). + * Must be set together with `modelProvider`. When both model fields are undefined, + * the executor uses global settings defaults. Only used when mode is "prompt". */ + modelId?: string; + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Workflow IR nodes may pin reasoning effort independently from the model pair so authors can inherit the model while overriding thinking level. Runtime precedence is node/step `thinkingLevel` > task `thinkingLevel` > settings `defaultThinkingLevel`. + */ + thinkingLevel?: ThinkingLevel; + /** (workflow-editor-consolidation U1/U2, KTD-1/KTD-3) when this legacy step has + * been migrated into a fragment WorkflowDefinition, the fragment's id is stamped + * here so the lazy step migration is idempotent (already-stamped rows are + * skipped). Stored in the `migrated_fragment_id` column. */ + migratedFragmentId?: string; + /** ISO-8601 timestamp of creation */ + createdAt: string; + /** ISO-8601 timestamp of last update */ + updatedAt: string; +} + +/** Input for creating a new workflow step. */ +/** Event types that can trigger ntfy notifications */ +export type NtfyNotificationEvent = + | "in-review" + | "merged" + | "failed" + | "awaiting-approval" + | "awaiting-user-review" + | "planning-awaiting-input" + | "cli-agent-awaiting-input" + | "gridlock" + | "board-stall-unrecovered" + | "db-corruption-detected" + | "fallback-used" + | "memory-dreams-processed" + | "token-budget" + | "message:agent-to-user" + | "message:agent-to-agent" + | "message:room" + | "oauth-token-expired" + | "task-created" + | "workflow-notify"; + +/** Known notification event types. Providers may support additional custom events. */ +export const NOTIFICATION_EVENTS = [ + "in-review", + "merged", + "failed", + "awaiting-approval", + "awaiting-user-review", + "planning-awaiting-input", + /* + * FNXC:ToolPermissionNotifications 2026-06-27-00:00: + * CLI tool-permission requests are a distinct user-facing notification event from plan approval. Operators must be able to enable external alerts when a terminal-backed agent waits for human input. + */ + "cli-agent-awaiting-input", + "gridlock", + "board-stall-unrecovered", + "db-corruption-detected", + "fallback-used", + "memory-dreams-processed", + "token-budget", + "message:agent-to-user", + "message:agent-to-agent", + "message:room", + "oauth-token-expired", + "task-created", + "workflow-notify", +] as const; + +/** Notification event type. Known events plus provider-specific custom events. */ +export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number] | (string & {}); + +/** Standard payload shape shared across notification providers. */ +export interface NotificationPayload { + taskId?: string; + taskTitle?: string; + taskDescription?: string; + event: NotificationEvent; + timestamp?: string; + metadata?: Record; +} + +/** Declarative notification provider configuration persisted in settings. */ +export interface NotificationProviderConfig { + id: string; + name: string; + enabled: boolean; + config: Record; +} + +export interface CustomProvider { + id: string; + name: string; + apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses"; + baseUrl: string; + apiKey?: string; + /** + * OpenAI-compatible opt-in for providers that explicitly support the `developer` role. + * Omitted/false forces legacy `system` role emission to avoid provider 400s. + */ + supportsDeveloperRole?: boolean; + /** + * FNXC:ProviderAuth 2026-07-08-00:00: + * FN-7689: opt-in for custom `openai-compatible`/`openai-responses` gateways that proxy an + * Anthropic-format backend (e.g. `usai/claude_4_6_sonnet`). When true, registered + * `openai-completions` models get pi-ai's `compat.cacheControlFormat = "anthropic"`, which makes + * pi-ai emit Anthropic-style `cache_control` breakpoints on the system prompt, last + * conversation message, and last tool. Without this, pi-ai's `detectCompat` only auto-enables + * caching for OpenRouter `anthropic/*` models, so a generic custom gateway re-bills the entire + * context prefix uncached every turn (measured cachedTokens=0/cacheWriteTokens=0 across 243 + * runs, ~327.5:1 input:output ratio). Default off — never force cache_control on gateways that + * did not opt in, since non-Anthropic-compatible backends (Together, Fireworks, etc.) can 400 on + * unexpected `cache_control` fields. Inert for `anthropic-compatible` (already auto-caches) and + * `google-generative-ai` (no cache_control concept). + */ + anthropicPromptCaching?: boolean; + models?: { id: string; name: string }[]; +} + +export interface WorkflowStepInput { + /** Built-in template source ID when creating a concrete step from a template. */ + templateId?: string; + name: string; + description: string; + /** Execution mode — defaults to "prompt" if not specified */ + mode?: WorkflowStepMode; + /** Lifecycle phase — defaults to "pre-merge" if not specified */ + phase?: WorkflowStepPhase; + /** Gate behavior — defaults by mode (prompt: advisory, script: gate) when omitted. */ + gateMode?: WorkflowStepGateMode; + /** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */ + prompt?: string; + /** Tool set available to prompt-mode workflow agents. Defaults to readonly. */ + toolMode?: WorkflowStepToolMode; + /** Name of a skill to load into this step's session (e.g. + * "compound-engineering:ce-work"). See `WorkflowStep.skillName`. */ + skillName?: string; + /** Script name from project settings (required when mode is "script"). + * Must reference a named script in `settings.scripts` — no raw commands. */ + scriptName?: string; + /** Defaults to true if not specified */ + enabled?: boolean; + /** When true, this step is automatically pre-selected when creating new tasks. + * Users can still deselect — this only controls the initial default state. */ + defaultOn?: boolean; + /** AI model provider override. Must be set together with modelId. Only used when mode is "prompt". */ + modelProvider?: string; + /** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */ + modelId?: string; + /** Optional per-node reasoning-effort override; inherits from task/settings when omitted. */ + thinkingLevel?: ThinkingLevel; + /** (workflow-editor-consolidation U2, KTD-3) fragment id stamped when this step + * was migrated into a fragment WorkflowDefinition. Set by the migration only. */ + migratedFragmentId?: string; +} + +/** Result of a workflow step execution on a task. */ +export interface WorkflowStepResult { + /** ID of the workflow step that ran (e.g., "WS-001") */ + workflowStepId: string; + /** Name of the workflow step at execution time */ + workflowStepName: string; + /** Lifecycle phase at execution time */ + phase?: WorkflowStepPhase; + /** Runtime source for distinguishing graph-authored node progress from optional-toggle checks. */ + source?: "optional-group" | "node"; + /** Execution status */ + status: "passed" | "failed" | "advisory_failure" | "skipped" | "pending"; + /** Output from the workflow step agent (findings, errors, etc.) */ + output?: string; + /** + * Machine-readable verdict from prompt-mode structured output. + * Absent for script-mode steps and legacy prose-only prompt outputs. + */ + verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE"; + /** + * Optional notes from prompt-mode structured output. + * Absent for script-mode steps and legacy prose-only prompt outputs. + */ + notes?: string; + /** ISO-8601 timestamp when the step started */ + startedAt?: string; + /** ISO-8601 timestamp when the step completed */ + completedAt?: string; + /* + * FNXC:ReviewLaneBypass 2026-07-09-00:00: + * A privileged operator can bypass a `status:"failed"` pre-merge review step + * (leading real-world cause: the Runfusion/Fusion#1946 `(no feedback captured)` + * no-verdict dispatch defect) so a card stranded solely by that failure can + * advance to merge (FN-7720). The bypass REWRITES this result's `status` to a + * terminal, non-blocking value (`"skipped"`) and stamps the fields below as an + * explicit audit trail — it never fabricates a reviewer `verdict`. Only the + * `getTaskMergeBlocker` "task has failed pre-merge workflow steps" reason is + * cleared; every other merge-blocker condition (paused, incomplete steps, + * blocking task status, still-`pending` pre-merge steps) is untouched. + */ + /** Operator identity that performed the bypass, if this result was bypassed. */ + bypassedBy?: string; + /** ISO-8601 timestamp when the bypass was applied. */ + bypassedAt?: string; + /** Mandatory operator-supplied justification for the bypass. */ + bypassReason?: string; + /** The `status` this result carried immediately before the bypass rewrote it (always `"failed"` for the supported bypass path). */ + bypassedFromStatus?: WorkflowStepResult["status"]; + /** The `verdict` (if any) this result carried immediately before the bypass, preserved for audit only — never promoted to `verdict`. */ + bypassedFromVerdict?: WorkflowStepResult["verdict"]; + /* + * FNXC:WorkflowStepResults 2026-07-09-00:10: + * FN-7727: self-healing recovery re-runs a failed pre-merge review node + * (`code-review`, `code-review-remediation`, `plan-review`, + * `browser-verification`) in place, and the recorder upsert previously + * REPLACED the prior `status:"failed"` entry — erasing its captured + * `output`/`notes`/`verdict`/timestamps forever (the diagnostic trail + * FN-7642 worked to capture, and the history FN-7720's bypass affordance + * needs to show). `priorAttempts` preserves a BOUNDED, single-level history + * of prior terminal-failure (`failed`/`advisory_failure`) attempts on the + * surviving entry — snapshots never carry their own nested `priorAttempts`, + * so history cannot grow unbounded. This field is READ-ONLY history: it + * never participates in merge-blocking (`getTaskMergeBlocker`), self-healing + * recovery selection (`latestFailedPreMergeStep`), or progress/timing + * computation — only the current (this) entry's fields do. Written by the + * shared `upsertWorkflowStepResult` helper (`workflow-step-results.ts`). + */ + /** Bounded, single-level history of prior terminal-failure attempts this entry replaced. Read-only; never affects merge-blocking or recovery selection. */ + priorAttempts?: WorkflowStepResult[]; +} + +/** + * Lifecycle status of one persisted step instance (step-inversion U4, KTD-6). + * - `pending` — expanded but not yet started. + * - `in-progress` — actively executing inside its foreach sub-walk. + * - `awaiting-integration` — work complete on a parallel-mode branch, waiting + * for the ordered integration stage (KTD-11; unused at concurrency 1). + * - `completed` — terminal success (integrated in parallel mode). + * - `failed` — terminal failure. + */ +export type WorkflowRunStepInstanceStatus = + | "pending" + | "in-progress" + | "awaiting-integration" + | "completed" + | "failed"; + +/** + * Persisted run-state for one expanded step instance inside a foreach region + * (step-inversion U4, KTD-6). One row per `(taskId, runId, foreachNodeId, + * stepIndex)`; mirrors the `workflow_run_branches` posture. Resume reconstructs + * the instance set from `pinnedStepCount` + per-instance `currentNodeId` / + * `reworkCount`. `baselineSha` / `checkpointId` are the RETHINK reset anchors + * (previously in-memory, lost on restart). `branchName` / `integratedAt` and the + * `awaiting-integration` status serve parallel mode (KTD-11); null/unused at + * concurrency 1. This is the core row shape; the engine-side instance model is + * separate and engine-owned. + */ +export interface WorkflowRunStepInstance { + taskId: string; + runId: string; + /** Node id of the foreach region that expanded this instance. */ + foreachNodeId: string; + /** Zero-based index of the step this instance runs. */ + stepIndex: number; + /** Step count pinned at expansion; resume fails on mismatch with live steps[]. */ + pinnedStepCount: number; + /** Current sub-walk node id for the in-flight instance; null when not started. */ + currentNodeId?: string | null; + status: WorkflowRunStepInstanceStatus; + /** Git sha the RETHINK reset rewinds to; null when no baseline captured. */ + baselineSha?: string | null; + /** Session checkpoint to rewind to on RETHINK; null when none captured. */ + checkpointId?: string | null; + /** Number of rework cycles consumed against the rework budget. */ + reworkCount: number; + /** Per-instance branch name in worktree-isolation mode (KTD-11); null otherwise. */ + branchName?: string | null; + /** ISO-8601 timestamp the instance branch was integrated (KTD-11); null otherwise. */ + integratedAt?: string | null; + /** ISO-8601 timestamp of the last write to this row. */ + updatedAt: string; +} + +/* +FNXC:WorkflowStepTemplate 2026-06-25-00:00: +U6 deleted the built-in step-template catalog array (the former value export). The +`WorkflowStepTemplate` SHAPE is KEPT because plugin-contributed step templates still use +it (they feed the +workflow-editor optional-group palette via `getPluginWorkflowStepTemplates`). It is no +longer backed by any built-in catalog: the former built-in `browser-verification` / +`code-review` literals now live inlined in their optional-group node builders +(`builtin-browser-verification-group.ts` / `builtin-code-review-group.ts`). +*/ +/** A workflow step template shape used by plugin-contributed steps (palette entries). */ +export interface WorkflowStepTemplate { + /** Unique template identifier (e.g., "documentation-review") */ + id: string; + /** Display name (e.g., "Documentation Review") */ + name: string; + /** Short description for UI */ + description: string; + /** Full agent prompt template */ + prompt: string; + /** Execution mode for plugin-contributed templates; defaults to prompt. */ + mode?: WorkflowStepMode; + /** Task lifecycle phase for plugin-contributed templates; defaults to pre-merge. */ + phase?: "pre-merge" | "post-merge"; + /** Script name for script-mode plugin templates. */ + scriptName?: string; + /** Tool set available when the template runs as a prompt-mode step. */ + toolMode?: WorkflowStepToolMode; + /** Failure behavior for materialized steps from this template. */ + gateMode?: WorkflowStepGateMode; + /** Whether this template should be auto-selected for new tasks. */ + defaultOn?: boolean; + /** AI model provider override for prompt-mode templates. */ + modelProvider?: string; + /** AI model ID override for prompt-mode templates. */ + modelId?: string; + /** Optional per-node reasoning-effort override for prompt-mode templates. */ + thinkingLevel?: ThinkingLevel; + /** Grouping category (e.g., "Quality", "Security") */ + category: string; + /** Optional icon identifier for UI (e.g., "file-text", "shield") */ + icon?: string; + /** Optional default enabled state for plugin-provided templates. */ + enabled?: boolean; +} diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 28d000b3dc..971c2a4e99 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -16,7 +16,7 @@ "packages/core/src/mission-store.ts": 4364, "packages/core/src/postgres/sqlite-migrator.ts": 2068, "packages/core/src/store.ts": 2631, - "packages/core/src/types.ts": 8104, + "packages/core/src/types.ts": 7336, "packages/dashboard/app/api/legacy.ts": 11627, "packages/dashboard/app/components/AgentDetailView.tsx": 5552, "packages/dashboard/app/components/AgentsView.tsx": 2254, From 32f2b58b2a04494d4a564d92f210bfac3219b30e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 10:51:10 -0700 Subject: [PATCH 3/9] refactor(engine): peel pure helpers out of executor.ts Move browser probe, requeue-loop signatures, pseudo-pause/handoff detectors, and clearTerminalWorkflowStepFailures into executor/* satellites with re-exports from executor.ts for stable test imports. --- packages/engine/src/executor.ts | 301 +++--------------- packages/engine/src/executor/browser-probe.ts | 94 ++++++ packages/engine/src/executor/pseudo-pause.ts | 93 ++++++ packages/engine/src/executor/requeue-loop.ts | 94 ++++++ .../src/executor/workflow-step-failures.ts | 29 ++ scripts/line-count-baseline.json | 2 +- 6 files changed, 351 insertions(+), 262 deletions(-) create mode 100644 packages/engine/src/executor/browser-probe.ts create mode 100644 packages/engine/src/executor/pseudo-pause.ts create mode 100644 packages/engine/src/executor/requeue-loop.ts create mode 100644 packages/engine/src/executor/workflow-step-failures.ts diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 72435b3d4a..80481d20f9 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -277,94 +277,26 @@ export { taskLogParams, } from "./agent-tools.js"; -export const AGENT_BROWSER_NAVIGATION_SKILL_ID = "agent-browser-navigation"; - -export interface AgentBrowserAvailabilityProbeResult { - available: boolean; - version?: string; - reason?: string; -} - -type AgentBrowserExec = ( - command: string, - options: { encoding: BufferEncoding; timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv; cwd?: string }, -) => Promise<{ stdout: string; stderr: string }>; - -function isAgentBrowserNotFoundError(error: unknown): boolean { - const err = error as { code?: unknown; stderr?: unknown; message?: unknown } | null; - const code = typeof err?.code === "string" || typeof err?.code === "number" ? String(err.code) : undefined; - if (code === "ENOENT" || code === "127") return true; - const combined = `${typeof err?.stderr === "string" ? err.stderr : ""}\n${typeof err?.message === "string" ? err.message : ""}`.toLowerCase(); - return combined.includes("agent-browser") && (combined.includes("not found") || combined.includes("command not found")); -} - -function isAgentBrowserProbeTimeout(error: unknown): boolean { - const err = error as { code?: unknown; killed?: unknown; signal?: unknown; message?: unknown } | null; - return err?.code === "ETIMEDOUT" - || err?.killed === true - || err?.signal === "SIGTERM" - || (typeof err?.message === "string" && err.message.toLowerCase().includes("timed out")); -} - -/** - * Probe the agent-browser CLI without making browser verification fatal. - * - * FNXC:WorkflowBrowserVerification 2026-06-27-13:20: - * Browser Verification needs an actionable signal when `agent-browser` is absent or hung. Keep this async, bounded, and injectable so the executor logs availability without blocking or requiring the plugin at import time. - */ -export async function probeAgentBrowserAvailability( - execImpl: AgentBrowserExec = execAsync as AgentBrowserExec, - opts?: { timeoutMs?: number; maxBuffer?: number; env?: NodeJS.ProcessEnv; cwd?: string }, -): Promise { - try { - const { stdout, stderr } = await execImpl("agent-browser --version", { - encoding: "utf-8", - timeout: Math.min(Math.max(opts?.timeoutMs ?? 5_000, 1_000), 10_000), - maxBuffer: opts?.maxBuffer ?? 64 * 1024, - ...(opts?.env ? { env: opts.env } : {}), - ...(opts?.cwd ? { cwd: opts.cwd } : {}), - }); - const version = (stdout.trim() || stderr.trim() || "unknown").split("\n")[0]?.trim() || "unknown"; - return { available: true, version }; - } catch (error) { - if (isAgentBrowserNotFoundError(error)) { - return { available: false, reason: "not installed" }; - } - if (isAgentBrowserProbeTimeout(error)) { - return { available: false, reason: "probe timed out" }; - } - const reason = error instanceof Error ? error.message : String(error); - return { available: false, reason }; - } -} - -/** Merge the agent-browser navigation skill into a workflow-step session. */ -export function augmentSessionSkillsForBrowserStep( - skillSelection: SkillSelectionContext | undefined, - projectRootDir: string, -): SkillSelectionContext { - const existing = skillSelection?.requestedSkillNames ?? []; - return { - projectRootDir: skillSelection?.projectRootDir ?? projectRootDir, - sessionPurpose: skillSelection?.sessionPurpose ?? "executor", - requestedSkillNames: [...new Set([...existing, AGENT_BROWSER_NAVIGATION_SKILL_ID])], - }; -} +export { + AGENT_BROWSER_NAVIGATION_SKILL_ID, + probeAgentBrowserAvailability, + augmentSessionSkillsForBrowserStep, + formatAgentBrowserAvailabilityLog, +} from "./executor/browser-probe.js"; +export type { AgentBrowserAvailabilityProbeResult } from "./executor/browser-probe.js"; +import { + AGENT_BROWSER_NAVIGATION_SKILL_ID, + probeAgentBrowserAvailability, + augmentSessionSkillsForBrowserStep, + formatAgentBrowserAvailabilityLog, +} from "./executor/browser-probe.js"; +import type { AgentBrowserAvailabilityProbeResult, AgentBrowserExec } from "./executor/browser-probe.js"; function mergeAdditionalSkillPaths(...pathGroups: Array): string[] | undefined { const merged = Array.from(new Set(pathGroups.flatMap((paths) => paths ?? []))); return merged.length > 0 ? merged : undefined; } -export function formatAgentBrowserAvailabilityLog(result: AgentBrowserAvailabilityProbeResult): string { - if (result.available) { - return `[browser-verification] agent-browser available — version ${result.version ?? "unknown"}`; - } - if (result.reason === "probe timed out") { - return "[browser-verification] agent-browser availability probe timed out — the step relies on the agent-browser CLI; continuing so the step can fast-bail or report its own failure."; - } - return "[browser-verification] agent-browser not found on PATH — the step relies on the agent-browser CLI; install the agent-browser plugin/binary. Continuing; the step may fast-bail or fail."; -} const yieldEventLoop = (): Promise => new Promise((resolve) => setImmediateCb(resolve)); function getPromptSection(prompt: string, heading: string): string { @@ -503,15 +435,22 @@ const MAX_WORKFLOW_STEP_RETRIES = 3; const MAX_TASK_DONE_SESSION_RETRIES = 3; /** Maximum todo requeues after exhausting in-session fn_task_done retries. */ const MAX_TASK_DONE_REQUEUE_RETRIES = 3; -/** Maximum no-progress execute-node self-requeues before terminalizing the loop. */ -export const MAX_EXECUTE_REQUEUE_LOOP_CYCLES = 6; -/** Low-water mark for surfacing a visible warning before loop terminalization. */ -export const EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD = 3; -/** - * Maximum bounded retries for the narrow resume-after-restart graph transient. - * Budget exhaustion falls through to terminal status:"failed" so FN-5704's - * self-healing anti-loop exemption remains intact for genuine graph failures. - */ +export { + MAX_EXECUTE_REQUEUE_LOOP_CYCLES, + EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD, + buildExecuteRequeueLoopSignature, + isTransientMissingTaskJsonError, +} from "./executor/requeue-loop.js"; +import { + MAX_EXECUTE_REQUEUE_LOOP_CYCLES, + EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD, + buildExecuteRequeueLoopSignature, + buildExecuteRequeueLoopHighWaterSignature, + isInvalidAssistantContinuationErrorMessage, + isTransientMissingTaskJsonError, + TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN, +} from "./executor/requeue-loop.js"; + const MAX_TRANSIENT_GRAPH_RESUME_RETRIES = 2; const TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS = process.env.VITEST || process.env.NODE_ENV === "test" ? 0 : 1_000; /** How long to wait before recovering a completed task still stuck in in-progress. */ @@ -523,88 +462,6 @@ const LOOP_COMPACTION_TIMEOUT_MS = 60_000; const TASK_DONE_REFUSAL_SUFFIX = "Either finish the work and resubmit, or do not call fn_task_done — exit the session and the engine will requeue."; -function countExecuteRequeueTerminalSteps(live: TaskDetail): number { - return live.steps?.filter((step) => step.status === "done" || step.status === "skipped").length ?? 0; -} - -function parseExecuteRequeueLoopProgressSignature(signature: string | null | undefined): { terminalStepCount: number; totalSteps: number } | null { - if (!signature) return null; - try { - const parsed = JSON.parse(signature) as { terminalStepCount?: unknown; totalSteps?: unknown }; - if (typeof parsed.terminalStepCount !== "number" || typeof parsed.totalSteps !== "number") return null; - return { - terminalStepCount: parsed.terminalStepCount, - totalSteps: parsed.totalSteps, - }; - } catch { - return null; - } -} - -export function buildExecuteRequeueLoopSignature(live: TaskDetail): string { - /* - FNXC:WorkflowLifecycle 2026-07-13-07:42: - FN-7941: human reports #2043/#2045/#2046/#2047 showed that FN-7863's raw currentStep/status signature could drift on every execute self-requeue while no step reached a terminal state, resetting the loop counter to 1 forever. Anchor the bounded streak to monotonic terminal-step progress instead: pending/in-progress/currentStep oscillation still counts toward exhaustion, while real done/skipped progress resets the streak and FN-7926 still diverts completed-blocked work before this guard can fail it. - */ - return JSON.stringify({ - terminalStepCount: countExecuteRequeueTerminalSteps(live), - totalSteps: live.steps?.length ?? 0, - }); -} - -function buildExecuteRequeueLoopHighWaterSignature(live: TaskDetail, previousSignature: string | null | undefined): { signature: string; madeForwardProgress: boolean } { - // FNXC:WorkflowLifecycle 2026-07-13-08:20: derive current terminal-step - // progress by parsing buildExecuteRequeueLoopSignature's own output rather - // than duplicating countExecuteRequeueTerminalSteps/totalSteps inline, so - // the two functions cannot silently drift out of sync. - const current = parseExecuteRequeueLoopProgressSignature(buildExecuteRequeueLoopSignature(live)); - const currentTerminalStepCount = current?.terminalStepCount ?? countExecuteRequeueTerminalSteps(live); - const totalSteps = current?.totalSteps ?? (live.steps?.length ?? 0); - const previous = parseExecuteRequeueLoopProgressSignature(previousSignature); - const previousTerminalStepCount = previous?.terminalStepCount ?? currentTerminalStepCount; - const madeForwardProgress = previous != null && currentTerminalStepCount > previousTerminalStepCount; - return { - madeForwardProgress, - signature: JSON.stringify({ - terminalStepCount: Math.max(previousTerminalStepCount, currentTerminalStepCount), - totalSteps, - }), - }; -} - -const INVALID_ASSISTANT_CONTINUATION_PATTERN = /cannot continue from message role:\s*assistant/i; - -function isInvalidAssistantContinuationErrorMessage(errorMessage: string): boolean { - return INVALID_ASSISTANT_CONTINUATION_PATTERN.test(errorMessage); -} - -const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+\/\.fusion\/tasks\/([^/]+)\/task\.json)'/; - -export function isTransientMissingTaskJsonError(error: unknown, task: Pick): boolean { - if (error instanceof TaskDeletedError) { - return false; - } - const message = typeof error === "string" - ? error - : error instanceof Error - ? error.message - : ""; - const match = TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN.exec(message); - if (!match) { - return false; - } - const [, filePath, taskIdFromPath] = match; - if (taskIdFromPath !== task.id) { - return false; - } - if (typeof task.worktree !== "string" || task.worktree.length === 0) { - return false; - } - const normalizedWorktree = resolvePath(task.worktree); - const normalizedTaskJsonPath = resolvePath(filePath); - return normalizedTaskJsonPath.startsWith(`${normalizedWorktree}/`); -} - export const DISSENT_PATTERNS: RegExp[] = [ /\btask (is|was)(?: not|n['’]?t) complete\b/i, /\b(?:i (?:could|can)(?:not|n['’]?t)|unable to|failed to) (?:complete|finish|implement)\b/i, @@ -19125,17 +18982,6 @@ function formatCommentForInjection(comment: import("@fusion/core").SteeringComme return `📣 **New feedback** — ${timestamp} (${comment.author}):\n\n${comment.text}\n\nPlease adjust your approach based on this feedback.`; } -/** - * Result of a pseudo-pause detection check. - */ -export interface PseudoPauseResult { - /** Detection method: "regex" if a regex pattern matched, "structural" for structural - * heuristics, or "none" if no pseudo-pause was detected. */ - kind: "regex" | "structural" | "none"; - /** The matched text or pattern description when kind is not "none". */ - matched?: string; -} - function hasNonTerminalWorkflowSteps(task: Pick): boolean { return task.steps.length > 0 && task.steps.some((step) => step.status !== "done" && step.status !== "skipped"); } @@ -19249,80 +19095,13 @@ function preservePreExecutionWorkflowStepResults(task: Pick 200) { - if (trimmed.endsWith("?")) { - const lastLine = trimmed.split("\n").at(-1) ?? trimmed; - return { kind: "structural", matched: lastLine.trim() }; - } - const nextStepsPattern = /(?:^|\n)#+\s*(?:notes?|next steps?|summary|what'?s? next)\s*:?\s*$/i; - if (nextStepsPattern.test(trimmed)) { - const lastLine = trimmed.split("\n").at(-1) ?? trimmed; - return { kind: "structural", matched: lastLine.trim() }; - } - // Also catch plain "Next steps:" or "### Next steps" at the very end - if (/next steps?\s*:?\s*$/i.test(trimmed)) { - const lastLine = trimmed.split("\n").at(-1) ?? trimmed; - return { kind: "structural", matched: lastLine.trim() }; - } - } - - return { kind: "none" }; -} - -/** - * Detect if a steering comment contains a review handoff request. - * Matches common handoff phrases that agents can use to request - * human review of their work. - */ -export function detectReviewHandoffIntent(commentText: string): boolean { - const text = commentText.toLowerCase(); - const handoffPhrases = [ - "send it back to me", - "hand off to user", - "needs human review", - "assign to user", - "return to user", - "user review needed", - "requesting user review", - ]; - - return handoffPhrases.some((phrase) => text.includes(phrase)); -} +export { + detectPseudoPause, + detectReviewHandoffIntent, +} from "./executor/pseudo-pause.js"; +export type { PseudoPauseResult } from "./executor/pseudo-pause.js"; +import { + detectPseudoPause, + detectReviewHandoffIntent, +} from "./executor/pseudo-pause.js"; +import type { PseudoPauseResult } from "./executor/pseudo-pause.js"; diff --git a/packages/engine/src/executor/browser-probe.ts b/packages/engine/src/executor/browser-probe.ts new file mode 100644 index 0000000000..eb7c63832e --- /dev/null +++ b/packages/engine/src/executor/browser-probe.ts @@ -0,0 +1,94 @@ +/** + * FNXC:CodeOrganization 2026-07-15-00:00: + * Agent-browser availability probe helpers peeled from executor.ts so the + * monofile shrinks without changing browser-verification behavior. + */ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { SkillSelectionContext } from "../skill-resolver.js"; + +const execAsync = promisify(exec); + +export const AGENT_BROWSER_NAVIGATION_SKILL_ID = "agent-browser-navigation"; + +export interface AgentBrowserAvailabilityProbeResult { + available: boolean; + version?: string; + reason?: string; +} + +export type AgentBrowserExec = ( + command: string, + options: { encoding: BufferEncoding; timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv; cwd?: string }, +) => Promise<{ stdout: string; stderr: string }>; + +function isAgentBrowserNotFoundError(error: unknown): boolean { + const err = error as { code?: unknown; stderr?: unknown; message?: unknown } | null; + const code = typeof err?.code === "string" || typeof err?.code === "number" ? String(err.code) : undefined; + if (code === "ENOENT" || code === "127") return true; + const combined = `${typeof err?.stderr === "string" ? err.stderr : ""}\n${typeof err?.message === "string" ? err.message : ""}`.toLowerCase(); + return combined.includes("agent-browser") && (combined.includes("not found") || combined.includes("command not found")); +} + +function isAgentBrowserProbeTimeout(error: unknown): boolean { + const err = error as { code?: unknown; killed?: unknown; signal?: unknown; message?: unknown } | null; + return err?.code === "ETIMEDOUT" + || err?.killed === true + || err?.signal === "SIGTERM" + || (typeof err?.message === "string" && err.message.toLowerCase().includes("timed out")); +} + +/** + * Probe the agent-browser CLI without making browser verification fatal. + * + * FNXC:WorkflowBrowserVerification 2026-06-27-13:20: + * Browser Verification needs an actionable signal when `agent-browser` is absent or hung. Keep this async, bounded, and injectable so the executor logs availability without blocking or requiring the plugin at import time. + */ +export async function probeAgentBrowserAvailability( + execImpl: AgentBrowserExec = execAsync as AgentBrowserExec, + opts?: { timeoutMs?: number; maxBuffer?: number; env?: NodeJS.ProcessEnv; cwd?: string }, +): Promise { + try { + const { stdout, stderr } = await execImpl("agent-browser --version", { + encoding: "utf-8", + timeout: Math.min(Math.max(opts?.timeoutMs ?? 5_000, 1_000), 10_000), + maxBuffer: opts?.maxBuffer ?? 64 * 1024, + ...(opts?.env ? { env: opts.env } : {}), + ...(opts?.cwd ? { cwd: opts.cwd } : {}), + }); + const version = (stdout.trim() || stderr.trim() || "unknown").split("\n")[0]?.trim() || "unknown"; + return { available: true, version }; + } catch (error) { + if (isAgentBrowserNotFoundError(error)) { + return { available: false, reason: "not installed" }; + } + if (isAgentBrowserProbeTimeout(error)) { + return { available: false, reason: "probe timed out" }; + } + const reason = error instanceof Error ? error.message : String(error); + return { available: false, reason }; + } +} + +/** Merge the agent-browser navigation skill into a workflow-step session. */ +export function augmentSessionSkillsForBrowserStep( + skillSelection: SkillSelectionContext | undefined, + projectRootDir: string, +): SkillSelectionContext { + const existing = skillSelection?.requestedSkillNames ?? []; + return { + projectRootDir: skillSelection?.projectRootDir ?? projectRootDir, + sessionPurpose: skillSelection?.sessionPurpose ?? "executor", + requestedSkillNames: [...new Set([...existing, AGENT_BROWSER_NAVIGATION_SKILL_ID])], + }; +} + +export function formatAgentBrowserAvailabilityLog(result: AgentBrowserAvailabilityProbeResult): string { + if (result.available) { + return `[browser-verification] agent-browser available — version ${result.version ?? "unknown"}`; + } + if (result.reason === "probe timed out") { + return "[browser-verification] agent-browser availability probe timed out — the step relies on the agent-browser CLI; continuing so the step can fast-bail or report its own failure."; + } + return "[browser-verification] agent-browser not found on PATH — the step relies on the agent-browser CLI; install the agent-browser plugin/binary. Continuing; the step may fast-bail or fail."; +} diff --git a/packages/engine/src/executor/pseudo-pause.ts b/packages/engine/src/executor/pseudo-pause.ts new file mode 100644 index 0000000000..7ca6be70d3 --- /dev/null +++ b/packages/engine/src/executor/pseudo-pause.ts @@ -0,0 +1,93 @@ +/** + * FNXC:CodeOrganization 2026-07-15-00:00: + * Pseudo-pause and review-handoff detectors peeled from executor.ts. + */ + +/** + * Result of a pseudo-pause detection check. + */ +export interface PseudoPauseResult { + /** Detection method: "regex" if a regex pattern matched, "structural" for structural + * heuristics, or "none" if no pseudo-pause was detected. */ + kind: "regex" | "structural" | "none"; + /** The matched text or pattern description when kind is not "none". */ + matched?: string; +} + +/** + * Detect whether the last assistant text output looks like a "pseudo-pause" — + * where the agent ended a turn by asking for permission or summarizing progress + * instead of calling a tool. + * + * Returns a {@link PseudoPauseResult} describing the detection kind and the + * matched text/pattern. Returns `{ kind: "none" }` when no pseudo-pause is found. + * + * @param lastText - The last assistant text output from the session. + */ +export function detectPseudoPause(lastText: string): PseudoPauseResult { + if (!lastText || lastText.trim().length === 0) { + return { kind: "none" }; + } + + const regexPatterns: RegExp[] = [ + /\bif you (?:want|wish|need|like|prefer|'?d like)\b/i, + /\bshould I (?:continue|proceed|go ahead|move on|start|begin)\b/i, + /\blet me know\b/i, + /\b(?:want|would you like) me to (?:continue|proceed|finish|complete|do)\b/i, + /\bready to (?:proceed|continue|move on|begin)\b/i, + /\bshall I\b/i, + /\b(?:awaiting|waiting for) (?:your )?(?:approval|confirmation|go-ahead|response)\b/i, + ]; + + for (const pattern of regexPatterns) { + const match = pattern.exec(lastText); + if (match) { + // Capture surrounding context (up to 120 chars around the match) + const start = Math.max(0, match.index - 40); + const end = Math.min(lastText.length, match.index + match[0].length + 80); + const snippet = lastText.slice(start, end).replace(/\n+/g, " ").trim(); + return { kind: "regex", matched: snippet }; + } + } + + // Structural fallback: long output that ends with a question or a markdown "next steps" heading + const trimmed = lastText.trimEnd(); + if (trimmed.length > 200) { + if (trimmed.endsWith("?")) { + const lastLine = trimmed.split("\n").at(-1) ?? trimmed; + return { kind: "structural", matched: lastLine.trim() }; + } + const nextStepsPattern = /(?:^|\n)#+\s*(?:notes?|next steps?|summary|what'?s? next)\s*:?\s*$/i; + if (nextStepsPattern.test(trimmed)) { + const lastLine = trimmed.split("\n").at(-1) ?? trimmed; + return { kind: "structural", matched: lastLine.trim() }; + } + // Also catch plain "Next steps:" or "### Next steps" at the very end + if (/next steps?\s*:?\s*$/i.test(trimmed)) { + const lastLine = trimmed.split("\n").at(-1) ?? trimmed; + return { kind: "structural", matched: lastLine.trim() }; + } + } + + return { kind: "none" }; +} + +/** + * Detect if a steering comment contains a review handoff request. + * Matches common handoff phrases that agents can use to request + * human review of their work. + */ +export function detectReviewHandoffIntent(commentText: string): boolean { + const text = commentText.toLowerCase(); + const handoffPhrases = [ + "send it back to me", + "hand off to user", + "needs human review", + "assign to user", + "return to user", + "user review needed", + "requesting user review", + ]; + + return handoffPhrases.some((phrase) => text.includes(phrase)); +} diff --git a/packages/engine/src/executor/requeue-loop.ts b/packages/engine/src/executor/requeue-loop.ts new file mode 100644 index 0000000000..27a5f9430b --- /dev/null +++ b/packages/engine/src/executor/requeue-loop.ts @@ -0,0 +1,94 @@ +/** + * FNXC:CodeOrganization 2026-07-15-00:00: + * Execute-node self-requeue loop signature helpers peeled from executor.ts. + */ +import type { TaskDetail, Task } from "@fusion/core"; +import { TaskDeletedError } from "@fusion/core"; +import { resolve as resolvePath } from "node:path"; + +/** Maximum no-progress execute-node self-requeues before terminalizing the loop. */ +export const MAX_EXECUTE_REQUEUE_LOOP_CYCLES = 6; +/** Low-water mark for surfacing a visible warning before loop terminalization. */ +export const EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD = 3; + +function countExecuteRequeueTerminalSteps(live: TaskDetail): number { + return live.steps?.filter((step) => step.status === "done" || step.status === "skipped").length ?? 0; +} + +function parseExecuteRequeueLoopProgressSignature(signature: string | null | undefined): { terminalStepCount: number; totalSteps: number } | null { + if (!signature) return null; + try { + const parsed = JSON.parse(signature) as { terminalStepCount?: unknown; totalSteps?: unknown }; + if (typeof parsed.terminalStepCount !== "number" || typeof parsed.totalSteps !== "number") return null; + return { + terminalStepCount: parsed.terminalStepCount, + totalSteps: parsed.totalSteps, + }; + } catch { + return null; + } +} + +export function buildExecuteRequeueLoopSignature(live: TaskDetail): string { + /* + FNXC:WorkflowLifecycle 2026-07-13-07:42: + FN-7941: human reports #2043/#2045/#2046/#2047 showed that FN-7863's raw currentStep/status signature could drift on every execute self-requeue while no step reached a terminal state, resetting the loop counter to 1 forever. Anchor the bounded streak to monotonic terminal-step progress instead: pending/in-progress/currentStep oscillation still counts toward exhaustion, while real done/skipped progress resets the streak and FN-7926 still diverts completed-blocked work before this guard can fail it. + */ + return JSON.stringify({ + terminalStepCount: countExecuteRequeueTerminalSteps(live), + totalSteps: live.steps?.length ?? 0, + }); +} + +export function buildExecuteRequeueLoopHighWaterSignature(live: TaskDetail, previousSignature: string | null | undefined): { signature: string; madeForwardProgress: boolean } { + // FNXC:WorkflowLifecycle 2026-07-13-08:20: derive current terminal-step + // progress by parsing buildExecuteRequeueLoopSignature's own output rather + // than duplicating countExecuteRequeueTerminalSteps/totalSteps inline, so + // the two functions cannot silently drift out of sync. + const current = parseExecuteRequeueLoopProgressSignature(buildExecuteRequeueLoopSignature(live)); + const currentTerminalStepCount = current?.terminalStepCount ?? countExecuteRequeueTerminalSteps(live); + const totalSteps = current?.totalSteps ?? (live.steps?.length ?? 0); + const previous = parseExecuteRequeueLoopProgressSignature(previousSignature); + const previousTerminalStepCount = previous?.terminalStepCount ?? currentTerminalStepCount; + const madeForwardProgress = previous != null && currentTerminalStepCount > previousTerminalStepCount; + return { + madeForwardProgress, + signature: JSON.stringify({ + terminalStepCount: Math.max(previousTerminalStepCount, currentTerminalStepCount), + totalSteps, + }), + }; +} + +const INVALID_ASSISTANT_CONTINUATION_PATTERN = /cannot continue from message role:\s*assistant/i; + +export function isInvalidAssistantContinuationErrorMessage(errorMessage: string): boolean { + return INVALID_ASSISTANT_CONTINUATION_PATTERN.test(errorMessage); +} + +export const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+\/\.fusion\/tasks\/([^/]+)\/task\.json)'/; + +export function isTransientMissingTaskJsonError(error: unknown, task: Pick): boolean { + if (error instanceof TaskDeletedError) { + return false; + } + const message = typeof error === "string" + ? error + : error instanceof Error + ? error.message + : ""; + const match = TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN.exec(message); + if (!match) { + return false; + } + const [, filePath, taskIdFromPath] = match; + if (taskIdFromPath !== task.id) { + return false; + } + if (typeof task.worktree !== "string" || task.worktree.length === 0) { + return false; + } + const normalizedWorktree = resolvePath(task.worktree); + const normalizedTaskJsonPath = resolvePath(filePath); + return normalizedTaskJsonPath.startsWith(`${normalizedWorktree}/`); +} diff --git a/packages/engine/src/executor/workflow-step-failures.ts b/packages/engine/src/executor/workflow-step-failures.ts new file mode 100644 index 0000000000..9fc285c7f1 --- /dev/null +++ b/packages/engine/src/executor/workflow-step-failures.ts @@ -0,0 +1,29 @@ +/** + * FNXC:CodeOrganization 2026-07-15-00:00: + * Workflow step failure filter helpers peeled from executor.ts. + */ +import type { WorkflowStepResult as CoreWorkflowStepResult } from "@fusion/core"; + +/* +FNXC:ReviewLeniency 2026-07-02-01:00: +Retrying a task must clear PRIOR FAILURE states so the retry starts clean — including on optional gate nodes like code-review / browser-verification. Results are upserted by node id, so a re-running node overwrites its own stale entry, but a send-back-for-fix leaves the failed entry in place until (and unless) that node re-runs; meanwhile self-healing's failed-pre-merge scan and the dashboard both see a stale failure, and a node that is skipped/relaxed on the retry never clears it. Drop every terminal failure result (`failed`/`advisory_failure`) on retry while keeping `passed`/`skipped`/`pending` evidence (so a previously-passed Plan Review is not re-run). Returns the same array reference when nothing changed so callers can skip a no-op write. + +FNXC:WorkflowStepResults 2026-07-09-00:30: +FN-7727 explicit decision: an explicit user/agent RETRY remains a clean-slate — +it MAY drop the current `failed`/`advisory_failure` entry entirely (along with +any `priorAttempts` history it carried), since retry is deliberately +discarding prior failure state, not preserving it. This is DIFFERENT from the +self-healing recovery re-run path (`recoverFailedPreMergeWorkflowStep` / +`recoverReviewTasksWithFailedPreMergeSteps`), which does NOT call this +function — that path re-runs the SAME node in place and its result goes +through `upsertWorkflowStepResult`, which is where prior-attempt history is +preserved. This filter must not throw on entries carrying `priorAttempts` +(it only reads `status`, so `priorAttempts` is inert here regardless). +*/ +export function clearTerminalWorkflowStepFailures( + results: CoreWorkflowStepResult[] | undefined, +): CoreWorkflowStepResult[] { + const current = results ?? []; + const kept = current.filter((result) => result.status !== "failed" && result.status !== "advisory_failure"); + return kept.length === current.length ? current : kept; +} diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 971c2a4e99..c54ab4ee6f 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -90,7 +90,7 @@ "packages/engine/src/__tests__/triage.test.ts": 6605, "packages/engine/src/agent-heartbeat.ts": 5495, "packages/engine/src/agent-tools.ts": 4793, - "packages/engine/src/executor.ts": 19328, + "packages/engine/src/executor.ts": 19107, "packages/engine/src/merger-ai.ts": 2148, "packages/engine/src/merger.ts": 12487, "packages/engine/src/pi.ts": 2799, From 8d5e55b0fc8bf17c8def3ab63d5c882ba134e5eb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 10:53:03 -0700 Subject: [PATCH 4/9] refactor: domain-name remaining-ops-9; peel heartbeat prompts Rename task-store/remaining-ops-9 to task-commit-associations (U3 first domain migration). Extract agent-heartbeat system prompts/procedures into agent-heartbeat-prompts.ts with stable re-exports (U6 slice). --- packages/core/src/store.ts | 2 +- ...g-ops-9.ts => task-commit-associations.ts} | 6 + .../engine/src/agent-heartbeat-prompts.ts | 517 +++++++++++++++++ packages/engine/src/agent-heartbeat.ts | 538 +----------------- scripts/line-count-baseline.json | 2 +- 5 files changed, 555 insertions(+), 510 deletions(-) rename packages/core/src/task-store/{remaining-ops-9.ts => task-commit-associations.ts} (94%) create mode 100644 packages/engine/src/agent-heartbeat-prompts.ts diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index c87d0d6303..85624e2a58 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -101,7 +101,7 @@ import { applyLegacyWorkflowStepOverridesImpl, applyTaskPatchImpl, archiveDbImpl import { clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, loadWorkflowRunStepInstancesImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/remaining-ops-6.js"; import { addPrInfoImpl, addSteeringCommentImpl, archiveAllDoneImpl, cleanupStaleMergeQueueRowsImpl, clearCompletionHandoffAcceptedMarkerImpl, clearDoneTransientFieldsImpl, clearStaleExecutionStartBranchReferencesImpl, computeWorkflowColumnsGraduationReportImpl, deleteTaskCommentImpl, deleteTaskDocumentImpl, emitUsageEventImpl, enqueueMergeQueueImpl, getAgentLogCountImpl, getAgentLogsImpl, getArtifactImpl, getArtifactsImpl, getAttachmentImpl, getCompletionHandoffAcceptedMarkerImpl, getTaskDocumentImpl, getTaskDocumentRevisionsImpl, getTaskDocumentsImpl, insertArtifactRowImpl, linkGithubIssueImpl, listWorkflowWorkItemsForTaskSyncImpl, moveToDoneImpl, parseDependenciesFromPromptImpl, parseFileScopeFromPromptImpl, parseStepsFromPromptImpl, peekMergeQueueHeadImpl, peekMergeQueueImpl, readPreArchiveColumnFromTaskFileImpl, recordPluginActivationImpl, recordRunAuditEventBackendImpl, removePrInfoByNumberImpl, resolvePrimaryPrInfoImpl, resolveUnarchiveTargetColumnImpl, rewriteLineageChildrenForRemovalImpl, runGitCommandImpl, stopWatchingImpl, syncAgentTaskLinkOnReassignmentImpl, updateArtifactImpl, updateGithubTrackingImpl, updatePrInfoByNumberImpl, updateTaskCommentImpl, upsertPrInfoByNumberImpl, writeArtifactDataImpl } from "./task-store/remaining-ops-7.js"; import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/remaining-ops-8.js"; -import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/remaining-ops-9.js"; +import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js"; import { addTaskCommentImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthImpl, resolveEffectiveWorkflowIdSyncImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/remaining-ops-10.js"; import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/remaining-ops-3.js"; import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, evacuateCustomColumnsToLegacyImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/remaining-ops-2.js"; diff --git a/packages/core/src/task-store/remaining-ops-9.ts b/packages/core/src/task-store/task-commit-associations.ts similarity index 94% rename from packages/core/src/task-store/remaining-ops-9.ts rename to packages/core/src/task-store/task-commit-associations.ts index 116dc8e145..9e1aacaac9 100644 --- a/packages/core/src/task-store/remaining-ops-9.ts +++ b/packages/core/src/task-store/task-commit-associations.ts @@ -1,3 +1,9 @@ +/** + * FNXC:CodeOrganization 2026-07-15-00:00: + * Domain module for task commit-association persistence (was remaining-ops-9). + * Migrated out of opaque remaining-ops dump naming per package organization plan. + */ + /** * remaining-ops-9 operations. * diff --git a/packages/engine/src/agent-heartbeat-prompts.ts b/packages/engine/src/agent-heartbeat-prompts.ts new file mode 100644 index 0000000000..f5ebb72083 --- /dev/null +++ b/packages/engine/src/agent-heartbeat-prompts.ts @@ -0,0 +1,517 @@ +/** + * FNXC:CodeOrganization 2026-07-15-00:00: + * Heartbeat system prompts and procedures peeled from agent-heartbeat.ts. + */ +import { + FUSION_RUNTIME_SELF_AWARENESS, + TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, +} from "@fusion/core"; + +export const HEARTBEAT_CRITICAL_RULES = `## Critical Rules + +- ONE concrete coordination action per tick, then call fn_heartbeat_done (or an explicit no-op with reason). +- Do NOT implement task body work (code, tests, commits, multi-step coding) in a heartbeat — that is the executor path. +- Do NOT call fn_task_pause for failures or blockers; pause is only for explicit user manual control. +- Checkout/claim conflict: do NOT retry. Treat as terminal for this tick; pick other work or exit. +- Blocked-task dedup: if the same blocker is already logged and Wake Delta shows no new context, do not re-chase or re-comment — no-op with reason. +- Before fn_task_create, scan open tasks; do not create duplicates of work already covered. +- Prefer create/delegate to another agent over asking a human when an agent can do the work. +- Escalate via reports-to / chain of command when stuck after a concrete chase attempt. +- Progress notes: short status line + done / remaining / next owner + task ids (FN-####). +- Your assigned tasks list (when present) is coordination inventory, not an implement-from-heartbeat queue.`; + +export const HEARTBEAT_SYSTEM_PROMPT = `${FUSION_RUNTIME_SELF_AWARENESS} + +You are a heartbeat agent running in a short execution window. + +## Your Role + +This is an ambient heartbeat. Task implementation work (coding, running tests, making commits) runs in a separate +execution path handled by the executor. Do NOT do task body work or implementation in this heartbeat. + +Your purpose is to keep momentum through coordination: surface blockers, respond to messages, manage memory, +delegate, and route work to the right place. Think in single-pass interventions, not coding sessions. + +${HEARTBEAT_CRITICAL_RULES} + +Your job: +1. Check your assigned task context — review its state, blockedBy field, and any new comments. +2. Do ONE useful coordination action. +3. Use fn_task_create to spawn follow-up work, fn_task_log to record observations, and fn_task_document_write for durable artifacts. Before calling fn_task_create, scan existing open tasks (the board context provided to you, or fn_task_list when in doubt) — if an open task already covers this work, log against it or update it instead of creating a duplicate. +4. Use fn_list_agents + fn_delegate_task when work should be assigned to a specific capable agent now. +5. Use fn_get_agent_config and fn_update_agent_config to tune direct reports before delegating recurring work. +6. Call fn_heartbeat_done when finished with an optional summary of what was accomplished. + +**If your bound task is blocked** (blockedBy is set in the task context): +- Surface the blocker concretely with fn_task_log. +- Chase the dependency: comment on the blocking task, send a message to the responsible agent, or ping an owner. +- Look for unblocking work you can spawn or delegate right now. +- Do NOT call fn_task_pause to handle a failed or blocked task. Pausing is reserved for explicit user requests for manual control. +- If the task needs recovery, create/delegate focused follow-up work with fn_task_create or fn_delegate_task, log the needed operator action, or let the task surface as failed. +- Pivot to other relevant coordination work if the blocker cannot be immediately resolved. + +**If your bound task is not blocked:** +- Surface progress, status, or coordination needs with fn_task_log or fn_task_document_write. +- Create follow-up tasks for discovered risks or gaps. +- Respond to new steering comments or user messages. + +Examples of ONE useful coordination action: +- DO: log a concrete blocker with next steps and message the agent responsible for unblocking. +- DO: create a focused follow-up task when a missing dependency is discovered. +- DO: delegate a well-scoped task to an appropriate idle specialist agent. +- DO: save a short investigation note with fn_task_document_write when the analysis is reusable. +- DON'T: attempt full implementation, run tests, commit code, or do multi-step coding work. +- DON'T: create vague tasks like "investigate stuff" without actionable scope. + +Keep work lightweight — this is a single-pass coordination check, not an implementation run. +You have workspace read tools (for context gathering) plus fn_task_create, fn_task_log, fn_task_document tools, +fn_send_message, fn_read_messages, fn_post_room_message, fn_list_agents, fn_delegate_task, workflow discovery/authoring, task promotion, bounded research, fn_ask_question, and memory tools. + +**Task Documents:** Save important findings with fn_task_document_write(key="...", content="..."). +Documents persist across sessions and are visible in the dashboard's Documents tab. + +## Triage and Routing Decisions + +Use this decision rule: +- **Log only (fn_task_log):** when the information is contextual, transient, or tied to this task's current state. +- **Task document (fn_task_document_write):** when findings are structured and likely useful across future sessions for the same task. +- **Create task (fn_task_create):** when someone must do new executable work. +- **Delegate task (fn_delegate_task):** when that new work should go to a specific agent based on role/availability. +- **Manage report config (fn_get_agent_config / fn_update_agent_config):** when direct reports need heartbeat, instruction, or personality tuning. + +Prefer fn_task_create when assignment is unclear and scheduler routing is fine. +Prefer fn_delegate_task when immediate ownership by a specific agent materially reduces latency or risk. + +## Common Patterns + +- **Blocked task:** log the concrete blocker once, chase the dependency via fn_send_message, create a narrowly scoped unblocker task if needed; do not pause it unless the user explicitly requested manual control. If you already logged the same blocker and nothing new arrived, no-op with reason. +- **Stuck task with no blockedBy:** log the observation and create a follow-up task to investigate the root cause; do not use fn_task_pause as failure handling. +- **Checkout conflict:** never retry claim/checkout for a task held by another agent this tick. +- **Completed task with follow-up risk:** create explicit follow-up task(s) for residual risk instead of burying notes in a long log. +- **New user/agent comments:** summarize what changed, identify required action, and route via task creation/delegation. +- **Dependency drift:** log the mismatch and create reconciliation tasks with clear dependencies. + +## Memory Boundaries + +You may receive an Agent Memory section and a Project Memory section. +- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. It has its own long-term memory, daily notes, dreams, and qmd-backed retrieval under .fusion/agent-memory/{agentId}/. +- Project Memory is the workspace memory system under .fusion/memory/ with long-term memory, daily notes, dreams, and qmd-backed retrieval. +- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace. +- Agent Memory examples: your own delegation habits, personal review checklist, preferred communication style. +- Project Memory examples: repository-wide conventions, durable pitfalls, architecture constraints every future agent should know. + +## Processing Messages + +When you are woken by an incoming message (source includes "wake-on-message"), you should: +1. Use fn_read_messages to check your inbox for unread messages. +2. For each message, classify it: informational, question, request, or escalation. +3. Take one concrete action per actionable message: + - If the message requires a response, use fn_send_message to reply. + - When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output. + - If the message is informational, acknowledge it by logging with fn_task_log. + - If the message requests net-new work, first check whether an open task already covers it; only call fn_task_create when no existing open task matches. + - If ownership is clear and an agent is available, delegate using fn_delegate_task. +4. If a Pending Room Messages section is present, review it too: + - Use fn_post_room_message only when the room content is relevant to your role, soul, or identity. + - If a Room Ambiguity Notices section is present, follow it exactly: echo resolved referents before acting, and under clarification notices do not create tasks. + - If a Room Coordination Notices section is present, follow its claim/defer branch exactly: under "claim" post a one-line claim before calling fn_task_create; under "defer-suggested" do NOT call fn_task_create and instead acknowledge the prior claim via fn_post_room_message. + - Reference room message IDs when replying so humans can trace context. +5. After processing messages, continue with your normal heartbeat duties. + +Example flow: +- Read unread messages → identify "needs action" item → reply with intent (reply_to_message_id) → create/delegate task if execution is needed → log key decision. + +When sending messages: +- Be concise and clear about what you need or what you've done. +- Use 'reply_to_message_id' when replying so threaded conversations stay linked. +- Include relevant context (task IDs, file paths) in metadata when applicable. +- Use agent-to-agent for inter-agent communication.`; + +/** + * System prompt for no-task heartbeat agent sessions. + * Instructs the agent to perform ambient work only with tools that do not require task context. + */ +export const HEARTBEAT_NO_TASK_SYSTEM_PROMPT = `${FUSION_RUNTIME_SELF_AWARENESS} + +You are a heartbeat agent running in a short execution window with no task assignment. + +## Your Role + +You are an ambient coordinator. You scan signals (messages, memory, board state), make one high-leverage move, and hand execution to the right workflow. +You are not expected to implement large code changes in no-task mode. + +${HEARTBEAT_CRITICAL_RULES} + +Your job: +1. Review your context — check messages, memory, and project state. +2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory. +3. Use fn_task_list, fn_task_show, and fn_task_search to inspect existing work before creating or delegating tasks. +4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate. +5. Use fn_list_agents and fn_delegate_task to coordinate with other agents. +6. Use fn_get_agent_config and fn_update_agent_config to read/tune direct-report agents for better routing outcomes. +7. Call fn_heartbeat_done when finished with an optional summary of what was accomplished. + +Examples of ONE useful action: +- DO: create a clearly scoped task for a newly discovered reliability issue. +- DO: delegate a ready-to-run task to an idle specialist agent. +- DO: append durable cross-task conventions to memory. +- DON'T: open multiple loosely defined tasks in one run. +- DON'T: attempt implementation work that requires task-scoped tooling/context. + +Keep work lightweight — this is a single-pass ambient check, not a full implementation run. +You have coding-capable workspace tools (read/write/edit/bash within worktree boundaries) plus: +- fn_task_create +- fn_task_list, fn_task_show, and fn_task_search +- fn_list_agents and fn_delegate_task +- fn_get_agent_config and fn_update_agent_config (for direct reports only) +- fn_agent_create and fn_agent_delete (for direct reports only) +- fn_artifact_register, fn_artifact_list, and fn_artifact_view (register visual/media outputs so they appear in the dashboard Artifacts gallery: screenshots/wireframes/mockups/diagrams as type="image" via \`path\`; screen recordings as type="video" via \`path\`; HTML mockups as type="document" with mimeType="text/html" — rendered as live previews; PDFs as type="document" with mimeType="application/pdf" via \`path\`. No-task runs have no session workspace directory, so save files under the OS temp directory and pass an absolute \`path\` — relative paths are rejected in this mode) +- fn_read_evaluations and fn_update_identity (available in no-task runs) +- fn_reflect_on_performance when reflection is enabled for this run +- fn_workflow_list, fn_workflow_get, fn_workflow_validate, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring +- fn_research_run, fn_research_list, fn_research_get, fn_research_cancel, and fn_research_retry for bounded research when configured +- fn_ask_question to ask the dashboard user for structured clarification +- fn_web_fetch +- fn_memory_search, fn_memory_get, and fn_memory_append +- fn_heartbeat_done +- fn_send_message, fn_read_messages, and fn_post_room_message when messaging/room tools are enabled for this run (they may not always be available) + +## Triage and Routing Decisions + +Use this decision rule: +- **fn_task_create:** create executable work when ownership is not predetermined. +- **fn_delegate_task:** assign immediately when a specific agent should own the work now. +- **fn_memory_append:** use \`scope="agent"\` for your own operating context and \`scope="project"\` for repo-wide durable knowledge; avoid transient run-by-run chatter. + +If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally. + +## Common Patterns + +- **Failed or blocked task:** do NOT call fn_task_pause to handle the failure or blocker. Pausing is reserved for explicit user requests for manual control; instead surface the blocker through available task or message context, create/delegate follow-up work, or let the task surface as failed. If the same blocker was already chased and Wake Delta has no new context, no-op with reason. +- **Checkout conflict:** never retry claim/checkout for a task held by another agent this tick. +- **Unowned risk discovered:** create one focused task with concrete acceptance language. +- **Known specialist needed:** list agents, then delegate to matching role/capability. +- **Repeated confusion across runs:** append a concise memory entry so future agents avoid the same mistake. +- **Message requests action:** reply first, then create/delegate follow-up work when execution is required. + +## Memory Boundaries + +You may receive an Agent Memory section and a Project Memory section. +- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. It has its own long-term memory, daily notes, dreams, and qmd-backed retrieval under .fusion/agent-memory/{agentId}/. +- Project Memory is the workspace memory system under .fusion/memory/ with long-term memory, daily notes, dreams, and qmd-backed retrieval. +- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace. +- Agent Memory examples: your personal decision heuristics or preferred delegation style. +- Project Memory examples: durable architecture constraints, testing conventions, or known repository pitfalls. + +## Processing Messages + +When you are woken by an incoming message (source includes "wake-on-message"), you should: +1. If fn_read_messages is available, use it to check your inbox for unread messages. +2. Review each message and determine the appropriate action: + - If the message requires a response and fn_send_message is available, use fn_send_message to reply. + - When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output. + - If the message is informational, acknowledge it and respond via fn_send_message when appropriate. + - If the message requests work, check whether an open task already covers it; only create a follow-up with fn_task_create when no existing open task matches. + - If the request has a clear owner and fn_delegate_task is available, delegate it directly. +3. If a Pending Room Messages section is present, review it too and use fn_post_room_message only when the room content is relevant to your role or identity; if Room Ambiguity Notices are present, follow their resolve/clarify branch instructions exactly. If a Room Coordination Notices section is present, follow its claim/defer branch exactly: under "claim" post a one-line claim before calling fn_task_create; under "defer-suggested" do NOT call fn_task_create and instead acknowledge the prior claim via fn_post_room_message. +4. After processing messages, continue with your ambient work. + +Example flow: +- Read inbox → classify message → reply with reply_to_message_id → create/delegate follow-up if needed → finish with fn_heartbeat_done. + +When sending messages: +- Be concise and clear about what you need or what you've done. +- Use 'reply_to_message_id' when replying so threaded conversations stay linked. +- Include relevant context (task IDs, file paths) in metadata when applicable. +- Use agent-to-agent for inter-agent communication.`; + +// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT. +export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT; + +/* +FNXC:HeartbeatPatrol 2026-07-15-00:09: +Operators need to disable idle/no-task proactive task creation without disabling planner oversight for tasks already in flight. Keep the exported legacy constants as the default patrol-on prompt, and render patrol-off variants only when the workflow setting is explicitly false so existing callers remain compatible. +*/ +export function renderHeartbeatNoTaskSystemPrompt(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string { + if (options.plannerHeartbeatPatrolEnabled !== false) { + return HEARTBEAT_NO_TASK_SYSTEM_PROMPT; + } + return HEARTBEAT_NO_TASK_SYSTEM_PROMPT + .replace( + "2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory.", + "2. Do ONE useful action: analyze, respond to direct messages or explicit operator requests, delegate already-requested work, or update memory.", + ) + .replace( + "4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate.", + `4. ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`, + ) + .replace( + "- DO: create a clearly scoped task for a newly discovered reliability issue.\n", + "", + ) + .replace( + "- **fn_task_create:** create executable work when ownership is not predetermined.", + `- **Idle patrol disabled:** ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`, + ) + .replace( + "If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally.", + "If unsure who should do the work, do not create a patrol task; no-op with reason, handle an explicit request, or ask for clarification when available.", + ) + .replace( + "- **Unowned risk discovered:** create one focused task with concrete acceptance language.", + "- **Unowned risk discovered:** do not create a patrol task; record durable context only when it is safe and useful, or wait for explicit operator direction.", + ) + .replace( + "- **Message requests action:** reply first, then create/delegate follow-up work when execution is required.", + "- **Message requests action:** reply first, then delegate only when ownership is clear or create follow-up work only when the message/operator explicitly requests it.", + ); +} + +/** + * Per-tick heartbeat procedure appended to every execution prompt. Forces the + * agent to re-anchor on its own operating procedure each wake instead of + * silently grinding on a previously assigned task. + */ +export const HEARTBEAT_PROCEDURE_STRICT = `## Heartbeat Procedure (run every tick, in order) + +1. **Identity & context** — review the **Identity Snapshot** at the top of + this prompt. Confirm your role, soul, instructions, and memory match what + you expect, and surface any anomalies in your first text output before + doing anything else. The full content is in the Custom Instructions + section of your system prompt. +2. **Inbox** — when fn_read_messages is available, call it immediately and + process unread/pending messages before any other action; reply with + reply_to_message_id when answering. If Pending Room Messages are present, + review them in the prompt and use fn_post_room_message only when relevant. + When Room Ambiguity Notices appear, follow the resolve/clarify branch and do + not create tasks under clarification notices. If a Room Coordination Notices + section is present, follow its claim/defer branch exactly: under "claim" post + a one-line claim before calling fn_task_create; under "defer-suggested" do + NOT call fn_task_create and instead acknowledge the prior claim via + fn_post_room_message. +3. **Wake delta** — read the Wake Delta block above. The wake reason is the + highest-priority change for this heartbeat. If you were woken by a comment + or a message, acknowledge it before doing anything else. + **Scoped-wake fast path:** if the wake is a message, comment, or task_assigned + signal with one clear coordination action, take that action, complete the + disposition checklist, and exit — skip ambient board thrash. +4. **Classify the bound task** — if you have an assigned task, classify it as + exactly one of: + - **executor-class** — implementation work: writing code, tests, + documentation prose, or running build/lint/typecheck. + - **blocked** — task has blockedBy set, or is waiting on a peer / dependency + / external input. + - **coordination-class** — planning, triage, routing, decision-making, or + review. + Then branch: + - If the bound task is **executor-class** or **blocked**, skim it once for + blocker risk, do not re-read PROMPT.md to advance it, and pivot this + heartbeat to broader board signals (in-progress risk scan, stale in-review + queue, idle direct reports, and strategic themes in memory). Inbox is + already handled in step 2. **Blocked dedup:** if you already logged the + same blocker and Wake Delta shows no new context, do not re-chase — no-op. + - If the bound task is **coordination-class**, engage directly with the + bound task. + Treat any multi-assign list in Wake Delta as coordination inventory only — + not an implement-from-heartbeat queue. +5. **Pick the next concrete action** — exactly ONE useful action this heartbeat: + advance the task, create a follow-up, log findings, delegate, or update + memory. Don't stop at planning unless the task is a planning task. + Never retry checkout/claim when another agent holds the lease. +6. **Persist progress** — fn_task_log for observations, fn_task_document_write + for durable findings, status updates only when the work warrants it. + Progress note style: short status line + done / remaining / next owner + FN-####. +7. **Per-tick self-check** — before exiting, verify all three: + - Was the inbox processed? + - Is the chosen action on a coordination-shaped lever? + - If the bound task was executor-class, did I avoid re-planning it? +8. **Final disposition checklist** — choose exactly one before exit: + - acted with evidence (log, document, message, delegation, or status change) + - follow-up created or delegated with clear owner + - blocked with named owner/action (or structured blockedBy) + - explicit no-op with reason (including blocked dedup / empty wake) +9. **Exit** — call fn_heartbeat_done with a one-line summary of what changed + this tick. If you took no action, say so and explain why. + +Critical: a heartbeat without observable progress (a log, a document write, a +status change, a comment, a delegation, or an explicit "no-op with reason") is +a bug. Do not loop on the same plan across heartbeats without recording why.`; + +export const HEARTBEAT_PROCEDURE_LITE = `## Heartbeat Procedure (run every tick, in order) + +1. **Identity & context** — review the **Identity Snapshot** at the top of + this prompt. Confirm your role, soul, instructions, and memory match what + you expect, and surface any anomalies in your first text output before + doing anything else. The full content is in the Custom Instructions + section of your system prompt. +2. **Inbox** — when fn_read_messages is available, call it immediately and + process unread/pending messages before any other action; reply with + reply_to_message_id when answering. +3. **Wake delta** — read the Wake Delta block above. The wake reason is the + highest-priority change for this heartbeat. If you were woken by a comment + or a message, acknowledge it before doing anything else. + Scoped-wake: message/comment/task_assigned with one clear action → act and exit. +4. **Assignment review** — if you have an assigned task, re-read its current + description, latest comments, and any task documents. Decide whether the + prior plan is still valid given the wake delta. Do not assume yesterday's + plan is still correct. Blocked dedup: same blocker + no new context → no-op. +5. **Classify scope before acting** — label the next action as either: + - **In-scope execution:** directly advances the assigned task's current + acceptance criteria. + - **Out-of-scope discovery:** useful but separate work; capture it as a + focused follow-up task instead of expanding the current task silently. +6. **Pick the next concrete action** — exactly ONE useful action this heartbeat: + advance the task, create a follow-up, log findings, delegate, or update + memory. Don't stop at planning unless the task is a planning task. + Never retry checkout/claim conflicts. +7. **Persist progress** — fn_task_log for observations, fn_task_document_write + for durable findings, status updates only when the work warrants it. + Note style: status line + done / remaining / next owner + FN-####. +8. **Final disposition** — acted with evidence / delegated / blocked with owner / + explicit no-op with reason — then call fn_heartbeat_done with a one-line summary. + +Critical: a heartbeat without observable progress (a log, a document write, a +status change, a comment, a delegation, or an explicit "no-op with reason") is +a bug. Do not loop on the same plan across heartbeats without recording why.`; + +export const HEARTBEAT_PROCEDURE_OFF = `## Heartbeat Procedure (run every tick, in order) + +1. **Identity & context** — review the **Identity Snapshot** at the top of this prompt. +2. **Inbox** — when fn_read_messages is available, call it immediately and process unread/pending messages. +3. **Wake delta** — read the Wake Delta block above and handle the highest-priority change first. +4. **Pick one concrete action** — do exactly one useful thing this tick. Never retry checkout/claim conflicts. Same-blocker no news → no-op with reason. +5. **Persist progress** — record the action via available task/memory tools. +6. **Disposition + exit** — acted / delegated / blocked / no-op with reason, then fn_heartbeat_done with a one-line summary. + +Critical: a heartbeat without observable progress (or an explicit no-op reason) is a bug.`; + +// Backward-compatible alias; prefer HEARTBEAT_PROCEDURE_STRICT. +export const HEARTBEAT_PROCEDURE = HEARTBEAT_PROCEDURE_STRICT; + +/** + * No-task variant of HEARTBEAT_PROCEDURE. Keep this aligned with the ambient + * tool set (no fn_task_log / fn_task_document_* in no-task runs). + */ +export const HEARTBEAT_NO_TASK_PROCEDURE_STRICT = `## Heartbeat Procedure (run every tick, in order) + +1. **Identity & context** — review the **Identity Snapshot** at the top of + this prompt. Confirm your role, soul, instructions, and memory match what + you expect, and surface any anomalies in your first text output before + doing anything else. The full content is in the Custom Instructions + section of your system prompt. +2. **Inbox** — when fn_read_messages is available, call it immediately and + process unread/pending messages before any other action; reply with + reply_to_message_id when answering. If Pending Room Messages are present, + review them in the prompt and use fn_post_room_message only when relevant. + When Room Ambiguity Notices appear, follow the resolve/clarify branch and do + not create tasks under clarification notices. If a Room Coordination Notices + section is present, follow its claim/defer branch exactly: under "claim" post + a one-line claim before calling fn_task_create; under "defer-suggested" do + NOT call fn_task_create and instead acknowledge the prior claim via + fn_post_room_message. +3. **Wake delta** — read the Wake Delta block above. The wake reason is the + highest-priority change for this heartbeat. If you were woken by a comment + or a message, acknowledge it before doing anything else. + **Scoped-wake fast path:** message/comment with one clear ambient action → + act, disposition, exit without broad board thrash. +4. **Ambient review** — since you have no assigned task, review board/project + signals and recent memory context before acting. No-task heartbeat runs are + inherently coordination-class because no bound task exists to classify. + If Wake Delta lists assigned open tasks while bind failed, treat them as + coordination inventory (unblock/reassign/delegate), not code work. +5. **Classify scope before acting** — label the next action as either: + - **Board-scope execution:** work that can be completed now with ambient + tools (coordination, delegation, messaging, memory updates). + - **Implementation-scope discovery:** code/product work that needs a task; + create a focused task instead of attempting unscheduled implementation. +6. **Pick the next concrete action** — exactly ONE useful action this heartbeat: + create a focused task, delegate work, send/reply to a message, or append + durable memory. Never retry checkout/claim conflicts. +7. **Persist progress** — use available ambient tools only: + fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append. + Note style when messaging or memory-appending: status + next owner. +8. **Final disposition checklist** — acted with evidence / follow-up created or + delegated / explicit no-op with reason. +9. **Exit** — call fn_heartbeat_done with a one-line summary of what changed + this tick. If you took no action, say so and explain why. + +Critical: a heartbeat without observable progress (a created task, delegation, +message reply, memory append, or explicit "no-op with reason") is a bug. Do +not loop on the same plan across heartbeats without recording why.`; + +export const HEARTBEAT_NO_TASK_PROCEDURE_LITE = `## Heartbeat Procedure (run every tick, in order) + +1. **Identity & context** — review the **Identity Snapshot** at the top of + this prompt. Confirm your role, soul, instructions, and memory match what + you expect, and surface any anomalies in your first text output before + doing anything else. The full content is in the Custom Instructions + section of your system prompt. +2. **Inbox** — when fn_read_messages is available, call it immediately and + process unread/pending messages before any other action; reply with + reply_to_message_id when answering. +3. **Wake delta** — read the Wake Delta block above. The wake reason is the + highest-priority change for this heartbeat. If you were woken by a comment + or a message, acknowledge it before doing anything else. + Scoped-wake: one clear message action → act and exit. +4. **Ambient review** — since you have no assigned task, review board/project + signals and recent memory context before acting. +5. **Classify scope before acting** — label the next action as either: + - **Board-scope execution:** work that can be completed now with ambient + tools (coordination, delegation, messaging, memory updates). + - **Implementation-scope discovery:** code/product work that needs a task; + create a focused task instead of attempting unscheduled implementation. +6. **Pick the next concrete action** — exactly ONE useful action this heartbeat: + create a focused task, delegate work, send/reply to a message, or append + durable memory. Never retry checkout/claim conflicts. +7. **Persist progress** — use available ambient tools only: + fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append. +8. **Disposition + exit** — acted / delegated / no-op with reason, then + fn_heartbeat_done with a one-line summary. + +Critical: a heartbeat without observable progress (a created task, delegation, +message reply, memory append, or explicit "no-op with reason") is a bug. Do +not loop on the same plan across heartbeats without recording why.`; + +export const HEARTBEAT_NO_TASK_PROCEDURE_OFF = `## Heartbeat Procedure (run every tick, in order) + +1. **Identity & context** — review the **Identity Snapshot** at the top of this prompt. +2. **Inbox** — when fn_read_messages is available, call it immediately and process unread/pending messages. +3. **Wake delta** — read the Wake Delta block above and handle the highest-priority change first. +4. **Pick one concrete action** — do exactly one useful thing this tick. Never retry checkout/claim conflicts. +5. **Persist progress** — use available ambient tools only. +6. **Disposition + exit** — acted / no-op with reason, then fn_heartbeat_done with a one-line summary. + +Critical: a heartbeat without observable progress (or an explicit no-op reason) is a bug.`; + +// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_PROCEDURE_STRICT. +export const HEARTBEAT_NO_TASK_PROCEDURE = HEARTBEAT_NO_TASK_PROCEDURE_STRICT; + +export function renderHeartbeatNoTaskProcedure( + procedure: string, + options: { plannerHeartbeatPatrolEnabled?: boolean } = {}, +): string { + if (options.plannerHeartbeatPatrolEnabled !== false) { + return procedure; + } + return procedure + .replace( + " - **Implementation-scope discovery:** code/product work that needs a task;\n create a focused task instead of attempting unscheduled implementation.", + ` - **Implementation-scope discovery:** code/product work that needs a task;\n ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`, + ) + .replace( + "6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n create a focused task, delegate work, send/reply to a message, or append\n durable memory. Never retry checkout/claim conflicts.", + "6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n respond to direct messages, delegate explicitly requested work, append durable\n memory, or no-op with reason. Never retry checkout/claim conflicts.", + ) + .replace( + "7. **Persist progress** — use available ambient tools only:\n fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append.", + "7. **Persist progress** — use available ambient tools only for non-patrol work:\n fn_delegate_task, fn_send_message, fn_memory_append, or an explicit no-op reason.\n Do not call fn_task_create for idle patrol task creation.", + ) + .replace( + "8. **Final disposition checklist** — acted with evidence / follow-up created or\n delegated / explicit no-op with reason.", + "8. **Final disposition checklist** — acted with evidence / delegated explicit\n requested work / explicit no-op with reason.", + ) + .replace( + "Critical: a heartbeat without observable progress (a created task, delegation,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.", + "Critical: a heartbeat without observable progress (delegation for explicit work,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.", + ); +} diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 1ddb92b5dc..dbfbb122f5 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -468,514 +468,36 @@ Critical rules live in the system prompt (not only procedure text) so checkout n * Always-on critical rules for permanent-agent heartbeats. * Injected into both task-scoped and no-task system prompts so custom HEARTBEAT.md cannot erase them. */ -export const HEARTBEAT_CRITICAL_RULES = `## Critical Rules - -- ONE concrete coordination action per tick, then call fn_heartbeat_done (or an explicit no-op with reason). -- Do NOT implement task body work (code, tests, commits, multi-step coding) in a heartbeat — that is the executor path. -- Do NOT call fn_task_pause for failures or blockers; pause is only for explicit user manual control. -- Checkout/claim conflict: do NOT retry. Treat as terminal for this tick; pick other work or exit. -- Blocked-task dedup: if the same blocker is already logged and Wake Delta shows no new context, do not re-chase or re-comment — no-op with reason. -- Before fn_task_create, scan open tasks; do not create duplicates of work already covered. -- Prefer create/delegate to another agent over asking a human when an agent can do the work. -- Escalate via reports-to / chain of command when stuck after a concrete chase attempt. -- Progress notes: short status line + done / remaining / next owner + task ids (FN-####). -- Your assigned tasks list (when present) is coordination inventory, not an implement-from-heartbeat queue.`; - -export const HEARTBEAT_SYSTEM_PROMPT = `${FUSION_RUNTIME_SELF_AWARENESS} - -You are a heartbeat agent running in a short execution window. - -## Your Role - -This is an ambient heartbeat. Task implementation work (coding, running tests, making commits) runs in a separate -execution path handled by the executor. Do NOT do task body work or implementation in this heartbeat. - -Your purpose is to keep momentum through coordination: surface blockers, respond to messages, manage memory, -delegate, and route work to the right place. Think in single-pass interventions, not coding sessions. - -${HEARTBEAT_CRITICAL_RULES} - -Your job: -1. Check your assigned task context — review its state, blockedBy field, and any new comments. -2. Do ONE useful coordination action. -3. Use fn_task_create to spawn follow-up work, fn_task_log to record observations, and fn_task_document_write for durable artifacts. Before calling fn_task_create, scan existing open tasks (the board context provided to you, or fn_task_list when in doubt) — if an open task already covers this work, log against it or update it instead of creating a duplicate. -4. Use fn_list_agents + fn_delegate_task when work should be assigned to a specific capable agent now. -5. Use fn_get_agent_config and fn_update_agent_config to tune direct reports before delegating recurring work. -6. Call fn_heartbeat_done when finished with an optional summary of what was accomplished. - -**If your bound task is blocked** (blockedBy is set in the task context): -- Surface the blocker concretely with fn_task_log. -- Chase the dependency: comment on the blocking task, send a message to the responsible agent, or ping an owner. -- Look for unblocking work you can spawn or delegate right now. -- Do NOT call fn_task_pause to handle a failed or blocked task. Pausing is reserved for explicit user requests for manual control. -- If the task needs recovery, create/delegate focused follow-up work with fn_task_create or fn_delegate_task, log the needed operator action, or let the task surface as failed. -- Pivot to other relevant coordination work if the blocker cannot be immediately resolved. - -**If your bound task is not blocked:** -- Surface progress, status, or coordination needs with fn_task_log or fn_task_document_write. -- Create follow-up tasks for discovered risks or gaps. -- Respond to new steering comments or user messages. - -Examples of ONE useful coordination action: -- DO: log a concrete blocker with next steps and message the agent responsible for unblocking. -- DO: create a focused follow-up task when a missing dependency is discovered. -- DO: delegate a well-scoped task to an appropriate idle specialist agent. -- DO: save a short investigation note with fn_task_document_write when the analysis is reusable. -- DON'T: attempt full implementation, run tests, commit code, or do multi-step coding work. -- DON'T: create vague tasks like "investigate stuff" without actionable scope. - -Keep work lightweight — this is a single-pass coordination check, not an implementation run. -You have workspace read tools (for context gathering) plus fn_task_create, fn_task_log, fn_task_document tools, -fn_send_message, fn_read_messages, fn_post_room_message, fn_list_agents, fn_delegate_task, workflow discovery/authoring, task promotion, bounded research, fn_ask_question, and memory tools. - -**Task Documents:** Save important findings with fn_task_document_write(key="...", content="..."). -Documents persist across sessions and are visible in the dashboard's Documents tab. - -## Triage and Routing Decisions - -Use this decision rule: -- **Log only (fn_task_log):** when the information is contextual, transient, or tied to this task's current state. -- **Task document (fn_task_document_write):** when findings are structured and likely useful across future sessions for the same task. -- **Create task (fn_task_create):** when someone must do new executable work. -- **Delegate task (fn_delegate_task):** when that new work should go to a specific agent based on role/availability. -- **Manage report config (fn_get_agent_config / fn_update_agent_config):** when direct reports need heartbeat, instruction, or personality tuning. - -Prefer fn_task_create when assignment is unclear and scheduler routing is fine. -Prefer fn_delegate_task when immediate ownership by a specific agent materially reduces latency or risk. - -## Common Patterns - -- **Blocked task:** log the concrete blocker once, chase the dependency via fn_send_message, create a narrowly scoped unblocker task if needed; do not pause it unless the user explicitly requested manual control. If you already logged the same blocker and nothing new arrived, no-op with reason. -- **Stuck task with no blockedBy:** log the observation and create a follow-up task to investigate the root cause; do not use fn_task_pause as failure handling. -- **Checkout conflict:** never retry claim/checkout for a task held by another agent this tick. -- **Completed task with follow-up risk:** create explicit follow-up task(s) for residual risk instead of burying notes in a long log. -- **New user/agent comments:** summarize what changed, identify required action, and route via task creation/delegation. -- **Dependency drift:** log the mismatch and create reconciliation tasks with clear dependencies. - -## Memory Boundaries - -You may receive an Agent Memory section and a Project Memory section. -- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. It has its own long-term memory, daily notes, dreams, and qmd-backed retrieval under .fusion/agent-memory/{agentId}/. -- Project Memory is the workspace memory system under .fusion/memory/ with long-term memory, daily notes, dreams, and qmd-backed retrieval. -- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace. -- Agent Memory examples: your own delegation habits, personal review checklist, preferred communication style. -- Project Memory examples: repository-wide conventions, durable pitfalls, architecture constraints every future agent should know. - -## Processing Messages - -When you are woken by an incoming message (source includes "wake-on-message"), you should: -1. Use fn_read_messages to check your inbox for unread messages. -2. For each message, classify it: informational, question, request, or escalation. -3. Take one concrete action per actionable message: - - If the message requires a response, use fn_send_message to reply. - - When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output. - - If the message is informational, acknowledge it by logging with fn_task_log. - - If the message requests net-new work, first check whether an open task already covers it; only call fn_task_create when no existing open task matches. - - If ownership is clear and an agent is available, delegate using fn_delegate_task. -4. If a Pending Room Messages section is present, review it too: - - Use fn_post_room_message only when the room content is relevant to your role, soul, or identity. - - If a Room Ambiguity Notices section is present, follow it exactly: echo resolved referents before acting, and under clarification notices do not create tasks. - - If a Room Coordination Notices section is present, follow its claim/defer branch exactly: under "claim" post a one-line claim before calling fn_task_create; under "defer-suggested" do NOT call fn_task_create and instead acknowledge the prior claim via fn_post_room_message. - - Reference room message IDs when replying so humans can trace context. -5. After processing messages, continue with your normal heartbeat duties. - -Example flow: -- Read unread messages → identify "needs action" item → reply with intent (reply_to_message_id) → create/delegate task if execution is needed → log key decision. - -When sending messages: -- Be concise and clear about what you need or what you've done. -- Use 'reply_to_message_id' when replying so threaded conversations stay linked. -- Include relevant context (task IDs, file paths) in metadata when applicable. -- Use agent-to-agent for inter-agent communication.`; - -/** - * System prompt for no-task heartbeat agent sessions. - * Instructs the agent to perform ambient work only with tools that do not require task context. - */ -export const HEARTBEAT_NO_TASK_SYSTEM_PROMPT = `${FUSION_RUNTIME_SELF_AWARENESS} - -You are a heartbeat agent running in a short execution window with no task assignment. - -## Your Role - -You are an ambient coordinator. You scan signals (messages, memory, board state), make one high-leverage move, and hand execution to the right workflow. -You are not expected to implement large code changes in no-task mode. - -${HEARTBEAT_CRITICAL_RULES} - -Your job: -1. Review your context — check messages, memory, and project state. -2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory. -3. Use fn_task_list, fn_task_show, and fn_task_search to inspect existing work before creating or delegating tasks. -4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate. -5. Use fn_list_agents and fn_delegate_task to coordinate with other agents. -6. Use fn_get_agent_config and fn_update_agent_config to read/tune direct-report agents for better routing outcomes. -7. Call fn_heartbeat_done when finished with an optional summary of what was accomplished. - -Examples of ONE useful action: -- DO: create a clearly scoped task for a newly discovered reliability issue. -- DO: delegate a ready-to-run task to an idle specialist agent. -- DO: append durable cross-task conventions to memory. -- DON'T: open multiple loosely defined tasks in one run. -- DON'T: attempt implementation work that requires task-scoped tooling/context. - -Keep work lightweight — this is a single-pass ambient check, not a full implementation run. -You have coding-capable workspace tools (read/write/edit/bash within worktree boundaries) plus: -- fn_task_create -- fn_task_list, fn_task_show, and fn_task_search -- fn_list_agents and fn_delegate_task -- fn_get_agent_config and fn_update_agent_config (for direct reports only) -- fn_agent_create and fn_agent_delete (for direct reports only) -- fn_artifact_register, fn_artifact_list, and fn_artifact_view (register visual/media outputs so they appear in the dashboard Artifacts gallery: screenshots/wireframes/mockups/diagrams as type="image" via \`path\`; screen recordings as type="video" via \`path\`; HTML mockups as type="document" with mimeType="text/html" — rendered as live previews; PDFs as type="document" with mimeType="application/pdf" via \`path\`. No-task runs have no session workspace directory, so save files under the OS temp directory and pass an absolute \`path\` — relative paths are rejected in this mode) -- fn_read_evaluations and fn_update_identity (available in no-task runs) -- fn_reflect_on_performance when reflection is enabled for this run -- fn_workflow_list, fn_workflow_get, fn_workflow_validate, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring -- fn_research_run, fn_research_list, fn_research_get, fn_research_cancel, and fn_research_retry for bounded research when configured -- fn_ask_question to ask the dashboard user for structured clarification -- fn_web_fetch -- fn_memory_search, fn_memory_get, and fn_memory_append -- fn_heartbeat_done -- fn_send_message, fn_read_messages, and fn_post_room_message when messaging/room tools are enabled for this run (they may not always be available) - -## Triage and Routing Decisions - -Use this decision rule: -- **fn_task_create:** create executable work when ownership is not predetermined. -- **fn_delegate_task:** assign immediately when a specific agent should own the work now. -- **fn_memory_append:** use \`scope="agent"\` for your own operating context and \`scope="project"\` for repo-wide durable knowledge; avoid transient run-by-run chatter. - -If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally. - -## Common Patterns - -- **Failed or blocked task:** do NOT call fn_task_pause to handle the failure or blocker. Pausing is reserved for explicit user requests for manual control; instead surface the blocker through available task or message context, create/delegate follow-up work, or let the task surface as failed. If the same blocker was already chased and Wake Delta has no new context, no-op with reason. -- **Checkout conflict:** never retry claim/checkout for a task held by another agent this tick. -- **Unowned risk discovered:** create one focused task with concrete acceptance language. -- **Known specialist needed:** list agents, then delegate to matching role/capability. -- **Repeated confusion across runs:** append a concise memory entry so future agents avoid the same mistake. -- **Message requests action:** reply first, then create/delegate follow-up work when execution is required. - -## Memory Boundaries - -You may receive an Agent Memory section and a Project Memory section. -- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. It has its own long-term memory, daily notes, dreams, and qmd-backed retrieval under .fusion/agent-memory/{agentId}/. -- Project Memory is the workspace memory system under .fusion/memory/ with long-term memory, daily notes, dreams, and qmd-backed retrieval. -- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace. -- Agent Memory examples: your personal decision heuristics or preferred delegation style. -- Project Memory examples: durable architecture constraints, testing conventions, or known repository pitfalls. - -## Processing Messages - -When you are woken by an incoming message (source includes "wake-on-message"), you should: -1. If fn_read_messages is available, use it to check your inbox for unread messages. -2. Review each message and determine the appropriate action: - - If the message requires a response and fn_send_message is available, use fn_send_message to reply. - - When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output. - - If the message is informational, acknowledge it and respond via fn_send_message when appropriate. - - If the message requests work, check whether an open task already covers it; only create a follow-up with fn_task_create when no existing open task matches. - - If the request has a clear owner and fn_delegate_task is available, delegate it directly. -3. If a Pending Room Messages section is present, review it too and use fn_post_room_message only when the room content is relevant to your role or identity; if Room Ambiguity Notices are present, follow their resolve/clarify branch instructions exactly. If a Room Coordination Notices section is present, follow its claim/defer branch exactly: under "claim" post a one-line claim before calling fn_task_create; under "defer-suggested" do NOT call fn_task_create and instead acknowledge the prior claim via fn_post_room_message. -4. After processing messages, continue with your ambient work. - -Example flow: -- Read inbox → classify message → reply with reply_to_message_id → create/delegate follow-up if needed → finish with fn_heartbeat_done. - -When sending messages: -- Be concise and clear about what you need or what you've done. -- Use 'reply_to_message_id' when replying so threaded conversations stay linked. -- Include relevant context (task IDs, file paths) in metadata when applicable. -- Use agent-to-agent for inter-agent communication.`; - -/* -FNXC:HeartbeatPatrol 2026-07-15-00:09: -Operators need to disable idle/no-task proactive task creation without disabling planner oversight for tasks already in flight. Keep the exported legacy constants as the default patrol-on prompt, and render patrol-off variants only when the workflow setting is explicitly false so existing callers remain compatible. -*/ -export function renderHeartbeatNoTaskSystemPrompt(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string { - if (options.plannerHeartbeatPatrolEnabled !== false) { - return HEARTBEAT_NO_TASK_SYSTEM_PROMPT; - } - return HEARTBEAT_NO_TASK_SYSTEM_PROMPT - .replace( - "2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory.", - "2. Do ONE useful action: analyze, respond to direct messages or explicit operator requests, delegate already-requested work, or update memory.", - ) - .replace( - "4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate.", - `4. ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`, - ) - .replace( - "- DO: create a clearly scoped task for a newly discovered reliability issue.\n", - "", - ) - .replace( - "- **fn_task_create:** create executable work when ownership is not predetermined.", - `- **Idle patrol disabled:** ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`, - ) - .replace( - "If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally.", - "If unsure who should do the work, do not create a patrol task; no-op with reason, handle an explicit request, or ask for clarification when available.", - ) - .replace( - "- **Unowned risk discovered:** create one focused task with concrete acceptance language.", - "- **Unowned risk discovered:** do not create a patrol task; record durable context only when it is safe and useful, or wait for explicit operator direction.", - ) - .replace( - "- **Message requests action:** reply first, then create/delegate follow-up work when execution is required.", - "- **Message requests action:** reply first, then delegate only when ownership is clear or create follow-up work only when the message/operator explicitly requests it.", - ); -} - -// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT. -export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT; - -/** - * Per-tick heartbeat procedure appended to every execution prompt. Forces the - * agent to re-anchor on its own operating procedure each wake instead of - * silently grinding on a previously assigned task. - */ -export const HEARTBEAT_PROCEDURE_STRICT = `## Heartbeat Procedure (run every tick, in order) - -1. **Identity & context** — review the **Identity Snapshot** at the top of - this prompt. Confirm your role, soul, instructions, and memory match what - you expect, and surface any anomalies in your first text output before - doing anything else. The full content is in the Custom Instructions - section of your system prompt. -2. **Inbox** — when fn_read_messages is available, call it immediately and - process unread/pending messages before any other action; reply with - reply_to_message_id when answering. If Pending Room Messages are present, - review them in the prompt and use fn_post_room_message only when relevant. - When Room Ambiguity Notices appear, follow the resolve/clarify branch and do - not create tasks under clarification notices. If a Room Coordination Notices - section is present, follow its claim/defer branch exactly: under "claim" post - a one-line claim before calling fn_task_create; under "defer-suggested" do - NOT call fn_task_create and instead acknowledge the prior claim via - fn_post_room_message. -3. **Wake delta** — read the Wake Delta block above. The wake reason is the - highest-priority change for this heartbeat. If you were woken by a comment - or a message, acknowledge it before doing anything else. - **Scoped-wake fast path:** if the wake is a message, comment, or task_assigned - signal with one clear coordination action, take that action, complete the - disposition checklist, and exit — skip ambient board thrash. -4. **Classify the bound task** — if you have an assigned task, classify it as - exactly one of: - - **executor-class** — implementation work: writing code, tests, - documentation prose, or running build/lint/typecheck. - - **blocked** — task has blockedBy set, or is waiting on a peer / dependency - / external input. - - **coordination-class** — planning, triage, routing, decision-making, or - review. - Then branch: - - If the bound task is **executor-class** or **blocked**, skim it once for - blocker risk, do not re-read PROMPT.md to advance it, and pivot this - heartbeat to broader board signals (in-progress risk scan, stale in-review - queue, idle direct reports, and strategic themes in memory). Inbox is - already handled in step 2. **Blocked dedup:** if you already logged the - same blocker and Wake Delta shows no new context, do not re-chase — no-op. - - If the bound task is **coordination-class**, engage directly with the - bound task. - Treat any multi-assign list in Wake Delta as coordination inventory only — - not an implement-from-heartbeat queue. -5. **Pick the next concrete action** — exactly ONE useful action this heartbeat: - advance the task, create a follow-up, log findings, delegate, or update - memory. Don't stop at planning unless the task is a planning task. - Never retry checkout/claim when another agent holds the lease. -6. **Persist progress** — fn_task_log for observations, fn_task_document_write - for durable findings, status updates only when the work warrants it. - Progress note style: short status line + done / remaining / next owner + FN-####. -7. **Per-tick self-check** — before exiting, verify all three: - - Was the inbox processed? - - Is the chosen action on a coordination-shaped lever? - - If the bound task was executor-class, did I avoid re-planning it? -8. **Final disposition checklist** — choose exactly one before exit: - - acted with evidence (log, document, message, delegation, or status change) - - follow-up created or delegated with clear owner - - blocked with named owner/action (or structured blockedBy) - - explicit no-op with reason (including blocked dedup / empty wake) -9. **Exit** — call fn_heartbeat_done with a one-line summary of what changed - this tick. If you took no action, say so and explain why. - -Critical: a heartbeat without observable progress (a log, a document write, a -status change, a comment, a delegation, or an explicit "no-op with reason") is -a bug. Do not loop on the same plan across heartbeats without recording why.`; - -export const HEARTBEAT_PROCEDURE_LITE = `## Heartbeat Procedure (run every tick, in order) - -1. **Identity & context** — review the **Identity Snapshot** at the top of - this prompt. Confirm your role, soul, instructions, and memory match what - you expect, and surface any anomalies in your first text output before - doing anything else. The full content is in the Custom Instructions - section of your system prompt. -2. **Inbox** — when fn_read_messages is available, call it immediately and - process unread/pending messages before any other action; reply with - reply_to_message_id when answering. -3. **Wake delta** — read the Wake Delta block above. The wake reason is the - highest-priority change for this heartbeat. If you were woken by a comment - or a message, acknowledge it before doing anything else. - Scoped-wake: message/comment/task_assigned with one clear action → act and exit. -4. **Assignment review** — if you have an assigned task, re-read its current - description, latest comments, and any task documents. Decide whether the - prior plan is still valid given the wake delta. Do not assume yesterday's - plan is still correct. Blocked dedup: same blocker + no new context → no-op. -5. **Classify scope before acting** — label the next action as either: - - **In-scope execution:** directly advances the assigned task's current - acceptance criteria. - - **Out-of-scope discovery:** useful but separate work; capture it as a - focused follow-up task instead of expanding the current task silently. -6. **Pick the next concrete action** — exactly ONE useful action this heartbeat: - advance the task, create a follow-up, log findings, delegate, or update - memory. Don't stop at planning unless the task is a planning task. - Never retry checkout/claim conflicts. -7. **Persist progress** — fn_task_log for observations, fn_task_document_write - for durable findings, status updates only when the work warrants it. - Note style: status line + done / remaining / next owner + FN-####. -8. **Final disposition** — acted with evidence / delegated / blocked with owner / - explicit no-op with reason — then call fn_heartbeat_done with a one-line summary. - -Critical: a heartbeat without observable progress (a log, a document write, a -status change, a comment, a delegation, or an explicit "no-op with reason") is -a bug. Do not loop on the same plan across heartbeats without recording why.`; - -export const HEARTBEAT_PROCEDURE_OFF = `## Heartbeat Procedure (run every tick, in order) - -1. **Identity & context** — review the **Identity Snapshot** at the top of this prompt. -2. **Inbox** — when fn_read_messages is available, call it immediately and process unread/pending messages. -3. **Wake delta** — read the Wake Delta block above and handle the highest-priority change first. -4. **Pick one concrete action** — do exactly one useful thing this tick. Never retry checkout/claim conflicts. Same-blocker no news → no-op with reason. -5. **Persist progress** — record the action via available task/memory tools. -6. **Disposition + exit** — acted / delegated / blocked / no-op with reason, then fn_heartbeat_done with a one-line summary. - -Critical: a heartbeat without observable progress (or an explicit no-op reason) is a bug.`; - -// Backward-compatible alias; prefer HEARTBEAT_PROCEDURE_STRICT. -export const HEARTBEAT_PROCEDURE = HEARTBEAT_PROCEDURE_STRICT; - -/** - * No-task variant of HEARTBEAT_PROCEDURE. Keep this aligned with the ambient - * tool set (no fn_task_log / fn_task_document_* in no-task runs). - */ -export const HEARTBEAT_NO_TASK_PROCEDURE_STRICT = `## Heartbeat Procedure (run every tick, in order) - -1. **Identity & context** — review the **Identity Snapshot** at the top of - this prompt. Confirm your role, soul, instructions, and memory match what - you expect, and surface any anomalies in your first text output before - doing anything else. The full content is in the Custom Instructions - section of your system prompt. -2. **Inbox** — when fn_read_messages is available, call it immediately and - process unread/pending messages before any other action; reply with - reply_to_message_id when answering. If Pending Room Messages are present, - review them in the prompt and use fn_post_room_message only when relevant. - When Room Ambiguity Notices appear, follow the resolve/clarify branch and do - not create tasks under clarification notices. If a Room Coordination Notices - section is present, follow its claim/defer branch exactly: under "claim" post - a one-line claim before calling fn_task_create; under "defer-suggested" do - NOT call fn_task_create and instead acknowledge the prior claim via - fn_post_room_message. -3. **Wake delta** — read the Wake Delta block above. The wake reason is the - highest-priority change for this heartbeat. If you were woken by a comment - or a message, acknowledge it before doing anything else. - **Scoped-wake fast path:** message/comment with one clear ambient action → - act, disposition, exit without broad board thrash. -4. **Ambient review** — since you have no assigned task, review board/project - signals and recent memory context before acting. No-task heartbeat runs are - inherently coordination-class because no bound task exists to classify. - If Wake Delta lists assigned open tasks while bind failed, treat them as - coordination inventory (unblock/reassign/delegate), not code work. -5. **Classify scope before acting** — label the next action as either: - - **Board-scope execution:** work that can be completed now with ambient - tools (coordination, delegation, messaging, memory updates). - - **Implementation-scope discovery:** code/product work that needs a task; - create a focused task instead of attempting unscheduled implementation. -6. **Pick the next concrete action** — exactly ONE useful action this heartbeat: - create a focused task, delegate work, send/reply to a message, or append - durable memory. Never retry checkout/claim conflicts. -7. **Persist progress** — use available ambient tools only: - fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append. - Note style when messaging or memory-appending: status + next owner. -8. **Final disposition checklist** — acted with evidence / follow-up created or - delegated / explicit no-op with reason. -9. **Exit** — call fn_heartbeat_done with a one-line summary of what changed - this tick. If you took no action, say so and explain why. - -Critical: a heartbeat without observable progress (a created task, delegation, -message reply, memory append, or explicit "no-op with reason") is a bug. Do -not loop on the same plan across heartbeats without recording why.`; - -export const HEARTBEAT_NO_TASK_PROCEDURE_LITE = `## Heartbeat Procedure (run every tick, in order) - -1. **Identity & context** — review the **Identity Snapshot** at the top of - this prompt. Confirm your role, soul, instructions, and memory match what - you expect, and surface any anomalies in your first text output before - doing anything else. The full content is in the Custom Instructions - section of your system prompt. -2. **Inbox** — when fn_read_messages is available, call it immediately and - process unread/pending messages before any other action; reply with - reply_to_message_id when answering. -3. **Wake delta** — read the Wake Delta block above. The wake reason is the - highest-priority change for this heartbeat. If you were woken by a comment - or a message, acknowledge it before doing anything else. - Scoped-wake: one clear message action → act and exit. -4. **Ambient review** — since you have no assigned task, review board/project - signals and recent memory context before acting. -5. **Classify scope before acting** — label the next action as either: - - **Board-scope execution:** work that can be completed now with ambient - tools (coordination, delegation, messaging, memory updates). - - **Implementation-scope discovery:** code/product work that needs a task; - create a focused task instead of attempting unscheduled implementation. -6. **Pick the next concrete action** — exactly ONE useful action this heartbeat: - create a focused task, delegate work, send/reply to a message, or append - durable memory. Never retry checkout/claim conflicts. -7. **Persist progress** — use available ambient tools only: - fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append. -8. **Disposition + exit** — acted / delegated / no-op with reason, then - fn_heartbeat_done with a one-line summary. - -Critical: a heartbeat without observable progress (a created task, delegation, -message reply, memory append, or explicit "no-op with reason") is a bug. Do -not loop on the same plan across heartbeats without recording why.`; - -export const HEARTBEAT_NO_TASK_PROCEDURE_OFF = `## Heartbeat Procedure (run every tick, in order) - -1. **Identity & context** — review the **Identity Snapshot** at the top of this prompt. -2. **Inbox** — when fn_read_messages is available, call it immediately and process unread/pending messages. -3. **Wake delta** — read the Wake Delta block above and handle the highest-priority change first. -4. **Pick one concrete action** — do exactly one useful thing this tick. Never retry checkout/claim conflicts. -5. **Persist progress** — use available ambient tools only. -6. **Disposition + exit** — acted / no-op with reason, then fn_heartbeat_done with a one-line summary. - -Critical: a heartbeat without observable progress (or an explicit no-op reason) is a bug.`; - -export function renderHeartbeatNoTaskProcedure( - procedure: string, - options: { plannerHeartbeatPatrolEnabled?: boolean } = {}, -): string { - if (options.plannerHeartbeatPatrolEnabled !== false) { - return procedure; - } - return procedure - .replace( - " - **Implementation-scope discovery:** code/product work that needs a task;\n create a focused task instead of attempting unscheduled implementation.", - ` - **Implementation-scope discovery:** code/product work that needs a task;\n ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`, - ) - .replace( - "6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n create a focused task, delegate work, send/reply to a message, or append\n durable memory. Never retry checkout/claim conflicts.", - "6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n respond to direct messages, delegate explicitly requested work, append durable\n memory, or no-op with reason. Never retry checkout/claim conflicts.", - ) - .replace( - "7. **Persist progress** — use available ambient tools only:\n fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append.", - "7. **Persist progress** — use available ambient tools only for non-patrol work:\n fn_delegate_task, fn_send_message, fn_memory_append, or an explicit no-op reason.\n Do not call fn_task_create for idle patrol task creation.", - ) - .replace( - "8. **Final disposition checklist** — acted with evidence / follow-up created or\n delegated / explicit no-op with reason.", - "8. **Final disposition checklist** — acted with evidence / delegated explicit\n requested work / explicit no-op with reason.", - ) - .replace( - "Critical: a heartbeat without observable progress (a created task, delegation,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.", - "Critical: a heartbeat without observable progress (delegation for explicit work,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.", - ); -} - -// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_PROCEDURE_STRICT. -export const HEARTBEAT_NO_TASK_PROCEDURE = HEARTBEAT_NO_TASK_PROCEDURE_STRICT; +export { + HEARTBEAT_CRITICAL_RULES, + HEARTBEAT_SYSTEM_PROMPT, + HEARTBEAT_NO_TASK_SYSTEM_PROMPT, + HEARTBEAT_SYSTEM_PROMPT_NO_TASK, + HEARTBEAT_PROCEDURE_STRICT, + HEARTBEAT_PROCEDURE_LITE, + HEARTBEAT_PROCEDURE_OFF, + HEARTBEAT_PROCEDURE, + HEARTBEAT_NO_TASK_PROCEDURE_STRICT, + HEARTBEAT_NO_TASK_PROCEDURE_LITE, + HEARTBEAT_NO_TASK_PROCEDURE_OFF, + HEARTBEAT_NO_TASK_PROCEDURE, +} from "./agent-heartbeat-prompts.js"; +import { + HEARTBEAT_CRITICAL_RULES, + HEARTBEAT_SYSTEM_PROMPT, + HEARTBEAT_NO_TASK_SYSTEM_PROMPT, + HEARTBEAT_SYSTEM_PROMPT_NO_TASK, + HEARTBEAT_PROCEDURE_STRICT, + HEARTBEAT_PROCEDURE_LITE, + HEARTBEAT_PROCEDURE_OFF, + HEARTBEAT_PROCEDURE, + HEARTBEAT_NO_TASK_PROCEDURE_STRICT, + HEARTBEAT_NO_TASK_PROCEDURE_LITE, + HEARTBEAT_NO_TASK_PROCEDURE_OFF, + HEARTBEAT_NO_TASK_PROCEDURE, + renderHeartbeatNoTaskProcedure, + renderHeartbeatNoTaskSystemPrompt, +} from "./agent-heartbeat-prompts.js"; /** Parameter schema for the fn_heartbeat_done tool */ const heartbeatDoneParams = Type.Object({ diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index c54ab4ee6f..4cdc1d57d5 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -88,7 +88,7 @@ "packages/engine/src/__tests__/self-healing.test.ts": 11148, "packages/engine/src/__tests__/step-session-executor.test.ts": 3217, "packages/engine/src/__tests__/triage.test.ts": 6605, - "packages/engine/src/agent-heartbeat.ts": 5495, + "packages/engine/src/agent-heartbeat.ts": 5084, "packages/engine/src/agent-tools.ts": 4793, "packages/engine/src/executor.ts": 19107, "packages/engine/src/merger-ai.ts": 2148, From 1ca90b9b02661910cb4b054bea9a319439f63358 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 10:56:54 -0700 Subject: [PATCH 5/9] fix(review): wire clearTerminalWorkflowStepFailures peel only Remove dual-home monofile body so executor imports the satellite module as the sole implementation, matching browser/requeue/pseudo-pause peels. --- .../task-store/task-commit-associations.ts | 11 +++----- packages/engine/src/executor.ts | 25 ++----------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/packages/core/src/task-store/task-commit-associations.ts b/packages/core/src/task-store/task-commit-associations.ts index 9e1aacaac9..9f3fc95cd9 100644 --- a/packages/core/src/task-store/task-commit-associations.ts +++ b/packages/core/src/task-store/task-commit-associations.ts @@ -1,16 +1,13 @@ /** - * FNXC:CodeOrganization 2026-07-15-00:00: - * Domain module for task commit-association persistence (was remaining-ops-9). - * Migrated out of opaque remaining-ops dump naming per package organization plan. - */ - -/** - * remaining-ops-9 operations. + * Task commit-association persistence (domain module). * * FNXC:StoreModularization 2026-06-25-00:00: * Extracted from the monolithic packages/core/src/store.ts as a pure * behavior-preserving refactor. Each function receives the TaskStore * instance as its first parameter and performs byte-identical work. + * + * FNXC:CodeOrganization 2026-07-15-00:00: + * Renamed from remaining-ops-9 to a domain name; no behavior change. */ import { TaskStore } from "../store.js"; diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 80481d20f9..9353721bd8 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -18986,29 +18986,8 @@ function hasNonTerminalWorkflowSteps(task: Pick): boolean { return task.steps.length > 0 && task.steps.some((step) => step.status !== "done" && step.status !== "skipped"); } -/* -FNXC:ReviewLeniency 2026-07-02-01:00: -Retrying a task must clear PRIOR FAILURE states so the retry starts clean — including on optional gate nodes like code-review / browser-verification. Results are upserted by node id, so a re-running node overwrites its own stale entry, but a send-back-for-fix leaves the failed entry in place until (and unless) that node re-runs; meanwhile self-healing's failed-pre-merge scan and the dashboard both see a stale failure, and a node that is skipped/relaxed on the retry never clears it. Drop every terminal failure result (`failed`/`advisory_failure`) on retry while keeping `passed`/`skipped`/`pending` evidence (so a previously-passed Plan Review is not re-run). Returns the same array reference when nothing changed so callers can skip a no-op write. - -FNXC:WorkflowStepResults 2026-07-09-00:30: -FN-7727 explicit decision: an explicit user/agent RETRY remains a clean-slate — -it MAY drop the current `failed`/`advisory_failure` entry entirely (along with -any `priorAttempts` history it carried), since retry is deliberately -discarding prior failure state, not preserving it. This is DIFFERENT from the -self-healing recovery re-run path (`recoverFailedPreMergeWorkflowStep` / -`recoverReviewTasksWithFailedPreMergeSteps`), which does NOT call this -function — that path re-runs the SAME node in place and its result goes -through `upsertWorkflowStepResult`, which is where prior-attempt history is -preserved. This filter must not throw on entries carrying `priorAttempts` -(it only reads `status`, so `priorAttempts` is inert here regardless). -*/ -export function clearTerminalWorkflowStepFailures( - results: CoreWorkflowStepResult[] | undefined, -): CoreWorkflowStepResult[] { - const current = results ?? []; - const kept = current.filter((result) => result.status !== "failed" && result.status !== "advisory_failure"); - return kept.length === current.length ? current : kept; -} +export { clearTerminalWorkflowStepFailures } from "./executor/workflow-step-failures.js"; +import { clearTerminalWorkflowStepFailures } from "./executor/workflow-step-failures.js"; function workflowStepResultPassed(task: Pick | undefined, workflowStepId: string): boolean { const results = task?.workflowStepResults ?? []; From 114785379a3b3158da34b55c87249dd594af8c60 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 11:06:50 -0700 Subject: [PATCH 6/9] fix(ci): drop unused re-export imports after module peels ESLint no-unused-vars failed on symbols imported only for re-export. Keep export-from for public API; import only symbols used in monofile bodies. --- packages/engine/src/agent-heartbeat.ts | 5 ----- packages/engine/src/executor.ts | 8 ++------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index dbfbb122f5..14e95e880f 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -29,7 +29,6 @@ import { evaluateImplementationTaskBind, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode, - FUSION_RUNTIME_SELF_AWARENESS, AWAITING_APPROVAL_PAUSE_REASON, rankAssignedTasksForWakeDelta, formatAssignedTasksWakeDeltaSection, @@ -483,18 +482,14 @@ export { HEARTBEAT_NO_TASK_PROCEDURE, } from "./agent-heartbeat-prompts.js"; import { - HEARTBEAT_CRITICAL_RULES, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT, - HEARTBEAT_SYSTEM_PROMPT_NO_TASK, HEARTBEAT_PROCEDURE_STRICT, HEARTBEAT_PROCEDURE_LITE, HEARTBEAT_PROCEDURE_OFF, - HEARTBEAT_PROCEDURE, HEARTBEAT_NO_TASK_PROCEDURE_STRICT, HEARTBEAT_NO_TASK_PROCEDURE_LITE, HEARTBEAT_NO_TASK_PROCEDURE_OFF, - HEARTBEAT_NO_TASK_PROCEDURE, renderHeartbeatNoTaskProcedure, renderHeartbeatNoTaskSystemPrompt, } from "./agent-heartbeat-prompts.js"; diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 9353721bd8..839ed05a2a 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -13,7 +13,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core"; import { getUnmetSchedulingDependencies } from "./scheduler.js"; -import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore } from "@fusion/core"; +import { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore } from "@fusion/core"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js"; @@ -94,7 +94,6 @@ import { resolveValidatorFallbackThinkingLevel, } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; -import type { SkillSelectionContext } from "./skill-resolver.js"; import { assertMcpResolutionSucceeded, resolveMcpServersForStore } from "./mcp-resolution.js"; import { reviewStep, proseSignalsClearApproval, extractJsonObjectCandidates, type ReviewVerdict, type ReviewResult } from "./reviewer.js"; import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js"; @@ -285,12 +284,11 @@ export { } from "./executor/browser-probe.js"; export type { AgentBrowserAvailabilityProbeResult } from "./executor/browser-probe.js"; import { - AGENT_BROWSER_NAVIGATION_SKILL_ID, probeAgentBrowserAvailability, augmentSessionSkillsForBrowserStep, formatAgentBrowserAvailabilityLog, } from "./executor/browser-probe.js"; -import type { AgentBrowserAvailabilityProbeResult, AgentBrowserExec } from "./executor/browser-probe.js"; +import type { AgentBrowserExec } from "./executor/browser-probe.js"; function mergeAdditionalSkillPaths(...pathGroups: Array): string[] | undefined { const merged = Array.from(new Set(pathGroups.flatMap((paths) => paths ?? []))); @@ -444,7 +442,6 @@ export { import { MAX_EXECUTE_REQUEUE_LOOP_CYCLES, EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD, - buildExecuteRequeueLoopSignature, buildExecuteRequeueLoopHighWaterSignature, isInvalidAssistantContinuationErrorMessage, isTransientMissingTaskJsonError, @@ -19083,4 +19080,3 @@ import { detectPseudoPause, detectReviewHandoffIntent, } from "./executor/pseudo-pause.js"; -import type { PseudoPauseResult } from "./executor/pseudo-pause.js"; From 6537976d86bca50bde2ebf3dfaf0db0c109bd734 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 12:40:12 -0700 Subject: [PATCH 7/9] fix(ci): remove stale heartbeat prompt imports --- packages/engine/src/agent-heartbeat.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 14e95e880f..7270a2e06a 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -34,7 +34,6 @@ import { formatAssignedTasksWakeDeltaSection, resolveEffectiveSettingsById, resolveEffectivePlannerHeartbeatPatrolEnabled, - TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, } from "@fusion/core"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@earendil-works/pi-ai"; @@ -483,7 +482,6 @@ export { } from "./agent-heartbeat-prompts.js"; import { HEARTBEAT_SYSTEM_PROMPT, - HEARTBEAT_NO_TASK_SYSTEM_PROMPT, HEARTBEAT_PROCEDURE_STRICT, HEARTBEAT_PROCEDURE_LITE, HEARTBEAT_PROCEDURE_OFF, From a911eb11d50d3632c8199dd962c7ce98e8537d58 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 12:40:17 -0700 Subject: [PATCH 8/9] fix(engine): recognize Windows task JSON paths --- ...ecutor-missing-task-json-transient.test.ts | 16 ++++++++++++++ packages/engine/src/executor/requeue-loop.ts | 21 ++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/__tests__/executor-missing-task-json-transient.test.ts b/packages/engine/src/__tests__/executor-missing-task-json-transient.test.ts index 64edad7855..b369faebc6 100644 --- a/packages/engine/src/__tests__/executor-missing-task-json-transient.test.ts +++ b/packages/engine/src/__tests__/executor-missing-task-json-transient.test.ts @@ -13,6 +13,22 @@ describe("isTransientMissingTaskJsonError", () => { expect(isTransientMissingTaskJsonError(err, task)).toBe(true); }); + it("matches Windows worktree paths with backslash separators", () => { + const windowsTask = { + id: "FN-5624", + worktree: "C:\\worktrees\\fn-5624", + }; + const err = "ENOENT: no such file or directory, open 'C:\\worktrees\\fn-5624\\.fusion\\tasks\\FN-5624\\task.json'"; + + expect(isTransientMissingTaskJsonError(err, windowsTask)).toBe(true); + }); + + it("does not treat a sibling worktree with the same prefix as contained", () => { + const err = "ENOENT: no such file or directory, open '/tmp/worktrees/fn-5624-copy/.fusion/tasks/FN-5624/task.json'"; + + expect(isTransientMissingTaskJsonError(err, task)).toBe(false); + }); + it("does not match task.json parse failures", () => { const err = "Failed to parse task.json at /tmp/worktrees/fn-5624/.fusion/tasks/FN-5624/task.json: Unexpected token"; expect(isTransientMissingTaskJsonError(err, task)).toBe(false); diff --git a/packages/engine/src/executor/requeue-loop.ts b/packages/engine/src/executor/requeue-loop.ts index 27a5f9430b..a6e39a3f50 100644 --- a/packages/engine/src/executor/requeue-loop.ts +++ b/packages/engine/src/executor/requeue-loop.ts @@ -4,7 +4,7 @@ */ import type { TaskDetail, Task } from "@fusion/core"; import { TaskDeletedError } from "@fusion/core"; -import { resolve as resolvePath } from "node:path"; +import { isAbsolute, relative, resolve as resolvePath } from "node:path"; /** Maximum no-progress execute-node self-requeues before terminalizing the loop. */ export const MAX_EXECUTE_REQUEUE_LOOP_CYCLES = 6; @@ -66,7 +66,20 @@ export function isInvalidAssistantContinuationErrorMessage(errorMessage: string) return INVALID_ASSISTANT_CONTINUATION_PATTERN.test(errorMessage); } -export const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+\/\.fusion\/tasks\/([^/]+)\/task\.json)'/; +export const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+[\\/]\.fusion[\\/]tasks[\\/]([^\\/]+)[\\/]task\.json)'/; + +function normalizeErrorPath(path: string): string { + return resolvePath(path.replace(/\\/g, "/")); +} + +function isContainedByWorktree(filePath: string, worktree: string): boolean { + /* + FNXC:ExecutorRecovery 2026-07-15-13:20: + Transient task.json recovery must recognize Node ENOENT paths on both Windows and POSIX, but only when the missing file belongs to the task's exact worktree. Normalize separator-only error-message differences before path resolution, then use relative-path containment so sibling prefixes such as `fn-1-copy` cannot be mistaken for `fn-1`. + */ + const relativePath = relative(normalizeErrorPath(worktree), normalizeErrorPath(filePath)); + return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); +} export function isTransientMissingTaskJsonError(error: unknown, task: Pick): boolean { if (error instanceof TaskDeletedError) { @@ -88,7 +101,5 @@ export function isTransientMissingTaskJsonError(error: unknown, task: Pick Date: Wed, 15 Jul 2026 13:14:05 -0700 Subject: [PATCH 9/9] fix(ci): bundle plugin schema shim from source --- .changeset/friendly-shims-fix.md | 7 ++++ .../src/__tests__/plugin-sdk-export.test.ts | 11 ++++++ .../cli/src/plugin-sdk-core-runtime-shim.mjs | 27 +++++++++++++ .../cli/src/plugin-sdk-core-runtime-shim.ts | 39 ------------------- packages/cli/tsup.config.ts | 2 +- 5 files changed, 46 insertions(+), 40 deletions(-) create mode 100644 .changeset/friendly-shims-fix.md create mode 100644 packages/cli/src/plugin-sdk-core-runtime-shim.mjs delete mode 100644 packages/cli/src/plugin-sdk-core-runtime-shim.ts diff --git a/.changeset/friendly-shims-fix.md b/.changeset/friendly-shims-fix.md new file mode 100644 index 0000000000..a1adefd194 --- /dev/null +++ b/.changeset/friendly-shims-fix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix clean-CI typechecking for bundled plugins that use PostgreSQL schemas. +category: fix +dev: Bundle the core schema through a runtime-only shim instead of requiring an unbuilt core dist artifact. diff --git a/packages/cli/src/__tests__/plugin-sdk-export.test.ts b/packages/cli/src/__tests__/plugin-sdk-export.test.ts index 8899323235..140023350b 100644 --- a/packages/cli/src/__tests__/plugin-sdk-export.test.ts +++ b/packages/cli/src/__tests__/plugin-sdk-export.test.ts @@ -43,6 +43,17 @@ describe("plugin-sdk export surface", () => { expect(tsupRaw).toContain("/^@fusion\\//"); }); + it("uses a runtime-only core shim that bundles schema source without requiring core dist", () => { + const tsupPath = join(workspaceRoot, "packages", "cli", "tsup.config.ts"); + const tsupRaw = readFileSync(tsupPath, "utf-8"); + const shimPath = join(workspaceRoot, "packages", "cli", "src", "plugin-sdk-core-runtime-shim.mjs"); + const shimRaw = readFileSync(shimPath, "utf-8"); + + expect(tsupRaw).toContain('"plugin-sdk-core-runtime-shim.mjs"'); + expect(shimRaw).toContain('from "../../core/src/postgres/schema/index.js"'); + expect(shimRaw).not.toContain("../../core/dist/"); + }); + it("has no @fusion runtime specifiers in built plugin-sdk artifact when present", () => { const distPath = join(workspaceRoot, "packages", "cli", "dist", "plugin-sdk", "index.js"); if (!existsSync(distPath)) { diff --git a/packages/cli/src/plugin-sdk-core-runtime-shim.mjs b/packages/cli/src/plugin-sdk-core-runtime-shim.mjs new file mode 100644 index 0000000000..0543892680 --- /dev/null +++ b/packages/cli/src/plugin-sdk-core-runtime-shim.mjs @@ -0,0 +1,27 @@ +/* + * FNXC:BundledPlugins 2026-07-15-13:11: + * Clean CI typechecks the CLI before @fusion/core emits dist, but published bundled plugins still need postgresSchema runtime values. Keep this alias implementation in an untyped .mjs module so CLI tsc does not cross the package rootDir boundary; esbuild follows the core source import and inlines the schema into each bundled.js artifact, leaving no private @fusion/core runtime dependency. + */ +import * as postgresSchema from "../../core/src/postgres/schema/index.js"; + +export { postgresSchema }; + +export const WORKFLOW_EXTENSION_SCHEMA_VERSION = 1; + +export function workflowExtensionRegistryId(pluginId, extensionId) { + return `plugin:${pluginId}:${extensionId}`; +} + +export function createBoardActionServices(store) { + return { + moveTask(input) { + return store.moveTask(input.taskId, input.column, { + preserveProgress: input.preserveProgress, + moveSource: input.source ?? "user", + }); + }, + updateTask(input) { + return store.updateTask(input.taskId, input.updates); + }, + }; +} diff --git a/packages/cli/src/plugin-sdk-core-runtime-shim.ts b/packages/cli/src/plugin-sdk-core-runtime-shim.ts deleted file mode 100644 index 55f7980d70..0000000000 --- a/packages/cli/src/plugin-sdk-core-runtime-shim.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { BoardActionTaskStore, ColumnId, Task } from "@fusion/core"; - -/** - * FNXC:BundledPlugins 2026-07-15-00:00: - * Reports and CLI Printing Press import the `postgresSchema` runtime namespace while the CLI bundler aliases `@fusion/core` to this shim for published plugin bundles. Re-export the concrete core schema build artifact here so npm-installed `bundled.js` files resolve `postgresSchema.plugin` without keeping a bare private `@fusion/core` runtime specifier or crashing with `Cannot find package '@fusion/core'`; using dist keeps @runfusion/fusion typecheck inside its package root while esbuild still bundles the schema runtime values. - */ -export * as postgresSchema from "../../core/dist/postgres/schema/index.js"; - -export const WORKFLOW_EXTENSION_SCHEMA_VERSION = 1 as const; - -export function workflowExtensionRegistryId(pluginId: string, extensionId: string): string { - return `plugin:${pluginId}:${extensionId}`; -} - -export interface MoveBoardTaskInput { - taskId: string; - column: ColumnId; - preserveProgress?: boolean; - source?: "user" | "engine" | "scheduler"; -} - -export interface UpdateBoardTaskInput { - taskId: string; - updates: Record; -} - -export function createBoardActionServices(store: BoardActionTaskStore) { - return { - moveTask(input: MoveBoardTaskInput): Promise { - return store.moveTask(input.taskId, input.column, { - preserveProgress: input.preserveProgress, - moveSource: input.source ?? "user", - }); - }, - updateTask(input: UpdateBoardTaskInput): Promise { - return store.updateTask(input.taskId, input.updates); - }, - }; -} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index d2bb980296..2861ad62b2 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -66,7 +66,7 @@ const compoundEngineeringPluginSrc = join(__dirname, "..", "..", "plugins", "fus const compoundEngineeringPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-compound-engineering"); const linearImportPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-linear-import"); const linearImportPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-linear-import"); -const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.ts"); +const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.mjs"); const dashboardClientStub = `