Changelog 02b85d3tree, with Claude Opus 5.5
Guide for expanding several countries, with a ranking of what most needs work
- docs/EXPAND-COUNTRIES.md: choosing a batch, taking it country by country with a commit per country, treating the shared files (changes.yaml, glossary, atlas), splitting work between several agents with one coordinator, a progress note per batch, and when to stop or skip. - scripts/country-gaps.cjs --rank [N] [--region R]: every country's gaps weighed by population, with a one-line summary each, marking countries busy where someone has uncommitted changes. The single-country work list is unchanged. - A Claude Code skill (/expand-countries) and pointers in AGENTS.md, EXPAND-COUNTRY.md and DATA-MAINTENANCE.md lead to the guide.
6 files of code, design or docs +285 −75 lines
Code, design and documents 6 files
.claude/skills/expand-countries/SKILL.md +17 −0
@@ -0,0 +1,17 @@ +--- +name: expand-countries +description: Expand several countries of the Cannabis Freedom Index in one batch - pick the countries that most need work (or a given region or list) and take each through the expand-country workflow, committing country by country. Use when asked to expand, deepen or research multiple countries, a region, or "the next batch" (e.g. "expand five African countries", "/expand-countries Africa 5"). +--- + +Follow `docs/EXPAND-COUNTRIES.md`, and for each country `docs/EXPAND-COUNTRY.md`. They are the shared +guides for every agent working on this repository; this skill only points to them. + +1. Read both guides. +2. Choose the batch: `node scripts/country-gaps.cjs --rank 25` (add `--region <Region>` when a region + is given), or use the countries named in the arguments. Skip any marked BUSY. +3. Take the countries one at a time through `docs/EXPAND-COUNTRY.md`, building and committing each + country (only your own files) before the next, and keep the batch's progress note in + `docs/research/expansion-<date>.md`. +4. If you use subagents, follow section 4 of `docs/EXPAND-COUNTRIES.md`: each worker owns different + countries and returns its history entries as a `history-batch.cjs` file; only you edit + `changes.yaml`, build and commit.
AGENTS.md +2 −0
@@ -6,4 +6,6 @@ cannabis law (`changes.yaml`, `articles/`) and a cultural atlas (`culture/`), bu - **To expand or research a country**, follow `docs/EXPAND-COUNTRY.md`. Start with `node scripts/country-gaps.cjs <id>`. +- **To work through several countries**, follow `docs/EXPAND-COUNTRIES.md`. Start with + `node scripts/country-gaps.cjs --rank 25` (optionally `--region Africa`). - Data rules: `docs/DATA-MAINTENANCE.md`. How texts read: `docs/WRITING.md`. Atlas entries: `docs/CULTURE.md`.
docs/DATA-MAINTENANCE.md +3 −1
@@ -936,5 +936,7 @@ node scripts/data-status.cjs chain de # one country's full timeline To expand one country end to end (history, articles, context sections, penalty tables, links), -follow `docs/EXPAND-COUNTRY.md`; `node scripts/country-gaps.cjs <id>` prints its work list. +follow `docs/EXPAND-COUNTRY.md`; `node scripts/country-gaps.cjs <id>` prints its work list. For a +batch of countries, follow `docs/EXPAND-COUNTRIES.md`; `node scripts/country-gaps.cjs --rank` ranks +them by how much is missing. 1. **Pick countries.** `node scripts/data-status.cjs thin` lists places with two or fewer
docs/EXPAND-COUNTRIES.md +104 −0
@@ -0,0 +1,104 @@ +# Expanding several countries + +How to work through a batch of countries, one after another or with several agents at once. Each +country is done exactly as `docs/EXPAND-COUNTRY.md` describes; this file covers choosing the batch, +the order, sharing the work and keeping it safe while others work in the same repository. + +## 1. Choose the batch + +```bash +node scripts/country-gaps.cjs --rank 25 # the countries that most need work +node scripts/country-gaps.cjs --rank 25 --region Africa # or within one region +``` + +The ranking weighs each country's gaps (missing sections, penalty tables, empty history decades, +Wikipedia-only texts, key entries without an article) by population. Take the batch from it, from +a region, or from a list you were given. + +- **Five to ten countries per batch.** A batch is finished country by country; a half-done batch + of thirty helps nobody. +- **Skip countries marked BUSY**: someone has uncommitted changes in their files right now. Run + the ranking again before each new country, since others move on too. +- Neighbours and countries with a shared history (a colonial power and its colonies, one region's + treaties) go well together: sources found for one often cover the next. + +## 2. Work country by country + +For each country in the batch: + +1. `git status` and `git log --oneline -5`: is anyone working on this country now? If so, skip it. +2. `node scripts/country-gaps.cjs <id>`: the country's work list. +3. Work through `docs/EXPAND-COUNTRY.md` in its order: current law and penalty tables, history, + articles, context sections, culture, links. +4. Build (`npm run build`; wait if another build is running), then read the built page in + `dist/country/<id>/`. +5. **Commit that country before starting the next**, with only your own files, and a message that + names the country and what changed ("Morocco: penalty tables, hemp and industry sections, 4 + history entries, 1980s Rif eradication article"). +6. Add a line to the batch's progress note (below). + +Finishing and committing each country means the work survives if the batch is interrupted, and +others see at once which countries are done. + +## 3. Shared files + +Most work touches only the country's own files (`countries/<id>.yaml`, its articles, its atlas +entries). A few files are shared by every country, and two agents editing them at once is where +work gets lost: + +| Shared file | How to treat it | +|---|---| +| `changes.yaml` | Add entries with `node scripts/history-batch.cjs batch.json` rather than editing by hand, and commit soon after. Never stage someone else's entries. | +| `glossary.yaml` | Add words at the end of the right group; commit with the country. | +| `culture/*` atlas entries for several countries | Edit only the lines you need; check `git status` first. | +| `docs/DATA-MAINTENANCE.md`, `docs/WRITING.md` | Only when a rule really changes; not for progress notes. | + +If a shared file already holds someone else's uncommitted changes, stage only your own lines +(`git add -p`, or write the file with only your changes into the index), and say so in your report. + +## 4. Several agents at once + +Parallel work is fine if each agent owns its countries and one agent coordinates: + +- **The coordinator** picks the batch, gives each worker a different set of countries, and is the + only one who edits `changes.yaml` and runs builds. +- **Each worker** edits only its own countries' files (profiles, articles, atlas entries for those + countries) and returns its new or corrected history entries as a `history-batch.cjs` JSON file + instead of editing `changes.yaml`, plus a short list of what it found and could not verify. +- The coordinator applies each worker's history file, builds once, checks the pages, and commits + each country separately. +- Never let two workers have the same country, and never let a worker revert or reformat a file it + does not own. + +## 5. Progress note + +Keep one note per batch in `docs/research/expansion-<date>.md`: + +```markdown +# Expansion batch, 28 October 2026 + +Chosen from `country-gaps.cjs --rank 25 --region Africa`. + +| Country | Done | Left | Commit | +|---|---|---|---| +| Morocco | penalty tables, hemp, industry, 4 history entries, 1 article | 1950s still thin; no source for 2019 arrest figures | abc1234 | +| Senegal | skipped: busy (another agent) | | | +``` + +Update it after each country and commit it with that country. It tells the next agent where to pick up. + +## 6. When to stop or skip + +- **The build fails because of someone else's unfinished work**: don't fix or revert their files. + Finish your country's files, check them in the built page if the build gets far enough, note the + blocker, and move on. If a committed file is broken and you were asked to keep the build working, + make the smallest fix and say what you changed. +- **A country has almost no reachable sources**: write what the sources support, note the gaps in + the progress note, and move on; never fill gaps with guesses or generic text. +- **Quality before count.** Three countries done properly beat ten with thin sections. + +## 7. Report + +At the end, report per country: what was added (sections, tables, entries, articles, atlas +entries), what was corrected (scores, statuses, facts), what could not be verified, and what is +left; plus the commits and anything that blocked you.
docs/EXPAND-COUNTRY.md +2 −0
@@ -5,4 +5,6 @@ penalty tables and links. Written for any agent or person working on the data. T live in the other guides; this file is the order of work and the rules that are easy to miss. +For a batch of several countries, or several agents at once, see `docs/EXPAND-COUNTRIES.md`. + Read first, once: `docs/DATA-MAINTENANCE.md` (data, sources, history entries, context sections, penalty steps), `docs/WRITING.md` (how every text reads), and `docs/CULTURE.md` if you write an
scripts/country-gaps.cjs +157 −74
@@ -1,89 +1,172 @@ -// What is still missing for one country, as a work list for expanding it (.claude/skills/expand-country). +// What is still missing for a country, as a work list for expanding it (docs/EXPAND-COUNTRY.md), +// or a ranking of all countries by how much is missing (docs/EXPAND-COUNTRIES.md). // -// node scripts/country-gaps.cjs ke Kenya -// node scripts/country-gaps.cjs us/ca a state or province file +// node scripts/country-gaps.cjs ke one country's work list +// node scripts/country-gaps.cjs us/ca a state or province file +// node scripts/country-gaps.cjs --rank the 25 countries that most need work +// node scripts/country-gaps.cjs --rank 50 --region Africa // -// Reads the files directly; run from the project root. Lists the profile's missing or thin context -// sections and statuses, penalty tables, texts without inline citations, sources that are only -// Wikipedia, the history by decade with its gaps, entries without a check or an article, and the -// atlas entries that belong to the country. -const fs = require("fs"), path = require("path"), y = require("js-yaml"); +// Reads the files directly; run from the project root. The work list names the profile's missing or +// thin context sections and statuses, penalty tables, texts without inline citations, texts resting +// only on Wikipedia, the history by decade with its gaps, entries without a check or an article, and +// the atlas entries that belong to the country. The ranking weighs the same gaps by population and +// marks countries whose files someone has uncommitted changes in ("busy"), which should be skipped. +const fs = require("fs"), path = require("path"), y = require("js-yaml"), { execFileSync } = require("child_process"); -const id = process.argv[2]; -if (!id) { console.error("usage: node scripts/country-gaps.cjs <country file name, e.g. ke or us/ca>"); process.exit(1); } -const file = fs.existsSync(`countries/${id}.yaml`) ? `countries/${id}.yaml` : `countries/${id}/index.yaml`; -if (!fs.existsSync(file)) { console.error(`no profile at countries/${id}.yaml or countries/${id}/index.yaml`); process.exit(1); } -const p = y.load(fs.readFileSync(file, "utf8")); -const slug = id.split("/")[0]; -const ctx = p.context ?? {}; -const out = (s = "") => console.log(s); -const list = (label, items) => out(` ${label}: ${items.length ? items.join(", ") : "none"}`); - -out(`${p.name} (${file}), reviewed ${String(p.updated ?? "?").slice(0, 10)}, recreational ${p.recreational}, medical ${p.medical}`); -out(); - -// ── the profile ── -out("PROFILE"); -// a state or province file holds its scores and segment texts only; the context is the country's -const unit = id.includes("/") && !file.endsWith("index.yaml"); -if (unit) out(` a state or province file: context sections live in countries/${slug}/index.yaml`); const REQUIRED = ["recreational", "medical", "hemp", "production", "consequences"]; const RESEARCHED = ["clubs", "planting", "visitors", "driving", "black_market", "arrests"]; const OPTIONAL = ["culture", "research", "spotlights", "genetics"]; -if (!unit) list("missing standard sections", REQUIRED.filter((k) => !ctx[k])); -if (!unit) list("missing researched sections", RESEARCHED.filter((k) => !ctx[k])); -if (!unit) list("optional sections not written", OPTIONAL.filter((k) => !ctx[k])); -if (p.recreational === "death" && !ctx.death_penalty) out(" ! recreational is \"death\" but there is no death_penalty section"); -if (!unit && ctx.products) out(" products still present: fold it into recreational, medical and hemp, then delete it"); -list("statuses not set", ["hemp", "production", "research"].filter((k) => ctx[k] && !ctx[k].status).map((k) => `${k}.status`)); -if (!unit) list("penalty tables missing", ["possession", "cultivation"].filter((k) => p.segments?.[k] && !p.segments[k].penalties)); -const blocks = [...Object.entries(p.segments ?? {}).map(([k, b]) => [`segments.${k}`, b]), - ...Object.entries(ctx).flatMap(([k, b]) => (Array.isArray(b) ? b.map((x, i) => [`context.${k}[${i}]`, x]) : [[`context.${k}`, b]]))]; -list("texts without inline citations", blocks.filter(([, b]) => b?.text && !/\[@/.test(b.text)).map(([k]) => k)); -list("texts with no source at all", blocks.filter(([, b]) => b?.text && !(b.sources ?? []).length).map(([k]) => k)); -const wiki = (key) => /wikipedia/i.test(p.sources?.[key]?.title ?? "") || /wikipedia\.org/.test(p.sources?.[key]?.url ?? ""); -list("texts resting only on Wikipedia", blocks.filter(([, b]) => (b?.sources ?? []).length && b.sources.every(wiki)).map(([k]) => k)); -const linked = blocks.filter(([, b]) => /\]\(\//.test(b?.text ?? "")).length; -out(` internal links in texts: ${linked} of ${blocks.length} texts link to history or culture`); -out(); +const fileOf = (id) => (fs.existsSync(`countries/${id}.yaml`) ? `countries/${id}.yaml` : `countries/${id}/index.yaml`); -// ── the history ── -out("HISTORY"); -const changes = (y.load(fs.readFileSync("changes.yaml", "utf8")) ?? []).filter((e) => e.country === slug); -const articles = new Set(fs.readdirSync("articles").map((f) => f.replace(/\.md$/, ""))); +// loaded once: the history, the articles, the atlas entries by country, the populations +const CHANGES = y.load(fs.readFileSync("changes.yaml", "utf8")) ?? []; +const ARTICLES = new Set(fs.readdirSync("articles").map((f) => f.replace(/\.md$/, ""))); +const ATLAS = {}; +for (const section of ["language", "traditions", "people"]) { + for (const f of fs.readdirSync(path.join("culture", section)).filter((x) => x.endsWith(".md"))) { + const fm = y.load(fs.readFileSync(path.join("culture", section, f), "utf8").match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "") ?? {}; + for (const c of fm.countries ?? []) (ATLAS[c] ??= []).push(`${section}/${f.slice(0, -3)}`); + } +} +const POP = {}; +for (const line of fs.readFileSync("datasets/population.csv", "utf8").trim().split("\n").slice(1)) { + const [c, yr, n] = line.split(","); + if (!POP[c] || +yr > POP[c][0]) POP[c] = [+yr, +n]; +} const year = (e) => parseInt(String(e.date), 10); -const dated = changes.filter((e) => year(e)).sort((a, b) => year(a) - year(b)); -out(` ${changes.length} entries, ${changes.filter((e) => e.before !== undefined && e.before !== null).length} that move the score, ${changes.filter((e) => articles.has(e.id)).length} with an article`); -if (dated.length) { - const first = year(dated[0]), last = year(dated.at(-1)); - out(` from ${first} to ${last}`); +const THIS_YEAR = new Date().getFullYear(); + +// Everything missing for one profile file +function gapsOf(id) { + const file = fileOf(id); + const p = y.load(fs.readFileSync(file, "utf8")); + const slug = id.split("/")[0], ctx = p.context ?? {}; + // a state or province file holds its scores and segment texts only; the context is the country's + const unit = id.includes("/") && !file.endsWith("index.yaml"); + const blocks = [...Object.entries(p.segments ?? {}).map(([k, b]) => [`segments.${k}`, b]), + ...Object.entries(ctx).flatMap(([k, b]) => (Array.isArray(b) ? b.map((x, i) => [`context.${k}[${i}]`, x]) : [[`context.${k}`, b]]))]; + const wiki = (key) => /wikipedia/i.test(p.sources?.[key]?.title ?? "") || /wikipedia\.org/.test(p.sources?.[key]?.url ?? ""); + const changes = CHANGES.filter((e) => e.country === slug && (!unit || e.subunit === id.split("/")[1] || !e.subunit)); + const dated = changes.filter((e) => year(e)).sort((a, b) => year(a) - year(b)); const decades = {}; for (const e of dated) decades[Math.floor(year(e) / 10) * 10] = (decades[Math.floor(year(e) / 10) * 10] ?? 0) + 1; - const row = []; - for (let d = Math.min(1900, Math.floor(first / 10) * 10); d <= 2020; d += 10) row.push(`${d}s ${decades[d] ?? "—"}`); - out(` by decade: ${row.join(" · ")}`); + const first = dated.length ? year(dated[0]) : null, last = dated.length ? year(dated.at(-1)) : null; const empty = []; - for (let d = Math.max(1920, Math.floor(first / 10) * 10); d <= 2020; d += 10) if (!decades[d]) empty.push(`${d}s`); - list("decades with nothing (look for laws, cases, raids, reports)", empty); - const thisYear = new Date().getFullYear(); - if (last < thisYear - 1) out(` ! nothing since ${last}: check recent news and law changes`); + if (first) for (let d = Math.max(1920, Math.floor(first / 10) * 10); d <= 2020; d += 10) if (!decades[d]) empty.push(`${d}s`); + const atlas = ATLAS[slug] ?? []; + const scored = (e) => e.before !== undefined && e.before !== null; + return { + id, file, unit, name: p.name, region: p.region, updated: String(p.updated ?? "?").slice(0, 10), rec: p.recreational, med: p.medical, + missingRequired: unit ? [] : REQUIRED.filter((k) => !ctx[k]), + missingResearched: unit ? [] : RESEARCHED.filter((k) => !ctx[k]), + missingOptional: unit ? [] : OPTIONAL.filter((k) => !ctx[k]), + deathWithoutSection: p.recreational === "death" && !ctx.death_penalty, + products: !unit && Boolean(ctx.products), + statuses: ["hemp", "production", "research"].filter((k) => ctx[k] && !ctx[k].status).map((k) => `${k}.status`), + penalties: unit ? [] : ["possession", "cultivation"].filter((k) => p.segments?.[k] && !p.segments[k].penalties), + uncited: blocks.filter(([, b]) => b?.text && !/\[@/.test(b.text)).map(([k]) => k), + unsourced: blocks.filter(([, b]) => b?.text && !(b.sources ?? []).length).map(([k]) => k), + wikiOnly: blocks.filter(([, b]) => (b?.sources ?? []).length && b.sources.every(wiki)).map(([k]) => k), + linked: blocks.filter(([, b]) => /\]\(\//.test(b?.text ?? "")).length, texts: blocks.length, + entries: changes.length, scoredEntries: changes.filter(scored).length, withArticle: changes.filter((e) => ARTICLES.has(e.id)).length, + first, last, decades, empty, stale: last !== null && last < THIS_YEAR - 1, + unchecked: changes.filter((e) => !e.checked && !e.estimate).map((e) => e.id), + estimates: changes.filter((e) => e.estimate).map((e) => e.id), + noArticle: changes.filter((e) => !ARTICLES.has(e.id) && scored(e)).map((e) => e.id), + atlas, unlinkedAtlas: ctx.culture ? atlas.filter((c) => !ctx.culture.text.includes(`/culture/${c}/`)) : [], + atlasWithoutCulture: !ctx.culture && atlas.length > 0, + }; } -list("not checked against a source", changes.filter((e) => !e.checked && !e.estimate).map((e) => e.id)); -list("estimates (no source yet)", changes.filter((e) => e.estimate).map((e) => e.id)); -const noArticle = changes.filter((e) => !articles.has(e.id) && e.before !== undefined && e.before !== null).map((e) => e.id); -list("score-moving entries without an article", noArticle); -out(" more article candidates, ranked: node scripts/article-backlog.cjs --country " + slug); -out(); -// ── culture ── -out("CULTURE"); -const culture = []; -for (const section of ["language", "traditions", "people"]) { - for (const f of fs.readdirSync(path.join("culture", section)).filter((x) => x.endsWith(".md"))) { - const fm = y.load(fs.readFileSync(path.join("culture", section, f), "utf8").match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "") ?? {}; - if ((fm.countries ?? []).includes(slug)) culture.push(`${section}/${f.slice(0, -3)}`); +// How much work a country still needs, before weighing by population +const workOf = (g) => g.missingRequired.length * 4 + g.missingResearched.length * 1.5 + (g.products ? 2 : 0) + g.statuses.length + + g.penalties.length * 2 + g.uncited.length * 0.3 + g.unsourced.length * 2 + g.wikiOnly.length + (g.deathWithoutSection ? 4 : 0) + + g.empty.length * 1.5 + (g.stale ? 3 : 0) + (g.entries < 5 ? 5 - g.entries : 0) + g.unchecked.length + g.estimates.length * 2 + + g.noArticle.length * 1.5 + (g.atlasWithoutCulture ? 2 : 0) + g.unlinkedAtlas.length * 0.5; + +// files someone has uncommitted changes in: those countries are being worked on now +function busy() { … 88 more lines