clearotron 0.3.3 → 0.4.0-beta.1

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 (142) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +1 -14
  3. package/bin/brandowner.mjs +5 -5
  4. package/bin/connect.mjs +4 -4
  5. package/bin/onboard.mjs +5 -7
  6. package/bin/start.mjs +20 -5
  7. package/bin/update.mjs +6 -1
  8. package/build-info.json +2 -2
  9. package/docs/architecture/04-configuration-reference.md +2 -6
  10. package/driver/CHANGELOG.md +28 -0
  11. package/driver/ask-ledger.mjs +2 -2
  12. package/driver/band-shape.mjs +8 -8
  13. package/driver/blind-frame-model.mjs +1 -1
  14. package/driver/common-law-receipts.mjs +2 -2
  15. package/driver/commonlaw-carry.mjs +2 -2
  16. package/driver/company-bundle.mjs +11 -18
  17. package/driver/connotation-search.mjs +4 -4
  18. package/driver/contract-e3-backlog.mjs +3 -3
  19. package/driver/declination-call.mjs +1 -1
  20. package/driver/declination-tool.mjs +1 -1
  21. package/driver/dev-portal.mjs +1 -1
  22. package/driver/door-call-verdict.mjs +27 -0
  23. package/driver/driver.config.mjs +17 -11
  24. package/driver/e2e/README.md +1 -1
  25. package/driver/engine/mcp/clarivate-server.mjs +2 -1
  26. package/driver/engine/mcp/corsearch-server.mjs +1 -0
  27. package/driver/engine/mcp/euipo-server.mjs +1 -0
  28. package/driver/engine/mcp/free-tier-server.mjs +1 -1
  29. package/driver/engine/mcp/gather-config.mjs +1 -1
  30. package/driver/engine/mcp/perplexity-server.mjs +1 -1
  31. package/driver/engine/mcp/recording-server.mjs +1 -1
  32. package/driver/engine/mcp/signa-server.mjs +1 -0
  33. package/driver/engine/mcp/supplemental.mjs +1 -1
  34. package/driver/engine/mcp/uspto-local-server.mjs +1 -0
  35. package/driver/enqueue-schema.mjs +2 -2
  36. package/driver/feedback-issues.mjs +1 -1
  37. package/driver/feedback-store.mjs +1 -1
  38. package/driver/findings-model.mjs +3 -3
  39. package/driver/flag-snapshot.mjs +1 -1
  40. package/driver/floor-duty.mjs +2 -2
  41. package/driver/form-neighbourhood.mjs +47 -15
  42. package/driver/frame-diff-model.mjs +3 -3
  43. package/driver/gateway.mjs +5 -5
  44. package/driver/jx-lanes.mjs +1 -1
  45. package/driver/known-conflicts.mjs +18 -0
  46. package/driver/log.mjs +2 -2
  47. package/driver/package.json +1 -1
  48. package/driver/pipeline-knockout.mjs +129 -95
  49. package/driver/pipeline.mjs +69 -32
  50. package/driver/placement-carry.mjs +2 -2
  51. package/driver/placement-form.mjs +1 -1
  52. package/driver/portal-mcp-client.mjs +1 -1
  53. package/driver/portal-request-origin.mjs +79 -0
  54. package/driver/portal-service.mjs +45 -17
  55. package/driver/predelivery-lint.mjs +10 -10
  56. package/driver/profile-page.html +9 -13
  57. package/driver/profile-service.mjs +25 -13
  58. package/driver/profiles.mjs +17 -4
  59. package/driver/progress.mjs +1 -1
  60. package/driver/provider-usage.mjs +24 -1
  61. package/driver/publish/index.mjs +17 -5
  62. package/driver/publish/knockout.mjs +3 -2
  63. package/driver/publish/render-knockout.mjs +1 -1
  64. package/driver/publish/render.mjs +14 -3
  65. package/driver/publish/search-depth.mjs +4 -2
  66. package/driver/recall-reconciliation.mjs +1 -1
  67. package/driver/record-carry.mjs +6 -6
  68. package/driver/recording-agreement.mjs +2 -2
  69. package/driver/reference-score.mjs +27 -27
  70. package/driver/register-count.mjs +56 -1
  71. package/driver/register-digest-record.mjs +1 -1
  72. package/driver/register-plan.mjs +5 -5
  73. package/driver/register-records.mjs +10 -1
  74. package/driver/registry-fidelity.mjs +4 -4
  75. package/driver/repair-composers.mjs +6 -6
  76. package/driver/run-economics.mjs +8 -19
  77. package/driver/screen-gate.mjs +1 -1
  78. package/driver/skills/blind-frame/SKILL.md +2 -2
  79. package/driver/skills/clearance-common-law/SKILL.md +2 -2
  80. package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
  81. package/driver/skills/clearance-register/digest.md +1 -1
  82. package/driver/skills/clearance-register/unit.md +1 -1
  83. package/driver/skills/clearance-search/report-prose.md +5 -5
  84. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  85. package/driver/skills/clearance-variants/SKILL.md +2 -2
  86. package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
  87. package/driver/skills/frame-diff/SKILL.md +2 -2
  88. package/driver/skills/knockout-assess/SKILL.md +9 -9
  89. package/driver/skills/matter-frame/watchlist-reference.md +1 -1
  90. package/driver/skills/narrative-refutation/SKILL.md +2 -2
  91. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  92. package/driver/stage-context.mjs +4 -4
  93. package/driver/stages.mjs +23 -15
  94. package/driver/suite-census.json +138 -42
  95. package/driver/systemd/clearotron-client-mcp.service +24 -0
  96. package/driver/systemd/clearotron-mcp-face.service +24 -0
  97. package/driver/systemd/clearotron-portal.service +24 -0
  98. package/driver/systemd/clearotron-worker.service +24 -0
  99. package/driver/tokens.mjs +26 -17
  100. package/driver/turnaround-bands.mjs +1 -1
  101. package/driver/unit-inventory.mjs +3 -3
  102. package/driver/variant-manifest-model.mjs +1 -1
  103. package/driver/verify.mjs +2 -2
  104. package/driver/whatif-memo-run.mjs +1 -1
  105. package/mcp-server/CHANGELOG.md +8 -0
  106. package/mcp-server/lib/audit.mjs +9 -2
  107. package/mcp-server/lib/http-handler.mjs +7 -3
  108. package/mcp-server/mint-token.mjs +8 -6
  109. package/mcp-server/package.json +1 -1
  110. package/mcp-server/server.mjs +11 -1
  111. package/package.json +1 -1
  112. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
  113. package/portal-ui/dist/index.html +1 -1
  114. package/portal-ui/package.json +1 -1
  115. package/providers/_shared/README.md +1 -1
  116. package/providers/_shared/answer-memory.mjs +199 -0
  117. package/providers/_shared/ledger-path.mjs +1 -1
  118. package/providers/_shared/ledger.mjs +47 -5
  119. package/providers/_shared/script-form.mjs +24 -5
  120. package/providers/_shared/term-shape.mjs +5 -5
  121. package/providers/clarivate/src/capabilities.js +11 -0
  122. package/providers/clarivate/src/core.js +140 -13
  123. package/providers/jx-subclass/lookup.mjs +1 -1
  124. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  125. package/providers/oauth-mcp-bridge/package.json +1 -1
  126. package/providers/signa/src/capabilities.js +24 -0
  127. package/providers/signa/src/core.js +66 -0
  128. package/scripts/deprecate-below.mjs +114 -2
  129. package/scripts/freeze-example-run.mjs +1 -1
  130. package/scripts/live-surface-check.mjs +11 -2
  131. package/scripts/release-entry-catch-up.mjs +211 -0
  132. package/scripts/release-note-required.mjs +102 -6
  133. package/scripts/release-rehearsal-version.mjs +60 -0
  134. package/scripts/release-sbom.mjs +104 -0
  135. package/scripts/release-visible-check.mjs +7 -5
  136. package/scripts/score.mjs +3 -3
  137. package/shared/brand.mjs +1 -1
  138. package/shared/client-door.mjs +15 -8
  139. package/shared/driver-dir.mjs +20 -9
  140. package/shared/names-in-force.mjs +2 -0
  141. package/shared/scope.mjs +25 -10
  142. package/shared/store-in-repo.mjs +38 -17
@@ -516,7 +516,7 @@ export const UNIT_INVENTORY = Object.freeze([
516
516
  // `untrackedReason` above is kept in the PAST tense of its own finding on purpose: it records how
517
517
  // this unit was discovered — by enumerating production, not by reading a list — and that is history
518
518
  // the stop does not erase.
519
- orphanReason: "STOPPED AND DISABLED ON PRODUCTION 2026-08-20 on the owner's word (#1437), so it runs "
519
+ orphanReason: "STOPPED AND DISABLED ON PRODUCTION 2026-08-20 on the owner's word, so it runs "
520
520
  + "on no box and `runsOn` is empty. It is not deleted: the unit file, scripts/feedback-mint.mjs and "
521
521
  + "the whole capture path are intact, and `systemctl --user enable --now feedback-mint.timer` as "
522
522
  + "the service account puts it back. It drained a store that never existed — /srv/trademark-archive/_feedback "
@@ -524,8 +524,8 @@ export const UNIT_INVENTORY = Object.freeze([
524
524
  + "flag was ever captured, so stopping it dropped nothing. The decision owed, if any, is whether the "
525
525
  + "feedback machinery is removed rather than parked; driver/run-activity.mjs still carries a note "
526
526
  + "addressed to whoever picks that up, and it is still unclaimed.",
527
- note: "STOPPED. Production ran it every 30 minutes against an input nothing writes to any more "
528
- + "(#1437); the owner's word came 2026-08-20 and it was disabled the same evening.",
527
+ note: "STOPPED. Production ran it every 30 minutes against an input nothing writes to any more"
528
+ + "; the owner's word came 2026-08-20 and it was disabled the same evening.",
529
529
  },
530
530
  // ── OTHER REPOS' UNITS, SHARING THE BOX ───────────────────────────────────────────────────────────
531
531
  // Declared, not adopted. A unit this repo does not own is still a unit the arm enumerates, and an
@@ -332,7 +332,7 @@ export function variantRomanizationGaps(model) {
332
332
  * which is also where the incident string came from.
333
333
  *
334
334
  * `watchlist_owners` — those compile to `predicate:"owner"`, exempt from every term rule because
335
- * owner names are prose by nature ("Delphi Technologies (BorgWarner Inc.)").
335
+ * owner names are prose by nature ("Korphi Technologies (BorgWarner Inc.)").
336
336
  */
337
337
  // — `termAnnotationIssue` moved to term-shape.mjs, the ONE term vocabulary, so the plan
338
338
  // compiler applies the SAME predicate this gate does rather than a second copy of it.
package/driver/verify.mjs CHANGED
@@ -848,7 +848,7 @@ export const CORRECTION_KINDS = ["coverage-disposition", "fact", "rating", "narr
848
848
  const CORRECTION_KIND_RE = /\[kind:\s*([a-z][a-z-]*)\s*\]/i;
849
849
  // — THE SECOND CHANNEL: WHICH FINDING A FLAG IS ABOUT.
850
850
  //
851
- // The reviewer already writes it, in prose — a delivered review's flags open "Finding 9 — DELPHIC…",
851
+ // The reviewer already writes it, in prose — a delivered review's flags open "Finding 9 — KORPHIC…",
852
852
  // "Findings 4, 7, 8." — and the driver could not read it: `targetsOf` matched mark/owner NAMES and
853
853
  // returned nothing for six of nine flags on that run. So the corrective pass re-emitted the whole
854
854
  // narrative and the whole findings.json, for 683 seconds, and exactly ONE finding moved.
@@ -2574,7 +2574,7 @@ function checkClientSummaryJoin(p, c) {
2574
2574
  const risk = body.match(/^-\s*risk:\s*([A-Za-z –-]+)$/m)?.[1]?.trim();
2575
2575
  if (!risk) continue;
2576
2576
  // wp50: deterministic join (ord line → exact mark → unique containment). The old first-match
2577
- // containment join bound "DEMVENZY — Novartis" to the VENZY finding and VALIDATED the wrong tier.
2577
+ // containment join bound "DEMVENZY — Norvanta" to the VENZY finding and VALIDATED the wrong tier.
2578
2578
  const f = joinFindingToBlock({ ord: parseBlockOrd(body), head }, findingsDoc.findings);
2579
2579
  if (!f) continue; // unjoinable block — shape checks own it
2580
2580
  if (v4) {
@@ -97,7 +97,7 @@ export function movedArtifacts(before = {}, after = {}) {
97
97
  *
98
98
  * ✕ EVERYTHING ELSE IS THE SEAT WRITING WHERE IT WAS NOT INVITED, and it is refused by name. The seat
99
99
  * has a real file-write grant on the run directory, and on a delivered run it used it: an archived
100
- * client run carries `memo-osler-coexistence.json` at its root, which is the seat's reply written to a
100
+ * client run carries `memo-halver-coexistence.json` at its root, which is the seat's reply written to a
101
101
  * path of its own choosing instead of to the `expectFile` it was given. That also explains the retry
102
102
  * that preceded it — the harness looked for the reply where it had asked for it and did not find it.
103
103
  *
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.0-beta.1
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.0-beta.0
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.3
4
12
 
5
13
  No changes in this release.
@@ -50,7 +50,7 @@ export function summarize(body) {
50
50
  */
51
51
  export const UNNAMED_DOOR = "unnamed";
52
52
 
53
- export function appendAudit({ email, sub, body, status, transport, door, path = DEFAULT_AUDIT_PATH }) {
53
+ export function appendAudit({ email, sub, kind = null, namesVerbs = null, body, status, transport, door, path = DEFAULT_AUDIT_PATH }) {
54
54
  // `sub` = the inner-token PRINCIPAL (ops-token issuance, INSTALL.md §8) — distinguishes
55
55
  // two automations sharing a transport identity. null for internal/user sessions without a sub claim.
56
56
  //
@@ -69,7 +69,14 @@ export function appendAudit({ email, sub, body, status, transport, door, path =
69
69
  // WRITTEN ONLY WHEN GIVEN, the same rule as `transport` and for the same reason: the existing log
70
70
  // shape must not move for records that have no answer to this. A door that does not name itself is a
71
71
  // record with no `door` key, not a record claiming to be from nowhere.
72
- const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}), door: door || UNNAMED_DOOR }) + "\n";
72
+ //
73
+ // `kind` is the principal's kind (ops, user, account, internal), and `namesVerbs` says, for an ops token
74
+ // only, whether it names its write verbs. An ops token that names none is being retired: re-issued with
75
+ // verbs, then refused once this log shows none in use. So the log must be able to count them. Written
76
+ // only when given, by the same rule as `transport`: a caller that never knew writes no claim.
77
+ const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null,
78
+ ...(kind ? { kind } : {}), ...(typeof namesVerbs === "boolean" ? { namesVerbs } : {}),
79
+ ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}), door: door || UNNAMED_DOOR }) + "\n";
73
80
  try { mkdirSync(dirname(path), { recursive: true }); appendFileSync(path, line); } catch { /* best-effort */ }
74
81
  }
75
82
 
@@ -278,7 +278,11 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
278
278
  : send(res, 401, { error: `invalid trademark token: ${e.message}` });
279
279
  }
280
280
  const transport = await createSession(sessions, scope, user.email);
281
- entry = { transport, sub: scope.sub ?? null, kind: scope.kind ?? null };
281
+ // `namesVerbs` rides with the session so EVERY call it makes is audited with it, not only the
282
+ // first: an ops token that names no verbs is being retired (re-issued with verbs, then refused),
283
+ // and the audit is how the live ones are found. Whether it names verbs, never which, never the token.
284
+ const namesVerbs = scope.kind === "ops" ? Array.isArray(scope.verbs) && scope.verbs.length > 0 : null;
285
+ entry = { transport, sub: scope.sub ?? null, kind: scope.kind ?? null, namesVerbs };
282
286
  // STAMP THE SCOPE'S OWN FACTS ONTO THE STORED ENTRY, HERE, AND AFTER THE HANDSHAKE.
283
287
  //
284
288
  // `createSession` is injected, and every caller carries its own copy of the entry shape — two
@@ -293,7 +297,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
293
297
  // and left the gate reading a field nobody had set — green, and doing nothing.
294
298
  stampScope = () => {
295
299
  const stored = transport.sessionId ? sessions.get(transport.sessionId) : null;
296
- if (stored) { stored.sub = scope.sub ?? null; stored.kind = scope.kind ?? null; }
300
+ if (stored) { stored.sub = scope.sub ?? null; stored.kind = scope.kind ?? null; stored.namesVerbs = namesVerbs; }
297
301
  };
298
302
  } else {
299
303
  if (entry.email && entry.email !== user.email) {
@@ -328,7 +332,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
328
332
  // `finish` fires once the response is fully sent, which is when `res.statusCode` is the answer
329
333
  // the caller got rather than the default it started as.
330
334
  res.once("finish", () => {
331
- try { appendAudit({ email: user.email, sub: entry.sub ?? null, body, status: res.statusCode, door }); }
335
+ try { appendAudit({ email: user.email, sub: entry.sub ?? null, kind: entry.kind ?? null, namesVerbs: entry.namesVerbs ?? null, body, status: res.statusCode, door }); }
332
336
  catch { /* best-effort */ }
333
337
  });
334
338
  const answered = entry.transport.handleRequest(req, res, body);
@@ -15,7 +15,7 @@
15
15
  // intake connector never needs stop_run.
16
16
 
17
17
  import { fileURLToPath } from "node:url";
18
- import { mintToken, verifyToken, TOOL_SCOPES } from "../shared/scope.mjs";
18
+ import { mintToken, verifyToken } from "../shared/scope.mjs";
19
19
  import { homedir } from "node:os";
20
20
  import { defaultDenylistPath } from "../shared/client-door.mjs"; // — one owner for the path
21
21
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
@@ -25,7 +25,7 @@ function fail(msg, code = 1) { process.stderr.write(`mint-token: ${msg}\n`); pro
25
25
  const USAGE = `usage: node mint-token.mjs --scope ops|user|account [options]
26
26
  --scope ops an automation/operator principal (write verbs allowed)
27
27
  --sub <name> REQUIRED for ops: the principal name (rides into the audit log)
28
- --verbs a,b least-privilege write-verb allowlist (recommended; omit = full ops)
28
+ --verbs a,b REQUIRED for ops: the write tools this token may call
29
29
  --accounts a,b GRANTS cap: the account keys (profileKeys) this token may see/start (omit = all)
30
30
  --ttl-days <n> default 30
31
31
  --scope user a run-bound report-link token (read-only client layer)
@@ -76,6 +76,12 @@ export function mintFromOptions({ scope, sub = null, runId = null, ttlDays, verb
76
76
  // They belong here rather than at either caller for exactly the reason the extraction existed: a rule
77
77
  // enforced by one of two callers is a rule the other one does not have.
78
78
  if (scope === "ops" && !sub) throw new Error("--sub is required for an ops token — the audit log must name the principal");
79
+ // AN OPS TOKEN NAMES ITS VERBS. One that named none held every write verb, and over the network that
80
+ // included starting a what-if; it is the most valuable credential the product issues, so it is no longer
81
+ // issued. Tokens already out keep working: the door records their use (the audit line's `namesVerbs`) so
82
+ // they can be found, re-issued with verbs and then refused, in that order.
83
+ if (scope === "ops" && !(Array.isArray(verbs) && verbs.length))
84
+ throw new Error("--verbs is required for an ops token — name the write tools it may call, for example --verbs start_run,stop_run");
79
85
  if (scope === "user" && !runId) throw new Error("--run is required for a user token (it is bound to exactly one run)");
80
86
  if (scope === "account" && !sub) throw new Error("--sub is required for an account key — it names the grants-file identity the key reads its accounts from");
81
87
  if (!Number.isFinite(ttlDays) || ttlDays <= 0) throw new Error("--ttl-days must be a positive number");
@@ -110,10 +116,6 @@ export function mintFromOptions({ scope, sub = null, runId = null, ttlDays, verb
110
116
  notes.push(`revoke this TOKEN specifically: add "${t.jti}" as a line in ${denylistFile()}`);
111
117
  if (scope === "account")
112
118
  notes.push(`the grants row IS the reach — an account key reads its accounts from the grants file on every request`);
113
- if (scope === "ops" && !verbs) {
114
- const writable = Object.keys(TOOL_SCOPES).filter((k) => TOOL_SCOPES[k].write);
115
- notes.push(`note: FULL ops authority (${writable.join(", ")}) — consider --verbs for automation principals`);
116
- }
117
119
  return { token, claims: t, notes };
118
120
  }
119
121
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.3",
3
+ "version": "0.4.0-beta.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -668,7 +668,7 @@ const TOOL_DEFS = [
668
668
  { name: "plan_run", description: "FREE PREVIEW — resolve what a search WOULD do and return it, spending nothing and queueing nothing. Takes the SAME arguments as start_run. Use this BEFORE start_run whenever the requester has not already confirmed the specifics: it reports the PRODUCT that resolves and WHERE it came from (their request, the account default, a saved search, or the territories themselves), the territories and marketplaces that would ACTUALLY be searched, the turnaround, and any blockers as questions to put back. Show the result to the requester; then call start_run with the SAME arguments to actually run it. Nothing here reserves or holds anything.", inputSchema: { type: "object", properties: { markName: { type: "string", description: "The mark (single-mark form). REQUIRED unless marks[] is given." }, marks: { type: "array", items: { type: "object", properties: { name: { type: "string" }, classes: { type: "array", items: { type: "number" } }, ref: { type: "string" } }, required: ["name"] } }, classes: { type: "array", items: { type: "number" } }, goods: { type: "string" }, jurisdictions: { type: "array", items: { type: "string" }, description: JURISDICTIONS_DESC }, platforms: { type: "array", items: { type: "string" } }, customer: { type: "string" }, profileKey: { type: "string", description: "The customer ACCOUNT key (see list_profiles). OMIT it for a new or unknown client and the search runs under the neutral generic profile, which is never a reason to refuse a search — offer that and the portal route (their own company, with its own framework and territories) in the same reply. On a session whose access names accounts, omitting it needs the generic profile to be one of them; when it is not, the refusal says so and names what the session does hold (see describe_options)." }, projectKey: { type: "string" }, forwarder: { type: "string", description: "Requester/reply-routing key — same as start_run, so the preview describes the job that would actually be built. Account sessions may omit it — it is stamped from your verified identity." }, forwarderEmail: { type: "string" }, ref: { type: "string" }, product: { type: "string", enum: ORDERABLE_PRODUCTS, description: PRODUCT_DESC }, nativeLanguage: { type: "boolean", description: "OPTIONAL, and only on a multi-country-focus-search: the native-language investigation. Send TRUE or omit the field — FALSE IS REFUSED, not honoured: the toggle only ever added, so there is nothing for false to switch off, and accepting it would let you believe you had removed a reading that runs anyway. It runs AUTOMATICALLY on a full-country-search and is not part of the other two, so asking for it there is refused rather than accepted and ignored. It routes on territory — the scope must name one it covers." }, worldwide: { type: "boolean", description: "Search EVERYWHERE, and refuse to be narrowed by the account's own default territories. This is not the same as omitting jurisdictions, which means \"whatever the account says\" — the two used to be indistinguishable on this wire, which is how a request that bought everywhere ran an account's seven countries. Never send \"Worldwide\" as a jurisdictions entry." }, recipeKey: { type: "string" }, upfrontInstructions: { type: "string" }, deadline: { type: "string" }, deliveryRoute: { type: "string", enum: ["email", "portal"], description: "OPTIONAL: how the delivered packet leaves — email (default). \"portal\" is reserved for the portal delivery lane and CLARIFIES at admission until it ships (never a silent no-op) — so the preview BLOCKS it here rather than describing a job start_run could not run." }, commercialFlexibility: { type: "string" }, priorUse: { type: "string" }, campaignShape: { type: "string", description: "Campaign-shape FACTS from the request/client — how the mark will be deployed: standalone brand vs flavour/sub-brand under a NAMED house mark; seasonal/limited vs permanent; launch scale. Verbatim facts only — the engine records them on the matter frame instead of inferring a launch shape." }, customerUnknown: { type: "boolean" } }, required: ["forwarder"] } },
669
669
  // ---- OPS write-face (requires an ops token; never exposed to a user/internal session) ----
670
670
  { name: "start_run", description: "OPS-ONLY. Enqueue a NEW clearance/search run the driver will pick up. Provide markName (or marks[] for a batch) + forwarder (and ideally classes/goods/customer/profileKey/ref). Returns the queue id + slug; the runId/codename is assigned when the runner claims it — poll list_runs (by markName) or run_changes for progress. Spends real money once it runs.", inputSchema: { type: "object", properties: { agent: { type: "string", description: "Which agent workspace's queue. Omit for the deployment default (CLEAROTRON_QUEUE_DIR or the default agent's queue — docs/INTAKE.md)." }, markName: { type: "string", description: "The mark (single-mark form). Required unless marks[] is provided." }, marks: { type: "array", items: { type: "object", properties: { name: { type: "string" }, classes: { type: "array", items: { type: "number" } }, ref: { type: "string" } }, required: ["name"] }, description: "Batch form: EVERY name of the batch in ONE job. Only a knockout-search reads more than one name at a time; a clearance reads one, and more is refused rather than truncated." }, classes: { type: "array", items: { type: "number" }, description: "Nice classes — whole numbers 1–45 (1–34 goods, 35–45 services). classes OR goods; either suffices." }, goods: { type: "string" }, jurisdictions: { type: "array", items: { type: "string" }, description: JURISDICTIONS_DESC }, platforms: { type: "array", items: { type: "string" }, description: "OPTIONAL per-run SCOPE: extra marketplaces to sweep, as bare store domains e.g. [\"gnc.com\"]. ADDED to the account's own marketplaces — additive only, never a replacement (a client's platform list is a mandate). Max 10; \"web\" is implicit and must not be listed." }, customer: { type: "string" }, profileKey: { type: "string", description: "The customer ACCOUNT key (see list_profiles) whose profile rates this run; omit for the neutral generic profile — that is how a clearance for a NEW client runs, and a client with no account yet is never a reason to refuse one. Offer it alongside the portal route (their own company, with its own framework, territories and marketplaces) so the requester chooses. On a session whose access names accounts, omitting it needs the generic profile to be one of them; when it is not, the refusal says so and names what the session does hold (see describe_options)." }, forwarder: { type: "string", description: "Requester/reply-routing key — rides the delivery packet so the integrator knows who gets the report (docs/DELIVERY.md). Account sessions may omit it — it is stamped from your verified identity." }, forwarderEmail: { type: "string" }, ref: { type: "string" }, provider: { type: "string" }, brief: { type: "string" }, upfrontInstructions: { type: "string" }, projectKey: { type: "string", description: "OPTIONAL (spec 62): the PROJECT/engagement key UNDER profileKey (see that customer's projects[] in list_profiles) whose overlay rates this run; omit to run on the customer profile. An unknown key clarifies at intake." }, product: { type: "string", enum: ORDERABLE_PRODUCTS, description: PRODUCT_DESC }, nativeLanguage: { type: "boolean", description: "OPTIONAL, and only on a multi-country-focus-search: the native-language investigation. Send TRUE or omit the field — FALSE IS REFUSED, not honoured: the toggle only ever added, so there is nothing for false to switch off, and accepting it would let you believe you had removed a reading that runs anyway. It runs AUTOMATICALLY on a full-country-search and is not part of the other two, so asking for it there is refused rather than accepted and ignored. It routes on territory — the scope must name one it covers." }, worldwide: { type: "boolean", description: "Search EVERYWHERE, and refuse to be narrowed by the account's own default territories. This is not the same as omitting jurisdictions, which means \"whatever the account says\" — the two used to be indistinguishable on this wire, which is how a request that bought everywhere ran an account's seven countries. Never send \"Worldwide\" as a jurisdictions entry." }, recipeKey: { type: "string", description: "OPTIONAL alternative selector: a saved search (recipe) slug for this customer. Mutually exclusive with product — a saved search already carries one." }, deliveryRoute: { type: "string", enum: ["email", "portal"], description: "OPTIONAL: how the delivered packet leaves — email (default). \"portal\" is reserved for the portal delivery lane and CLARIFIES at admission until it ships (never a silent no-op)." }, parentRunId: { type: "string", description: "OPTIONAL escalation lineage: the runId this run escalates from (e.g. a knockout HIGH mark → this clearotron)." }, deliverableSpec: { type: "string" }, commercialFlexibility: { type: "string" }, priorUse: { type: "string" }, campaignShape: { type: "string", description: "Campaign-shape FACTS from the request/client — how the mark will be deployed: standalone brand vs flavour/sub-brand under a NAMED house mark; seasonal/limited vs permanent; launch scale. Verbatim facts only — the engine records them on the matter frame instead of inferring a launch shape." }, deadline: { type: "string", description: "ISO-8601 — drives the deadline envelope." }, customerUnknown: { type: "boolean" }, dupOverride: { type: "boolean", description: "Requester-confirmed force-run past matter dedup." }, clientPrincipal: { type: "boolean", description: "Set by the client portal only: this run was started by a CLIENT and consumes that account's runCaps.dailyRuns allowance. Omit for staff, agent and email-door runs — absence means uncapped, and setting it can only restrict the run that carries it." }, rawRequest: { type: "string" }, forwarderDomain: { type: "string" }, msgId: { type: "string" }, conversationId: { type: "string" }, id: { type: "string" } }, required: ["forwarder"] } },
671
- { name: "stop_run", description: "OPS-ONLY. Cancel a run. Pass id to remove a not-yet-claimed queued job (a real cancel, no spend). Pass runId to file a cancel request on a started run. By default the cancel is COOPERATIVE — the sentinel is written and a turn already in flight is allowed to finish, which has no deadline: a reasoning turn can run for tens of minutes. Add immediate:true to also end that turn: it is sent a stop, anything of it still running a few seconds later is killed, and the answer says the turn ended only once it has seen it end. If it could not be ended, the run ends at its next step boundary, and the answer says that instead.", inputSchema: { type: "object", properties: { agent: { type: "string" }, id: { type: "string", description: "A queued job id (from start_run) — dequeues before the runner claims it." }, runId: { type: "string", description: "A started run's runId — files a cancel sentinel." }, immediate: { type: "boolean", description: "Stop NOW instead of at the next step boundary. The cancel sentinel is written FIRST and then the run's own recorded engine turn is stopped, in that order — the sentinel is what makes the record clean, so the terminal names the stage, the actor and the request time instead of leaving a run that reads as still running. The turn is sent SIGTERM (to its whole process group when it leads one, as both engines spawn it, so the tools it started are reached too), watched, and sent SIGKILL if anything of it is still running when the grace runs out; the answer waits for that, which takes seconds. A turn that is stopped loses its step's work; everything already recorded is kept. Sending the stop is not the same as the turn ending, and the answer claims the second only when it has seen it (immediate.ended). Falls back to the boundary stop, and SAYS SO in the answer, when there is no live turn to signal — a run between steps, or one whose turn has already exited — or when the turn was still there after the SIGKILL. The answer states which of the two actually happened."}, onBehalfOf: { type: "string", description: "The identifier of a human this caller has itself verified (#1378). RECORDED BESIDE the token's own principal, never instead of it — a stop by the portal for alice@x is written `portal:alice@x`, so an asserted name is never mistaken for a proved one. Bare identifiers only; anything else is dropped." } } } },
671
+ { name: "stop_run", description: "OPS-ONLY. Cancel a run. Pass id to remove a not-yet-claimed queued job (a real cancel, no spend). Pass runId to file a cancel request on a started run. By default the cancel is COOPERATIVE — the sentinel is written and a turn already in flight is allowed to finish, which has no deadline: a reasoning turn can run for tens of minutes. Add immediate:true to also end that turn: it is sent a stop, anything of it still running a few seconds later is killed, and the answer says the turn ended only once it has seen it end. If it could not be ended, the run ends at its next step boundary, and the answer says that instead.", inputSchema: { type: "object", properties: { agent: { type: "string" }, id: { type: "string", description: "A queued job id (from start_run) — dequeues before the runner claims it." }, runId: { type: "string", description: "A started run's runId — files a cancel sentinel." }, immediate: { type: "boolean", description: "Stop NOW instead of at the next step boundary. The cancel sentinel is written FIRST and then the run's own recorded engine turn is stopped, in that order — the sentinel is what makes the record clean, so the terminal names the stage, the actor and the request time instead of leaving a run that reads as still running. The turn is sent SIGTERM (to its whole process group when it leads one, as both engines spawn it, so the tools it started are reached too), watched, and sent SIGKILL if anything of it is still running when the grace runs out; the answer waits for that, which takes seconds. A turn that is stopped loses its step's work; everything already recorded is kept. Sending the stop is not the same as the turn ending, and the answer claims the second only when it has seen it (immediate.ended). Falls back to the boundary stop, and SAYS SO in the answer, when there is no live turn to signal — a run between steps, or one whose turn has already exited — or when the turn was still there after the SIGKILL. The answer states which of the two actually happened."}, onBehalfOf: { type: "string", description: "The identifier of a human this caller has itself verified. RECORDED BESIDE the token's own principal, never instead of it — a stop by the portal for alice@x is written `portal:alice@x`, so an asserted name is never mistaken for a proved one. Bare identifiers only; anything else is dropped." } } } },
672
672
  { name: "server_info", description: "The AGPL §13 source offer: this server's name, version, licence, source repository and the COMMIT IT IS RUNNING. Callable by every session kind — the offer is owed to whoever interacts with the service. Reads no run and no account data.", inputSchema: { type: "object", properties: {} } },
673
673
  { name: "list_outbox_events", description: "Integrator discovery read (docs/DELIVERY.md): every pending outbox event — delivered markers plus the self-contained run-failed / intake-rejected / duplicate-skipped / late-bind-ack packets. Route each, then mark_sent (delivered) or ack_event (the rest).", inputSchema: { type: "object", properties: {} } },
674
674
  { name: "get_delivery_packet", description: "The run's send payload: _driver/delivery.json (subject, recipient routing, verbatim emailBodyHtml, whatsapp line) and/or failure.json, plus sendPending/.sent state. NOT exposed to client tokens.", inputSchema: { type: "object", properties: { runId: { type: "string" } }, required: ["runId"] } },
@@ -851,6 +851,16 @@ export function attachHandlers(server, { scope = { kind: "ops", runId: null }, l
851
851
  let authedArgs;
852
852
  try { authedArgs = authorize(scope, name, args); }
853
853
  catch (e) { log(`authz deny ${name} [${scope.kind}]: ${e.message}`); return { isError: true, content: [{ type: "text", text: `FORBIDDEN (${name}): ${e.message}` }] }; }
854
+ // THE LISTING IS THE WALL. A tool this session's audience is not shown is one it cannot call: this
855
+ // used to filter only the listing, and a hidden tool called by name ran anyway once authorize()
856
+ // passed it. That is how an ops token reached what_if_run over HTTP, where the listing hides it and
857
+ // the engine starts. After authorize(), so a caller it already refuses keeps the refusal it names;
858
+ // this catches only what it lets through. Same answer as a name that does not exist, because to this
859
+ // session it does not.
860
+ if (!allow(name)) {
861
+ log(`listing deny ${name} [${scope.kind}${local ? "" : ", http"}]: not offered to this session`);
862
+ return { isError: true, content: [{ type: "text", text: `unknown tool "${name}"` }] };
863
+ }
854
864
  // ACCOUNT GATE (GRANTS, INSTALL.md §8): a run-addressed call from an account-scoped session must
855
865
  // target a run inside the grant. Enforced BEFORE dispatch so a denied run leaks nothing — not even
856
866
  // its existence (the deny message names the account, never the run's contents).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.3",
4
+ "version": "0.4.0-beta.1",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -11865,6 +11865,7 @@ var api = {
11865
11865
  accountNames: Object.fromEntries(Object.entries(asRecord(b["accountNames"])).filter(([, v]) => typeof v === "string" && v)),
11866
11866
  accountFacts: Object.fromEntries(Object.entries(asRecord(b["accountFacts"])).map(([k, v]) => [k, asCompanyFacts(v)])),
11867
11867
  concurrentRuns: asNumber(b["concurrentRuns"]),
11868
+ houseMarketplaces: asStrings(b["houseMarketplaces"]),
11868
11869
  engineMode: b["engineMode"] === "demo" ? "demo" : b["engineMode"] === "engine-unproven" ? "engine-unproven" : null,
11869
11870
  engineProgramDisputed: b["engineProgramDisputed"] === true ? true : b["engineProgramDisputed"] === false ? false : null,
11870
11871
  setupRoute: b["setupRoute"] === "packaged" ? "packaged" : b["setupRoute"] === "checkout" ? "checkout" : null,
@@ -21701,11 +21702,14 @@ var PROFILE_FIELDS = [
21701
21702
  label: "Marketplaces",
21702
21703
  kind: "lines",
21703
21704
  group: "defaults",
21705
+ picker: "marketplaces",
21706
+ clearToEmptyList: true,
21704
21707
  item: {
21705
21708
  ok: (e) => {
21706
21709
  const d = e.trim().toLowerCase();
21707
21710
  return d !== "web" && !/\s/.test(d) && /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/.test(d);
21708
21711
  },
21712
+ strict: true,
21709
21713
  expected: "a bare store domain like amazon.com"
21710
21714
  },
21711
21715
  hint: "One per line. A project can add to this list; it can never remove one."
@@ -21752,7 +21756,10 @@ var PROJECT_EDITABLE = new Set([
21752
21756
  "industry",
21753
21757
  "defaultProduct"
21754
21758
  ]);
21755
- var projectFields = () => PROFILE_FIELDS.filter((f) => PROJECT_EDITABLE.has(rootKey(f)) && !f.customerOnly);
21759
+ var projectFields = () => PROFILE_FIELDS.filter((f) => PROJECT_EDITABLE.has(rootKey(f)) && !f.customerOnly).map((f) => f.clearToEmptyList ? {
21760
+ ...f,
21761
+ clearToEmptyList: false
21762
+ } : f);
21756
21763
  var choiceLabel = (spec, value) => spec.choices?.find((c) => c.value === value)?.label ?? null;
21757
21764
  function depthChoices(products) {
21758
21765
  return products.map((l) => {
@@ -21800,6 +21807,10 @@ function applyField(draft, spec, raw) {
21800
21807
  const next = { ...draft };
21801
21808
  const trimmed = raw.trim();
21802
21809
  if (!trimmed) {
21810
+ if (spec.clearToEmptyList) {
21811
+ next[spec.key] = [];
21812
+ return next;
21813
+ }
21803
21814
  if (spec.clearWith !== void 0) {
21804
21815
  next[spec.key] = spec.clearWith;
21805
21816
  return next;
@@ -21947,7 +21958,7 @@ var suggestionStyle = {
21947
21958
  background: "var(--surface-raised)",
21948
21959
  color: "var(--text-muted)"
21949
21960
  };
21950
- function FieldPicker({ spec, value, onChange }) {
21961
+ function FieldPicker({ spec, value, onChange, suggestions = [] }) {
21951
21962
  const [query, setQuery] = (0, import_react.useState)("");
21952
21963
  const addId = (0, import_react.useId)();
21953
21964
  const addBox = (0, import_react.useRef)(null);
@@ -22026,7 +22037,43 @@ function FieldPicker({ spec, value, onChange }) {
22026
22037
  })]
22027
22038
  });
22028
22039
  }
22029
- const suggestions = matchTerritoriesIn(TERRITORIES, query, chosen, 8);
22040
+ if (spec.picker === "marketplaces") {
22041
+ const have = new Set(chosen.map((c) => c.trim().toLowerCase()));
22042
+ const offered = suggestions.filter((d) => !have.has(d.trim().toLowerCase()));
22043
+ return (0, import_jsx_runtime.jsxs)("div", {
22044
+ style: { marginTop: 6 },
22045
+ children: [offered.length ? (0, import_jsx_runtime.jsx)("div", {
22046
+ style: {
22047
+ display: "flex",
22048
+ flexWrap: "wrap",
22049
+ gap: 4
22050
+ },
22051
+ children: offered.map((d) => (0, import_jsx_runtime.jsxs)("button", {
22052
+ type: "button",
22053
+ onClick: () => toggle(d),
22054
+ style: suggestionStyle,
22055
+ children: ["+ ", d]
22056
+ }, d))
22057
+ }) : null, chosen.length ? (0, import_jsx_runtime.jsx)("div", {
22058
+ className: "chip-row",
22059
+ style: { marginTop: 8 },
22060
+ children: chosen.map((d) => (0, import_jsx_runtime.jsxs)("span", {
22061
+ className: "chip chip-own",
22062
+ children: [d, (0, import_jsx_runtime.jsx)("button", {
22063
+ type: "button",
22064
+ className: "chip-x",
22065
+ onClick: () => toggle(d),
22066
+ "aria-label": `Remove ${d}`,
22067
+ children: (0, import_jsx_runtime.jsx)(Icon, {
22068
+ name: "x",
22069
+ size: 13
22070
+ })
22071
+ })]
22072
+ }, d))
22073
+ }) : null]
22074
+ });
22075
+ }
22076
+ const found = matchTerritoriesIn(TERRITORIES, query, chosen, 8);
22030
22077
  return (0, import_jsx_runtime.jsxs)("div", {
22031
22078
  style: { marginTop: 6 },
22032
22079
  children: [
@@ -22046,14 +22093,14 @@ function FieldPicker({ spec, value, onChange }) {
22046
22093
  fontFamily: "inherit"
22047
22094
  }
22048
22095
  }),
22049
- suggestions.length ? (0, import_jsx_runtime.jsx)("div", {
22096
+ found.length ? (0, import_jsx_runtime.jsx)("div", {
22050
22097
  style: {
22051
22098
  display: "flex",
22052
22099
  flexWrap: "wrap",
22053
22100
  gap: 4,
22054
22101
  marginTop: 5
22055
22102
  },
22056
- children: suggestions.map((t) => (0, import_jsx_runtime.jsxs)("button", {
22103
+ children: found.map((t) => (0, import_jsx_runtime.jsxs)("button", {
22057
22104
  type: "button",
22058
22105
  onClick: () => {
22059
22106
  toggle(t);
@@ -22092,7 +22139,7 @@ function FieldTag({ tag }) {
22092
22139
  children: tag
22093
22140
  });
22094
22141
  }
22095
- function Field({ spec, value, choices, onChange }) {
22142
+ function Field({ spec, value, choices, onChange, suggestions }) {
22096
22143
  const picker = spec.kind === "choice" || spec.kind === "boolean";
22097
22144
  const multi = spec.kind === "lines" || spec.kind === "prose";
22098
22145
  const classes = spec.picker === "classes";
@@ -22138,7 +22185,8 @@ function Field({ spec, value, choices, onChange }) {
22138
22185
  (0, import_jsx_runtime.jsx)(FieldPicker, {
22139
22186
  spec,
22140
22187
  value,
22141
- onChange
22188
+ onChange,
22189
+ ...suggestions ? { suggestions } : {}
22142
22190
  })
22143
22191
  ]
22144
22192
  });
@@ -22188,7 +22236,7 @@ function NewCompany({ ctx }) {
22188
22236
  const key = derivedKey || (keyTyped ?? "");
22189
22237
  useUnsaved((Boolean(typedName) || Object.keys(state.edits).length > 0) && !result);
22190
22238
  const refusedEntries = specs().flatMap((spec) => {
22191
- if (!spec.item?.strict) return [];
22239
+ if (!spec.item?.strict || spec.key === "platforms") return [];
22192
22240
  const raw = boxValue(state, spec);
22193
22241
  if (!raw.trim()) return [];
22194
22242
  return parseLines(raw, spec.commaSeparated ?? false).filter((e) => !spec.item.ok(e));
@@ -22319,6 +22367,7 @@ function NewCompany({ ctx }) {
22319
22367
  spec,
22320
22368
  value: spec.key === "selfExclusionOwners" && !namesTouched ? typedName : boxValue(state, spec),
22321
22369
  choices: null,
22370
+ suggestions: ctx.me.houseMarketplaces,
22322
22371
  onChange: (v) => {
22323
22372
  if (spec.key === "selfExclusionOwners") setNamesTouched(true);
22324
22373
  setState((st) => typeField(st, spec, v));
@@ -23629,7 +23678,8 @@ function Profile({ ctx }) {
23629
23678
  edits
23630
23679
  }, spec),
23631
23680
  choices: spec.key === "defaultProduct" ? productChoices : spec.choices ?? null,
23632
- onChange: (v) => edit(spec, v)
23681
+ onChange: (v) => edit(spec, v),
23682
+ suggestions: ctx.me.houseMarketplaces
23633
23683
  }),
23634
23684
  spec.key === "defaultJurisdictions" ? (0, import_jsx_runtime.jsx)(UnsearchableTerritories, { derived: loaded.derived }) : null,
23635
23685
  spec.key === "defaultProduct" && coverageOf(loaded.derived) ? (0, import_jsx_runtime.jsxs)("details", {
@@ -49,7 +49,7 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-GBbbyQxc.js"></script>
52
+ <script type="module" crossorigin src="/portal/assets/index-D_O_55vK.js"></script>
53
53
  <link rel="stylesheet" crossorigin href="/portal/assets/index-5CCwiJG7.css">
54
54
  </head>
55
55
  <body>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.3",
5
+ "version": "0.4.0-beta.1",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -21,7 +21,7 @@ documents both: `countProbe` (`"endpoint"` / `"cheap"` / `"none"`) says where a
21
21
  | `transport-guard.mjs` | Converts a network REJECTION into the tool-error shape at the dependency seam, so one timeout degrades one query rather than killing the stage. |
22
22
  | `result-shape.mjs` | Declares the neutral tools' result vocabulary. Imported by its gate test and deliberately by no core — a tool result is the model's prompt surface. |
23
23
  | `script-form.mjs` | Which script a term is written in, and whether the active provider's index holds that form of it. |
24
- | `term-shape.mjs` | Whether a string is a searchable mark term and agrees with its predicate (`{ predicate: "exact", term: "TIKI*" }` is a silent clean). |
24
+ | `term-shape.mjs` | Whether a string is a searchable mark term and agrees with its predicate (`{ predicate: "exact", term: "WAVO*" }` is a silent clean). |
25
25
  | `territory-codes.mjs` | Display name → ISO 3166 / WIPO ST.3 code. `""` means worldwide, `null` means unknown — and never goes to the wire. |
26
26
  | `provider-text.mjs` | Clips a vendor message from the MIDDLE, because the word that classifies a refusal sits at the end of the sentence. |
27
27
  | `test/` | Twelve files, provider-agnostic by design — several drive every adapter's real code rather than fixtures of what we believe it returns. |