failproofai 0.0.14-beta.1 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  35. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/app/index.html +1 -1
  38. package/.next/standalone/.next/server/app/index.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  40. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  41. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  42. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  43. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  44. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  48. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0--lkk6._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-219ec._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__04_h-a5._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08w4wmd._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g6bbuw._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q0qzx1._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q2nbsl._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__16le-kd._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ffkmds._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1legmza._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mrihkj._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qb590j._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1shcqgr._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1xuedea._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  83. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__015_i4t._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__030k0c6._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03lv-pe._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__05r_17v._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e2l3c1._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hci7t3._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hdsupo._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p84xee._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qeuy9c._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s89hoe._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ynf7tx._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0brjczq._.js → [root-of-the-server]__0zf8xlq._.js} +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17ai7sy._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1nq7ivq._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0wp9w6i._.js → [root-of-the-server]__1s2b6h0._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tkjqka._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/_05whahf._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_1kje4fm._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  106. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/lib_0xkhw_v._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_12rjfea.js → node_modules_html-to-image_es_index_0gkzjaa.js} +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  113. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  114. package/.next/standalone/.next/server/pages/404.html +1 -1
  115. package/.next/standalone/.next/server/pages/500.html +1 -1
  116. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  117. package/.next/standalone/.next/server/server-reference-manifest.json +10 -10
  118. package/.next/standalone/.next/static/chunks/06z_ex1ts3n4s.js +1 -0
  119. package/.next/standalone/.next/static/chunks/0o0ut0ugd0_-s.js +1 -0
  120. package/.next/standalone/.next/static/chunks/0xl97x-874k-u.js +1 -0
  121. package/.next/standalone/.next/static/chunks/19d7hqx8udopz.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{0emg30_6svycu.js → 29o4j32z643_a.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/{1359o5785rmve.js → 2uh_4k4m296z5.js} +2 -2
  124. package/.next/standalone/.next/static/chunks/370sj2lgkw5ey.js +1 -0
  125. package/.next/standalone/.next/static/chunks/{0d5lypu1zs21r.js → 3s8aaig91937v.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/3w7g0n0iwwnbs.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{0o9wpnze1ey8y.js → 42oeva1-w0z2d.js} +1 -1
  128. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +11 -2
  129. package/.next/standalone/app/components/copy-button.tsx +22 -6
  130. package/.next/standalone/app/components/log-viewer/entry-row.tsx +8 -3
  131. package/.next/standalone/app/components/raw-log-viewer.tsx +33 -15
  132. package/.next/standalone/app/policies/hooks-client.tsx +1 -1
  133. package/.next/standalone/{ci/cli-integration → integration-suite}/README.md +25 -7
  134. package/.next/standalone/integration-suite/ci-entrypoint.sh +164 -0
  135. package/.next/standalone/{ci/cli-integration → integration-suite}/run.sh +4 -4
  136. package/.next/standalone/lib/entry-keys.ts +36 -0
  137. package/.next/standalone/lib/format-duration.ts +23 -9
  138. package/.next/standalone/package.json +5 -4
  139. package/.next/standalone/server.js +1 -1
  140. package/dist/cli.mjs +342 -106
  141. package/lib/entry-keys.ts +36 -0
  142. package/lib/format-duration.ts +23 -9
  143. package/package.json +5 -4
  144. package/scripts/translate-docs/cli.ts +6 -0
  145. package/scripts/translate-docs/mdx-translator.ts +30 -19
  146. package/scripts/translate-docs/readme-translator.ts +31 -21
  147. package/scripts/translate-docs/translator.ts +142 -7
  148. package/scripts/translate-docs/types.ts +6 -0
  149. package/scripts/translate-docs/validate-translation.ts +132 -0
  150. package/scripts/validate-mdx.ts +80 -11
  151. package/src/audit/cli.ts +12 -6
  152. package/src/auth/cli.ts +14 -5
  153. package/src/hooks/builtin-policies.ts +168 -50
  154. package/src/hooks/configure-wizard.ts +238 -22
  155. package/src/hooks/custom-hooks-loader.ts +70 -4
  156. package/src/hooks/handler.ts +4 -1
  157. package/src/hooks/policy-types.ts +8 -0
  158. package/src/hooks/tui.ts +95 -28
  159. package/.next/standalone/.next/static/chunks/20tuctw38jbgw.js +0 -1
  160. package/.next/standalone/.next/static/chunks/2ldhnaweu9ew3.js +0 -1
  161. package/.next/standalone/.next/static/chunks/2vav_x0_336ds.js +0 -1
  162. package/.next/standalone/.next/static/chunks/3cg7x0joi2b98.js +0 -1
  163. package/.next/standalone/.next/static/chunks/3k87p-tijud-4.js +0 -1
  164. package/.next/standalone/.next/static/chunks/3kmaqcp7rldad.js +0 -1
  165. /package/.next/standalone/.next/static/{LkN-JjmhKGmL-WklH7WVi → WQRKgSfFekLcC_pvyzp4S}/_buildManifest.js +0 -0
  166. /package/.next/standalone/.next/static/{LkN-JjmhKGmL-WklH7WVi → WQRKgSfFekLcC_pvyzp4S}/_clientMiddlewareManifest.js +0 -0
  167. /package/.next/standalone/.next/static/{LkN-JjmhKGmL-WklH7WVi → WQRKgSfFekLcC_pvyzp4S}/_ssgManifest.js +0 -0
  168. /package/.next/standalone/{ci/cli-integration → integration-suite}/Dockerfile +0 -0
  169. /package/.next/standalone/{ci/cli-integration → integration-suite}/canary-policies.mjs +0 -0
  170. /package/.next/standalone/{ci/cli-integration → integration-suite}/capture-tokens.sh +0 -0
  171. /package/.next/standalone/{ci/cli-integration → integration-suite}/inject-tokens.sh +0 -0
  172. /package/.next/standalone/{ci/cli-integration → integration-suite}/install-clis.sh +0 -0
  173. /package/.next/standalone/{ci/cli-integration → integration-suite}/probe-cli.sh +0 -0
  174. /package/.next/standalone/{ci/cli-integration → integration-suite}/report.js +0 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Stable per-entry identity for the log viewer.
3
+ *
4
+ * Deliberately standalone and dependency-free: `lib/log-entries.ts` reaches
5
+ * fs/promises through its project-resolution imports, so a *value* import of
6
+ * it from a client component drags node:fs into the browser bundle and the
7
+ * session page 500s. Client code may only import types from there.
8
+ */
9
+ import type { LogEntry } from "./log-entries";
10
+
11
+ /**
12
+ * Assigns every entry a unique, render-stable identity.
13
+ *
14
+ * Not every CLI writes a per-record `uuid` — Codex, Copilot, Cursor and Pi
15
+ * transcripts have none, so `baseEntry` leaves it "". Keying rows off
16
+ * `uuid || timestamp` therefore collapses to the timestamp, which those CLIs
17
+ * reuse freely (one 771-record Codex session: 93 timestamps shared by 2-5
18
+ * records each). Duplicate React keys break reconciliation, and in the
19
+ * virtualized log list that strands orphaned DOM nodes stacked on top of live
20
+ * rows — they keep their old transform and never recover (#292 follow-up).
21
+ *
22
+ * Collisions are disambiguated by occurrence order, so keys stay stable for as
23
+ * long as the parsed entry list is — which is what React and the virtualizer's
24
+ * measurement cache both require.
25
+ */
26
+ export function buildEntryKeys(entries: LogEntry[]): Map<LogEntry, string> {
27
+ const keys = new Map<LogEntry, string>();
28
+ const seen = new Map<string, number>();
29
+ for (const entry of entries) {
30
+ const base = entry.uuid || `${entry._source}:${entry.timestamp}`;
31
+ const n = seen.get(base) ?? 0;
32
+ seen.set(base, n + 1);
33
+ keys.set(entry, n === 0 ? base : `${base}#${n}`);
34
+ }
35
+ return keys;
36
+ }
@@ -16,14 +16,28 @@ export function formatRelativeTime(ts: number): string {
16
16
 
17
17
  export function formatDuration(ms: number): string {
18
18
  if (ms < 1000) return `${ms}ms`;
19
- const seconds = ms / 1000;
20
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
21
- const totalMinutes = Math.floor(seconds / 60);
22
- if (totalMinutes >= 60) {
23
- const hours = Math.floor(totalMinutes / 60);
24
- const remainingMinutes = totalMinutes % 60;
25
- return `${hours}h ${remainingMinutes}m`;
19
+
20
+ // Round to the precision the output will actually use, then bucket.
21
+ // Seconds are shown with one decimal place, so round to the nearest 0.1 s
22
+ // before deciding whether we have crossed into the minute range.
23
+ const deciseconds = Math.round(ms / 100);
24
+ if (deciseconds < 600) {
25
+ return `${(deciseconds / 10).toFixed(1)}s`;
26
+ }
27
+
28
+ // Minutes are shown with whole seconds, so round to the nearest second
29
+ // before splitting into minutes and seconds.
30
+ const totalSeconds = Math.round(ms / 1000);
31
+ if (totalSeconds < 3600) {
32
+ const minutes = Math.floor(totalSeconds / 60);
33
+ const seconds = totalSeconds % 60;
34
+ return `${minutes}m ${seconds}s`;
26
35
  }
27
- const remainingSeconds = (seconds % 60).toFixed(0);
28
- return `${totalMinutes}m ${remainingSeconds}s`;
36
+
37
+ // Hours are shown with whole minutes, so round to the nearest minute
38
+ // before splitting into hours and minutes.
39
+ const totalMinutes = Math.round(ms / 60000);
40
+ const hours = Math.floor(totalMinutes / 60);
41
+ const minutes = totalMinutes % 60;
42
+ return `${hours}h ${minutes}m`;
29
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.14-beta.1",
3
+ "version": "0.0.14",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -71,11 +71,11 @@
71
71
  "access": "public"
72
72
  },
73
73
  "devDependencies": {
74
- "@anthropic-ai/sdk": "^0.111.0",
74
+ "@anthropic-ai/sdk": "^0.112.4",
75
75
  "@mdx-js/mdx": "^3.1.1",
76
76
  "@tailwindcss/postcss": "^4.3.1",
77
77
  "@tanstack/react-virtual": "^3.14.3",
78
- "@testing-library/jest-dom": "^6.9.1",
78
+ "@testing-library/jest-dom": "^7.0.0",
79
79
  "@testing-library/react": "^16.3.2",
80
80
  "@testing-library/user-event": "^14.6.1",
81
81
  "@types/node": "26.1.1",
@@ -106,6 +106,7 @@
106
106
  "postcss": "8.5.14",
107
107
  "eslint-plugin-react-hooks": "7.0.1",
108
108
  "vite": "8.0.16",
109
- "undici": "7.28.0"
109
+ "undici": "7.28.0",
110
+ "brace-expansion": "5.0.7"
110
111
  }
111
112
  }
@@ -419,6 +419,12 @@ async function main() {
419
419
  if (totalInput > 0) {
420
420
  console.log(`Total tokens: ${totalInput} input + ${totalOutput} output`);
421
421
  }
422
+ // Surface pages that needed a re-translation to pass validation, so a run
423
+ // that quietly retried does not read as a clean one in the job log.
424
+ const retried = translated.filter((r) => (r.attempts ?? 1) > 1);
425
+ if (retried.length > 0) {
426
+ console.log(`Retried: ${retried.length}`);
427
+ }
422
428
 
423
429
  if (errors.length > 0) {
424
430
  process.exit(1);
@@ -10,7 +10,8 @@ import {
10
10
  import { dirname, join, relative } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { getLanguageByCode } from "./config";
13
- import { translateContent } from "./translator";
13
+ import { translateValidated } from "./translator";
14
+ import { findTranslationError } from "./validate-translation";
14
15
  import {
15
16
  readCache,
16
17
  writeCache,
@@ -193,10 +194,13 @@ export async function translateMdxPage(
193
194
  dryRun?: boolean;
194
195
  model?: string;
195
196
  cache?: TranslationCache;
197
+ /** Override the docs root. Tests point this at a fixture tree. */
198
+ docsDir?: string;
196
199
  } = {},
197
200
  ): Promise<TranslationResult> {
198
- const relPath = relative(DOCS_DIR, sourcePath);
199
- const outputPath = join(DOCS_DIR, lang, relPath);
201
+ const docsDir = options.docsDir ?? DOCS_DIR;
202
+ const relPath = relative(docsDir, sourcePath);
203
+ const outputPath = join(docsDir, lang, relPath);
200
204
  const sourceContent = readFileSync(sourcePath, "utf-8");
201
205
 
202
206
  const langConfig = getLanguageByCode(lang);
@@ -228,25 +232,31 @@ export async function translateMdxPage(
228
232
  };
229
233
  }
230
234
 
231
- // Translate
232
- const { translated, inputTokens, outputTokens } = await translateContent(
233
- sourceContent,
234
- lang,
235
- langConfig.name,
236
- options.model,
237
- );
238
-
239
- // Strip stray quote artifacts from JSX attribute values, drop any
240
- // unmatched trailing code fence the model sometimes hallucinates, convert
241
- // any HTML comments to MDX comments, then rewrite links.
242
- const sanitized = convertHtmlComments(
243
- stripStrayTrailingFence(sanitizeJsxAttributes(translated)),
244
- );
245
- const withLinks = rewriteInternalLinks(sanitized, lang);
235
+ // Translate and validate the exact bytes we will write. The render callback
236
+ // reproduces the historical sanitize + link-rewrite chain byte-for-byte
237
+ // (strip stray JSX-attribute quotes, drop an unmatched trailing fence,
238
+ // convert HTML comments to MDX, then add the language prefix to links), so
239
+ // the validated bytes ARE the written bytes and a pass here equals a pass in
240
+ // the deploy. On exhaustion translateValidated throws before we reach the
241
+ // write, so an invalid page is never written or cached.
242
+ const { rendered, inputTokens, outputTokens, attempts } =
243
+ await translateValidated({
244
+ source: sourceContent,
245
+ lang,
246
+ langName: langConfig.name,
247
+ model: options.model,
248
+ label: `${relPath} [${lang}]`,
249
+ render: (raw) =>
250
+ rewriteInternalLinks(
251
+ convertHtmlComments(stripStrayTrailingFence(sanitizeJsxAttributes(raw))),
252
+ lang,
253
+ ),
254
+ validate: (bytes) => findTranslationError(bytes, sourceContent),
255
+ });
246
256
 
247
257
  // Write output
248
258
  mkdirSync(dirname(outputPath), { recursive: true });
249
- writeFileSync(outputPath, withLinks);
259
+ writeFileSync(outputPath, rendered);
250
260
 
251
261
  // Update cache — skip if caller manages the cache (batch write)
252
262
  if (!options.cache) {
@@ -269,6 +279,7 @@ export async function translateMdxPage(
269
279
  inputTokens,
270
280
  outputTokens,
271
281
  cached: false,
282
+ attempts,
272
283
  };
273
284
  }
274
285
 
@@ -2,12 +2,13 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { LANGUAGES, getLanguageByCode } from "./config";
5
- import { translateContent } from "./translator";
5
+ import { translateValidated } from "./translator";
6
6
  import {
7
7
  stripStrayTrailingFence,
8
8
  convertHtmlComments,
9
9
  sanitizeJsxAttributes,
10
10
  } from "./mdx-translator";
11
+ import { findTranslationError } from "./validate-translation";
11
12
  import { readCache, writeCache, isCached, setCacheEntry } from "./cache";
12
13
  import type { TranslationResult, TranslationCache } from "./types";
13
14
 
@@ -90,15 +91,12 @@ export async function translateReadme(
90
91
  };
91
92
  }
92
93
 
93
- // Translate
94
- const { translated, inputTokens, outputTokens } = await translateContent(
95
- sourceContent,
96
- lang,
97
- langConfig.name,
98
- options.model,
99
- );
100
-
101
- // Build the final output with header
94
+ // Compute the wrapper (disclaimer, language selector, RTL <div>) up front so
95
+ // the render callback can assemble the FINAL bytes on every attempt. The
96
+ // assembled bytes — not the raw model output — are what gets validated: the
97
+ // swallowed-`</div>` class is introduced by the wrapper AFTER the model
98
+ // returns, and `mintlify validate` never sees the README at all, so this gate
99
+ // is the only thing standing between a broken README and the deploy.
102
100
  const disclaimer = langConfig.rtl
103
101
  ? `> **\u26a0\ufe0f** \u0647\u0630\u0647 \u062a\u0631\u062c\u0645\u0629 \u0622\u0644\u064a\u0629. \u0644\u0644\u0627\u0637\u0644\u0627\u0639 \u0639\u0644\u0649 \u0623\u062d\u062f\u062b \u0625\u0635\u062f\u0627\u0631\u060c \u0631\u0627\u062c\u0639 [English README](../../README.md).`
104
102
  : `> **\u26a0\ufe0f** This is an auto-generated translation. For the latest version, see the [English README](../../README.md). Community corrections welcome!`;
@@ -107,20 +105,31 @@ export async function translateReadme(
107
105
  const rtlOpen = langConfig.rtl ? `<div dir="rtl">\n\n` : "";
108
106
  const rtlClose = langConfig.rtl ? `\n\n</div>` : "";
109
107
 
110
- // Run the same MDX sanitizers as translateMdxPage the README emits JSX
111
- // (the logo table), so its output has to satisfy Mintlify's MDX parser too:
112
- // strip stray quote artifacts from JSX attributes, drop any unmatched
113
- // trailing code fence the model hallucinates (which would swallow the RTL
114
- // `</div>` wrapper), and convert HTML comments to MDX comments.
115
- const cleaned = convertHtmlComments(
116
- stripStrayTrailingFence(sanitizeJsxAttributes(translated)),
117
- );
118
-
119
- const output = `${disclaimer}\n\n${langSelector}\n\n---\n${rtlOpen}\n${cleaned}\n${rtlClose}`;
108
+ // Translate and validate the assembled bytes, re-translating on failure.
109
+ const { rendered, inputTokens, outputTokens, attempts } =
110
+ await translateValidated({
111
+ source: sourceContent,
112
+ lang,
113
+ langName: langConfig.name,
114
+ model: options.model,
115
+ label: `README.${lang}.md`,
116
+ render: (raw) => {
117
+ // Same MDX sanitizers as translateMdxPage — the README emits JSX (the
118
+ // logo table), so strip stray attribute quotes, drop any unmatched
119
+ // trailing code fence (which would swallow the RTL `</div>`), and
120
+ // convert HTML comments to MDX — then wrap in disclaimer + selector +
121
+ // RTL div.
122
+ const cleaned = convertHtmlComments(
123
+ stripStrayTrailingFence(sanitizeJsxAttributes(raw)),
124
+ );
125
+ return `${disclaimer}\n\n${langSelector}\n\n---\n${rtlOpen}\n${cleaned}\n${rtlClose}`;
126
+ },
127
+ validate: (bytes) => findTranslationError(bytes, sourceContent),
128
+ });
120
129
 
121
130
  // Write output
122
131
  mkdirSync(I18N_DIR, { recursive: true });
123
- writeFileSync(outputPath, output);
132
+ writeFileSync(outputPath, rendered);
124
133
 
125
134
  // Update cache — skip if caller manages the cache (batch write)
126
135
  if (!options.cache) {
@@ -136,6 +145,7 @@ export async function translateReadme(
136
145
  inputTokens,
137
146
  outputTokens,
138
147
  cached: false,
148
+ attempts,
139
149
  };
140
150
  }
141
151
 
@@ -25,6 +25,25 @@ const MAX_TOKENS =
25
25
  ? parsedMaxTokens
26
26
  : 64000;
27
27
 
28
+ // Maximum TOTAL validation attempts per page: 1 initial translation plus up to
29
+ // MAX_ATTEMPTS-1 re-translations when the rendered output fails validation
30
+ // (see translateValidated below). Distinct from the SDK transport budget
31
+ // TRANSLATE_MAX_RETRIES (default 5, in getClient): that retries a single HTTP
32
+ // request on a connection error; this re-translates a page whose *content*
33
+ // failed the docs-build checks. They multiply — at most
34
+ // MAX_ATTEMPTS x (1 + maxRetries) HTTP requests for one page in the worst case.
35
+ // 3 collapses the observed per-page failure rate to negligible while costing
36
+ // wall-clock (a serial retry inside one worker slot), not peak concurrency.
37
+ // Override via TRANSLATE_MAX_ATTEMPTS (integer >= 1; 1 disables retries).
38
+ const parsedMaxAttempts = Number.parseInt(
39
+ process.env.TRANSLATE_MAX_ATTEMPTS ?? "",
40
+ 10,
41
+ );
42
+ const MAX_ATTEMPTS =
43
+ Number.isInteger(parsedMaxAttempts) && parsedMaxAttempts > 0
44
+ ? parsedMaxAttempts
45
+ : 3;
46
+
28
47
  function getClient(): Anthropic {
29
48
  if (!client) {
30
49
  // Default 5 retries (up from SDK default of 2) so transient
@@ -44,7 +63,7 @@ const SYSTEM_PROMPT = `You are a professional technical documentation translator
44
63
 
45
64
  1. **Preserve all code blocks exactly as-is** — never translate content inside backtick-fenced code blocks (\`\`\`...\`\`\`) or inline code (\`...\`).
46
65
  2. **Preserve MDX component syntax** — tags like <Card>, <CardGroup>, <CodeGroup>, <Steps>, <Step>, <Note>, <Tip>, <Tabs>, <Tab>, <Warning> must remain unchanged. Their attribute names (title, icon, href, cols) must remain in English. Only translate the text content of the \`title\` attribute and the text body between tags. **Never put an ASCII straight \`"\` inside a \`title="…"\` (or any JSX attribute value)** — it terminates the attribute and breaks MDX parsing. If the target language would normally wrap a word in quotation marks (e.g. German „…", Japanese 「…」), drop the inner quotes inside attribute values and rely on the surrounding tag for emphasis.
47
- 3. **Preserve YAML frontmatter keys** — only translate the string values of \`title\` and \`description\`. Keep the \`icon\` value unchanged.
66
+ 3. **Preserve YAML frontmatter keys** — only translate the string values of \`title\` and \`description\`. Keep the \`icon\` value unchanged. Never rename, add, or drop a frontmatter key. The \`title\` and \`description\` values are wrapped in double quotes: **never put an unescaped ASCII \`"\` inside them** — it terminates the YAML string and breaks the frontmatter parse (exactly as an ASCII \`"\` breaks a JSX attribute in rule 2). If the source value contains an escaped quote (\`\\"\`), keep it escaped in the same form; if the target language would quote a phrase, use typographic quotes (e.g. „…", «…», 「…」) or rephrase to avoid the inner quote.
48
67
  4. **Preserve all URLs and paths** — never modify href values, image paths, or links.
49
68
  5. **Preserve Markdown structure** — headers (#, ##), lists (-, *), tables (|), bold (**), italic (*), links ([text](url)) must keep their Markdown formatting.
50
69
  6. **Preserve badge/shield URLs** — any [![...](https://img.shields.io/...)](url) pattern must remain completely unchanged.
@@ -65,11 +84,40 @@ ${DO_NOT_TRANSLATE.map((t) => `- ${t}`).join("\n")}
65
84
 
66
85
  Return ONLY the translated content. Do not add explanations, notes, or commentary.`;
67
86
 
87
+ export interface RetryFeedback {
88
+ attempt: number;
89
+ maxAttempts: number;
90
+ /** The previous attempt's validation error — model-actionable text. */
91
+ error: string;
92
+ }
93
+
94
+ /**
95
+ * The repair note appended to the user turn on a retry. The failed attempt is
96
+ * NOT fed back as an assistant turn: a fresh re-translate keeps the input flat
97
+ * across attempts. Conversational repair would grow the input every attempt and
98
+ * push a large README toward the max_tokens ceiling on exactly the retry where
99
+ * a truncation would be worst — and the error is self-locating (it carries the
100
+ * caret excerpt / body snippet), so the model needs the note, not its own prior
101
+ * output, to fix the defect.
102
+ */
103
+ function buildRetryFeedback(f: RetryFeedback): string {
104
+ return (
105
+ `[Retry ${f.attempt} of ${f.maxAttempts}. The source document above is unchanged.]\n\n` +
106
+ "Your previous translation of this document was REJECTED by the docs build " +
107
+ "and discarded. It failed validation with:\n\n" +
108
+ `${f.error}\n\n` +
109
+ "Translate the source again from the beginning and return ONLY the " +
110
+ "corrected translation — do not comment on this note. Do not reproduce that " +
111
+ "defect. Every rule above still applies."
112
+ );
113
+ }
114
+
68
115
  export async function translateContent(
69
116
  content: string,
70
117
  targetLang: string,
71
118
  targetLangName: string,
72
119
  model: string = "claude-sonnet-4-6",
120
+ feedback?: RetryFeedback,
73
121
  ): Promise<{ translated: string; inputTokens: number; outputTokens: number }> {
74
122
  const anthropic = getClient();
75
123
 
@@ -81,16 +129,18 @@ export async function translateContent(
81
129
  // surfaces to the SDK as `APIConnectionError ("Connection error.")`.
82
130
  // `messages.stream(...).finalMessage()` returns the same Message shape
83
131
  // as `messages.create(...)`, so the rest of the pipeline is unchanged.
132
+ const base = `Translate the following documentation content into ${targetLangName} (${targetLang}).\n\n---\n\n${content}`;
133
+ // On a retry, append the repair note after the source in the SAME single user
134
+ // turn — no assistant turn is fed back, so the request stays flat-input.
135
+ const userContent = feedback
136
+ ? `${base}\n\n---\n\n${buildRetryFeedback(feedback)}`
137
+ : base;
138
+
84
139
  const response = await anthropic.messages.stream({
85
140
  model,
86
141
  max_tokens: MAX_TOKENS,
87
142
  system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }],
88
- messages: [
89
- {
90
- role: "user",
91
- content: `Translate the following documentation content into ${targetLangName} (${targetLang}).\n\n---\n\n${content}`,
92
- },
93
- ],
143
+ messages: [{ role: "user", content: userContent }],
94
144
  }).finalMessage();
95
145
 
96
146
  // A truncated translation is worse than a failed one: the model stops
@@ -116,3 +166,88 @@ export async function translateContent(
116
166
  outputTokens: response.usage.output_tokens,
117
167
  };
118
168
  }
169
+
170
+ /**
171
+ * Translate `source`, render it to the exact bytes that will be written, and
172
+ * validate those bytes — re-translating with the validation error fed back
173
+ * until it passes or MAX_ATTEMPTS is reached.
174
+ *
175
+ * The loop lives here, not in translateContent, because each caller renders
176
+ * different final bytes (the MDX pages add rewriteInternalLinks; the README
177
+ * wraps the body in a disclaimer + RTL `<div>`). Validating the RENDERED bytes —
178
+ * not the raw model output — is what makes a pass here equal to a pass in
179
+ * `mintlify validate` and the deploy.
180
+ *
181
+ * On exhaustion it THROWS (never returns a partial), so the caller's write is
182
+ * unreachable and no invalid page is ever written or cached — the same
183
+ * fail-loud contract translateContent already enforces for max_tokens
184
+ * truncation. Transport/auth/max_tokens errors from translateContent propagate
185
+ * unchanged and consume no attempt: only *validity* failures retry.
186
+ */
187
+ export async function translateValidated(opts: {
188
+ source: string;
189
+ lang: string;
190
+ langName: string;
191
+ model?: string;
192
+ /** Label for the per-attempt warning line, e.g. `agenteye/cli.mdx [de]`. */
193
+ label: string;
194
+ /** Turn raw model output into the exact bytes that will be written. */
195
+ render: (raw: string) => string;
196
+ /** Validate the rendered bytes; return an error message, or null if valid. */
197
+ validate: (rendered: string) => Promise<string | null>;
198
+ }): Promise<{
199
+ rendered: string;
200
+ inputTokens: number;
201
+ outputTokens: number;
202
+ attempts: number;
203
+ }> {
204
+ let inputTokens = 0;
205
+ let outputTokens = 0;
206
+ let lastError = "";
207
+
208
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
209
+ const feedback: RetryFeedback | undefined =
210
+ attempt > 1
211
+ ? { attempt, maxAttempts: MAX_ATTEMPTS, error: lastError }
212
+ : undefined;
213
+
214
+ // No try/catch: a transport/auth/max_tokens throw is not a validity
215
+ // failure — let it propagate so it is never silently retried as one.
216
+ const result = await translateContent(
217
+ opts.source,
218
+ opts.lang,
219
+ opts.langName,
220
+ opts.model,
221
+ feedback,
222
+ );
223
+ inputTokens += result.inputTokens;
224
+ outputTokens += result.outputTokens;
225
+
226
+ if (result.translated.trim() === "") {
227
+ // An empty response is a validity failure, not a usable page — resample
228
+ // rather than write a blank file.
229
+ lastError =
230
+ "The translation was empty. Return the full translated document.";
231
+ console.warn(
232
+ ` ${opts.label} -> attempt ${attempt}/${MAX_ATTEMPTS} produced empty output; retrying`,
233
+ );
234
+ continue;
235
+ }
236
+
237
+ const rendered = opts.render(result.translated);
238
+ const error = await opts.validate(rendered);
239
+ if (error === null) {
240
+ return { rendered, inputTokens, outputTokens, attempts: attempt };
241
+ }
242
+
243
+ lastError = error;
244
+ console.warn(
245
+ ` ${opts.label} -> attempt ${attempt}/${MAX_ATTEMPTS} failed validation: ${error.split("\n")[0]}`,
246
+ );
247
+ }
248
+
249
+ throw new Error(
250
+ `translation into ${opts.langName} (${opts.lang}) still fails validation ` +
251
+ `after ${MAX_ATTEMPTS} attempt(s): ${lastError.split("\n")[0]}`,
252
+ );
253
+ }
@@ -43,4 +43,10 @@ export interface TranslationResult {
43
43
  inputTokens: number;
44
44
  outputTokens: number;
45
45
  cached: boolean;
46
+ /**
47
+ * Total translation attempts spent (1 = valid on first try). Optional so the
48
+ * cached / dry-run result literals need no change; absent means "not
49
+ * translated this run" (cached) or "1".
50
+ */
51
+ attempts?: number;
46
52
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Generation-time validation for a freshly translated page.
3
+ *
4
+ * `findTranslationError` runs the exact checks the Mintlify deploy performs —
5
+ * frontmatter YAML then MDX body — plus one the deploy cannot: frontmatter
6
+ * KEY-PARITY against the English source. It is called on the exact bytes about
7
+ * to be written to disk (post-sanitize, post-link-rewrite), so a page that
8
+ * passes here is a page `mintlify validate` and the deploy accept.
9
+ *
10
+ * Why a translation-specific wrapper instead of `findPageError` alone:
11
+ * - Frontmatter YAML (class A): the model re-emits an inner `"` unescaped into
12
+ * a double-quoted `title:`/`description:` value, breaking the YAML. This is
13
+ * the class that failed run 29575781632; `findFrontmatterError` catches it.
14
+ * - Key parity (class B): the model drops the frontmatter block entirely, or
15
+ * renames a key. A dropped block is still *valid YAML* (mintlify tolerates
16
+ * it, deriving the title from the slug), so only comparing against the
17
+ * source's keys catches it. This is deliberately stricter than mintlify —
18
+ * the keys are prompt-forbidden to change, so it fails ~never but converts
19
+ * a silent content regression into a retry.
20
+ * - MDX body (classes C+): stray `<slug>`, `{#anchor}`, unmatched fence,
21
+ * swallowed `</div>` — `findMdxParseError` catches these.
22
+ *
23
+ * The message returned is written for a MODEL to act on: it names the failing
24
+ * construct and, for a body error, quotes the offending line, so the retry has
25
+ * a concrete defect to fix rather than a bare line number the RTL/README
26
+ * wrappers would have offset anyway.
27
+ */
28
+ import { findFrontmatterError, findMdxParseError } from "../validate-mdx";
29
+ import YAML from "yaml";
30
+
31
+ // Same matcher as validate-mdx.ts; duplicated locally so this module owns its
32
+ // frontmatter extraction and does not depend on an internal export.
33
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
34
+
35
+ /**
36
+ * The sorted top-level keys of a page's frontmatter, or `null` when the page
37
+ * has no frontmatter block or its block is not a key/value mapping (an
38
+ * unparseable or scalar block). Callers distinguish "no block" from a YAML
39
+ * error separately via `findFrontmatterError`.
40
+ */
41
+ function frontmatterKeys(page: string): string[] | null {
42
+ const match = FRONTMATTER_RE.exec(page);
43
+ if (!match) return null;
44
+ try {
45
+ const doc = YAML.parse(match[1]);
46
+ if (doc && typeof doc === "object" && !Array.isArray(doc)) {
47
+ return Object.keys(doc).sort();
48
+ }
49
+ return null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * A ±2-line window around `line` (1-based), the failing line prefixed `> ` and
57
+ * its neighbours ` `. Empty string when `line` is undefined.
58
+ */
59
+ function excerpt(page: string, line?: number): string {
60
+ if (!line) return "";
61
+ const lines = page.split("\n");
62
+ const start = Math.max(1, line - 2);
63
+ const end = Math.min(lines.length, line + 2);
64
+ const out: string[] = [];
65
+ for (let n = start; n <= end; n++) {
66
+ out.push(`${n === line ? "> " : " "}${lines[n - 1]}`);
67
+ }
68
+ return out.join("\n");
69
+ }
70
+
71
+ const backticked = (keys: string[]): string =>
72
+ keys.map((k) => `\`${k}\``).join(", ");
73
+
74
+ /**
75
+ * Validate a rendered translation against its English `source`. Returns a
76
+ * model-actionable error message, or `null` when the page is publishable.
77
+ */
78
+ export async function findTranslationError(
79
+ rendered: string,
80
+ source: string,
81
+ ): Promise<string | null> {
82
+ // Validate the rendered frontmatter block FIRST, for every source shape —
83
+ // even when the source had none. findMdxParseError (below) blanks a leading
84
+ // `---` block before compiling, so a malformed block the model *added* to a
85
+ // frontmatter-less page would otherwise be invisible here yet still break the
86
+ // Mintlify deploy. Closing exactly that blind spot is this module's job, so
87
+ // it must hold regardless of whether the source has frontmatter.
88
+ const fm = findFrontmatterError(rendered);
89
+ if (fm) {
90
+ return (
91
+ "The YAML frontmatter (the `---` block at the top of the file) does " +
92
+ `not parse:\n\n${fm.message}`
93
+ );
94
+ }
95
+
96
+ const sourceKeys = frontmatterKeys(source);
97
+ if (sourceKeys) {
98
+ // The source has frontmatter, so the translation must carry the same keys.
99
+ // (Its block, if present, already parsed cleanly above.)
100
+ const keys = frontmatterKeys(rendered);
101
+ if (keys === null) {
102
+ return (
103
+ "The YAML frontmatter is missing. The English source starts with a " +
104
+ `\`---\` block containing ${backticked(sourceKeys)}; the translation ` +
105
+ "must start with the same block — translate the values, keep the keys."
106
+ );
107
+ }
108
+ // Subset check BY DESIGN: a missing or renamed key is a content regression
109
+ // (the page loses its title/description), so reject it. An *extra* key is
110
+ // harmless — Mintlify ignores unknown frontmatter keys — so tolerate it
111
+ // rather than burn a retry (and risk exhausting the whole batch) on a page
112
+ // that would deploy fine. Reject content loss; allow harmless additions.
113
+ const missing = sourceKeys.filter((k) => !keys.includes(k));
114
+ if (missing.length > 0) {
115
+ return (
116
+ `The YAML frontmatter keys changed. Expected ${backticked(sourceKeys)} ` +
117
+ `but got ${backticked(keys)}. Translate the values, never rename or ` +
118
+ "drop a key."
119
+ );
120
+ }
121
+ }
122
+
123
+ const body = await findMdxParseError(rendered);
124
+ if (body) {
125
+ const snippet = excerpt(rendered, body.line);
126
+ return `The MDX body does not parse: ${body.message}${
127
+ snippet ? `\n\n${snippet}` : ""
128
+ }`;
129
+ }
130
+
131
+ return null;
132
+ }