clearotron 0.3.1 → 0.3.2-beta.0
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/CONTRIBUTING.md +30 -1
- package/README.md +1 -0
- package/build-info.json +2 -2
- package/docs/writing-rules.md +208 -0
- package/docs/writing-standard.md +85 -0
- package/driver/CHANGELOG.md +6 -0
- package/driver/contract-e3-backlog.mjs +3 -3
- package/driver/contract-vocabulary.mjs +15 -14
- package/driver/gateway.mjs +33 -17
- package/driver/package.json +1 -1
- package/driver/publish/render-knockout.mjs +6 -26
- package/driver/publish/render.mjs +25 -5
- package/driver/suite-census.json +24 -0
- package/driver/verify.mjs +22 -3
- 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/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/mint-writing-standard-backlog.mjs +82 -0
- package/scripts/writing-standard-check.mjs +122 -0
- package/shared/says-something-new.mjs +62 -0
- package/shared/writing-standard-caveats.json +14 -0
- package/shared/writing-standard-classes.mjs +526 -0
package/CONTRIBUTING.md
CHANGED
|
@@ -93,7 +93,7 @@ served to the model at run time — `synthesis-rules.md` is a 16,000-word progra
|
|
|
93
93
|
brevity, tone or tidiness changes what a clearance concludes. Nothing in this section, and nothing in
|
|
94
94
|
any writing pass over the documentation, applies to them.
|
|
95
95
|
|
|
96
|
-
## The
|
|
96
|
+
## The four rules that fail CI
|
|
97
97
|
|
|
98
98
|
**1. Build `portal-ui/dist` before you push.** The bundle is not committed; it is gitignored, and
|
|
99
99
|
CI runs `npm run build:ui` from source. So there is nothing to add — run it locally when you touch
|
|
@@ -130,6 +130,35 @@ The reviewer is the check. Say what you checked in the PR rather than citing a p
|
|
|
130
130
|
**3. Types are enforced, and `vite build` does not typecheck.** `npm run typecheck -w portal-ui`
|
|
131
131
|
runs as its own CI step. Run it before you push.
|
|
132
132
|
|
|
133
|
+
**4. Every sentence a customer reads is written against the standard, and five classes of it are
|
|
134
|
+
checked.** The standard is [`docs/writing-standard.md`](docs/writing-standard.md); the prose rules
|
|
135
|
+
under it are [`docs/writing-rules.md`](docs/writing-rules.md), and they apply first. Together they
|
|
136
|
+
bind report HTML, portal screens, the README and the docs.
|
|
137
|
+
|
|
138
|
+
`node scripts/writing-standard-check.mjs` refuses what your change ADDS to one of those surfaces:
|
|
139
|
+
an engineering identifier in text a client reads, a reviewer-only marker in rendered output, a known
|
|
140
|
+
caveat sentence, a screen that writes its own page heading instead of using `PageHeader`, and a lede
|
|
141
|
+
whose words are all already in its title. It names the class and prints the line. It never rewrites —
|
|
142
|
+
a rewritten sentence is a sentence nobody reviewed.
|
|
143
|
+
|
|
144
|
+
It refuses what you add, not what is already here. The standing population is counted per file and
|
|
145
|
+
per class in `driver/test/fixtures/writing-standard-backlog.json`, and the floor beside it refuses
|
|
146
|
+
any file that grows, so the number can only fall. If you repair one, re-mint the floor with
|
|
147
|
+
`node scripts/mint-writing-standard-backlog.mjs --apply` so it drops with the tree.
|
|
148
|
+
|
|
149
|
+
### The review step, for what no check can judge
|
|
150
|
+
|
|
151
|
+
Tone is not one of the five classes and no word list will ever catch it. A sentence can carry no
|
|
152
|
+
identifier, no caveat and no banned word and still be the thing the standard exists to stop: a
|
|
153
|
+
heading that restates its section, a paragraph explaining what the reader can already see, a sentence
|
|
154
|
+
that only works if you know how the engine is built.
|
|
155
|
+
|
|
156
|
+
So: **a pull request that changes text a customer reads is reviewed against the rendered page or
|
|
157
|
+
report, not against the diff.** One reviewer reads it as someone who has never seen this product and
|
|
158
|
+
asks one question of every new sentence — *what would a reader with zero context think this means?*
|
|
159
|
+
If the answer needs the codebase, the sentence is cut or rewritten. One reviewer, one question,
|
|
160
|
+
recorded in the pull request.
|
|
161
|
+
|
|
133
162
|
### If your change touches a Markdown file, two more will catch you
|
|
134
163
|
|
|
135
164
|
Both are in `npm run test:full` rather than the fast tier, and neither is something a reviewer spots.
|
package/README.md
CHANGED
|
@@ -139,6 +139,7 @@ credential.
|
|
|
139
139
|
| Check it works before spending anything | [docs/E2E.md](docs/E2E.md) |
|
|
140
140
|
| Understand the architecture | [docs/architecture/](docs/architecture/) · [decisions](docs/decisions/) |
|
|
141
141
|
| Run it under your own name, or fork it | [docs/branding.md](docs/branding.md) · [TRADEMARKS.md](TRADEMARKS.md) |
|
|
142
|
+
| Write a sentence a customer will read | [docs/writing-standard.md](docs/writing-standard.md) · [docs/writing-rules.md](docs/writing-rules.md) |
|
|
142
143
|
|
|
143
144
|
## Development
|
|
144
145
|
|
package/build-info.json
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Writing Rules
|
|
2
|
+
|
|
3
|
+
From the On Style writing rules, https://github.com/gundersen/on-style, MIT licence; reproduced here so the standard is complete on its own. The licence text is at the end of this file.
|
|
4
|
+
|
|
5
|
+
*Apply these to all prose you produce.*
|
|
6
|
+
|
|
7
|
+
*These rules fight trained instincts. Pay special attention to: no hedging, no over-explaining, keep it short, no metadiscourse, finish strong, and don't fake contradiction. These are the rules most likely to be violated — enforce them first.*
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Words — what to use and what to cut
|
|
12
|
+
|
|
13
|
+
## Cut dead weight
|
|
14
|
+
|
|
15
|
+
Every word must earn its place. Kill:
|
|
16
|
+
- **Zombie nouns**: "make an appearance" → "appear"; "provide an explanation" → "explain"
|
|
17
|
+
- **Light verbs** propping up nominalizations: make, do, have, bring, put, take
|
|
18
|
+
- **Metaconcept filler**: matter, view, subject, process, basis, factor, level, model
|
|
19
|
+
- **Throat-clearing openers**: "It is worth noting that...", "It should be pointed out that...", "There is a tendency for..."
|
|
20
|
+
|
|
21
|
+
If a 40-word sentence says what 15 words can say, use 15.
|
|
22
|
+
|
|
23
|
+
## Be concrete
|
|
24
|
+
|
|
25
|
+
Use nouns and verbs, not adjectives and adverbs. Prefer plain words over fancy ones. Replace abstractions with the tangible things they refer to — people, actions, objects. If the reader can't picture it, rewrite it. Be concrete about the argument; be vague about the scenery.
|
|
26
|
+
|
|
27
|
+
"The company experienced a significant deterioration in operational performance across multiple facilities during the quarter" — how many facilities? What happened? "Three factories missed production targets and the Chicago plant shut down for two weeks" — now the reader sees it.
|
|
28
|
+
|
|
29
|
+
## No jargon
|
|
30
|
+
|
|
31
|
+
If a phrase requires a second phrase to explain it, use the explanation. "How the AI in our weapons makes decisions" — not "autonomy governance." "We raised $50M to build bigger rockets" — not "we secured a strategic capital infusion to scale our launch vehicle program." The test: could a smart outsider follow it without a glossary?
|
|
32
|
+
|
|
33
|
+
## No hedging — make definite assertions
|
|
34
|
+
|
|
35
|
+
Don't reflexively pad with: almost, apparently, fairly, somewhat, sort of, to a certain degree, relatively, presumably. Cut throat-clearing qualifiers: actually, of course, surely, in fact, just, so, pretty, that said. Hedge only when genuine uncertainty exists and the reader needs to know about it. When you hedge, do it once and with precision — not as a verbal tic.
|
|
36
|
+
|
|
37
|
+
Beyond avoiding hedges, write with force. A sentence can be hedge-free and still limp. "The project went okay" has no qualifiers but says nothing. "The project shipped three weeks late and lost the client" says something.
|
|
38
|
+
|
|
39
|
+
## No intensifiers
|
|
40
|
+
|
|
41
|
+
Drop: very, highly, extremely, incredibly, truly, really, quite. These words weaken rather than strengthen. An unmodified claim reads as categorical and confident. Adding "very" turns it into a score on a scale.
|
|
42
|
+
|
|
43
|
+
## No GPT-isms
|
|
44
|
+
|
|
45
|
+
If it sounds like a language model wrote it, rewrite it. Ban list:
|
|
46
|
+
- **Compound modifiers that mean nothing**: "operationally relevant," "institutionally protected," "non-fragmentable," "identity-defining institutional constraint," "mission-critical," "best-in-class"
|
|
47
|
+
- **Faux-precision hedges**: "it's worth noting that," "it's important to understand," "this is particularly significant"
|
|
48
|
+
- **Breathless connectors**: "moreover," "furthermore," "indeed," "notably," "crucially," "importantly"
|
|
49
|
+
- **Vague authority words**: "robust," "comprehensive," "strategic," "leveraging," "ecosystem," "synergy," "paradigm," "holistic"
|
|
50
|
+
- **Performative structure**: "Let's break this down," "Here's the thing," "There are several key factors to consider"
|
|
51
|
+
|
|
52
|
+
The test: if the phrase exists only because an LLM stitched together impressive-sounding words, cut it.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
# Sentences — how they move
|
|
57
|
+
|
|
58
|
+
## Active voice by default
|
|
59
|
+
|
|
60
|
+
Put the agent before the action. "The board approved the deal" — not "The deal was approved by the board." If you can't name who's doing it, the sentence is hiding. Use passive only when the doer is unknown, unimportant, or when you need to shift emphasis deliberately.
|
|
61
|
+
|
|
62
|
+
## Light before heavy
|
|
63
|
+
|
|
64
|
+
Put short, simple phrases before long, complex ones. Topic before comment. Given information before new information. End sentences with the material that deserves the most emphasis.
|
|
65
|
+
|
|
66
|
+
"The executive team, after weeks of deliberation and consultation with outside counsel regarding the potential regulatory implications, approved the merger." The verb arrives too late. Flip it: "The executive team approved the merger after weeks of deliberation over regulatory risk."
|
|
67
|
+
|
|
68
|
+
## One idea per sentence
|
|
69
|
+
|
|
70
|
+
If a sentence tries to do two things, split it. If the reader has to reread it, it's too long or too tangled. The difficulty of a sentence depends on its geometry, not just its word count — avoid deeply nested clauses.
|
|
71
|
+
|
|
72
|
+
## Punctuation is breath
|
|
73
|
+
|
|
74
|
+
Punctuation controls how the reader hears you — the pacing, the pauses, the asides. A comma sounds different than a semicolon; parentheses make a different noise than dashes. Read sentences aloud. Where you pause naturally, punctuate. Where the punctuation forces an unnatural pause, remove it.
|
|
75
|
+
|
|
76
|
+
Two bright-line rules:
|
|
77
|
+
- **Always use the serial comma.** "Guards, dogs, and sensors" — keep the comma before "and." It eliminates ambiguity and costs nothing.
|
|
78
|
+
- **Never use exclamation points.** Let the words carry the emphasis. If the sentence needs a ! to land, the sentence isn't strong enough.
|
|
79
|
+
|
|
80
|
+
## Don't fake contradiction
|
|
81
|
+
|
|
82
|
+
Don't toss in a "But" or "However" to create the illusion that a second thought contradicts a first when it doesn't. "However" is not a synonym for "and" or "also" — it signals a turn. If there's no turn, drop it. Use contrastive connectors only when there is an actual contrast.
|
|
83
|
+
|
|
84
|
+
## Watch for dangling modifiers
|
|
85
|
+
|
|
86
|
+
"Walking through the data, the trend becomes clear" — the trend isn't walking. Make sure the modifier attaches to the subject of the sentence. "Walking through the data, we noticed a clear trend." If the sentence reads fine but the grammar is wrong, the reader will stumble even if they can't say why.
|
|
87
|
+
|
|
88
|
+
## Name the thing
|
|
89
|
+
|
|
90
|
+
Don't open a sentence with "this," "that," "these," or "it" unless the referent is unmistakable from the previous sentence. Vague pronouns break coherence — the reader has to look back to figure out what you mean. When in doubt, name the thing. "This approach" instead of "This." "The deal" instead of "It."
|
|
91
|
+
|
|
92
|
+
## Consistent terms, consistent vantage
|
|
93
|
+
|
|
94
|
+
If you use two different words, the reader assumes you mean two different things. Don't vary terminology for variety's sake. Stay in one tense, one voice, one point of view — don't flip without reason.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
# Paragraphs & Structure — shape and sequence
|
|
99
|
+
|
|
100
|
+
## Show, don't announce
|
|
101
|
+
|
|
102
|
+
Lead with the thing itself. Never open with "In this section I will discuss..." or "It's important to note that..." — just say it. No metadiscourse. No narrating your own writing process.
|
|
103
|
+
|
|
104
|
+
## Connect every sentence
|
|
105
|
+
|
|
106
|
+
Each sentence must follow from the one before it. Don't dump facts without showing how they relate. Use a logical sequence: general to specific, cause to effect, chronological, or big to small. If adding "therefore," "however," or "for example" between two sentences doesn't make sense, the transition is broken.
|
|
107
|
+
|
|
108
|
+
## Every paragraph earns its place
|
|
109
|
+
|
|
110
|
+
Each paragraph should have a single job. Begin with a sentence that states or implies the point. Let the following sentences develop it. End with emphasis, not a digression. A paragraph is a complete thought — not a visual break you insert when the block of text looks too long. If you can't say what a paragraph is about in one sentence, it's doing too many things.
|
|
111
|
+
|
|
112
|
+
## Finish strong
|
|
113
|
+
|
|
114
|
+
End paragraphs and sections with the sentence that carries the most weight. Don't trail off with qualifications or filler. The last thing the reader sees is what sticks.
|
|
115
|
+
|
|
116
|
+
"The deal, which was the largest acquisition in the company's history, closed Tuesday at $4.2 billion after six months of negotiation." The best fact is buried in the middle. Rearrange: "After six months of negotiation, the deal closed at $4.2 billion — the largest acquisition in the company's history."
|
|
117
|
+
|
|
118
|
+
## Say it once
|
|
119
|
+
|
|
120
|
+
State a principle in one place. Reference it after that. Never re-derive the same idea from scratch in every section.
|
|
121
|
+
|
|
122
|
+
## The rule of threes
|
|
123
|
+
|
|
124
|
+
People remember things better when grouped in threes. Three main points, three examples, three items in a list. It keeps structure clear and memorable. When you have five points, see if two of them are really the same point. When you have seven, you've lost the reader.
|
|
125
|
+
|
|
126
|
+
## Keep it short
|
|
127
|
+
|
|
128
|
+
If an email could be 3 sentences, don't write 3 paragraphs. Say it in as few paragraphs as the argument requires. Cut entire paragraphs the same way you cut words — if removing it doesn't hurt the argument, it was never helping.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
# Calibration — proportions and matching claims
|
|
133
|
+
|
|
134
|
+
## Respect the reader
|
|
135
|
+
|
|
136
|
+
Assume the reader is intelligent but doesn't share your specialized knowledge. Explain terms without being condescending. Never write in a way that makes the reader feel stupid — classic style makes the reader feel like a genius.
|
|
137
|
+
|
|
138
|
+
## Sometimes less detail is more accurate
|
|
139
|
+
|
|
140
|
+
Don't fabricate specifics to sound authoritative. "The company was founded in the early 2000s" is more accurate than "the company was founded in 2003" if you don't actually know the year. Precision with nonessential details invites errors and adds noise. Be specific when the detail matters and you're confident in it.
|
|
141
|
+
|
|
142
|
+
## Do not overstate
|
|
143
|
+
|
|
144
|
+
If you say "revolutionary" and the reader thinks "incremental," you've lost them — not just on that sentence but on everything that follows. Make claims proportional to the evidence. Overstatement erodes trust faster than understatement. Let the facts do the persuading.
|
|
145
|
+
|
|
146
|
+
## Do not explain too much
|
|
147
|
+
|
|
148
|
+
State it, show it, move on. If you state a principle and then explain it and then explain the explanation, you're writing a textbook, not a memo. Let examples reveal the point. Trust the reader to follow.
|
|
149
|
+
|
|
150
|
+
## Numbers over superlatives
|
|
151
|
+
|
|
152
|
+
If a number could replace a word or phrase, use the number. "Revenue growth significantly outpaced expectations in Q2" — by how much? "Revenue grew 34% in Q2, beating the Street estimate by 11 points" — now the reader can think with it. If a sentence still needs a qualitative descriptor after the number is included, use a calibrated and neutral term ("modest," "material" with a defined threshold) rather than an evocative one.
|
|
153
|
+
|
|
154
|
+
## No emotionally charged framing
|
|
155
|
+
|
|
156
|
+
Phrases like "not your salvation," "you're in trouble," "this is the one to stare at" are persuasion techniques, not analysis. Present the data and let the conclusion follow naturally. Analysis should read like a term sheet, not an op-ed.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
# Voice & Framing — identity and stance
|
|
161
|
+
|
|
162
|
+
## Say what you are, never what you're not
|
|
163
|
+
|
|
164
|
+
Define by positive assertion. Don't build identity through negation. "We build vertical rockets" — not "we are not a traditional aerospace company, not a components supplier, not a consultancy."
|
|
165
|
+
|
|
166
|
+
## No hidden negatives
|
|
167
|
+
|
|
168
|
+
Some sentences look positive but structurally say "we don't do X." Flip them. "We design what we can build in volume" — not "We subordinate performance ambition to scalable manufacturability." "We ship weekly" — not "We don't let perfect block progress." If the sentence only makes sense by contrast with something you're rejecting, rewrite it as a direct claim.
|
|
169
|
+
|
|
170
|
+
## Pass the "so what?" test
|
|
171
|
+
|
|
172
|
+
Every sentence must earn its place. If it sounds authoritative but wouldn't help settle an argument in a room, cut it. If you could swap in any name — any company, any person, any product — and the sentence still works, it's empty.
|
|
173
|
+
|
|
174
|
+
## Guide decisions, don't prescribe process
|
|
175
|
+
|
|
176
|
+
A framework tells you what matters when you're making a choice. It doesn't create governance structures, escalation pathways, or approval chains. Those belong in an ops manual, not a strategy document or memo.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Self-check
|
|
181
|
+
|
|
182
|
+
Rules decay over long outputs — the longer you write, the more likely you revert to trained habits. After writing any prose longer than two paragraphs, re-read it against the preamble list before presenting it. Check for: hedging, over-explaining, verbosity, metadiscourse, trailing caveats, fake contradiction, and vague pronoun references.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Licence of these rules
|
|
187
|
+
|
|
188
|
+
MIT License
|
|
189
|
+
|
|
190
|
+
Copyright (c) 2026 gundy
|
|
191
|
+
|
|
192
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
193
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
194
|
+
in the Software without restriction, including without limitation the rights
|
|
195
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
196
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
197
|
+
furnished to do so, subject to the following conditions:
|
|
198
|
+
|
|
199
|
+
The above copyright notice and this permission notice shall be included in all
|
|
200
|
+
copies or substantial portions of the Software.
|
|
201
|
+
|
|
202
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
203
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
204
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
205
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
206
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
207
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
208
|
+
SOFTWARE.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Writing standard
|
|
2
|
+
|
|
3
|
+
Every surface a customer reads: report HTML, portal screens, README, docs. Two parts.
|
|
4
|
+
|
|
5
|
+
## Part one: how to write
|
|
6
|
+
|
|
7
|
+
`writing-rules.md` beside this file is the prose standard, and it applies to every sentence in those
|
|
8
|
+
surfaces before anything below does. Read it in full once. The six rules it says to enforce first, because
|
|
9
|
+
they are the ones a trained writer and a language model both break by instinct: no hedging, no
|
|
10
|
+
over-explaining, keep it short, no metadiscourse, finish strong, and do not fake contradiction. If a
|
|
11
|
+
sentence sounds like a language model wrote it, rewrite it.
|
|
12
|
+
|
|
13
|
+
## Part two: the product's own rules
|
|
14
|
+
|
|
15
|
+
Eight rules, each with the sentence that caused it.
|
|
16
|
+
|
|
17
|
+
## One header per page, and nothing restating it
|
|
18
|
+
|
|
19
|
+
A page names itself once. A line under the title earns its place only by saying something the title
|
|
20
|
+
does not.
|
|
21
|
+
|
|
22
|
+
- Before: `Home` over `Now`.
|
|
23
|
+
- After: `Home`.
|
|
24
|
+
|
|
25
|
+
## Example text goes inside the field
|
|
26
|
+
|
|
27
|
+
A field's example belongs in its placeholder, where the reader is typing. A sentence above the field is
|
|
28
|
+
read before the reader knows what the field is for.
|
|
29
|
+
|
|
30
|
+
- Before, above the field: "A sentence is enough, or paste the whole thread."
|
|
31
|
+
- After, in the field: "Need a quick check on AQUAPLUS for energy drinks in the US before Friday."
|
|
32
|
+
|
|
33
|
+
## No caveat, no disclaimer, no "what this is not"
|
|
34
|
+
|
|
35
|
+
Say what the search did and what happens next. Never define the product by negation.
|
|
36
|
+
|
|
37
|
+
- Before: "What it is not. A clearance search. We drew no register conclusions and give no filing advice."
|
|
38
|
+
- After: "A name that passes here goes on to clearance."
|
|
39
|
+
|
|
40
|
+
## No engineering word in anything a client reads
|
|
41
|
+
|
|
42
|
+
Error codes, connector names, routing tables and internal identifiers are not the reader's vocabulary.
|
|
43
|
+
State the limit and its consequence.
|
|
44
|
+
|
|
45
|
+
- Before: "the Japan adapter was unavailable this session (CONNECTION_CLOSED)".
|
|
46
|
+
- After: "Case-law research could not be completed for Japan."
|
|
47
|
+
|
|
48
|
+
## A count only when it changes what the reader does
|
|
49
|
+
|
|
50
|
+
Register hit counts tell a lawyer how crowded a field is. Keep those. Activity counts measure the
|
|
51
|
+
engine, not the answer.
|
|
52
|
+
|
|
53
|
+
- Before: "Fifty-two meaning and connotation queries were run across the Latin, katakana and hiragana forms."
|
|
54
|
+
- After: "Meaning was checked in three scripts; nothing loaded attaches to the name."
|
|
55
|
+
|
|
56
|
+
## The product's own words for its own states
|
|
57
|
+
|
|
58
|
+
A state has one name, used on the screen, in the report, in help and in the export.
|
|
59
|
+
|
|
60
|
+
- Before: "No permissions".
|
|
61
|
+
- After: "View reports".
|
|
62
|
+
|
|
63
|
+
## The audit lives in the workbook and the MCP server, never on the page
|
|
64
|
+
|
|
65
|
+
Every search run, every empty result and the working notes go to the audit workbook, and the MCP server
|
|
66
|
+
answers questions about any of them. The page carries the finding.
|
|
67
|
+
|
|
68
|
+
- Before: "Source routing attempted: Per the source-selection table, Japan is a non-US, non-EU
|
|
69
|
+
jurisdiction, so the applicable adapter is…"
|
|
70
|
+
- After: nothing on the page. One row in the workbook.
|
|
71
|
+
|
|
72
|
+
## A fault is fixed, or shown to the person who can act on it
|
|
73
|
+
|
|
74
|
+
A customer cannot repair a missing coverage record. Narrating the failure to them turns our defect into
|
|
75
|
+
their problem.
|
|
76
|
+
|
|
77
|
+
- Before: "No coverage record was produced for this run. This section normally lists what each search
|
|
78
|
+
covered and what is still open… Ask us before relying on it."
|
|
79
|
+
- After: the operator is told; the report carries the coverage that exists.
|
|
80
|
+
|
|
81
|
+
## What holds these
|
|
82
|
+
|
|
83
|
+
`enforcement.md` lists the classes a CI check refuses in a diff. Tone is not one of them, and no word
|
|
84
|
+
list will ever catch it: a reviewer reads the rendered page as someone who has never seen this product,
|
|
85
|
+
against `writing-rules.md`, and asks what they would think each sentence means.
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.3.2-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7cc0d29: Fixed: A report now lists every part of the search that was left open. One with a short name could be hidden by another line that happened to mention the same word. The overall result was never affected, only the list of what remained open.
|
|
8
|
+
|
|
3
9
|
## 0.3.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -488,7 +488,7 @@ export const E3_BACKLOG = [
|
|
|
488
488
|
where: "driver/stages.mjs:3231",
|
|
489
489
|
surface: "stage-message",
|
|
490
490
|
evidence: "- meters: {\"mark_similarity\":{...},\"goods_proximity\":{...},\"use\":{...},\"enforcer\":{...}} — all four present, each {\"token\",\"basis\",\"source\"}. … mark_similarity = high | medium | low. goods_proximity = high | medium | low. enforcer = high | medium | low | unknown. use = confirmed | not-confirmed | un",
|
|
491
|
-
reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; driver/verify.mjs:
|
|
491
|
+
reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; driver/verify.mjs:1127 checkFindingsSibling gates meters.*.source; finding_basis_source_missing",
|
|
492
492
|
removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
|
|
493
493
|
},
|
|
494
494
|
{
|
|
@@ -684,7 +684,7 @@ export const E3_BACKLOG = [
|
|
|
684
684
|
// because an un-anchorable row lands in the NOT-CHECKED slice, and that is coverage lost rather than
|
|
685
685
|
// a pass.
|
|
686
686
|
evidence: "is ONE of coverage-disposition | fact | rating | narrative — pick the one your own legal read says the correction IS: coverage-disposition (a coverage row / disposition placement is wrong or dishonest)",
|
|
687
|
-
reparsedBy: "driver/verify.mjs:
|
|
687
|
+
reparsedBy: "driver/verify.mjs:803 CORRECTION_KIND_RE = /\\[kind:\\s*([a-z][a-z-]*)\\s*\\]/i → parseCorrectionKinds (verify.mjs:1026), consumed in pipelineInner() in pipeline.mjs for the run.jsonl `correction-kinds` histogram, which since #1558 also carries `kindChannelOk` — the counts are DERIVED from the parsed rows, and that key states whether the reviewer's kind channel produced anything at all rather than leaving a reader to infer it by comparing untyped against total. Telemetry only today",
|
|
688
688
|
removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
|
|
689
689
|
},
|
|
690
690
|
{
|
|
@@ -697,7 +697,7 @@ export const E3_BACKLOG = [
|
|
|
697
697
|
// `[on: -]` case went from a value to an ABSENCE — you omit the field — which is the one part a
|
|
698
698
|
// reader could get wrong from the old wording, since there is no value meaning "no finding".
|
|
699
699
|
evidence: "**AND EVERY FLAG CARRIES WHICH FINDING IT IS ABOUT** — the `on` field, an array of ordinals. Same rule as `kind`: you send the values, the driver renders the token.",
|
|
700
|
-
reparsedBy: "driver/verify.mjs:
|
|
700
|
+
reparsedBy: "driver/verify.mjs:820 CORRECTION_ON_RE = /\\[on:\\s*([0-9,\\s-]*?)\\s*\\]/i. SKILL-FILE ONLY — the stage message at stages.mjs:1842-1877 never mentions `[on:]`. This is #850's \"the element shape is in the skill file, not the stage message\" in its purest form: an E3 lint reading stages.mjs alone sees the [kind:] token and misses its twin",
|
|
701
701
|
removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
|
|
702
702
|
},
|
|
703
703
|
{
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
// extractor never sees:
|
|
17
17
|
//
|
|
18
18
|
// D1 verify.mjs:940 fail(`connotation_${reason}:…`) — reason iterates the table at line 894
|
|
19
|
-
// D2 verify.mjs
|
|
20
|
-
// D3 verify.mjs:
|
|
21
|
-
// D4 verify.mjs
|
|
19
|
+
// D2 verify.mjs fail(String(e.message)) — parseFindingsJson throws token-first
|
|
20
|
+
// D3 verify.mjs:1184 checkJson: fail(String(e.message)) — FIVE parsers reach this one site
|
|
21
|
+
// D4 verify.mjs parseCoverageLedgerJson, same shape
|
|
22
22
|
// D5 verify.mjs:1504 fail(`${unaccounted[0].token}:…`) — token minted in a DATA ROW
|
|
23
23
|
// D6 verify.mjs:1567 fail(`${violations[0].token}…`) — validatePlanFeasibility in register-plan.mjs
|
|
24
24
|
// D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2018 disclosureTextByAxis
|
|
@@ -99,7 +99,7 @@ export const VOCABULARY = [
|
|
|
99
99
|
{ token: "ratified_form_unread", stages: ["synthesis"], site: "driver/verify.mjs" },
|
|
100
100
|
{ token: "coverage_recommendation", stages: ["synthesis"], site: "driver/verify.mjs" },
|
|
101
101
|
{ token: "coverage_gap_unexplained", stages: ["synthesis"], site: "driver/verify.mjs" },
|
|
102
|
-
{ token: "finding", stages: ["synthesis"], site: "driver/verify.mjs
|
|
102
|
+
{ token: "finding", stages: ["synthesis"], site: "driver/verify.mjs", family: "driver/findings-model.mjs (token-first throws; `finding_*` and `findings_*`)", dynamic: "D2" },
|
|
103
103
|
|
|
104
104
|
// ── register-digest ────────────────────────────────────────────────────────────────────────────────
|
|
105
105
|
{ token: "coverage_form_damaged", stages: ["register-digest"], site: "driver/verify.mjs" },
|
|
@@ -109,16 +109,16 @@ export const VOCABULARY = [
|
|
|
109
109
|
{ token: "coverage_form_missing", stages: ["register-digest"], site: "driver/verify.mjs" },
|
|
110
110
|
{ token: "coverage_form_empty", stages: ["register-digest"], site: "driver/verify.mjs" },
|
|
111
111
|
{ token: "coverage_status_offenum", stages: ["register-digest"], site: "driver/verify.mjs:2050" },
|
|
112
|
-
{ token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs:
|
|
112
|
+
{ token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs:1510 coverageFormFail", family: "driver/register-plan.mjs:1642 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
|
|
113
113
|
{ token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1417 validatePlanFeasibility", dynamic: "D6" },
|
|
114
114
|
{ token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1766 searchedJurisdictionsFromPlan", dynamic: "D6" },
|
|
115
115
|
{ token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2018 disclosureTextByAxis", dynamic: "D7" },
|
|
116
116
|
{ token: "coverage_clean_tainted", stages: ["register-digest"], site: "driver/verify.mjs" },
|
|
117
|
-
{ token: "coverage_ledger_", stages: ["register-digest"], site: "driver/verify.mjs
|
|
118
|
-
{ token: "coverage_key_unknown", stages: ["register-digest"], site: "driver/verify.mjs
|
|
119
|
-
{ token: "coverage_axis_", stages: ["register-digest"], site: "driver/verify.mjs
|
|
120
|
-
{ token: "coverage_status_invalid", stages: ["register-digest"], site: "driver/verify.mjs
|
|
121
|
-
{ token: "coverage_classes_invalid", stages: ["register-digest"], site: "driver/verify.mjs
|
|
117
|
+
{ token: "coverage_ledger_", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs (parseCoverageLedgerJson token-first throws)", dynamic: "D4" },
|
|
118
|
+
{ token: "coverage_key_unknown", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
|
|
119
|
+
{ token: "coverage_axis_", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
|
|
120
|
+
{ token: "coverage_status_invalid", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
|
|
121
|
+
{ token: "coverage_classes_invalid", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
|
|
122
122
|
{ token: "plan_execution_unreadable", stages: ["register-digest", "narrative-refutation"], site: "driver/verify.mjs:1496, 1712" },
|
|
123
123
|
|
|
124
124
|
// ── matter-frame / prelim-variants / blind-frame / frame-diff ──────────────────────────────────────
|
|
@@ -130,7 +130,7 @@ export const VOCABULARY = [
|
|
|
130
130
|
{ token: "variantmodel_term_markup", stages: ["prelim-variants"], site: "driver/verify.mjs" },
|
|
131
131
|
{ token: "variantmodel_missing", stages: ["prelim-variants"], site: "driver/verify.mjs:1153, 1212" },
|
|
132
132
|
// Recovered during E2 authoring, absent from the draft census: variant-manifest.json is strict-parsed
|
|
133
|
-
// through checkSiblingJson (verify.mjs:
|
|
133
|
+
// through checkSiblingJson (verify.mjs:1197) → checkJson (:742), so the WHOLE variantmodel_* family
|
|
134
134
|
// reaches prelim-variants, not just the four literal tokens above.
|
|
135
135
|
// CONVERSION 3 widened this family's SOURCE without widening its prefix. `acceptPrelimVariants` raises
|
|
136
136
|
// `variantmodel_scope_layer_invalid`, `_scope_status_invalid`, `_scope_item_missing` and `_scope_pipe`
|
|
@@ -319,7 +319,8 @@ export const STAGE_UNREACHABLE_VALIDATORS = [
|
|
|
319
319
|
*
|
|
320
320
|
* The drift is CLUSTERED, not random: 34, 35, 36, 52 recur. That is one insertion above a block moving
|
|
321
321
|
* every citation below it, all at once, silently, in a PR that was about something else entirely. A
|
|
322
|
-
* reader following `coverage_ledger_ → verify.mjs
|
|
322
|
+
* reader following `coverage_ledger_ → verify.mjs` to line 1603 landed on a comment 219 lines from the
|
|
323
|
+
* dispatcher. (That number is the 2026 measurement's own datum, not a pointer into today's file.)
|
|
323
324
|
*
|
|
324
325
|
* `symbol:` names a thing instead — a function, a constant, an exported name. It survives every move.
|
|
325
326
|
* `site:` stays as the hint it always was, and `contract-audit.test.mjs` asserts that any `symbol:` a row
|
|
@@ -419,9 +420,9 @@ export const INNER_CODES = Object.freeze([
|
|
|
419
420
|
// CONNOTATION_FORM_REASONS at connotation-search.mjs:1731 is `CONNOTATION_REASONS` minus
|
|
420
421
|
// `no_recorded_queries`, and the `connotation_` family row (dynamic D1) is declared against exactly
|
|
421
422
|
// that list. The four call codes are folded by a template — `connotation_${callFail.reason}` at
|
|
422
|
-
// verify.mjs
|
|
423
|
+
// verify.mjs — so they are namespaced by construction rather than one branch at a time.
|
|
423
424
|
{ code: "call_never_made", mints: ["driver/connotation-search.mjs:2007"], rollsUpTo: ["connotation_call_never_made"],
|
|
424
|
-
why: "CALL_AUDIT_ROWS. The typed transport's four call states, handed in by disposition-call-audit.mjs and namespaced at verify.mjs
|
|
425
|
+
why: "CALL_AUDIT_ROWS. The typed transport's four call states, handed in by disposition-call-audit.mjs and namespaced at verify.mjs." },
|
|
425
426
|
{ code: "call_truncated", mints: ["driver/connotation-search.mjs:2008"], rollsUpTo: ["connotation_call_truncated"],
|
|
426
427
|
why: "As call_never_made — same table, same projection." },
|
|
427
428
|
{ code: "call_schema_violation", mints: ["driver/connotation-search.mjs:2009"], rollsUpTo: ["connotation_call_schema_violation"],
|
package/driver/gateway.mjs
CHANGED
|
@@ -2596,29 +2596,45 @@ export function correctionHint(lastFail, { gridLedgerName = "common-law-grid.jso
|
|
|
2596
2596
|
const dropped = (lastFail.match(/connotation_query_unrecorded:(.+)$/s) || [])[1] || "";
|
|
2597
2597
|
// ── TWO FAULTS, TWO REMEDIES, and sending the wrong one is what made this permanent ────────────
|
|
2598
2598
|
//
|
|
2599
|
-
// The gate marks each dropped query `[
|
|
2600
|
-
//
|
|
2601
|
-
//
|
|
2602
|
-
//
|
|
2599
|
+
// The gate marks each dropped query `[unmatched; nearest recorded: …]` or `[no recorded query
|
|
2600
|
+
// resembles this one]`, and the difference is what the gate can SEE, not what happened.
|
|
2601
|
+
//
|
|
2602
|
+
// UNMATCHED is knowable: something close is recorded, so the search ran and the wording differs.
|
|
2603
|
+
// Re-running changes nothing, and telling a seat to re-run is how a stage fails four times with the
|
|
2604
|
+
// same string.
|
|
2605
|
+
//
|
|
2606
|
+
// NO RESEMBLANCE IS NOT KNOWABLE, and this hint used to pretend otherwise. It said the query was
|
|
2607
|
+
// missing and to go and run it — but a query recorded under a translation, a transliteration or the
|
|
2608
|
+
// seat's own rewording resembles nothing and has already run, and that seat was then sent round the
|
|
2609
|
+
// same loop the unmatched branch exists to break. The gate cannot tell the two apart; no threshold
|
|
2610
|
+
// can, and a threshold that could would be one that hides a query nobody ran.
|
|
2611
|
+
//
|
|
2612
|
+
// So this branch stops asserting and hands over both repairs. Both are cheap, a seat can tell which
|
|
2613
|
+
// applies by looking at its own ledger, and neither wastes an attempt: if the search did run, fix
|
|
2614
|
+
// the row's wording; if it did not, run it and append the row. Where both labels appear, both
|
|
2615
|
+
// sentences are sent, as before.
|
|
2603
2616
|
const anyUnmatched = /\[unmatched; nearest recorded:/.test(dropped);
|
|
2604
|
-
const
|
|
2605
|
-
hint = anyUnmatched && !
|
|
2617
|
+
const anyUnresembled = /\[no recorded query resembles this one\]/.test(dropped);
|
|
2618
|
+
hint = anyUnmatched && !anyUnresembled
|
|
2606
2619
|
? `these dictated meaning queries ARE recorded in ${gridLedgerName} extras.pr_risk[] under a ` +
|
|
2607
2620
|
`different wording, which is why the driver cannot match them: ${dropped}. Do NOT re-run them — ` +
|
|
2608
2621
|
`the search already ran and its results are already in the ledger. EDIT each row's \`query\` ` +
|
|
2609
2622
|
`field to the query text EXACTLY as the task message dictates it, character for character, and ` +
|
|
2610
2623
|
`leave its results untouched. The driver matches your rows to its list by that text`
|
|
2611
|
-
: `
|
|
2612
|
-
`
|
|
2613
|
-
`
|
|
2614
|
-
`
|
|
2615
|
-
`
|
|
2616
|
-
`
|
|
2617
|
-
`
|
|
2618
|
-
`
|
|
2619
|
-
`
|
|
2620
|
-
`
|
|
2621
|
-
`
|
|
2624
|
+
: `the driver cannot match these dictated meaning queries to any row in ${gridLedgerName} ` +
|
|
2625
|
+
`extras.pr_risk[]: ${dropped}. That means one of two things and the driver cannot tell which, so ` +
|
|
2626
|
+
`check your own ledger and do whichever applies — both are cheap. IF THE SEARCH ALREADY RAN and ` +
|
|
2627
|
+
`you recorded it under different wording — a translation, a reordering, your own phrasing — do ` +
|
|
2628
|
+
`NOT run it again. EDIT that row's \`query\` field to the query text EXACTLY as the task message ` +
|
|
2629
|
+
`dictates it, character for character, and leave its results untouched. IF IT NEVER RAN, run it ` +
|
|
2630
|
+
`now and append a row to extras.pr_risk[]. A query that returned NO results still owes its row: ` +
|
|
2631
|
+
`record it with an empty results array, which is the receipt that the search RAN and came back ` +
|
|
2632
|
+
`clean. On an "offensive meaning" query the empty answer IS the good news, and a missing row is ` +
|
|
2633
|
+
`indistinguishable from a search nobody performed. Either way, record each query's text EXACTLY ` +
|
|
2634
|
+
`as the task message dictates it — the driver matches your rows to its list by that text, so a ` +
|
|
2635
|
+
`reworded query reads as one you never ran. Touch ONLY the listed queries and leave every other ` +
|
|
2636
|
+
`recorded row exactly as it is. Where a query is marked \`[unmatched; nearest recorded: …]\` the ` +
|
|
2637
|
+
`driver has already found its row for you: edit that row's \`query\` and do not search again`;
|
|
2622
2638
|
} else if (/connotation_search_missing/.test(lastFail)) {
|
|
2623
2639
|
// — was "your PR / reputational section claims a clean meaning … but the ledger recorded ZERO
|
|
2624
2640
|
// searches", which under the `ensure` prefix instructed the model to make the unbacked claim.
|
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.3.
|
|
5
|
+
"version": "0.3.2-beta.0",
|
|
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": {
|
|
@@ -53,6 +53,7 @@ import { demoBannerHtml } from './render.mjs'; // — the SAME banner the clea
|
|
|
53
53
|
// fail-open; re-deriving either in a renderer would be this file starting a second status vocabulary,
|
|
54
54
|
// which is the thing providers/_shared/screen.mjs exists to prevent.
|
|
55
55
|
import { makeClassifyStatus, isAllClass } from '../../providers/_shared/screen.mjs';
|
|
56
|
+
import { saysSomethingNew } from '../../shared/says-something-new.mjs';
|
|
56
57
|
|
|
57
58
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
58
59
|
|
|
@@ -1336,32 +1337,11 @@ const SCOPE_BLOCK_TEXT = 'What this is. A fast screen for obvious blockers to us
|
|
|
1336
1337
|
+ 'goes on to clearance. Every conflict above links to the material we found. The audit workbook holds '
|
|
1337
1338
|
+ 'every search run, every empty result and the working notes. Register data.';
|
|
1338
1339
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
const contentWords = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ')
|
|
1346
|
-
.split(/\s+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
|
|
1347
|
-
|
|
1348
|
-
/**
|
|
1349
|
-
* Does this line assert anything the reference text does not already assert?
|
|
1350
|
-
*
|
|
1351
|
-
* TRUE unless every content word in the line is already in the reference. An empty line has nothing to
|
|
1352
|
-
* say and returns false; a line with one unfamiliar word is kept. Singular/plural is folded so that
|
|
1353
|
-
* "conclusion" does not read as new beside "conclusions".
|
|
1354
|
-
*/
|
|
1355
|
-
function saysSomethingNew(line, reference) {
|
|
1356
|
-
// The stem must be IDEMPOTENT on the singular, or the fold does nothing: an earlier form stripped
|
|
1357
|
-
// "es" and turned "gives" into "giv" while leaving "give" alone, so the two never matched and every
|
|
1358
|
-
// caveat looked new. Strip one trailing "s" and nothing else.
|
|
1359
|
-
const stem = (w) => w.replace(/ies$/, 'y').replace(/s$/, '');
|
|
1360
|
-
const known = new Set(contentWords(reference).map(stem));
|
|
1361
|
-
const words = contentWords(line);
|
|
1362
|
-
if (!words.length) return false;
|
|
1363
|
-
return words.some((w) => !known.has(stem(w)));
|
|
1364
|
-
}
|
|
1340
|
+
// THE CAVEAT FILTER IS SHARED, NOT LOCAL. `saysSomethingNew` was defined here and is now in
|
|
1341
|
+
// `shared/says-something-new.mjs`, because the writing-standard check asks the identical question of a
|
|
1342
|
+
// page's lede against its title. Two definitions of one rule is one definition and one imitation of it.
|
|
1343
|
+
// The stopword set and the idempotent stem travel with it; the reference text below stays here, because
|
|
1344
|
+
// it is this page's own words and nothing else's.
|
|
1365
1345
|
|
|
1366
1346
|
function readBlock(m, framework) {
|
|
1367
1347
|
const factors = (m.factors ?? []).filter((s) => typeof s === 'string' && s.trim());
|