Cannabis Freedom Index

Changelog 61aa785tree, with Claude Opus 5.5

Guide for expanding one country, with a gap script, for every agent

- docs/EXPAND-COUNTRY.md: the order of work for taking one country and filling in its current law and penalty tables, history, articles, context sections, culture and links, with the rules that are easy to miss (others work in the repo at the same time, no dev servers, one build at a time, add found history, whole-country sections, commit only your own files). - scripts/country-gaps.cjs <id>: a country's work list (missing sections and statuses, penalty tables, uncited texts, Wikipedia-only texts, empty history decades, entries without an article, unlinked atlas entries). - AGENTS.md points any agent to the guide; a Claude Code skill (/expand-country) does the same. .gitignore now keeps local .claude settings private but commits shared skills.

6 files of code, design or docs +247 −1 lines

Code, design and documents 6 files

.claude/skills/expand-country/SKILL.md +14 −0
@@ -0,0 +1,14 @@
+---
+name: expand-country
+description: Expand one country in the Cannabis Freedom Index - find missing history entries, write articles, fill missing context sections, penalty tables and links. Use when asked to expand, deepen, fill in or research a specific country (e.g. "expand Kenya", "/expand-country ke").
+---
+
+Follow `docs/EXPAND-COUNTRY.md` exactly. It is the shared guide for every agent working on this
+repository; this skill only points to it.
+
+1. Read `docs/EXPAND-COUNTRY.md`, then the parts of `docs/DATA-MAINTENANCE.md` and `docs/WRITING.md`
+   it names.
+2. Run `node scripts/country-gaps.cjs <id>` and `node scripts/article-backlog.cjs --country <id>` for
+   the country in the arguments (a profile file name such as `ke`, `de` or `us/ca`).
+3. Work through the guide's steps in order: current law and penalty tables, history, articles,
+   context sections, culture, sources, then build and commit only your own files.

.gitignore +3 −1
@@ -2,5 +2,7 @@ node_modules/
 dist/
 .astro/
-.claude/
+# local Claude Code settings stay private; shared skills are committed
+.claude/*
+!.claude/skills/
 .cursor/
 .env

AGENTS.md +13 −0
@@ -0,0 +1,13 @@
+# Notes for agents
+
+This repository is the Cannabis Freedom Index: country profiles (`countries/`), a history of
+cannabis law (`changes.yaml`, `articles/`) and a cultural atlas (`culture/`), built with Astro.
+
+- **To expand or research a country**, follow `docs/EXPAND-COUNTRY.md`. Start with
+  `node scripts/country-gaps.cjs <id>`.
+- Data rules: `docs/DATA-MAINTENANCE.md`. How texts read: `docs/WRITING.md`. Atlas entries:
+  `docs/CULTURE.md`.
+- Others work here at the same time: check `git status` first, don't touch files with someone
+  else's uncommitted changes, and commit only your own files.
+- Don't start dev or preview servers; check with `npm run build` (one build at a time) and the
+  HTML in `dist/`.

docs/DATA-MAINTENANCE.md +3 −0
@@ -935,4 +935,7 @@ node scripts/data-status.cjs chain de     # one country's full timeline
 ## Workflow that has worked
 
+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.
+
 1. **Pick countries.** `node scripts/data-status.cjs thin` lists places with two or fewer
    entries; `unchecked` and `estimates` list what still needs a source. Go by population, or by

docs/EXPAND-COUNTRY.md +125 −0
@@ -0,0 +1,125 @@
+# Expanding one country
+
+How to take one country and fill in what is missing: its history, articles, context sections,
+penalty tables and links. Written for any agent or person working on the data. The detailed rules
+live in the other guides; this file is the order of work and the rules that are easy to miss.
+
+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
+atlas entry.
+
+## 0. Before you start
+
+- **Other people and agents work in this repository at the same time.** Run `git status` and
+  `git log --oneline -5` first. Don't edit files that someone else has uncommitted changes in, and
+  never revert or "fix" their half-finished work unless you were asked to.
+- **Don't start dev or preview servers.** Check your work with `npm run build` and by reading the
+  built HTML in `dist/`.
+- **One build at a time.** If another `astro build` is running (`pgrep -f "astro build"`), wait
+  for it to finish; two builds at once delete each other's files and fail with "Cannot find
+  module … dist/.prerender".
+
+## 1. See what is missing
+
+```bash
+node scripts/country-gaps.cjs ke            # the country's work list
+node scripts/article-backlog.cjs --country ke   # which history entries most deserve an article
+```
+
+`country-gaps` lists missing context sections and statuses, penalty tables, texts without inline
+citations, texts resting only on Wikipedia, the history by decade with empty decades, unchecked
+entries, entries without an article, and atlas entries the profile does not link. Work through it
+in the order below.
+
+## 2. Current law first
+
+The present legal state matters most. Before history or culture, make sure the profile is right
+today:
+
+- Read the current statute (national law portal, official gazette) and any recent amendment,
+  court ruling or official guidance. Search news from the last two years for changes.
+- Check the six segment texts and scores against what you find. If the law or practice has
+  changed, **change the score too**, calibrated against comparable countries (look at a peer with
+  a similar law and its scores), and add the change as a history entry with `before`.
+- Add the **penalty tables** for possession and cultivation from the statute
+  (DATA-MAINTENANCE.md, "Penalty steps"): amounts, treatment, the law's term, minimum and maximum
+  prison, caning or death where they apply, and the fine.
+
+## 3. History
+
+- Fill the empty decades and the time since the latest entry: first bans, new drug laws,
+  amendments, court rulings, notable cases, raids, eradication, reports, debates, treaties.
+- **Add every dated event you find**, without asking first, if a source supports it. It belongs in
+  `changes.yaml` even if you found it while researching something else.
+- An event that changed what an adult may do moves the score (`before`); everything else is a
+  context entry with no `before`. The score line over time must make sense (DATA-MAINTENANCE.md,
+  "The score timeline must make sense").
+- Titles and descriptions follow WRITING.md: what changed for people, in plain words, two to four
+  sentences, no lists of law numbers.
+- Mark `checked: true` only for entries whose source you opened and read.
+
+## 4. Articles
+
+- Pick from `article-backlog.cjs --country <id>`: score-moving entries and the current law first.
+- 350–500 words, three to five short sections, at most one small table (WRITING.md, "Articles").
+- Cite per sentence with `[@n]`. Link the earlier and later history entries and the country page.
+- Don't add a photo unless it shows the subject itself; no photos of buildings or logos of
+  institutions.
+
+## 5. Context sections
+
+Write what is missing, in the profile YAML under `context` (DATA-MAINTENANCE.md, "Writing context
+sections well"):
+
+- **Required:** `recreational`, `medical`, `hemp`, `production`, `consequences`. Fold any old
+  `products` section into them and delete it.
+- **When researched:** `clubs`, `planting`, `visitors`, `driving`, `black_market`, `arrests`.
+- **Where there is something real to say:** `culture` (connect every atlas entry of the country),
+  `research` (science, not policy counting), `genetics` (real local plants only), `spotlights`
+  (one to three things the scores do not show).
+- Set the statuses where the text states the fact: `hemp`, `production`, `research`, `clubs`,
+  `driving`, `visitors`.
+
+How every section reads:
+
+- Start with what a person meets; three to seven sentences; the **whole country**, not one city
+  from one source.
+- Every figure with its year and what it counts. Proposals are labelled as proposals.
+- Cite every sentence (`[@key]`); when you revise an old text without marks, convert it.
+- **Link generously** with Markdown: dated events to their history entries
+  (`[legal since 1 July 2024](/history/germany-cultivation-associations-2024/)`), people and
+  traditions to Culture, named organisations to their official website. A link is not a source.
+- Don't explain words for cannabis or common jargon by hand: the term layer does it. Add new
+  jargon to `glossary.yaml`, or write a Culture/Language entry for a local word for cannabis.
+
+## 6. Culture and the atlas
+
+- If you meet a tradition, a person or a local word for cannabis worth an entry, write one
+  (CULTURE.md): 500–900 words, with images where an open-licence image of the subject exists.
+- New history about an atlas subject goes into that entry's text with a link; the build checks it.
+
+## 7. Sources
+
+- Prefer primary sources: the statute, the gazette, the court, the regulator, the statistics
+  office, the official evaluation. Good newspapers for practice and recent events.
+- Wikipedia is a list of leads, not a source to rest on; keep it only where nothing else states
+  the claim.
+- UK government travel advice (GOV.UK, FCDO) only for the United Kingdom and closed countries;
+  replace it elsewhere when you find a better source.
+- Quote titles that contain a colon followed by a space in YAML: `title: "ABDA — Die Apotheke: …"`.
+
+## 8. Check and finish
+
+```bash
+npm run build          # must pass; it checks schemas, citations and internal links
+npm run seo:audit      # what the deploy runs after the build
+node scripts/country-gaps.cjs ke   # the list should be shorter
+```
+
+- Look at the built page in `dist/country/<id>/index.html` for the sections, tables and links.
+- Set the profile's `updated:` to today.
+- **Commit only your own files**, with a message that says what changed for the country. Leave
+  other people's uncommitted changes out; if a shared file holds their changes too, stage only
+  your lines.
+- Note in the commit message, or in a short `docs/research/` note for a large pass, what you could
+  not verify and what is left.

scripts/country-gaps.cjs +89 −0
@@ -0,0 +1,89 @@
+// What is still missing for one country, as a work list for expanding it (.claude/skills/expand-country).
+//
+//   node scripts/country-gaps.cjs ke          Kenya
+//   node scripts/country-gaps.cjs us/ca       a state or province file
+//
+// 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");
+
+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();
+
+// ── 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$/, "")));
+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 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 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`);
+}
+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)}`);
+  }
+}
+list("atlas entries for this country", culture);
+if (ctx.culture) list("atlas entries the culture section does not link", culture.filter((c) => !ctx.culture.text.includes(`/culture/${c}/`)));
+else if (culture.length) out("  ! atlas entries exist but there is no culture section to connect them");

    ↑↓ to move, ↵ to open.