diff --git a/.changeset/fn-7563-overseer-badge-explanation.md b/.changeset/fn-7563-overseer-badge-explanation.md
new file mode 100644
index 0000000000..31b019ecfe
--- /dev/null
+++ b/.changeset/fn-7563-overseer-badge-explanation.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Planner-overseer task badge now shows a readable label and explains what it is waiting on.
+category: fix
+dev: TaskCard badge renders plannerOverseerStateLabel + plannerOverseerBadgeTooltip built from the existing PlannerOverseerRuntimeSnapshot (reason/watchedStage/signal/pendingConfirmation); presentation-only, no engine changes.
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index ce51edb2a7..2b5a47a26f 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,5 +1,12 @@
import type { InReviewStallSignal } from "./in-review-stall.js";
import type { PlannerOverseerRuntimeSnapshot } from "./planner-overseer-state.js";
+// FNXC:PlannerOversight 2026-07-04-18:00: FN-7563 needs `PlannerOverseerState`/
+// `PlannerOverseerRuntimeSnapshot` as TYPE-ONLY imports in the dashboard's pure
+// `plannerOverseerBadge.ts` helper. The dashboard's vite alias for "@fusion/core"
+// resolves only to this file (types.ts), not the package barrel, so the types must
+// be re-exported here (type-only — no engine/runtime code crosses into the browser
+// bundle) rather than requiring dashboard code to import the source module path.
+export type { PlannerOverseerState, 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";
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index aca0b43de0..03d0e9be82 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -28,6 +28,7 @@ import { ProviderIcon } from "./ProviderIcon";
import { PluginSlot } from "./PluginSlot";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { useCoarsePointer } from "../hooks/useCoarsePointer";
+import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "./plannerOverseerBadge";
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
@@ -2774,13 +2775,11 @@ function TaskCardComponent({
{task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && (
- {task.plannerOverseerState.state}
+ {plannerOverseerStateLabel(task.plannerOverseerState.state, t)}
)}
{showStalledReview && stalledReview && (
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index a485b3d23b..01e6b965cd 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -295,6 +295,78 @@ describe("TaskCard", () => {
expect(screen.getByTestId("planner-overseer-state-badge")).toBeInTheDocument();
});
+ // FN-7563: the badge used to print the raw kebab-case state (e.g.
+ // "awaiting-confirmation") with a bare "Planner overseer: awaiting-confirmation"
+ // tooltip. This reproduces the reported in-review symptom and asserts the badge
+ // is now human-readable and self-explanatory.
+ it("explains an in-review awaiting-confirmation badge with a readable label and a reason-bearing tooltip", () => {
+ const task = makeTask({
+ column: "in-review",
+ plannerOverseerState: {
+ state: "awaiting-confirmation",
+ oversightLevel: "autonomous",
+ watchedStage: "executor",
+ signal: "stalled",
+ attemptCount: 2,
+ attemptLimit: 3,
+ pendingConfirmation: true,
+ observedAt: 1700000000000,
+ reason: "Retry limit reached; waiting for an operator decision",
+ },
+ });
+
+ render();
+
+ const badge = screen.getByTestId("planner-overseer-state-badge");
+ expect(badge.textContent).not.toBe("awaiting-confirmation");
+ expect(badge.textContent).toBe("Awaiting confirmation");
+
+ const title = badge.getAttribute("title") ?? "";
+ expect(title).not.toBe("Planner overseer: awaiting-confirmation");
+ expect(title).toContain("Retry limit reached; waiting for an operator decision");
+ expect(title).toMatch(/human decision/i);
+ expect(title).not.toMatch(/undefined/);
+ });
+
+ it("renders readable labels for in-progress watching and recovering overseer states", () => {
+ const watchingTask = makeTask({
+ column: "in-progress",
+ plannerOverseerState: {
+ state: "watching",
+ oversightLevel: "autonomous",
+ watchedStage: "executor",
+ signal: "progressing",
+ attemptCount: 0,
+ attemptLimit: 3,
+ pendingConfirmation: false,
+ observedAt: 1700000000000,
+ },
+ });
+ const { unmount } = render();
+ let badge = screen.getByTestId("planner-overseer-state-badge");
+ expect(badge.textContent).toBe("Overseer watching");
+ expect(badge.getAttribute("title")).not.toMatch(/undefined/);
+ unmount();
+
+ const recoveringTask = makeTask({
+ column: "in-progress",
+ plannerOverseerState: {
+ state: "recovering",
+ oversightLevel: "autonomous",
+ attemptCount: 1,
+ attemptLimit: 3,
+ pendingConfirmation: false,
+ observedAt: 1700000000000,
+ },
+ });
+ render();
+ badge = screen.getByTestId("planner-overseer-state-badge");
+ expect(badge.textContent).toBe("Overseer recovering");
+ const title = badge.getAttribute("title") ?? "";
+ expect(title).not.toMatch(/undefined/);
+ expect(title.length).toBeGreaterThan(0);
+ });
+
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/app/components/__tests__/plannerOverseerBadge.test.ts b/packages/dashboard/app/components/__tests__/plannerOverseerBadge.test.ts
new file mode 100644
index 0000000000..43deee4999
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/plannerOverseerBadge.test.ts
@@ -0,0 +1,103 @@
+import { describe, it, expect } from "vitest";
+import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "../plannerOverseerBadge";
+import type { PlannerOverseerRuntimeSnapshot, PlannerOverseerState } from "@fusion/core";
+
+const NON_IDLE_STATES: PlannerOverseerState[] = [
+ "watching",
+ "steering",
+ "recovering",
+ "awaiting-confirmation",
+];
+
+function makeSnapshot(
+ overrides: Partial = {},
+): Pick {
+ return {
+ state: "watching",
+ reason: undefined,
+ watchedStage: undefined,
+ signal: undefined,
+ pendingConfirmation: undefined,
+ ...overrides,
+ };
+}
+
+describe("plannerOverseerStateLabel", () => {
+ for (const state of NON_IDLE_STATES) {
+ it(`renders a human-readable label for "${state}" (no raw kebab-case)`, () => {
+ const label = plannerOverseerStateLabel(state);
+ expect(label).not.toBe(state);
+ expect(label).not.toMatch(/-/);
+ });
+ }
+
+ it("renders a distinct readable label for awaiting-confirmation", () => {
+ expect(plannerOverseerStateLabel("awaiting-confirmation")).toBe("Awaiting confirmation");
+ });
+});
+
+describe("plannerOverseerBadgeTooltip", () => {
+ it("uses the verbatim reason when present", () => {
+ const tooltip = plannerOverseerBadgeTooltip(
+ makeSnapshot({ state: "steering", reason: "Executor is stalled on step 3" }),
+ );
+ expect(tooltip).toContain("Executor is stalled on step 3");
+ });
+
+ it("falls back gracefully (no literal undefined) when reason is absent", () => {
+ const tooltip = plannerOverseerBadgeTooltip(makeSnapshot({ state: "watching", reason: undefined }));
+ expect(tooltip).not.toMatch(/undefined/);
+ expect(tooltip.length).toBeGreaterThan(0);
+ });
+
+ it("appends watched stage and signal when both present", () => {
+ const tooltip = plannerOverseerBadgeTooltip(
+ makeSnapshot({ state: "recovering", watchedStage: "executor", signal: "progressing" }),
+ );
+ expect(tooltip).toContain("executor");
+ expect(tooltip).toContain("progressing");
+ expect(tooltip).not.toMatch(/undefined/);
+ });
+
+ it("appends watched stage without a signal clause when signal is absent", () => {
+ const tooltip = plannerOverseerBadgeTooltip(makeSnapshot({ state: "steering", watchedStage: "executor" }));
+ expect(tooltip).toContain("executor");
+ expect(tooltip).not.toMatch(/undefined/);
+ });
+
+ it("omits stage/signal wording entirely when both are absent", () => {
+ const tooltip = plannerOverseerBadgeTooltip(makeSnapshot({ state: "steering", reason: "Applying a targeted fix" }));
+ expect(tooltip).not.toMatch(/undefined/);
+ });
+
+ it("explains a pending human decision for awaiting-confirmation regardless of the pendingConfirmation flag value", () => {
+ const tooltip = plannerOverseerBadgeTooltip(
+ makeSnapshot({ state: "awaiting-confirmation", reason: "Retry limit reached", pendingConfirmation: true }),
+ );
+ expect(tooltip).toMatch(/human decision/i);
+ });
+
+ it("explains a pending human decision when pendingConfirmation is true even outside the awaiting-confirmation state", () => {
+ const tooltip = plannerOverseerBadgeTooltip(
+ makeSnapshot({ state: "watching", reason: "Observing", pendingConfirmation: true }),
+ );
+ expect(tooltip).toMatch(/human decision/i);
+ });
+
+ it("does not mention a pending decision when pendingConfirmation is false and state is not awaiting-confirmation", () => {
+ const tooltip = plannerOverseerBadgeTooltip(
+ makeSnapshot({ state: "watching", reason: "Observing", pendingConfirmation: false }),
+ );
+ expect(tooltip).not.toMatch(/human decision/i);
+ });
+
+ it("never emits the raw kebab-case state string when reason is absent (uses the readable label instead)", () => {
+ // Only "awaiting-confirmation" is kebab-case; the others contain no hyphen
+ // so a substring check would be trivially true. Assert the hyphenated raw
+ // form specifically never leaks into the tooltip.
+ const tooltip = plannerOverseerBadgeTooltip(
+ makeSnapshot({ state: "awaiting-confirmation", reason: undefined }),
+ );
+ expect(tooltip).not.toContain("awaiting-confirmation");
+ });
+});
diff --git a/packages/dashboard/app/components/plannerOverseerBadge.ts b/packages/dashboard/app/components/plannerOverseerBadge.ts
new file mode 100644
index 0000000000..7b9ab45dfd
--- /dev/null
+++ b/packages/dashboard/app/components/plannerOverseerBadge.ts
@@ -0,0 +1,104 @@
+import type { TFunction } from "i18next";
+import type { PlannerOverseerState, PlannerOverseerRuntimeSnapshot } from "@fusion/core";
+
+/**
+ * FNXC:PlannerOversight 2026-07-04-18:05:
+ * FN-7563: the `TaskCard` planner-overseer badge previously printed the raw
+ * kebab-case runtime state (e.g. `awaiting-confirmation`) with a bare
+ * `Planner overseer: awaiting-confirmation` tooltip — an operator on an
+ * `in-review` task had no explanation of what was being confirmed or why.
+ * This module is the single source of truth for (a) a human-readable label
+ * per non-idle `PlannerOverseerState`, and (b) an explanatory tooltip string
+ * composed from the transient `PlannerOverseerRuntimeSnapshot` fields the
+ * engine already populates (`reason`, `watchedStage`, `signal`,
+ * `pendingConfirmation`). It is presentation-only: a pure function of the
+ * snapshot it is given. It must NOT re-derive, cache, or mutate overseer
+ * state — the snapshot itself remains owned by FN-7511/FN-7512/FN-7531.
+ *
+ * Deliberately free of React/engine imports (only TYPE imports from
+ * `@fusion/core`) so it stays trivially unit-testable and safe under the
+ * dashboard's `@fusion/core` -> `packages/core/src/types.ts` vite alias.
+ */
+
+/** Human-readable label for a non-idle planner-overseer state. Falls back to the raw state for future/unknown enum values so a new state never silently disappears. */
+export function plannerOverseerStateLabel(
+ state: PlannerOverseerState,
+ t?: TFunction<"app">,
+): string {
+ const translate: (key: string, fallback: string, opts?: Record) => string =
+ t ?? ((_key, fallback) => fallback);
+ switch (state) {
+ case "watching":
+ return translate("tasks.plannerOverseerState.watching", "Overseer watching");
+ case "steering":
+ return translate("tasks.plannerOverseerState.steering", "Overseer steering");
+ case "recovering":
+ return translate("tasks.plannerOverseerState.recovering", "Overseer recovering");
+ case "awaiting-confirmation":
+ return translate("tasks.plannerOverseerState.awaitingConfirmation", "Awaiting confirmation");
+ case "idle":
+ return translate("tasks.plannerOverseerState.idle", "Overseer idle");
+ default:
+ return state;
+ }
+}
+
+/**
+ * Composes the badge tooltip: leads with the human-readable `reason` when
+ * present (verbatim, mirroring the `taskDetail.oversight.explainReason`
+ * wording), appends the watched stage/signal when known, and — for
+ * `awaiting-confirmation` — appends a sentence stating a human decision is
+ * pending. Never emits the literal `undefined`; every optional field has a
+ * graceful fallback clause instead.
+ */
+export function plannerOverseerBadgeTooltip(
+ snapshot: Pick,
+ t?: TFunction<"app">,
+): string {
+ const translate: (key: string, fallback: string, opts?: Record) => string =
+ t ??
+ ((_key, fallback, opts) => {
+ if (!opts) return fallback;
+ return Object.entries(opts).reduce(
+ (acc, [key, value]) => acc.replaceAll(`{{${key}}}`, String(value)),
+ fallback,
+ );
+ });
+
+ const parts: string[] = [];
+
+ if (snapshot.reason) {
+ parts.push(snapshot.reason);
+ } else {
+ parts.push(
+ translate("tasks.plannerOverseerStateTooltip.stateOnly", "Planner overseer: {{label}}", {
+ label: plannerOverseerStateLabel(snapshot.state, t),
+ }),
+ );
+ }
+
+ if (snapshot.watchedStage) {
+ parts.push(
+ snapshot.signal
+ ? translate(
+ "tasks.plannerOverseerStateTooltip.watchingStageSignal",
+ "Watching {{stage}} ({{signal}}).",
+ { stage: snapshot.watchedStage, signal: snapshot.signal },
+ )
+ : translate("tasks.plannerOverseerStateTooltip.watchingStage", "Watching {{stage}}.", {
+ stage: snapshot.watchedStage,
+ }),
+ );
+ }
+
+ if (snapshot.state === "awaiting-confirmation" || snapshot.pendingConfirmation) {
+ parts.push(
+ translate(
+ "tasks.plannerOverseerStateTooltip.pendingConfirmation",
+ "A human decision is required before the overseer can continue.",
+ ),
+ );
+ }
+
+ return parts.join(" ");
+}