Detecting Riparian and Inland Floodplain Transitions — track land cover change in riparian and floodplain areas using free satellite imagery.
Repository: NewGraphEnvironment/drift Primary Language: R pkgdown site: https://www.newgraphenvironment.com/drift/
dft_prefix for all exported functions- Two pipelines: categorical (classified land cover → transitions) and continuous (Sentinel-2 spectral-index trajectories → abrupt breaks + gradual trends). The continuous path complements the categorical one — it can QA which mapped transitions carry a real spectral change, and catch gradual degradation/recovery the annual labels miss.
- Generic STAC pipeline — works with any classified raster (IO LULC, ESA WorldCover, custom COGs)
R/— package functions,tests/testthat/— testthat 3e tests,vignettes/— worked examplesinst/lulc_classes/— shipped CSV class tables (code, class_name, color, description)inst/indices/— spectral-index registry CSV (ndvi, kndvi, ndmi) read bydft_index_table()inst/extdata/— small test rasters (Neexdzii Kwa reach, 204KB total)inst/notes/— durable technical reference (see Reference docs below)data-raw/— scripts to regenerate test data (flooded + gdalcubes) and vignette artifacts
Categorical land-cover change:
rasters <- dft_stac_fetch(aoi, source = "io-lulc", years = c(2017, 2020, 2023))
classified <- dft_rast_classify(rasters, source = "io-lulc")
summary <- dft_rast_summarize(classified, unit = "ha")
dft_map_interactive(classified, aoi = aoi)Continuous index-trajectory change (v0.3.0; see the trajectory-break-detection vignette):
cube <- dft_stac_cube(aoi, source = "sentinel-2-l2a", index = "kndvi",
datetime = "2017-01-01/2023-12-31", months = 6:9) # returns a terra stack
breaks <- dft_rast_break(cube, start = c(2022, 1)) # abrupt change, dated (BFAST bfastmonitor)
trend <- dft_rast_trend(cube) # gradual change (Theil-Sen slope + Mann-Kendall)- Dual-mode maps:
dft_map_interactive()usesaddRasterImage()for local SpatRasters,addTiles()via titiler for remote COGs - titiler URL from option:
getOption("drift.titiler_url")— keeps infrastructure private - class_table fallback: All functions accept
class_tabletibble or fall back todft_class_table(source) - List or single: Core functions accept a single SpatRaster or a named list — names become layer labels / year column
- gdalcubes for STAC: Server-side crop, not full tile download. Orders of magnitude faster than /vsicurl/
- flooded — generates floodplain AOI polygons (upstream of drift)
- gq — cartographic style registry (planned bridge for leaflet translator)
devtools::document() # after roxygen changes
devtools::test() # ~300 tests; network/bfast tests skip by default (opt-in via env var)
devtools::install() # needed before rendering vignettespatch_area_min, not min_patch_area.
Why: Groups related parameters by prefix (patch_area_min, patch_area_max, patch_count) so they autocomplete together and read as a family.
How to apply: When adding a parameter that could have siblings, lead with the noun/concept, then the modifier — same spirit as the NGE noun_verb-detail convention.
Tag PR bodies with Relates to NewGraphEnvironment/sred-2025-2026#16 (the issue ref is year-stamped — update it each SRED year).
inst/notes/gdalcubes-pc-gotchas.md— non-obvious gdalcubes 0.7.3 + Planetary Computer Sentinel-2 gotchas (filter_geom segfault, reduce_time worker closures, terra↔gdalcubes NetCDF round-trip, the S2 +1000 DN offset boundary at 2022-01-25, PC pagination). Read before touching the continuous pipeline.
Use the gq package for all shared layer symbology. Never hardcode hex color values when a registry style exists.
library(gq)
reg <- gq_reg_main() # load once per script — 51+ layersCore pattern: reg$layers$lake, reg$layers$road, reg$layers$bec_zone, etc.
| Target | Simple layer | Classified layer |
|---|---|---|
| tmap | gq_tmap_style(layer) → do.call(tm_polygons, ...) |
gq_tmap_classes(layer) → field, values, labels |
| mapgl | gq_mapgl_style(layer) → paint properties |
gq_mapgl_classes(layer) → match expression |
For project-specific layers not in the main registry, use a hand-curated CSV and merge:
reg <- gq_reg_merge(gq_reg_main(), gq_reg_read_csv("path/to/custom.csv"))Install: pak::pak("NewGraphEnvironment/gq")
| Output | Tool | When |
|---|---|---|
| PDF / print figures | tmap v4 |
Bookdown PDF, static reports |
| Interactive HTML | mapgl (MapLibre GL) |
Bookdown gitbook, memos, web pages |
| QGIS project | Native QML | Field work, Mergin Maps |
sf_use_s2(FALSE)at top of every mapping script- Compute area BEFORE simplify in SQL
- No map title — title belongs in the report caption
- Legend over least-important terrain — swap legend and logo sides when it reduces AOI occlusion. No fixed convention for which side.
- Four-corner rule — legend, logo, scale bar, keymap each get their own corner. Never stack two in the same quadrant.
- Bbox must match canvas aspect ratio — compute the ratio from geographic extents and page dimensions. Mismatch causes white space bands.
- Consistent element-to-frame spacing — all inset elements should have visually equal margins from the frame edge
- Map fills to frame — basemap extends edge-to-edge, no dead bands. Use near-zero
inner.marginsandouter.margins. - Suppress auto-legends — build manual ones from registry values
- ALL CAPS labels appear larger — use title case for legend labels (gq
gq_tmap_classes()handles this automatically viato_title()fallback)
Read the PNG and check before showing anyone:
- Correct polygon/study area shown? (verify source data, not just the bbox)
- Map fills the page? (no white/black bands)
- Keymap inside frame with spacing from edge?
- No element overlap? (each in its own corner)
- Legend over least-important terrain?
- Consistent spacing across all elements?
- Scale bar breaks appropriate for extent?
See the cartography skill for full reference: basemap blending, BC spatial data queries, label hierarchy, mapgl gotchas, and worked examples.
Use drift and flooded together for riparian land cover change analysis. flooded delineates floodplain extents from DEMs and stream networks; drift tracks what's changing inside them over time.
Pipeline:
# 1. Delineate floodplain AOI (flooded)
valleys <- flooded::fl_valley_confine(dem, streams)
# 2. Fetch, classify, summarize (drift)
rasters <- drift::dft_stac_fetch(aoi, source = "io-lulc", years = c(2017, 2020, 2023))
classified <- drift::dft_rast_classify(rasters, source = "io-lulc")
summary <- drift::dft_rast_summarize(classified, unit = "ha")
# 3. Interactive map with layer toggle
drift::dft_map_interactive(classified, aoi = aoi)- Class colors come from drift's shipped class tables (IO LULC, ESA WorldCover)
- For production COGs on S3,
dft_map_interactive()serves tiles via titiler — setoptions(drift.titiler_url = "...") - See the drift vignette for a worked example (Neexdzii Kwa floodplain, 2017-2023)
When this repo has GitHub Actions workflows, scan recent runs on session start. Catches failed pkgdown deploys, broken vignette builds, and stale citation regenerations that would otherwise linger until the user manually checks.
gh run list --limit 5 --json status,conclusion,name,createdAt,databaseId \
--jq '.[] | select(.conclusion == "failure")'If any failures since the last visit, surface to the user before starting other work:
Workflow
<name>failed<time>ago (run<id>). Investigate withgh run view <id> --log-failed. Fix or proceed with current task?
User decides; do not auto-fix.
- pkgdown — docs site on GitHub Pages broken
- R-CMD-check — package may not install
- Vignette / build-vignettes — vignette docs incomplete
- update-citation-cff — CITATION.cff stale
Without this scan, post-merge workflow failures linger until someone (often the user) notices a stale docs site or a missing vignette. The session-start sweep catches them on the first re-entry into the repo.
The skill watches workflows triggered by a fresh merge in real time — that's the targeted catch. This convention is the backstop for failures that landed when no one was watching (merges via web UI, scheduled triggers, manually-triggered workflows).
Structured checklist for reviewing diffs before commit. Used by /code-check.
Add new checks here when a bug class is discovered — they compound over time.
- Variables in double-quoted strings containing single quotes break if value has
' "echo '${VAR}'"— if VAR contains', shell syntax breaks- Use
printf '%s\n' "$VAR" | commandto pipe values safely - Heredocs: unquoted
<<EOFexpands variables locally,<<'EOF'does not — know which you need - Pass-through-ssh args:
printf '%q'escapes per-arg so workload paths with spaces / quotes / metacharacters survive the local-shell → ssh-argv → remote-shell round-trip. Without it,ssh host 'cmd' "$path"joins args with spaces on remote and re-parses, losing argument boundaries. git commit -m "$(cat <<'EOF' ... EOF)"chokes on apostrophes in prose bodies in some contexts — the bash parser surfaces an unmatched-quote error even though heredoc bodies should be quote-neutral. Resilient default for multi-line commit messages: write the body to/tmp/msg.txtand usegit commit -F /tmp/msg.txt.
cmd1 | cmd2 <<EOF— the heredoc binds tocmd2(the rightmost simple command). If you intendedcmd1to receive it, put<<EOFon cmd1 explicitly:cmd1 <<EOF | cmd2.- Symptom when wrong: ssh body silently echoed by tee/cat/etc, ssh side gets empty stdin, exits 0 (or near-0) without doing anything. Caught the hard way 2026-05-01 in cypher_restore-fwapg.sh.
set -eudoes NOT propagate exit codes through pipelines.ssh ... | tee logreturns tee's exit (always 0 for healthy tee), masking ssh failure.- Use
set -euo pipefailfor any script that pipes a meaningful command into tee/cat/grep/etc. Or check${PIPESTATUS[0]}explicitly. - Symptom when wrong: task notifications report "exit 0 / completed" while remote work was actually skipped or errored.
- Hardcoded absolute paths (
/Users/airvine/...) break for other users - Use
REPO_ROOT="$(cd "$(dirname "$0")/<relative>" && pwd)" - After moving scripts, verify
../depth still resolves correctly - Usage comments should match actual script location
|| truehides real errors — is the failure actually safe to ignore?- Empty variable before destructive operation (rm, destroy) — add guard:
[ -n "$VAR" ] || exit 1 grepreturning empty silently — downstream commands get empty input
- BSD/macOS
mktemp -d -t <name>requires the template to contain at least 3Xs (XXXXXXis the safe default). Without them, mktemp errors to stderr (too few X's in template) and prints nothing to stdout. - Pattern:
SCRATCH=$(mktemp -d -t aider-smoke) && cd "$SCRATCH" && <destructive>. When mktemp fails,$SCRATCH="".cd ""is a no-op that leaves you in the caller's cwd. The destructive command (rm,git init,git add+commit) then runs in cwd instead of a throwaway tmpdir. - Caught the hard way 2026-05-13: a Claude smoke test inside the rtj checkout did exactly this, accidentally committed a
demo.Rto the active feature branch, which then rode the squash-merge into rtj/main and had to be cleaned up post-merge. - Fix patterns:
- Always use
XXXXXX(6 X's) in the template:mktemp -d -t aider-smoke.XXXXXX. - Guard the result:
SCRATCH=$(mktemp -d ...) || exit 1; [ -n "$SCRATCH" ] || exit 1. - Use
set -euo pipefailso the failed command-substitution kills the script.
- Always use
- macOS ships BSD
sed/grep. Linux CI/cloud-init hosts ship GNU. Snippets that work on one silently misbehave on the other. \+and\|are GNU BRE extensions. On BSD they're treated as literal+and|, so the regex still "matches" but matches nothing useful — leaving raw input unchanged.- Symptom seen 2026-05-28:
sed 's/[^a-z0-9]\+/-/g'on macOS left spaces in an issue-title slug, producing an invalid git branch name. - Fix: use
sed -E(POSIX ERE) so+,|,?,(...)all work without escapes on both flavors. The same regex becomessed -E 's/[^a-z0-9]+/-/g'.
- Symptom seen 2026-05-28:
s|pat|repl|delimiter conflicts with|in alternation/replacement on BSD. Pick a delimiter that does not appear in pattern or replacement (#,,,:are common choices). Compounds|x|y|; s|^| /||chains where the trailing||looks like an empty delimiter break on BSD sed even when GNU accepts them.- Don't parse
ls. BSDlsemits ANSI colour codes when stdout is a TTY or whenCLICOLOR_FORCEis set in env (often by shell rc files), and the codes leak through pipes. Downstreamgrep/sedchokes on the embedded escapes ([01;31m...[0m).- Use
find <dir> -maxdepth 1 -mindepth 1 -type d -exec basename {} \;for directory listings, orprintf '%s\n' <dir>/*/for a glob, orfor d in <dir>/*/; do basename "$d"; done.
- Use
- When writing a snippet you expect to ship in a
skills/SKILL.md or any cloud-init runcmd: it must be POSIX-portable. Default tosed -E, avoid\+/\|, and don't pipels.
gh pr createresolves branch from CWD, not--repo. Specifying--repo NewGraphEnvironment/Xdoes NOT switch branch resolution — the command still reads the current working directory's checked-out branch. To open a PR in repo X,cdinto X's checkout first, or pass--head <branch>explicitly.gh issue createwith heredoc bodies fails on prose containing special shell characters (apostrophes, dollar signs, backticks). Use--body-file /tmp/issue.mdinstead — every project'snewgraph.mdconvention specifies this; codified here for the underlying class.
- Secrets passed as command-line args are visible in
ps aux - Use env files, stdin pipes, or temp files with
chmod 600instead
- Must be pure ASCII — em dashes, curly quotes, arrows cause silent parse failure
- Check with:
perl -ne 'print "$.: $_" if /[^\x00-\x7F]/' file.yaml
- Any runcmd item containing both
{and:is at risk of being parsed as a YAML flow-mapping (dict), not a literal string. Cloud-init's shellify hits a non-string and throws TypeError, aborting all subsequent runcmd steps silently whilefinal_messagestill fires. - Don't write:
- test -s /file || { echo "FATAL: ..." }— the:inside braces makes YAML see a dict. - Do write: use
- |block scalar with explicitif/then/fi:- | if [ ! -s /file ]; then echo "FATAL: ..." >&2 exit 1 fi
- Validate post-edit:
python3 -c "import yaml; runcmd=yaml.safe_load(open('cloud-init.yaml').read().split(chr(10),1)[1])['runcmd']; print([type(x).__name__ for x in runcmd if not isinstance(x,str)] or 'all strings')". If the output is anything other thanall strings, the runcmd will fail.
cloud-init cleancauses full re-provisioning on next boot — almost never what you want before snapshot- Use
tailscale logoutnottailscale downbefore snapshot (deregister vs disconnect) - Wipe
/var/lib/tailscale/*before snapshot too —tailscale logoutdeauthorizes server-side but local node identity blob persists in tailscaled.state. Snapshot restored elsewhere inherits prior key material untiltailscale upruns again. - Wipe
/etc/ssh/ssh_host_*before snapshot — otherwise droplets spawned from the same image share host identity.
- Secrets rendered via
templatefile()are readable at169.254.169.254metadata endpoint - Acceptable for ephemeral machines, document the tradeoff
- Heredocs in runcmd that write secrets:
<<'EOF'(quoted) prevents bash from re-expanding$Xsequences in already-substituted credential strings. AWS keys rarely contain$but base64-padded secrets might.
apt-key adv --keyserveris deprecated on Ubuntu 24.04 noble — silently fails AND APT ignores resulting keyring. Usegpg --dearmor+signed-by=keyring file pattern.- Repo .list files in
write_files:trigger the implicitpackage_updateBEFORE runcmd installs the keyring → first apt-get update fails with NO_PUBKEY. Put the repo line in runcmd alongside the key install, not in write_files.
- DO injects
ssh_key_idsonly into/root/.ssh/authorized_keys(cloud-init'scc_sshmodule). Cloud-initusers:block withssh_authorized_keys: []does NOT pick those up. - Non-root users that need SSH access must copy from root's keys in runcmd:
- mkdir -p /home/<user>/.ssh - cp /root/.ssh/authorized_keys /home/<user>/.ssh/authorized_keys - chown -R <user>:<user> /home/<user>/.ssh
- Guard with
test -s /root/.ssh/authorized_keysto fail loudly ifcc_sshhasn't run before runcmd (rare race).
- Parsing
tofu state showtext output is fragile — usetofu outputinstead - Missing outputs that scripts need — add them to main.tf
- Snapshot/image IDs in tfvars after deleting the snapshot — stale reference
- Validate resource IDs before destroy:
[ -n "$ID" ] || exit 1 tofu destroywithout-targetdestroys everything including reserved IPs- Snapshot ID extraction by name: use
awk -v n="$NAME" '$2 == n {print $1}'(exact match on column 2).grep -F "$NAME"is substring-match and can grab a stale snapshot whose name contains the new name as a substring.
- The AWS provider (5.x and some 6.x) has a known class of bug where a transient read error (false 404, regional-endpoint hiccup) is interpreted as "resource deleted outside of OpenTofu." The plan will show the resource and any children scheduled for destroy + recreate (
forces replacementcascades through children that interpolate the parent's id/arn). - If you didn't delete the resource and the plan says it's gone, verify against the cloud API before applying:
aws s3 head-bucket --bucket X,aws iam get-role --role-name X, etc. Atofu plan -refresh=truere-run a moment later often reports "No changes." - Caught 2026-05-14 in rtj env/prod for stac-era5-land: bucket fully intact (60 objects, 307 MB) but plan said deleted with 5 child resources "must be replaced." Apply would have clobbered the policy + lifecycle configs against the still-existing bucket. Recovery via
-targeton the unrelated resource being added (rtj#157 then codifieslifecycle { prevent_destroy = true }on the bucket + load-bearing children). - Belt-and-suspenders defense: add
lifecycle { prevent_destroy = true }to high-value resources (S3 buckets, RDS instances, anything irreplaceable) in their module. Tofu will refuse to plan a destroy until the lifecycle line itself is removed in config — converts the failure mode from "apply silently clobbers" into "plan errors withInstance cannot be destroyed." Don't apply it to count-based resources wherecount: 1 → 0is a legitimate transition.
- DO snapshots include the source droplet's disk size. New droplets from a snapshot must have disk >= snapshot disk. Resize up is fine; resize down below the snapshot disk is impossible without rebuilding.
- Build the snapshot at the smallest droplet size you'd ever want to spin from it. Sizes vs disks at writing:
g-4vcpu-16gb= 50 GB,g-8vcpu-32gb/m-4vcpu-32gb= 100 GB,m-8vcpu-64gb= 200 GB. - If your workload requires X GB RAM minimum, your snapshot floor is whatever droplet has X GB AND the smallest disk class.
- Targeted destroy (
tofu destroy -target=module.droplet -target=...assignment...) preserves the reserved IP at $4/mo. Fulltofu destroyreleases it (next apply gets a NEW IP).
- DO returns 422 "Droplet already has a pending event" when reserved IP assignment fires immediately after droplet+firewall creation. The droplet's internal event queue takes time to drain.
- Every DO droplet module that uses a reserved IP MUST have:
time_sleepresource between droplet creation and IP assignment, withcreate_duration ≥ 60s(10s and 30s have both been observed to race; 60s has more headroom)depends_on = [time_sleep.<name>]on thedigitalocean_reserved_ip_assignmentresource- A retry fallback in the wrapping shell script (
up.shstyle) that detects the 422 in tofu output and usesdoctl compute reserved-ip-action assign <ip> <droplet-id>to recover. Tofu doesn't retry; it leaves state half-applied (assignment recorded but DO didn't actually attach).
- Snapshot-based spins are MORE prone to the race than first-boot from blank Ubuntu (more startup events compete for the droplet's event queue).
- Audit existing modules:
grep -L 'time_sleep' env/do/*/<host>/main.tffinds modules missing the gate. As of 2026-05-02, openclaw and geoserv have notime_sleep— they will race eventually.
imresamu/postgis(and similar postgis images) on first cold start (empty data volume) take 5-12 min to install all extensions — varies with disk IO and noisy-neighbor lottery on cloud hosts. Health-wait scripts must allow 15 min minimum, ideally with hard-fail + log dump on timeout.
- fresh's
docker/docker-compose.ymldefaults are tuned for a 128 GB host (shared_buffers=32GB,shm_size=36gb). On smaller hosts, postgres OOMs at startup with "could not map anonymous shared memory". - 32 GB host floor: use the M1/cypher 32 GB-host preset (
scripts/fwapg/compose.override.m1.yml) which setsshared_buffers=8GB, shm_size=12gb. - Below 32 GB: postgres can technically start with smaller
shared_buffersbut fwapg work becomes painful. Don't run fwapg pipelines on <32 GB hosts.
ALTER DATABASE <db> SET search_path TO ...is a database-level setting stored in the postgres data dir. Wiped withdocker compose down -v. Must be re-applied on every restore.- Codify in your restore script, not in cloud-init or compose env (those don't apply to db-level settings).
- Killing the client (R, Python, psql) closes its connection. The libpq backend on the server keeps running the in-flight query until it finishes — server-side orphan. The orphaned backend holds whatever locks it had (table, view, advisory). Every later
DROP VIEW/LOCK TABLE/ALTERon the same object blocks behind it indefinitely — silent hangs indistinguishable from a slow query. - Caught 2026-05-25 in link#205: a
pkill'dwsg_run_one.Rleft afrs_network_featuresSELECT running 1h45m; subsequent recomputes wedged onDROP VIEW barriers_bt_accessfor 1h08m before someone noticed. - Always terminate the server-side backend, not just the client:
Then kill the client. Order matters when you don't know which side will block.
SELECT pid, pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='<db>' AND state='active' AND now()-query_start > interval '3 minutes' AND pid <> pg_backend_pid();
- Any long-running DB op from an R/Python/etc. client should set both at session start, ideally via env (
PGOPTIONS='-c statement_timeout=600000 -c lock_timeout=60000') or on the connection itself (DBI::dbExecute(conn, "SET statement_timeout = '600000'")). A runaway query then cancels server-side (no orphan); a blockedDROP VIEWgives up rather than wedging behind a zombie lock. Without it, silent hangs become indistinguishable from "still working" and you wait hours. - Pick a generous-but-bounded timeout (10× expected query time). The point isn't tight enforcement — it's "fail loud instead of fail silent."
JOIN b ON some_function(a.cols, b.cols)— Postgres can only use the underlying indexes ifsome_functionisLANGUAGE sql(inlineable).plpgsqlfunctions are opaque and force per-row evaluation → seq scan / nested loop without indexes. Verify with\df+ <function>(look atLanguage) andEXPLAIN(look for the function body expanded into Filter / Index Cond).- Caught in link#205 with
whse_basemapping.fwa_downstream— it ISLANGUAGE sql+ the planner did inline it; the symptom was elsewhere (see below). But if a function-based join is slow and the function is plpgsql, that's the first thing to look at.
id_segmentin link's persist schema is unique within a WSG, not globally (link#203).WHERE id_segment IN (SELECT id_segment FROM streams WHERE wsg=aoi)against persist matches access rows from every WSG sharing those id_segment values → N(WSGs)× duplicates → PK violations downstream and 50× memory.- Fix: filter by the full tenant key (
watershed_group_code = aoi) when the table has it. Pattern: introspect viainformation_schema.columnsat runtime and branch — the same function can serve a working schema (single tenant, no WSG col) and persist (multi-tenant, with WSG col).
- A
CREATE VIEW v AS SELECT * FROM big_table WHERE …carries no row-count statistics. Used as a join input, the planner may pick the other side (big) as the outer driver, blowing nested-loop cost ~1000× — the symptom looks like "the indexes aren't being used" but it's actually a wrong-direction nested loop. - Caught in link#205: AOI-scoping streams via a
VIEWleft Postgres thinking the 26k FINA segments were as big as the 800k persist barriers; it picked barriers as outer; 71M estimated result rows; >10 min wall. - Fix when AOI-scoping into a smaller dataset: materialise as a real
CREATE TABLEwith indexes +ANALYZE. The planner then sees the small row count and picks it as outer. Drop the table onon.exitif it's transient.
- A "DELETE WHERE wsg='X'; INSERT …" pair into a persist table from an orchestration script: if the INSERT fails (e.g. duplicate key from a subtle JOIN bug), the DELETE already ran → data loss for that WSG. Wrap in a single transaction (
BEGIN; … ; COMMIT) when the persist table is the only source of truth, so a failed INSERT rolls back the DELETE. (link#205 lost FINA'sstreams_mapping_codeto this; the surrounding cheap-recompute orchestration inwsg_recompute_one.Rshould wrap both statements in a tx.)
- Tailscale SSH ACL
"users": ["autogroup:nonroot"]fortag:computeblocksssh root@<node>over the tailnet. Usessh <user>@<node>+ sudo for root operations. - For SSH-as-root from off-tailnet (regular OpenSSH on the public IP), the ACL doesn't apply — but you need the SSH key registered on the node.
- Cypher-style ephemeral compute droplets need both flags on the auth key: Reusable (same key works across destroy/recreate) + Ephemeral (tailnet entries auto-clean when offline >5 min).
- Tag the key (e.g.
tag:compute) at creation time. Nodes joining with that key inherit the tag automatically — no--advertise-tagsneeded attailscale uptime.
.tfvarsmust be gitignored (contains tokens, passwords).tfvars.exampleshould have all variables with empty/placeholder values- Sensitive variables need
sensitive = truein variables.tf
0.0.0.0/0for SSH is world-open — document if intentional- If access is gated by Tailscale, say so explicitly
- Passwords with special chars (
',",$,!) break naive shell quoting printf '%q'escapes values for shell safety- Temp files for secrets: create with
chmod 600, delete after use
Configuration patterns and false-positive handling for the gitleaks pre-commit hook (kdot's Brewfile ships gitleaks + pre-commit; cyclops standardizes the hook):
.gitleaks.tomlschema in v8.30+: top-level table is[[allowlists]](PLURAL, array of tables). Each entry MUST include at least one ofcommits/paths/regexes/stopwords. The singular[allowlist]andfingerprints = [...]forms shown in older docs fail to validate. Usepaths+regexestogether for targeted file-and-content allowlists. Example insoul/.gitleaks.toml.- PEM marker regex spans multi-line: gitleaks's
private-keyrule is(?i)-----BEGIN...PRIVATE KEY-----[\s\S]*-----END...-----. It matches across comment prefixes, blank lines, and code-fence boundaries. Commenting out the markers does NOT neutralize the match. Only fix in content is to omit the literal-----BEGIN/END...-----strings entirely and replace with prose ("Paste your private key here, preserving headers" etc.). See thertjcyphertfvars.exampleprecedent. curl-auth-headerrule false-positives on non-auth headers: matches any-H "X: Y"shape, not just credential-bearing headers. Trips on docs with custom CORS or app-specific headers (e.g.Zotero-Allowed-Request: true). Fix: targeted[[allowlists]]withpaths+regexes. Don't path-allowlist the whole file unless content is entirely safe.pre-commit installlegacy-hook handling: runningpre-commit installon a repo with an existing.git/hooks/pre-commitrenames it to.legacyand keeps invoking it after framework hooks. No breakage, but means hook surface is split between.pre-commit-config.yamland.git/hooks/pre-commit.legacy. For full visibility, migrate the legacy check into.pre-commit-config.yamlas alocalhook so the whole hook surface is declared in one place.- AWS canonical example keys are allowlisted by default (
AKIAIOSFODNN7EXAMPLEetc.) — don't use those in test fixtures expecting a block. Useghp_-shape PAT lookalikes or other non-allowlisted patterns for hook-trigger tests.
- pak stops on first unresolvable package — all subsequent packages are skipped
- Removed CRAN packages (like
leaflet.extras) must move to GitHub source - PPPM binaries may lag a few hours behind new CRAN releases
- Branch pins (
pkg@branch) are not reproducible — document why used - Pinned download URLs (RStudio .deb) go stale — document where to update
- Avoid
names,length,data,c,t,T,F, etc. as formal argument names. R's function-lookup fallback often rescuesnames(x)calls inside a function whose arg is also callednames— but it's a confusing read, breaks under refactors, and generates a real "could not find function" error when the lookup heuristic misses (e.g. inside lapply/vapply/match.fun chains). Prefer descriptive alternatives:label_names,n,df, etc. - Caught in mc#33 round 1 —
mc_label_ensure(names)worked by luck when callingnames(existing)to read a named-vector's names; renamed tolabel_namesfor safety.
- When two functions in the same package both decide whether a string is a "system value" (or any normalized form), they MUST use the same comparison. Mismatches are silent bugs that surface only on edge cases.
- mc#33 example:
mc_label_ensureusedtoupper(nm) %in% sys(case-insensitive system-label skip), butresolve_label_namesusednm %in% sys(case-sensitive). Result:add = "inbox"withcreate_missing = TRUEwas silently broken — ensure skipped creation, resolve couldn't match. Fix: both usetoupper(nm) %in% sysand the resolver normalizes its return to the canonical case. - Generalized check: when reviewing a diff that adds normalization (case, whitespace, prefix-trim) on one side of an interaction, grep for the other side and align them.
When importing config from one location into a canonical one (legacy ~/.bash_profile → dotfiles repo, old script's env → repo, another project's settings.json → soul):
- Verify every referenced path/binary exists. Dead PATH exports, missing interpreters, stale env vars should be cut, not codified.
Shell paths:
for p in $(echo "$PATH" | tr ':' ' '); do [ -d "$p" ] || echo "DEAD: $p"; done - Ask before dropping a reference — it may be something the user forgot to reinstall on this machine, not something to delete.
- Curated subset, not verbatim copy. The diff should reflect what you verified, not the whole source.
- Idempotent provisioning code (a resolver-file writer, a config installer, a "create unless present" block) has two paths: the cold path that actually creates/writes, and the warm path that detects "already present" and skips. They exercise almost-disjoint code.
- Testing only on a host where the artifact already exists hits only the warm no-op — which cannot catch any cold-path bug: missing-directory, a derivation that returns empty, a pipefail abort before the write, wrong permissions, a flush that never runs. The warm path's job is literally to do nothing, so a green warm test proves almost nothing about onboarding.
- Every fresh host runs the cold path — that's the one onboarding depends on. Test it deliberately: back up + remove the artifact, run cold, assert it was created correctly, then re-run to confirm the warm no-op. (Caught 2026-06-23 on rtj#75: the resolver-writer's first test plan only ran the warm path on a host that already had
/etc/resolver/<suffix>; a Plan-agent review flagged that the cold path — the one every new host takes — was untested. Fixed bysudo rm-ing the file and running cold before close.) - Generalizes beyond shell: any "ensure X exists / converge to desired state" operation — Terraform resources, migrations, package installs — wants the from-absent path tested, not just the already-converged re-run.
- Moving/renaming scripts: update CLAUDE.md, READMEs, usage comments
- New variables: update .tfvars.example
- New workflows: update relevant README
For non-trivial issue-driven work, follow this checklist. Each step exists for a reason — skipping leads to rework, broken builds, and avoidable bugs that we've hit repeatedly.
- Start with
/planning-init <N>— given an issue number, enters plan mode for codebase exploration, presents a phase breakdown for user approval, then scaffolds branch + PWF baseline with the approved phases. One command replaces the manual issue → explore → plan → branch → scaffold dance. - Write robust tests first — failing tests that reproduce the issue or document the new behavior. Tests are the contract; they fail until the work makes them pass.
- Name with intent — functions, parameters, internal helpers carry the naming style of the package they live in. Look at existing exports as the guide; consistency over cleverness. (Per-package naming convention TBD — see soul issue tracking.)
- Examples that run — every exported function gets a runnable
@examplesblock. Pkgdown renders them; CI executes them. An example that doesn't run is documentation rot. - Code-check before each commit —
/code-checkon staged diff. Catches what tests miss: edge cases, hard-coded paths, unguarded variables, security issues. - Atomic commits — each commit bundles code change + checkbox flip in
task_plan.md. The diff and the progress live in the same commit;git log -- planning/tells the full story. /planning-archivewhen complete — moves PWF toarchive/YYYY-MM-issue-N-slug/, creates a freshactive/. Then/gh-pr-pushopens the PR;/gh-pr-mergehandles the release bookkeeping.
For one-line typo fixes, version-bump-only PRs, or trivial documentation edits, the full workflow is overhead. Use judgment. The threshold is roughly: multi-step issue, multi-file change, or anything that requires scoping → use the workflow.
/planning-init <N>— start/planning-update— sync checkboxes mid-session/code-check— before every commit/planning-archive— when issue closes/gh-pr-push— open the PR/gh-pr-merge— merge with release bookkeeping
We've hit snags repeatedly when half-doing this — branches that mix concerns, tests bolted on after, code-check skipped (and then a bug ships in the diff), examples that fail in pkgdown. Each step is small; the cumulative reliability gain is real. The convention is here so it becomes the default expectation, not a thing the user has to remind every session about.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
Don't assume. Don't hide confusion. Surface tradeoffs.
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
Touch only what you must. Clean up only your own mess.
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
Define success criteria. Loop until verified.
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
How Claude manages structured planning for complex tasks using planning-with-files (PWF).
Use PWF when a task has multiple phases, requires research, or involves more than ~5 tool calls. Triggers:
- User says "let's plan this", "plan mode", "use planning", or invokes
/planning-init - Complex issue work begins (multi-step, uncertain approach)
- Claude judges the task warrants structured tracking
Skip planning for single-file edits, quick fixes, or tasks with obvious next steps.
- Explore first — Enter plan mode (read-only). Read code, trace paths, understand the problem before proposing anything.
- Plan to files — Write the plan into 3 files in
planning/active/:task_plan.md— Phases with checkbox tasksfindings.md— Research, discoveries, technical analysisprogress.md— Session log with timestamps and commit refs
- Plan-review with the Plan agent before committing the plan — After scaffolding
task_plan.mdbut BEFORE the baseline commit, spawn the Plan subagent (Agent({subagent_type: "Plan", prompt: "..."}) and ask it to critically review the task_plan against the issue body + actual codebase. Categorize findings as Blocker / Gap / Ordering / Assumption / Scope / Acceptance. Address each before committing. The agent reads files fresh — it catches what you miss when you've been thinking about the design too long. Real example: caught 21 issues including hardcoded literals across 4 files not listed in the plan, untested DB column mismatches, unfixable test-literal-string assertions, and a baseline-cache-shadow that would have produced a 6-second no-op run. Cost: ~5 min agent. Saves: hours of mid-implementation rework. - Commit the plan — After Plan-agent review + fixes. This is the baseline.
- Work in atomic commits — Each commit bundles code changes WITH checkbox updates in the planning files. The diff shows both what was done and the checkbox marking it done.
- Code check before commit — Run
/code-checkon staged diffs before committing. Don't mark a task done until the diff passes review. - Archive when complete — Move
planning/active/toplanning/archive/via/planning-archive. Write a README.md in the archive directory with a one-paragraph outcome summary and closing commit/PR ref — future sessions scan these to catch up fast.
Every commit that completes a planned task MUST include:
- The code/script changes
- The checkbox update in
task_plan.md(- [ ]->- [x]) - A progress entry in
progress.mdif meaningful
This creates a git audit trail where git log -- planning/ tells the full story. Each commit is self-documenting — you can backtrack with git and understand everything that happened.
Phases with checkboxes. This is the core tracking file.
# Task Plan
## Phase 1: [Name]
- [ ] Task description
- [ ] Another task
## Phase 2: [Name]
- [ ] Task descriptionMark tasks done as they're completed: - [x] Task description
Append-only research log. Discoveries, technical analysis, things learned.
# Findings
## [Topic]
[What was found, with source/date]Session entries with commit references.
# Progress
## Session YYYY-MM-DD
- Completed: [items]
- Commits: [refs]
- Next: [items]planning/
active/ <- Current work (3 PWF files)
archive/ <- Completed issues
YYYY-MM-issue-N-slug/
If planning/ doesn't exist in the repo, run /planning-init first.
| Skill | When to use |
|---|---|
/planning-init |
First time in a repo — creates directory structure |
/planning-update |
Mid-session — sync checkboxes and progress |
/planning-archive |
Issue complete — archive and create fresh active/ |
Standards for R package development across New Graph Environment repositories. Based on R Packages (2e) by Hadley Wickham and Jenny Bryan.
Reference packages: When starting a new package, study these existing
packages for patterns: flooded, gq. They demonstrate the conventions below
in practice (DESCRIPTION fields, README layout, NEWS.md style, pkgdown setup,
test structure, hex sticker, etc.).
- tidyverse style guide: snake_case, pipe operators (
|>or%>%) - Match existing patterns in each codebase
- Use
pakfor package installation (notinstall.packages) - Prefix column name vectors with
cols_for discoverability in the environment pane:cols_all,cols_carry,cols_split,cols_writable. Same principle for other grouped vectors (params_,tbl_, etc.) - For SQL DDL+INSERT pairs that share a schema, use a single named
vector as the source of truth. Both
CREATE TABLEandINSERT (cols) SELECT colsderive their column lists from the samecols_*vector. Avoids drift between table shape and write projection — when columns change, you edit one place. Example:cols_streams <- c( id_segment = "integer NOT NULL", watershed_group_code = "varchar(4) NOT NULL", geom = "geometry(MultiLineStringZM, 3005)" # … ) # CREATE TABLE consumes both names + types ddl_body <- paste(names(cols_streams), unname(cols_streams), sep = " ", collapse = ", ") # INSERT consumes names only proj <- paste(names(cols_streams), collapse = ", ")
Follow R Packages (2e) conventions:
R/for functions,tests/testthat/for tests,man/for docsDESCRIPTIONwith proper fields (Title, Description, Authors@R)DESCRIPTIONURL field: include both the GitHub repo and the pkgdown site so pkgdown links correctly (e.g.,URL: https://github.com/OWNER/PKG, https://owner.github.io/PKG/)NAMESPACEmanaged by roxygen2 (#' @export,#' @import,#' @importFrom)- Never edit
NAMESPACEorman/by hand
Each exported function gets its own R file and its own test file:
R/fl_mask.R→tests/testthat/test-fl_mask.R- Commit the function and its tests together
- Use
Fixes #Nin the commit message to close the corresponding issue
File a GitHub issue for each function before building it. This creates a traceable record of what was planned, built, and verified.
For new packages or major features, work on a branch and merge via PR:
main ← scaffold-branch (PR closes with "Relates to NewGraphEnvironment/sred#N")
This gives one PR that contains all commits — a single SRED cross-reference
covers the entire body of work. Individual commits within the branch close
their respective function issues with Fixes #N.
Close function issues via commit messages — see Closing Issues in newgraph conventions.
- Use testthat 3e (
Config/testthat/edition: 3in DESCRIPTION) - Run
devtools::test()before committing - Test files mirror source:
R/utils.R->tests/testthat/test-utils.R - Test for edge cases and potential failures, not just happy paths
- Tests must pass before closing the function's issue
- Always grep for errors in the same command as the test run to avoid
running twice:
For error context:
Rscript -e 'devtools::test()' 2>&1 | grep -E "(FAIL|ERROR|PASS)" | tail -5
grep -E "(ERROR:|FAIL )" -A 10 | head -25
-
cli::cli_alert_warning()is notwarning(). It's visual only — callers can't catch it withwithCallingHandlers(warning = ...)and testthat'sexpect_warning()won't fire. When a function offers awarnmode that callers may want to react to programmatically, usewarning(). Reservecli_alert_warning()for FYI messages with no programmatic contract. -
expect_match(x, ..., all = FALSE)passes silently oncharacter(0). If the input is empty (e.g. no warnings fired), the assertion succeeds vacuously and defeats the test. Always pair withexpect_gt(length(x), 0)first when input may be empty.
Examples are how users discover what a function does. They must:
- Actually run — no
\dontrun{}unless external resources are required - Use bundled test data via
system.file()so they work for anyone - Show why the function is useful — not just that it runs, but what it produces and why you'd use it
- Use qualified names for non-exported dependencies (
terra::rast(),sf::st_read()) since examples run in the user's environment
At least one vignette showing the full pipeline on real data:
- Demonstrates the package solving an actual problem end-to-end
- Uses bundled test data (committed to
inst/testdata/) - Hosted on pkgdown so users can read it without installing
Output format: Use bookdown::html_vignette2 (not
rmarkdown::html_vignette) for figure numbering. Requires bookdown in
Suggests and chunks must have fig.cap / caption = for numbered
figures and tables.
Gotcha — cross-references don't resolve in vignettes. Table \@ref(tab:foo)
and Figure \@ref(fig:foo) markers compile to a literal \@ref(...) in
the rendered HTML rather than a numbered link. Bookdown's cross-ref
machinery isn't fully wired through html_vignette2 under pkgdown.
Use natural language instead — "the table below", "the floodplain map",
"the parameter table" — and let the captions speak for themselves. If
you need real numbered cross-refs, use bookdown::html_document2
(matches the cd-style report-appendix pattern) and accept that the
output is no longer a true package vignette.
Vignettes that need external resources (DB, API, STAC): Do NOT use
the .Rmd.orig pre-knit pattern — it breaks bookdown figure numbering
because knitr evaluates chunks during pre-knit and emits 
markdown that bookdown can't number.
Instead, separate data generation from presentation:
data-raw/vignette_data.R— runs the queries, saves results as.rdstoinst/testdata/(orinst/vignette-data/)- Vignette loads
.rdsfiles, all chunks run live during pkgdown build - Note at top of vignette: "Data generated by
data-raw/script.R" - bookdown controls all chunks — figure numbers, cross-refs work
This is the same pattern as test data: data-raw/ documents how the data
was produced, committed artifacts make vignettes reproducible without the
external resource.
- Created via a script in
data-raw/that documents exactly how the data was produced (database queries, spatial crops, etc.) - Committed to
inst/testdata/— small enough to ship with the package - Used by tests, examples, and vignettes — one dataset, three purposes
- roxygen2 for all exported functions
@importor@importFromin the package-level doc (R/<pkg>-package.R) to populate NAMESPACE — don't rely on::everywhere in function bodies- pkgdown site for public packages with
_pkgdown.yml(bootstrap 5) - GitHub Action for pkgdown (
usethis::use_github_action("pkgdown"))
Run lintr::lint_package() before committing R package code. Fix all warnings — every lint should be worth fixing.
linters: linters_with_defaults(
line_length_linter(120),
object_name_linter(styles = c("snake_case", "dotted.case")),
commented_code_linter = NULL
)
exclusions: list(
"renv" = list(linters = "all")
)- 120 char line length (default 80 is too strict for data pipelines)
- Allow dotted.case (common in base R and legacy code)
- Suppress commented code lints (exploratory R scripts often have commented alternatives)
- Exclude renv directory entirely
- Minimize Imports — use
Suggestsfor packages only needed in tests/vignettes - Pin versions only when breaking changes are known
- Prefer packages already in the tidyverse ecosystem
- Update
NEWS.md— keep it concise:- First release: one line (e.g., "Initial release. Brief description.")
- Later releases: describe what changed and why, not function-by-function. Link to the pkgdown reference page for details — don't duplicate it.
- Don't list every function; the pkgdown reference page is the single source of truth for what's in the package.
- Bump version in
DESCRIPTION(e.g.,0.0.0.9000→0.1.0) — as the final commit of the branch, after verification numbers/tests are final. Mid-branch bumps are premature and churn: additional code changes end up bundled inside a "release" that already claimed the version. - Commit as "Release vX.Y.Z"
- Tag:
git tag vX.Y.Z && git push && git push --tags
Protect main from deletion and force pushes:
gh api repos/OWNER/REPO/rulesets --method POST --input - <<'EOF'
{
"name": "Protect main",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }
],
"conditions": { "ref_name": { "include": ["refs/heads/main"], "exclude": [] } },
"rules": [ { "type": "deletion" }, { "type": "non_fast_forward" } ]
}
EOFusethis::create_package(".")usethis::use_mit_license("New Graph Environment Ltd.")usethis::use_testthat(edition = 3)usethis::use_pkgdown()usethis::use_github_action("pkgdown")usethis::use_directory("dev")— reproducible setup scriptusethis::use_directory("data-raw")— data generation scripts- Hex sticker via
hexSticker(seedata-raw/make_hexsticker.R) - Set GitHub Pages to serve from
gh-pagesbranch
Keep a dev/dev.R file that documents every setup step. Not idempotent —
run interactively. This is the reproducible recipe for the package scaffold.
Keep the README lean:
- Hex sticker, one-line description, install, example showing why it's useful
- Link to pkgdown vignette and function reference — don't duplicate them
- Don't maintain a function table — it's just another thing to keep updated and pkgdown's reference page is the single source of truth
When an LLM assistant modifies R package code:
- Run
lintr::lint_package()— fix issues before committing - Run
devtools::test()with error grep — ensure tests pass in one call:Rscript -e 'devtools::test()' 2>&1 | grep -E "(FAIL|ERROR|PASS)" | tail -5
- Run
devtools::document()and grep for results:Rscript -e 'devtools::document()' 2>&1 | grep -E "(Writing|Updating|warning)" | tail -10
- Check
devtools::check()passes for releases — capture results in one call:Rscript -e 'devtools::check()' 2>&1 | grep -E "(ERROR|WARNING|NOTE|errors|warnings|notes)" | tail -10
How references flow between Claude Code, Zotero, and technical writing at New Graph Environment.
Three tools, different purposes. Use the right one.
| Need | Tool | Why |
|---|---|---|
| Search by keyword, read metadata/fulltext, semantic search | MCP zotero_* tools |
pyzotero, works with Zotero item keys |
Look up by citation key (e.g., irvine2020ParsnipRiver) |
/zotero-lookup skill |
Citation keys are a BBT feature — pyzotero can't resolve them |
| Create items, attach PDFs, deduplicate | /zotero-api skill |
Connector API for writes, JS console for attachments |
Citation keys vs item keys: Citation keys (like irvine2020ParsnipRiver) come from Better BibTeX. Item keys (like K7WALMSY) are native Zotero. The MCP works with item keys. /zotero-lookup bridges citation keys to item data.
BBT citation key storage: As of Feb 2025+, BBT stores citation keys as a citationKey field directly in zotero.sqlite (via Zotero's item data system), not in a separate BBT database. The old better-bibtex.sqlite and better-bibtex.migrated files are stale and no longer updated. Query citation keys with: SELECT idv.value FROM items i JOIN itemData id ON i.itemID = id.itemID JOIN itemDataValues idv ON id.valueID = idv.valueID JOIN fields f ON id.fieldID = f.fieldID WHERE f.fieldName = 'citationKey'.
BBT citekey format is locally patched to strip &: the citekeyFormat pref (extensions.zotero.translators.better-bibtex.citekeyFormat in ~/Library/Application Support/Zotero/Profiles/*/prefs.js) has a .replace(find = "&", replace = "") segment added by hand. Without it, institutional authors containing & (e.g. "BC Species & Ecosystem Explorer", "WA Dept of Fish & Wildlife") leak & into the citekey, and pandoc's @key parser stops at & — so cites render broken in any bookdown/quarto build even though biblatex accepts the key. Reapply via Zotero → Tools → Run JavaScript: Zotero.Prefs.set("translators.better-bibtex.citekeyFormat", val) (also patch citekeyFormatEditing to match). Survives Zotero/BBT auto-updates; reverts only on a profile reset or a manual edit via the BBT preferences UI. Detect drift: grep citekeyFormat ~/Library/Application\ Support/Zotero/Profiles/*/prefs.js should show the .replace(find = "&", ...) chain. Teammates on Skeena/Fraser/restoration machines that hit the same @key-breaks-at-& drift should run the same Zotero.Prefs.set.
When research turns up a reference:
- DOI available: Tell the user — Zotero's magic wand (DOI lookup) is the fastest path
- ResearchGate link: Flag to user for manual check — programmatic fetch is blocked (403), but full text is often there
- BC gov report: Search ACAT, for.gov.bc.ca library, EIRS viewer
- Paywalled: Note it, move on. Don't waste time trying to bypass.
Preferred order:
- DOI magic wand in Zotero UI (fastest, most complete metadata)
- Web API POST with
collectionsarray (grey literature, local PDFs — targets collection directly, no UI interaction needed) saveItemsvia/zotero-api(batch creation from structured data — requires UI collection selection)- JS console script for group library (when connector can't target the right collection)
Collection targeting: saveItems drops items into whatever collection is selected in Zotero's UI. Always confirm with the user before calling it. Web API bypasses this — include "collections": ["KEY"] in the POST body. Find collection keys with ?q=name search on the collections endpoint.
saveItems attachments silently fail. Don't use them. Instead:
- Web API S3 upload (preferred): Create attachment item → get upload auth → build S3 body (Python: prefix + file bytes + suffix) → POST to S3 → register with uploadKey. Works without Zotero running. See
/zotero-apiskill section 4. - JS console fallback: Download with
curl, attach viaitem_attach_pdf.jsin Zotero JS console. - Verify attachment exists via MCP:
zotero_get_item_children
After manual adds, confirm via MCP:
zotero_search_items— find by titlezotero_get_item_metadata— check fields are completezotero_get_item_children— confirm PDF attached
If duplicates were created (common with saveItems retries):
- Run
collection_dedup.jsvia Zotero JS console - It keeps the copy with the most attachments, trashes the rest
# index.Rmd — dynamic bib from Zotero via Better BibTeX
bibliography: "`r rbbt::bbt_write_bib('references.bib', overwrite = TRUE)`"rbbt pulls from BBT, which syncs with Zotero. Edit references in Zotero → rebuild report → bibliography updates.
Library targeting: rbbt must know which Zotero library to search. This is set globally in ~/.Rprofile:
# default library — NewGraphEnvironment group (libraryID 9, group 4733734)
options(rbbt.default.library_id = 9)Without this option, rbbt searches only the personal library (libraryID 1) and won't find group library references. The library IDs map to Zotero's internal numbering — use /zotero-lookup with SELECT DISTINCT libraryID FROM citationkey against the BBT database to discover available libraries.
[@key2020]— parenthetical: (Author 2020)@key2020— narrative: Author (2020)[@key1; @key2]— multiplenocite:in YAML — include uncited references
When a review paper references an older study, trace back to the original and cite it. Don't attribute findings to the review when the original exists. (See LLM Agent Conventions in newgraph.md.)
When the original is unavailable (paywalled, out of print, can't locate): use secondary citation format in the prose and include bib entries for both sources:
Smith et al. (2003; as cited in Doctor 2022) found that...
Both @smith2003 and @doctor2022 go in the .bib file. The reader can then track down the original themselves. Flag incomplete metadata on the primary entry — it's better to have a partial reference than none at all.
When you need a PDF and the obvious URL doesn't work:
- DOI resolver → publisher site (often has OA link)
- Europe PMC (
europepmc.org/backend/ptpmcrender.fcgi?accid=PMC{ID}&blobtype=pdf) — ncbi blocks curl - SciELO — needs
User-Agent: Mozilla/5.0header - ResearchGate — flag to user for manual download
- Semantic Scholar — sometimes has OA links
- Ask user for institutional access
Always verify downloads: file paper.pdf should say "PDF document", not HTML.
scripts/rag_build.R— maps citation keys to Zotero PDF attachment keys, builds DuckDBdata/rag/gitignored — store is local, not committed- Dependencies: ragnar, Ollama with nomic-embed-text model
- See
/lit-searchskill for full recipe
ragnar_store_connect() then ragnar_retrieve() — returns chunks with source file attribution.
- NEVER write abstracts manually — if CrossRef has no abstract, leave blank
- NEVER cite specific numbers without verifying from the source PDF via ragnar search
- NEVER paraphrase equations — copy exact notation and cite page/section