clearotron 0.3.2-beta.12 → 0.3.2-beta.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 (100) hide show
  1. package/CONTRIBUTING.md +6 -5
  2. package/INSTALL.md +3 -4
  3. package/README.md +2 -1
  4. package/bin/clearotron.mjs +7 -1
  5. package/bin/example.mjs +61 -23
  6. package/bin/onboard.mjs +16 -2
  7. package/bin/start.mjs +6 -3
  8. package/build-info.json +2 -2
  9. package/docs/CLIENT-MCP.md +6 -6
  10. package/docs/DELIVERY.md +3 -3
  11. package/docs/ONBOARDING.md +1 -1
  12. package/docs/PORTAL.md +3 -3
  13. package/docs/RELEASES.md +1 -1
  14. package/docs/SECURITY.md +1 -1
  15. package/docs/architecture/04-configuration-reference.md +4 -4
  16. package/docs/architecture/05-config-governance.md +10 -10
  17. package/docs/architecture/06-operations-runbook.md +3 -3
  18. package/docs/architecture/07-quality-and-audit.md +1 -1
  19. package/docs/architecture/08-development-guide.md +2 -2
  20. package/docs/architecture/09-security-and-data.md +1 -1
  21. package/docs/decisions/0002-no-dark-functionality.md +1 -1
  22. package/docs/decisions/0006-what-the-public-repository-carries.md +3 -3
  23. package/driver/CHANGELOG.md +21 -0
  24. package/driver/ask-ledger.mjs +1 -1
  25. package/driver/band-shape.mjs +1 -1
  26. package/driver/bundled-demos.mjs +2 -2
  27. package/driver/card-budget.mjs +2 -2
  28. package/driver/case-law-ledger.mjs +2 -2
  29. package/driver/connotation-search.mjs +5 -5
  30. package/driver/contract-e3-backlog.mjs +13 -13
  31. package/driver/coverage-form.mjs +2 -2
  32. package/driver/demo-container.mjs +26 -2
  33. package/driver/disposition-tool.mjs +2 -2
  34. package/driver/engine/CONTRACT.md +3 -3
  35. package/driver/engine/mcp/codex-config.mjs +3 -11
  36. package/driver/engine/mcp/gather-config.mjs +1 -1
  37. package/driver/engine/openai-agent.mjs +2 -2
  38. package/driver/findings-model.mjs +21 -6
  39. package/driver/gateway.mjs +1 -1
  40. package/driver/package.json +1 -1
  41. package/driver/pipeline-knockout.mjs +1 -1
  42. package/driver/pipeline.mjs +4 -4
  43. package/driver/placement-union.mjs +1 -1
  44. package/driver/portal-mcp-client.mjs +1 -1
  45. package/driver/portal-report.mjs +4 -1
  46. package/driver/portal-service.mjs +17 -5
  47. package/driver/predelivery-lint.mjs +9 -4
  48. package/driver/progress.mjs +37 -1
  49. package/driver/publish/render-knockout.mjs +31 -6
  50. package/driver/publish/render.mjs +6 -6
  51. package/driver/record-discard.mjs +1 -1
  52. package/driver/register-count.mjs +1 -1
  53. package/driver/register-digest-record.mjs +1 -1
  54. package/driver/report-card-record.mjs +2 -2
  55. package/driver/roster-verdict.mjs +2 -2
  56. package/driver/search-policy.mjs +1 -1
  57. package/driver/skeptic-record.mjs +1 -1
  58. package/driver/stages.mjs +1 -1
  59. package/driver/suite-census.json +40 -10
  60. package/driver/systemd/README.md +1 -1
  61. package/driver/unit-inventory.mjs +2 -2
  62. package/driver/verify.mjs +1 -1
  63. package/mcp-server/CHANGELOG.md +8 -0
  64. package/mcp-server/CONNECT.md +8 -8
  65. package/mcp-server/lib/runs.mjs +1 -1
  66. package/mcp-server/package.json +1 -1
  67. package/mcp-server/serve.mjs +27 -0
  68. package/package.json +1 -1
  69. package/portal-ui/package.json +1 -1
  70. package/providers/free-tier/src/capabilities.js +2 -2
  71. package/providers/jx/src/core.js +3 -3
  72. package/providers/jx/src/turn-envelope.mjs +1 -1
  73. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  74. package/providers/oauth-mcp-bridge/package.json +1 -1
  75. package/providers/perplexity/README.md +1 -1
  76. package/providers/signa/src/capabilities.js +2 -2
  77. package/providers/signa/src/core.js +2 -2
  78. package/providers/uspto-local/src/core.js +1 -1
  79. package/providers/uspto-local/src/sync.js +1 -1
  80. package/scripts/README.md +2 -6
  81. package/scripts/citation-anchor-report.mjs +1 -1
  82. package/scripts/citation-line-check.mjs +3 -3
  83. package/scripts/e2e.mjs +1 -1
  84. package/scripts/env-audit.mjs +1 -1
  85. package/scripts/env-classify.mjs +1 -1
  86. package/scripts/pack-publishable.mjs +1 -1
  87. package/scripts/release-artifact-seal.mjs +2 -2
  88. package/scripts/settings-render-check.mjs +5 -1
  89. package/scripts/strip-tracker-citations.mjs +4 -4
  90. package/scripts/test-run.mjs +46 -3
  91. package/shared/browser-temp-root.mjs +10 -3
  92. package/shared/client-door.mjs +18 -6
  93. package/shared/connect-clients.mjs +2 -0
  94. package/shared/demo-start-args.mjs +10 -0
  95. package/shared/identifier-scan.mjs +2 -2
  96. package/shared/invocation.mjs +1 -1
  97. package/shared/reference-guard-classes.mjs +5 -3
  98. package/shared/stdio-connect.mjs +80 -28
  99. package/shared/toml-string.mjs +25 -0
  100. package/shared/writing-standard-classes.mjs +2 -3
@@ -32,6 +32,7 @@
32
32
  import { join } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { stableInstallRoot } from "./permanent-install.mjs";
35
+ import { tomlString } from "./toml-string.mjs";
35
36
 
36
37
  /** The install root — the directory holding `mcp-server/`, resolved from this module rather than cwd. */
37
38
  export const INSTALL_ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..");
@@ -50,8 +51,8 @@ export const STDIO_SERVER_NAME = "trademark-artifacts";
50
51
  * @param {{ installRoot?: string, workDir?: string|null }} [opts]
51
52
  * @returns {string} the exact command to run
52
53
  */
53
- export function stdioConnectCommand({ installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform } = {}) {
54
- return STDIO_SHAPES["claude-cli"].render({ server: join(installRoot, "mcp-server", "server.mjs"), workDir, reportsDir, platform });
54
+ export function stdioConnectCommand({ installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform, node = process.execPath } = {}) {
55
+ return STDIO_SHAPES["claude-cli"].render({ server: join(installRoot, "mcp-server", STDIO_ENTRY), workDir, reportsDir, platform, node });
55
56
  }
56
57
 
57
58
  /**
@@ -103,15 +104,38 @@ export function stdioConnectOffer(opts = {}) {
103
104
  function envOf({ workDir = null, reportsDir = null } = {}) {
104
105
  return { ...(workDir ? { CLEAROTRON_WORK_DIR: workDir } : {}), ...(reportsDir ? { CLEAROTRON_REPORTS_DIR: reportsDir } : {}) };
105
106
  }
106
- const envFlags = (o) => Object.entries(envOf(o)).map(([k, v]) => ` -e ${k}=${v}`).join("");
107
107
  const envBlock = (o) => (Object.keys(envOf(o)).length ? { env: envOf(o) } : {});
108
108
 
109
+ // ── ONE WORD PER ARGUMENT, IN THE SHELL THAT READS THE LINE ───────────────────────────────────────────
110
+ //
111
+ // The command was the arguments joined with spaces, so a path with a space in it became two arguments:
112
+ // `/tmp/Example User/node/bin/node` reached the assistant as `/tmp/Example` and `User/node/bin/node`
113
+ // (measured on the published beta, 2026-09-19). Each argument is now written as one word for the shell it
114
+ // is pasted into. A word made only of characters no shell treats specially is left bare, so the line for
115
+ // an ordinary install reads exactly as it did.
116
+ //
117
+ // POSIX shells: single quotes, the one quoting in which no character is special. A single quote inside is
118
+ // closed, escaped and reopened.
119
+ const POSIX_BARE = /^[A-Za-z0-9_@%+=:,./-]+$/;
120
+ export const posixWord = (s) => (POSIX_BARE.test(String(s)) ? String(s) : `'${String(s).replace(/'/g, "'\\''")}'`);
121
+ // Windows: cmd and PowerShell both honour double quotes, and the program then splits its command line by
122
+ // the C runtime's rules, under which backslashes are literal except where they run up to a quote; there
123
+ // they are doubled, and a quote inside the word is escaped. A comma or a parenthesis is quoted too, because
124
+ // PowerShell reads a bare one as an array or an expression. `$`, a backtick and `%` cannot be quoted the
125
+ // same way for both shells, and a path holding one is left to the reader.
126
+ const WINDOWS_BARE = /^[A-Za-z0-9_+=:./\\-]+$/;
127
+ export const windowsWord = (s) => {
128
+ const w = String(s);
129
+ if (WINDOWS_BARE.test(w)) return w;
130
+ return `"${w.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
131
+ };
132
+
109
133
  /**
110
134
  * The argument that ends Claude Code's own options. Windows PowerShell 5.1 drops a bare `--` before it
111
135
  * reaches a native program, so the line failed with "missing required argument"; quoted, every shell passes
112
136
  * it through (bash, zsh, PowerShell and cmd alike), so a Windows install is handed the quoted form.
113
137
  */
114
- const separator = (platform = process.platform) => (platform === "win32" ? '"--"' : "--");
138
+ const separator = (windowsShell) => (windowsShell ? '"--"' : "--");
115
139
 
116
140
  /**
117
141
  * WHAT THE HOST ACTUALLY RUNS, and on WSL that is not `node`.
@@ -125,6 +149,12 @@ const separator = (platform = process.platform) => (platform === "win32" ? '"--"
125
149
  * -e …`. The distribution is named from `WSL_DISTRO_NAME` when we have it, because a machine with more
126
150
  * than one would otherwise get whichever is default — which may be a distribution with no install.
127
151
  *
152
+ * AND IT IS NEVER LEFT BLANK (owner, 2026-09-19). With no name to hand the row used to drop `-d` and take
153
+ * the default distribution, which fails on a machine whose default is not this one, with nothing on screen
154
+ * to connect the failure to the choice. The row now carries WSL_DISTRO_PLACEHOLDER in the name's place,
155
+ * and the Windows side's hint says plainly to fill it in. `WSL_DISTRO_NAME` is the one place the name can
156
+ * be read from inside the distribution: `/etc/wsl.conf` holds settings for it, not its name.
157
+ *
128
158
  * THE ENVIRONMENT CROSSES THROUGH `env`, NOT THROUGH THE HOST'S OWN env BLOCK. A host on Windows sets
129
159
  * variables for the process it starts, which is `wsl.exe`; they do not cross the boundary into the
130
160
  * distribution, so a work directory set that way is silently absent on the other side and the server
@@ -134,17 +164,32 @@ const separator = (platform = process.platform) => (platform === "win32" ? '"--"
134
164
  * Off WSL the launcher is exactly what it always was, so every row on every other platform is
135
165
  * byte-identical to before. PURE.
136
166
  */
137
- export function stdioLauncher({ server, workDir = null, reportsDir = null, wsl = null } = {}) {
167
+ /**
168
+ * WHICH NODE, AND WHICH FILE (owner, 2026-09-19). A line that said bare `node` ran whatever `node` the
169
+ * assistant's own PATH found. Through `wsl.exe -e`, a non-login shell, that was the distribution's system
170
+ * Node 18 while the demo ran on Node 22, and the server died on a syntax error before saying anything
171
+ * (measured on a Windows laptop). A desktop assistant on any platform starts from a bare PATH the same
172
+ * way. So every line names the absolute Node this install is running on, `process.execPath`, seen from
173
+ * where the line is composed, inside WSL when that is where the install is. It names `serve.mjs`, the
174
+ * entry that refuses an old Node in one plain line before the server loads.
175
+ */
176
+ export const STDIO_ENTRY = "serve.mjs";
177
+
178
+ /** What stands in the distribution's name when it cannot be read, and the line that says to fill it in. */
179
+ export const WSL_DISTRO_PLACEHOLDER = "YOUR-WSL-DISTRIBUTION";
180
+ export const WSL_DISTRO_FILL_IN = `Replace ${WSL_DISTRO_PLACEHOLDER} with this Linux's name before you paste it: \`wsl -l\` in Windows lists the names.`;
181
+
182
+ export function stdioLauncher({ server, workDir = null, reportsDir = null, wsl = null, node = process.execPath } = {}) {
138
183
  const vars = envOf({ workDir, reportsDir });
139
- if (!wsl) return { command: "node", args: [server], env: vars, crossesIntoWsl: false };
184
+ if (!wsl) return { command: node, args: [server], env: vars, crossesIntoWsl: false };
140
185
  const distro = String(wsl.distro ?? "").trim();
141
186
  return {
142
187
  command: "wsl.exe",
143
188
  // `-e` runs the command directly rather than through a login shell, so nothing of the reader's
144
189
  // profile can rewrite the arguments between Windows and the server.
145
- args: [...(distro ? ["-d", distro] : []), "-e",
190
+ args: ["-d", distro || WSL_DISTRO_PLACEHOLDER, "-e",
146
191
  ...(Object.keys(vars).length ? ["env", ...Object.entries(vars).map(([k, v]) => `${k}=${v}`)] : []),
147
- "node", server],
192
+ node, server],
148
193
  // Already carried inside the argument list above; a host-side env block would set them on the
149
194
  // Windows process and never reach the server.
150
195
  env: {},
@@ -157,21 +202,27 @@ export const STDIO_SHAPES = Object.freeze({
157
202
  "claude-cli": {
158
203
  kind: "command",
159
204
  where: null,
160
- render: ({ server, workDir, reportsDir, platform, wsl }) => {
161
- const l = stdioLauncher({ server, workDir, reportsDir, wsl });
205
+ render: ({ server, workDir, reportsDir, platform, wsl, node }) => {
206
+ const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
207
+ // WHICH SHELL READS IT. A native Windows install's line is pasted on Windows, and so is the row
208
+ // that crosses into WSL: it exists for an assistant on the Windows side. Everything else is pasted
209
+ // into a POSIX shell.
210
+ const windowsShell = platform === "win32" || l.crossesIntoWsl;
211
+ const word = windowsShell ? windowsWord : posixWord;
162
212
  // The host's own `-e` flags set variables for the process IT starts. Off WSL that is the server;
163
213
  // through the wrapper it is `wsl.exe`, and they stop at the boundary — so on WSL they ride inside
164
214
  // the command instead and this line carries none.
165
- const flags = l.crossesIntoWsl ? "" : envFlags({ workDir, reportsDir });
166
- return `claude mcp add ${STDIO_SERVER_NAME} --scope user${flags} ${separator(platform)} ${l.command} ${l.args.join(" ")}`;
215
+ const flags = l.crossesIntoWsl ? ""
216
+ : Object.entries(envOf({ workDir, reportsDir })).map(([k, v]) => ` -e ${windowsShell ? word(`${k}=${v}`) : `${k}=${word(v)}`}`).join("");
217
+ return `claude mcp add ${STDIO_SERVER_NAME} --scope user${flags} ${separator(windowsShell)} ${[l.command, ...l.args].map(word).join(" ")}`;
167
218
  },
168
219
  after: null,
169
220
  },
170
221
  "desktop-json": {
171
222
  kind: "config",
172
223
  where: "Settings → Developer → Edit Config",
173
- render: ({ server, workDir, reportsDir, wsl }) => {
174
- const l = stdioLauncher({ server, workDir, reportsDir, wsl });
224
+ render: ({ server, workDir, reportsDir, wsl, node }) => {
225
+ const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
175
226
  return JSON.stringify({
176
227
  mcpServers: {
177
228
  [STDIO_SERVER_NAME]: {
@@ -189,8 +240,8 @@ export const STDIO_SHAPES = Object.freeze({
189
240
  "generic-json": {
190
241
  kind: "config",
191
242
  where: "your agent's MCP server configuration",
192
- render: ({ server, workDir, reportsDir, wsl }) => {
193
- const l = stdioLauncher({ server, workDir, reportsDir, wsl });
243
+ render: ({ server, workDir, reportsDir, wsl, node }) => {
244
+ const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
194
245
  return JSON.stringify({
195
246
  command: l.command,
196
247
  args: l.args,
@@ -203,20 +254,21 @@ export const STDIO_SHAPES = Object.freeze({
203
254
  kind: "config",
204
255
  where: "~/.codex/config.toml",
205
256
  // Written out rather than produced by a TOML library: this is four lines with no user-supplied
206
- // strings in key positions, and adding a dependency to emit them would be the larger risk.
257
+ // strings in key positions, and adding a dependency to emit them would be the larger risk. Every
258
+ // value goes through `tomlString`, so a Windows path's backslashes are escaped as TOML requires.
207
259
  //
208
260
  // `env` and not `env_vars`, deliberately, and CONNECT.md explains why the distinction matters:
209
261
  // Codex does not forward the shell environment, and a credential would have to be forwarded BY NAME
210
262
  // rather than written into a file. This server takes no credential — the work directory is a path,
211
263
  // not a secret — so `env` is correct here and would not be for a server that wanted a key.
212
- render: ({ server, workDir, reportsDir, wsl }) => {
213
- const l = stdioLauncher({ server, workDir, reportsDir, wsl });
264
+ render: ({ server, workDir, reportsDir, wsl, node }) => {
265
+ const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
214
266
  return [
215
267
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
216
- `command = "${l.command}"`,
217
- `args = [${l.args.map((a) => `"${a}"`).join(", ")}]`,
268
+ `command = ${tomlString(l.command)}`,
269
+ `args = [${l.args.map(tomlString).join(", ")}]`,
218
270
  ...(Object.keys(l.env).length
219
- ? [`env = { ${Object.entries(l.env).map(([k, v]) => `${k} = "${v}"`).join(", ")} }`] : []),
271
+ ? [`env = { ${Object.entries(l.env).map(([k, v]) => `${k} = ${tomlString(v)}`).join(", ")} }`] : []),
220
272
  ].join("\n");
221
273
  },
222
274
  after: null,
@@ -276,7 +328,7 @@ export const REMOTE_SHAPES = Object.freeze({
276
328
  label: "Copy block",
277
329
  render: ({ address }) => [
278
330
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
279
- `url = "${address}"`,
331
+ `url = ${tomlString(address)}`,
280
332
  ].join("\n"),
281
333
  },
282
334
  "claude-cli-http": {
@@ -288,7 +340,7 @@ export const REMOTE_SHAPES = Object.freeze({
288
340
  label: null,
289
341
  render: ({ address }) => [
290
342
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
291
- `url = "${address}"`,
343
+ `url = ${tomlString(address)}`,
292
344
  `bearer_token_env_var = "${KEY_ENV_VAR}"`,
293
345
  ].join("\n"),
294
346
  },
@@ -335,11 +387,11 @@ export function remoteConnectFor(shape, { address = null } = {}) {
335
387
  */
336
388
  export const WSL_ROW_HEADINGS = Object.freeze({ fromWindows: "From Windows", insideWsl: "Inside WSL" });
337
389
 
338
- export function stdioConnectFor(shape, { installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform, wsl = null } = {}) {
390
+ export function stdioConnectFor(shape, { installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform, wsl = null, node = process.execPath } = {}) {
339
391
  const spec = Object.hasOwn(STDIO_SHAPES, String(shape ?? "")) ? STDIO_SHAPES[shape] : null;
340
392
  if (!spec) return null;
341
- const server = join(installRoot, "mcp-server", "server.mjs");
342
- const render = (target) => spec.render({ server, workDir, reportsDir, platform, wsl: target });
393
+ const server = join(installRoot, "mcp-server", STDIO_ENTRY);
394
+ const render = (target) => spec.render({ server, workDir, reportsDir, platform, wsl: target, node });
343
395
  const base = { shape, kind: spec.kind, where: spec.where, after: spec.after, name: STDIO_SERVER_NAME };
344
396
  // OFF WSL NOTHING CHANGES: one launcher, no variants, and `variants: null` rather than an empty array
345
397
  // so a consumer cannot read "this install has no sides" as "this install has two sides, both missing".
@@ -355,7 +407,7 @@ export function stdioConnectFor(shape, { installRoot = stableInstallRoot({ insta
355
407
  ...base,
356
408
  text: render(wsl),
357
409
  variants: [
358
- { heading: WSL_ROW_HEADINGS.fromWindows, text: render(wsl) },
410
+ { heading: WSL_ROW_HEADINGS.fromWindows, text: render(wsl), hint: String(wsl.distro ?? "").trim() ? null : WSL_DISTRO_FILL_IN },
359
411
  { heading: WSL_ROW_HEADINGS.insideWsl, text: render(null) },
360
412
  ],
361
413
  };
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // ONE TOML STRING ENCODER, for every block this product writes.
5
+ //
6
+ // Two composers write TOML: the engine's per-run Codex configuration and the connection block a reader
7
+ // pastes into `~/.codex/config.toml`. The second wrapped raw values in double quotes, so a Windows path
8
+ // such as `C:\Program Files\nodejs\node.exe` produced a file TOML parsers refuse ("Unescaped '\' in a
9
+ // string"). A value is a TOML string only once it has been through here.
10
+
11
+ /**
12
+ * A TOML basic string holding exactly `s`. PURE.
13
+ *
14
+ * TOML requires the quotation mark, the backslash and every control character other than tab to be
15
+ * escaped (U+0000 to U+0008, U+000A to U+001F, and U+007F). The named escapes are used where TOML has one,
16
+ * and `\uXXXX` for the rest.
17
+ */
18
+ export function tomlString(s) {
19
+ const esc = String(s ?? "")
20
+ .replace(/\\/g, "\\\\").replace(/"/g, '\\"')
21
+ .replace(/\x08/g, "\\b").replace(/\t/g, "\\t").replace(/\n/g, "\\n")
22
+ .replace(/\f/g, "\\f").replace(/\r/g, "\\r")
23
+ .replace(/[\x00-\x1f\x7f]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
24
+ return `"${esc}"`;
25
+ }
@@ -443,9 +443,8 @@ const H1_BLOCK = /<h1\b[^>]*>([\s\S]*?)<\/h1>/g
443
443
  * heading was entirely its subject.
444
444
  */
445
445
  const isSubjectHeading = (inner) => {
446
- const bare = String(inner)
447
- .replace(/\{(?:[^{}]|\{[^{}]*\})*\}/g, '') // JSX expressions, one level of nesting
448
- .replace(/<[^>]*>/g, '') // nested tags
446
+ let bare = String(inner).replace(/\{(?:[^{}]|\{[^{}]*\})*\}/g, '') // JSX expressions, one level of nesting
447
+ for (let prev = null; prev !== bare;) { prev = bare; bare = bare.replace(/<[^>]*>/g, '') } // nested tags, until none is left
449
448
  return !/[A-Za-z]{2}/.test(bare)
450
449
  }
451
450