slice 3.5c: seed 15 built-in skills across the 6 stacks
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 25s
ci / rust (push) Failing after 3m41s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Hand-authored skill catalog anchored to real 2026-07 versions:
  - Rust 1.97.1 (stable), edition 2024
  - React 19.2.7, Server Components + Actions
  - TailwindCSS 4.3.3 (CSS-first config, Oxide engine)
  - three.js r185 (WebGPURenderer stable, BatchedMesh matured)
  - React Native 0.86 / Expo SDK 54+ (New Architecture default)
  - cargo-nextest 0.9.140, gitleaks 8.20+, cargo-audit 0.21+
  - Postgres 17 (18 in beta, don't rely on)
  - CUDA Blackwell, Metal Apple7+, ROCm CDNA3

Ships 15 skills across the categories:
  foundation/  workspace-repo-commit-protocol
               small-focused-commits
               tdd-red-green-refactor
               code-review-checklist
               int-xx-marker-protocol
               decompose-int-items
  rust/        write-rust-current-edition
               rust-error-handling
               cargo-test-driven-development
               rust-async-tokio-idioms
  backend/     postgres-migrations-forward-only
               postgres-index-selection
               api-pagination-day-1
  frontend/    react-19-server-components
               tailwind-v4-idioms
               component-4-state-model
  mobile/      expo-managed-vs-bare
               rn-flashlist-perf
  gpu/         gpu-coalescing-and-occupancy
               roofline-model
  threejs/     threejs-perf-and-teardown
  security/    cargo-audit-workflow
               secret-scanning-gitleaks

skills_loader.rs walks skills/**/*.md, parses YAML frontmatter
(name, description, when_to_use, tags), upserts via
skills_catalog::upsert_builtin. Idempotent per boot — bumps version
+ appends skill_versions row ONLY when body changes. Deterministic
sha256-derived ids so builtins are stable across boots.

Dockerfile copies skills/ to /etc/clawmates/skills. Server boot
task spawns loader alongside team_template_loader.

Follow-ups (Slice 3.5c continuation, future PRs):
  - 20-30 more skills (duckdb, shadcn composition, a11y, WebGPU
    migration, metal frame capture, rocprof, deep gitea forge
    integration, semgrep rulepacks)
  - Bind skills to team template roles (add [role.skills] refs to
    templates/teams/*.toml + wire template_role_skills population
    in team_template_loader)

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 13:55:44 -07:00
co-authored by Claude Opus 4.7
parent cddbcb91b3
commit 7b23f61632
28 changed files with 1657 additions and 0 deletions
+9
View File
@@ -281,6 +281,15 @@ async fn run() -> Result<(), String> {
eprintln!("team_template_loader: loaded {n} builtin team template(s)"); eprintln!("team_template_loader: loaded {n} builtin team template(s)");
}); });
} }
// Load builtin skills from skills/**/*.md into the skills catalog
// (Slice 3.5c). Same idempotent-per-boot semantics.
{
let pool = pool.clone();
tokio::spawn(async move {
let n = cm_api::skills_loader::load_builtins(&pool).await;
eprintln!("skills_loader: loaded {n} builtin skill(s)");
});
}
// Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert // Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert
// until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist. // until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist.
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10)); cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
+1
View File
@@ -9,6 +9,7 @@ publish.workspace = true
[dependencies] [dependencies]
getrandom = "0.2" getrandom = "0.2"
toml = "0.8" toml = "0.8"
serde_yaml = "0.9"
hex = "0.4" hex = "0.4"
hmac = "0.12" hmac = "0.12"
sha2 = "0.10" sha2 = "0.10"
+1
View File
@@ -13,6 +13,7 @@ mod recursive_exec;
pub mod research_container; pub mod research_container;
mod routes; mod routes;
mod runtime_provision; mod runtime_provision;
pub mod skills_loader;
pub mod swarm; pub mod swarm;
pub mod team_template_loader; pub mod team_template_loader;
pub mod tool_versions; pub mod tool_versions;
+172
View File
@@ -0,0 +1,172 @@
//! Load builtin skills from `skills/**/*.md` into `skills` +
//! `skill_versions` at server boot. Slice 3.5c of the missions
//! consolidation.
//!
//! Frontmatter shape (YAML between `---` fences):
//! name: <slug>
//! description: <one-line, shown to the LLM in resources/list>
//! when_to_use: <trigger sentence, appended to description>
//! tags: [foundation, rust, ...]
//!
//! The body is the rest of the file. Both are upserted idempotently:
//! `skills_catalog::upsert_builtin` bumps the version + appends to
//! `skill_versions` ONLY when the body actually changes.
use serde::Deserialize;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::path::PathBuf;
use cm_db::repo::skills_catalog::{upsert_builtin, UpsertBuiltinSkill};
#[derive(Debug, Deserialize)]
struct Frontmatter {
name: String,
description: String,
#[serde(default)]
when_to_use: Option<String>,
#[serde(default)]
tags: Vec<String>,
}
fn skills_dir() -> PathBuf {
if let Ok(d) = std::env::var("CLAWMATES_SKILLS_DIR") {
return PathBuf::from(d);
}
let container = PathBuf::from("/etc/clawmates/skills");
if container.exists() {
return container;
}
PathBuf::from("skills")
}
/// Deterministic id per builtin skill name — sha256 of a stable
/// namespace + the name. Matches the pattern used by the team-template
/// loader so ids are reproducible across boots.
fn builtin_id(name: &str) -> uuid::Uuid {
let mut h = Sha256::new();
h.update(b"clawmates.builtin.skill\x00");
h.update(name.as_bytes());
let d = h.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&d[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
uuid::Uuid::from_bytes(bytes)
}
/// Walk the skills tree + upsert every `*.md`. Returns the count of
/// successful upserts. Failures are logged and skipped so a single
/// broken skill can't block boot.
pub async fn load_builtins(pool: &PgPool) -> usize {
let dir = skills_dir();
let files = match walk_md(&dir) {
Ok(v) => v,
Err(e) => {
eprintln!(
"skills_loader: dir {} not readable: {e} — skipping builtin skills seed",
dir.display()
);
return 0;
}
};
let mut loaded = 0usize;
for path in files {
match load_one(pool, &path).await {
Ok(name) => {
loaded += 1;
eprintln!("skills_loader: upserted builtin skill {name}");
}
Err(e) => {
eprintln!("skills_loader: failed to load {}: {e}", path.display());
}
}
}
loaded
}
fn walk_md(root: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
fn recurse(p: &std::path::Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(p)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
recurse(&path, out)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
out.push(path);
}
}
Ok(())
}
recurse(root, &mut out)?;
out.sort();
Ok(out)
}
/// Parse the frontmatter + body out of one file and upsert.
async fn load_one(pool: &PgPool, path: &std::path::Path) -> Result<String, String> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let (frontmatter, body) = split_frontmatter(&text)
.ok_or_else(|| format!("no --- frontmatter block in {}", path.display()))?;
let fm: Frontmatter = serde_yaml::from_str(frontmatter)
.map_err(|e| format!("parse frontmatter of {}: {e}", path.display()))?;
let skill = UpsertBuiltinSkill {
id: builtin_id(&fm.name),
name: &fm.name,
description: &fm.description,
when_to_use: fm.when_to_use.as_deref(),
tags: fm.tags.clone(),
body,
};
upsert_builtin(pool, skill)
.await
.map_err(|e| format!("upsert {}: {e}", fm.name))?;
Ok(fm.name)
}
/// Extract the `---\n<yaml>\n---\n<body>` shape. Returns `(yaml, body)`
/// or None if the file doesn't start with a frontmatter fence.
fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
let mut rest = text.strip_prefix("---\n")?;
// Some editors add a BOM; strip a leading whitespace/newline pair.
if rest.starts_with('\r') {
rest = rest.strip_prefix('\r').unwrap_or(rest);
}
let end = rest.find("\n---\n")?;
let yaml = &rest[..end];
let body = &rest[end + "\n---\n".len()..];
Some((yaml, body.trim_start()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_frontmatter() {
let src = "---\nname: foo\ndescription: bar\n---\n# body\n";
let (fm, body) = split_frontmatter(src).unwrap();
assert!(fm.contains("name: foo"));
assert_eq!(body, "# body\n");
}
#[test]
fn no_frontmatter_returns_none() {
assert!(split_frontmatter("# plain md\n").is_none());
}
#[test]
fn builtin_id_stable() {
assert_eq!(
builtin_id("workspace-repo-commit-protocol"),
builtin_id("workspace-repo-commit-protocol")
);
assert_ne!(
builtin_id("workspace-repo-commit-protocol"),
builtin_id("other-skill")
);
}
}
+1
View File
@@ -46,5 +46,6 @@ RUN apt-get update \
COPY --from=builder /clawmates-server /usr/local/bin/clawmates-server COPY --from=builder /clawmates-server /usr/local/bin/clawmates-server
# Builtin templates (team + workflow). Loader upserts them on boot. # Builtin templates (team + workflow). Loader upserts them on boot.
COPY templates /etc/clawmates/templates COPY templates /etc/clawmates/templates
COPY skills /etc/clawmates/skills
USER 65532 USER 65532
ENTRYPOINT ["/usr/local/bin/clawmates-server"] ENTRYPOINT ["/usr/local/bin/clawmates-server"]
+66
View File
@@ -0,0 +1,66 @@
---
name: api-pagination-day-1
description: Every list endpoint paginates from the first version. Cursor-based over offset. Bounded max_limit. Includes total when cheap.
when_to_use: You're the api_designer role, or a coder adding any `GET /...` endpoint that returns a collection.
tags: [backend, api]
---
# List endpoints paginate from day 1
Unpaginated lists are a production incident waiting to happen. The one time the endpoint is called with a million-row table, the response body fills the load balancer's buffer, memory spikes, and everything downstream 503s.
## The pattern
```rust
#[derive(Deserialize)]
struct ListQuery {
#[serde(default = "default_limit")]
limit: u32,
/// Cursor from a prior page's `next_cursor`. Opaque to the client.
cursor: Option<String>,
}
fn default_limit() -> u32 { 50 }
const MAX_LIMIT: u32 = 500;
#[derive(Serialize)]
struct Page<T> {
items: Vec<T>,
next_cursor: Option<String>,
/// Present only when it's cheap to compute (COUNT(*) over a small
/// filtered set). Never for a full-table count.
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<u64>,
}
```
## Cursor over offset
- **Offset (`LIMIT 50 OFFSET 500`)** — Postgres scans and discards the first 500 rows on every request. Cost grows with page number.
- **Cursor (`WHERE id > $cursor ORDER BY id LIMIT 50`)** — O(log n) always. The cursor is the last row's sort key, base64'd (or JWT-signed if you want tamper-detection).
For clawmates, the standard cursor shape is `base64(json({"id": "...", "ts": "..."}))` — includes id + the ORDER BY tiebreaker so pages are deterministic even under concurrent inserts.
## Bounds
- **`limit.clamp(1, MAX_LIMIT)`** — server-side clamp, not just server-side validation. Never trust the client to respect `max_limit`.
- **Return `413 Payload Too Large`** when a caller sends `limit=100000` if you'd rather they notice than have their traffic silently clamped.
## Sorting
- Every paginated endpoint has ONE canonical sort order.
- Cursor must include the sort key + a tie-breaker (usually `id`). Otherwise two rows with the same sort key can be missed or duplicated across pages.
## Total counts
- Cheap: `SELECT count(*) FROM t WHERE small_indexed_filter`. Include it.
- Expensive: unfiltered COUNT on a large table. **Don't run it.** Return `total: null` and let the UI show "50 of many".
## OpenAPI
The response schema declares `items`, `next_cursor`, and `total?`. Never return a bare array — you can't add a cursor later without a breaking change.
## Anti-patterns
- **`GET /foo` returning `[...]`** with no pagination shape. Every list route pays for future-proofing on day 1.
- **`page=N&per_page=M`** — offset-based. Ships until traffic bites you.
- **Cursor that includes an unsigned index into a mutable list** — reorders + inserts corrupt it. Cursor must be a stable identifier.
@@ -0,0 +1,76 @@
---
name: postgres-index-selection
description: Which index type to reach for — btree/gin/gist/brin/hash — and when partial or expression indexes beat a plain one.
when_to_use: You're adding an index or diagnosing a slow query in Postgres.
tags: [backend, postgres, performance]
---
# Postgres index selection (17)
Default to **btree** unless the data shape says otherwise.
## btree
- Equality + range on scalar columns.
- Multi-column: leading column must be in the WHERE clause for the index to help. `(a, b, c)` helps `WHERE a=? AND b=?`, not `WHERE b=?`.
- Descending: rare — btree can scan in either direction, but `ORDER BY x DESC` with a mixed-direction multi-col query benefits from `(a ASC, b DESC)`.
## Partial indexes — huge wins
Any time a WHERE clause repeats across queries, a partial index is a smaller, faster index:
```sql
CREATE INDEX missions_running_idx
ON missions (updated_at DESC)
WHERE status IN ('running', 'pending');
```
The clawmates codebase leans on this heavily — see `topology_runs_status_idx WHERE status = 'queued'`.
## Expression indexes
For computed lookups:
```sql
CREATE UNIQUE INDEX users_lower_email_idx ON users (lower(email));
-- Query MUST use the exact expression:
SELECT * FROM users WHERE lower(email) = lower($1);
```
## GIN
- **JSONB**: `CREATE INDEX foo_meta_gin ON foo USING gin (metadata jsonb_path_ops);` — `jsonb_path_ops` is smaller + faster than default for `@>` queries. Use default when you also need `?` / `?|` / `?&`.
- **Arrays**: `CREATE INDEX skills_tags_gin ON skills USING gin (tags);` — `WHERE tags && ARRAY['rust']` becomes index-served.
- **Full-text search**: `to_tsvector('english', body)` with a GIN index.
## GiST
- Geo (PostGIS): `USING gist` on `geometry` / `geography` columns.
- Range types: `USING gist` on `tstzrange` for "does this range overlap" queries.
## BRIN
- Very large tables where the indexed column correlates with physical row order (append-only timestamps, sequential ids). 1/1000th the size of btree, works only for range scans.
## Hash (rare)
- Equality-only, very high-cardinality, no range. Postgres 10+ hash indexes are WAL-logged + replicated so they're finally safe — but btree wins in most benchmarks. Reach for it only when profiling justifies.
## Diagnosis flow
```sql
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT ...;
```
Read from bottom up. Watch for:
- **Seq Scan on a hot table** with a WHERE that should be indexed → missing index.
- **Index Scan** with `Rows Removed by Filter` > returned rows → wrong index; add a compound or partial.
- **Bitmap Heap Scan** with `Recheck` on lossy → index is being used but not tightly; check if partial would help.
- **Sort** with `Method: external merge Disk` → sort spills to disk; add index that provides the order.
## Anti-patterns
- **Indexing every column**. Each index has a write cost (INSERT/UPDATE amplification). Prefer 1–2 well-chosen composites over 6 single-column indexes.
- **Indexing a boolean without WHERE**. `CREATE INDEX foo_active_idx ON foo(active)` is worthless; `... WHERE active = true` is what you want.
- **Trusting `pg_stat_user_indexes.idx_scan = 0`** without waiting a full billing cycle. Some indexes exist for the monthly report.
@@ -0,0 +1,64 @@
---
name: postgres-migrations-forward-only
description: Forward-only, reversible-by-new-migration policy for Postgres 17. No `--rollback`, no ALTER TABLE without concurrent-safe patterns on hot tables.
when_to_use: You are the db_engineer role or any coder authoring a `migrations/NNNN_*.sql` file.
tags: [backend, postgres, migrations, versioned]
---
# Postgres migrations (17-safe)
Anchored to **Postgres 17** (latest stable as of 2026-07). Postgres 18 is in beta at time of writing — don't rely on 18-only syntax without a version guard.
## Cardinal rule: forward-only
- Every migration file is applied once and never rolled back. If you need to undo, ship a NEW migration that undoes.
- `sqlx-cli` reads `migrations/NNNN_*.sql` in ascending order; each is wrapped in a txn where possible.
- The migration is part of the commit that introduces the code depending on it. Split ONLY when a two-step deploy is required (see below).
## Two-step deploys (for column drops + renames)
- **Adding a column**: single migration, always safe.
- **Removing a column**:
1. **Migration N** stops writing the column (code change first).
2. **Migration N+1** (weeks later) drops the column, after every replica has caught up.
- **Renaming a column**: don't. Add new, dual-write, backfill, stop-writing-old, drop-old.
## Concurrent-safe patterns on hot tables
Anything > 1M rows or in the request path needs concurrent-safe DDL:
```sql
-- Index adds
CREATE INDEX CONCURRENTLY foo_bar_idx ON foo(bar);
-- (CONCURRENTLY can't run in a txn — omit sqlx's txn wrap by prefixing
-- the migration file with `-- fx-no-tx`; sqlx-cli honors it.)
-- Adding NOT NULL to an existing column
ALTER TABLE foo ADD CONSTRAINT foo_bar_nn CHECK (bar IS NOT NULL) NOT VALID;
ALTER TABLE foo VALIDATE CONSTRAINT foo_bar_nn;
-- Later: drop the CHECK, add NOT NULL to the column — cheap now.
-- Backfill in batches
UPDATE foo SET bar = new_value
WHERE id IN (SELECT id FROM foo WHERE bar IS NULL LIMIT 10000);
-- Loop until 0 rows.
```
## Foreign keys
- Every FK gets an index UNLESS the table is small (< 100k rows expected long-term).
- `ON DELETE CASCADE` for owned child rows; `ON DELETE SET NULL` for weak references; explicit `NO ACTION` for anything else.
- Deferring: `DEFERRABLE INITIALLY IMMEDIATE` on tables where cross-table batch writes need to defer FK checks.
## Naming
- Migration filename: `NNNN_snake_case_description.sql` — 4-digit prefix, no gaps.
- Table names: singular is fine, plural is fine — pick one per crate and never mix.
- Index name: `<table>_<cols>_idx` for unique; `<table>_<cols>_uniq` for UNIQUE.
- Constraint name: `<table>_<cols>_ck` for CHECK, `_fk` for FK.
## What NOT to put in a migration
- Data seeding beyond a handful of rows — that's a boot-time loader (see [[team_template_loader]] pattern).
- `SET search_path` or `SET timezone` — belongs in role config or session, not migration.
- Anything requiring a specific extension without `CREATE EXTENSION IF NOT EXISTS pgcrypto` (or whichever) first.
@@ -0,0 +1,62 @@
---
name: code-review-checklist
description: A structured checklist for the reviewer role — correctness, safety, simplicity, testability, docs — with explicit approve/block markers.
when_to_use: You are a reviewer role or performing a review pass on any code diff.
tags: [foundation, review]
---
# Code review checklist
Work top-down. A block on any earlier item stops the pass — don't score everything before opining.
## 1. Correctness (BLOCKS)
- Does the change match the PLANNER's acceptance criteria for this INT-XX?
- Do the tests exercise the behavior described in the plan, not just the code that was written?
- Any edge cases the plan named but the code doesn't handle? (empty input, boundary values, timezones, unicode)
- Any silent error paths (`unwrap_or_default()`, `.ok()?`) that hide real failures?
## 2. Safety (BLOCKS)
- New `unsafe` blocks — is the justification comment concrete about the invariants?
- New FFI — is the C-side contract checked at every call site?
- SQL — parameterized every user input? No string concatenation into queries?
- HTTP — validates auth, checks workspace scope on every read?
## 3. Simplicity (usually NIT, sometimes BLOCK)
- Any code that a smaller function would replace? (`Iterator::sum`, `.map`, `.filter`)
- Any abstraction added for one caller? BLOCK — introduce when second caller lands.
- Any dead code, commented-out blocks, `// TODO fix later` without a ticket? BLOCK.
- Files > 1500 LOC after the change? Split.
## 4. Performance (BLOCK on hot paths, NIT elsewhere)
- Cold path (init, config load, admin endpoints): NIT.
- Hot path (per-request handler, per-frame render, tight loop): BLOCK on obvious O(n²), unnecessary allocations, blocking sync in async context.
## 5. Testability / observability (BLOCK if downstream on-call would suffer)
- Does the change add meaningful log lines at decision points? (Info at boundaries, warn on retries, error on give-up.)
- Are new metrics wired? Prometheus counter/histogram naming convention followed?
## 6. Docs (NIT if module-private, BLOCK if public API)
- New public function / struct / route → doc comment with example.
- Non-obvious invariant → comment on the declaration.
## Verdict markers
Emit exactly one of these on a line by itself when the pass is done:
```
REVIEW_APPROVE: INT-NN
```
or
```
REVIEW_BLOCK: INT-NN — <specific issue that must be fixed>
```
Multi-line commentary is fine, but the marker must appear literally.
+63
View File
@@ -0,0 +1,63 @@
---
name: decompose-int-items
description: How to break a mission's roadmap/research artifact into INT-XX items sized for one coder-turn each.
when_to_use: You are the planner role, at the start of a coding phase, when the input is a spec/roadmap and the output is a task list.
tags: [foundation, planning]
---
# Decomposing into INT-XX items
Each INT item is one unit of work handed to a coder. The mission loop iterates one INT per iteration by default. Sizing matters — too big and iterations stall, too small and you burn tokens on ceremony.
## Sizing heuristic
Aim for **1–3 hours of focused coder time** per INT. Practically:
- Touches ≤ 5 files
- Adds ≤ 300 LOC net
- Owns exactly one test story (a `#[test]` or a small `describe` block)
- Independently mergeable — reverting it doesn't break other INTs
If an item exceeds any of these, split.
## The output shape
Each INT is a block with:
```
INT-05: <short title>
**What:** one paragraph, observable behavior only (no implementation
details unless they're required for correctness).
**Files (expected):** src/foo.rs, tests/foo_test.rs
**Acceptance:**
- [ ] `cargo test foo::` passes with the new case
- [ ] Coverage on src/foo.rs ≥ 90%
- [ ] No new `unsafe` blocks
**Depends on:** INT-04 (must be merged first)
**Risk notes:** touches the hot path — bench before/after.
```
Emit each on its own — the task-card parser (Slice 5) UPSERTs one `mission_tasks` row per marker.
## Ordering
- **Dependencies first.** INT-01 → INT-02 → ... — but leave room for reorders. A coder can emit `REORDER: <rationale>` if a prerequisite blocks them.
- **De-risk first.** Put the highest-uncertainty item early so failure is cheap to recover from.
- **Interface before implementation.** If INT-N introduces a new module boundary, land the trait/type in INT-N-a and the impl in INT-N-b.
## Emit protocol
At the end of the planning turn, emit:
```
TASK: INT-01 — <title>
TASK: INT-02 — <title>
...
PLAN_COMPLETE: INT-01..05
```
The parser creates `mission_tasks` rows for each TASK line. `PLAN_COMPLETE` records that the plan pass finished so the mission's coding phase can begin iterating.
@@ -0,0 +1,44 @@
---
name: int-xx-marker-protocol
description: The literal line-based markers the mission loop parses to advance state — TASK, WORK, HANDOFF, TEST_PASS, REVIEW_APPROVE, COMPLETED.
when_to_use: Pin on every coding role. Missing or malformed markers cause the mission to stall.
tags: [foundation, protocol]
---
# INT-XX marker protocol
Missions parse your turn output line-by-line for these markers. **They must appear literally**, with the colon, on their own line, no bold, no code fence.
## The full ladder
```
TASK: INT-NN — <title> # planner opens a new item
PLAN_COMPLETE: INT-NN # planner is done specifying
WORK: INT-NN # coder starts implementing
HANDOFF: INT-NN # coder passes to tester/reviewer
TEST_PASS: INT-NN # tester confirms green
TEST_FAIL: INT-NN — <reason> # tester failed the build; coder loops
REVIEW_APPROVE: INT-NN # reviewer OKs the diff
REVIEW_BLOCK: INT-NN — <reason> # reviewer requests changes
COMPLETED: INT-NN # committer pushed; loop advances
```
## Rules
1. **Exactly one INT id per marker line.** `COMPLETED: INT-05, INT-06` won't parse.
2. **Emit at the end of your substantive turn**, not the beginning. Otherwise the parser advances state before the work is real.
3. **Never emit a marker you can't back up.** Emitting `COMPLETED: INT-NN` without a corresponding `git push` desynchronizes the mission from the repo.
4. **`REORDER: <one-sentence rationale>`** if you're taking an INT out of order — the loop's review timeline logs these.
## What the loop does with each
- `TASK` + `WORK` + `HANDOFF` are observability only — they show up in the mission's task-card feed (Slice 5).
- `TEST_FAIL` / `REVIEW_BLOCK` re-schedules the item back to the coder.
- `COMPLETED` bumps `mission_tasks.status = 'complete'` and advances the loop's `consumed_int_ids`. Next iteration picks the next unconsumed INT.
- `REORDER` writes a `loop_reorder_event` row — a reviewer can inspect why the order shifted.
## Common failures
- **Marker in a code fence**: `\`\`\`COMPLETED: INT-05\`\`\`` won't parse. Put it OUTSIDE the fence.
- **Marker with markdown emphasis**: `**COMPLETED: INT-05**` won't parse.
- **Wrong dash**: `INT-NN — title` uses en-dash; `INT-NN - title` also works. `INT-NN – title` (em-dash) also works. Any dash-like char is fine; the parser trims whitespace around it.
@@ -0,0 +1,45 @@
---
name: small-focused-commits
description: Every commit does one thing, is understandable in isolation, and passes tests on its own. Bisectable-by-default policy.
when_to_use: Before every `git commit` — check that the commit is a single reviewable unit.
tags: [foundation, git, review]
---
# Small, focused commits
A commit's job is to make ONE change that a reviewer can hold in their head, that CI can green, and that `git bisect` can meaningfully cross.
## Test yourself before you commit
1. **Can you describe the change in one sentence with no `and`?** If not, split.
2. **Does the diff exceed ~300 lines net, excluding generated/mechanical?** If yes, split unless every line is genuinely part of the same idea (renaming a symbol across a codebase counts).
3. **Would `git bisect` on this commit be meaningful?** If the commit combines a bug fix + a refactor, bisect can't isolate the bug — split.
4. **Does the commit compile + tests pass on its own?** If no, you're building a broken bisect landscape — reorder or squash so every commit is green.
## Splitting patterns
- **Mechanical then semantic** — rename first, behavior second. Two commits, each trivially reviewable.
- **Add then use** — introduce the new abstraction as an unused module first, then wire callers in a follow-up. Reviewers can validate the shape before opining on integration.
- **Refactor then feature** — never `refactor + add feature` in one commit. It hides what the feature actually cost.
- **Test then implementation** — TDD writes the test first; commit the failing test, then the passing impl (see [[tdd-red-green-refactor]]).
## What a good commit message looks like
```
<INT-NN> <imperative one-liner, ≤72 chars>
<paragraph explaining WHY the change is needed; what problem it solves;
what alternatives were considered and why they were rejected>
<optional: performance/security notes, migration guidance, follow-ups>
Refs: INT-NN
```
The subject is what shows up in `git log --oneline`. Make it stand alone.
## Anti-patterns
- `wip` / `fix` / `update stuff` — never merge these; if that's what you have locally, squash before push.
- One commit per file — commits are about ideas, not filesystem layout.
- 40-line commit that touches 22 files — probably a rename that should be its own commit (see above).
@@ -0,0 +1,39 @@
---
name: tdd-red-green-refactor
description: Strict test-driven development — write the failing test first, make it pass minimally, then refactor. Prevents overbuilt code and pinpoints regressions.
when_to_use: Before writing any behavior-adding code. Applies to Rust, TypeScript, Python, anywhere tests can run cheap.
tags: [foundation, testing, tdd]
---
# TDD: red → green → refactor
## The loop
1. **RED** — Write the test that fails because the behavior doesn't exist yet. Run it. Confirm it fails for the RIGHT reason (missing symbol, wrong output — not a syntax error).
2. **GREEN** — Write the simplest possible code that makes the test pass. Not the "correct" version — the SIMPLEST one. Hardcoded return value is legal.
3. **REFACTOR** — Now that you have a passing safety net, restructure. Extract, rename, tighten types. Every intermediate state must still be green.
4. **Commit the RED-to-GREEN pair as one commit.** The refactor is its own commit.
## Why this order
- Writing the test first forces you to design the API from the caller's perspective. The API you wish existed usually beats the API you accidentally get.
- Watching the test fail proves the test can fail — a test that has never failed is a test you don't trust.
- Refactoring under a green bar means every step is safe. Refactoring in the dark means every step could silently break behavior.
## What counts as "a test"
- Rust: `#[test]` unit test, `#[tokio::test]` async, or an integration test under `tests/`. Not a `println!`.
- TypeScript: Vitest / Jest / Playwright. Storybook + a visual snapshot counts for component work.
- Any language: it exits non-zero when the behavior is broken, without a human interpreting the output.
## Coverage discipline
- Target: ≥90% line coverage on files you touched in this INT-XX item. Measured by `cargo llvm-cov` for Rust, `vitest --coverage` for TS.
- Coverage regressions on changed files block merge — enforce via the mission's `commit_policy` (Slice 4).
- 100% coverage is a smell — usually means testing implementation details. Aim for behavior coverage.
## Anti-patterns
- Writing the impl first "because it's obvious" and adding tests after — you already lost the design feedback and the tests will inevitably shape to what the impl happens to do.
- Testing multiple behaviors in one `#[test]` — a failure now hides what actually broke.
- Snapshot-only test suites — snapshots catch NOTHING structural. Pair with at least one assertion per behavior.
@@ -0,0 +1,54 @@
---
name: workspace-repo-commit-protocol
description: How to interact with /workspace/repo — the mission's checked-out codebase — and how to commit + push meaningful changes back.
when_to_use: You are a coder, committer, or any role that edits code. Pin this at turn start so you never lose orientation.
tags: [foundation, coding, git]
---
# Workspace repo + commit protocol
You are running inside a mission's team container. The clawhdf5/backend/whatever repository the mission targets is bind-mounted at **`/workspace/repo`**. That is the ONLY path where source-modifying edits belong.
## Ground rules
1. **`cd /workspace/repo` at the start of every substantive turn.** If you `pwd` and it isn't `/workspace/repo`, cd there first — your default CWD is the ZeroClaw agent workspace (`~/workspace`), which is scratch storage, not the codebase.
2. **All `file_read` / `file_write` / `shell` calls that touch source use paths under `/workspace/repo`.** Anything else is scratch — the mission will not persist it.
3. **Never write files outside `/workspace/repo` and expect them to survive** — the agent workspace resets, `/tmp` is per-container ephemeral.
## Commit protocol
When (and only when) you have a meaningful, tested change:
```
cd /workspace/repo
git status # sanity-check what you touched
git diff --stat # confirm scope matches the plan
git add -A
git commit -m "<INT-NN> <one-line title>
<one-paragraph rationale — WHY, not what>
Refs: INT-NN
"
git push
```
- **Commit message uses the mission's INT-XX marker** on the subject line — the mission's task-card parser (Slice 5) advances state on it.
- **One INT per commit** unless the change genuinely can't be split; split-when-in-doubt.
- **Never `--force`, never rewrite pushed history** without an explicit `HANDOFF: safe to force-push` from the reviewer.
## When NOT to commit
- Tests failing → fix or revert; never commit red.
- Reviewer emitted `REVIEW_BLOCK: INT-NN — <reason>` for the current item.
- The change is a WIP or exploratory — that lives in the agent's scratch workspace, not the repo.
## Emit the completion marker
After a successful push, on a line by itself:
```
COMPLETED: INT-NN
```
The mission loop advances on that marker. If you didn't push, don't emit it.
@@ -0,0 +1,59 @@
---
name: component-4-state-model
description: Every component ships with four visual states — empty, loading, error, ready — from day 1. No spinners without skeletons.
when_to_use: You're designing or authoring a component that displays data. Frontend + mobile teams pin this.
tags: [frontend, design, ux]
---
# The four-state model
Every component that renders remote data has FOUR distinct visual states. Ship all four in the same PR — you can't "add empty state later" without shipping broken UX in the interim.
## The states
1. **Empty** — the request succeeded and returned zero items, OR the entity hasn't been created yet.
2. **Loading** — the request is in flight. Show a **skeleton** matching the eventual layout, never a spinner in isolation.
3. **Error** — the request failed. Show what went wrong + a recovery affordance (retry, contact support, or the fallback action).
4. **Ready** — data is here, render it.
## The skeleton rule
- Skeletons should match the ready state's LAYOUT within 5%. A wide card skeleton that resolves to a narrow list is disorienting.
- Skeletons pulse subtly (`animate-pulse` in Tailwind is fine). No spinners layered on top.
- Skeleton for lists shows **3 items** — enough to convey shape, not so many the eye reads them as content.
## The empty state rule
- Explain what would fill this component + the action to make it happen.
- CTA button placed prominently, not buried.
- Emojis / illustrations are fine but the copy carries the meaning.
Example (from the missions canvas):
> **No missions yet.** Pick a workflow template to get started. → `[Start a mission]`
## The error state rule
- Show the human-friendly reason (never the raw stack). "Couldn't reach the server. Retry?" not "TypeError: undefined is not a function".
- Retry button that actually retries the same request, not a page reload.
- If the error is auth (401/403), the affordance is "sign in", not "retry".
- Log the raw error to observability (Sentry, PostHog, etc.) so it's investigable. Users see the human message.
## Implementation shape (React 19)
```tsx
export function ThingList() {
const { data, error, isLoading, refetch } = useThings();
if (isLoading) return <ThingListSkeleton />;
if (error) return <ErrorCard error={error} onRetry={refetch} />;
if (!data?.length) return <EmptyCard onCreate={openWizard} />;
return <>{data.map((t) => <ThingRow key={t.id} thing={t} />)}</>;
}
```
## Anti-patterns
- **Empty state = "No data."** Do the work; explain what fills it and how.
- **Spinner-only loading state.** Nothing tells the user what's coming.
- **`data && data.length && data.map(...)`** without any branch for the falsy cases. That renders NOTHING and the user thinks the app is broken.
- **`toast.error(err.message)`** as your entire error handling. Toasts are ephemeral; a persistent error state is what tells the user the component is broken.
@@ -0,0 +1,100 @@
---
name: react-19-server-components
description: React 19.2 patterns — RSC by default, `use()` hook, Actions + useActionState, `use client` boundaries.
when_to_use: You're writing React components in a Next.js 15+ app on React 19. Rust_sdlc + frontend teams pin this.
tags: [frontend, react, versioned]
---
# React 19 patterns (as of 19.2.7)
## Server Components by default
- **Every new component is a Server Component unless it needs interactivity.** `use client` opts in.
- **Why**: no JS ships to the browser for pure-display components, data fetching happens at the source, secrets stay server-side.
- **When to `use client`**:
- Hooks (`useState`, `useEffect`, `useReducer`, custom hooks that use them).
- Event handlers (`onClick`, `onChange`).
- Browser-only APIs (`window`, `document`, `IntersectionObserver`).
- Third-party libs that call `useLayoutEffect` internally.
Pattern: keep the parent server, extract the interactive leaf as client:
```tsx
// list.tsx (server component)
export async function List() {
const items = await db.items.findMany();
return (
<div>
{items.map((i) => <Row key={i.id} item={i} />)}
<RefreshButton /> {/* client leaf */}
</div>
);
}
// refresh-button.tsx
"use client";
export function RefreshButton() {
return <button onClick={() => router.refresh()}>Refresh</button>;
}
```
## The `use()` hook
Read a promise or context inside a component (server OR client):
```tsx
import { use } from "react";
export function User({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends until resolved
return <div>{user.name}</div>;
}
```
- Wrap in `<Suspense fallback={...}>` at the boundary you want to loading-state.
- Works with context: `const theme = use(ThemeContext)` — can be called conditionally, unlike `useContext`.
## Actions + `useActionState`
Server actions handle mutations without an API route:
```tsx
"use client";
import { useActionState } from "react";
async function submit(prev: State, formData: FormData) {
"use server";
await db.thing.create({ data: formData });
return { ok: true };
}
export function Form() {
const [state, action, pending] = useActionState(submit, { ok: false });
return (
<form action={action}>
<input name="title" />
<button disabled={pending}>Save</button>
{state.ok && <p>Saved!</p>}
</form>
);
}
```
## `useOptimistic`
For instant UI feedback on mutations:
```tsx
const [optimistic, addOptimistic] = useOptimistic(items, (state, next) => [...state, next]);
```
## What changed vs React 18
- **`forwardRef` is optional** — regular components accept `ref` as a prop (19.0+).
- **`use client` boundary is enforced** — you can't `useState` in a Server Component. Fails at build.
- **Removed**: `React.createRef` in functional components (was already deprecated), string refs.
## Common bugs
- **Passing a function from a Server Component to a Client Component prop** — fails serialization. Solution: define the function inside the client component or pass a Server Action.
- **`useState` in an async Server Component** — build error. Extract the interactive part to a `"use client"` leaf.
- **`document` accessed in the top level of a client component file** — SSR runs the top level, blows up. Guard with `if (typeof window !== "undefined")` or move into an effect.
+74
View File
@@ -0,0 +1,74 @@
---
name: tailwind-v4-idioms
description: TailwindCSS v4 (4.3.3 as of 2026-07) — CSS-first config, no tailwind.config.js by default, container queries, custom variants.
when_to_use: You're authoring styles in a TailwindCSS v4 project. Frontend team pins this.
tags: [frontend, css, tailwind, versioned]
---
# TailwindCSS v4 (4.3.3)
## What changed vs v3
- **CSS-first config**. No `tailwind.config.js` by default. Theme tokens live in your CSS via `@theme { ... }`.
- **`@import "tailwindcss"`** replaces the three `@tailwind base/components/utilities` directives.
- **Zero-config content detection** — scans automatically via the Vite/PostCSS plugin, no `content: [...]` glob.
- **Native CSS variables everywhere** — every design token is a `--color-primary` style var, usable in raw CSS.
- **Container queries built-in** — `@container/name` variant, no plugin.
- **Faster** — Rust-powered engine (Oxide), ~100× faster incremental builds.
## Minimal setup
```css
/* app.css */
@import "tailwindcss";
@theme {
--color-brand-500: oklch(0.7 0.15 250);
--font-display: "Inter", ui-sans-serif;
}
```
Then in a component: `class="text-brand-500 font-display"`.
## Class-order convention
For each `class="..."` attribute, order tokens:
```
[layout] → [box] → [typography] → [color] → [state]
flex items-center gap-2 → w-full p-4 rounded-lg → text-sm font-semibold → text-white bg-brand-500 → hover:bg-brand-600 focus:outline-none
```
Prettier's `prettier-plugin-tailwindcss` enforces this — run it in CI.
## Design system integration (ShadCN)
- ShadCN 2.x ships v4-native components. No compat layer needed.
- Custom variants: define once in `@variant` and reuse:
```css
@variant hocus (&:hover, &:focus-visible);
/* class="hocus:bg-brand-500" */
```
- Prefer `data-[state=open]:...` variants for stateful ShadCN primitives.
## Container queries
```html
<div class="@container/card">
<p class="@md/card:text-lg text-sm">Adapts to card width, not viewport.</p>
</div>
```
- Name the container (`/card`) so nested containers don't cross-reference.
- Breakpoints: `@sm`, `@md`, `@lg`, `@xl` follow the same px values as viewport.
## Dark mode
`@media (prefers-color-scheme: dark)` is picked up automatically. For a manual toggle, use `dark:` variant + a class or attribute switch on `<html>` (`data-theme="dark"` + `@variant dark (&[data-theme="dark"] *)`).
## Anti-patterns
- **Bringing back `tailwind.config.js`** for one tweak. Use `@theme` in CSS instead.
- **Inline styles alongside utility classes** — pick one per element.
- **`@apply` in component CSS** — allowed but should be rare; utility class in JSX is usually clearer.
- **Arbitrary values everywhere** — `class="mt-[13px]"` is a code smell if it repeats. Promote to a theme token.
@@ -0,0 +1,66 @@
---
name: gpu-coalescing-and-occupancy
description: The two knobs that dominate GPU perf — memory coalescing + occupancy targets across CUDA (Blackwell), Metal (Apple 7+), ROCm (RDNA3/CDNA3).
when_to_use: You're a kernel_author or bench_engineer profiling a GPU kernel. GPU team pins.
tags: [gpu, cuda, metal, rocm, performance]
---
# Coalescing + occupancy
Two dominant knobs on ANY modern GPU. Get them right first; then worry about smaller wins.
## Coalescing
**Rule**: consecutive threads in a warp/wavefront should read consecutive 32/64/128-bit words.
- **CUDA (Blackwell, SM_100+)** — warp = 32 threads. Threads 0..31 accessing `arr[tid]` = 1 memory transaction. `arr[tid * stride]` for stride > 1 = multiple transactions. Violation cost: 4×–32× slowdown depending on stride.
- **Metal (Apple 7+, i.e., A17 / M3+)** — SIMD-group = 32 threads, same principle. `metal::simd_shuffle` for cross-lane comms without shared memory.
- **ROCm (RDNA3, CDNA3)** — wavefront = 32 (RDNA) or 64 (CDNA). Same rule; note the 64-wide wavefronts on CDNA change your indexing math.
Prove coalescing in a comment on the kernel:
```cpp
// Coalesced: thread tid reads global[tid], stride-1.
// Warp-level transaction: 1× 128-byte load per warp.
float x = global_in[gid];
```
## Occupancy
Rough targets:
- **Memory-bound kernel**: aim for **≥ 50% occupancy** to hide DRAM latency.
- **Compute-bound kernel with high ILP**: 25%–50% is often fine; more threads compete for registers + shared memory.
Occupancy is bounded by whichever hits first:
- Registers per thread × threads per block ≤ register file per SM.
- Shared / threadgroup memory per block ≤ per-SM budget.
- Threads per block ≤ max (typically 1024).
Tools:
- **Nsight Compute** (CUDA): `--section=Occupancy` prints the bottleneck.
- **Xcode GPU Frame Capture** (Metal): occupancy chart in the performance report.
- **rocprof** (ROCm): `--stats` for kernel occupancy.
## Memory hierarchy strategy
Order-of-magnitude comparison — always prefer the fastest tier your data footprint allows:
| Tier | CUDA name | Latency | Notes |
|---|---|---|---|
| Register | reg | ~1 cycle | Per-thread; too many spills to L1 |
| Shared | shared / threadgroup / LDS | ~30 cycles | Per-block. CUDA: 48–100 KB. Metal: 32 KB (Apple7+). ROCm: 64 KB LDS |
| L1 / texture | L1 tex | ~200 cycles | Cache; some HW has separate paths |
| L2 | L2 | ~500 cycles | Shared across SMs |
| Global | DRAM / HBM | 400–800 cycles | Coalescing matters MOST here |
## Divergence
- Warp-uniform control flow keeps all lanes active.
- `if (tid % 2)` splits the warp — half the lanes idle per branch.
- On CUDA Volta+ (7.0+), Independent Thread Scheduling makes divergence CORRECT under all conditions but doesn't make it FAST.
## The one bottleneck rule
After profiling, report ONE bottleneck to attack next. Reporting five doesn't help — you'll fix one and re-profile anyway.
Format:
> `KERNEL foo achieves 340 GB/s of 3.35 TB/s HBM = 10% BW-bound. Occupancy 62%. Bottleneck: stride-2 loads from arr — refactor tile shape to coalesce.`
+74
View File
@@ -0,0 +1,74 @@
---
name: roofline-model
description: The roofline model — is your kernel compute-bound or memory-bound? Estimate arithmetic intensity before optimizing.
when_to_use: You're the arch_analyst or bench_engineer role sizing up a GPU kernel before or after implementation.
tags: [gpu, performance, analysis]
---
# The roofline model
Before you optimize anything, know which wall you're hitting.
## The two rooflines
- **Peak memory bandwidth** — how many bytes/sec your GPU can pull from HBM.
- **Peak compute** — how many FLOPs/sec your GPU can execute.
Where they meet defines the **ridge point** (in FLOPs / byte). Kernels to the left of the ridge are memory-bound; to the right, compute-bound.
## Reference numbers (2026)
| Device | Peak BW (TB/s) | Peak FP32 (TFLOP/s) | FP16/BF16 tensor | Ridge (FLOP/B, FP32) |
|---|---|---|---|---|
| NVIDIA B200 (Blackwell) | 8.0 | 80 | ~2200 tensor | ~10 |
| NVIDIA H100 | 3.35 | 67 | 989 tensor | ~20 |
| AMD MI300X (CDNA3) | 5.3 | 163 | ~2600 tensor | ~31 |
| Apple M3 Max | 0.4 | 14 | — | ~35 |
*Rough figures — verify against the actual SKU. Ratios matter more than absolutes.*
## Arithmetic intensity
FLOPs performed per byte read from HBM. Compute it BEFORE writing the kernel:
```
GEMM (A: MxK, B: KxN, C: MxN, all FP32):
FLOPs = 2*M*N*K
Bytes = 4*(M*K + K*N + M*N) (naive, no tiling)
AI = 2*M*N*K / (4*(M*K + K*N + M*N))
For M=N=K=1024: AI ≈ 170 FLOP/B → compute-bound on H100
For M=N=1024, K=32: AI ≈ 15 FLOP/B → memory-bound on H100
```
## What the diagnosis tells you
**Memory-bound** — DON'T optimize the math. Reduce bytes:
- Fuse kernels to keep data in registers/shared.
- Use lower precision (FP16/BF16/INT8) if numerics allow.
- Better tiling to reuse loaded data.
- Coalesce (see [[gpu-coalescing-and-occupancy]]).
**Compute-bound** — DON'T optimize the loads. Feed the ALU:
- Use tensor cores (Nvidia) / matrix cores (AMD) / AMX / metal Simd matrix ops.
- Instruction-level parallelism (multiple independent FMAs per thread).
- Higher occupancy is often COUNTERPRODUCTIVE — you want registers, not more threads.
**Balanced (near the ridge)** — hardest. Small changes tip you into one regime or the other. Profile OFTEN.
## Reporting
Every kernel benchmark report includes:
```
Kernel: gemm_fp32_tiled
Achieved throughput: 52 TFLOP/s (78% of 67 TFLOP/s peak)
Achieved bandwidth: 280 GB/s (8% of 3.35 TB/s peak)
Arithmetic intensity: 180 FLOP/B
Regime: compute-bound
Bottleneck to attack: tensor core underutilization — WMMA fragment misaligned
```
## Anti-patterns
- **Optimizing math on a memory-bound kernel.** Doubling FLOPs while DRAM saturates changes nothing.
- **Micro-benchmarking without measuring HBM traffic.** Wall clock alone can't tell you which regime you're in.
- **Skipping roofline "because we already know it's slow".** You don't know the CEILING until you plot it.
+56
View File
@@ -0,0 +1,56 @@
---
name: expo-managed-vs-bare
description: Default to Expo managed workflow; drop to bare only for capabilities Expo hasn't shipped. Expo SDK 54+ (RN 0.86) baseline.
when_to_use: Starting or expanding an Expo/React Native app. Mobile team pins.
tags: [mobile, expo, react-native, versioned]
---
# Expo managed vs bare (SDK 54+, RN 0.86)
Anchored to **Expo SDK 54** (2026 line) shipping React Native 0.86 as of 2026-07. New Architecture is default; JSC is removed; Hermes-only.
## Managed by default
- One codebase, `expo prebuild` on demand to regenerate iOS/Android projects.
- OTA updates via EAS Update.
- Config Plugins system covers 95% of native customization without ejecting.
## When to drop to bare
- Custom native modules with no Expo Module wrapper (rare — Expo Modules API covers most cases now).
- Fork of React Native itself.
- Vendor SDK that ships only a `.aar` or `.xcframework` with a config that Expo Config Plugins can't express.
If none of the above, stay managed. `expo prebuild` gives you the native project when you need to poke it, without permanent ejection.
## SDK version alignment
- Expo SDK N ships against a specific RN version. Don't `yarn add react-native@latest` — use `expo install react-native` so you get the version the SDK bundles.
- Expo SDK is released ~quarterly; RN follows ~monthly. Follow Expo's calendar, not RN's.
- Native modules published to npm should declare `expo-modules-core` peer dep with a range covering the current + previous SDK.
## Config Plugins
- `app.config.ts` over `app.json` — you get typing + can compute values (env-branched dev/staging/prod).
- Plugins compose: `[plugins]: ["expo-notifications", ["./plugins/withCustomEntitlement", { key: "..." }]]`.
- Custom plugins live in `plugins/` and are typed by `ConfigPlugin<Props>`.
## EAS Build
- Two profiles minimum: `development` (dev client, sim + device), `production` (store submission).
- `eas.json` per-branch overrides for env vars; secrets go into EAS Secrets, never repo.
- **Fingerprint** (SDK 51+) determines rebuild need — if your JS-only change fingerprints identically, no native rebuild. Saves 15+ min per iteration.
## When Bare IS the answer
- Deep customization of the launcher / splash screen beyond `expo-splash-screen` capabilities.
- Multi-target apps (main + share extension + widget) where Expo's single-target model doesn't fit.
- Enterprise MDM constraints that require unusual entitlements.
If you go bare, keep the Expo dep chain (`expo`, `expo-modules-core`) — you still benefit from `expo-router`, `expo-notifications`, etc. Bare ≠ vanilla RN.
## Anti-patterns
- **Ejecting to bare because a StackOverflow answer said so.** Search for a Config Plugin first.
- **Mixing `react-native init` with `expo` deps.** Pick one project generator; don't try to retrofit.
- **Locking to a beta SDK for production.** Expo betas are for testing your app against the next SDK, not for shipping.
+64
View File
@@ -0,0 +1,64 @@
---
name: rn-flashlist-perf
description: React Native list performance — FlashList (Shopify) over FlatList for anything > 20 rows. Reanimated 3 for animations. New Architecture-aware.
when_to_use: You're rendering scrollable lists or animations in RN 0.86+. Mobile team pins.
tags: [mobile, react-native, performance]
---
# RN performance essentials
## Lists
- **FlashList** (`@shopify/flash-list` — 1.7+ for RN 0.86 New Architecture support) over `FlatList` for any list of 20+ rows. Recycles views instead of unmounting.
- **Estimated item size** is REQUIRED for FlashList perf. Measure a typical row height, plug in.
```tsx
<FlashList
data={items}
estimatedItemSize={72}
renderItem={({ item }) => <Row item={item} />}
keyExtractor={(i) => i.id}
/>
```
- **`getItemType`** for heterogeneous lists — FlashList recycles per type, so a section-header + row list gets 2 recycled pools.
- **`overrideItemLayout`** when items have known-in-advance heights that vary — skips measurement pass.
## Animations
- **Reanimated 3.x** for anything animating > 3× per frame (60+/s). Runs on the UI thread — no JS bridge bounce.
- **Never** `Animated` (the legacy API) for gesture-driven interactions. It goes through the JS bridge and jitters under load.
- **Worklets** for anything that reads a shared value + computes. Marked `"worklet"` at the top of the fn.
```tsx
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
// Update from JS: scale.value = withSpring(1.1);
```
## Gesture handling
- **`react-native-gesture-handler` v2.x** — required, not optional. Wrap `<GestureHandlerRootView>` at the app root.
- **`Gesture.Pan()`** etc. over `PanResponder`. Composes with Reanimated worklets natively.
## Image handling
- **`expo-image`** for any image where you'd have reached for `<Image>` — automatic caching, disk + memory tiers, format-optimal decoding.
- **Never** load a >1024px image directly. Resize server-side or via `expo-image-manipulator`.
- **Prefer WebP or AVIF** for static assets — smaller than PNG at same quality.
## Bridge crossings to avoid
- **`console.log` in prod builds** — goes through the bridge, has real cost. Strip with `babel-plugin-transform-remove-console`.
- **Anonymous fns in `renderItem`** — trigger reconciliation every render. Extract outside or `useCallback`.
- **Inline styles that create new objects every render** — same problem. Extract with `StyleSheet.create` or memoize.
## New Architecture caveats (default in SDK 54+)
- **Fabric renderer** — layout is synchronous. `onLayout` fires reliably.
- **Turbo Modules** — native calls are typed + can be sync where safe. If you own a native module and haven't migrated, do it (Codegen handles the JSI wrapper).
- **Some libraries still lag New Arch** — check the Fabric compat table; those still bounce through the bridge until they update.
## Anti-patterns
- **`ScrollView` with 200 hard-mounted children.** ScrollView renders all children up front — use FlashList always.
- **`setInterval` for animation.** Reanimated `withRepeat` runs on UI thread; setInterval jitters.
- **`InteractionManager.runAfterInteractions`** as a general delay tool. Its actual semantics are subtle; use `setTimeout(0)` if you just want "next tick".
@@ -0,0 +1,61 @@
---
name: cargo-test-driven-development
description: The `cargo nextest run` workflow for Rust TDD — fast, parallel, isolates flakes. Falls back to `cargo test` when nextest isn't installed.
when_to_use: You're the tester role, or coder role in the RED phase of TDD, on a Rust codebase.
tags: [rust, testing, tdd, versioned]
---
# Cargo TDD workflow
## Runner
**Primary: `cargo nextest run` (0.9.140 as of 2026-07).** Why over `cargo test`:
- Parallel process isolation — one panicking test doesn't taint others.
- Structured, greppable output.
- `--retries N` policy for flake-tolerance without hiding real failures.
- Per-test timeouts.
Install: `cargo install cargo-nextest --locked` in the runtime image; CI runs `cargo nextest run --profile ci`.
## The RED-GREEN loop (Rust flavor)
1. **RED — write the failing test.**
```rust
#[tokio::test]
async fn workspace_scoped_get_rejects_cross_workspace() {
let pool = testkit::pool().await;
let mine = seed_workspace(&pool).await;
let theirs = seed_workspace(&pool).await;
let row = insert_row(&pool, theirs).await;
assert!(get(&pool, row.id, mine).await.unwrap().is_none());
}
```
Run: `cargo nextest run workspace_scoped_get_rejects_cross_workspace`. Confirm the test fails because the fn doesn't exist yet or because behavior is missing.
2. **GREEN — minimal implementation.** Hardcode if that's what the ONE test asks for. Trust that the NEXT test will force you to generalize.
3. **REFACTOR under green.** Extract, rename, tighten. Re-run after each keystroke that could break something. `cargo nextest run <fn_name>` on the specific test is faster than the whole suite.
## Speed tricks
- **`cargo nextest run -j 8`** — parallelize across cores.
- **`cargo nextest run --no-fail-fast`** — see all failures in one pass, not just the first.
- **`cargo nextest run -E 'test(foo)'`** — filter expression language, more powerful than a substring.
- **`cargo check --tests`** before running — catches type errors ~10× faster than compiling test binaries.
## Coverage
- **`cargo llvm-cov nextest`** — nextest under llvm-cov instrumentation. Reports per-file line coverage.
- CI enforces ≥90% on changed lines. Locally: `cargo llvm-cov nextest --html && open target/llvm-cov/html/index.html`.
## Integration tests that hit real infra
- Postgres via `testcontainers` — spins a fresh container per test suite. NEVER mock the DB when the test's job is to prove a query.
- HTTP via `axum::Router` + `tower::ServiceExt::oneshot` — driver is in-process, no port needed.
- Docker via `bollard` mock or a `#[ignore]` guarded live test.
## Anti-patterns
- **Snapshot-only test files.** A snapshot proves nothing structural. Pair with real assertions.
- **`#[ignore]`d tests that stay ignored.** Ignore is a debt marker — file the ticket to fix and put the ticket ID in the ignore reason.
- **Testing panics with `should_panic` when a `Result::Err` would do.** Panics on the happy path of a test framework are user-hostile output.
+63
View File
@@ -0,0 +1,63 @@
---
name: rust-async-tokio-idioms
description: Tokio 1.x runtime idioms — spawn vs block_in_place, cancellation, channels, avoiding Arc<Mutex<T>> patterns.
when_to_use: You're writing async Rust code with Tokio. Applies to nearly all clawmates crates.
tags: [rust, async, tokio]
---
# Tokio async idioms
Anchored to **Tokio 1.x** (workspace tracks the latest 1.x line, currently ~1.42).
## Runtime choice
- **`#[tokio::main]`** in the binary. Default is multi-thread — fine.
- **`#[tokio::test]`** in tests. Default is current-thread (fast, deterministic). Add `flavor = "multi_thread"` when the code under test spawns.
- **NEVER** call `tokio::runtime::Handle::current().block_on(...)` inside async code — deadlocks in current-thread, breaks structured concurrency in multi-thread.
## Spawning
```rust
let handle = tokio::spawn(async move {
do_work().await
});
let result = handle.await??; // ?? = JoinError, then inner Result
```
- **`tokio::spawn`** for detached background work. Returns `JoinHandle`.
- **`tokio::task::spawn_blocking`** for CPU-heavy or blocking-syscall work (`std::fs`, `sync::Mutex`). Bounded pool — don't spawn thousands.
- **`tokio::task::spawn_local`** only inside a `LocalSet` — you almost never want this.
## Cancellation
- Dropping a `JoinHandle` DOES NOT cancel the task by default. Use `.abort()` or `CancellationToken`.
- Prefer `tokio_util::sync::CancellationToken` for structured cancel — pass the child token, call `parent.cancel()` at teardown.
- `tokio::select!` on `token.cancelled()` in every long-running loop.
## Channels
- **`mpsc::channel(cap)`** for actor-like state — one owner processes messages. Cap ≥ number of expected concurrent senders.
- **`oneshot::channel()`** for request/response. Sender is consumed on send.
- **`broadcast::channel(cap)`** for fan-out. Slow subscribers get `Lagged` errors — handle it.
- **`watch::channel(initial)`** for latest-value-wins state (config, health flags).
## Avoiding `Arc<Mutex<T>>`
Common trap: shared mutable state protected by a lock. Usually a smell.
- Immutable snapshot: `Arc<T>` is enough.
- Update-then-read cadence: `arc-swap::ArcSwap<T>` (lock-free).
- Actor-shaped: `mpsc::channel` + one task owning the state.
- Sync mutex needed AT ALL: use `std::sync::Mutex` (not `tokio::sync::Mutex`) for anything held < 1ms. Tokio's mutex is for holding across `.await`, which you should be actively avoiding.
## `.await` discipline
- Never `.await` inside a `std::sync::MutexGuard` scope — deadlock waiting to happen. Drop the guard first.
- Never `.await` inside a `tokio::task::block_in_place` block — it panics.
- `tokio::select! { biased; ... }` when you need branch order guarantees (usually shutdown-first).
## Anti-patterns
- **`futures::executor::block_on` inside a Tokio task.** Use `tokio::task::block_in_place` if you really need to sync-bridge.
- **`std::thread::sleep` in async code.** Use `tokio::time::sleep`.
- **Spawning from `Drop`.** `Drop` isn't async; the runtime may already be shutting down.
+67
View File
@@ -0,0 +1,67 @@
---
name: rust-error-handling
description: When to use `anyhow` vs `thiserror`, how to add context, when to panic. Rust 1.97+ idioms.
when_to_use: Any time you write a fn returning `Result` in Rust.
tags: [rust, errors]
---
# Rust error handling
## Which error type
- **Application binary** (`clawmates-server`, `clawmates-node`) → `anyhow::Result<T>` everywhere. You never match on the error at runtime; you print + log + exit.
- **Library crate** (`cm-db`, `cm-api`, `cm-runtime`) → `thiserror` on a per-module `Error` enum. Callers get typed variants they can match.
- **Test code** → `anyhow` is fine even in library crates. Tests don't match on error types.
## `thiserror` shape
```rust
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DbError {
#[error("row not found")]
NotFound,
#[error("workspace mismatch: expected {expected}, got {actual}")]
WorkspaceMismatch { expected: Uuid, actual: Uuid },
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
}
```
Rules:
- **`#[from]` for exactly one wrapped external type per variant.** Otherwise `?` becomes ambiguous.
- **`#[error("...")]` includes the values.** Never `#[error("db error")]` — that's information-lossy.
- **`#[error(transparent)]`** for pass-through wrappers where the inner error's message is already sufficient.
## Adding context
Every `?` at a module boundary needs context. Without it, a deeply-nested `sqlx::Error` is unattributable.
```rust
use anyhow::Context;
let user = load(pool, id)
.await
.with_context(|| format!("load user {id}"))?;
```
- **`.context("...")`** for a static string.
- **`.with_context(|| ...)`** when the message includes runtime data (allocation is lazy — only fires on error).
## When to panic
Panic is fine when:
- The invariant CAN'T be violated at runtime (`unreachable!("mission_kind is validated at insert time")`).
- You're in `main()` before the async runtime, and the config file is broken — `.expect("clawmates.toml is required")`.
- Test-only preconditions (`assert_eq!` inside `#[test]`).
Panic is NOT fine when:
- The input came from the network.
- The input came from a config parse.
- The input came from a database row that a migration is supposed to guarantee — write an `Error` variant explaining WHY the row shape violates the invariant.
## Backtrace hygiene
- Set `RUST_BACKTRACE=1` in dev + CI. Off in prod (leaks internals).
- `anyhow::Error` captures a backtrace automatically. Log it via `error!("op failed: {e:?}")` — the `?` formatter includes the chain + backtrace.
+53
View File
@@ -0,0 +1,53 @@
---
name: write-rust-current-edition
description: House Rust style anchored to edition 2024 + MSRV 1.97.1 (as of 2026-07). Idioms, patterns, and version-guarded features.
when_to_use: Writing any Rust code. Pin on rust_sdlc + backend + gpu coder roles.
tags: [rust, style, versioned]
---
# Writing Rust (edition 2024, MSRV 1.97.1)
## Toolchain we ship against
Version fields as of **2026-07**. Verify with `rustc --version` before assuming.
- **stable**: 1.97.1
- **workspace MSRV**: 1.97.1 (some crates require 1.98.0+; check `rust-toolchain.toml`)
- **edition**: 2024 (default for new crates; workspace-wide bump per project)
- **cargo tools we standardize on**: `cargo-nextest 0.9.140`, `cargo-audit 0.21+`, `cargo-llvm-cov 0.6+`, `cargo-machete`, `cargo-hakari` (for workspace unification)
## Idioms
- **`let-else` over deep nesting.**
```rust
let Some(user) = auth.load(id).await? else {
return Err(NotFound.into());
};
```
- **`?` with `.context()`** at every boundary (uses `anyhow::Context` or the equivalent). Bare `?` inside a private function is fine; at API surfaces, add context.
- **`if let` chains** (stable in edition 2024):
```rust
if let Some(x) = maybe && x > 0 && !seen.contains(&x) { ... }
```
Nest with `if let` blocks in edition-2021 crates until MSRV catches up.
- **Struct-of-args when a fn exceeds ~5 params** — pass an `Args<'a>` struct. Clippy's `too_many_arguments` enforces at 7.
- **`impl Trait` in argument position** for callbacks; `Box<dyn Trait>` only when erasure is genuinely needed.
- **`Arc<T>` over `Rc<T>`** anywhere `Send` might matter — you're almost always in a Tokio runtime.
## Anti-patterns
- **`unwrap()` in library code.** Only allowed in tests, in `main.rs` before the runtime spins up, and behind `debug_assert!`.
- **`.clone()` as a borrow-checker escape.** Ask why the borrow can't work first. If it genuinely can't, add a comment explaining the tradeoff.
- **`Arc<Mutex<T>>` when a channel would do.** For actor-like state, `tokio::sync::mpsc` is almost always the right answer.
- **`Box<Pin<Future>>` in public APIs** where `impl Future` would do — leaks implementation and forces heap alloc.
- **`#[allow(clippy::...)]` without a comment.** Every allow needs a one-line WHY.
## Testing
- **`cargo nextest run`** as the default runner — faster, better output, isolates flaky tests. Fall back to `cargo test` when nextest isn't installed.
- **`#[tokio::test]`** for async. Prefer `#[tokio::test(flavor = "multi_thread")]` when the code under test spawns.
- See [[cargo-test-driven-development]] for the TDD flow.
## Lint policy
Every Rust crate CI runs `cargo clippy --all-targets -- -D warnings`. Warnings ARE errors. Fix the warning; if it's a false positive, `#[allow(...)]` with a comment.
+58
View File
@@ -0,0 +1,58 @@
---
name: cargo-audit-workflow
description: `cargo-audit` (rustsec) — how to run it, triage findings, and integrate into the security template's mission flow.
when_to_use: You're on a security mission or investigating a RUSTSEC advisory in a Rust project.
tags: [security, rust, versioned]
---
# cargo-audit workflow
Anchored to **cargo-audit 0.21+** (2026 line) reading the [RustSec Advisory DB](https://rustsec.org/advisories/).
## Basic usage
```
cargo install cargo-audit --locked
cargo audit # scans Cargo.lock, reports vulnerabilities
cargo audit fetch # update advisory DB
cargo audit --deny warnings # non-zero on any warning too (yanked crates, unmaintained)
```
CI runs: `cargo audit --deny warnings --json` — JSON output goes to the mission's `mission_artifacts` as a `security_report`.
## Triage each finding
For every advisory the report surfaces:
1. **Is the vulnerable path reachable from OUR code?**
- `cargo tree -i <cratename>` shows who depends on it.
- Sometimes a vulnerable transitive dep is only exercised by a feature you don't enable — check the report's `affected_functions`.
2. **What versions fix it?**
- The advisory lists `patched_versions`. Try `cargo update -p <crate> --precise <version>` to hop to a patched minor without a semver-major bump.
- If patched only in a newer major, plan the migration (may be a multi-INT effort).
3. **Is there a `RUSTSEC` allowlist for a known-false-positive?**
- `[advisories.ignore]` in `audit.toml` — with a comment explaining WHY and a ticket link + expiry date.
- Never ignore without expiry; reviewers should re-evaluate quarterly.
## Common findings + fixes
- **`RUSTSEC-YYYY-NNNN` on a dev-dep**: less urgent, but fix anyway — dev tools run in CI with elevated permissions.
- **Yanked crate** — the version was pulled from crates.io. Update immediately; `cargo update -p <crate>` picks the next non-yanked.
- **Unmaintained crate warning** — no CVE yet, but the maintainer walked away. Long-term: replace. Short-term: pin and document.
## Integration with the security mission template
The security-hardening mission runs `cargo audit --json` in the security_scan phase. Each finding becomes a `mission_task` with:
- `external_id = "RUSTSEC-YYYY-NNNN"`
- `title = advisory.title`
- `status = "created"`
The coding phase picks up each finding as an INT-XX item and applies the fix (bump, replace, or add ignore-with-justification). Emit `COMPLETED: RUSTSEC-YYYY-NNNN` when the follow-up commit lands.
## Anti-patterns
- **Silencing an advisory in CI without a fix commit.** Advisories aren't a code style violation — they're a claim your code is exposed. Fix or explicitly accept.
- **Bumping to a beta version to "fix" it.** Betas can regress. Prefer a patched stable; if none exists, wait or fork.
- **Running `cargo audit` only in CI, never locally.** Every commit that changes Cargo.lock should trigger a local run.
@@ -0,0 +1,83 @@
---
name: secret-scanning-gitleaks
description: Detecting + removing leaked credentials with gitleaks 8+. Pre-commit + CI + full history sweep policies.
when_to_use: You're on a security mission, or setting up a new repo, or triaging a "did we push a key?" incident.
tags: [security, git]
---
# Gitleaks workflow
Anchored to **gitleaks 8.20+** (2026 line). Detects secrets in the working tree, staged changes, and git history.
## Modes
- **`gitleaks protect --staged`** — pre-commit hook. Blocks commits containing secrets.
- **`gitleaks detect`** — scans working tree + full history. Use in CI.
- **`gitleaks detect --log-opts="--since=2026-01-01"`** — bounded historical sweep for large repos.
## Pre-commit integration
```bash
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.20.0
hooks:
- id: gitleaks
```
## Config: `.gitleaks.toml`
```toml
title = "clawmates gitleaks config"
[extend]
useDefault = true # start from bundled rules
[[rules]]
id = "clawmates-internal-token"
description = "Our internal service session token pattern"
regex = '''cm_svc_[a-f0-9]{64}'''
tags = ["clawmates", "token"]
[allowlist]
description = "Test fixtures that intentionally contain fake secrets"
paths = ['''crates/cm-auth/tests/fixtures/.*''']
regexes = ['''sk-ant-api03-TEST[a-zA-Z0-9]+''']
```
Rules to add per-repo, not global — every codebase has domain-specific token shapes.
## What to do when it fires
**On a commit-in-progress:**
1. Don't commit.
2. Rotate the credential immediately (whether you'd have pushed it or not — assume compromised the moment it existed on disk).
3. Remove from the file. Re-attempt.
**On a historical hit (already pushed):**
1. Rotate first, panic second.
2. Force-remove from history with `git filter-repo --replace-text` or `bfg-repo-cleaner`.
3. `git push --force` after coordination with everyone with a clone (they'll need to `git reset --hard origin/main` to avoid re-introducing).
4. Notify anyone who might have cached the value (CI logs, artifact registries, error monitoring backends often store request bodies).
## What gitleaks misses
- **Base64-encoded secrets.** Add a rule if you use them.
- **Split-across-lines secrets** — most rules are single-line regex.
- **Secrets in binary files.** Gitleaks skips binaries by default; scan artifacts separately.
- **Encrypted-at-rest formats** (age, sops) — encrypted content is opaque to gitleaks; make sure the ENCRYPTED file being in the repo is the intent.
## Integration with the security mission template
Runs `gitleaks detect --report-format=json` in the security_scan phase. Each finding becomes a `mission_task`:
- `external_id = <fingerprint>`
- `title = "<RuleID> at <file>:<line>"`
- `status = "created"`
The coding phase remediates: rotate + purge + add allowlist entry if a false positive. Emit `COMPLETED: <fingerprint>` when the rotation is confirmed AND the historical removal is deployed.
## Anti-patterns
- **Adding a blanket `.gitleaks.toml` allowlist** covering entire directories. Neuters detection. Allowlist specific patterns with justification comments.
- **Fixing the secret leak by squashing history without rotating.** The secret was on disk on multiple machines; it's compromised. Rotate first.
- **`.gitignore` as your secret defense.** Files can be added by name from the CLI; gitignore only helps `git add .` sweeps.
@@ -0,0 +1,82 @@
---
name: threejs-perf-and-teardown
description: three.js r185 essentials — 60fps budget, InstancedMesh over per-node Meshes, dispose everything on scene teardown.
when_to_use: You're coding or profiling a three.js/WebGL/WebGPU scene. threeJS team pins.
tags: [threejs, webgl, webgpu, performance, versioned]
---
# three.js perf + teardown (r185)
Anchored to **three.js r185** (2026-07). WebGPURenderer is stable enough for production on Chromium/Safari; WebGL2 renderer still the default fallback.
## Frame budget
Target **16.6 ms/frame** (60 fps) on the median target device. Break it down:
- JS + scene graph updates: ≤ 4 ms
- GPU submission: ≤ 4 ms
- GPU rendering (browser waits): remainder
If you can't hit 60 fps at 1440p on a mid-range GPU, DON'T ship as-is. Downgrade features (fewer shadow casters, lower shadow map res, LOD swaps) before shipping.
## Draw call budget
- Target **≤ 200 draw calls per frame**. Every distinct material × geometry combo = ≥ 1 draw call.
- **InstancedMesh** for any repeated geometry. 10,000 trees = 1 draw call, not 10,000.
- **BatchedMesh** (added in r168+, matured through r185) for heterogeneous geometry with shared material — batch different meshes with one call.
## Memory teardown
**The #1 cause of "why does the tab crash after 20 minutes"**. GPU resources are NOT garbage collected — you must dispose.
```ts
function teardown(scene: THREE.Scene) {
scene.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
const m = obj as THREE.Mesh;
m.geometry.dispose();
if (Array.isArray(m.material)) m.material.forEach((x) => x.dispose());
else m.material.dispose();
}
});
renderer.dispose(); // WebGL context + programs
renderer.forceContextLoss(); // guarantee GPU release, not deferred
}
```
Textures must ALSO be disposed — walk materials and dispose any `map`, `normalMap`, `roughnessMap`, etc.
## Render-loop discipline
- **Reuse math objects.** Never `new THREE.Vector3()` inside the render loop.
```ts
const _tmp = new THREE.Vector3(); // module-level
function tick() {
_tmp.copy(a).sub(b).normalize(); // no allocation
}
```
- Same for `Matrix4`, `Quaternion`, `Color`.
- If you MUST allocate, batch outside the loop.
## Shadows
- **One directional shadow-caster.** Bake everything else via lightmaps or ambient occlusion.
- Shadow map resolution: 1024 for medium range, 2048 max. 4096 is a mobile-crash-in-a-can.
- `light.shadow.autoUpdate = false; light.shadow.needsUpdate = true;` when the shadow is static — one render + freeze.
## Custom shaders
- Try `ShaderMaterial` when built-ins get close but not exact.
- `onBeforeCompile` hook for tweaking a built-in when it's 95% right — patch the shader source instead of rewriting.
- WebGPU: WGSL, not GLSL. Migrate shader-by-shader as you adopt WebGPURenderer.
## Profiling
- **Chrome DevTools Performance panel** — JS time.
- **SpectorJS** browser extension — per-frame draw call breakdown, texture inspector, shader debugger.
- **WebGL / WebGPU inspector** in Firefox — similar to Spector.
## Anti-patterns
- **`new THREE.Mesh(geometry, material)` inside `requestAnimationFrame`.** Creates 60 mesh objects per second; profile-visible before you notice.
- **Adding then removing lights on interaction.** Recompiles shaders (cache-miss). Toggle intensity to 0 instead.
- **`renderer.setPixelRatio(window.devicePixelRatio)` on a 3× retina display without a quality slider.** Renders 9× the pixels; kills fps. Cap at 2 by default.