clearotron 0.5.0-beta.5 → 0.5.0-beta.6
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.
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +9 -0
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/coverage-form.mjs +4 -3
- package/driver/owner-screen.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +24 -9
- package/driver/register-plan.mjs +59 -5
- package/driver/score-redaction.mjs +47 -2
- package/driver/skills/clearance-register/SKILL.md +2 -0
- package/driver/skills/clearance-register/unit.md +2 -0
- package/driver/suite-census.json +31 -1
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/execute-plan.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/core.js +58 -7
- package/scripts/release-notes-lint.mjs +10 -4
- package/scripts/score.mjs +30 -3
package/build-info.json
CHANGED
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.5.0-beta.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fixed: A clearance on the Signa register no longer loses searches to a brief rate limit; it waits as long as the register asks and searches again.
|
|
8
|
+
- Fixed: A clearance resumed after an interruption now searches the territories the order named, not the account's default markets.
|
|
9
|
+
- Fixed: A wider register search is no longer set aside because a narrower search returned a short list; the engine now weighs what each waiting search could add to the advice and records its reason.
|
|
10
|
+
- Fixed: The lawyer's report now says why a search was not carried out: the register refused it for the day and a later run can repeat it, or the register cannot run that kind of search at all.
|
|
11
|
+
|
|
3
12
|
## 0.5.0-beta.5
|
|
4
13
|
|
|
5
14
|
### Patch Changes
|
|
@@ -409,7 +409,7 @@ export const E3_BACKLOG = [
|
|
|
409
409
|
{
|
|
410
410
|
stage: "register-digest",
|
|
411
411
|
kind: "exactly-these-keys",
|
|
412
|
-
where: "driver/skills/clearance-register/SKILL.md:
|
|
412
|
+
where: "driver/skills/clearance-register/SKILL.md:228-229",
|
|
413
413
|
surface: "skill-file",
|
|
414
414
|
evidence: "**The status vocabulary is CLOSED: EXACTLY one bare token of: `confirmed-clean` / `coverage-limited` / `deferred` / `withheld-by-judgment`.** Qualifiers never go in a status cell; they go in the reason.",
|
|
415
415
|
reparsedBy: "driver/coverage-form.mjs / driver/coverage-ledger.mjs. Since the typed-transport conversion the STAGE MESSAGE no longer restates the enum; the surviving copies are SKILL.md (here), digest.md:207, the dispatch brief (coverage-form.mjs coverageFormBrief), the record_coverage schema (coverage-server.mjs) and gateway.mjs's repair hints — still one enum spelled at five sites",
|
package/driver/coverage-form.mjs
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { REGISTER_AXES, COVERAGE_STATUSES, normalizeAxis, CROWD_RULING_TOKEN,
|
|
32
32
|
CROWD_RULING_UNIT_GRAMMAR } from "./coverage-ledger.mjs";
|
|
33
33
|
import { shortId } from "./connotation-search.mjs";
|
|
34
|
-
import { openBlocksByAxis, crowdNarrowing, blocksByQidOf, isProviderHardErrorReason } from "./register-plan.mjs";
|
|
34
|
+
import { openBlocksByAxis, crowdNarrowing, blocksByQidOf, isProviderHardErrorReason, refusedSearchSentence } from "./register-plan.mjs";
|
|
35
35
|
import { territoryLayerReport, unsearchedLayerReason } from "./binding-layers.mjs"; import { goodsTermsList } from "../providers/_shared/term-shape.mjs";
|
|
36
36
|
|
|
37
37
|
const STATUS_SET = new Set(COVERAGE_STATUSES);
|
|
@@ -979,7 +979,8 @@ export function renderCoverageLedgerSection(rows) {
|
|
|
979
979
|
Number.isInteger(r.total_hits) ? `${r.total_hits} hits` : "",
|
|
980
980
|
r.unaccounted_classes?.length ? `classes unaccounted: ${r.unaccounted_classes.join(", ")}` : "",
|
|
981
981
|
r.unaccounted_terms?.length ? `terms unaccounted: ${r.unaccounted_terms.join(", ")}` : "",
|
|
982
|
-
|
|
982
|
+
// A search the register refused reads as the owner's sentence for it, not as its recorded line.
|
|
983
|
+
r.receipt_reason ? `receipt: ${refusedSearchSentence(r.receipt_reason) ?? r.receipt_reason}` : "",
|
|
983
984
|
].filter(Boolean).join("; ");
|
|
984
985
|
const reason = [cell(r.reason), detail ? `(${cell(detail)})` : ""].filter(Boolean).join(" ");
|
|
985
986
|
return `| ${cell(r.unit)} | ${cell(String(r.status).trim().toLowerCase())} | ${reason} | ${cell(r.qid ?? "—")} |`;
|
|
@@ -1075,7 +1076,7 @@ function briefRow(r) {
|
|
|
1075
1076
|
Number.isInteger(r.total_hits) ? `${r.total_hits} hits` : "",
|
|
1076
1077
|
r.unaccounted_classes?.length ? `classes unaccounted: ${r.unaccounted_classes.join(", ")}` : "",
|
|
1077
1078
|
r.unaccounted_terms?.length ? `terms unaccounted: ${r.unaccounted_terms.join(", ")}` : "",
|
|
1078
|
-
r.receipt_reason ? `receipt: ${String(r.receipt_reason).slice(0, 160)}` : "",
|
|
1079
|
+
r.receipt_reason ? `receipt: ${refusedSearchSentence(r.receipt_reason) ?? String(r.receipt_reason).slice(0, 160)}` : "",
|
|
1079
1080
|
// what was retrieved under the crowd (register-plan.mjs, crowdNarrowing), as data beside the counts it rests on
|
|
1080
1081
|
r.narrowing ? `narrowing: ${r.narrowing}${r.lists?.length ? ` (${r.lists.slice(0, 6).join(", ")})` : ""}${r.not_covered?.length ? `; not covered: ${r.not_covered.slice(0, 12).join(", ")}` : ""}` : "",
|
|
1081
1082
|
].filter(Boolean).join("; ");
|
package/driver/owner-screen.mjs
CHANGED
|
@@ -146,7 +146,7 @@ function sliceState(entry, block) {
|
|
|
146
146
|
/** Plain-English reason a slice is not an enumerated answer (null when it is). PURE. */
|
|
147
147
|
function sliceReason(entry, block, deferredReason, capabilities) {
|
|
148
148
|
if (entry?.unsupported === true)
|
|
149
|
-
return str(entry.unsupported_reason) || `the active register provider (${str(capabilities?.id) || "unknown"}) has no owner surface — this
|
|
149
|
+
return str(entry.unsupported_reason) || `the active register provider (${str(capabilities?.id) || "unknown"}) has no owner surface — this search was never carried out`;
|
|
150
150
|
if (!block) return "no band block carries this qid — the slice is unaccounted for, never a clean";
|
|
151
151
|
if (block.deferred === true) return str(deferredReason) || str(block.reason).slice(0, 300);
|
|
152
152
|
if (block.error === true) return `provider error — the slice never really ran: ${str(block.reason).slice(0, 200)}`;
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.5.0-beta.
|
|
5
|
+
"version": "0.5.0-beta.6",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
package/driver/pipeline.mjs
CHANGED
|
@@ -65,7 +65,7 @@ import { reconcileRecall, parseFindingsEndings, parseCrowdRulings, readOkRecordU
|
|
|
65
65
|
import { buildCrowdContext, CROWD_CONTEXT_AXIS } from "./crowd-context.mjs";
|
|
66
66
|
import { writeUpForm, gradedWriteUpRung } from "./write-up-form.mjs"; // §8/§9 — which findings earn a full card
|
|
67
67
|
import { shortEntryBody, groupEntries, buildGradedEntriesSection } from "./write-up-render.mjs";
|
|
68
|
-
import { compileRegisterPlan, parseRegisterPlan, resolvePlanAgainstStore, joinPlanToBands, crowdNarrowing, marketStepMarkets, blocksByQidOf, deriveCoverageSkeleton, validatePlanFeasibility, feasibilityMessages, foldSupplementalEntries, withheldFamilyTwins, pendingRequiredQids, refuseWithheldFamilies, partitionFoldDirectives, screenThenCap, fullyDeferredAxes, variantTermIssue, searchedJurisdictionsFromPlan, PLAN_AUDIT_HEAD, PLAN_AUDIT_CLASSES, deferExhaustedProviderErrors, ladderExhaustedQids, isProviderHardErrorReason } from "./register-plan.mjs";
|
|
68
|
+
import { undoneSearchesNote, compileRegisterPlan, parseRegisterPlan, resolvePlanAgainstStore, joinPlanToBands, crowdNarrowing, marketStepMarkets, blocksByQidOf, deriveCoverageSkeleton, validatePlanFeasibility, feasibilityMessages, foldSupplementalEntries, withheldFamilyTwins, pendingRequiredQids, refuseWithheldFamilies, partitionFoldDirectives, screenThenCap, fullyDeferredAxes, variantTermIssue, searchedJurisdictionsFromPlan, PLAN_AUDIT_HEAD, PLAN_AUDIT_CLASSES, deferExhaustedProviderErrors, ladderExhaustedQids, isProviderHardErrorReason } from "./register-plan.mjs";
|
|
69
69
|
// — ONE binding of the office split to this box's env, shared with Depth 2's two lanes.
|
|
70
70
|
import { registerCapabilities, registerUnavailableOffices } from "./register-unreachable.mjs";
|
|
71
71
|
import { parseVariantManifestModel, famousMarkElements } from "./variant-manifest-model.mjs";
|
|
@@ -131,7 +131,7 @@ import { publishReport, composeEmailHtml, deliverySubject } from "./publish/inde
|
|
|
131
131
|
import { parseCaseLawProfiles, joinCaseLawProfiles } from "./publish/parse.mjs";
|
|
132
132
|
import { buildAuditMd, parseSpineFindingBlocks } from "./publish/audit-from-spine.mjs";
|
|
133
133
|
import { deriveRegisterPresence } from "./publish/register-presence.mjs"; // — the audit stores every live in-scope record
|
|
134
|
-
import { lastAcceptedMatterFrame, withFrameClasses, frameIdentifiedClassRows, frameHouseElementCandidate } from "./matter-frame-record.mjs"; // — the frame's inferred scope, when nothing was instructed; and the classes it judged necessary beyond the instructed ones, each with its reason, which the plan compile gives one identical-mark question apiece (decision 18)
|
|
134
|
+
import { lastAcceptedMatterFrame, withFrameClasses, frameIdentifiedClassRows, frameHouseElementCandidate, readInstructedScope } from "./matter-frame-record.mjs"; // — the frame's inferred scope, when nothing was instructed; and the classes it judged necessary beyond the instructed ones, each with its reason, which the plan compile gives one identical-mark question apiece (decision 18)
|
|
135
135
|
import { bandShapeGoods, instructedOrder, goodsEvidence } from "./record-goods.mjs"; // — the band's goods, read from the run's own records, so the shape can sort by the client's goods
|
|
136
136
|
import { frameWebChoiceFor, frameAskedForWebGrid, webGridOf, closureBlocksOf } from "./web-grid.mjs"; // — the web grid the matter frame decides: its stores, its forms, what it set aside
|
|
137
137
|
import { romanizedTermsFromPlan, mintSupplementalQid } from "./register-plan.mjs";
|
|
@@ -13634,9 +13634,11 @@ async function pipelineInner(job, opts = {}) {
|
|
|
13634
13634
|
// A2 — held axes are DISCLOSED, never silently absent: the note is what the report's front matter
|
|
13635
13635
|
// renders, and a gap nothing can close is precisely the gap the lawyer must be told about. It
|
|
13636
13636
|
// rides beside any close note rather than replacing it.
|
|
13637
|
+
// The account is the owner's sentences (approved 2026-10-30): the searches the register refused during the run apart
|
|
13638
|
+
// from the ones it does not support, each refused one with the register's recorded answer, read from the receipt.
|
|
13637
13639
|
const capabilityHeldAxes = heldAxes.filter((a) => !nothingToSendOnly(a));
|
|
13638
13640
|
if (capabilityHeldAxes.length) {
|
|
13639
|
-
const heldNote =
|
|
13641
|
+
const heldNote = undoneSearchesNote(ctx.registerPlan, envelopeReceipt, capabilityHeldAxes);
|
|
13640
13642
|
ctx.envelopeNote = ctx.envelopeNote ? `${ctx.envelopeNote}; ${heldNote}` : heldNote;
|
|
13641
13643
|
}
|
|
13642
13644
|
refreshSupplementalExecution(ctx); // envelope closes may have PROPOSED supplementals — account for them
|
|
@@ -18120,21 +18122,31 @@ function resolveRun(job, opts) {
|
|
|
18120
18122
|
* is the person trying to repair it.
|
|
18121
18123
|
*
|
|
18122
18124
|
* This is not a guess at the job. `seedRunStatus` writes these fields FROM the job the engine ran, so
|
|
18123
|
-
* reading them back is the engine's own record, not a reconstruction of intent.
|
|
18124
|
-
*
|
|
18125
|
-
*
|
|
18125
|
+
* reading them back is the engine's own record, not a reconstruction of intent. `reconstructCtx` reads
|
|
18126
|
+
* profile and framework from the run's frozen sidecars with `write: false`, so the run's own configuration
|
|
18127
|
+
* governs and the job supplies identity.
|
|
18128
|
+
*
|
|
18129
|
+
* THE ORDER'S TERRITORIES COME FROM THE RUN'S INSTRUCTED SCOPE, `scope` here. Status does not carry
|
|
18130
|
+
* them, and the stage messages read them off the job: the narrowing line names "one
|
|
18131
|
+
* question per market of the order's scope", and the default-territories line says whether the request
|
|
18132
|
+
* named any. A job without them fell back to the account's defaults, so a stage re-run on a saved run was
|
|
18133
|
+
* told markets the order never named (measured, 2026-10-29: four in place of the order's three). The run
|
|
18134
|
+
* wrote `jurisdictions` and `geography` verbatim from its job at intake (`instructedScopeOf`), so copying
|
|
18135
|
+
* them back gives the job its own territories, not a reading of them.
|
|
18126
18136
|
*
|
|
18127
18137
|
* THE CALLER MUST CHECK THE SLUG, and `resumeJobRefusal` below is that check. A rebuilt job that derives
|
|
18128
18138
|
* the run's own slug has reproduced the run's identity — `deriveSlug` reads `ref` and `markName` and
|
|
18129
18139
|
* nothing else — and one that derives a different slug is a job for a different matter.
|
|
18130
18140
|
*/
|
|
18131
|
-
export function jobFromStatus(status) { // @internal
|
|
18141
|
+
export function jobFromStatus(status, scope = null) { // @internal
|
|
18132
18142
|
const s = status ?? {};
|
|
18133
18143
|
if (!s.id && !s.ref && !s.markName) return null; // not a status this can rebuild from
|
|
18134
18144
|
return {
|
|
18135
18145
|
id: s.id ?? null, ref: s.ref ?? null, markName: s.markName ?? null,
|
|
18136
18146
|
classes: Array.isArray(s.classes) ? s.classes : null,
|
|
18137
18147
|
forwarder: s.forwarder ?? null,
|
|
18148
|
+
...(Array.isArray(scope?.jurisdictions) ? { jurisdictions: scope.jurisdictions } : {}),
|
|
18149
|
+
...(scope?.geography && typeof scope.geography === "object" ? { geography: scope.geography } : {}),
|
|
18138
18150
|
};
|
|
18139
18151
|
}
|
|
18140
18152
|
|
|
@@ -19025,10 +19037,13 @@ if (isEntrypoint(import.meta.url)) void (async () => {
|
|
|
19025
19037
|
let status = null;
|
|
19026
19038
|
try { status = JSON.parse(readFileSync(join(found.dir, "status.json"), "utf8")); }
|
|
19027
19039
|
catch (e) { console.error(`error: cannot read ${join(found.dir, "status.json")} — ${e.message}`); process.exit(2); }
|
|
19028
|
-
|
|
19040
|
+
const scope = readInstructedScope(found.dir);
|
|
19041
|
+
job = jobFromStatus(status, scope);
|
|
19029
19042
|
const refusal = resumeJobRefusal(job, found.slug);
|
|
19030
19043
|
if (refusal) { console.error(`error: ${refusal}`); process.exit(2); }
|
|
19031
|
-
console.error(`[pipeline] --job not given; rebuilt the job for ${a.codename} from ${join(found.dir, "status.json")} (slug ${found.slug} confirmed)`
|
|
19044
|
+
console.error(`[pipeline] --job not given; rebuilt the job for ${a.codename} from ${join(found.dir, "status.json")} (slug ${found.slug} confirmed)`
|
|
19045
|
+
+ (scope ? "; the order's territories from its instructed scope"
|
|
19046
|
+
: "; the run holds no readable instructed scope, so its territories fall back to the account's defaults"));
|
|
19032
19047
|
} else {
|
|
19033
19048
|
try { job = JSON.parse(readFileSync(a.job, "utf8")); }
|
|
19034
19049
|
catch (e) { console.error(`error: cannot read job file ${a.job} — ${e.message}`); process.exit(2); }
|
package/driver/register-plan.mjs
CHANGED
|
@@ -174,11 +174,11 @@ export { AWAITS_READING_TURN, awaitsReadingTurn, guardParentQid };
|
|
|
174
174
|
// that is the point.
|
|
175
175
|
export const unsupportedPredicateReason = (predicate, providerId) =>
|
|
176
176
|
`predicate "${predicate}" is not supported by the active register provider (${providerId}) — that capability is absent, `
|
|
177
|
-
+ `so this
|
|
177
|
+
+ `so this search was never carried out. It is a deferred gap for judgment, never a clean negative.`;
|
|
178
178
|
|
|
179
179
|
export const uncoveredJurisdictionReason = (jurisdictions, providerId) =>
|
|
180
180
|
`jurisdiction ${[...jurisdictions].join(", ")} is not covered by the active register provider (${providerId}) — `
|
|
181
|
-
+ `the office is outside the provider's coverage, so this
|
|
181
|
+
+ `the office is outside the provider's coverage, so this search was never carried out. It is a deferred gap for `
|
|
182
182
|
+ `judgment, never a clean negative.`;
|
|
183
183
|
|
|
184
184
|
// ──: an office the provider COVERS but this deployment cannot reach ─────────────────────────────
|
|
@@ -204,7 +204,7 @@ export const uncoveredJurisdictionReason = (jurisdictions, providerId) =>
|
|
|
204
204
|
export const officeUnavailableReason = (jurisdictions, providerId, { memberId, missing = [] } = {}) =>
|
|
205
205
|
`jurisdiction ${[...jurisdictions].join(", ")} is not covered by the active register provider (${providerId}) `
|
|
206
206
|
+ `AS THIS DEPLOYMENT IS CONFIGURED — the source that serves it (${memberId ?? "unknown"}) is not wired up here`
|
|
207
|
-
+ `${missing.length ? ` (${missing.join(" + ")} unset)` : ""}, so this
|
|
207
|
+
+ `${missing.length ? ` (${missing.join(" + ")} unset)` : ""}, so this search was never carried out. The provider `
|
|
208
208
|
+ `does cover this office: set the variable and re-run to close it. It is a deferred gap for judgment, `
|
|
209
209
|
+ `never a clean negative.`;
|
|
210
210
|
|
|
@@ -217,7 +217,7 @@ export const officeUnavailableReason = (jurisdictions, providerId, { memberId, m
|
|
|
217
217
|
// what a new one lands in, and the reason below is the sentence it lands with.
|
|
218
218
|
export const ownerIntersectionUnsupportedReason = (providerId) =>
|
|
219
219
|
`owner×term intersection is not supported by the active register provider (${providerId}) — the owner scope `
|
|
220
|
-
+ `field cannot be combined with mark text there, so this
|
|
220
|
+
+ `field cannot be combined with mark text there, so this search was never carried out. It is a deferred gap for `
|
|
221
221
|
+ `judgment, never a clean negative.`;
|
|
222
222
|
|
|
223
223
|
/** Is an owner×term entry executable on the provider? null when it is (or when no contract/owner). */
|
|
@@ -248,7 +248,7 @@ export function ownerIntersectionGap(entry, capabilities) {
|
|
|
248
248
|
export const goodsTextUnsupportedReason = (providerId) =>
|
|
249
249
|
`goods-and-services text narrowing is not supported by the active register provider (${providerId}) — the `
|
|
250
250
|
+ `goods description cannot be searched there, so the narrowed sweep this entry asks for was never run. `
|
|
251
|
-
+ `Running it on the class alone would have returned the crowd the narrowing exists to cut, so the
|
|
251
|
+
+ `Running it on the class alone would have returned the crowd the narrowing exists to cut, so the search `
|
|
252
252
|
+ `is deferred. It is a gap for judgment, never a clean negative.`;
|
|
253
253
|
|
|
254
254
|
/** Is a goods-narrowed entry executable on the provider? null when it is (or when it asks for no goods text). */
|
|
@@ -2796,6 +2796,60 @@ export const isProviderHardErrorReason = (reason) => {
|
|
|
2796
2796
|
return s.startsWith(PROVIDER_HARD_ERROR_PREFIX) || s.startsWith(PROVIDER_PERMANENT_ERROR_PREFIX);
|
|
2797
2797
|
};
|
|
2798
2798
|
|
|
2799
|
+
// ── WHY A SEARCH WAS NOT CARRIED OUT, IN THE LAWYER'S WORDS (the owner's sentences, approved 2026-10-30) ──────────────
|
|
2800
|
+
//
|
|
2801
|
+
// The front matter gave every search the register left undone one account: the provider cannot express it. That is
|
|
2802
|
+
// true of a search the register does not support, and false of one it refused during the run, such as on a spent daily
|
|
2803
|
+
// limit, which a later run can repeat. Each kind now has its own sentence. The refused one also stands beside each
|
|
2804
|
+
// refused search in the coverage ledger, in place of its recorded line, which is written for the run's record and not
|
|
2805
|
+
// for a reader; an unsupported search keeps its own line there, which names what the register lacks. The refused sentence
|
|
2806
|
+
// names the register's recorded answer in its bracket: a daily limit, a rate limit, or no answer. Any other answer gets
|
|
2807
|
+
// no bracket. The run's record keeps the recorded line.
|
|
2808
|
+
export const REFUSAL_BRACKETS = Object.freeze({
|
|
2809
|
+
"daily-limit": "its daily limit was reached",
|
|
2810
|
+
"rate-limited": "it was rate-limited",
|
|
2811
|
+
"not-responding": "it was not responding",
|
|
2812
|
+
});
|
|
2813
|
+
export const refusedSearchesSentence = (cause = null) =>
|
|
2814
|
+
`These searches were not carried out because the register refused them during this run${REFUSAL_BRACKETS[cause] ? ` (${REFUSAL_BRACKETS[cause]})` : ""}; a later run can repeat them.`;
|
|
2815
|
+
export const UNSUPPORTED_SEARCHES_SENTENCE = "These searches cannot be carried out because the register does not support this kind of search; no run can close this gap.";
|
|
2816
|
+
|
|
2817
|
+
/** The register's recorded answer to a search it refused, as a bracket: a 429 or rate limit that names a daily limit
|
|
2818
|
+
* (Signa's `daily_credit_cap_exceeded`, which clears at UTC midnight), any other 429 or rate limit, and a 5xx or no
|
|
2819
|
+
* answer at all. Signa's `insufficient_credits` is a used-up credit balance, which retrying cannot clear until the
|
|
2820
|
+
* balance changes: none of the three, so like anything else it is null. PURE. */
|
|
2821
|
+
export function refusalCause(reason) {
|
|
2822
|
+
const s = String(reason ?? "");
|
|
2823
|
+
const status = Number((s.match(/\bHTTP (\d{3})\b/i) ?? [])[1] ?? NaN);
|
|
2824
|
+
if (status === 429 || /(?:^|[^a-z])rate[_ -]?limit/i.test(s)) {
|
|
2825
|
+
if (/daily/i.test(s)) return "daily-limit";
|
|
2826
|
+
return /insufficient[_ ]credits?/i.test(s) ? null : "rate-limited";
|
|
2827
|
+
}
|
|
2828
|
+
if (status >= 500 && status <= 599) return "not-responding";
|
|
2829
|
+
if (!(status >= 400 && status <= 499) && /\btimed out\b|\btimeout\b|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up|fetch failed/i.test(s))
|
|
2830
|
+
return "not-responding";
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/** The sentence that stands beside one search the register refused, by its recorded line; null for any other line,
|
|
2835
|
+
* which stands as recorded. PURE. */
|
|
2836
|
+
export const refusedSearchSentence = (reason) => (isProviderHardErrorReason(reason) ? refusedSearchesSentence(refusalCause(reason)) : null);
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* The front matter's account of the searches left undone on the axes no re-run can close: the refused ones first, one
|
|
2840
|
+
* sentence per recorded answer in the brackets' order, then the unsupported ones. `axes` are those axes; the receipt's
|
|
2841
|
+
* deferred rows on them are the searches. Each such axis holds at least one search the register does not support, which
|
|
2842
|
+
* is what holds it, so the unsupported sentence always closes the note. A refused search on the same axis keeps its own
|
|
2843
|
+
* sentence rather than taking the axis's (ruled 2026-10-30). PURE.
|
|
2844
|
+
*/
|
|
2845
|
+
export function undoneSearchesNote(plan, receipt, axes) {
|
|
2846
|
+
const axisOf = new Map((plan?.entries ?? []).map((e) => [String(e?.qid ?? ""), String(e?.axis ?? "").toLowerCase()]));
|
|
2847
|
+
const on = new Set([...(axes ?? [])].map((a) => String(a).toLowerCase()));
|
|
2848
|
+
const refused = (receipt?.deferred ?? []).filter((d) => on.has(axisOf.get(String(d?.qid ?? ""))) && isProviderHardErrorReason(d?.reason));
|
|
2849
|
+
const causes = new Set(refused.map((d) => refusalCause(d.reason)));
|
|
2850
|
+
return [...[...Object.keys(REFUSAL_BRACKETS), null].filter((c) => causes.has(c)).map((c) => refusedSearchesSentence(c)), UNSUPPORTED_SEARCHES_SENTENCE].join(" ");
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2799
2853
|
/** Every qid this run has already taken through the fan-in ladder, per its own last receipt. PURE. */
|
|
2800
2854
|
export function ladderExhaustedQids(receipt) {
|
|
2801
2855
|
const out = new Set();
|
|
@@ -64,6 +64,10 @@ const PROSE_KEYS = new Set([
|
|
|
64
64
|
// (zero `sheet` keys in a real run's findings, checked on R19 `a46abdad`), so prose costs nothing and
|
|
65
65
|
// is right for what arrives. See the note below on why that is luck rather than design.
|
|
66
66
|
"sheet",
|
|
67
|
+
// The carry-through rows' own prose, from `carryThroughRows` below. `subject_cell` is a common-law
|
|
68
|
+
// subject cell whole, "<entity> — <what they do>". `section` is the heading the row sits under in the
|
|
69
|
+
// run's own markdown, and a heading can name the party its table is about.
|
|
70
|
+
"subject_cell", "section",
|
|
67
71
|
]);
|
|
68
72
|
|
|
69
73
|
/** Fields that carry a name: a mark, a proprietor, a subject, a form of a mark. */
|
|
@@ -83,10 +87,16 @@ const NAME_KEYS = new Set([
|
|
|
83
87
|
// by a different route. Read at its site (the per-territory query roll) rather than inferred: it is
|
|
84
88
|
// `e.term ?? e.terms[0]` off the frozen plan's own query entries.
|
|
85
89
|
"term",
|
|
90
|
+
// A common-law row's identifier, from `carryThroughRows` below: a site's address, whose domain is
|
|
91
|
+
// usually the party's name. It has no whitespace, so it adds no derived word.
|
|
92
|
+
"url",
|
|
86
93
|
]);
|
|
87
94
|
|
|
88
95
|
/** Arrays whose entries are bare names rather than objects. */
|
|
89
|
-
|
|
96
|
+
// `searched_marks` and `run_slugs` are not reference keys. score.mjs hands them in beside the reference: the
|
|
97
|
+
// marks the run itself searched, which reach this file through a finding only where a mark has one, and the
|
|
98
|
+
// run's folder, which is named for its mark (phase0.mjs deriveSlug) and printed with every path.
|
|
99
|
+
const NAME_LIST_KEYS = new Set(["covers_marks", "close_variations", "variations", "aliases", "searched_marks", "run_slugs"]);
|
|
90
100
|
|
|
91
101
|
/**
|
|
92
102
|
* Keys whose strings are NOT a name and NOT matter prose: schema labels, scenario ids, band and level
|
|
@@ -196,7 +206,12 @@ export function protectedStrings(root) {
|
|
|
196
206
|
// party called "Depth Charge" turns `per-territory depth` into `per-territory «name 2»`, and a reader
|
|
197
207
|
// cannot tell a redaction from a word. Both layers still apply to everything the run prints; only a
|
|
198
208
|
// line the SCORER ITSELF authored drops the derived one, through `authoredRedactor` below.
|
|
209
|
+
//
|
|
210
|
+
// A WORD THAT IS ALSO A GIVEN NAME IS NOT DERIVED. A run can search a single word that is also a word of a
|
|
211
|
+
// longer protected name, and the authored lines drop every derived word, so the coverage line printed that
|
|
212
|
+
// searched mark in clear. A given name stays given whatever else it is a word of.
|
|
199
213
|
const derived = new Set();
|
|
214
|
+
const given = new Set([...names].map((n) => foldForMatching(n.trim()).folded.toLowerCase()));
|
|
200
215
|
for (const n of [...names]) {
|
|
201
216
|
const parts = n.trim().split(/\s+/);
|
|
202
217
|
if (parts.length < 2) continue;
|
|
@@ -209,7 +224,7 @@ export function protectedStrings(root) {
|
|
|
209
224
|
// ligature is four characters raw and five folded, so a distinctive word was excluded for being
|
|
210
225
|
// short when it is not. Deriving from the folded form makes both decisions right by construction.
|
|
211
226
|
const bare = foldForMatching(w).folded.replace(/[^\p{L}\p{N}]/gu, "");
|
|
212
|
-
if (bare.length >= 5 && !LEGAL_FORM.has(bare.toLowerCase())) { names.add(bare); derived.add(bare); }
|
|
227
|
+
if (bare.length >= 5 && !LEGAL_FORM.has(bare.toLowerCase())) { names.add(bare); if (!given.has(bare.toLowerCase())) derived.add(bare); }
|
|
213
228
|
}
|
|
214
229
|
}
|
|
215
230
|
// THE FLOOR STAYS AND THE CASE IT MISSES IS NAMED. A fleet decision on an internal page rather than
|
|
@@ -237,6 +252,36 @@ export function protectedStrings(root) {
|
|
|
237
252
|
return { names, prose, derived, unclassified: [...unclassified].sort() };
|
|
238
253
|
}
|
|
239
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The carry-through rows, shaped for `protectedStrings`.
|
|
257
|
+
*
|
|
258
|
+
* THEY ARE THE RUN'S OWN TEXT, AND NO OTHER INPUT HOLDS THEM. A row lands in carry-through's `lost`
|
|
259
|
+
* because its subject arrived nowhere downstream, so it is in neither the findings nor the scored
|
|
260
|
+
* buckets, and the boundary had nothing to match it against. Measured on an invented fixture: the
|
|
261
|
+
* default `--json` printed `carry_through.lost[]`'s subject, URL and section heading in clear while the
|
|
262
|
+
* fields around them were tokenised, and the human path printed the same row under REACHED NONE.
|
|
263
|
+
*
|
|
264
|
+
* THE ENTITY IS THE NAME, AND THE WHOLE CELL IS PROSE. The common-law surface writes a subject as
|
|
265
|
+
* "<entity> — <what they do>". Collected whole as a name, every long word of the description becomes a
|
|
266
|
+
* derived protected word and tokenises the scorer's own sentences wherever it occurs. So only the head
|
|
267
|
+
* before the dash is a name, and the cell is withheld whole, as it stands.
|
|
268
|
+
*
|
|
269
|
+
* A URL IS A NAME, because a common-law site's domain is usually the party's own name. A register URI
|
|
270
|
+
* is not collected, for the reason `record_id` is safe: it is an office and a number.
|
|
271
|
+
*/
|
|
272
|
+
export function carryThroughRows({ lost = [], unmeasurable = [], conflicts = [] } = {}) {
|
|
273
|
+
return [...lost, ...unmeasurable, ...conflicts].map((r) => {
|
|
274
|
+
const cell = isStr(r.subject) ? r.subject.trim() : "";
|
|
275
|
+
const head = cell.split(/\s+[—–]\s+/)[0].trim();
|
|
276
|
+
return {
|
|
277
|
+
subject: head,
|
|
278
|
+
...(cell !== head ? { subject_cell: cell } : {}),
|
|
279
|
+
section: r.section,
|
|
280
|
+
...(isStr(r.identifier) && /^https?:\/\//i.test(r.identifier) ? { url: r.identifier } : {}),
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
240
285
|
/**
|
|
241
286
|
* The line a caller prints when `unclassified` is not empty. Names keys, never values.
|
|
242
287
|
*
|
|
@@ -124,6 +124,8 @@ step that gives you a list you can read.
|
|
|
124
124
|
A list you have read answers the crowd it came from. Do not ask that crowd again without the limits that answered
|
|
125
125
|
it.
|
|
126
126
|
|
|
127
|
+
A small list for a longer mark or narrower goods does not establish that a distinct root or sound-alike family adds nothing. Judge what that family could add to the advice and record the reason. Where no count is held, do not describe the family as a measured crowd.
|
|
128
|
+
|
|
127
129
|
The same steps serve any other question that comes back as a crowd, when reading it would change what you tell the client. Before you narrow a wider crowd, write in the proposal what reading it would change. If you cannot, set it aside with its count and your reason. You need not take every step on every crowd. A wider crowd that the lists you have read already
|
|
128
130
|
answer is set aside with its count and your reason. That is finished work, not a gap. The countries the identical
|
|
129
131
|
mark's market step did not read stay on its coverage row with their count, as today.
|
|
@@ -71,6 +71,8 @@ step that gives you a list you can read.
|
|
|
71
71
|
A list you have read answers the crowd it came from. Do not ask that crowd again without the limits that answered
|
|
72
72
|
it.
|
|
73
73
|
|
|
74
|
+
A small list for a longer mark or narrower goods does not establish that a distinct root or sound-alike family adds nothing. Judge what that family could add to the advice and record the reason. Where no count is held, do not describe the family as a measured crowd.
|
|
75
|
+
|
|
74
76
|
The same steps serve any other question that comes back as a crowd, when reading it would change what you tell the client. Before you narrow a wider crowd, write in the proposal what reading it would change. If you cannot, set it aside with its count and your reason. You need not take every step on every crowd. A wider crowd that the lists you have read already
|
|
75
77
|
answer is set aside with its count and your reason. That is finished work, not a gap. The countries the identical
|
|
76
78
|
mark's market step did not read stay on its coverage row with their count, as today.
|
package/driver/suite-census.json
CHANGED
|
@@ -909,6 +909,12 @@
|
|
|
909
909
|
"skips": 0,
|
|
910
910
|
"todos": 0
|
|
911
911
|
},
|
|
912
|
+
"a-job-rebuilt-from-its-run-keeps-the-orders-territories.test.mjs": {
|
|
913
|
+
"tests": 5,
|
|
914
|
+
"asserts": 14,
|
|
915
|
+
"skips": 0,
|
|
916
|
+
"todos": 0
|
|
917
|
+
},
|
|
912
918
|
"a-jq-program-in-a-js-string-keeps-its-backslash.test.mjs": {
|
|
913
919
|
"tests": 2,
|
|
914
920
|
"asserts": 6,
|
|
@@ -1575,6 +1581,12 @@
|
|
|
1575
1581
|
"skips": 0,
|
|
1576
1582
|
"todos": 0
|
|
1577
1583
|
},
|
|
1584
|
+
"a-refused-search-is-told-apart-from-an-unsupported-one.test.mjs": {
|
|
1585
|
+
"tests": 4,
|
|
1586
|
+
"asserts": 14,
|
|
1587
|
+
"skips": 0,
|
|
1588
|
+
"todos": 0
|
|
1589
|
+
},
|
|
1578
1590
|
"a-refused-tool-call-is-classified-and-counted.test.mjs": {
|
|
1579
1591
|
"tests": 6,
|
|
1580
1592
|
"asserts": 21,
|
|
@@ -1649,7 +1661,7 @@
|
|
|
1649
1661
|
},
|
|
1650
1662
|
"a-release-note-is-written-for-its-reader.test.mjs": {
|
|
1651
1663
|
"tests": 9,
|
|
1652
|
-
"asserts":
|
|
1664
|
+
"asserts": 72,
|
|
1653
1665
|
"skips": 0,
|
|
1654
1666
|
"todos": 0
|
|
1655
1667
|
},
|
|
@@ -7377,6 +7389,18 @@
|
|
|
7377
7389
|
"skips": 0,
|
|
7378
7390
|
"todos": 0
|
|
7379
7391
|
},
|
|
7392
|
+
"the-scorer-withholds-the-carry-through-rows.test.mjs": {
|
|
7393
|
+
"tests": 6,
|
|
7394
|
+
"asserts": 16,
|
|
7395
|
+
"skips": 0,
|
|
7396
|
+
"todos": 0
|
|
7397
|
+
},
|
|
7398
|
+
"the-scorer-withholds-the-searched-marks.test.mjs": {
|
|
7399
|
+
"tests": 5,
|
|
7400
|
+
"asserts": 13,
|
|
7401
|
+
"skips": 0,
|
|
7402
|
+
"todos": 0
|
|
7403
|
+
},
|
|
7380
7404
|
"the-screen-gate-records-what-its-drops-rest-on.pipeline.test.mjs": {
|
|
7381
7405
|
"tests": 3,
|
|
7382
7406
|
"asserts": 20,
|
|
@@ -9083,6 +9107,12 @@
|
|
|
9083
9107
|
"skips": 0,
|
|
9084
9108
|
"todos": 0
|
|
9085
9109
|
},
|
|
9110
|
+
"providers/signa/test/a-rate-limit-refusal-is-waited-out.test.mjs": {
|
|
9111
|
+
"tests": 10,
|
|
9112
|
+
"asserts": 41,
|
|
9113
|
+
"skips": 0,
|
|
9114
|
+
"todos": 0
|
|
9115
|
+
},
|
|
9086
9116
|
"providers/signa/test/a-search-row-is-a-band-row.test.mjs": {
|
|
9087
9117
|
"tests": 3,
|
|
9088
9118
|
"asserts": 15,
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
3
|
+
"version": "0.5.0-beta.6",
|
|
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.",
|
package/package.json
CHANGED
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.5.0-beta.
|
|
5
|
+
"version": "0.5.0-beta.6",
|
|
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": {
|
|
@@ -528,7 +528,7 @@ export function makeExecutePlan(deps) {
|
|
|
528
528
|
if (capabilities && capabilities.ownerTermIntersection !== true
|
|
529
529
|
&& typeof e?.owner === "string" && e.owner.trim() && String(e?.predicate ?? "default") !== "owner") {
|
|
530
530
|
blocks.push({ state: "incomplete", ...base, total_hits: 0, fetched: 0, sample: [], error: true, deferred: true,
|
|
531
|
-
reason: `owner×term intersection is not supported by the active register provider (${capabilities.id ?? "unknown"}) — the owner scope field cannot be combined with mark text there, so this
|
|
531
|
+
reason: `owner×term intersection is not supported by the active register provider (${capabilities.id ?? "unknown"}) — the owner scope field cannot be combined with mark text there, so this search was never carried out. It is a deferred gap for judgment, never a clean negative.` });
|
|
532
532
|
stateByQid.set(e.qid, "error");
|
|
533
533
|
return;
|
|
534
534
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
3
|
+
"version": "0.5.0-beta.6",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
|
|
@@ -31,6 +31,7 @@ import { nonAnswerBodyError, parseJsonBody, unparsedBodyError } from "../../_sha
|
|
|
31
31
|
import { answerKey, compareAnswers, noteAnswer, openAnswerMemory, recallAnswer, rememberAnswer } from "../../_shared/answer-memory.mjs";
|
|
32
32
|
import { makeEnumerate, isOwnerScoped } from "../../_shared/enumerate.mjs";
|
|
33
33
|
import { makeExecutePlan } from "../../_shared/execute-plan.mjs";
|
|
34
|
+
import { pastSendUntil } from "../../_shared/send-until.mjs";
|
|
34
35
|
import CAPABILITIES, { SIGNA_OFFICE_KEYS, OWNER_SCOPED_WINDOW } from "./capabilities.js";
|
|
35
36
|
// The register's own measure of a term's length: case and accents folded, spaces not counted — the same
|
|
36
37
|
// measure its documented floors are stated in (the compiler's foldedTermLength counts the same way).
|
|
@@ -98,7 +99,44 @@ const creditsCharged = (resp) => {
|
|
|
98
99
|
};
|
|
99
100
|
const endpointOf = (method, path) => `${method} ${String(path).split("?")[0].replace(/^(\/v1\/trademarks\/)[^/]+/, "$1{id}")}`;
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
// ── A RATE-LIMIT REFUSAL IS WAITED OUT, THE WAY THE REGISTER'S OWN DOCUMENTATION SAYS ─────────────
|
|
103
|
+
//
|
|
104
|
+
// Signa's "Rate Limits & Credits" page (docs.signa.so/api-reference/rate-limits): a 429 carries one of
|
|
105
|
+
// three error types, and only `rate_limited` (too many requests per minute, or too many searches running
|
|
106
|
+
// at once) clears by waiting: "Wait `Retry-After` seconds, then retry." `Retry-After` is in seconds and
|
|
107
|
+
// comes only on a 429, and an error is never billed. `daily_credit_cap_exceeded` and
|
|
108
|
+
// `insufficient_credits` do not clear within a call, so they return at once, as every 429 used to.
|
|
109
|
+
//
|
|
110
|
+
// This loop used to retry server errors only, so a 429 came back on its first attempt and the plan
|
|
111
|
+
// executor asked again at once, inside the same window.
|
|
112
|
+
//
|
|
113
|
+
// The wait is the register's own figure: `Retry-After`, else the body's `retry_after`, else the
|
|
114
|
+
// documentation's backoff (one second, doubling). Up to a second of jitter goes on top, as Signa's
|
|
115
|
+
// resilience guide does, so units refused together do not return together. A retry is another attempt on
|
|
116
|
+
// the same ledger row, never a row of its own.
|
|
117
|
+
//
|
|
118
|
+
// ONE CALL SLEEPS AT MOST ONE PER-MINUTE WINDOW IN ALL, AND NEVER PAST ITS TIME TO STOP SENDING. A plan call
|
|
119
|
+
// runs inside a model's turn, and a call that outlasts the turn ends with it, unwritten (send-until.mjs). So
|
|
120
|
+
// a wait that would take the call's sleeping past 60 seconds, or past the turn's time to stop sending, is
|
|
121
|
+
// not taken: that refusal returns as it always has.
|
|
122
|
+
export const RATE_LIMIT_RETRIES = 3;
|
|
123
|
+
export const RATE_LIMIT_SLEEP_BUDGET_MS = 60_000;
|
|
124
|
+
const RATE_LIMIT_BASE_MS = 1000;
|
|
125
|
+
|
|
126
|
+
/** How long to wait before asking again after a 429, or null when it is not a refusal a wait clears. */
|
|
127
|
+
export function rateLimitWaitMs(retryAfter, body, retried, random = Math.random) {
|
|
128
|
+
if (body?.error?.type !== "rate_limited") return null;
|
|
129
|
+
const header = String(retryAfter ?? "").trim() === "" ? NaN : Number(retryAfter);
|
|
130
|
+
const stated = Number.isFinite(header) && header >= 0 ? header * 1000
|
|
131
|
+
: Number.isFinite(body.error.retry_after) && body.error.retry_after >= 0 ? body.error.retry_after * 1000
|
|
132
|
+
: RATE_LIMIT_BASE_MS * 2 ** retried;
|
|
133
|
+
return stated + random() * RATE_LIMIT_BASE_MS;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
137
|
+
|
|
138
|
+
// `sleep` is for a test, which cannot wait out a real window; every caller here leaves it alone.
|
|
139
|
+
export async function signaFetch(apiKey, base, path, { method = "GET", body = null, retries = 1, tctx = null, sleep = pause } = {}) {
|
|
102
140
|
const url = `${base}${path}`;
|
|
103
141
|
const endpoint = endpointOf(method, path);
|
|
104
142
|
const headers = { Authorization: `Bearer ${apiKey}`, Accept: "application/json" };
|
|
@@ -126,20 +164,33 @@ export async function signaFetch(apiKey, base, path, { method = "GET", body = nu
|
|
|
126
164
|
note({ held: true, served: true, status: held.status });
|
|
127
165
|
return { status: held.status, ok, url, body: parsed, raw: held.raw, parseError };
|
|
128
166
|
}
|
|
129
|
-
let attempts = 0, resp;
|
|
167
|
+
let attempts = 0, resp, raw = null, serverRetries = 0, rateRetries = 0, slept = 0;
|
|
130
168
|
try {
|
|
131
|
-
for (
|
|
132
|
-
attempts
|
|
169
|
+
for (;;) {
|
|
170
|
+
attempts++;
|
|
171
|
+
raw = null;
|
|
133
172
|
resp = await fetch(url, init);
|
|
134
|
-
if (resp.
|
|
135
|
-
|
|
173
|
+
if (resp.status === 429) {
|
|
174
|
+
// The body says which of the three refusals this is, so it is read here, and kept for the
|
|
175
|
+
// caller if this is the answer that stands.
|
|
176
|
+
raw = await resp.text();
|
|
177
|
+
const wait = rateRetries < RATE_LIMIT_RETRIES ? rateLimitWaitMs(resp.headers.get("retry-after"), parseJsonBody(raw).body, rateRetries) : null;
|
|
178
|
+
if (wait === null || slept + wait > RATE_LIMIT_SLEEP_BUDGET_MS || pastSendUntil(tctx, Date.now() + wait)) break;
|
|
179
|
+
rateRetries++;
|
|
180
|
+
slept += wait;
|
|
181
|
+
await sleep(wait);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (resp.ok || resp.status < 500 || serverRetries === retries) break;
|
|
185
|
+
serverRetries++;
|
|
186
|
+
await sleep(1500 * serverRetries);
|
|
136
187
|
}
|
|
137
188
|
} catch (err) {
|
|
138
189
|
logCall(tctx, { http_status: 0, ok: false, attempts, took_ms: Date.now() - t0, bytes: 0, cache_hit: false, endpoint, credits_charged: null });
|
|
139
190
|
if (mem) note({ held: Boolean(held), status: 0 });
|
|
140
191
|
throw err;
|
|
141
192
|
}
|
|
142
|
-
|
|
193
|
+
if (raw === null) raw = await resp.text();
|
|
143
194
|
// The parse failure travels on `parseError` instead of being swallowed — providers/_shared/http-body.mjs.
|
|
144
195
|
const { body: parsed, parseError } = parseJsonBody(raw);
|
|
145
196
|
logCall(tctx, { http_status: resp.status, ok: resp.ok, attempts, took_ms: Date.now() - t0, bytes: raw.length, cache_hit: false, endpoint, credits_charged: creditsCharged(resp) });
|
|
@@ -204,13 +204,19 @@ export function findings(text, {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// A SEMICOLON ENDS A CLAUSE THE READER CAN HOLD ON ITS OWN, so the limit counts each clause. A sentence
|
|
208
|
+
// the owner approved word for word ran to 34 words as two clauses of 17, and the check, not the sentence,
|
|
209
|
+
// was the thing to change (owner item 988, as with item 959 before it). A COLON FOLLOWED BY A SPACE ENDS
|
|
210
|
+
// ONE TOO: the next sentence he approved word for word ran to 37 words, as 12 before its colon and 25 after
|
|
211
|
+
// (2026-10-30). A colon with no space after it, as in a time or an address, ends nothing.
|
|
207
212
|
for (const s of sentencesIn(prose)) {
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
for (const clause of s.split(/[;:]\s+/)) {
|
|
214
|
+
if (words(clause) <= MAX_WORDS) continue;
|
|
215
|
+
const at = lines.findIndex((l, i) => i >= bodyStart && l.includes(clause.slice(0, 30)));
|
|
210
216
|
out.push({
|
|
211
217
|
line: (at < 0 ? bodyStart : at) + 1,
|
|
212
|
-
rule: `a sentence runs to at most ${MAX_WORDS} words; this one is ${words(
|
|
213
|
-
offending:
|
|
218
|
+
rule: `a sentence runs to at most ${MAX_WORDS} words, or each clause of it after a semicolon or a colon; this one is ${words(clause)}`,
|
|
219
|
+
offending: clause.slice(0, 90) + (clause.length > 90 ? "…" : ""),
|
|
214
220
|
});
|
|
215
221
|
}
|
|
216
222
|
}
|
package/scripts/score.mjs
CHANGED
|
@@ -64,7 +64,7 @@ import { recordQids } from "../driver/named-band.mjs";
|
|
|
64
64
|
import { previousRunDir, scenarioRefs } from "../driver/e2e-rounds.mjs";
|
|
65
65
|
// The names rule, kept out of this file so it is testable without a run directory — the same reason
|
|
66
66
|
// reference-score.mjs holds the scoring rules rather than this script.
|
|
67
|
-
import { protectedStrings, redactor, authoredRedactor, printAuthored, printPreRedacted, redactDeep, installRedaction, unclassifiedNotice, REDACTION_NOTICE } from "../driver/score-redaction.mjs";
|
|
67
|
+
import { protectedStrings, carryThroughRows, redactor, authoredRedactor, printAuthored, printPreRedacted, redactDeep, installRedaction, unclassifiedNotice, REDACTION_NOTICE } from "../driver/score-redaction.mjs";
|
|
68
68
|
import { readSettleStamp } from "../driver/settle-stamp.mjs";
|
|
69
69
|
import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
|
|
70
70
|
|
|
@@ -375,6 +375,20 @@ function verdictIsCleanOf(runDir) {
|
|
|
375
375
|
});
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
/**
|
|
379
|
+
* The run's folder names, as the redaction must know them: a slug is `tmp<ref>-<mark>` or
|
|
380
|
+
* `noref<hash>-<mark>` (phase0.mjs deriveSlug), and the `run:` and `reviewer:` lines print the path whole.
|
|
381
|
+
* Both the slug and its mark part, because the mark part also names files inside the run.
|
|
382
|
+
*/
|
|
383
|
+
function slugsOf(runDir, { status = null, meta = null } = {}) {
|
|
384
|
+
const found = new Set([status?.slug, meta?.slug].filter((s) => typeof s === "string" && s.trim()).map((s) => s.trim()));
|
|
385
|
+
for (const seg of String(runDir).split(/[\\/]/)) if (SLUG_SHAPE.test(seg)) found.add(seg);
|
|
386
|
+
for (const s of [...found]) { const mark = s.replace(SLUG_SHAPE_HEAD, ""); if (mark && mark !== s) found.add(mark); }
|
|
387
|
+
return [...found];
|
|
388
|
+
}
|
|
389
|
+
const SLUG_SHAPE = /^(tmp[a-z0-9]+|noref[0-9a-f]{6})-./;
|
|
390
|
+
const SLUG_SHAPE_HEAD = /^(tmp[a-z0-9]+|noref[0-9a-f]{6})-/;
|
|
391
|
+
|
|
378
392
|
function readRun(runDir, ref) {
|
|
379
393
|
if (!existsSync(runDir)) die(`no run directory at ${runDir}`);
|
|
380
394
|
const hasDriver = existsSync(driverDir(runDir));
|
|
@@ -397,6 +411,7 @@ function readRun(runDir, ref) {
|
|
|
397
411
|
const settle = readSettleStamp(runDir);
|
|
398
412
|
return {
|
|
399
413
|
dir: runDir, lane, hasDriver, hasStatus, poolMeta, settle, findings, subjects,
|
|
414
|
+
slugs: slugsOf(runDir, { status: readJson(join(runDir, "status.json")), meta: poolMeta }),
|
|
400
415
|
// — from the RUN DIR's own status.json, which a failed or in-flight run has and a pool
|
|
401
416
|
// meta.json only exists after publish. Null on any run scored before that stamp, and the printer
|
|
402
417
|
// says so rather than leaving the line off: a missing instrument and a matching one must not read
|
|
@@ -1015,12 +1030,24 @@ const delta = prev ? bucketDelta(scored.buckets, prev.buckets) : null;
|
|
|
1015
1030
|
// as a name substitutes it in all of them rather than needing each one withheld whole. That matters:
|
|
1016
1031
|
// `text` carries the reviewer's verdict word, and withholding it entire would blank a value the reader
|
|
1017
1032
|
// came for.
|
|
1033
|
+
//
|
|
1034
|
+
// AND A FOURTH: THE CARRY-THROUGH ROWS. A subject lands in `lost` there because it reached no
|
|
1035
|
+
// downstream artifact, so it is in none of the three above, and both outputs printed it in clear.
|
|
1036
|
+
//
|
|
1037
|
+
// AND THE RUN'S OWN: THE MARKS IT SEARCHED, AND ITS FOLDER. A knockout's subject roll reached the
|
|
1038
|
+
// redaction only through a mark's finding rows, so a mark with none, or spelled other than the
|
|
1039
|
+
// reference spells it, went out in clear on the `coverage:` line. The folder is the run's slug, named
|
|
1040
|
+
// for its mark, and the `run:` and `reviewer:` lines print the path.
|
|
1018
1041
|
let unclassifiedKeys = [];
|
|
1042
|
+
const carry = carryThrough(run.dir);
|
|
1043
|
+
const carryCoverage = coverageConflicts(run.dir, carry.lost);
|
|
1019
1044
|
// The two instruments the JSON payload needs, live only when names are withheld. With `--names` they
|
|
1020
1045
|
// stay identity, which is the same "nothing to withhold" case `printAuthored` already has.
|
|
1021
1046
|
let redactValue = (x) => x, redactKey = (x) => x;
|
|
1022
1047
|
if (!opts.names) {
|
|
1023
|
-
const { names, prose, derived, unclassified } = protectedStrings({ reference: ref, scored, run: run.findings ?? null
|
|
1048
|
+
const { names, prose, derived, unclassified } = protectedStrings({ reference: ref, scored, run: run.findings ?? null,
|
|
1049
|
+
carry: carryThroughRows({ lost: carry.lost, unmeasurable: carry.unmeasurable, conflicts: carryCoverage.conflicts }),
|
|
1050
|
+
searched_marks: run.subjects ?? [], run_slugs: run.slugs ?? [] });
|
|
1024
1051
|
unclassifiedKeys = unclassified;
|
|
1025
1052
|
// The second redactor is for lines this tool WROTE. It drops the derived-word layer
|
|
1026
1053
|
// only, so a party's ordinary long word stops rewriting our own headings while its full name is still
|
|
@@ -1061,7 +1088,7 @@ if (opts.json) {
|
|
|
1061
1088
|
// — the same measure the human output prints, in the payload too. `--json` bypasses print,
|
|
1062
1089
|
// so leaving it out would hide it from exactly the readers most likely to automate on it, and a
|
|
1063
1090
|
// consumer would have no way to tell an absent measure from a clean one.
|
|
1064
|
-
carry_through:
|
|
1091
|
+
carry_through: { ...carry, coverage: carryCoverage },
|
|
1065
1092
|
}, { redactValue, redactKey }), null, 2)}\n`);
|
|
1066
1093
|
} else {
|
|
1067
1094
|
// Said before the first number, not after the last: a reader who stops at the top screen must know
|