diff --git a/.changeset/fn-7531-planner-overseer-state-exposure.md b/.changeset/fn-7531-planner-overseer-state-exposure.md
new file mode 100644
index 0000000000..e47ffac7bc
--- /dev/null
+++ b/.changeset/fn-7531-planner-overseer-state-exposure.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Task cards can now show the planner overseer's active state (idle/watching/steering/recovering/awaiting-confirmation).
+category: feature
+dev: Adds a serializable `PlannerOverseerRuntimeSnapshot` + pure `derivePlannerOverseerState` (core), a read-only `ProjectEngine.getPlannerOverseerRuntimeSnapshot(taskId)` accessor assembling it from the FN-7511 monitor + FN-7512/7513 recovery controller, and a best-effort additive `plannerOverseerState` enrichment on `GET /api/tasks` (mirrors the `branchProgress` pattern; never persisted, never fails the board load). Consumed by FN-7516's TaskCard.
diff --git a/docs/architecture.md b/docs/architecture.md
index bde6f88015..bb0ae8134e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1560,6 +1560,42 @@ same settings self-healing already gates lifecycle mutation on.
**Downstream ownership (not this layer):** the dashboard UI/badges surfacing withheld state (FN-7515+),
a persisted intervention timeline (FN-7519), and richer run-audit/activity presentation (FN-7520).
+### Planner overseer runtime-state exposure (FN-7531)
+
+/*
+FNXC:PlannerOversight 2026-07-04-00:00:
+FN-7531 closes the data-exposure gap FN-7516 needed: the planner overseer's runtime state (FN-7511's
+`PlannerOverseerMonitor` observations, FN-7512/FN-7513's `PlannerRecoveryController` attempt/pending-
+confirmation registries) was engine-side and in-memory only. This task adds a lightweight, serializable
+snapshot and surfaces it on the `GET /api/tasks` payload so task cards can render an indicator without
+a second round-trip.
+*/
+
+`packages/core/src/planner-overseer-state.ts` declares the externally-meaningful five-value enum
+`PLANNER_OVERSEER_STATES` (`idle | watching | steering | recovering | awaiting-confirmation`), the
+serializable `PlannerOverseerRuntimeSnapshot` interface (`state`, `oversightLevel`, `watchedStage?`,
+`signal?`, `attemptCount?`, `attemptLimit?`, `pendingConfirmation?`, `observedAt?` — `watchedStage`/
+`signal` are kept as bare `string` so the engine's stage taxonomy is not pulled into `@fusion/core`),
+and the pure, never-throw `derivePlannerOverseerState(input)`. Precedence: `oversightLevel === "off"`
+or no active observation → `"idle"`; a pending confirmation → `"awaiting-confirmation"` (wins over an
+in-flight recovery attempt); a recorded recovery attempt → `"recovering"`; `"steer"` level → `"steering"`;
+otherwise (`observe`/`autonomous` watching, no attempts/pending) → `"watching"`.
+
+`ProjectEngine.getPlannerOverseerRuntimeSnapshot(taskId)` (delegating to the pure
+`assemblePlannerOverseerRuntimeSnapshot` helper in `packages/engine/src/planner-overseer-runtime-snapshot.ts`
+for testability) reads the latest observation from `PlannerOverseerMonitor.getObservations(taskId)` plus
+`PlannerRecoveryController.getPendingConfirmations(taskId)`/`getAttemptCount(taskId, stage)`, and returns
+`null` (never throws) when there is no active observation for the task. `GET /tasks`
+(`register-task-workflow-routes.ts`) additively enriches each returned task with `plannerOverseerState`
+when the engine snapshot is non-null — best-effort, mirroring the existing `branchProgress` enrichment
+block right beside it: any engine error is swallowed and the un-enriched list is returned, and tasks with
+no active observation omit the field entirely (byte-identical payload). `Task.plannerOverseerState?` is a
+transient field — engine-populated at serialization time, never persisted to the store or task.json.
+
+FN-7516's `TaskCard` renders the badge/affordance; this task only provides the field, the engine
+accessor, and (since FN-7516 had not yet landed consumption) a minimal guarded read plus a
+memo-comparator entry so the card repaints on state change.
+
---
## 11) Multi-Project Architecture
diff --git a/packages/core/src/__tests__/planner-overseer-state.test.ts b/packages/core/src/__tests__/planner-overseer-state.test.ts
new file mode 100644
index 0000000000..89b36ef1af
--- /dev/null
+++ b/packages/core/src/__tests__/planner-overseer-state.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { derivePlannerOverseerState } from "../planner-overseer-state.js";
+
+describe("derivePlannerOverseerState", () => {
+ it("returns idle when oversightLevel is off, regardless of other inputs", () => {
+ expect(
+ derivePlannerOverseerState({
+ oversightLevel: "off",
+ hasObservation: true,
+ attemptCount: 5,
+ pendingConfirmationCount: 2,
+ }),
+ ).toBe("idle");
+ });
+
+ it("returns idle when there is no active observation", () => {
+ expect(
+ derivePlannerOverseerState({
+ oversightLevel: "autonomous",
+ hasObservation: false,
+ attemptCount: 3,
+ pendingConfirmationCount: 1,
+ }),
+ ).toBe("idle");
+ });
+
+ it("returns awaiting-confirmation when a pending confirmation exists, winning over attempts", () => {
+ expect(
+ derivePlannerOverseerState({
+ oversightLevel: "autonomous",
+ hasObservation: true,
+ attemptCount: 2,
+ pendingConfirmationCount: 1,
+ }),
+ ).toBe("awaiting-confirmation");
+ });
+
+ it("returns recovering when an attempt has been recorded and there is no pending confirmation", () => {
+ expect(
+ derivePlannerOverseerState({
+ oversightLevel: "autonomous",
+ hasObservation: true,
+ attemptCount: 1,
+ pendingConfirmationCount: 0,
+ }),
+ ).toBe("recovering");
+ });
+
+ it("returns steering for an active steer-level observation with no attempts/pending", () => {
+ expect(
+ derivePlannerOverseerState({
+ oversightLevel: "steer",
+ hasObservation: true,
+ attemptCount: 0,
+ pendingConfirmationCount: 0,
+ }),
+ ).toBe("steering");
+ });
+
+ it("returns watching for observe/autonomous active observations with no attempts/pending", () => {
+ expect(
+ derivePlannerOverseerState({
+ oversightLevel: "observe",
+ hasObservation: true,
+ }),
+ ).toBe("watching");
+ expect(
+ derivePlannerOverseerState({
+ oversightLevel: "autonomous",
+ hasObservation: true,
+ }),
+ ).toBe("watching");
+ });
+
+ it("never throws on undefined optional inputs", () => {
+ expect(() =>
+ derivePlannerOverseerState({
+ oversightLevel: "autonomous",
+ hasObservation: true,
+ attemptCount: undefined,
+ pendingConfirmationCount: undefined,
+ }),
+ ).not.toThrow();
+ });
+});
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c97a106d62..5d5ce5e3ae 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -443,6 +443,13 @@ export {
type PlannerRecoveryDecision,
type DecidePlannerRecoveryInput,
} from "./planner-recovery.js";
+export {
+ PLANNER_OVERSEER_STATES,
+ derivePlannerOverseerState,
+ type PlannerOverseerState,
+ type PlannerOverseerRuntimeSnapshot,
+ type DerivePlannerOverseerStateInput,
+} from "./planner-overseer-state.js";
export {
classifyPlannerActionSideEffect,
requiresPlannerConfirmation,
diff --git a/packages/core/src/planner-overseer-state.ts b/packages/core/src/planner-overseer-state.ts
new file mode 100644
index 0000000000..3bea80f36e
--- /dev/null
+++ b/packages/core/src/planner-overseer-state.ts
@@ -0,0 +1,78 @@
+import type { PlannerOversightLevel } from "./types.js";
+
+/**
+ * FNXC:PlannerOversight 2026-07-04-00:00:
+ * FN-7531 exposes the planner overseer's engine-side, in-memory runtime state
+ * (from FN-7511's `PlannerOverseerMonitor` observations and FN-7512/FN-7513's
+ * `PlannerRecoveryController` attempt/confirmation registries) to the
+ * dashboard. This module declares the externally-meaningful, serializable
+ * state enum and the pure derivation function that is the seam FN-7516's
+ * `TaskCard` badge consumes — it does NOT change the monitor, the recovery
+ * controller, the oversight-level resolution, the confirmation gates, or the
+ * human-control safeguards (those remain owned by FN-7511–FN-7514).
+ *
+ * `watchedStage`/`signal` are intentionally typed as bare `string` (not the
+ * engine's `OverseerWatchedStage`/`OverseerObservationSignal` unions) so the
+ * engine's stage taxonomy is not pulled into `@fusion/core`.
+ */
+export const PLANNER_OVERSEER_STATES = ["idle", "watching", "steering", "recovering", "awaiting-confirmation"] as const;
+export type PlannerOverseerState = (typeof PLANNER_OVERSEER_STATES)[number];
+
+/**
+ * A transient, serializable snapshot of the planner overseer's current
+ * runtime state for one task. Engine-populated at `GET /api/tasks`
+ * serialization time (mirroring the additive `branchProgress` board-payload
+ * convention) — never persisted to the store or task.json.
+ */
+export interface PlannerOverseerRuntimeSnapshot {
+ state: PlannerOverseerState;
+ oversightLevel: PlannerOversightLevel;
+ watchedStage?: string;
+ signal?: string;
+ attemptCount?: number;
+ attemptLimit?: number;
+ pendingConfirmation?: boolean;
+ observedAt?: number;
+}
+
+/** Pure input the state derivation reads — no engine types, no side effects. */
+export interface DerivePlannerOverseerStateInput {
+ oversightLevel: PlannerOversightLevel;
+ hasObservation: boolean;
+ attemptCount?: number;
+ pendingConfirmationCount?: number;
+}
+
+/**
+ * FNXC:PlannerOversight 2026-07-04-00:00:
+ * Pure, deterministic, never-throw mapping from the overseer's current
+ * inputs to exactly one of the five `PlannerOverseerState` values. Checked
+ * in this precedence order:
+ * 1. `oversightLevel === "off"` OR no active observation → `"idle"`.
+ * 2. A pending confirmation exists → `"awaiting-confirmation"` (wins over
+ * an in-flight recovery attempt — a human decision is blocking).
+ * 3. A recovery attempt has been recorded → `"recovering"`.
+ * 4. `oversightLevel === "steer"` → `"steering"`.
+ * 5. Otherwise (`observe`/`autonomous` watching with no attempts/pending)
+ * → `"watching"`.
+ */
+export function derivePlannerOverseerState(input: DerivePlannerOverseerStateInput): PlannerOverseerState {
+ const oversightLevel = input?.oversightLevel;
+ const hasObservation = Boolean(input?.hasObservation);
+ const attemptCount = input?.attemptCount ?? 0;
+ const pendingConfirmationCount = input?.pendingConfirmationCount ?? 0;
+
+ if (oversightLevel === "off" || !hasObservation) {
+ return "idle";
+ }
+ if (pendingConfirmationCount > 0) {
+ return "awaiting-confirmation";
+ }
+ if (attemptCount > 0) {
+ return "recovering";
+ }
+ if (oversightLevel === "steer") {
+ return "steering";
+ }
+ return "watching";
+}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index e00612cb5e..6152b03b28 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,4 +1,5 @@
import type { InReviewStallSignal } from "./in-review-stall.js";
+import type { PlannerOverseerRuntimeSnapshot } from "./planner-overseer-state.js";
import type { ModelPricing } from "./model-pricing.js";
import type { InReviewStalledSignal } from "./in-review-stalled.js";
import type { StalePausedReviewSignal } from "./stale-paused-review.js";
@@ -2473,6 +2474,17 @@ export interface Task {
* "inherit workflow default" — see `resolveEffectivePlannerOversightLevel` in
* workflow-settings-resolver.ts for precedence. */
plannerOversightLevel?: PlannerOversightLevel;
+ /**
+ * FNXC:PlannerOversight 2026-07-04-00:00:
+ * FN-7531 transient, engine-populated snapshot of the planner overseer's
+ * current runtime state (idle/watching/steering/recovering/awaiting-
+ * confirmation), assembled from the FN-7511 `PlannerOverseerMonitor` +
+ * FN-7512/FN-7513 `PlannerRecoveryController` registries. Attached
+ * best-effort to the `GET /api/tasks` payload (mirroring the additive
+ * `branchProgress` board-payload convention) — NEVER written to the
+ * store or task.json. Consumed by FN-7516's `TaskCard` badge.
+ */
+ plannerOverseerState?: PlannerOverseerRuntimeSnapshot;
/** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */
assignedAgentId?: string;
/** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index 7d301a0cea..73f158170f 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -715,7 +715,13 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) &&
// FNXC:GitHubTracking 2026-07-01-00:00: Context-menu tracking actions depend on githubTracking.enabled, so memoized cards must repaint when a PATCH enables tracking and remove the now-ineligible menu item.
JSON.stringify(previousTask.githubTracking ?? null) === JSON.stringify(nextTask.githubTracking ?? null) &&
- JSON.stringify(previousTask.gitlabTracking ?? null) === JSON.stringify(nextTask.gitlabTracking ?? null)
+ JSON.stringify(previousTask.gitlabTracking ?? null) === JSON.stringify(nextTask.gitlabTracking ?? null) &&
+ // FNXC:PlannerOversight 2026-07-04-00:00: FN-7531 exposes the transient, engine-populated
+ // `plannerOverseerState` snapshot on the board payload; repaint the card whenever the
+ // overseer state changes (idle/watching/steering/recovering/awaiting-confirmation) so a
+ // consumer's badge stays live. FN-7516 owns the visual affordance/design; this task only
+ // provides a minimal, type-safe, guarded read.
+ JSON.stringify(previousTask.plannerOverseerState ?? null) === JSON.stringify(nextTask.plannerOverseerState ?? null)
);
}
@@ -2506,6 +2512,25 @@ function TaskCardComponent({
})}
)}
+ {/*
+ FNXC:PlannerOversight 2026-07-04-00:00:
+ FN-7531 provides `task.plannerOverseerState` (transient, engine-populated on the
+ board payload) plus a repaint-correct memo comparator; FN-7516 owns the styled
+ badge/design and surface-by-surface rendering. This is a minimal, type-safe,
+ guarded read only — nothing renders for an absent field or the "idle" state.
+ */}
+ {task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && (
+
+ {task.plannerOverseerState.state}
+
+ )}
{showStalledReview && stalledReview && (
{
expect(__test_areTaskCardPropsEqual(base as any, withGitLab as any)).toBe(false);
});
+ it("repaints the memoized card when plannerOverseerState changes, and renders nothing when absent", () => {
+ const idleTask = makeTask({ plannerOverseerState: undefined });
+ const watchingTask = makeTask({
+ plannerOverseerState: {
+ state: "watching",
+ oversightLevel: "autonomous",
+ watchedStage: "executor",
+ signal: "progressing",
+ attemptCount: 0,
+ attemptLimit: 3,
+ pendingConfirmation: false,
+ observedAt: 1700000000000,
+ },
+ });
+
+ expect(
+ __test_areTaskCardPropsEqual({ task: idleTask } as any, { task: watchingTask } as any),
+ ).toBe(false);
+ expect(
+ __test_areTaskCardPropsEqual({ task: watchingTask } as any, { task: watchingTask } as any),
+ ).toBe(true);
+
+ const { rerender } = render();
+ expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByTestId("planner-overseer-state-badge")).toBeInTheDocument();
+ });
+
it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => {
const onOpenDetailWithTab = vi.fn();
render(
diff --git a/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts b/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts
new file mode 100644
index 0000000000..f401b54d33
--- /dev/null
+++ b/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts
@@ -0,0 +1,114 @@
+// @vitest-environment node
+//
+// FN-7531: HTTP-level coverage for the additive `plannerOverseerState`
+// enrichment on `GET /tasks`. Mirrors the `branchProgress` enrichment
+// contract: attach when the engine snapshot accessor returns a non-null
+// snapshot, omit entirely (byte-identical payload) otherwise, and never
+// fail the board load even when the accessor throws.
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import express from "express";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { TaskStore } from "@fusion/core";
+import type { ProjectEngine } from "@fusion/engine";
+import { createApiRoutes } from "../../routes.js";
+import { request as REQUEST } from "../../test-request.js";
+
+describe("GET /tasks — plannerOverseerState enrichment", () => {
+ let store: TaskStore;
+ let rootDir: string;
+ let globalDir: string;
+
+ beforeEach(async () => {
+ rootDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-root-"));
+ globalDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-global-"));
+ store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
+ await store.init();
+ });
+
+ afterEach(() => {
+ store.close();
+ rmSync(rootDir, { recursive: true, force: true });
+ rmSync(globalDir, { recursive: true, force: true });
+ });
+
+ function buildApp(engine: Partial | undefined): express.Express {
+ const app = express();
+ app.use(express.json());
+ app.use("/api", createApiRoutes(store, engine ? { engine: engine as unknown as ProjectEngine } : undefined));
+ return app;
+ }
+
+ it("attaches plannerOverseerState when the engine snapshot accessor returns a snapshot", async () => {
+ const task = await store.createTask({ description: "watched task" });
+
+ const snapshot = {
+ state: "watching" as const,
+ oversightLevel: "autonomous" as const,
+ watchedStage: "executor",
+ signal: "progressing",
+ attemptCount: 0,
+ attemptLimit: 3,
+ pendingConfirmation: false,
+ observedAt: 1700000000000,
+ };
+
+ const engineStub: Partial = {
+ getTaskStore: () => store,
+ getPlannerOverseerRuntimeSnapshot: (taskId: string) => (taskId === task.id ? snapshot : null),
+ };
+
+ const app = buildApp(engineStub);
+ const res = await REQUEST(app, "GET", "/api/tasks");
+ expect(res.status).toBe(200);
+ const found = (res.body as Array>).find((t) => t.id === task.id);
+ expect(found?.plannerOverseerState).toEqual(snapshot);
+ });
+
+ it("omits plannerOverseerState entirely (no key) when the accessor returns null", async () => {
+ const task = await store.createTask({ description: "idle task" });
+
+ const engineStub: Partial = {
+ getTaskStore: () => store,
+ getPlannerOverseerRuntimeSnapshot: () => null,
+ };
+
+ const app = buildApp(engineStub);
+ const res = await REQUEST(app, "GET", "/api/tasks");
+ expect(res.status).toBe(200);
+ const found = (res.body as Array>).find((t) => t.id === task.id);
+ expect(found).toBeDefined();
+ expect(found && "plannerOverseerState" in found).toBe(false);
+ });
+
+ it("returns 200 with the un-enriched list when the accessor throws (board load never fails)", async () => {
+ const task = await store.createTask({ description: "throwing task" });
+
+ const engineStub: Partial = {
+ getTaskStore: () => store,
+ getPlannerOverseerRuntimeSnapshot: () => {
+ throw new Error("boom");
+ },
+ };
+
+ const app = buildApp(engineStub);
+ const res = await REQUEST(app, "GET", "/api/tasks");
+ expect(res.status).toBe(200);
+ const found = (res.body as Array>).find((t) => t.id === task.id);
+ expect(found).toBeDefined();
+ expect(found && "plannerOverseerState" in found).toBe(false);
+ });
+
+ it("returns 200 with the un-enriched list when no engine is present at all", async () => {
+ const task = await store.createTask({ description: "no engine task" });
+
+ const app = buildApp(undefined);
+ const res = await REQUEST(app, "GET", "/api/tasks");
+ expect(res.status).toBe(200);
+ const found = (res.body as Array>).find((t) => t.id === task.id);
+ expect(found).toBeDefined();
+ expect(found && "plannerOverseerState" in found).toBe(false);
+ });
+});
diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts
index bf29c8cb3b..4ee2a3847a 100644
--- a/packages/dashboard/src/routes/register-task-workflow-routes.ts
+++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts
@@ -859,7 +859,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.get("/tasks", async (req, res) => {
try {
- const { store: scopedStore } = await getProjectContext(req);
+ const { store: scopedStore, engine } = await getProjectContext(req);
const limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined;
const offset = typeof req.query.offset === "string" ? Number.parseInt(req.query.offset, 10) : undefined;
const q = typeof req.query.q === "string" ? req.query.q.trim() : undefined;
@@ -909,6 +909,27 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Branch-progress enrichment is best-effort and must never fail the
// board load — fall through with the un-enriched task list.
}
+
+ // FNXC:PlannerOversight 2026-07-04-00:00:
+ // FN-7531 additively attaches the transient `plannerOverseerState`
+ // snapshot for each task with an active planner-overseer observation,
+ // mirroring the `branchProgress` enrichment block directly above: it
+ // is best-effort, never fails the board load on any engine error, and
+ // omits the field entirely (rather than attaching `null`) for tasks
+ // with no active observation — the payload stays byte-identical for
+ // those tasks. Consumed by FN-7516's TaskCard badge.
+ try {
+ if (engine && tasks.length > 0) {
+ tasks = tasks.map((task) => {
+ const plannerOverseerState = engine.getPlannerOverseerRuntimeSnapshot(task.id);
+ return plannerOverseerState ? { ...task, plannerOverseerState } : task;
+ });
+ }
+ } catch {
+ // Planner-overseer-state enrichment is best-effort and must never
+ // fail the board load — fall through with the un-enriched task list.
+ }
+
res.json(tasks);
} catch (err: unknown) {
if (err instanceof ApiError) {
diff --git a/packages/engine/src/__tests__/planner-overseer-runtime-snapshot.test.ts b/packages/engine/src/__tests__/planner-overseer-runtime-snapshot.test.ts
new file mode 100644
index 0000000000..6d6e82a3f1
--- /dev/null
+++ b/packages/engine/src/__tests__/planner-overseer-runtime-snapshot.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest";
+import { assemblePlannerOverseerRuntimeSnapshot } from "../planner-overseer-runtime-snapshot.js";
+import type { OverseerStageObservation, OverseerWatchedStage } from "../planner-overseer.js";
+
+function observation(overrides: Partial = {}): OverseerStageObservation {
+ return {
+ taskId: "FN-1",
+ stage: "executor" as OverseerWatchedStage,
+ signal: "progressing",
+ oversightLevel: "autonomous",
+ observedAt: 1700000000000,
+ reason: "test",
+ sources: [],
+ ...overrides,
+ };
+}
+
+function fakeMonitor(observations: OverseerStageObservation[]) {
+ return {
+ getObservations: (taskId: string) => (taskId === "FN-1" ? observations : []),
+ };
+}
+
+function fakeController(opts: { pending?: { status?: string }[]; attempts?: Record } = {}) {
+ return {
+ getPendingConfirmations: () => opts.pending ?? [],
+ getAttemptCount: (_taskId: string, stage: string) => opts.attempts?.[stage] ?? 0,
+ };
+}
+
+describe("assemblePlannerOverseerRuntimeSnapshot", () => {
+ it("returns null when there is no observation for the task", () => {
+ const monitor = fakeMonitor([]);
+ const controller = fakeController();
+ expect(assemblePlannerOverseerRuntimeSnapshot("FN-1", monitor, controller)).toBeNull();
+ });
+
+ it("returns null when the monitor is undefined", () => {
+ expect(assemblePlannerOverseerRuntimeSnapshot("FN-1", undefined, fakeController())).toBeNull();
+ });
+
+ it("returns a watching snapshot for an active observation with no attempts/pending", () => {
+ const obs = observation({ oversightLevel: "autonomous", stage: "reviewer" as OverseerWatchedStage, signal: "stuck" });
+ const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), fakeController());
+ expect(snapshot).toMatchObject({
+ state: "watching",
+ oversightLevel: "autonomous",
+ watchedStage: "reviewer",
+ signal: "stuck",
+ attemptCount: 0,
+ pendingConfirmation: false,
+ observedAt: 1700000000000,
+ });
+ expect(snapshot?.attemptLimit).toBeGreaterThan(0);
+ });
+
+ it("returns a steering snapshot for an active steer-level observation", () => {
+ const obs = observation({ oversightLevel: "steer" });
+ const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), fakeController());
+ expect(snapshot?.state).toBe("steering");
+ });
+
+ it("returns a recovering snapshot with attemptCount/attemptLimit when attempts are recorded", () => {
+ const obs = observation({ stage: "executor" as OverseerWatchedStage });
+ const controller = fakeController({ attempts: { executor: 2 } });
+ const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), controller);
+ expect(snapshot).toMatchObject({ state: "recovering", attemptCount: 2 });
+ expect(snapshot?.attemptLimit).toBeGreaterThan(0);
+ });
+
+ it("returns an awaiting-confirmation snapshot with pendingConfirmation:true when a confirmation is pending", () => {
+ const obs = observation();
+ const controller = fakeController({ pending: [{ status: "pending" }] });
+ const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), controller);
+ expect(snapshot).toMatchObject({ state: "awaiting-confirmation", pendingConfirmation: true });
+ });
+
+ it("uses the latest observation when the ring buffer has multiple entries", () => {
+ const older = observation({ signal: "progressing", observedAt: 1 });
+ const latest = observation({ signal: "failed", observedAt: 2 });
+ const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([older, latest]), fakeController());
+ expect(snapshot?.signal).toBe("failed");
+ expect(snapshot?.observedAt).toBe(2);
+ });
+
+ it("never throws — a throwing monitor/controller degrades to null", () => {
+ const throwingMonitor = {
+ getObservations: () => {
+ throw new Error("boom");
+ },
+ };
+ expect(assemblePlannerOverseerRuntimeSnapshot("FN-1", throwingMonitor, fakeController())).toBeNull();
+
+ const throwingController = {
+ getPendingConfirmations: () => {
+ throw new Error("boom");
+ },
+ getAttemptCount: () => 0,
+ };
+ expect(
+ assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([observation()]), throwingController),
+ ).toBeNull();
+ });
+});
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index 61faa20af7..b202d6b4aa 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -647,6 +647,14 @@ export {
type OverseerLogStore,
type PlannerOverseerMonitorOptions,
} from "./planner-overseer.js";
+// FN-7531: re-export the core planner-overseer state types for engine consumers
+// (e.g. `ProjectEngine.getPlannerOverseerRuntimeSnapshot`).
+export {
+ PLANNER_OVERSEER_STATES,
+ derivePlannerOverseerState,
+ type PlannerOverseerState,
+ type PlannerOverseerRuntimeSnapshot,
+} from "@fusion/core";
export {
PlannerRecoveryController,
type PlannerRecoveryContext,
diff --git a/packages/engine/src/planner-overseer-runtime-snapshot.ts b/packages/engine/src/planner-overseer-runtime-snapshot.ts
new file mode 100644
index 0000000000..43b9fa103a
--- /dev/null
+++ b/packages/engine/src/planner-overseer-runtime-snapshot.ts
@@ -0,0 +1,67 @@
+import { derivePlannerOverseerState, PLANNER_RECOVERY_MAX_ATTEMPTS, type PlannerOverseerRuntimeSnapshot } from "@fusion/core";
+import type { OverseerStageObservation } from "./planner-overseer.js";
+
+/**
+ * FNXC:PlannerOversight 2026-07-04-00:00:
+ * FN-7531 narrow read-only shapes for the two engine subsystems this
+ * assembly helper reads. Kept as minimal structural interfaces (rather than
+ * importing the concrete `PlannerOverseerMonitor`/`PlannerRecoveryController`
+ * classes) so unit tests can pass focused in-memory fakes without
+ * constructing the full engine.
+ */
+export interface PlannerOverseerObservationSource {
+ getObservations(taskId: string): OverseerStageObservation[];
+}
+
+export interface PlannerRecoveryRegistrySource {
+ getPendingConfirmations(taskId: string): { status?: string }[];
+ getAttemptCount(taskId: string, stage: string): number;
+}
+
+/**
+ * FNXC:PlannerOversight 2026-07-04-00:00:
+ * Pure(-ish) assembly of the transient `PlannerOverseerRuntimeSnapshot` for
+ * one task from the FN-7511 monitor's latest observation plus the
+ * FN-7512/FN-7513 controller's attempt/pending-confirmation registries.
+ * Read-only: never mutates either source. Returns `null` when there is no
+ * active observation for the task (nothing to show on the card). Never
+ * throws — this is `ProjectEngine.getPlannerOverseerRuntimeSnapshot`'s
+ * delegate, called from the hot `GET /api/tasks` path, so any subsystem
+ * error degrades to `null` rather than risking the board load.
+ */
+export function assemblePlannerOverseerRuntimeSnapshot(
+ taskId: string,
+ monitor: PlannerOverseerObservationSource | undefined,
+ recoveryController: PlannerRecoveryRegistrySource | undefined,
+): PlannerOverseerRuntimeSnapshot | null {
+ try {
+ const observations = monitor?.getObservations(taskId);
+ const observation = observations && observations.length > 0 ? observations[observations.length - 1] : undefined;
+ if (!observation) {
+ return null;
+ }
+
+ const pendingConfirmationCount = recoveryController?.getPendingConfirmations(taskId).length ?? 0;
+ const attemptCount = recoveryController?.getAttemptCount(taskId, observation.stage) ?? 0;
+
+ const state = derivePlannerOverseerState({
+ oversightLevel: observation.oversightLevel,
+ hasObservation: true,
+ attemptCount,
+ pendingConfirmationCount,
+ });
+
+ return {
+ state,
+ oversightLevel: observation.oversightLevel,
+ watchedStage: observation.stage,
+ signal: observation.signal,
+ attemptCount,
+ attemptLimit: PLANNER_RECOVERY_MAX_ATTEMPTS,
+ pendingConfirmation: pendingConfirmationCount > 0,
+ observedAt: observation.observedAt,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts
index c82c924ebc..aaa2138307 100644
--- a/packages/engine/src/project-engine.ts
+++ b/packages/engine/src/project-engine.ts
@@ -12,8 +12,10 @@ import type {
ResearchModelSettings,
ResearchSynthesisRequest,
ResearchSynthesisResult,
+ PlannerOverseerRuntimeSnapshot,
} from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, resolveEffectivePlannerOversightLevel, resolveEffectiveSettings, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
+import { assemblePlannerOverseerRuntimeSnapshot } from "./planner-overseer-runtime-snapshot.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
@@ -1026,6 +1028,21 @@ export class ProjectEngine {
return this.plannerRecoveryController;
}
+ /**
+ * FNXC:PlannerOversight 2026-07-04-00:00:
+ * FN-7531 read-only accessor assembling the transient, serializable
+ * `PlannerOverseerRuntimeSnapshot` for one task from the FN-7511
+ * `PlannerOverseerMonitor`'s latest observation plus the FN-7512/FN-7513
+ * `PlannerRecoveryController`'s attempt/pending-confirmation registries.
+ * Never mutates either subsystem, never throws (any failure degrades to
+ * `null` so a hot request path like `GET /api/tasks` is never put at
+ * risk), and returns `null` when there is no active observation for the
+ * task (nothing to show on the card).
+ */
+ getPlannerOverseerRuntimeSnapshot(taskId: string): PlannerOverseerRuntimeSnapshot | null {
+ return assemblePlannerOverseerRuntimeSnapshot(taskId, this.plannerOverseer, this.plannerRecoveryController);
+ }
+
/**
* FNXC:PlannerOversight 2026-07-04-12:00:
* Concrete FN-7512 handler wiring — ONLY reuses existing mechanisms: