diff --git a/docs/keiko-editor/1491-production-readiness-audit.md b/docs/keiko-editor/1491-production-readiness-audit.md
new file mode 100644
index 00000000..19f36e73
--- /dev/null
+++ b/docs/keiko-editor/1491-production-readiness-audit.md
@@ -0,0 +1,72 @@
+# Epic #1491 production readiness audit
+
+Audit timestamp: 2026-06-26T12:13:23Z
+
+Scope:
+
+- Epic #1491: Keiko Agent-Native Editor Foundation and Runtime Governance.
+- Final child issue #1389: Runtime, Git, and command UX with audit evidence.
+- Integrated dependency: Epic #470 governed Git delivery, as merged into the #1491 feature branch by PR #1551.
+
+## Current delivery state
+
+- #470 remains closed as completed and is treated as an integrated dependency, not as an open workstream.
+- PR #1551 is merged into `feat/keiko-agent-native-editor-foundation-and-runtime`.
+- #1389 implementation is present on the #1491 feature branch, but the issue must stay open until this audit PR is merged and closure evidence is posted.
+- #1491 is not yet merged to `dev`; epic closure must wait for a green PR from the feature branch to `dev`.
+
+## Audit findings
+
+The audit did not find a second Git write path, a browser shell escape, or a container execution bypass.
+The editor runtime composes existing governed surfaces:
+
+- Read-only repository state stays on `/api/git/status` and `/api/git/diff`.
+- Git mutations stay on `/api/git-delivery/*` and the governed Git, pull request, and merge windows.
+- Command execution uses server-discovered task ids only.
+- Container execution uses the server-frozen catalog only.
+
+Two production hardening gaps were found and fixed:
+
+1. Command and container run controls could accept rapid duplicate submits before React rerendered the `running` state.
+ The widgets now use the synchronous in-flight ref as the immediate guard, and the run buttons are truly disabled during execution.
+2. Runtime-linked windows could retain stale project paths when an existing window was reconfigured from the Runtime hub.
+ The Runtime hub and command/container windows now synchronize their project path from updated window configuration.
+
+## Verification matrix
+
+Local targeted verification:
+
+- Runtime hub, command, container, and widget registry tests passed.
+- Git delivery evidence check passed.
+- Editor documentation link check passed.
+
+Required full verification before merge:
+
+- `npm run typecheck`
+- `npm run lint`
+- `npm run arch:check`
+- `npm run arch:check:negative`
+- `npm run test:coverage:quality`
+- `npm run build --workspace @oscharko-dev/keiko-ui`
+- `npm run check:git-delivery-evidence`
+- `npm run check:editor-doc-links`
+- `npm audit --audit-level=moderate --workspace @oscharko-dev/keiko-ui`
+
+Additional browser verification should be run only if this audit PR expands beyond the current UI hardening and documentation changes.
+
+## Closure requirements
+
+#1389 can be closed after this audit PR is merged and the issue receives a closure comment linking:
+
+- the merged implementation PR #1551,
+- this audit PR,
+- the local and GitHub check evidence,
+- the retained Git Delivery boundary.
+
+#1491 can be closed only after:
+
+- every migrated child issue is closed with correct status labels,
+- #1389 is closed,
+- this audit evidence is merged,
+- a PR from `feat/keiko-agent-native-editor-foundation-and-runtime` to `dev` is merged green,
+- the epic receives a final closeout comment.
diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx
index e91fd034..1590654d 100644
--- a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx
+++ b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.test.tsx
@@ -2,7 +2,7 @@
// the UI through the same paths a real BFF would. Covers: catalog fetch, run-button POST, failure
// surface, cancel via SSE-captured runId, SSE event display, and an axe a11y smoke.
-import { render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -107,6 +107,17 @@ describe("CommandsWidget", () => {
expect(screen.getByRole("button", { name: /run task/i })).toBeInTheDocument();
});
+ it("reloads the discovered task catalog when the window project path changes", async () => {
+ const view = render();
+ await screen.findByRole("combobox", { name: /task/i });
+ vi.mocked(fetchCommandCatalog).mockClear();
+
+ view.rerender();
+
+ await waitFor(() => expect(fetchCommandCatalog).toHaveBeenCalledWith("/proj-next"));
+ expect(screen.getByLabelText(/project path/i)).toHaveValue("/proj-next");
+ });
+
it("populates the task dropdown from the discovered catalog", async () => {
const user = userEvent.setup();
render();
@@ -139,6 +150,20 @@ describe("CommandsWidget", () => {
});
});
+ it("guards against duplicate submissions before React rerenders the running state", async () => {
+ vi.mocked(createCommandRun).mockImplementation(() => new Promise(() => undefined));
+ render();
+ await screen.findByRole("combobox", { name: /task/i });
+ const runButton = screen.getByRole("button", { name: /run task/i });
+ await waitFor(() => expect(runButton).toHaveAttribute("aria-disabled", "false"));
+
+ fireEvent.click(runButton);
+ fireEvent.click(runButton);
+
+ expect(createCommandRun).toHaveBeenCalledTimes(1);
+ expect(runButton).toBeDisabled();
+ });
+
it("surfaces a failure reason badge for a non-zero exit", async () => {
vi.mocked(createCommandRun).mockResolvedValue({
...RESULT,
diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx
index 8e0268d6..f98fa85a 100644
--- a/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx
+++ b/packages/keiko-ui/src/app/components/desktop/widgets/cards/CommandsWidget.tsx
@@ -90,6 +90,10 @@ function resultSummary(result: CommandTaskRunResult): string {
export function CommandsWidget(props: CommandsWidgetProps): ReactNode {
const [projectInput, setProjectInput] = useState(props.projectPath ?? "");
+ useEffect(() => {
+ setProjectInput(props.projectPath ?? "");
+ }, [props.projectPath]);
+
const [tasks, setTasks] = useState([]);
const [taskId, setTaskId] = useState("");
const [running, setRunning] = useState(false);
@@ -169,7 +173,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode {
const onSubmit = useCallback(
async (e: FormEvent): Promise => {
e.preventDefault();
- if (running || taskId.length === 0) return;
+ if (running || runningRef.current || taskId.length === 0) return;
setError(null);
setResult(null);
setInFlightRunId(null);
@@ -236,7 +240,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode {
className="tm-action"
data-primary="true"
ref={runBtnRef}
- disabled={tasks.length === 0}
+ disabled={running || tasks.length === 0 || taskId.length === 0}
aria-disabled={running || tasks.length === 0 || taskId.length === 0}
>
{running ? "Running…" : "Run task"}
@@ -245,6 +249,7 @@ export function CommandsWidget(props: CommandsWidgetProps): ReactNode {