ultraprospect — a territory, turned into prospects you can cite
For engine evidence and coverage limits, read engine-evidence.
Like its ultra* siblings this is a division of labour. The engine decides
the mechanics: geocode the place, sweep what can be swept, tile around the
upstream caps, fuse what is certainly the same company, confirm the rest against
whatever authority the country has, account for what it could not reach. You decide the judgment: whether a near-miss pair is one business, what a
company actually does, which of them is worth a call.
The engine is built to be boring and honest about its edges. Nothing it produces
is a guess dressed as a fact, and every count it reports is one it measured.
The core rules:
- Reason from the run, not from memory. You know nothing about a town's
economy that is not in
places.json. If it is not in the run, fetch it or
say you did not.
- A truncated run is a truncated run, and a confirmed one is not a sweep.
When
manifest.truncated is true, say so in the first sentence of whatever
you write, and name the lane. Read the register lane's mode, which the
Coverage table prints as its own column, and which France, the United
Kingdom and Estonia can answer "sweep" to — each by a DIFFERENT shape,
named in the lane's reason. When it says "confirm" say so: the
list is what OpenStreetMap holds for that territory, checked against the
register, not what the register holds. A company nobody has mapped is not in
it. And the UK's sweep is by POST TOWN, not by the bounding box the OSM lane
used — the lane's reason says so, and repeating that is the difference
between an enumeration and a claim about a slightly different territory.
Presenting any of these as a whole territory is the one failure nobody
downstream can detect.
2b. A dated record is a fact about its date. A record carrying asOf came
out of a bulk snapshot, not from asking the register just now. Germany's
export stopped in 2019, so a German register identity is who filed under that
number THEN. Write it that way — "registered at … as of 2018-07" — and never
let it found a present-tense claim. check enforces this; do not make it
have to.
3. Absence is a finding. isHiring: false means we looked where hiring
would be and found none; isHiring absent means a board exists that we
could not read. Report the second as unknown, never as "not hiring".
4. Never invent a contact. Every email, phone number and person must
appear verbatim in a fetched page or an open-data record. Do not
reconstruct prenom.nom@domaine from a pattern — a plausible address is
worse than none.
5. Adjudicate, don't rubber-stamp. MATCH.todo.json exists because the
matcher refused to decide. Read the evidence in each pair and answer it;
approving the file wholesale throws away the reason it was written.
6. The attribution travels with the data. OSM is ODbL: any list you hand
over carries the notice the manifest gives you.
Running the engine
An installed skill lives away from the user's project, so a cwd-relative path
will NOT resolve. Use the absolute path to this skill's directory:
node <skill-dir>/scripts/ultraprospect.mjs <command> [options]
--help is the full flag surface and is kept in sync with the code by a build
gate. Read it rather than guessing a flag.
Route the ask
| You want to… |
Run |
| Check a place name resolves, before spending a sweep |
where "<place>" |
| Get a keyless register for the UK, Germany or Estonia, once |
ingest --country gb · de · ee |
| List every company in a town, street or radius |
scan --where "<place>" |
| Same, narrowed to ONE KIND OF BUSINESS, both lanes |
scan --where "<place>" --category amenity=cafe,naf=56.30Z |
| Same, narrowed to an industry or a company size |
scan --where "<place>" --section J,M --min-employees 10 |
| Answer the pairs the matcher would not decide |
match --run <dir> --apply verdicts.json |
| Find each company's website (the host's native web search) |
resolve --run <dir> --queries, then --web-results |
| Read those websites — what they do, who they hire |
enrich --run <dir> --tier 1, then --tier 2 --limit 20 |
| Attach a register identity outside France's sweep |
confirm --run <dir> |
| Rank what you found |
score --run <dir> |
| Write up one company from its evidence |
dossier --run <dir> --id <id> |
| Prove the write-up is grounded before anyone reads it |
check --run <dir> |
| Hand it over: CSV, report, one self-contained page |
render --run <dir> |
| Import the user's corrections or exclusions |
Read user feedback, then feedback --run <dir> [--apply <file>] and render again |
| See what moved since last month's sweep |
watch --run <new> --since <old> |
| Spread the judgement across subagents |
orchestrate --run <dir> |
| Drive it all from another harness |
mcp — serves where, ingest, scan, places, confirm, enrich, score, dossier, check, render, watch, doctor |
| Find out why a run came back thin |
doctor |
Cheat sheet
ultraprospect doctor --country de # are the upstreams this run needs up?
ultraprospect where "Vincennes" --country fr # resolve, or list the candidates and exit 2
ultraprospect scan --where "Vincennes" --country fr # both lanes, fused
ultraprospect scan --lat 48.8566 --long 2.3522 --radius 500m # a disc: the OSM lane is trimmed to it, like the register's
ultraprospect scan --where "Lyon" --section M --min-employees 20 --out ./runs
ultraprospect scan --where "Saint-Mandé" --country fr --exclude-legal-form 9110,6540,9220
ultraprospect scan --where "Vincennes" --country fr --category amenity=cafe,naf=56.30Z # ONE trade, BOTH lanes
ultraprospect scan --where "Kreuzberg, Berlin" --country de --category office=it # where no register sweeps, OSM alone
ultraprospect resolve --run <dir> --queries --skip chain,unnamed,public,vacant # spend no search on a bank branch
ultraprospect ingest --country gb # Companies House monthly snapshot, 470 MB, keyless
ultraprospect ingest --country de # the German register export, 260 MB, keyless
ultraprospect ingest --list # what is cached, which vintage, how much disk
ultraprospect ingest --check # has a register published something newer? exit 1 if so
ultraprospect scan --where "Hebden Bridge" --country gb # after ingest: the UK register IS enumerated
ultraprospect scan --where "Berlin" --country de # OSM sweeps the ground; the register comes later
ultraprospect confirm --run <dir> # Impressum -> HRB/USt-IdNr -> the authority confirms
ultraprospect scan --where "Berlin" --no-registry # skip the register lane entirely
ultraprospect match --run <dir> --apply verdicts.json # fold your adjudication back in
ultraprospect resolve --run <dir> --queries # the queries for YOU to search
ultraprospect resolve --run <dir> --web-results hits.json # ingest your hits, fetch, corroborate
ultraprospect resolve --run <dir> --only <id,id,id> # aim the lane: the ids you care about, not the first N
ultraprospect enrich --run <dir> --tier 1 # home + legal notice on every site
ultraprospect enrich --run <dir> --tier 2 --limit 20 # a page per role + the ATS APIs
ultraprospect score --run <dir> # rank by measured signals
ultraprospect dossier --run <dir> --id <id> # the grounding packet, pages and all
ultraprospect check --run <dir> # the gate. Exit 1 means do not present.
ultraprospect render --run <dir> # CSV + JSON + REPORT.md + index.html
ultraprospect render --run <dir> --min-fit possible # only the ones you judged worth it
ultraprospect watch --run <new> --since <old> # who opened, closed, started hiring
ultraprospect orchestrate --run <dir> # fan the two judgement phases out
ultraprospect mcp # serve it over MCP, stdio
ultraprospect scan --fixture <dir> # replay a recorded sweep, offline
Workflow
Resolve the place first. where costs one request and tells you what the
geocoder thinks you meant. If it exits 2 it has found several distinct places
with comparable confidence — show the user the list and ask, or pass
--pick <n>. Do not paper over it: "Vincennes" is a Paris suburb and a
town in Indiana, and picking silently produces a complete, plausible,
entirely wrong prospect file.
Scan, with filters if the territory is dense. This matters in France,
where the register IS swept: a French commune holds tens of thousands of
registered units, most of them dormant micro-entrepreneurs.
--min-employees, --section and --activity are how a run stays useful.
Those filters reach the register lane only. When OSM also ran without
--category, the manifest says so, records filters.narrowedLanes: ["registry"], and names how many rows the whole-catalogue OSM sweep returned.
--min-employees is translated through the sweep connector's own size-band
vocabulary; France declares that capability. A UK or Estonian sweep refuses
--min-employees and --size-band instead of accepting a filter it cannot
apply.
--legal-form keeps only the filed INSEE legal-form codes named;
--exclude-legal-form removes codes such as 9110 (condominium syndicates) or
9220 (associations). Includes are sent to /search; exclusions have no API
negation and are always applied client-side before a row spends the
--max-results budget. The register lane stops at that budget and declares
itself partial rather than spending twenty minutes.
2a. --category is the only filter that aims BOTH lanes. Reach for it first.
The older filters each reach one lane and only one: --osm-groups narrows
OpenStreetMap, while --section / --activity / --min-employees /
--size-band / --legal-form narrow the register. Neither knows the other
exists, so "I want cafés" said with either of them alone half-narrows the run.
The manifest now calls that asymmetry out; it does not make the lanes cover
the same catalogue. Measured on a Saint-Mandé sweep:
--section I gave 154 register rows and left the OSM lane returning all 295
places in town, of which 5 were cafés — one intent, two territories.
--category takes ONE vocabulary aimed at both, and it is the vocabulary
places.json already prints back at you:
| You type |
Lane |
Means |
amenity=cafe |
OSM |
that exact tag |
shop or shop=* |
OSM |
the whole key |
naf=56.30Z, sic-uk=56302, pkd=… |
register |
an activity code, in the register's own scheme |
nace=I |
register |
a section letter |
So the round trip holds: whatever category a row carries, you can paste it
straight back into --category on the next run. The scheme prefixes come off
the connectors themselves, not a hardcoded list.
--osm-groups accepts the nine catalogue groups shop, office, craft,
healthcare, industrial, amenity, tourism, leisure and club.
It refuses rather than half-narrow. Name only OSM tags where a register
CAN be swept and the run stops, because the register lane would enumerate the
whole territory beside your five cafés. Answer it by naming the other lane
too, or by saying the asymmetry is deliberate with --category-lane osm.
Where no register can be enumerated at all — Germany, Spain, most of the
world — there is no second lane to aim and an OSM-only category just runs.
Two things it will not let you do, both of which used to return a confident
nothing: mixing a section with an activity code outside it (nace=I,naf=10.71C
— the register ANDs them, so the answer is zero rows), and excusing a lane
that was not going to run anyway.
Measured, same town: --category amenity=cafe,amenity=restaurant,shop=bakery, naf=56.10A,naf=56.30Z,naf=10.71C returns 48 OSM + 65 register = 89 places
with 24 matched across both lanes — against 422 places and 27 matches
unfiltered. Nearly all the matches, a fifth of the noise.
2b. Ingest first where a register publishes a file instead of an API. The
United Kingdom, Germany and Estonia do, and their exports are keyless.
ingest --country gb fetches Companies House's monthly snapshot (470 MB) and
turns the UK into a territory that can be ENUMERATED; ingest --country de
fetches the German register export (260 MB) and gives confirm a source that
names the holder of an HRB number. ingest --country ee indexes Estonia's
daily export for enumeration by administrative unit. Each runs once and everything afterwards is
a local read — ingest --list says what is cached and how much disk it took.
Without the ingest, these connectors report themselves unavailable with that
command in the message, and the run continues.
- Read the coverage before reading the data.
manifest.lanes says what
each lane returned, whether it was capped, and — for the register lane —
whether the territory was "sweep"-ed or "confirm"-ed. The report prints
mode as its own column. manifest.truncated is the headline. Three registers
can be enumerated keylessly and they are not enumerated the same way: France
by bounding box through its API, the United Kingdom by POST TOWN out of the
snapshot, and Estonia by administrative unit after ingest --country ee.
Everywhere else the lane says in words that no register could be
swept, and that is a property of the world's open data, not a failure of the
run.
3b. Where the register was not swept, run confirm after enrich --tier 1.
That order is not arbitrary: the strongest route reads the registration number
off the legal notice tier 1 fetches — an Impressum in Germany, an aviso legal in Spain, both legally mandatory — and asks an authority whose it is.
Without pages, confirm can only look companies up by name, which is a
candidate rather than a fact. It refuses rather than doing the weak half
silently.
In Germany this is where the two sources divide the work, and saying which
answered matters: VIES confirms a USt-IdNr is live TODAY and returns "---"
for the holder, while de-offeneregister names who filed under an HRB number
in 2017-2019 and stamps asOf on it. Neither alone is a current identity, and
the pair is worth more than either. A German register number without its
Amtsgericht is ambiguous by construction — the same number exists at several
courts — so the connector refuses a bare number that matches more than one.
Inspect exact joins, then adjudicate MATCH.todo.json. Before scoring,
the matcher joins OSM-declared register identifiers exactly: a SIRET names an
establishment, while a SIREN must name exactly one record in this run. There
is no distance gate, unmatched identifiers stay declared rather than
producing a register record, and manifest.counts.mergedByIdentifier says
how many fusions this signal produced.
Each remaining pair carries the OSM name, the register
name that actually scored (matchedName — often an enseigne, not the legal
name), the distance in metres and the component scores. Answer with a JSON
array of {osmId, registryId, connectorId, merge, why} and fold it back with
match --apply.
Judge on evidence: same trade name, same street number, a brand the register
files under an enseigne. When you cannot tell, say merge: false — two rows
are recoverable, one wrong merge is not.
Find the websites — this is your job, and the run rests on it. Four in
five places arrive without one, and everything downstream grows from that
URL. resolve will refuse to run without search results rather than
quietly check the handful OSM already tagged: on a real Vincennes sweep that
silence produced 11 corroborated sites out of 1164, which reads as a town
with no web presence instead of as a search nobody ran.
The keyless engines are blocked, so this loop is not optional. Measured
live: DuckDuckGo answers an anti-bot challenge with HTTP 202, DuckDuckGo Lite
the same, Mojeek serves a challenge with a 200 and then a 403. All four, one
run. --engine-search is a probe, not a plan — when it says blocked,
nothing was searched, which is NOT the same as nothing being there.
The loop, mechanically:
# 1. size it, and spend nothing on rows that cannot become a prospect
ultraprospect resolve --run <dir> --queries --skip chain,unnamed,public,vacant
# 2. run EVERY printed query through your own WebSearch, one at a time
# 3. pool every hit into ONE array — duplicates, directories, noise and all
cat > hits.json <<'JSON'
[
{"placeId": "osm:n248494308", "url": "https://…", "title": "…", "snippet": "…"},
{"placeId": "osm:n248494308", "url": "https://…", "title": "…", "snippet": "…"},
{"placeId": "osm:n1585168700", "url": "https://…", "title": "…", "snippet": "…"}
]
JSON
# 4. hand them back — the engine fetches each one and judges it
ultraprospect resolve --run <dir> --web-results hits.json
placeId is the id from RESOLVE.todo.json; several hits share one, and
that is expected. Do not filter — you are finding candidates, not
choosing. The engine fetches each one and keeps it only if the page carries
the company's registration number, its street address or the distinctive part
of its name; a domain that ranked first and corroborates nothing is recorded
as unverified, never as the website.
Spend the searches on rows that could buy something. --skip drops the
ones that cannot, and every test reads a tag a mapper ASSERTED rather than
guessing from a name: chain reads brand/brand:wikidata, public reads
operator:type or the legal unit's filed legal form through its register's
own rule, vacant reads shop=vacant and disused:*, unnamed is a row
whose name is literally its own tag. Measured on the Saint-Mandé sweep:
76 of 420 rows skipped — 42 chain, 24 unnamed, 9 public, 6 vacant, five of them
counted twice — which is 104 searches not run on BNP Paribas branches and
primary schools. Nothing is
deleted: the rows keep their place in places.json, and RESOLVE.todo.json
gains a skipped array naming each id and the reasons that applied — so you
can disagree with a call and re-run without it, rather than take the total on
trust.
Two registers cannot be aimed at all and the run says so rather than
pretending: Estonia enumerates but its export carries no activity code, so a
register term there is refused instead of accepted and dropped.
A Facebook page is a finding, not a gap. For a local trader it is very
often the ENTIRE web presence, so resolve records it in contacts.socials
and marks the place webPresence: "social-only" — its own state, beside
own-site and none, and a column in the CSV. Measured on two Saint-Mandé
food businesses: both came back social-only, which is the true answer and
the actionable one. none means we searched and found nothing; an EMPTY
web_presence means nobody has searched yet, and the two must not be read
as the same thing.
The queries themselves prefer evidence to hope. Where a street is known,
<name> <number> <street> <town> is spent before the legal-notice angle: a
café is not obliged to publish mentions légales and mostly does not, while
the door number is a fact its site, its listing and its social page all
repeat. Where there is no street — most German OSM nodes — the Impressum
angle is still the best one left, and still runs. --queries-per-place
raises or lowers the budget; three is the default because you run each one
by hand.
Aim the lane on a big territory. It is sequential and fetches per place,
so a two-thousand-place sweep is a long run. --limit takes a PREFIX of the
file; --only <ids> takes the places you actually want, which is what you
need when the ones worth searching for are scattered through the sweep — the
IT companies in a city of offices, the manufacturers in a mixed high street.
Read category (and registry.activityCode, where a register answered) out
of places.json to pick them. --only narrows --queries too, so a fanned
out worklist matches the fold that follows it.
With many places, fan it out: orchestrate --run <dir> --phase resolve.
Enrich in two tiers, and spend the second one deliberately. Tier 1 reads
the homepage and the legal notice on every corroborated site: four requests,
and it answers whether the site is alive, what it says it does, whether the
company runs a hiring pipeline, and whether its registration is published there.
Tier 2 is the expensive one — a page per role (about, services, products,
pricing, careers, team, contact, cases, news) plus the openings read
straight out of the ATS API rather than out of a JavaScript shell. Run it on
the ones you have a reason to care about, not on the whole town: a thousand
places at eight pages each is six thousand requests and several hours.
6b. Bring your own vocabulary — the engine has none, on purpose.
--terms finds words VERBATIM in the pages tier 1 and 2 fetched, and
--roles counts job titles matching them. Neither ships a default in any
language, and that is the design rather than a gap. A word list frozen into a
general tool is one person's curation wearing the costume of a measurement:
it goes stale, it privileges whichever languages its author happened to
speak, and for every market it misses it reports a confident absence.
So translating a concept into a market's own words is YOUR job, and you have
two things the engine does not: the languages, and the web.
Translate the concept, not the German. "Does this company buy outside
work" is portage salarial and auto-entrepreneur in France, ZZP in the
Netherlands, autónomo in Spain, Freiberufler and Werkvertrag in
Germany, libero professionista in Italy. A literal translation of a
German word finds nothing, because these are legal statuses, not synonyms.
Check the phrasing against the live web before you scan. Search how
companies in that country actually word it on their careers pages, and take
the words you SEE rather than the ones you expect. resolve --engine-search
drives webindex's keyless search from inside this bundle, but prefer your
own WebSearch: the keyless engines block automated clients, and when they
do the lane says so in words rather than reporting an empty web.
Mind the inflection. Matching tolerates up to three trailing letters,
which carries German -n/-ern, French -s and Spanish -os. Languages
that inflect by REPLACING the ending — Polish, Czech — need a stem:
samozatrudnieni, not samozatrudnienie.
Refuse a term that means two things. B2B means contractor in a Polish
job ad and business-to-business in every English one. A term with two
readings produces hits nobody can act on, and they will look measured.
The trap is worst where the two readings live on the SAME page. Measured on
a Hamburg run: of three companies whose careers page matched this lexicon,
two carried Freelancer heading a real freelancer intake — "Wie deine
Zusammenarbeit mit uns abläuft", "Freelancer? Bitte hier entlang!" — and the
third matched selbstständig three times in "Freiraum für selbstständiges
und eigenverantwortliches Arbeiten", which describes an EMPLOYEE working
autonomously. Same page role, same lexicon, opposite meanings. German
selbstständig/selbständig, French autonome and English independent
all read both ways in a job ad; the words that only mean the legal status —
Freiberufler, freiberuflich, Werkvertrag, Subunternehmer,
portage salarial, ZZP — are the ones worth counting.
--terms-on defaults to the careers page, and widening it is a decision.
Measured on a real run before that default existed: 48 hits from home and
legal pages, and every sampled one was wrong — externe Dienstleister naming
data processors in a privacy policy, Freiberufler naming the CLIENTS a law
firm advises, Freelancer on a one-person studio's homepage describing its
owner. The words were right and the page was wrong, which is the worst shape
of wrong here because it reads as evidence.
Whatever you pass is recorded in the signals beside the hits, so a count
always says what it counted, and check re-reads every hit against the page
it cites.
Rank, then judge. score adds a measured total from things it counted —
site alive, recently touched, roles open, headcount band, revenue filed,
contactable. It does NOT score whether a company matches the brief, however
the --icp text is phrased. That is yours: read the packets, then fold
verdicts back with score --apply '[{"id":"…","fit":"strong","why":"…"}]'.
fit sits beside total; it never overwrites it, so the one column nobody
had to be trusted for stays intact.
Write each dossier from its packet. dossier --id <id> prints the fact
sheet and the FULL TEXT of every page fetched for that company, each under
the id you must cite. End each factual sentence with [P3]; mark your own
inference [M]. Do not cite a page fetched for a different company — the
gate checks ownership, not just existence.
Run check before anyone reads the output. Exit 1 means stop. It
re-opens every citation, demands a source or an [M] on every factual line,
and re-reads every email, phone and person against the page it claims to
come from. Contacts declared in OSM carry the exact feature as osm:<id>;
the gate re-reads that feature from the run's osm.json and looks only at
its contact tags. That last rule is the one that matters: an address
assembled from a naming convention is plausible, unfalsifiable at a glance,
and will be emailed. The gate makes it impossible rather than discouraged.
Render, and hand over all four — they are not the same deliverable.
render writes one run in four shapes, and which one you put in front of
someone decides whether they can act on it:
| File |
What it is for |
Hand it to |
index.html |
Looking. Self-contained, makes NO network request, opens from a file:// path with nothing installed. Sort, filter and facet in place to find the twenty rows that matter out of eight hundred — then open any row: the verdict verbatim, the score broken into the terms that produced it, every contact with the page id it was read from, the open roles, the register identity and its sourceUrl, the site signals. With JavaScript off, every panel is open. |
anyone who has to SEE the territory |
REPORT.md |
Reading and sharing. Coverage with each lane's mode, which connector answered and what attached each record, what the run was narrowed to, the counts, the score distribution, who is hiring, every fit verdict verbatim, the ranked table, and run notes deduplicated with their counts. Pastes into an issue, a PR, a wiki, a mail. |
anyone who has to judge whether the run is sound |
PROSPECTS.csv |
Working. Flat and CRM-shaped: score and fit in separate columns so the measured number survives your judgement, each contact's source page beside it, registry_as_of on anything from a snapshot, and role_filter / term_lexicon beside their counts so a number says what it counted. |
a CRM, a spreadsheet, a mail-merge |
prospects.json |
Piping. The same rows, unflattened, for whatever comes next. |
another program |
Do not hand over one and describe the others. The CSV is where someone will
act, the HTML is where they will look first, and the report is the only one
that carries the coverage — a CSV read without it is a list with no idea how
much of the territory it covers.
If the run is truncated, both the report and the page lead with that —
repeat it, do not paraphrase it away. If PRIVACY.md was written, the run
holds named individuals and that file says what follows from it.
Write from places.json. Every field carries where it came from. A place
with sources: ["osm","registry"] has both records attached; a place with one
source has one, and the other half is not "missing data" you may fill in.
OSM-declared emails, phones and social profiles are carried into contacts
with an osm:<id> source, so they remain distinct from contacts observed on
a fetched page and can be re-read from osm.json by check.
website.confidence is corroborated, unverified or declared (a mapper
typed it into OSM and nobody has checked) — say which when it matters.
When the run looks wrong
| Symptom |
Cause |
where exits 2 with a list |
Working as designed. Several distinct places match; choose one. |
| Very few OSM places |
Overpass mirrors were busy. doctor shows which answered; re-run. |
| Industrial sites are missing |
Re-run the scan with the current build. The industrial catalogue group includes man_made=works and every feature carrying an industrial tag. |
Contacts are empty after scan although OSM has phone tags |
They are now carried as declared contacts with an osm:<id> source. Re-run the scan with the current build. |
truncated: true on the register lane |
A French territory exceeded the API's 10 000-result ceiling even after the NACE split, or --max-results produced a per-section sample after 21 section probes. It is not a prefix and not the whole; narrow the filters. |
Register lane mode: "confirm", not "sweep" |
Expected everywhere but France, the United Kingdom and Estonia; the latter two require an ingested snapshot. OSM covered the ground; run confirm to attach register identities company by company. |
| The UK register lane returned nothing |
No snapshot in the cache. Run ingest --country gb once, then re-scan. The lane's reason says so verbatim. |
| A UK sweep missed a company you can see on the street |
Its registered office is in another post town — very often its accountant's. The sweep enumerates by post town, and the lane's reason says a post town is not a bounding box. |
merged is 0 on a GB run |
Companies House snapshot records carry no coordinates. Read the register lane's withCoordinates and reason: scored fusion could not run, so zero does not mean the names failed to match. |
My --section filter did not shrink the OSM lane |
Expected: --section narrows only the register lane. The manifest note and filters.narrowedLanes record the asymmetry; pass --category to narrow both lanes. |
A German record carries asOf: 2018-… |
Working as designed. The German export stopped in 2019 and each record says when it was retrieved. Write it as a fact about that date, never as today's. |
de-offeneregister refused a register number |
The bare number exists at more than one Amtsgericht. Supply the court from the Impressum, or leave it unattached — a number is not an identity without its court. |
confirm found identifiers but named no holders |
Expected in Germany and Spain: VIES confirms a VAT number is live and does not disclose who holds it. The identifiers are on the record, sourced and re-readable. |
confirm found nothing at all in the US |
Expected. There is no US company register and no published company number; identity there rests on address and name. |
| A merged place looks like two companies |
Adjudication was skipped or answered too generously. Check matchConfidence and the raw lanes in osm.json / registry.json. |
| Thousands of dormant one-person companies |
Add --min-employees; ceased companies are already excluded unless --include-ceased. |
| 950 rows of which 169 are condominium syndicates |
Add --exclude-legal-form 9110; the exclusion is applied before the result budget. |
| The run is full of schools, bank branches and EV chargers |
--osm-groups amenity is a whole catalogue group, not a trade. Aim it with --category amenity=cafe,…, and drop what cannot buy with resolve --skip chain,public. |
scan refused: "left the … lane sweeping unfiltered" |
Working as designed. A --category list that narrows one lane and not the other produces a run whose halves cover different territories. Name a term for the other lane, or --category-lane <the one you meant>. |
scan refused: activity codes "fall outside the sections asked for" |
The register ANDs section and activity code, so nace=I,naf=10.71C would return zero rows and read as an empty trade. Ask for one or the other. |
--max-results cut the register lane |
For an unfiltered run, the register probes all 21 NACE sections first, then spreads the budget across them. The lane is a per-section sample, not an alphabetical prefix and not the whole territory; sectionTotals and sectionReturned show the loss by section. Name the trade with --category naf=… when you need complete coverage of one activity. |
A company shows web_presence: social-only |
Not a gap. We searched, found no site of its own, and found a social profile that is theirs. For a local trader that is usually the whole web presence. |
web_presence is empty |
resolve has not run for that row. Different from none, which is a measured absence — do not report the two the same way. |
resolve exits 2 saying no results were supplied |
Working as designed. Run --queries, do the searching, pass --web-results. |
--engine-search says the keyless fallback was blocked |
The engines refused the request — a 403, or an anti-bot challenge served with a 200. Nothing was searched, which is NOT the same as nothing being there. Do your own WebSearch and pass --web-results; never report the run as a territory with no web presence. |
--engine-search corroborated almost nothing |
Expected, and the reason resolve refuses to run without your hits. Measured on a Saint-Mandé sweep: 0 sites out of 12 through the keyless fallback, 9 out of 12 from the same queries run through the agent's own WebSearch. |
A company's own domain shows as unverified |
The page did not carry its name, address or registration number. Often a JavaScript-only site — the evidence string says which. It is a candidate, not a confirmed site. |
enrich says "no place has a corroborated website" |
resolve has not run, or corroborated nothing. Enrichment only ever reads sites we proved belong to the company. |
A company with a careers page shows isHiring unset |
Deliberate. A board was detected but its openings could not be read, and "not hiring" would be a different claim. |
check says a contact is not on its page |
Believe it. Either the value was constructed, or the page changed since it was read. Both mean it must not ship. |
check flags a line you consider obvious |
It is a factual claim with no source. Cite the page, or mark it [M] and own it. |
Do not
- Never present a run with
truncated: true as a complete list of a territory.
- Never merge a
MATCH.todo.json pair you cannot justify from its evidence.
- Never write a contact detail that is not verbatim in a fetched page or an
open-data record, and never derive one from a naming pattern.
- Never describe a
confirm-mode run as if the register had been swept, and
never describe the absence of a sweepable register as a failure.
- Never present a UK sweep as covering a bounding box. It enumerates a POST TOWN,
which is a real enumeration and a different shape; the lane says so and so
should you.
- Never write a record carrying
asOf in the present tense. Germany's register
data stops in 2019, and "is registered at" where the evidence says "was
registered at, as of 2018-07" is the same class of claim as a confirmed
territory presented as a swept one.
- Never present an identifier an authority declined to attribute as an identity.
- Never strip the attributions in
manifest.licences from a deliverable, and
never add one for a connector that did not answer.
- Never re-run a sweep to "check" a number the manifest already reports — the
upstreams move, and a second run answers a different question.
- Never treat a search result as a company's website. Rank is not evidence of
ownership; only the fetched page corroborating itself is.
- Never present a run whose
check exits non-zero. There is no "with caveats".
- Never turn the
--icp text into a number. The engine refuses to; so should you.
- Never report a
--skipped row as absent from the territory. It was not
searched for, which is a decision about budget, not a finding about the world.
- Never present a half-narrowed run as one trade:
--section and --osm-groups
each reach one lane, and the run's two halves then describe different
territories. --category is the one that aims both, and it refuses rather
than let that happen silently.
- Never describe a
watch disappearance as a closure. A company drops out of a
sweep for half a dozen reasons; only the register can say a business ceased,
and DELTA.md keeps the two apart for exactly that reason.
Scope notes
- Measured, not asserted. Every count in the manifest is something the
engine observed. Where an upstream refuses to say how much exists, the engine
reports a floor and marks the lane truncated.
- Determinism is a product guarantee. The same fixture produces the same
fusion, byte for byte.
--fixture and --record exist so a pipeline can be
tested without five live services in the loop.
- Politeness is not optional. Every upstream here is public infrastructure,
three of them volunteer-run. The engine identifies itself, paces itself per
host and honours robots.txt.
Orchestration — route by harness
| You have |
Do |
| The Workflow tool |
orchestrate --run <dir>, then launch <dir>/orchestration/<phase>.workflow.mjs |
| Subagents but no Workflow tool |
orchestrate --run <dir>, dispatch each agent with the contract in <dir>/orchestration/agents/<role>.md |
| Neither |
orchestrate --run <dir> --eco and work down <dir>/orchestration/RUNBOOK.md yourself |
Three phases fan out — resolve, match and dossier. Searching for a
company's website is per-company thinking, and it is the phase the run rests on.
Enrichment is deliberately not one of them: reading those websites is I/O
against other people's servers, and parallelising it across subagents multiplies
the request rate while the per-host pacing only governs one process. Fan-out
is an optimisation for thinking, never for fetching.
Each phase names the model it wants, and it is not the same one. The fan-out
decides how many agents a phase runs; this decides which head, and the question
that settles it is what still catches the mistake afterwards:
| Phase |
Model |
Because |
resolve |
|
|
…(truncated)
1---2name: ultraprospect3description: Build a sourced, territory-wide company prospect list with registry checks, qualification, and citation-aware exports.4license: MIT5---67# ultraprospect — a territory, turned into prospects you can cite89For engine evidence and coverage limits, read [engine-evidence](references/engine-evidence.md).1011Like its `ultra*` siblings this is a **division of labour**. The engine decides12the mechanics: geocode the place, sweep what can be swept, tile around the13upstream caps, fuse what is certainly the same company, confirm the rest against14whatever authority the country has, account for what it could not reach. You decide the judgment: whether a near-miss pair is one business, what a15company actually does, which of them is worth a call.1617The engine is built to be boring and honest about its edges. Nothing it produces18is a guess dressed as a fact, and every count it reports is one it measured.1920> **The core rules:**21>22> 1. **Reason from the run, not from memory.** You know nothing about a town's23> economy that is not in `places.json`. If it is not in the run, fetch it or24> say you did not.25> 2. **A truncated run is a truncated run, and a confirmed one is not a sweep.**26> When `manifest.truncated` is true, say so in the first sentence of whatever27> you write, and name the lane. Read the register lane's `mode`, which the28> Coverage table prints as its own column, and which France, the United29> Kingdom and Estonia can answer `"sweep"` to — each by a DIFFERENT shape,30> named in the lane's `reason`. When it says `"confirm"` say so: the31> list is what OpenStreetMap holds for that territory, checked against the32> register, not what the register holds. A company nobody has mapped is not in33> it. And the UK's sweep is by POST TOWN, not by the bounding box the OSM lane34> used — the lane's `reason` says so, and repeating that is the difference35> between an enumeration and a claim about a slightly different territory.36> Presenting any of these as a whole territory is the one failure nobody37> downstream can detect.38>39> 2b. **A dated record is a fact about its date.** A record carrying `asOf` came40> out of a bulk snapshot, not from asking the register just now. Germany's41> export stopped in 2019, so a German register identity is who filed under that42> number THEN. Write it that way — "registered at … as of 2018-07" — and never43> let it found a present-tense claim. `check` enforces this; do not make it44> have to.45> 3. **Absence is a finding.** `isHiring: false` means we looked where hiring46> would be and found none; `isHiring` absent means a board exists that we47> could not read. Report the second as unknown, never as "not hiring".48> 4. **Never invent a contact.** Every email, phone number and person must49> appear verbatim in a fetched page or an open-data record. Do not50> reconstruct `prenom.nom@domaine` from a pattern — a plausible address is51> worse than none.52> 5. **Adjudicate, don't rubber-stamp.** `MATCH.todo.json` exists because the53> matcher refused to decide. Read the evidence in each pair and answer it;54> approving the file wholesale throws away the reason it was written.55> 6. **The attribution travels with the data.** OSM is ODbL: any list you hand56> over carries the notice the manifest gives you.5758## Running the engine5960An installed skill lives away from the user's project, so a cwd-relative path61will NOT resolve. Use the absolute path to this skill's directory:6263```bash64node <skill-dir>/scripts/ultraprospect.mjs <command> [options]65```6667`--help` is the full flag surface and is kept in sync with the code by a build68gate. Read it rather than guessing a flag.6970## Route the ask7172| You want to… | Run |73|---|---|74| Check a place name resolves, before spending a sweep | `where "<place>"` |75| Get a keyless register for the UK, Germany or Estonia, once | `ingest --country gb` · `de` · `ee` |76| List every company in a town, street or radius | `scan --where "<place>"` |77| Same, narrowed to ONE KIND OF BUSINESS, both lanes | `scan --where "<place>" --category amenity=cafe,naf=56.30Z` |78| Same, narrowed to an industry or a company size | `scan --where "<place>" --section J,M --min-employees 10` |79| Answer the pairs the matcher would not decide | `match --run <dir> --apply verdicts.json` |80| Find each company's website (the host's native web search) | `resolve --run <dir> --queries`, then `--web-results` |81| Read those websites — what they do, who they hire | `enrich --run <dir> --tier 1`, then `--tier 2 --limit 20` |82| Attach a register identity outside France's sweep | `confirm --run <dir>` |83| Rank what you found | `score --run <dir>` |84| Write up one company from its evidence | `dossier --run <dir> --id <id>` |85| Prove the write-up is grounded before anyone reads it | `check --run <dir>` |86| Hand it over: CSV, report, one self-contained page | `render --run <dir>` |87| Import the user's corrections or exclusions | Read [user feedback](references/feedback.md), then `feedback --run <dir> [--apply <file>]` and render again |88| See what moved since last month's sweep | `watch --run <new> --since <old>` |89| Spread the judgement across subagents | `orchestrate --run <dir>` |90| Drive it all from another harness | `mcp` — serves where, ingest, scan, places, confirm, enrich, score, dossier, check, render, watch, doctor |91| Find out why a run came back thin | `doctor` |9293## Cheat sheet9495```bash96ultraprospect doctor --country de # are the upstreams this run needs up?97ultraprospect where "Vincennes" --country fr # resolve, or list the candidates and exit 298ultraprospect scan --where "Vincennes" --country fr # both lanes, fused99ultraprospect scan --lat 48.8566 --long 2.3522 --radius 500m # a disc: the OSM lane is trimmed to it, like the register's100ultraprospect scan --where "Lyon" --section M --min-employees 20 --out ./runs101ultraprospect scan --where "Saint-Mandé" --country fr --exclude-legal-form 9110,6540,9220102ultraprospect scan --where "Vincennes" --country fr --category amenity=cafe,naf=56.30Z # ONE trade, BOTH lanes103ultraprospect scan --where "Kreuzberg, Berlin" --country de --category office=it # where no register sweeps, OSM alone104ultraprospect resolve --run <dir> --queries --skip chain,unnamed,public,vacant # spend no search on a bank branch105ultraprospect ingest --country gb # Companies House monthly snapshot, 470 MB, keyless106ultraprospect ingest --country de # the German register export, 260 MB, keyless107ultraprospect ingest --list # what is cached, which vintage, how much disk108ultraprospect ingest --check # has a register published something newer? exit 1 if so109ultraprospect scan --where "Hebden Bridge" --country gb # after ingest: the UK register IS enumerated110ultraprospect scan --where "Berlin" --country de # OSM sweeps the ground; the register comes later111ultraprospect confirm --run <dir> # Impressum -> HRB/USt-IdNr -> the authority confirms112ultraprospect scan --where "Berlin" --no-registry # skip the register lane entirely113ultraprospect match --run <dir> --apply verdicts.json # fold your adjudication back in114ultraprospect resolve --run <dir> --queries # the queries for YOU to search115ultraprospect resolve --run <dir> --web-results hits.json # ingest your hits, fetch, corroborate116ultraprospect resolve --run <dir> --only <id,id,id> # aim the lane: the ids you care about, not the first N117ultraprospect enrich --run <dir> --tier 1 # home + legal notice on every site118ultraprospect enrich --run <dir> --tier 2 --limit 20 # a page per role + the ATS APIs119ultraprospect score --run <dir> # rank by measured signals120ultraprospect dossier --run <dir> --id <id> # the grounding packet, pages and all121ultraprospect check --run <dir> # the gate. Exit 1 means do not present.122ultraprospect render --run <dir> # CSV + JSON + REPORT.md + index.html123ultraprospect render --run <dir> --min-fit possible # only the ones you judged worth it124ultraprospect watch --run <new> --since <old> # who opened, closed, started hiring125ultraprospect orchestrate --run <dir> # fan the two judgement phases out126ultraprospect mcp # serve it over MCP, stdio127ultraprospect scan --fixture <dir> # replay a recorded sweep, offline128```129130## Workflow1311321. **Resolve the place first.** `where` costs one request and tells you what the133 geocoder thinks you meant. If it exits 2 it has found several distinct places134 with comparable confidence — show the user the list and ask, or pass135 `--pick <n>`. Do not paper over it: "Vincennes" is a Paris suburb *and* a136 town in Indiana, and picking silently produces a complete, plausible,137 entirely wrong prospect file.1381392. **Scan, with filters if the territory is dense.** This matters in France,140 where the register IS swept: a French commune holds tens of thousands of141 registered units, most of them dormant micro-entrepreneurs.142 `--min-employees`, `--section` and `--activity` are how a run stays useful.143 Those filters reach the register lane only. When OSM also ran without144 `--category`, the manifest says so, records `filters.narrowedLanes:145 ["registry"]`, and names how many rows the whole-catalogue OSM sweep returned.146 `--min-employees` is translated through the sweep connector's own size-band147 vocabulary; France declares that capability. A UK or Estonian sweep refuses148 `--min-employees` and `--size-band` instead of accepting a filter it cannot149 apply.150 `--legal-form` keeps only the filed INSEE legal-form codes named;151 `--exclude-legal-form` removes codes such as 9110 (condominium syndicates) or152 9220 (associations). Includes are sent to `/search`; exclusions have no API153 negation and are always applied client-side before a row spends the154 `--max-results` budget. The register lane stops at that budget and declares155 itself partial rather than spending twenty minutes.1561572a. **`--category` is the only filter that aims BOTH lanes. Reach for it first.**158159 The older filters each reach one lane and only one: `--osm-groups` narrows160 OpenStreetMap, while `--section` / `--activity` / `--min-employees` /161 `--size-band` / `--legal-form` narrow the register. Neither knows the other162 exists, so "I want cafés" said with either of them alone half-narrows the run.163 The manifest now calls that asymmetry out; it does not make the lanes cover164 the same catalogue. Measured on a Saint-Mandé sweep:165 `--section I` gave 154 register rows and left the OSM lane returning all 295166 places in town, of which 5 were cafés — one intent, two territories.167168 `--category` takes ONE vocabulary aimed at both, and it is the vocabulary169 `places.json` already prints back at you:170171 | You type | Lane | Means |172 |---|---|---|173 | `amenity=cafe` | OSM | that exact tag |174 | `shop` or `shop=*` | OSM | the whole key |175 | `naf=56.30Z`, `sic-uk=56302`, `pkd=…` | register | an activity code, in the register's own scheme |176 | `nace=I` | register | a section letter |177178 So the round trip holds: whatever `category` a row carries, you can paste it179 straight back into `--category` on the next run. The scheme prefixes come off180 the connectors themselves, not a hardcoded list.181182 `--osm-groups` accepts the nine catalogue groups `shop`, `office`, `craft`,183 `healthcare`, `industrial`, `amenity`, `tourism`, `leisure` and `club`.184185 **It refuses rather than half-narrow.** Name only OSM tags where a register186 CAN be swept and the run stops, because the register lane would enumerate the187 whole territory beside your five cafés. Answer it by naming the other lane188 too, or by saying the asymmetry is deliberate with `--category-lane osm`.189 Where no register can be enumerated at all — Germany, Spain, most of the190 world — there is no second lane to aim and an OSM-only category just runs.191192 Two things it will not let you do, both of which used to return a confident193 nothing: mixing a section with an activity code outside it (`nace=I,naf=10.71C`194 — the register ANDs them, so the answer is zero rows), and excusing a lane195 that was not going to run anyway.196197 Measured, same town: `--category amenity=cafe,amenity=restaurant,shop=bakery,198 naf=56.10A,naf=56.30Z,naf=10.71C` returns 48 OSM + 65 register = 89 places199 with **24 matched across both lanes** — against 422 places and 27 matches200 unfiltered. Nearly all the matches, a fifth of the noise.2012022b. **Ingest first where a register publishes a file instead of an API.** The203 United Kingdom, Germany and Estonia do, and their exports are keyless.204 `ingest --country gb` fetches Companies House's monthly snapshot (470 MB) and205 turns the UK into a territory that can be ENUMERATED; `ingest --country de`206 fetches the German register export (260 MB) and gives `confirm` a source that207 names the holder of an HRB number. `ingest --country ee` indexes Estonia's208 daily export for enumeration by administrative unit. Each runs once and everything afterwards is209 a local read — `ingest --list` says what is cached and how much disk it took.210 Without the ingest, these connectors report themselves unavailable with that211 command in the message, and the run continues.2122133. **Read the coverage before reading the data.** `manifest.lanes` says what214 each lane returned, whether it was capped, and — for the register lane —215 whether the territory was `"sweep"`-ed or `"confirm"`-ed. The report prints216 `mode` as its own column. `manifest.truncated` is the headline. Three registers217 can be enumerated keylessly and they are not enumerated the same way: France218 by bounding box through its API, the United Kingdom by POST TOWN out of the219 snapshot, and Estonia by administrative unit after `ingest --country ee`.220 Everywhere else the lane says in words that no register could be221 swept, and that is a property of the world's open data, not a failure of the222 run.2232243b. **Where the register was not swept, run `confirm` after `enrich --tier 1`.**225 That order is not arbitrary: the strongest route reads the registration number226 off the legal notice tier 1 fetches — an `Impressum` in Germany, an `aviso227 legal` in Spain, both legally mandatory — and asks an authority whose it is.228 Without pages, `confirm` can only look companies up by name, which is a229 candidate rather than a fact. It refuses rather than doing the weak half230 silently.231232 In Germany this is where the two sources divide the work, and saying which233 answered matters: VIES confirms a USt-IdNr is live TODAY and returns `"---"`234 for the holder, while `de-offeneregister` names who filed under an HRB number235 in 2017-2019 and stamps `asOf` on it. Neither alone is a current identity, and236 the pair is worth more than either. A German register number without its237 Amtsgericht is ambiguous by construction — the same number exists at several238 courts — so the connector refuses a bare number that matches more than one.2392404. **Inspect exact joins, then adjudicate `MATCH.todo.json`.** Before scoring,241 the matcher joins OSM-declared register identifiers exactly: a SIRET names an242 establishment, while a SIREN must name exactly one record in this run. There243 is no distance gate, unmatched identifiers stay declared rather than244 producing a register record, and `manifest.counts.mergedByIdentifier` says245 how many fusions this signal produced.246247 Each remaining pair carries the OSM name, the register248 name that *actually scored* (`matchedName` — often an enseigne, not the legal249 name), the distance in metres and the component scores. Answer with a JSON250 array of `{osmId, registryId, connectorId, merge, why}` and fold it back with251 `match --apply`.252 Judge on evidence: same trade name, same street number, a brand the register253 files under an enseigne. When you cannot tell, say `merge: false` — two rows254 are recoverable, one wrong merge is not.2552565. **Find the websites — this is your job, and the run rests on it.** Four in257 five places arrive without one, and everything downstream grows from that258 URL. `resolve` will **refuse to run** without search results rather than259 quietly check the handful OSM already tagged: on a real Vincennes sweep that260 silence produced 11 corroborated sites out of 1164, which reads as a town261 with no web presence instead of as a search nobody ran.262263 **The keyless engines are blocked, so this loop is not optional.** Measured264 live: DuckDuckGo answers an anti-bot challenge with HTTP 202, DuckDuckGo Lite265 the same, Mojeek serves a challenge with a 200 and then a 403. All four, one266 run. `--engine-search` is a probe, not a plan — when it says **blocked**,267 nothing was searched, which is NOT the same as nothing being there.268269 The loop, mechanically:270271 ```bash272 # 1. size it, and spend nothing on rows that cannot become a prospect273 ultraprospect resolve --run <dir> --queries --skip chain,unnamed,public,vacant274275 # 2. run EVERY printed query through your own WebSearch, one at a time276277 # 3. pool every hit into ONE array — duplicates, directories, noise and all278 cat > hits.json <<'JSON'279 [280 {"placeId": "osm:n248494308", "url": "https://…", "title": "…", "snippet": "…"},281 {"placeId": "osm:n248494308", "url": "https://…", "title": "…", "snippet": "…"},282 {"placeId": "osm:n1585168700", "url": "https://…", "title": "…", "snippet": "…"}283 ]284 JSON285286 # 4. hand them back — the engine fetches each one and judges it287 ultraprospect resolve --run <dir> --web-results hits.json288 ```289290 `placeId` is the id from `RESOLVE.todo.json`; several hits share one, and291 that is expected. **Do not filter** — you are finding candidates, not292 choosing. The engine fetches each one and keeps it only if the page carries293 the company's registration number, its street address or the distinctive part294 of its name; a domain that ranked first and corroborates nothing is recorded295 as `unverified`, never as the website.296297 **Spend the searches on rows that could buy something.** `--skip` drops the298 ones that cannot, and every test reads a tag a mapper ASSERTED rather than299 guessing from a name: `chain` reads `brand`/`brand:wikidata`, `public` reads300 `operator:type` or the legal unit's filed legal form through its register's301 own rule, `vacant` reads `shop=vacant` and `disused:*`, `unnamed` is a row302 whose name is literally its own tag. Measured on the Saint-Mandé sweep:303 76 of 420 rows skipped — 42 chain, 24 unnamed, 9 public, 6 vacant, five of them304 counted twice — which is 104 searches not run on BNP Paribas branches and305 primary schools. Nothing is306 deleted: the rows keep their place in `places.json`, and `RESOLVE.todo.json`307 gains a `skipped` array naming each id and the reasons that applied — so you308 can disagree with a call and re-run without it, rather than take the total on309 trust.310311 Two registers cannot be aimed at all and the run says so rather than312 pretending: Estonia enumerates but its export carries no activity code, so a313 register term there is refused instead of accepted and dropped.314315 **A Facebook page is a finding, not a gap.** For a local trader it is very316 often the ENTIRE web presence, so `resolve` records it in `contacts.socials`317 and marks the place `webPresence: "social-only"` — its own state, beside318 `own-site` and `none`, and a column in the CSV. Measured on two Saint-Mandé319 food businesses: both came back `social-only`, which is the true answer and320 the actionable one. `none` means we searched and found nothing; an EMPTY321 `web_presence` means nobody has searched yet, and the two must not be read322 as the same thing.323324 **The queries themselves prefer evidence to hope.** Where a street is known,325 `<name> <number> <street> <town>` is spent before the legal-notice angle: a326 café is not obliged to publish mentions légales and mostly does not, while327 the door number is a fact its site, its listing and its social page all328 repeat. Where there is no street — most German OSM nodes — the Impressum329 angle is still the best one left, and still runs. `--queries-per-place`330 raises or lowers the budget; three is the default because you run each one331 by hand.332333 **Aim the lane on a big territory.** It is sequential and fetches per place,334 so a two-thousand-place sweep is a long run. `--limit` takes a PREFIX of the335 file; `--only <ids>` takes the places you actually want, which is what you336 need when the ones worth searching for are scattered through the sweep — the337 IT companies in a city of offices, the manufacturers in a mixed high street.338 Read `category` (and `registry.activityCode`, where a register answered) out339 of `places.json` to pick them. `--only` narrows `--queries` too, so a fanned340 out worklist matches the fold that follows it.341342 With many places, fan it out: `orchestrate --run <dir> --phase resolve`.3433446. **Enrich in two tiers, and spend the second one deliberately.** Tier 1 reads345 the homepage and the legal notice on every corroborated site: four requests,346 and it answers whether the site is alive, what it says it does, whether the347 company runs a hiring pipeline, and whether its registration is published there.348 Tier 2 is the expensive one — a page per role (about, services, products,349 pricing, careers, team, contact, cases, news) plus the openings read350 straight out of the ATS API rather than out of a JavaScript shell. Run it on351 the ones you have a reason to care about, not on the whole town: a thousand352 places at eight pages each is six thousand requests and several hours.3533546b. **Bring your own vocabulary — the engine has none, on purpose.**355356 `--terms` finds words VERBATIM in the pages tier 1 and 2 fetched, and357 `--roles` counts job titles matching them. Neither ships a default in any358 language, and that is the design rather than a gap. A word list frozen into a359 general tool is one person's curation wearing the costume of a measurement:360 it goes stale, it privileges whichever languages its author happened to361 speak, and for every market it misses it reports a confident absence.362363 So translating a concept into a market's own words is YOUR job, and you have364 two things the engine does not: the languages, and the web.365366 - **Translate the concept, not the German.** "Does this company buy outside367 work" is `portage salarial` and `auto-entrepreneur` in France, `ZZP` in the368 Netherlands, `autónomo` in Spain, `Freiberufler` and `Werkvertrag` in369 Germany, `libero professionista` in Italy. A literal translation of a370 German word finds nothing, because these are legal statuses, not synonyms.371 - **Check the phrasing against the live web before you scan.** Search how372 companies in that country actually word it on their careers pages, and take373 the words you SEE rather than the ones you expect. `resolve --engine-search`374 drives webindex's keyless search from inside this bundle, but prefer your375 own WebSearch: the keyless engines block automated clients, and when they376 do the lane says so in words rather than reporting an empty web.377 - **Mind the inflection.** Matching tolerates up to three trailing letters,378 which carries German `-n`/`-ern`, French `-s` and Spanish `-os`. Languages379 that inflect by REPLACING the ending — Polish, Czech — need a stem:380 `samozatrudnieni`, not `samozatrudnienie`.381 - **Refuse a term that means two things.** `B2B` means contractor in a Polish382 job ad and business-to-business in every English one. A term with two383 readings produces hits nobody can act on, and they will look measured.384385 The trap is worst where the two readings live on the SAME page. Measured on386 a Hamburg run: of three companies whose careers page matched this lexicon,387 two carried `Freelancer` heading a real freelancer intake — "Wie deine388 Zusammenarbeit mit uns abläuft", "Freelancer? Bitte hier entlang!" — and the389 third matched `selbstständig` three times in "Freiraum für selbstständiges390 und eigenverantwortliches Arbeiten", which describes an EMPLOYEE working391 autonomously. Same page role, same lexicon, opposite meanings. German392 `selbstständig`/`selbständig`, French `autonome` and English `independent`393 all read both ways in a job ad; the words that only mean the legal status —394 `Freiberufler`, `freiberuflich`, `Werkvertrag`, `Subunternehmer`,395 `portage salarial`, `ZZP` — are the ones worth counting.396397 **`--terms-on` defaults to the careers page, and widening it is a decision.**398 Measured on a real run before that default existed: 48 hits from home and399 legal pages, and every sampled one was wrong — `externe Dienstleister` naming400 data processors in a privacy policy, `Freiberufler` naming the CLIENTS a law401 firm advises, `Freelancer` on a one-person studio's homepage describing its402 owner. The words were right and the page was wrong, which is the worst shape403 of wrong here because it reads as evidence.404405 Whatever you pass is recorded in the signals beside the hits, so a count406 always says what it counted, and `check` re-reads every hit against the page407 it cites.4084097. **Rank, then judge.** `score` adds a measured total from things it counted —410 site alive, recently touched, roles open, headcount band, revenue filed,411 contactable. It does NOT score whether a company matches the brief, however412 the `--icp` text is phrased. That is yours: read the packets, then fold413 verdicts back with `score --apply '[{"id":"…","fit":"strong","why":"…"}]'`.414 `fit` sits beside `total`; it never overwrites it, so the one column nobody415 had to be trusted for stays intact.4164178. **Write each dossier from its packet.** `dossier --id <id>` prints the fact418 sheet and the FULL TEXT of every page fetched for that company, each under419 the id you must cite. End each factual sentence with `[P3]`; mark your own420 inference `[M]`. Do not cite a page fetched for a different company — the421 gate checks ownership, not just existence.4224239. **Run `check` before anyone reads the output.** Exit 1 means stop. It424 re-opens every citation, demands a source or an `[M]` on every factual line,425 and re-reads every email, phone and person against the page it claims to426 come from. Contacts declared in OSM carry the exact feature as `osm:<id>`;427 the gate re-reads that feature from the run's `osm.json` and looks only at428 its contact tags. That last rule is the one that matters: an address429 assembled from a naming convention is plausible, unfalsifiable at a glance,430 and will be emailed. The gate makes it impossible rather than discouraged.43143210. **Render, and hand over all four — they are not the same deliverable.**433 `render` writes one run in four shapes, and which one you put in front of434 someone decides whether they can act on it:435436 | File | What it is for | Hand it to |437 |---|---|---|438 | `index.html` | **Looking.** Self-contained, makes NO network request, opens from a file:// path with nothing installed. Sort, filter and facet in place to find the twenty rows that matter out of eight hundred — then open any row: the verdict verbatim, the score broken into the terms that produced it, every contact with the page id it was read from, the open roles, the register identity and its `sourceUrl`, the site signals. With JavaScript off, every panel is open. | anyone who has to SEE the territory |439 | `REPORT.md` | **Reading and sharing.** Coverage with each lane's `mode`, which connector answered and what attached each record, what the run was narrowed to, the counts, the score distribution, who is hiring, every `fit` verdict verbatim, the ranked table, and run notes deduplicated with their counts. Pastes into an issue, a PR, a wiki, a mail. | anyone who has to judge whether the run is sound |440 | `PROSPECTS.csv` | **Working.** Flat and CRM-shaped: `score` and `fit` in separate columns so the measured number survives your judgement, each contact's source page beside it, `registry_as_of` on anything from a snapshot, and `role_filter` / `term_lexicon` beside their counts so a number says what it counted. | a CRM, a spreadsheet, a mail-merge |441 | `prospects.json` | **Piping.** The same rows, unflattened, for whatever comes next. | another program |442443 Do not hand over one and describe the others. The CSV is where someone will444 act, the HTML is where they will look first, and the report is the only one445 that carries the coverage — a CSV read without it is a list with no idea how446 much of the territory it covers.447448 If the run is truncated, both the report and the page lead with that —449 repeat it, do not paraphrase it away. If `PRIVACY.md` was written, the run450 holds named individuals and that file says what follows from it.45145211. **Write from `places.json`.** Every field carries where it came from. A place453 with `sources: ["osm","registry"]` has both records attached; a place with one454 source has one, and the other half is not "missing data" you may fill in.455 OSM-declared emails, phones and social profiles are carried into `contacts`456 with an `osm:<id>` source, so they remain distinct from contacts observed on457 a fetched page and can be re-read from `osm.json` by `check`.458 `website.confidence` is `corroborated`, `unverified` or `declared` (a mapper459 typed it into OSM and nobody has checked) — say which when it matters.460461## When the run looks wrong462463| Symptom | Cause |464|---|---|465| `where` exits 2 with a list | Working as designed. Several distinct places match; choose one. |466| Very few OSM places | Overpass mirrors were busy. `doctor` shows which answered; re-run. |467| Industrial sites are missing | Re-run the scan with the current build. The `industrial` catalogue group includes `man_made=works` and every feature carrying an `industrial` tag. |468| Contacts are empty after `scan` although OSM has phone tags | They are now carried as declared contacts with an `osm:<id>` source. Re-run the scan with the current build. |469| `truncated: true` on the register lane | A French territory exceeded the API's 10 000-result ceiling even after the NACE split, or `--max-results` produced a per-section sample after 21 section probes. It is not a prefix and not the whole; narrow the filters. |470| Register lane `mode: "confirm"`, not `"sweep"` | Expected everywhere but France, the United Kingdom and Estonia; the latter two require an ingested snapshot. OSM covered the ground; run `confirm` to attach register identities company by company. |471| The UK register lane returned nothing | No snapshot in the cache. Run `ingest --country gb` once, then re-scan. The lane's reason says so verbatim. |472| A UK sweep missed a company you can see on the street | Its registered office is in another post town — very often its accountant's. The sweep enumerates by post town, and the lane's reason says a post town is not a bounding box. |473| `merged` is 0 on a GB run | Companies House snapshot records carry no coordinates. Read the register lane's `withCoordinates` and `reason`: scored fusion could not run, so zero does not mean the names failed to match. |474| My `--section` filter did not shrink the OSM lane | Expected: `--section` narrows only the register lane. The manifest note and `filters.narrowedLanes` record the asymmetry; pass `--category` to narrow both lanes. |475| A German record carries `asOf: 2018-…` | Working as designed. The German export stopped in 2019 and each record says when it was retrieved. Write it as a fact about that date, never as today's. |476| `de-offeneregister` refused a register number | The bare number exists at more than one Amtsgericht. Supply the court from the Impressum, or leave it unattached — a number is not an identity without its court. |477| `confirm` found identifiers but named no holders | Expected in Germany and Spain: VIES confirms a VAT number is live and does not disclose who holds it. The identifiers are on the record, sourced and re-readable. |478| `confirm` found nothing at all in the US | Expected. There is no US company register and no published company number; identity there rests on address and name. |479| A merged place looks like two companies | Adjudication was skipped or answered too generously. Check `matchConfidence` and the raw lanes in `osm.json` / `registry.json`. |480| Thousands of dormant one-person companies | Add `--min-employees`; ceased companies are already excluded unless `--include-ceased`. |481| 950 rows of which 169 are condominium syndicates | Add `--exclude-legal-form 9110`; the exclusion is applied before the result budget. |482| The run is full of schools, bank branches and EV chargers | `--osm-groups amenity` is a whole catalogue group, not a trade. Aim it with `--category amenity=cafe,…`, and drop what cannot buy with `resolve --skip chain,public`. |483| `scan` refused: "left the … lane sweeping unfiltered" | Working as designed. A `--category` list that narrows one lane and not the other produces a run whose halves cover different territories. Name a term for the other lane, or `--category-lane <the one you meant>`. |484| `scan` refused: activity codes "fall outside the sections asked for" | The register ANDs section and activity code, so `nace=I,naf=10.71C` would return zero rows and read as an empty trade. Ask for one or the other. |485| `--max-results` cut the register lane | For an unfiltered run, the register probes all 21 NACE sections first, then spreads the budget across them. The lane is a per-section sample, not an alphabetical prefix and not the whole territory; `sectionTotals` and `sectionReturned` show the loss by section. Name the trade with `--category naf=…` when you need complete coverage of one activity. |486| A company shows `web_presence: social-only` | Not a gap. We searched, found no site of its own, and found a social profile that is theirs. For a local trader that is usually the whole web presence. |487| `web_presence` is empty | `resolve` has not run for that row. Different from `none`, which is a measured absence — do not report the two the same way. |488| `resolve` exits 2 saying no results were supplied | Working as designed. Run `--queries`, do the searching, pass `--web-results`. |489| `--engine-search` says the keyless fallback was **blocked** | The engines refused the request — a 403, or an anti-bot challenge served with a 200. Nothing was searched, which is NOT the same as nothing being there. Do your own WebSearch and pass `--web-results`; never report the run as a territory with no web presence. |490| `--engine-search` corroborated almost nothing | Expected, and the reason `resolve` refuses to run without your hits. Measured on a Saint-Mandé sweep: 0 sites out of 12 through the keyless fallback, 9 out of 12 from the same queries run through the agent's own WebSearch. |491| A company's own domain shows as `unverified` | The page did not carry its name, address or registration number. Often a JavaScript-only site — the evidence string says which. It is a candidate, not a confirmed site. |492| `enrich` says "no place has a corroborated website" | `resolve` has not run, or corroborated nothing. Enrichment only ever reads sites we proved belong to the company. |493| A company with a careers page shows `isHiring` unset | Deliberate. A board was detected but its openings could not be read, and "not hiring" would be a different claim. |494| `check` says a contact is not on its page | Believe it. Either the value was constructed, or the page changed since it was read. Both mean it must not ship. |495| `check` flags a line you consider obvious | It is a factual claim with no source. Cite the page, or mark it `[M]` and own it. |496497## Do not498499- Never present a run with `truncated: true` as a complete list of a territory.500- Never merge a `MATCH.todo.json` pair you cannot justify from its evidence.501- Never write a contact detail that is not verbatim in a fetched page or an502 open-data record, and never derive one from a naming pattern.503- Never describe a `confirm`-mode run as if the register had been swept, and504 never describe the absence of a sweepable register as a failure.505- Never present a UK sweep as covering a bounding box. It enumerates a POST TOWN,506 which is a real enumeration and a different shape; the lane says so and so507 should you.508- Never write a record carrying `asOf` in the present tense. Germany's register509 data stops in 2019, and "is registered at" where the evidence says "was510 registered at, as of 2018-07" is the same class of claim as a confirmed511 territory presented as a swept one.512- Never present an identifier an authority declined to attribute as an identity.513- Never strip the attributions in `manifest.licences` from a deliverable, and514 never add one for a connector that did not answer.515- Never re-run a sweep to "check" a number the manifest already reports — the516 upstreams move, and a second run answers a different question.517- Never treat a search result as a company's website. Rank is not evidence of518 ownership; only the fetched page corroborating itself is.519- Never present a run whose `check` exits non-zero. There is no "with caveats".520- Never turn the `--icp` text into a number. The engine refuses to; so should you.521- Never report a `--skip`ped row as absent from the territory. It was not522 searched for, which is a decision about budget, not a finding about the world.523- Never present a half-narrowed run as one trade: `--section` and `--osm-groups`524 each reach one lane, and the run's two halves then describe different525 territories. `--category` is the one that aims both, and it refuses rather526 than let that happen silently.527- Never describe a `watch` disappearance as a closure. A company drops out of a528 sweep for half a dozen reasons; only the register can say a business ceased,529 and `DELTA.md` keeps the two apart for exactly that reason.530531## Scope notes532533- **Measured, not asserted.** Every count in the manifest is something the534 engine observed. Where an upstream refuses to say how much exists, the engine535 reports a floor and marks the lane truncated.536- **Determinism is a product guarantee.** The same fixture produces the same537 fusion, byte for byte. `--fixture` and `--record` exist so a pipeline can be538 tested without five live services in the loop.539- **Politeness is not optional.** Every upstream here is public infrastructure,540 three of them volunteer-run. The engine identifies itself, paces itself per541 host and honours robots.txt.542543## Orchestration — route by harness544545| You have | Do |546|---|---|547| The Workflow tool | `orchestrate --run <dir>`, then launch `<dir>/orchestration/<phase>.workflow.mjs` |548| Subagents but no Workflow tool | `orchestrate --run <dir>`, dispatch each agent with the contract in `<dir>/orchestration/agents/<role>.md` |549| Neither | `orchestrate --run <dir> --eco` and work down `<dir>/orchestration/RUNBOOK.md` yourself |550551Three phases fan out — `resolve`, `match` and `dossier`. Searching for a552company's website is per-company thinking, and it is the phase the run rests on.553Enrichment is deliberately not one of them: reading those websites is I/O554against other people's servers, and parallelising it across subagents multiplies555the request rate while the per-host pacing only governs one process. **Fan-out556is an optimisation for thinking, never for fetching.**557558**Each phase names the model it wants, and it is not the same one.** The fan-out559decides how many agents a phase runs; this decides which head, and the question560that settles it is what still catches the mistake afterwards:561562| Phase | Model | Because |563|---|---|---|564| `resolve`565566…(truncated)