clearotron 0.3.2-beta.13 → 0.3.2-beta.15

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 (45) hide show
  1. package/bin/clearotron.mjs +7 -1
  2. package/bin/example.mjs +49 -22
  3. package/build-info.json +2 -2
  4. package/driver/CHANGELOG.md +22 -0
  5. package/driver/contract-vocabulary.mjs +5 -5
  6. package/driver/engine/mcp/codex-config.mjs +3 -11
  7. package/driver/named-band.mjs +1 -1
  8. package/driver/package.json +1 -1
  9. package/driver/pipeline-knockout.mjs +3 -1
  10. package/driver/pipeline.mjs +30 -7
  11. package/driver/publish/index.mjs +10 -1
  12. package/driver/publish/render-knockout.mjs +31 -6
  13. package/driver/publish/render.mjs +54 -2
  14. package/driver/publish/templates/report.css +16 -1
  15. package/driver/register-availability.mjs +2 -2
  16. package/driver/register-plan.mjs +229 -55
  17. package/driver/skills/clearance-register/unit.md +1 -1
  18. package/driver/skills/clearance-variants/SKILL.md +10 -4
  19. package/driver/stages.mjs +1 -1
  20. package/driver/suite-census.json +45 -3
  21. package/driver/variant-manifest-model.mjs +39 -1
  22. package/mcp-server/CHANGELOG.md +8 -0
  23. package/mcp-server/lib/audit-view.mjs +5 -3
  24. package/mcp-server/lib/brief.mjs +10 -4
  25. package/mcp-server/lib/runs.mjs +35 -1
  26. package/mcp-server/package.json +1 -1
  27. package/mcp-server/server.mjs +6 -3
  28. package/package.json +1 -1
  29. package/portal-ui/dist/assets/{index-7Lq-dXDV.css → index-5CCwiJG7.css} +10 -0
  30. package/portal-ui/dist/assets/{index-w8GFZftk.js → index-DMthc7PQ.js} +8 -1
  31. package/portal-ui/dist/index.html +2 -2
  32. package/portal-ui/package.json +1 -1
  33. package/providers/_shared/execute-plan.mjs +11 -1
  34. package/providers/_shared/term-shape.mjs +76 -0
  35. package/providers/clarivate/src/capabilities.js +36 -0
  36. package/providers/clarivate/src/core.js +119 -0
  37. package/providers/corsearch/src/capabilities.js +24 -0
  38. package/providers/corsearch/src/core.js +7 -0
  39. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  40. package/providers/oauth-mcp-bridge/package.json +1 -1
  41. package/providers/signa/src/capabilities.js +29 -0
  42. package/providers/signa/src/core.js +17 -0
  43. package/shared/demo-start-args.mjs +10 -0
  44. package/shared/stdio-connect.mjs +41 -10
  45. package/shared/toml-string.mjs +25 -0
@@ -125,6 +125,35 @@ export const CAPABILITIES = Object.freeze({
125
125
  maxOrWidth: 1,
126
126
  // filters.nice_classes[] is a top-level OR filter — one call, no fan-out.
127
127
  classFilter: "native",
128
+ // ── THE GOODS-AND-SERVICES TEXT FILTER ──────────────────────────────────────────────────────────
129
+ // `filters.goods_services_text` — a free-text filter over the goods and services descriptions,
130
+ // documented by the vendor and composing with `nice_classes` and the query in the same request. It
131
+ // is what lets a crowded contains sweep be narrowed to what a filing actually covers rather than
132
+ // the bucket it was filed in.
133
+ //
134
+ // The key is accepted, it NARROWS, and it takes FREE TEXT — not the whole-word-plus-OR shape
135
+ // Clarivate's equivalent field demands. So the same word list goes to both providers and each
136
+ // connector writes it the way its own field takes it.
137
+ //
138
+ // This API rejects an unknown filter key outright (`HTTP 400 Unrecognized key: …` — the defect that
139
+ // hid a bogus `status` key for two months), which is why one call could settle acceptance.
140
+ goodsTextSearch: true,
141
+ // A multi-word goods term: a bare space here is an implicit AND and quoting is IGNORED, so a phrase
142
+ // cannot be matched AS a phrase — the words simply intersect. That intersection is the nearest
143
+ // honest form of what a phrase asks for, and it is what this connector sends. Nothing pretends it
144
+ // is a phrase.
145
+ // A multi-word term here INTERSECTS its words — it is not a phrase match, and the name says so.
146
+ // See clarivate/capabilities.js for the vocabulary.
147
+ goodsTextMultiWord: "word-intersection",
148
+ // A LIST of alternatives cannot be expressed here AT ALL. Every form was tried and none answers the
149
+ // union: `OR`, lower-case `or` and a quoted OR all return a population SMALLER than either word
150
+ // alone (the OR is matched as a literal third word), a pipe and a comma return the two words
151
+ // INTERSECTED, and an array is refused outright. A long list would therefore intersect to nothing
152
+ // while still answering 200 — the quiet false clean this contract exists to refuse.
153
+ //
154
+ // So the compiler does not build a multi-term goods entry for this provider, and it must never fall
155
+ // back to a space-joined string. No coverage is lost: the broad class-wide sweep still runs.
156
+ goodsTextListOr: false,
128
157
  // Search rows already carry status / nice_classes / owner_name → screening is inline, zero extra calls.
129
158
  screenSource: "search-row",
130
159
  // No hard result ceiling, and no total to compare one against.
@@ -23,6 +23,7 @@
23
23
  import { readFileSync, existsSync } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
+ import { goodsTermsList } from "../../_shared/term-shape.mjs"; // the shared reader for the goods words
26
27
 
27
28
  import { makeLedger } from "../../_shared/ledger.mjs";
28
29
  import { nonAnswerBodyError, parseJsonBody, unparsedBodyError } from "../../_shared/http-body.mjs";
@@ -132,6 +133,22 @@ function buildFilters(p) {
132
133
  // with a text query in ONE request, and the intersection is a real narrowing rather than one clause
133
134
  // being silently ignored — the three populations (term alone, owner alone, both) differ from each other.
134
135
  if (typeof p.owner === "string" && p.owner.trim()) f.owner_name = p.owner.trim();
136
+ // The goods-and-services narrowing. ONE term only: this filter is a single string whose words are
137
+ // ANDed, and no OR form exists (OR is matched as a literal third word; a pipe and a comma intersect;
138
+ // an array is refused). So a list of alternatives cannot be expressed, and joining one with spaces
139
+ // would ask for filings covering EVERY word — a population that shrinks as the list grows while
140
+ // still answering 200. The compiler does not build a multi-term entry for this provider; this
141
+ // refusal is the backstop for any path that does not go through it.
142
+ const goods = goodsTermsList(p);
143
+ if (goods.length > 1) {
144
+ throw new Error(
145
+ `goods_text carries ${goods.length} terms and this register has no OR on its goods filter: the `
146
+ + `words would be intersected, not offered as alternatives, and the answer would narrow as the `
147
+ + `list grew with no error. Send one term.`);
148
+ }
149
+ // A single term's own words ARE intersected, and that is the nearest honest form of the phrase the
150
+ // caller asked for — it is sent as written, and nothing here calls it a phrase match.
151
+ if (goods.length) f.goods_services_text = goods[0];
135
152
  return f;
136
153
  }
137
154
 
@@ -12,6 +12,16 @@
12
12
  * `--demo-own-base` says it is the demo's default, which is what lets the supervisor remove the folder
13
13
  * when the window closes. `--keep` is the reader's, and it travels as given.
14
14
  */
15
+ /**
16
+ * The Node flag the demo runs under, so Node's one-time warning that its built-in SQLite is experimental
17
+ * does not land on the demo's first screen. Named once: the launcher puts it on the demo's own process,
18
+ * and `bin/example.mjs` hands it to the services it starts through NODE_OPTIONS.
19
+ */
20
+ export const EXPERIMENTAL_WARNING_OFF = "--disable-warning=ExperimentalWarning";
21
+
22
+ /** The same flag added to whatever NODE_OPTIONS the reader already has, which is kept. PURE. */
23
+ export const withWarningOff = (nodeOptions) => [nodeOptions, EXPERIMENTAL_WARNING_OFF].filter(Boolean).join(" ");
24
+
15
25
  export function demoStartArgs({ demoBase, readerBase = false, keep = false, port = null, noOpen = false } = {}) {
16
26
  const args = ["--demo", "--base", demoBase];
17
27
  if (!readerBase) args.push("--demo-own-base");
@@ -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)), "..");
@@ -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`.
@@ -180,11 +204,17 @@ export const STDIO_SHAPES = Object.freeze({
180
204
  where: null,
181
205
  render: ({ server, workDir, reportsDir, platform, wsl, node }) => {
182
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;
183
212
  // The host's own `-e` flags set variables for the process IT starts. Off WSL that is the server;
184
213
  // through the wrapper it is `wsl.exe`, and they stop at the boundary — so on WSL they ride inside
185
214
  // the command instead and this line carries none.
186
- const flags = l.crossesIntoWsl ? "" : envFlags({ workDir, reportsDir });
187
- 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(" ")}`;
188
218
  },
189
219
  after: null,
190
220
  },
@@ -224,7 +254,8 @@ export const STDIO_SHAPES = Object.freeze({
224
254
  kind: "config",
225
255
  where: "~/.codex/config.toml",
226
256
  // Written out rather than produced by a TOML library: this is four lines with no user-supplied
227
- // 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.
228
259
  //
229
260
  // `env` and not `env_vars`, deliberately, and CONNECT.md explains why the distinction matters:
230
261
  // Codex does not forward the shell environment, and a credential would have to be forwarded BY NAME
@@ -234,10 +265,10 @@ export const STDIO_SHAPES = Object.freeze({
234
265
  const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
235
266
  return [
236
267
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
237
- `command = "${l.command}"`,
238
- `args = [${l.args.map((a) => `"${a}"`).join(", ")}]`,
268
+ `command = ${tomlString(l.command)}`,
269
+ `args = [${l.args.map(tomlString).join(", ")}]`,
239
270
  ...(Object.keys(l.env).length
240
- ? [`env = { ${Object.entries(l.env).map(([k, v]) => `${k} = "${v}"`).join(", ")} }`] : []),
271
+ ? [`env = { ${Object.entries(l.env).map(([k, v]) => `${k} = ${tomlString(v)}`).join(", ")} }`] : []),
241
272
  ].join("\n");
242
273
  },
243
274
  after: null,
@@ -297,7 +328,7 @@ export const REMOTE_SHAPES = Object.freeze({
297
328
  label: "Copy block",
298
329
  render: ({ address }) => [
299
330
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
300
- `url = "${address}"`,
331
+ `url = ${tomlString(address)}`,
301
332
  ].join("\n"),
302
333
  },
303
334
  "claude-cli-http": {
@@ -309,7 +340,7 @@ export const REMOTE_SHAPES = Object.freeze({
309
340
  label: null,
310
341
  render: ({ address }) => [
311
342
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
312
- `url = "${address}"`,
343
+ `url = ${tomlString(address)}`,
313
344
  `bearer_token_env_var = "${KEY_ENV_VAR}"`,
314
345
  ].join("\n"),
315
346
  },
@@ -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
+ }