-
Watch for
+
{t("automations:watchForLabel")}
{trigger ? (
!next && onClose()}>
- Automation created
+ {t("automations:automationCreated")}
- Configure your external system to POST to this URL with the secret in the{" "}
- X-Webhook-Secret header. You can come
- back to this automation any time to copy these values again.
+
+
+
-
-
+
+
- Done
+ {t("automations:done")}
diff --git a/apps/web/components/task-create-dialog-multi-repo-guard.ts b/apps/web/components/task-create-dialog-multi-repo-guard.ts
index 64bd8b9611..b122520db2 100644
--- a/apps/web/components/task-create-dialog-multi-repo-guard.ts
+++ b/apps/web/components/task-create-dialog-multi-repo-guard.ts
@@ -1,17 +1,27 @@
+import { t } from "@/lib/i18n";
+
const MULTI_REPO_SUPPORTED_EXECUTOR_TYPES = new Set(["worktree", "local_docker", "ssh", "sprites"]);
/**
* Returns the selector explanation for runtimes that cannot launch a task
* with sibling repositories. This is deliberately a pure capability check:
* changing repository rows must never replace a user's supported executor.
+ *
+ * The three messages are copy: they render as the `title` on a disabled option
+ * in the automation editor's Executor Profile picker and in the task-create
+ * dialog. This module is shared by both surfaces and belongs to neither, so the
+ * Automations migration translated the strings its own picker renders without
+ * adding the file to `i18nGuardFiles` — allowlisting it would claim a
+ * completeness the task-create dialog has not had. Resolved at call time rather
+ * than module scope, so a locale switch is picked up.
*/
export function getMultiRepoExecutorDisabledReason(executorType: string | null | undefined) {
if (MULTI_REPO_SUPPORTED_EXECUTOR_TYPES.has(executorType ?? "")) return null;
if (executorType === "local" || executorType === "local_pc") {
- return "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.";
+ return t("common:multiRepoUnavailableLocal");
}
if (executorType === "remote_docker") {
- return "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances.";
+ return t("common:multiRepoUnavailableRemoteDocker");
}
- return "Multi-repo tasks are not supported by this executor.";
+ return t("common:multiRepoUnsupportedExecutor");
}
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index 3296beb402..60be0057e7 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -694,7 +694,13 @@ export const i18nGuardFiles = [
// union, and the `configure_session` rule `operation`s. Workflow and step
// NAMES are user data throughout and are always interpolated as values,
// never built into a message by concatenation.
- "app/settings/workspace/[id]/workflows/**/*.{ts,tsx}",
+ // `[[]id[]]`, not `[id]`: glob brackets are a character class, so the
+ // unescaped form matches nothing and the entry silently guards no files.
+ // This one and the two under Workspaces were escaped by #2247, which hit the
+ // same trap on its own dynamic route. `pnpm lint` is unchanged by the repair,
+ // so these directories were always clean — just unguarded. See docs/i18n.md
+ // ("An entry can be born dead").
+ "app/settings/workspace/[[]id[]]/workflows/**/*.{ts,tsx}",
"app/settings/workspace/use-workflow-creation.ts",
"app/settings/workspace/workspace-workflows-client.tsx",
"app/settings/workspace/workspace-workflows-dialogs.tsx",
@@ -933,8 +939,8 @@ export const i18nGuardFiles = [
// workspace's own name, so there is no translatable type-to-confirm token
// here to break; the name travels into the sentence as an interpolated value.
"app/settings/workspace/page.tsx",
- "app/settings/workspace/[id]/page.tsx",
- "app/settings/workspace/[id]/repositories/page.tsx",
+ "app/settings/workspace/[[]id[]]/page.tsx",
+ "app/settings/workspace/[[]id[]]/repositories/page.tsx",
"app/settings/workspace/workspace-edit-client.tsx",
"app/settings/workspace/workspace-not-found-card.tsx",
"app/settings/workspace/workspace-repositories-client.tsx",
@@ -1128,4 +1134,69 @@ export const i18nGuardFiles = [
// chrome — and `configuration-chat-toggle.tsx`, which `ConfigChatSetup`
// renders — is still English and belongs to the quick-chat migration.
"components/config-chat/*.{ts,tsx}",
+
+ // Automations — `components/automations/**` (incl. `trigger-configs/`) and
+ // the three `app/settings/**/automations/**` routes. Copy lives in a new
+ // `automations` namespace, except three strings reused verbatim from
+ // `common`: `common:automations` (the word SEGMENT_LABEL_KEYS already names
+ // as this settings route's owner), `common:cancel`, `common:status` and
+ // `common:requestFailed` — each diffed byte-for-byte against what the live
+ // surface rendered before reusing it. Inside the namespace the reverse call
+ // was made for "Webhook": the badge, the card summary and the picker's group
+ // heading get one key each rather than sharing one, because they are three
+ // different grammatical contexts and a language that inflects them
+ // differently would otherwise have nowhere to say so.
+ //
+ // Most of this directory's copy was invisible to the guard. `pnpm run
+ // lint:i18n` reported 116; the migration converted 156. The extra 40 were in
+ // shapes the rule does not inspect: SCREAMING_CASE config tables
+ // (`TRIGGER_LABELS`, `STATUS_BADGE`, `EXECUTION_MODE_ITEMS`, `PRESETS` in two
+ // files, `CRON_PRESETS`/`SIMPLE_SUMMARIES`/`TRIGGER_INFO`, `CI_CONCLUSIONS`,
+ // `PR_EVENTS`, `CATEGORY_META`), plain functions returning copy
+ // (`getTriggerSummary`, `getWorkflowStepHelpText`, `inputValueFor`), toasts
+ // and a save-contributor `invalidReason` in `automation-editor.tsx`, an
+ // `sr-only` "required" the guard's single-lowercase-token pattern skipped,
+ // and `automation-repository-selection.ts` — a `.ts` module with no JSX that
+ // reported 0 and held a picker option label.
+ //
+ // Persisted/protocol strings deliberately left in English, because a
+ // translated one breaks a stored automation with nothing failing until a
+ // locale switch: `DEFAULT_PROMPT` in `automation-editor.tsx` (persisted, sent
+ // to the agent verbatim, and compared with `===`), the `"New Automation"` and
+ // `"New Repository"` name fallbacks (both written to a record), every cron
+ // expression and shorthand, every TriggerType / RunStatus / ExecutionMode /
+ // PR-event / CI-conclusion id, `X-Webhook-Secret`, the `{{webhook.*}}`
+ // placeholder paths, and the example values in `placeholder` attributes
+ // (branch names, CI check names, GitHub usernames, a sample JSON body).
+ // `run.trigger_type` renders raw in the runs table for the same reason.
+ // Where syntax appears inside a sentence it travels as an interpolation
+ // value, so the pseudo-locale cannot accent it into something that no longer
+ // parses.
+ //
+ // NOT listed, and deliberately so:
+ // - `components/task-create-dialog-multi-repo-guard.ts`. Its three disabled
+ // -executor explanations render as a `title` in this editor's Executor
+ // Profile picker, so they are migrated here (into `common:multiRepo*`),
+ // but the module is shared with the un-migrated task-create dialog and
+ // belongs to neither. Allowlisting it would claim a completeness that
+ // dialog has not had; whichever migration takes the dialog adds it.
+ // - `formatRelativeTime` in `lib/utils.ts`, which the runs table and the
+ // automations table both render. It is a repo-wide formatter with 21
+ // consumers and `lib/i18n/formats.ts` already holds its intended
+ // replacement (`formatRelative`); swapping it is a cross-cutting change,
+ // not part of a copy migration.
+ //
+ // NOTE the `[[]id[]]` escaping on the dynamic route. Written as `[id]`, the
+ // brackets are a glob CHARACTER CLASS matching a single `i` or `d`, so the
+ // pattern matches nothing and the route is silently unguarded — verified by
+ // putting a hardcoded literal in `automations/new/page.tsx` and watching
+ // `pnpm lint` report 0 errors. `check-guard-allowlist.mjs` cannot catch this:
+ // it only inspects entries that LEFT the array, so an entry that never
+ // matched anything looks identical to a healthy one. `fs.globSync` on the
+ // escaped form is what proves an entry is live.
+ "components/automations/*.{ts,tsx}",
+ "components/automations/trigger-configs/*.tsx",
+ "app/settings/automations/page.tsx",
+ "app/settings/workspace/[[]id[]]/automations/**/*.tsx",
+ "hooks/domains/settings/use-automation-runs.ts",
];
diff --git a/apps/web/hooks/domains/settings/use-automation-runs.ts b/apps/web/hooks/domains/settings/use-automation-runs.ts
index bcd72da7e5..a2e5a259e1 100644
--- a/apps/web/hooks/domains/settings/use-automation-runs.ts
+++ b/apps/web/hooks/domains/settings/use-automation-runs.ts
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useCallback } from "react";
+import { t } from "@/lib/i18n";
import { toast } from "@/lib/toast/sonner";
import {
listAutomationRuns,
@@ -75,7 +76,9 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
removeRun(automationId, runId);
})
.catch((err: unknown) => {
- const msg = err instanceof Error ? err.message : "Failed to delete run";
+ // An Error message here is an API diagnostic and stays English; the
+ // fallback for a non-Error rejection is copy.
+ const msg = err instanceof Error ? err.message : t("automations:failedToDeleteRun");
toast.error(msg);
// revert on failure
listAutomationRuns(automationId)
@@ -88,7 +91,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
if (deletedRun) {
restoreRun(automationId, deletedRun);
}
- toast.error("Could not refresh runs — restored from local cache");
+ toast.error(t("automations:couldNotRefreshRuns"));
});
});
},
@@ -108,7 +111,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
clearRuns(automationId);
})
.catch((err: unknown) => {
- const msg = err instanceof Error ? err.message : "Failed to delete runs";
+ const msg = err instanceof Error ? err.message : t("automations:failedToDeleteRuns");
toast.error(msg);
// revert on failure
listAutomationRuns(automationId)
@@ -118,7 +121,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
// would stay permanently empty even though delete-all never
// succeeded server-side. Fall back to the pre-clear snapshot.
setRuns(automationId, previousRuns);
- toast.error("Could not refresh runs — restored from local cache");
+ toast.error(t("automations:couldNotRefreshRuns"));
});
});
}, [automationId, clearRuns, setRuns, storeApi, workspaceId]);
diff --git a/apps/web/src/locales/en/automations.json b/apps/web/src/locales/en/automations.json
new file mode 100644
index 0000000000..024864397e
--- /dev/null
+++ b/apps/web/src/locales/en/automations.json
@@ -0,0 +1,155 @@
+{
+ "addCondition": "Add Condition",
+ "addRepository": "Add repository",
+ "agentProfileLabel": "Agent Profile",
+ "agentProfilePlaceholder": "Select agent",
+ "alreadyAdded": "Already added",
+ "authorsLabel": "Authors (comma-separated)",
+ "automationCreated": "Automation created",
+ "availablePlaceholders": "Available placeholders",
+ "availablePlaceholdersColon": "Available placeholders:",
+ "baseBranchesLabel": "Base branches (comma-separated)",
+ "branchPatternsLabel": "Branch patterns (comma-separated, supports globs)",
+ "checkNamesLabel": "Check names (comma-separated, optional)",
+ "ciConclusionCancelled": "Cancelled",
+ "ciConclusionFailure": "Failure",
+ "ciConclusionSuccess": "Success",
+ "ciConclusionsLabel": "Conclusions",
+ "columnEnabled": "Enabled",
+ "columnLastTriggered": "Last Triggered",
+ "columnMode": "Mode",
+ "columnName": "Name",
+ "columnTriggers": "Triggers",
+ "comingSoonSuffix": " (Coming soon)",
+ "completeRequiredFields": "Complete the required automation fields before saving.",
+ "configurationLabel": "Configuration",
+ "couldNotRefreshRuns": "Could not refresh runs — restored from local cache",
+ "createWorkspace": "Create workspace",
+ "cronEveryDay": "Every day",
+ "cronEveryHour": "Every hour",
+ "cronEveryWeek": "Every week",
+ "cronExpressionLabel": "Cron expression",
+ "cronHelp": "Standard 5-field cron or shortcuts ({{shortcuts}})",
+ "customIntervalLabel": "Custom interval",
+ "delete": "Delete",
+ "deleteAll": "Delete all",
+ "deleteAllRuns": "Delete all runs",
+ "deleteAllRunsDescription": "This will permanently remove all run records for this automation — including any not currently loaded — and their associated tasks. This cannot be undone.",
+ "deleteAllRunsTitle": "Delete all runs?",
+ "deleteRun": "Delete run",
+ "done": "Done",
+ "enabledLabel": "Enabled",
+ "eventsLabel": "Events",
+ "excludeDraftPrs": "Exclude draft PRs",
+ "executionModeLabel": "Execution Mode",
+ "executionModePlaceholder": "Select mode",
+ "executionModeRun": "Run — fire-and-forget, hidden from kanban",
+ "executionModeRunShort": "Run",
+ "executionModeTask": "Task — creates a tracked kanban task",
+ "executionModeTaskShort": "Task",
+ "executorProfileLabel": "Executor Profile",
+ "executorProfilePlaceholder": "Select executor",
+ "failedToDeleteAutomation": "Failed to delete automation",
+ "failedToDeleteRun": "Failed to delete run",
+ "failedToDeleteRuns": "Failed to delete runs",
+ "failedToLoadWebhookSecret": "Failed to load webhook secret: {{error}}",
+ "failedToSaveAutomation": "Failed to save automation: {{error}}",
+ "instructionsLabel": "Instructions",
+ "invalidExpression": "Invalid expression. Use {{every}} with a duration, a shorthand, or a 5-field cron.",
+ "listDescription": "Create rules that automatically trigger agent tasks.",
+ "loading": "Loading...",
+ "loadingAutomations": "Loading automations...",
+ "maxConcurrentRuns": "Max concurrent runs",
+ "nameHelp": "Enter an automation name to enable saving.",
+ "nameLabel": "Name",
+ "namePlaceholder": "Automation name",
+ "neverTriggered": "Never",
+ "newAutomation": "New Automation",
+ "noAutomationsYet": "No automations yet",
+ "noRepositoriesHelp": "With no repositories selected, this automation runs against the workspace's first repository.",
+ "noRunsYet": "No runs yet",
+ "noTriggers": "None",
+ "noWorkspacesDescription": "Create a workspace first, then schedule automations inside it.",
+ "noWorkspacesYet": "No workspaces yet",
+ "orUseWebhookInstead": "Or use a webhook instead",
+ "pickWorkspace": "Pick a workspace to manage its automations.",
+ "prEventOpened": "Opened",
+ "prEventOpenedTooltip": "Fires once when a new open PR is detected. Each PR number is deduped — the same PR won't trigger again.",
+ "prTriggerRepositoryHelp": "PR triggers always use the PR's own repository.",
+ "presetDaily": "Daily",
+ "presetHours_one": "{{count}} hour",
+ "presetHours_other": "{{count}} hours",
+ "presetMinutes_one": "{{count}} min",
+ "presetMinutes_other": "{{count}} min",
+ "presetWeekly": "Weekly",
+ "pushTriggerHelp": "Triggers when code is pushed to matching branches",
+ "recentRuns_one": "Recent Runs ({{count}})",
+ "recentRuns_other": "Recent Runs ({{count}})",
+ "refresh": "Refresh",
+ "removeRepository": "Remove repository",
+ "repositoryLabel": "Repository",
+ "repositoryNone": "None — no repository",
+ "repositoryPlaceholderAuto": "Auto",
+ "required": "required",
+ "runStatusArchived": "Archived",
+ "runStatusCancelled": "Cancelled",
+ "runStatusFailed": "Failed",
+ "runStatusRunning": "Running",
+ "runStatusSkipped": "Skipped",
+ "runStatusSucceeded": "Succeeded",
+ "runStatusTriggered": "Triggered",
+ "runsColumnError": "Error",
+ "runsColumnTask": "Task",
+ "runsColumnTime": "Time",
+ "runsColumnTrigger": "Trigger",
+ "samplePayloadHelp": "Paste an example JSON body to discover available placeholders.",
+ "samplePayloadLabel": "Sample payload (optional)",
+ "scheduleLabel": "Schedule",
+ "scheduleSyntaxHelp": "Use <0>{{every}}0> with a duration (e.g., <1>{{example1}}1>, <2>{{example2}}2>) or shorthands like <3>{{hourly}}3>, <4>{{daily}}4>, <5>{{weekly}}5>.",
+ "scheduleTooltip": "How often to check for matching events. Checked every 30 seconds by a background process. Schedules persist across backend restarts.",
+ "searchConditions": "Search conditions...",
+ "selectRepositoryPlaceholder": "Select a repository",
+ "summaryCiCompleted": "CI completed",
+ "summaryCron": "Cron: {{expression}}",
+ "summaryCustomSchedule": "Custom schedule",
+ "summaryPrEvents": "PR: {{events}}",
+ "summaryPullRequestEvent": "Pull request event",
+ "summaryPushToBranch": "Push to branch",
+ "summaryWebhook": "Webhook",
+ "switchToScheduled": "Switch to scheduled",
+ "taskTitleHelp": "Leave empty to use the default. Supports placeholders.",
+ "taskTitleLabel": "Task title",
+ "taskTitlePlaceholder": "[Auto] automation name",
+ "thenDescription": "A new task will be created each time this automation triggers",
+ "thenTitle": "Then",
+ "triggerCategoryGithub": "GitHub",
+ "triggerCategoryWebhook": "Webhook",
+ "triggerInfoGithubPr": "Polls GitHub API on your schedule for PRs matching your filters.",
+ "triggerInfoNotImplemented": "Not yet implemented.",
+ "triggerInfoScheduled": "Checked every 30 seconds. Fires when the cron schedule matches.",
+ "triggerInfoWebhook": "Fires immediately when a POST request hits the webhook URL.",
+ "triggerLabelGithubCi": "GitHub CI",
+ "triggerLabelGithubPr": "GitHub PR",
+ "triggerLabelGithubPush": "GitHub Push",
+ "triggerLabelScheduled": "Scheduled",
+ "triggerLabelWebhook": "Webhook",
+ "triggerManually": "Trigger manually",
+ "unknownTriggerType": "Unknown trigger type",
+ "watchForLabel": "Watch for",
+ "webhookCreatedDescription": "Configure your external system to POST to this URL with the secret in the <0>{{header}}0> header. You can come back to this automation any time to copy these values again.",
+ "webhookSecretLabel": "Webhook secret",
+ "webhookSecretLoading": "Loading…",
+ "webhookUrlAfterSave": "Webhook URL will be available after saving the automation.",
+ "webhookUrlLabel": "Webhook URL",
+ "webhookUsageHelp": "Send a POST request with a JSON body and the secret in the <0>{{header}}0> header. Reference fields from the payload with <1>{{placeholder}}1>, e.g. <2>{{example}}2>.",
+ "whenDescription": "What causes this automation to run",
+ "whenTitle": "When",
+ "workflowHelp": "Select a workflow to enable saving.",
+ "workflowLabel": "Workflow",
+ "workflowPlaceholder": "Select workflow",
+ "workflowStepHelp": "Select a workflow step to enable saving.",
+ "workflowStepHelpNoWorkflow": "Select a workflow before choosing a step.",
+ "workflowStepLabel": "Workflow Step",
+ "workflowStepPlaceholder": "Select step",
+ "workflowStepPlaceholderNoWorkflow": "Pick a workflow first"
+}
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json
index f712a8a031..efea002494 100644
--- a/apps/web/src/locales/en/common.json
+++ b/apps/web/src/locales/en/common.json
@@ -46,6 +46,9 @@
"integrations": "Integrations",
"justNow": "just now",
"mAgo": "{{diffMin}}m ago",
+ "multiRepoUnavailableLocal": "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.",
+ "multiRepoUnavailableRemoteDocker": "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances.",
+ "multiRepoUnsupportedExecutor": "Multi-repo tasks are not supported by this executor.",
"plugins": "Plugins",
"prDetails": "PR Details",
"presetIconBug": "Bug",
diff --git a/apps/web/src/locales/pseudo/automations.json b/apps/web/src/locales/pseudo/automations.json
new file mode 100644
index 0000000000..2b33c58372
--- /dev/null
+++ b/apps/web/src/locales/pseudo/automations.json
@@ -0,0 +1,155 @@
+{
+ "addCondition": "Àďď Ćōńďĩţĩōń",
+ "addRepository": "Àďď ŕēƥōśĩţōŕŷ",
+ "agentProfileLabel": "Àĝēńţ Ƥŕōƒĩĺē",
+ "agentProfilePlaceholder": "Śēĺēćţ àĝēńţ",
+ "alreadyAdded": "Àĺŕēàďŷ àďďēď",
+ "authorsLabel": "Àũţĥōŕś (ćōḿḿà-śēƥàŕàţēď)",
+ "automationCreated": "Àũţōḿàţĩōń ćŕēàţēď",
+ "availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś",
+ "availablePlaceholdersColon": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
+ "baseBranchesLabel": "Ɓàśē ƀŕàńćĥēś (ćōḿḿà-śēƥàŕàţēď)",
+ "branchPatternsLabel": "Ɓŕàńćĥ ƥàţţēŕńś (ćōḿḿà-śēƥàŕàţēď, śũƥƥōŕţś ĝĺōƀś)",
+ "checkNamesLabel": "Ćĥēćķ ńàḿēś (ćōḿḿà-śēƥàŕàţēď, ōƥţĩōńàĺ)",
+ "ciConclusionCancelled": "Ćàńćēĺĺēď",
+ "ciConclusionFailure": "Ƒàĩĺũŕē",
+ "ciConclusionSuccess": "Śũććēśś",
+ "ciConclusionsLabel": "Ćōńćĺũśĩōńś",
+ "columnEnabled": "Ēńàƀĺēď",
+ "columnLastTriggered": "Ĺàśţ Ţŕĩĝĝēŕēď",
+ "columnMode": "Ḿōďē",
+ "columnName": "Ńàḿē",
+ "columnTriggers": "Ţŕĩĝĝēŕś",
+ "comingSoonSuffix": " (Ćōḿĩńĝ śōōń)",
+ "completeRequiredFields": "Ćōḿƥĺēţē ţĥē ŕēqũĩŕēď àũţōḿàţĩōń ƒĩēĺďś ƀēƒōŕē śàvĩńĝ.",
+ "configurationLabel": "Ćōńƒĩĝũŕàţĩōń",
+ "couldNotRefreshRuns": "Ćōũĺď ńōţ ŕēƒŕēśĥ ŕũńś — ŕēśţōŕēď ƒŕōḿ ĺōćàĺ ćàćĥē",
+ "createWorkspace": "Ćŕēàţē ŵōŕķśƥàćē",
+ "cronEveryDay": "Ēvēŕŷ ďàŷ",
+ "cronEveryHour": "Ēvēŕŷ ĥōũŕ",
+ "cronEveryWeek": "Ēvēŕŷ ŵēēķ",
+ "cronExpressionLabel": "Ćŕōń ēxƥŕēśśĩōń",
+ "cronHelp": "Śţàńďàŕď 5-ƒĩēĺď ćŕōń ōŕ śĥōŕţćũţś ({{shortcuts}})",
+ "customIntervalLabel": "Ćũśţōḿ ĩńţēŕvàĺ",
+ "delete": "Ďēĺēţē",
+ "deleteAll": "Ďēĺēţē àĺĺ",
+ "deleteAllRuns": "Ďēĺēţē àĺĺ ŕũńś",
+ "deleteAllRunsDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē àĺĺ ŕũń ŕēćōŕďś ƒōŕ ţĥĩś àũţōḿàţĩōń — ĩńćĺũďĩńĝ àńŷ ńōţ ćũŕŕēńţĺŷ ĺōàďēď — àńď ţĥēĩŕ àśśōćĩàţēď ţàśķś. Ţĥĩś ćàńńōţ ƀē ũńďōńē.",
+ "deleteAllRunsTitle": "Ďēĺēţē àĺĺ ŕũńś?",
+ "deleteRun": "Ďēĺēţē ŕũń",
+ "done": "Ďōńē",
+ "enabledLabel": "Ēńàƀĺēď",
+ "eventsLabel": "Ēvēńţś",
+ "excludeDraftPrs": "Ēxćĺũďē ďŕàƒţ ƤŔś",
+ "executionModeLabel": "Ēxēćũţĩōń Ḿōďē",
+ "executionModePlaceholder": "Śēĺēćţ ḿōďē",
+ "executionModeRun": "Ŕũń — ƒĩŕē-àńď-ƒōŕĝēţ, ĥĩďďēń ƒŕōḿ ķàńƀàń",
+ "executionModeRunShort": "Ŕũń",
+ "executionModeTask": "Ţàśķ — ćŕēàţēś à ţŕàćķēď ķàńƀàń ţàśķ",
+ "executionModeTaskShort": "Ţàśķ",
+ "executorProfileLabel": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
+ "executorProfilePlaceholder": "Śēĺēćţ ēxēćũţōŕ",
+ "failedToDeleteAutomation": "Ƒàĩĺēď ţō ďēĺēţē àũţōḿàţĩōń",
+ "failedToDeleteRun": "Ƒàĩĺēď ţō ďēĺēţē ŕũń",
+ "failedToDeleteRuns": "Ƒàĩĺēď ţō ďēĺēţē ŕũńś",
+ "failedToLoadWebhookSecret": "Ƒàĩĺēď ţō ĺōàď ŵēƀĥōōķ śēćŕēţ: {{error}}",
+ "failedToSaveAutomation": "Ƒàĩĺēď ţō śàvē àũţōḿàţĩōń: {{error}}",
+ "instructionsLabel": "Ĩńśţŕũćţĩōńś",
+ "invalidExpression": "Ĩńvàĺĩď ēxƥŕēśśĩōń. Ũśē {{every}} ŵĩţĥ à ďũŕàţĩōń, à śĥōŕţĥàńď, ōŕ à 5-ƒĩēĺď ćŕōń.",
+ "listDescription": "Ćŕēàţē ŕũĺēś ţĥàţ àũţōḿàţĩćàĺĺŷ ţŕĩĝĝēŕ àĝēńţ ţàśķś.",
+ "loading": "Ĺōàďĩńĝ...",
+ "loadingAutomations": "Ĺōàďĩńĝ àũţōḿàţĩōńś...",
+ "maxConcurrentRuns": "Ḿàx ćōńćũŕŕēńţ ŕũńś",
+ "nameHelp": "Ēńţēŕ àń àũţōḿàţĩōń ńàḿē ţō ēńàƀĺē śàvĩńĝ.",
+ "nameLabel": "Ńàḿē",
+ "namePlaceholder": "Àũţōḿàţĩōń ńàḿē",
+ "neverTriggered": "Ńēvēŕ",
+ "newAutomation": "Ńēŵ Àũţōḿàţĩōń",
+ "noAutomationsYet": "Ńō àũţōḿàţĩōńś ŷēţ",
+ "noRepositoriesHelp": "Ŵĩţĥ ńō ŕēƥōśĩţōŕĩēś śēĺēćţēď, ţĥĩś àũţōḿàţĩōń ŕũńś àĝàĩńśţ ţĥē ŵōŕķśƥàćē'ś ƒĩŕśţ ŕēƥōśĩţōŕŷ.",
+ "noRunsYet": "Ńō ŕũńś ŷēţ",
+ "noTriggers": "Ńōńē",
+ "noWorkspacesDescription": "Ćŕēàţē à ŵōŕķśƥàćē ƒĩŕśţ, ţĥēń śćĥēďũĺē àũţōḿàţĩōńś ĩńśĩďē ĩţ.",
+ "noWorkspacesYet": "Ńō ŵōŕķśƥàćēś ŷēţ",
+ "orUseWebhookInstead": "Ōŕ ũśē à ŵēƀĥōōķ ĩńśţēàď",
+ "pickWorkspace": "Ƥĩćķ à ŵōŕķśƥàćē ţō ḿàńàĝē ĩţś àũţōḿàţĩōńś.",
+ "prEventOpened": "Ōƥēńēď",
+ "prEventOpenedTooltip": "Ƒĩŕēś ōńćē ŵĥēń à ńēŵ ōƥēń ƤŔ ĩś ďēţēćţēď. Ēàćĥ ƤŔ ńũḿƀēŕ ĩś ďēďũƥēď — ţĥē śàḿē ƤŔ ŵōń'ţ ţŕĩĝĝēŕ àĝàĩń.",
+ "prTriggerRepositoryHelp": "ƤŔ ţŕĩĝĝēŕś àĺŵàŷś ũśē ţĥē ƤŔ'ś ōŵń ŕēƥōśĩţōŕŷ.",
+ "presetDaily": "Ďàĩĺŷ",
+ "presetHours_one": "{{count}} ĥōũŕ",
+ "presetHours_other": "{{count}} ĥōũŕś",
+ "presetMinutes_one": "{{count}} ḿĩń",
+ "presetMinutes_other": "{{count}} ḿĩń",
+ "presetWeekly": "Ŵēēķĺŷ",
+ "pushTriggerHelp": "Ţŕĩĝĝēŕś ŵĥēń ćōďē ĩś ƥũśĥēď ţō ḿàţćĥĩńĝ ƀŕàńćĥēś",
+ "recentRuns_one": "Ŕēćēńţ Ŕũńś ({{count}})",
+ "recentRuns_other": "Ŕēćēńţ Ŕũńś ({{count}})",
+ "refresh": "Ŕēƒŕēśĥ",
+ "removeRepository": "Ŕēḿōvē ŕēƥōśĩţōŕŷ",
+ "repositoryLabel": "Ŕēƥōśĩţōŕŷ",
+ "repositoryNone": "Ńōńē — ńō ŕēƥōśĩţōŕŷ",
+ "repositoryPlaceholderAuto": "Àũţō",
+ "required": "ŕēqũĩŕēď",
+ "runStatusArchived": "Àŕćĥĩvēď",
+ "runStatusCancelled": "Ćàńćēĺĺēď",
+ "runStatusFailed": "Ƒàĩĺēď",
+ "runStatusRunning": "Ŕũńńĩńĝ",
+ "runStatusSkipped": "Śķĩƥƥēď",
+ "runStatusSucceeded": "Śũććēēďēď",
+ "runStatusTriggered": "Ţŕĩĝĝēŕēď",
+ "runsColumnError": "Ēŕŕōŕ",
+ "runsColumnTask": "Ţàśķ",
+ "runsColumnTime": "Ţĩḿē",
+ "runsColumnTrigger": "Ţŕĩĝĝēŕ",
+ "samplePayloadHelp": "Ƥàśţē àń ēxàḿƥĺē ĴŚŌŃ ƀōďŷ ţō ďĩśćōvēŕ àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś.",
+ "samplePayloadLabel": "Śàḿƥĺē ƥàŷĺōàď (ōƥţĩōńàĺ)",
+ "scheduleLabel": "Śćĥēďũĺē",
+ "scheduleSyntaxHelp": "Ũśē <0>{{every}}0> ŵĩţĥ à ďũŕàţĩōń (ē.ĝ., <1>{{example1}}1>, <2>{{example2}}2>) ōŕ śĥōŕţĥàńďś ĺĩķē <3>{{hourly}}3>, <4>{{daily}}4>, <5>{{weekly}}5>.",
+ "scheduleTooltip": "Ĥōŵ ōƒţēń ţō ćĥēćķ ƒōŕ ḿàţćĥĩńĝ ēvēńţś. Ćĥēćķēď ēvēŕŷ 30 śēćōńďś ƀŷ à ƀàćķĝŕōũńď ƥŕōćēśś. Śćĥēďũĺēś ƥēŕśĩśţ àćŕōśś ƀàćķēńď ŕēśţàŕţś.",
+ "searchConditions": "Śēàŕćĥ ćōńďĩţĩōńś...",
+ "selectRepositoryPlaceholder": "Śēĺēćţ à ŕēƥōśĩţōŕŷ",
+ "summaryCiCompleted": "ĆĨ ćōḿƥĺēţēď",
+ "summaryCron": "Ćŕōń: {{expression}}",
+ "summaryCustomSchedule": "Ćũśţōḿ śćĥēďũĺē",
+ "summaryPrEvents": "ƤŔ: {{events}}",
+ "summaryPullRequestEvent": "Ƥũĺĺ ŕēqũēśţ ēvēńţ",
+ "summaryPushToBranch": "Ƥũśĥ ţō ƀŕàńćĥ",
+ "summaryWebhook": "Ŵēƀĥōōķ",
+ "switchToScheduled": "Śŵĩţćĥ ţō śćĥēďũĺēď",
+ "taskTitleHelp": "Ĺēàvē ēḿƥţŷ ţō ũśē ţĥē ďēƒàũĺţ. Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś.",
+ "taskTitleLabel": "Ţàśķ ţĩţĺē",
+ "taskTitlePlaceholder": "[Àũţō] àũţōḿàţĩōń ńàḿē",
+ "thenDescription": "À ńēŵ ţàśķ ŵĩĺĺ ƀē ćŕēàţēď ēàćĥ ţĩḿē ţĥĩś àũţōḿàţĩōń ţŕĩĝĝēŕś",
+ "thenTitle": "Ţĥēń",
+ "triggerCategoryGithub": "ĜĩţĤũƀ",
+ "triggerCategoryWebhook": "Ŵēƀĥōōķ",
+ "triggerInfoGithubPr": "Ƥōĺĺś ĜĩţĤũƀ ÀƤĨ ōń ŷōũŕ śćĥēďũĺē ƒōŕ ƤŔś ḿàţćĥĩńĝ ŷōũŕ ƒĩĺţēŕś.",
+ "triggerInfoNotImplemented": "Ńōţ ŷēţ ĩḿƥĺēḿēńţēď.",
+ "triggerInfoScheduled": "Ćĥēćķēď ēvēŕŷ 30 śēćōńďś. Ƒĩŕēś ŵĥēń ţĥē ćŕōń śćĥēďũĺē ḿàţćĥēś.",
+ "triggerInfoWebhook": "Ƒĩŕēś ĩḿḿēďĩàţēĺŷ ŵĥēń à ƤŌŚŢ ŕēqũēśţ ĥĩţś ţĥē ŵēƀĥōōķ ŨŔĹ.",
+ "triggerLabelGithubCi": "ĜĩţĤũƀ ĆĨ",
+ "triggerLabelGithubPr": "ĜĩţĤũƀ ƤŔ",
+ "triggerLabelGithubPush": "ĜĩţĤũƀ Ƥũśĥ",
+ "triggerLabelScheduled": "Śćĥēďũĺēď",
+ "triggerLabelWebhook": "Ŵēƀĥōōķ",
+ "triggerManually": "Ţŕĩĝĝēŕ ḿàńũàĺĺŷ",
+ "unknownTriggerType": "Ũńķńōŵń ţŕĩĝĝēŕ ţŷƥē",
+ "watchForLabel": "Ŵàţćĥ ƒōŕ",
+ "webhookCreatedDescription": "Ćōńƒĩĝũŕē ŷōũŕ ēxţēŕńàĺ śŷśţēḿ ţō ƤŌŚŢ ţō ţĥĩś ŨŔĹ ŵĩţĥ ţĥē śēćŕēţ ĩń ţĥē <0>{{header}}0> ĥēàďēŕ. Ŷōũ ćàń ćōḿē ƀàćķ ţō ţĥĩś àũţōḿàţĩōń àńŷ ţĩḿē ţō ćōƥŷ ţĥēśē vàĺũēś àĝàĩń.",
+ "webhookSecretLabel": "Ŵēƀĥōōķ śēćŕēţ",
+ "webhookSecretLoading": "Ĺōàďĩńĝ…",
+ "webhookUrlAfterSave": "Ŵēƀĥōōķ ŨŔĹ ŵĩĺĺ ƀē àvàĩĺàƀĺē àƒţēŕ śàvĩńĝ ţĥē àũţōḿàţĩōń.",
+ "webhookUrlLabel": "Ŵēƀĥōōķ ŨŔĹ",
+ "webhookUsageHelp": "Śēńď à ƤŌŚŢ ŕēqũēśţ ŵĩţĥ à ĴŚŌŃ ƀōďŷ àńď ţĥē śēćŕēţ ĩń ţĥē <0>{{header}}0> ĥēàďēŕ. Ŕēƒēŕēńćē ƒĩēĺďś ƒŕōḿ ţĥē ƥàŷĺōàď ŵĩţĥ <1>{{placeholder}}1>, ē.ĝ. <2>{{example}}2>.",
+ "whenDescription": "Ŵĥàţ ćàũśēś ţĥĩś àũţōḿàţĩōń ţō ŕũń",
+ "whenTitle": "Ŵĥēń",
+ "workflowHelp": "Śēĺēćţ à ŵōŕķƒĺōŵ ţō ēńàƀĺē śàvĩńĝ.",
+ "workflowLabel": "Ŵōŕķƒĺōŵ",
+ "workflowPlaceholder": "Śēĺēćţ ŵōŕķƒĺōŵ",
+ "workflowStepHelp": "Śēĺēćţ à ŵōŕķƒĺōŵ śţēƥ ţō ēńàƀĺē śàvĩńĝ.",
+ "workflowStepHelpNoWorkflow": "Śēĺēćţ à ŵōŕķƒĺōŵ ƀēƒōŕē ćĥōōśĩńĝ à śţēƥ.",
+ "workflowStepLabel": "Ŵōŕķƒĺōŵ Śţēƥ",
+ "workflowStepPlaceholder": "Śēĺēćţ śţēƥ",
+ "workflowStepPlaceholderNoWorkflow": "Ƥĩćķ à ŵōŕķƒĺōŵ ƒĩŕśţ"
+}
diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json
index 8919c4f5a2..f4439231a2 100644
--- a/apps/web/src/locales/pseudo/common.json
+++ b/apps/web/src/locales/pseudo/common.json
@@ -46,6 +46,9 @@
"integrations": "Ĩńţēĝŕàţĩōńś",
"justNow": "ĵũśţ ńōŵ",
"mAgo": "{{diffMin}}ḿ àĝō",
+ "multiRepoUnavailableLocal": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ũńàvàĩĺàƀĺē ōń Ĺōćàĺ ũńţĩĺ ĩţś ĩńĩţĩàĺ ĺàũńćĥ ƥàţĥ ćàń ƥŕōĵēćţ śĩƀĺĩńĝ ŕēƥōśĩţōŕĩēś.",
+ "multiRepoUnavailableRemoteDocker": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ũńàvàĩĺàƀĺē ōń Ŕēḿōţē Ďōćķēŕ ũńţĩĺ ĩţ śũƥƥōŕţś ćŕēàţĩńĝ ţàśķ ĩńśţàńćēś.",
+ "multiRepoUnsupportedExecutor": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ńōţ śũƥƥōŕţēď ƀŷ ţĥĩś ēxēćũţōŕ.",
"plugins": "Ƥĺũĝĩńś",
"prDetails": "ƤŔ Ďēţàĩĺś",
"presetIconBug": "Ɓũĝ",
diff --git a/docs/i18n.md b/docs/i18n.md
index d648418c1a..352bef27d5 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -235,6 +235,30 @@ new PR's. Entries that a broader glob already covers are deliberate and stay —
they record which PR migrated which half of a group — so this is an exact-match
check, not a redundancy check.
+#### An entry can be born dead, and only a glob expansion shows it
+
+Both checks above ask what happened to an entry *between* two states. Neither
+asks the prior question: **does this entry match any file at all?** An entry that
+matched nothing on the day it was added is indistinguishable from a healthy one —
+it never leaves the array, it is never a duplicate, and `pnpm lint` passes
+because the rule simply has no files to apply to. The list grows, the ratchet
+reports "N added", and the path is unguarded.
+
+A dynamic route is the way to hit this, because Next-style directories are named
+in glob syntax. `[id]` is a **character class** matching a single `i` or `d`:
+
+```bash
+node -e 'console.log(require("fs").globSync("app/settings/workspace/[id]/automations/**/*.tsx").length)' # 0
+node -e 'console.log(require("fs").globSync("app/settings/workspace/[[]id[]]/automations/**/*.tsx").length)' # 4
+```
+
+The Automations migration (#2247) shipped the unescaped form, and review caught
+it. So when an entry contains `[`, escape it as `[[]…[]]` — and prove the entry
+is live rather than trusting the glob result, the same way you would prove any
+other check earned its pass: put a hardcoded literal in one of the files the
+entry is supposed to cover and confirm `pnpm lint` reports
+`i18next/no-literal-string`, then remove it. A silent 0 errors is the tell.
+
### …but new code is guarded everywhere
Scoping the whole-file guard leaves a hole: a brand-new component under an