diff --git a/.changeset/fn-8064-proactive-chat.md b/.changeset/fn-8064-proactive-chat.md
new file mode 100644
index 0000000000..74cb6e39df
--- /dev/null
+++ b/.changeset/fn-8064-proactive-chat.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Task-detail chat now proactively narrates step progress, failures, and review outcomes in real time.
+category: feature
+dev: Adds bounded, secret-redacted engine status narration across step-session, default execution, graph review, and legacy review paths.
diff --git a/docs/architecture.md b/docs/architecture.md
index 8f1f89830b..c2f14cfe96 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -746,6 +746,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
### Observability and reflection
- `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging
+ - FN-8064 writes best-effort proactive `status` rows to the task-detail chat for step start, completion, intentional skips, and failure in both step-session/graph callbacks and store-accepted default `fn_task_update` transitions. Graph and legacy review paths each write one reviewer row: plan/spec APPROVE uses the plan-verified message; other real verdicts use the verdict message; `UNAVAILABLE` and reviewer exceptions write a safe operational "review could not complete" status without claiming a verdict. Failure and review-summary diagnostics are nullish-safe, secret-redacted, absolute-path/stack/newline stripped, and capped at 300 characters, so narration never blocks execution or persists raw command output.
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
- FN-7214: `task:reenter-paused-aborted-workflow-node` records executor re-entry after a typed workflow graph node was interrupted by engine pause/resume. Metadata includes `nodeId`, `fromColumn`, retry `attempt`/`maxAttempts`, `abortProvenance`, whether the task was preserved in `in-review`, and the re-entry `mode`.
- FN-7220: `task:classify-stale-in-review-plan-pause-abort-replay` records executor classification of a stale generic `in-review` plan-node pause/resume replay. Metadata includes `nodeId`, `fromColumn`, `abortProvenance`, whether a stale failure was cleared, `graphResumeRetryCount`, and `mode: "preserved-in-review"`.
diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css
index 9f2efe2d0a..ba41617002 100644
--- a/packages/dashboard/app/components/TaskChatTab.css
+++ b/packages/dashboard/app/components/TaskChatTab.css
@@ -220,6 +220,22 @@ FN-7231 requires task-detail chat text, user, tool, and thinking blocks to keep
overflow-wrap: anywhere;
}
+/*
+FNXC:ProactiveChatStatus 2026-07-16-12:05:
+FN-8064 makes engine status rows a real-time task-progress report. Give complete status
+messages a tokenized warning-toned distinction and reuse the shared status-dot convention;
+the existing compact padding rule keeps the affordance usable below the mobile breakpoint.
+*/
+.task-chat-entry--status {
+ border-color: color-mix(in srgb, var(--color-warning) 45%, var(--border));
+ background: color-mix(in srgb, var(--color-warning) 10%, var(--surface));
+}
+
+.task-chat-entry--status .task-chat-entry-label-row {
+ justify-content: flex-start;
+ gap: var(--space-xs);
+}
+
.task-chat-entry--user {
max-width: min(100%, calc(var(--space-2xl) * 18));
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx
index 225b13adfa..6295b7404a 100644
--- a/packages/dashboard/app/components/TaskChatTab.tsx
+++ b/packages/dashboard/app/components/TaskChatTab.tsx
@@ -405,7 +405,14 @@ function TaskChatText({ entries }: { entries: AgentLogEntry[] }) {
className={`task-chat-entry task-chat-entry--${firstEntry.type.replace("_", "-")}`}
data-testid={`task-chat-entry-${firstEntry.type}`}
>
-
+ {firstEntry.type === "status" && (
+
+
+ Status update
+
+
+ )}
+ {firstEntry.type !== "status" && }
{entries.map((entry) => entry.text).join("")}
diff --git a/packages/engine/src/__tests__/executor-prompt.test.ts b/packages/engine/src/__tests__/executor-prompt.test.ts
index f2edb8e254..f49f689a46 100644
--- a/packages/engine/src/__tests__/executor-prompt.test.ts
+++ b/packages/engine/src/__tests__/executor-prompt.test.ts
@@ -2613,14 +2613,13 @@ describe("fn_task_update bare-call guard (P1 api-contract)", () => {
// createTaskUpdateTool is a private executor method; the bare-call guard runs
// before any store access, so we reach it via the lowest-cost seam: construct
// a TaskExecutor over a mock store and invoke the private method with `as any`.
- function makeTool() {
- const store = createMockStore();
+ function makeTool(store = createMockStore()) {
const executor = new TaskExecutor(store, "/tmp/test");
- return (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }, new Map());
+ return { store, tool: (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }, new Map()) };
}
it("returns isError with a self-describing message when no fields are supplied", async () => {
- const tool = makeTool();
+ const { tool } = makeTool();
const result = await tool.execute("call-1", {});
expect(result.isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -2630,13 +2629,32 @@ describe("fn_task_update bare-call guard (P1 api-contract)", () => {
});
it("does not trigger the guard when a dependencies-only patch is supplied", async () => {
- const tool = makeTool();
+ const { tool } = makeTool();
const result = await tool.execute("call-1", { dependencies: [] });
// Reaches the dependencies path, not the bare-call guard.
expect(result.isError).not.toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).not.toContain("fn_task_update requires at least one of");
});
+
+ it("narrates a store-accepted skipped transition exactly once", async () => {
+ const { store, tool } = makeTool();
+ store.updateStep.mockResolvedValue(createMockTaskDetail({
+ steps: [{ name: "No code change needed", status: "skipped", dependsOn: [] }],
+ }));
+
+ const result = await tool.execute("call-1", { step: 0, status: "skipped" });
+
+ expect(result.isError).not.toBe(true);
+ expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
+ expect(store.appendAgentLog).toHaveBeenCalledWith(
+ "FN-001",
+ "Step 0 was skipped — No code change needed.",
+ "status",
+ undefined,
+ "executor",
+ );
+ });
});
// ---------------------------------------------------------------------------
diff --git a/packages/engine/src/__tests__/proactive-status.test.ts b/packages/engine/src/__tests__/proactive-status.test.ts
new file mode 100644
index 0000000000..bed478c56f
--- /dev/null
+++ b/packages/engine/src/__tests__/proactive-status.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildPlanVerifiedMessage,
+ buildReviewRollbackFailureMessage,
+ buildReviewUnavailableMessage,
+ buildReviewVerdictMessage,
+ buildStepFailureMessage,
+ buildStepSkippedMessage,
+ buildStepStartMessage,
+ buildStepSuccessMessage,
+ emitProactiveStatus,
+ sanitizeFailureReason,
+} from "../proactive-status.js";
+
+describe("proactive status narration", () => {
+ it("sanitizes nullish, stack, path, token and multiline diagnostics", () => {
+ const raw = "Error: token=super-secret\n at run (/Users/a/project/file.ts:1:2)\nBearer abcdefghijklmnopqrstuvwxyz\n" + "output\n".repeat(200);
+ const safe = sanitizeFailureReason(raw);
+ expect(safe).toContain("[REDACTED]");
+ expect(safe).not.toContain("/Users/a");
+ expect(safe).not.toContain("\n");
+ expect(safe.length).toBeLessThanOrEqual(300);
+ expect(sanitizeFailureReason(undefined)).toBe("No failure reason was provided.");
+ expect(sanitizeFailureReason(null)).toBe("No failure reason was provided.");
+ expect(sanitizeFailureReason({ toString: () => { throw new Error("nope"); } })).toBe("No failure reason was provided.");
+ });
+
+ it("elides absolute paths outside the common home-directory roots", () => {
+ const safe = sanitizeFailureReason("Command failed reading /etc/fusion/secrets.env from /srv/fusion/current/config.yaml");
+ expect(safe).toBe("Command failed reading [path] from [path]");
+ expect(safe).not.toContain("/etc/");
+ expect(safe).not.toContain("/srv/");
+ });
+
+ it("builds complete status messages and safely reports unavailable reviews", () => {
+ expect(buildStepStartMessage(2, "Ship it")).toBe("Starting Step 2: Ship it");
+ expect(buildStepSuccessMessage(2, "Ship it")).toBe("Step 2 finished — Ship it.");
+ expect(buildStepSkippedMessage(2, "No code change needed")).toBe("Step 2 was skipped — No code change needed.");
+ expect(buildStepSkippedMessage(2, "Step 2")).toBe("Step 2 was skipped.");
+ expect(buildStepFailureMessage(2, "Ship it", sanitizeFailureReason(undefined))).toContain("No failure reason");
+ expect(buildPlanVerifiedMessage()).toBe("The plan was written and verified.");
+ expect(buildReviewVerdictMessage("UNAVAILABLE", "nope")).toBeNull();
+ expect(buildReviewUnavailableMessage("token=secret /etc/fusion/secrets.env")).toBe("Review could not complete: token=[REDACTED] [path]");
+ expect(buildReviewVerdictMessage("RETHINK", "token=secret /home/private/file")).toMatch(/\[REDACTED\]/);
+ expect(buildReviewRollbackFailureMessage(sanitizeFailureReason("/Users/me/private.ts"))).toBe("Review could not roll the step back: [path]");
+ });
+
+ it("persists status narration best-effort", async () => {
+ const appendAgentLog = vi.fn().mockResolvedValue(undefined);
+ await emitProactiveStatus({ appendAgentLog } as never, "FN-1", "Finished", "executor", "safe detail");
+ expect(appendAgentLog).toHaveBeenCalledWith("FN-1", "Finished", "status", "safe detail", "executor");
+ await expect(emitProactiveStatus({ appendAgentLog: vi.fn().mockRejectedValue(new Error("down")) } as never, "FN-1", "Finished", "reviewer")).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts
index 371557ffbf..9b3a8e07d0 100644
--- a/packages/engine/src/executor.ts
+++ b/packages/engine/src/executor.ts
@@ -62,6 +62,18 @@ import { createWorkflowRuntimePrimitiveProvider } from "./workflow-runtime-primi
import { WorkflowCustomNodeExecutionService } from "./workflow-custom-node-execution.js";
import { WorkflowReviewService } from "./workflow-review-service.js";
import { WorkflowPlanningService } from "./workflow-planning-service.js";
+import {
+ buildPlanVerifiedMessage,
+ buildReviewUnavailableMessage,
+ buildReviewRollbackFailureMessage,
+ buildReviewVerdictMessage,
+ buildStepFailureMessage,
+ buildStepSkippedMessage,
+ buildStepStartMessage,
+ buildStepSuccessMessage,
+ emitProactiveStatus,
+ sanitizeFailureReason,
+} from "./proactive-status.js";
import {
ApprovalRequestStore,
buildExecutionMemoryInstructions,
@@ -5035,7 +5047,14 @@ export class TaskExecutor {
* (the read path threads the same instanceId through `runGraphTaskStep`). */
private graphStepActiveContext = new Map();
- /** Composite key for {@link graphStepActiveContext}: per-instance, not per-task. */
+ /**
+ * FNXC:ProactiveChatStatus 2026-07-16-12:30:
+ * Keep a graph RETHINK summary until its rework reset succeeds. The status wording says the step
+ * was rolled back, so it must not reach the task chat before resetStepToBaseline completes.
+ */
+ private graphRethinkNarrations = new Map();
+
+ /** Composite key for graph-owned per-instance state: never share parallel foreach instances. */
private graphActiveContextKey(taskId: string, instanceId: string): string {
return `${taskId}:${instanceId}`;
}
@@ -5437,6 +5456,9 @@ export class TaskExecutor {
for (const key of this.graphStepActiveContext.keys()) {
if (key.startsWith(ctxPrefix)) this.graphStepActiveContext.delete(key);
}
+ for (const key of this.graphRethinkNarrations.keys()) {
+ if (key.startsWith(ctxPrefix)) this.graphRethinkNarrations.delete(key);
+ }
}
}
@@ -5911,30 +5933,50 @@ export class TaskExecutor {
}
}
const liveSteps = await this.store.getTask(taskId).then((t) => t.steps).catch(() => []);
- await resetStepToBaseline(
- {
- store: this.store,
- worktreePath,
- // No single session ref for graph-owned step-sessions — rewind is skipped
- // when checkpointId resolves but no session is current (KTD-2 partial path).
- sessionRef: { current: null },
- reviewType: "code",
- // Branch-scoped RETHINK under worktree isolation makes the guard structural
- // (the reset can only touch the instance's own branch); shared isolation
- // keeps the defensive ancestry guard (KTD-2/KTD-11).
- blastRadiusGuard: branchScoped
- ? undefined
- : makeAncestryBlastRadiusGuard({
- worktreePath,
- task: { id: taskId, steps: liveSteps },
- stepIndex: active.stepIndex,
- }),
- },
- { id: taskId, steps: liveSteps },
- active.stepIndex,
- active.baselineSha,
- active.checkpointId,
- );
+ const narrationKey = this.graphActiveContextKey(taskId, active.instanceId);
+ const reviewSummary = this.graphRethinkNarrations.get(narrationKey);
+ try {
+ await resetStepToBaseline(
+ {
+ store: this.store,
+ worktreePath,
+ // No single session ref for graph-owned step-sessions — rewind is skipped
+ // when checkpointId resolves but no session is current (KTD-2 partial path).
+ sessionRef: { current: null },
+ reviewType: "code",
+ // Branch-scoped RETHINK under worktree isolation makes the guard structural
+ // (the reset can only touch the instance's own branch); shared isolation
+ // keeps the defensive ancestry guard (KTD-2/KTD-11).
+ blastRadiusGuard: branchScoped
+ ? undefined
+ : makeAncestryBlastRadiusGuard({
+ worktreePath,
+ task: { id: taskId, steps: liveSteps },
+ stepIndex: active.stepIndex,
+ }),
+ },
+ { id: taskId, steps: liveSteps },
+ active.stepIndex,
+ active.baselineSha,
+ active.checkpointId,
+ );
+ if (reviewSummary !== undefined) {
+ const narration = buildReviewVerdictMessage("RETHINK", reviewSummary);
+ void emitProactiveStatus(this.store, taskId, narration, "reviewer", sanitizeFailureReason(reviewSummary));
+ }
+ } catch (error) {
+ const safeReason = sanitizeFailureReason(error);
+ void emitProactiveStatus(
+ this.store,
+ taskId,
+ buildReviewRollbackFailureMessage(safeReason),
+ "reviewer",
+ safeReason,
+ );
+ throw error;
+ } finally {
+ this.graphRethinkNarrations.delete(narrationKey);
+ }
}
/**
@@ -7063,6 +7105,8 @@ export class TaskExecutor {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
reviewerLog.error(`${seamTask.id}: step-review failed: ${message}`);
+ const narration = buildReviewUnavailableMessage(err);
+ void emitProactiveStatus(this.store, seamTask.id, narration, "reviewer", sanitizeFailureReason(err));
return { verdict: "UNAVAILABLE", review: `reviewer error: ${message}` };
}
@@ -7071,6 +7115,17 @@ export class TaskExecutor {
`${config.type} step-review Step ${stepIndex}: ${review.verdict}${config.advisory ? " (advisory)" : ""}`,
review.summary,
);
+ const narration = config.type === "plan" && review.verdict === "APPROVE"
+ ? buildPlanVerifiedMessage()
+ : review.verdict === "UNAVAILABLE"
+ ? buildReviewUnavailableMessage(review.summary)
+ : buildReviewVerdictMessage(review.verdict, review.summary);
+ if (review.verdict === "RETHINK") {
+ // RETHINK's rollback claim is emitted by applyGraphRethinkReset only after reset succeeds.
+ this.graphRethinkNarrations.set(this.graphActiveContextKey(seamTask.id, active.instanceId), review.summary);
+ } else {
+ void emitProactiveStatus(this.store, seamTask.id, narration, "reviewer", narration ? sanitizeFailureReason(review.summary) : undefined);
+ }
// Single-writer rule (KTD-4): advisory (split-branch) reviews never write
// the projection — they are fan-out checks that cannot clobber the
@@ -10465,6 +10520,7 @@ export class TaskExecutor {
this.store.updateStep(task.id, stepIndex, "in-progress", stepProjectionOptions).catch((err) => {
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`);
});
+ void emitProactiveStatus(this.store, task.id, buildStepStartMessage(stepIndex, detail.steps[stepIndex]?.name), "executor");
} catch (err) {
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`);
}
@@ -10475,6 +10531,12 @@ export class TaskExecutor {
this.store.updateStep(task.id, stepIndex, result.success ? "done" : "skipped", stepProjectionOptions).catch((err) => {
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`);
});
+ const stepName = detail.steps[stepIndex]?.name;
+ const safeReason = result.success ? undefined : sanitizeFailureReason(result.error);
+ const message = result.success
+ ? buildStepSuccessMessage(stepIndex, stepName)
+ : buildStepFailureMessage(stepIndex, stepName, safeReason!);
+ void emitProactiveStatus(this.store, task.id, message, "executor", safeReason);
} catch (err) {
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`);
}
@@ -13211,6 +13273,18 @@ export class TaskExecutor {
};
}
+ // FNXC:ProactiveChatStatus 2026-07-16-12:45:
+ // Only store-accepted transitions narrate progress. A skipped step is terminal work too,
+ // so it needs its own status row rather than silently looking like an ignored no-op.
+ const narration = status === "in-progress"
+ ? buildStepStartMessage(stepIndex, stepInfo.name)
+ : status === "done"
+ ? buildStepSuccessMessage(stepIndex, stepInfo.name)
+ : status === "skipped"
+ ? buildStepSkippedMessage(stepIndex, stepInfo.name)
+ : null;
+ void emitProactiveStatus(this.store, taskId, narration, "executor");
+
return {
content: [{
type: "text" as const,
@@ -14367,6 +14441,7 @@ export class TaskExecutor {
);
}
+ let rollbackFailureNarrated = false;
try {
// Merge per-task effective workflow settings (U3, KTD-3) so the
// validator model-lane reads below pick up workflow values; this tool
@@ -14457,6 +14532,15 @@ export class TaskExecutor {
result.summary,
);
reviewerLog.log(`${taskId}: Step ${step} ${reviewType} → ${result.verdict}`);
+ const narration = (reviewType === "plan" || reviewType === ("spec" as typeof reviewType)) && result.verdict === "APPROVE"
+ ? buildPlanVerifiedMessage()
+ : result.verdict === "UNAVAILABLE"
+ ? buildReviewUnavailableMessage(result.summary)
+ : buildReviewVerdictMessage(result.verdict, result.summary);
+ // A RETHINK message asserts rollback completion, so wait for resetStepToBaseline below.
+ if (result.verdict !== "RETHINK") {
+ void emitProactiveStatus(store, taskId, narration, "reviewer", narration ? sanitizeFailureReason(result.summary) : undefined);
+ }
stuckDetector?.recordProgress(taskId);
// Track code review verdicts for enforcement. Plan reviews remain
@@ -14515,19 +14599,33 @@ export class TaskExecutor {
// agent-supplied baseline (KTD-2 — the guard is for graph-owned
// shared-isolation resets), so behavior stays byte-identical.
const checkpointId = stepCheckpoints.get(stepIndex);
- await resetStepToBaseline(
- {
+ try {
+ await resetStepToBaseline(
+ {
+ store,
+ worktreePath,
+ sessionRef,
+ reviewType: reviewType === "plan" ? "plan" : "code",
+ summary: result.summary,
+ },
+ { id: taskId, steps: taskSteps },
+ stepIndex,
+ reviewType === "code" ? baseline : undefined,
+ checkpointId,
+ );
+ } catch (error) {
+ const safeReason = sanitizeFailureReason(error);
+ void emitProactiveStatus(
store,
- worktreePath,
- sessionRef,
- reviewType: reviewType === "plan" ? "plan" : "code",
- summary: result.summary,
- },
- { id: taskId, steps: taskSteps },
- stepIndex,
- reviewType === "code" ? baseline : undefined,
- checkpointId,
- );
+ taskId,
+ buildReviewRollbackFailureMessage(safeReason),
+ "reviewer",
+ safeReason,
+ );
+ rollbackFailureNarrated = true;
+ throw error;
+ }
+ void emitProactiveStatus(store, taskId, narration, "reviewer", sanitizeFailureReason(result.summary));
if (reviewType === "plan") {
text = `RETHINK\n\nYour plan was rejected. Here is why:\n\n${result.review}\n\nTake a different approach to planning this step. Do NOT repeat the rejected strategy.`;
@@ -14573,6 +14671,10 @@ export class TaskExecutor {
const errorMessage = err instanceof Error ? err.message : String(err);
reviewerLog.error(`${taskId}: review failed: ${errorMessage}`);
await store.logEntry(taskId, `${reviewType} review failed: ${errorMessage}`);
+ if (!rollbackFailureNarrated) {
+ const narration = buildReviewUnavailableMessage(err);
+ void emitProactiveStatus(store, taskId, narration, "reviewer", sanitizeFailureReason(err));
+ }
/*
FNXC:ReviewerProviderErrors 2026-07-15-11:20:
diff --git a/packages/engine/src/proactive-status.ts b/packages/engine/src/proactive-status.ts
new file mode 100644
index 0000000000..bce9473c44
--- /dev/null
+++ b/packages/engine/src/proactive-status.ts
@@ -0,0 +1,117 @@
+import { redactSecrets, type AgentRole, type TaskStore } from "@fusion/core";
+import type { ReviewVerdict } from "./reviewer.js";
+
+const GENERIC_FAILURE_REASON = "No failure reason was provided.";
+const MAX_REASON_LENGTH = 300;
+
+/**
+ * FNXC:ProactiveChatStatus 2026-07-16-12:00:
+ * Issue #2153 requires the task-detail chat to narrate step start, success, intentional skips, safe
+ * failure reasons, and review/rollback outcomes as standalone status rows, making it a real-time progress report.
+ * Both step-session callbacks and the default fn_task_update seam use this shared wording. Failure
+ * and review-summary diagnostics are nullish-safe, redacted, path/stack stripped, single-line, and
+ * capped at 300 characters; UNAVAILABLE is not a verdict but is narrated as a safe operational failure.
+ */
+export function sanitizeFailureReason(rawError: unknown): string {
+ let candidate: string;
+ try {
+ if (rawError === null || rawError === undefined) return GENERIC_FAILURE_REASON;
+ if (rawError instanceof Error) candidate = rawError.message || String(rawError);
+ else if (typeof rawError === "string") candidate = rawError;
+ else candidate = String(rawError);
+ } catch {
+ return GENERIC_FAILURE_REASON;
+ }
+ if (!candidate.trim()) return GENERIC_FAILURE_REASON;
+
+ let sanitized = redactSecrets(candidate)
+ // Remove conventional JavaScript stack frames before general whitespace collapse.
+ .replace(/\s*at\s+[^\n]+\([^\n]*:\d+:\d+\)/g, " ")
+ .replace(/\s*at\s+[^\n]+:\d+:\d+/g, " ")
+ // Any absolute filesystem path is environment-sensitive, including system/service roots.
+ .replace(/(?:[A-Za-z]:\\|\/)[^\s:),]+/g, "[path]")
+ .replace(/[\r\n\t]+/g, " ")
+ .replace(/\s{2,}/g, " ")
+ .trim();
+
+ if (!sanitized) return GENERIC_FAILURE_REASON;
+ if (sanitized.length > MAX_REASON_LENGTH) sanitized = `${sanitized.slice(0, MAX_REASON_LENGTH - 1).trimEnd()}…`;
+ return sanitized;
+}
+
+function stepLabel(stepIndex: number, stepName?: string): string {
+ const fallback = `Step ${stepIndex}`;
+ return stepName?.trim() || fallback;
+}
+
+export function buildStepStartMessage(stepIndex: number, stepName?: string): string {
+ return `Starting Step ${stepIndex}: ${stepLabel(stepIndex, stepName)}`;
+}
+
+export function buildStepSuccessMessage(stepIndex: number, stepName?: string): string {
+ const name = stepName?.trim();
+ return name && name !== `Step ${stepIndex}` ? `Step ${stepIndex} finished — ${name}.` : `Step ${stepIndex} finished.`;
+}
+
+/**
+ * FNXC:ProactiveChatStatus 2026-07-16-12:45:
+ * Store-accepted skipped transitions are terminal task outcomes too. Narrate them distinctly so
+ * preflight and intentional no-op flows remain visible without fabricating a failure reason.
+ */
+export function buildStepSkippedMessage(stepIndex: number, stepName?: string): string {
+ const name = stepName?.trim();
+ return name && name !== `Step ${stepIndex}` ? `Step ${stepIndex} was skipped — ${name}.` : `Step ${stepIndex} was skipped.`;
+}
+
+export function buildStepFailureMessage(stepIndex: number, stepName: string | undefined, safeReason: string): string {
+ const prefix = stepName?.trim() && stepName.trim() !== `Step ${stepIndex}` ? `Step ${stepIndex} (${stepName.trim()})` : `Step ${stepIndex}`;
+ return `${prefix} did not complete: ${safeReason}`;
+}
+
+export function buildReviewVerdictMessage(verdict: ReviewVerdict, summary: unknown): string | null {
+ const safeSummary = sanitizeFailureReason(summary);
+ switch (verdict) {
+ case "APPROVE": return `Review passed — ${safeSummary}`;
+ case "REVISE": return `Review requested changes: ${safeSummary}`;
+ case "RETHINK": return `Review rolled the step back to rethink the approach: ${safeSummary}`;
+ case "UNAVAILABLE": return null;
+ }
+}
+
+export function buildPlanVerifiedMessage(): string {
+ return "The plan was written and verified.";
+}
+
+/**
+ * FNXC:ProactiveChatStatus 2026-07-16-13:10:
+ * A reviewer outage is progress-relevant even though UNAVAILABLE is not a verdict. Report it as
+ * an operational status, with the same bounded diagnostic policy as failures, so the chat tells
+ * the operator why review did not complete without misrepresenting it as APPROVE/REVISE/RETHINK.
+ */
+export function buildReviewUnavailableMessage(reason: unknown): string {
+ return `Review could not complete: ${sanitizeFailureReason(reason)}`;
+}
+
+/**
+ * FNXC:ProactiveChatStatus 2026-07-16-12:30:
+ * A RETHINK narration may claim that work was rolled back only after the baseline reset succeeds.
+ * Reset failures instead need a safe status row that tells the operator the rollback did not finish.
+ */
+export function buildReviewRollbackFailureMessage(safeReason: string): string {
+ return `Review could not roll the step back: ${safeReason}`;
+}
+
+export async function emitProactiveStatus(
+ store: Pick,
+ taskId: string,
+ message: string | null | undefined,
+ role: AgentRole,
+ detail?: string,
+): Promise {
+ if (!message) return;
+ try {
+ await store.appendAgentLog(taskId, message, "status", detail, role);
+ } catch {
+ // Proactive narration is strictly observational and must not affect execution.
+ }
+}