clearotron 0.4.0 → 0.4.1-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 (132) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/bin/README.md +1 -1
  3. package/bin/clearotron.mjs +2 -0
  4. package/bin/mcp.mjs +80 -0
  5. package/bin/start.mjs +14 -1
  6. package/build-info.json +2 -2
  7. package/demo/README.md +1 -1
  8. package/docs/SECURITY-OWASP.md +21 -13
  9. package/docs/SECURITY.md +20 -14
  10. package/docs/architecture/04-configuration-reference.md +3 -3
  11. package/docs/architecture/05-config-governance.md +1 -1
  12. package/docs/architecture/06-operations-runbook.md +5 -4
  13. package/docs/architecture/08-development-guide.md +3 -1
  14. package/docs/architecture/09-security-and-data.md +16 -11
  15. package/driver/CHANGELOG.md +29 -0
  16. package/driver/binding-layers.mjs +28 -6
  17. package/driver/citation-census.json +3 -3
  18. package/driver/common-law-coverage-status.mjs +52 -0
  19. package/driver/common-law-receipts.mjs +1 -1
  20. package/driver/compare.mjs +1 -1
  21. package/driver/connotation-search.mjs +5 -5
  22. package/driver/contract-audit.mjs +6 -6
  23. package/driver/contract-dictation-registry.mjs +1 -1
  24. package/driver/contract-e3-backlog.mjs +18 -17
  25. package/driver/contract-vocabulary.mjs +145 -132
  26. package/driver/declination-call.mjs +5 -1
  27. package/driver/degraded-parts.mjs +10 -3
  28. package/driver/dev-portal.mjs +2 -1
  29. package/driver/dispatch-record.mjs +19 -0
  30. package/driver/doubt-closure-tool.mjs +2 -2
  31. package/driver/driver.config.mjs +39 -6
  32. package/driver/engagement-receipt.mjs +1 -1
  33. package/driver/engine/CONTRACT.md +41 -1
  34. package/driver/engine/anthropic-agent.mjs +56 -4
  35. package/driver/engine/mcp/codex-config.mjs +8 -3
  36. package/driver/engine/mcp/coverage-server.mjs +1 -1
  37. package/driver/engine/mcp/gather-config.mjs +1 -1
  38. package/driver/engine/mcp/recording-server.mjs +32 -5
  39. package/driver/engine/mcp/stdio-server.mjs +45 -7
  40. package/driver/engine/mcp/supplemental.mjs +26 -1
  41. package/driver/engine/openai-agent.mjs +113 -20
  42. package/driver/engine/session-record.mjs +223 -0
  43. package/driver/enqueue-schema.mjs +2 -2
  44. package/driver/findings-model.mjs +4 -4
  45. package/driver/flag-snapshot.mjs +2 -1
  46. package/driver/form-neighbourhood.mjs +7 -7
  47. package/driver/frame-diff-model.mjs +3 -3
  48. package/driver/framework.mjs +22 -0
  49. package/driver/gateway.mjs +96 -14
  50. package/driver/jx.mjs +1 -1
  51. package/driver/knockout-review-record.mjs +55 -1
  52. package/driver/named-band.mjs +4 -0
  53. package/driver/owner-screen.mjs +7 -4
  54. package/driver/package.json +1 -1
  55. package/driver/phase0.mjs +13 -2
  56. package/driver/pipeline-knockout.mjs +15 -13
  57. package/driver/pipeline.mjs +132 -48
  58. package/driver/portal-families.mjs +3 -3
  59. package/driver/portal-service.mjs +6 -5
  60. package/driver/predelivery-lint.mjs +19 -19
  61. package/driver/products.mjs +1 -1
  62. package/driver/profiles.mjs +4 -3
  63. package/driver/publish/index.mjs +1 -1
  64. package/driver/publish/knockout.mjs +5 -4
  65. package/driver/publish/office-record-links.mjs +17 -2
  66. package/driver/publish/publish-inputs.mjs +3 -3
  67. package/driver/publish/render-knockout.mjs +17 -5
  68. package/driver/publish/render.mjs +4 -2
  69. package/driver/publish/xlsx.mjs +1 -1
  70. package/driver/record-fold.mjs +177 -0
  71. package/driver/register-count.mjs +20 -3
  72. package/driver/register-coverage.mjs +1 -1
  73. package/driver/register-plan.mjs +83 -18
  74. package/driver/register-records.mjs +7 -0
  75. package/driver/registry-fidelity.mjs +2 -2
  76. package/driver/repairs.mjs +1 -1
  77. package/driver/report-card-record.mjs +4 -4
  78. package/driver/result-noun-fields.mjs +3 -3
  79. package/driver/scope-facts.mjs +11 -3
  80. package/driver/scope-rules.mjs +2 -2
  81. package/driver/search-policy.mjs +20 -1
  82. package/driver/skills/clearance-common-law/SKILL.md +22 -22
  83. package/driver/skills/clearance-register/digest.md +3 -3
  84. package/driver/skills/clearance-register/register-recipes.md +11 -11
  85. package/driver/skills/clearance-register/status-rules.md +1 -1
  86. package/driver/skills/clearance-register/unit.md +1 -1
  87. package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
  88. package/driver/skills/clearance-search/phase2-execution.md +6 -5
  89. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  90. package/driver/skills/clearance-search/worked-examples.md +39 -39
  91. package/driver/skills/clearance-variants/SKILL.md +16 -16
  92. package/driver/skills/frame-diff/SKILL.md +2 -2
  93. package/driver/stage-context.mjs +36 -1
  94. package/driver/stages-knockout.mjs +6 -5
  95. package/driver/stages.mjs +30 -30
  96. package/driver/suite-census.json +259 -55
  97. package/driver/synthesis-record.mjs +5 -0
  98. package/driver/unit-inventory.mjs +17 -28
  99. package/driver/verify.mjs +7 -7
  100. package/mcp-server/CHANGELOG.md +8 -0
  101. package/mcp-server/CONNECT.md +9 -3
  102. package/mcp-server/demo-mode.mjs +10 -0
  103. package/mcp-server/lib/evidence.mjs +8 -8
  104. package/mcp-server/lib/knockout.mjs +1 -1
  105. package/mcp-server/lib/runs.mjs +7 -1
  106. package/mcp-server/lib/scrub.mjs +1 -1
  107. package/mcp-server/package.json +2 -2
  108. package/mcp-server/packs/README.md +1 -1
  109. package/mcp-server/server.mjs +16 -2
  110. package/package.json +4 -3
  111. package/portal-ui/package.json +3 -3
  112. package/providers/_shared/count.mjs +9 -2
  113. package/providers/_shared/enumerate.mjs +125 -41
  114. package/providers/_shared/execute-plan.mjs +15 -4
  115. package/providers/corsearch/src/capabilities.js +1 -1
  116. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  117. package/providers/oauth-mcp-bridge/package.json +2 -2
  118. package/providers/signa/src/capabilities.js +32 -2
  119. package/providers/signa/src/core.js +262 -36
  120. package/scripts/band-shape-probe.mjs +2 -2
  121. package/scripts/deprecate-below.mjs +28 -4
  122. package/scripts/e2e-first-time.mjs +17 -2
  123. package/scripts/env-audit.mjs +3 -2
  124. package/scripts/freeze-example-run.mjs +10 -10
  125. package/scripts/mcp-registry-listing.mjs +199 -0
  126. package/scripts/mint-plugin-folder.mjs +65 -0
  127. package/scripts/release-version.mjs +44 -0
  128. package/scripts/score.mjs +1 -1
  129. package/shared/register-floor.mjs +19 -0
  130. package/shared/scope.mjs +18 -2
  131. package/shared/trigger-lane.mjs +1 -1
  132. package/skills/clearotron-ops/SKILL.md +81 -24
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // mcp-registry-listing.mjs — the MCP Registry lists each stable release npm serves, and nothing else.
6
+ //
7
+ // node scripts/mcp-registry-listing.mjs plan the stables npm serves that the registry does not list
8
+ // node scripts/mcp-registry-listing.mjs publish <publisher> list each of them, oldest first, and read each back
9
+ //
10
+ // The registry (registry.modelcontextprotocol.io) is where MCP clients look a server up. It holds metadata
11
+ // only, about a package npm already serves, and a version's metadata can never change once it is listed.
12
+ // So nothing here reacts to a release: it asks npm which stables it serves and the registry which it
13
+ // lists, and lists what is missing. A run that fails is asked again by the next one, and no release ever
14
+ // waits for this.
15
+ //
16
+ // ONE SOURCE SETS BOTH VERSIONS. Every field that names a version comes from npm's manifest for that exact
17
+ // version, and so does the name: `mcpName` there is the listing's name. The registry reads `mcpName` from
18
+ // npm in turn and refuses a listing that disagrees, so the two cannot drift.
19
+ //
20
+ // A STABLE WITHOUT `mcpName`. The stables published before the field existed are passed by. One published
21
+ // after the first stable that carries it is refused: something stripped it, and listing past it would hide
22
+ // that.
23
+ //
24
+ // A SOURCE THAT CANNOT BE READ IS EXIT 2, never "nothing to list" and never "already listed". The
25
+ // registry's 404 for this exact name is an answer: nothing is listed under it yet.
26
+ //
27
+ // The registry marks the highest version latest whatever order versions arrive in (`registry_service.go`
28
+ // at v1.8.1). Missing stables are listed oldest first all the same, so its record reads in release order.
29
+ //
30
+ // Exit 0: nothing to list, or each one listed and read back.
31
+ // Exit 1: a stable after the first carrying `mcpName` lacks it, or a listing failed or did not read back.
32
+ // Exit 2: could not look.
33
+ import { spawnSync } from "node:child_process";
34
+ import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
35
+ import { tmpdir } from "node:os";
36
+ import { join } from "node:path";
37
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
38
+
39
+ export const SERVER_NAME = "io.github.CordilleraSarl/clearotron";
40
+ /** The README banner's alt text, a shipped line. The registry allows 100 characters. */
41
+ export const DESCRIPTION = "Clearotron — trademark clearance that shows its work";
42
+ export const SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
43
+ export const REPOSITORY = Object.freeze({ url: "https://github.com/CordilleraSarl/clearotron", source: "github" });
44
+ export const NPM_URL = "https://registry.npmjs.org/clearotron";
45
+ export const LISTED_URL = `https://registry.modelcontextprotocol.io/v0.1/servers/${encodeURIComponent(SERVER_NAME)}/versions`;
46
+ /** The registry's refusal of a version it already holds (`ErrInvalidVersion`, v1.8.1): already listed. */
47
+ const DUPLICATE = /cannot publish duplicate version/;
48
+
49
+ export class CouldNotLook extends Error {}
50
+
51
+ const STABLE = /^(\d+)\.(\d+)\.(\d+)$/;
52
+ export const isStable = (v) => STABLE.test(v);
53
+ const parts = (v) => v.match(STABLE).slice(1).map(Number);
54
+ export function compareStable(a, b) {
55
+ const [x, y] = [parts(a), parts(b)];
56
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
57
+ }
58
+
59
+ /** The listing for one stable npm serves, every field that can come from its manifest taken from it. PURE. */
60
+ export function serverJsonFor(manifest) {
61
+ if (manifest?.mcpName !== SERVER_NAME) {
62
+ throw new Error(`${manifest?.name}@${manifest?.version} carries mcpName ${JSON.stringify(manifest?.mcpName ?? null)}, not ${SERVER_NAME}`);
63
+ }
64
+ if (!isStable(manifest.version)) throw new Error(`${manifest.version} is not a stable version`);
65
+ return {
66
+ $schema: SCHEMA,
67
+ name: manifest.mcpName,
68
+ description: DESCRIPTION,
69
+ repository: { ...REPOSITORY },
70
+ version: manifest.version,
71
+ packages: [{
72
+ registryType: "npm",
73
+ identifier: manifest.name,
74
+ version: manifest.version,
75
+ transport: { type: "stdio" },
76
+ packageArguments: [{ type: "positional", value: "mcp" }],
77
+ }],
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Which stables to list. PURE.
83
+ * @param {Record<string, object>} served npm's manifests, by version
84
+ * @param {Set<string>} listed the versions the registry lists under the name
85
+ * @returns {{ list: object[], passedBy: string[], refused: string[] }} `list` oldest first
86
+ */
87
+ export function plan(served, listed) {
88
+ const stables = Object.keys(served).filter(isStable).sort(compareStable);
89
+ const first = stables.findIndex((v) => served[v]?.mcpName === SERVER_NAME);
90
+ if (first === -1) return { list: [], passedBy: stables, refused: [] };
91
+ const list = [];
92
+ const refused = [];
93
+ for (const v of stables.slice(first)) {
94
+ if (served[v]?.mcpName !== SERVER_NAME) refused.push(v);
95
+ else if (!listed.has(v)) list.push(served[v]);
96
+ }
97
+ return { list, passedBy: stables.slice(0, first), refused };
98
+ }
99
+
100
+ async function readJson(url, fetchImpl, who) {
101
+ let res;
102
+ try {
103
+ res = await fetchImpl(url, { headers: { accept: "application/json" } });
104
+ } catch (e) {
105
+ throw new CouldNotLook(`${who} did not answer: ${e.message}`);
106
+ }
107
+ if (res.status === 404) return null;
108
+ if (res.status !== 200) throw new CouldNotLook(`${who} answered ${res.status}`);
109
+ try {
110
+ return await res.json();
111
+ } catch {
112
+ throw new CouldNotLook(`${who} answered with something that is not JSON`);
113
+ }
114
+ }
115
+
116
+ /** npm's manifests for every version it serves. */
117
+ export async function readServed(fetchImpl = fetch) {
118
+ const body = await readJson(NPM_URL, fetchImpl, "npm");
119
+ if (!body?.versions || typeof body.versions !== "object") throw new CouldNotLook("npm's answer names no versions");
120
+ return body.versions;
121
+ }
122
+
123
+ /** The versions the registry lists under the exact name: empty when it has never listed it. */
124
+ export async function readListed(fetchImpl = fetch) {
125
+ const body = await readJson(LISTED_URL, fetchImpl, "the MCP Registry");
126
+ if (body === null) return new Set();
127
+ if (!Array.isArray(body?.servers)) throw new CouldNotLook("the MCP Registry's answer lists no servers");
128
+ if (body.metadata?.nextCursor) throw new CouldNotLook("the MCP Registry's answer is paged, and this reads one page");
129
+ const listed = new Set();
130
+ for (const s of body.servers) {
131
+ if (s?.server?.name !== SERVER_NAME) throw new CouldNotLook(`the MCP Registry answered for ${JSON.stringify(s?.server?.name)}`);
132
+ listed.add(s.server.version);
133
+ }
134
+ return listed;
135
+ }
136
+
137
+ /** Hand one listing to the publisher. A refusal as a duplicate means it is already there. */
138
+ export function publishOne(manifest, publisher, run = spawnSync) {
139
+ const dir = mkdtempSync(join(tmpdir(), "mcp-registry-"));
140
+ try {
141
+ const file = join(dir, "server.json");
142
+ writeFileSync(file, JSON.stringify(serverJsonFor(manifest), null, 2) + "\n");
143
+ const r = run(publisher, ["publish", file], { encoding: "utf8" });
144
+ const said = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
145
+ if (r.status === 0) return { ok: true, said };
146
+ if (DUPLICATE.test(said)) return { ok: true, duplicate: true, said };
147
+ return { ok: false, said: said || String(r.error?.message ?? `exit ${r.status}`) };
148
+ } finally {
149
+ rmSync(dir, { recursive: true, force: true });
150
+ }
151
+ }
152
+
153
+ export async function main(argv, { fetchImpl = fetch, run = spawnSync, output = process.env.GITHUB_OUTPUT, log = console.log, warn = console.error } = {}) {
154
+ const [cmd, publisher] = argv;
155
+ if (!(cmd === "plan" || (cmd === "publish" && publisher))) {
156
+ warn("usage: mcp-registry-listing.mjs plan | publish <publisher>");
157
+ return 2;
158
+ }
159
+ let p;
160
+ try {
161
+ p = plan(await readServed(fetchImpl), await readListed(fetchImpl));
162
+ } catch (e) {
163
+ if (!(e instanceof CouldNotLook)) throw e;
164
+ warn(`mcp-registry: could not look: ${e.message}`);
165
+ return 2;
166
+ }
167
+ if (p.passedBy.length) log(`mcp-registry: passed by, published before mcpName: ${p.passedBy.join(", ")}`);
168
+ if (p.refused.length) {
169
+ warn(`mcp-registry: REFUSED. These stables came after the first that carries mcpName and do not carry it: ${p.refused.join(", ")}`);
170
+ return 1;
171
+ }
172
+ log(p.list.length ? `mcp-registry: to list, oldest first: ${p.list.map((m) => m.version).join(", ")}` : "mcp-registry: nothing to list");
173
+ if (output) appendFileSync(output, `count=${p.list.length}\n`);
174
+ if (cmd === "plan") return 0;
175
+
176
+ for (const manifest of p.list) {
177
+ const r = publishOne(manifest, publisher, run);
178
+ if (!r.ok) {
179
+ warn(`mcp-registry: ${manifest.version} was not listed: ${r.said}`);
180
+ return 1;
181
+ }
182
+ let listed;
183
+ try {
184
+ listed = await readListed(fetchImpl);
185
+ } catch (e) {
186
+ if (!(e instanceof CouldNotLook)) throw e;
187
+ warn(`mcp-registry: could not read ${manifest.version} back: ${e.message}`);
188
+ return 2;
189
+ }
190
+ if (!listed.has(manifest.version)) {
191
+ warn(`mcp-registry: the publisher ${r.duplicate ? "called" : "reported"} ${manifest.version} ${r.duplicate ? "a duplicate" : "listed"}, and the registry does not list it`);
192
+ return 1;
193
+ }
194
+ log(`mcp-registry: ${manifest.version} ${r.duplicate ? "was already listed" : "listed"}, and read back`);
195
+ }
196
+ return 0;
197
+ }
198
+
199
+ if (isEntrypoint(import.meta.url)) process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // mint-plugin-folder.mjs — the folder Claude's plugin directory installs, its copies derived from the tree.
6
+ //
7
+ // The directory installs one folder, never the repository: a person who installs the plugin gets
8
+ // `plugin/` and nothing else. What the folder needs from the tree is copied into it, and `--check`
9
+ // refuses a folder whose copies have drifted from their sources:
10
+ // - the three connector skills, byte for byte from `skills/`, which stays the one copy the server
11
+ // briefs from and the published package ships;
12
+ // - the licence and its additional terms, byte for byte from the root.
13
+ // Written by hand and not derived: `plugin/README.md`, the manifest, `plugin/scripts/npx.mjs`, the
14
+ // launcher that starts npx on every platform, and `plugin/.mcp.json`, which names the exact stable
15
+ // version the server is started from. A stable cut moves that version and the manifest's together;
16
+ // between stables the package is a beta and the folder still names the last stable.
17
+ //
18
+ // node scripts/mint-plugin-folder.mjs write the copies
19
+ // node scripts/mint-plugin-folder.mjs --check exit 1 if any copy differs from its source, naming it
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join, relative } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
24
+
25
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
26
+ export const PLUGIN_DIR = "plugin";
27
+ export const CONNECTOR_SKILLS = Object.freeze(["clearotron-client", "clearotron-account", "clearotron-ops"]);
28
+
29
+ /** Every copied file of the folder: its path under the root, and its exact bytes. PURE but for reads. */
30
+ export function pluginCopies(root = ROOT) {
31
+ const read = (p) => readFileSync(join(root, p), "utf8");
32
+ const out = [];
33
+ for (const skill of CONNECTOR_SKILLS) {
34
+ for (const f of readdirSync(join(root, "skills", skill)).sort()) {
35
+ out.push({ path: join(PLUGIN_DIR, "skills", skill, f), text: read(join("skills", skill, f)) });
36
+ }
37
+ }
38
+ out.push({ path: join(PLUGIN_DIR, "LICENSE"), text: read("LICENSE") });
39
+ out.push({ path: join(PLUGIN_DIR, "ADDITIONAL-TERMS.md"), text: read("ADDITIONAL-TERMS.md") });
40
+ return out;
41
+ }
42
+
43
+ function main(argv) {
44
+ const check = argv.includes("--check");
45
+ const stale = [];
46
+ for (const { path, text } of pluginCopies()) {
47
+ const at = join(ROOT, path);
48
+ const now = existsSync(at) ? readFileSync(at, "utf8") : null;
49
+ if (now === text) continue;
50
+ if (check) { stale.push(path); continue; }
51
+ mkdirSync(dirname(at), { recursive: true });
52
+ writeFileSync(at, text);
53
+ console.log(`plugin-folder: wrote ${relative(ROOT, at)}`);
54
+ }
55
+ if (check && stale.length) {
56
+ console.error(`plugin-folder: STALE. ${stale.length} copied file(s) differ from their sources:`);
57
+ for (const p of stale) console.error(` ${p}`);
58
+ console.error("Re-mint: node scripts/mint-plugin-folder.mjs");
59
+ return 1;
60
+ }
61
+ if (check) console.log("plugin-folder: current");
62
+ return 0;
63
+ }
64
+
65
+ if (isEntrypoint(import.meta.url)) process.exit(main(process.argv.slice(2)));
@@ -42,6 +42,35 @@ export function alreadyOut({ version, tags = [], published = false }) {
42
42
  return where;
43
43
  }
44
44
 
45
+ // ── THE PLUGIN FOLDER FOLLOWS A STABLE, AND ONLY A STABLE ─────────────────────────────────────────────
46
+ //
47
+ // `plugin/` is what Claude's plugin directory installs, and it starts the server from npm at one exact
48
+ // version: the one `"clearotron@X.Y.Z"` argument in its `.mcp.json`, beside the same number in its
49
+ // manifest. A stable cut moves both in the version commit. A beta moves neither, so between stables the
50
+ // folder names the last stable. The pin is found by its prefix, never by its place in the argument list,
51
+ // and a file without exactly one match refuses the cut before anything is stamped.
52
+ export const PLUGIN_SERVER = join("plugin", ".mcp.json");
53
+ export const PLUGIN_MANIFEST = join("plugin", ".claude-plugin", "plugin.json");
54
+ const PIN = /"clearotron@[^"]*"/g;
55
+ const MANIFEST_VERSION = /^ {2}"version": "[^"]*"/gm;
56
+ export const isStableVersion = (v) => /^\d+\.\d+\.\d+$/.test(v);
57
+
58
+ /** PURE. Both plugin files at `version`, or why they cannot be moved. Nothing else in either changes. */
59
+ export function pluginAtVersion({ server, manifest }, version) {
60
+ const pins = server.match(PIN) ?? [];
61
+ if (pins.length !== 1) return { refused: `${PLUGIN_SERVER} carries ${pins.length} "clearotron@" arguments, not one` };
62
+ const versions = manifest.match(MANIFEST_VERSION) ?? [];
63
+ if (versions.length !== 1) return { refused: `${PLUGIN_MANIFEST} carries ${versions.length} top-level versions, not one` };
64
+ const next = {
65
+ server: server.replace(PIN, `"clearotron@${version}"`),
66
+ manifest: manifest.replace(MANIFEST_VERSION, ` "version": "${version}"`),
67
+ };
68
+ for (const [file, text] of [[PLUGIN_SERVER, next.server], [PLUGIN_MANIFEST, next.manifest]]) {
69
+ try { JSON.parse(text); } catch (e) { return { refused: `${file} is not JSON after the move: ${e.message}` }; }
70
+ }
71
+ return next;
72
+ }
73
+
45
74
  // ── THE CHANNEL DECIDES THE MODE, AND IT HAS TO BE DECIDED HERE ───────────────────────────────────
46
75
  //
47
76
  // `changeset version` computes a pre-release number or a stable one depending on whether the tree is in
@@ -343,12 +372,27 @@ function main() {
343
372
  process.exitCode = 1;
344
373
  return;
345
374
  }
375
+ let plugin = null;
376
+ if (isStableVersion(version)) {
377
+ const read = (p) => (existsSync(join(ROOT, p)) ? readFileSync(join(ROOT, p), "utf8") : "");
378
+ plugin = pluginAtVersion({ server: read(PLUGIN_SERVER), manifest: read(PLUGIN_MANIFEST) }, version);
379
+ if (plugin.refused) {
380
+ console.error(`release-version: the plugin folder cannot follow ${version}: ${plugin.refused}. Refusing before anything is stamped.`);
381
+ process.exitCode = 1;
382
+ return;
383
+ }
384
+ }
346
385
  const pkgPath = join(ROOT, "package.json");
347
386
  const pkg = readJson(pkgPath);
348
387
  const was = pkg.version;
349
388
  pkg.version = version;
350
389
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
351
390
  console.log(`release-version: clearotron ${was} -> ${version} (carried from the fixed group)`);
391
+ if (plugin) {
392
+ writeFileSync(join(ROOT, PLUGIN_SERVER), plugin.server);
393
+ writeFileSync(join(ROOT, PLUGIN_MANIFEST), plugin.manifest);
394
+ console.log(`release-version: the plugin folder starts clearotron@${version}, and its manifest says ${version}`);
395
+ }
352
396
 
353
397
  // AND THE LOCKFILE, WHICH NOTHING ELSE IN THIS CUT TOUCHES. `changeset version` rewrites the
354
398
  // workspace manifests and the block above rewrites the root's; `package-lock.json` records the same
package/scripts/score.mjs CHANGED
@@ -33,7 +33,7 @@
33
33
  // ── which directory to point it at ───────────────────────────────────────────────────────────────────
34
34
  //
35
35
  // The agent WORKSPACE archive dir, never the published pool dir. The pool keeps report.md and
36
- // findings.json but not `_driver/` or `_records/` — see driver/publish/pool-admin.mjs:145. Without
36
+ // findings.json but not `_driver/` or `_records/` — see doRepublish() in pool-admin.mjs. Without
37
37
  // `_driver/` there is no band and no reconciliation, so `withheld` cannot be computed at all, and a
38
38
  // scorer that silently reported every withheld mark as `lost` would send every fix to the wrong place.
39
39
  // So a run dir with no `_driver/` says so and declines to guess.
@@ -0,0 +1,19 @@
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
+ // register-floor.mjs — a register's floor, read one way and printed one way, on every path that meets it.
4
+ //
5
+ // A register asked something very broad can answer "at least 10,000" instead of a count: it counted, and
6
+ // gave a floor. That is an answer, never a failure and never a zero (ruled 2026-10-02). The knockout count
7
+ // has printed it as "more than 10,000" since it first met one; the clearance path reads and prints it here
8
+ // with the same words, so the two can never say it two ways.
9
+
10
+ /** A floor as every page prints it: the register's own figure, and no sentence around it. */
11
+ export const moreThan = (floor) => `more than ${floor.toLocaleString("en-US")}`;
12
+
13
+ /**
14
+ * The register's floor on a parsed answer that gave one instead of a count, or null. A floor counts only
15
+ * with the register's flag AND the number: a flag with no number says no more than "unknown" does. PURE.
16
+ */
17
+ export function floorOf(parsed) {
18
+ return parsed?.total_approximate === true && Number.isFinite(parsed?.total_floor) ? parsed.total_floor : null;
19
+ }
package/shared/scope.mjs CHANGED
@@ -792,6 +792,12 @@ function tenantStamp(scope, asked) {
792
792
  throw new Error(`name the organisation whose Generic this is (tenant) — your access covers ${orgs.length}`);
793
793
  }
794
794
 
795
+ // The install's organisation when the grants file holds exactly one; null with several, none, or no file.
796
+ function soleOrganisation() {
797
+ const orgs = Object.keys(loadGrants()?.tenants ?? {});
798
+ return orgs.length === 1 ? orgs[0] : null;
799
+ }
800
+
795
801
  // True iff `email`'s domain (the part after the final '@') is one of firmDomains. PURE (no jose), so the HTTP
796
802
  // handler can derive firm-staff-ness from the already-verified CF identity without pulling the auth lib. The
797
803
  // `internal` (read-all) grant rests on THIS positive check — NOT on which CF app admitted the caller — so a
@@ -1016,8 +1022,18 @@ export function authorize(scope, toolName, args = {}) {
1016
1022
  // paid run whose delivery packet routes nowhere. A refusal naming the missing field is the cheaper
1017
1023
  // failure by far, and it is not the "accepted then quietly narrower" shape: the preview describes a
1018
1024
  // job stamped from the verified identity, and start_run says plainly which field to add.
1019
- if (toolName === "plan_run") return stampForwarder(scope, args);
1020
- return args;
1025
+ //
1026
+ // WHICH ORGANISATION'S GENERIC. An ops token names no person, so there is nobody's reach to read the
1027
+ // organisation from — and passed through bare, every Generic run a connector started was filed under
1028
+ // no organisation, where the Generic filter on Clearances can never find it. On an install that holds
1029
+ // exactly one organisation there is nothing to guess, so the job takes it: the same answer the
1030
+ // person-scoped branch gives a person who sees that one organisation. A named tenant passes as it
1031
+ // stands, and with several organisations (or none) the job is left as it was.
1032
+ const placed = (toolName === "start_run" || toolName === "plan_run") && (args?.profileKey ?? "generic") === "generic"
1033
+ && !(typeof args?.tenant === "string" && args.tenant.trim()) ? soleOrganisation() : null;
1034
+ const routed = placed ? { ...args, tenant: placed } : args;
1035
+ if (toolName === "plan_run") return stampForwarder(scope, routed);
1036
+ return routed;
1021
1037
  }
1022
1038
  // kind === "account": a signed-in CLIENT, across their OWN account(s). Reads the client layer on any of
1023
1039
  // their runs, and drives the run lifecycle for them. Run OWNERSHIP is not checked here — it is enforced
@@ -228,7 +228,7 @@ export function triggerLaneVerdict({ url = null, hasToken = false, verbs = null,
228
228
  // false the moment the door finishes binding, which is seconds later.
229
229
  //
230
230
  // It is also false as a prediction, and that is the worse half. The submit path makes a FRESH upstream
231
- // call per request inside its own try/catch (`driver/portal-service.mjs:1770-1798`) and never consults
231
+ // call per request inside its own try/catch (the `trigger` call inside makePortalService() in portal-service.mjs) and never consults
232
232
  // this verdict. So nothing about a boot-time refusal determines what a clearance does.
233
233
  //
234
234
  // IT COST A DIAGNOSTIC DETOUR during the 0.1.6 production upgrade, and it prints on every reboot. The
@@ -10,28 +10,75 @@ You are an integrator agent driving a trademark clearance engine over MCP. Intak
10
10
  call hands the matter to a deterministic pipeline**, and delivery comes back to you as outbox
11
11
  events (see `COURIER.md`). You never compose reports and never touch the engine's files.
12
12
 
13
- ## Check before you spend — `plan_run`
14
-
15
- A search costs real money and takes real time, and the requester usually has not told you which
16
- search they mean. **`plan_run` takes exactly the same arguments as `start_run`, spends nothing, and
17
- tells you what would happen**: which product resolves and *where that came from* (their request,
18
- the account's default, or a saved search), the territories and marketplaces that would actually be
19
- searched, the turnaround, and any blockers.
20
-
21
- Use it whenever the requester has not confirmed the specifics — which is most of the time. Then say
22
- it back to them in their own terms, with the turnaround the preview reported, and let them answer:
23
-
24
- > That would run a **Multi-country focus search** on NOVAPULSE in classes 9 and 41 — your account's
25
- > default territories (US, EU, JP), back in <the turnaround the preview quoted>. You did not say
26
- > which search you wanted, so that is the account default rather than something you chose. Shall I
27
- > run it, or did you want the Knockout search across the whole name list instead?
28
-
29
- Two things it is not. It does **not** reserve anything — nothing is held, and nothing expires. And
30
- it is **advice, not a gate**: `start_run` will still run without it. Getting agreement first is your
31
- job, not the engine's.
32
-
33
- When they confirm, call `start_run` **with the same arguments**. If `plan_run` reported blockers,
34
- those are questions to put back to the requester — not failures to retry.
13
+ ## When a request arrives: confirm it, and start at once
14
+
15
+ A search costs real money and takes real time. Read the request for everything the search needs, send
16
+ the requester one confirmation, and start the search at once; they correct it afterwards. Never wait for
17
+ a yes.
18
+
19
+ **1. Is it a new matter?** The matter is the mark, not the thread. A forwarded reply quotes the original
20
+ request beneath it, which makes a follow-up look like a fresh request, so check `list_runs` for the mark
21
+ (in flight and recently delivered) and your own sent confirmation for the thread first. A bare "proceed"
22
+ or a thank-you never starts a second search: reply once that the search is already running, and stop. A
23
+ different mark in the same thread is a distinct matter and its own search. A changed instruction while
24
+ a run is in flight goes in through `feed_context`. Only the requester's explicit "run it again" starts a
25
+ second search of the same mark, with `dupOverride: true`.
26
+
27
+ **2. Read the brief out of the request.** Ask only for what is both impossible to infer and material.
28
+ - The mark or marks, and what kind each is: a word, a logo, a slogan.
29
+ - The account and the applicant (`profileKey`, `projectKey` and `customer` below). Say the applicant
30
+ together with what it implies: the applicant's own and affiliated marks are not reported as
31
+ conflicts. Never guess the applicant from the mark itself.
32
+ - Prior matters on the same reference or project: ask the engine first (`search_runs`, `list_runs`),
33
+ and say what you found.
34
+ - Goods or classes. Either one suffices: infer the other side, and say that you inferred it
35
+ ("sparkling water → class 32, inferred").
36
+ - The deadline and every operational instruction (recipients, billing, the reference), each said back
37
+ with its status: done, will do, or cannot do. Never pass over an instruction in silence.
38
+ - The deliverable as specified (a template, a risk framework, a format), verbatim.
39
+ - How free the client is to change the name, which sets the advice. If the request does not say, state
40
+ the posture you assumed.
41
+ - Prior and intended use. Unless the request says otherwise, a house root is assumed in use and the new
42
+ element unused.
43
+ - Special instructions, verbatim.
44
+
45
+ The reference number and the industry are shown, never asked for.
46
+
47
+ **3. Check it for free with `plan_run`.** It takes exactly the same arguments as `start_run`, spends
48
+ nothing and holds nothing. It reports which search resolves and where that came from (the request, the
49
+ account's default, or a saved search), the territories and marketplaces that would actually be searched,
50
+ the turnaround, and any blockers, as questions to put back. It is a check, not a gate. Put what it
51
+ reports in the confirmation, above all a search or a scope narrower than the words the requester used.
52
+
53
+ **4. Send the confirmation in three groups, in this order.**
54
+ - **Corrections and commitments:** a correction to the mark, the deadline diarised, the recipients, the
55
+ billing, and anything instructed that you will do or cannot do.
56
+ - **Assumed — correct me:** every inferred value with what it does (the applicant and the marks set
57
+ aside with it, inferred classes, the territories, the use posture), and every default applied. A
58
+ default fills silence only; it never narrows an explicit instruction, and a stated "worldwide" stays
59
+ worldwide.
60
+ - **Given:** what the request stated, said back briefly.
61
+
62
+ End with **"Starting now on this understanding — reply to correct anything."**, and call `start_run`
63
+ with the same arguments at once.
64
+
65
+ **5. What holds a search back.** Only two things: a mark that cannot be made out (absent, or with no
66
+ clearly likely reading), and goods and classes both absent. Ask for the missing item, and start when the
67
+ answer arrives. Nothing else holds a search:
68
+ - A mark with one obvious mistyping is searched on the corrected reading, with the correction first in
69
+ the confirmation ("the form wrote NOVAPLUSE; searched as NOVAPULSE — say so if that is wrong").
70
+ - A missing reference never holds a search.
71
+ - An unknown applicant never holds one. Lead the second group with the question, set
72
+ `customerUnknown: true`, and start at once:
73
+
74
+ > Who is the applicant? I've started the search — it doesn't depend on the answer — but until the
75
+ > applicant is named I can't separate their own marks from third-party conflicts: an identical or
76
+ > near-identical hit is reported as an ordinary conflict, carrying a note to disregard it if it's the
77
+ > applicant's own prior filing. Reply as early as you can — I'll fold it in on a best-effort basis
78
+ > while the search is still running.
79
+
80
+ The answer goes in through `feed_context`. Never promise that an answer arriving any time before
81
+ delivery will be folded in: a late answer is best effort.
35
82
 
36
83
  ## Starting a search — `start_run`
37
84
 
@@ -77,7 +124,14 @@ Extract from the request, faithfully (verbatim beats paraphrase):
77
124
  storefront that matters to them. These are ADDED to the account's own; you cannot remove theirs.
78
125
  Every one widens the grid, so pass only what was actually asked for.
79
126
  - **`ref`** (matter reference), **`deadline`** (ISO 8601 — drives deadline arithmetic), **`msgId`**
80
- (your channel's native message id — the delivery packet threads the reply on it).
127
+ (your channel's native message id — the delivery packet threads the reply on it), and
128
+ **`conversationId`** (your channel's thread key, which the engine's duplicate check reads).
129
+ - **`projectKey`** — the engagement under `profileKey`, from that customer's `projects` in
130
+ `list_profiles`, when the request names one or its list makes one unambiguous. Omit it otherwise.
131
+ - **`customerUnknown: true`** — when the applicant is neither stated nor implied by who sent the request.
132
+ - **`brief`** (the confirmation exactly as you sent it), **`rawRequest`** (the whole request, verbatim),
133
+ **`deliverableSpec`**, **`commercialFlexibility`** and **`priorUse`**, as plain text. Omit any that are
134
+ empty.
81
135
  - **`dupOverride: true`** — ONLY when the requester has explicitly confirmed a re-run of a matter
82
136
  the dedup gate parked. Never set it on your own initiative; a duplicate submission otherwise gets
83
137
  a polite duplicate-skipped notice, not a second spend.
@@ -87,7 +141,10 @@ The call returns the accepted job (or a validation refusal telling you exactly w
87
141
 
88
142
  ## While a run is in flight
89
143
 
90
- - **`get_run` / `list_runs`** — state, current step, verdict when reached.
144
+ - **`get_run` / `list_runs`** — state, current step, verdict when reached. Answer a requester's "how is
145
+ it going?" from what these return. An engine you cannot reach and a search that has made no progress
146
+ are different facts: say "I could not reach the engine" for the first and "no progress since <time>"
147
+ for the second, and never report one as the other.
91
148
  - **`feed_context`** — fold in a mid-run answer (typically the applicant binding). One call; the
92
149
  engine acknowledges through the outbox.
93
150
  - **`decision_timeline`** — how the verdict evolved, when someone asks "why conditional?".