diff --git a/.claude/skills/visual-recap/SKILL.md b/.claude/skills/visual-recap/SKILL.md
new file mode 100644
index 0000000..deb1c91
--- /dev/null
+++ b/.claude/skills/visual-recap/SKILL.md
@@ -0,0 +1,262 @@
+---
+name: visual-recap
+description:
+ Generate and maintain the system recap block in a PR description - a
+ GitHub-rendered visual summary of which compiler pipeline primitives a
+ change touches, how risky it is, and what changed. Use when planning a
+ non-trivial change (plan mode), when creating or updating a pull request
+ (recap mode), or when the user asks for a visual recap, visual plan, system
+ review, or PR recap.
+---
+
+# System recap (visual plan / visual recap)
+
+Produce a high-altitude, visual review aid directly in the PR description. No
+deployment, no third-party service: GitHub renders the block (including mermaid
+diagrams), and the PR itself is the storage. A future viewer app can ingest the
+same marker-delimited block via the GitHub API, so follow the format exactly.
+
+The recap is informational and non-blocking. It supplements the PR description
+and normal code review; it never replaces reading the diff.
+
+## Two modes, one format
+
+- **Plan mode** (before/while implementing): describe the intended change
+ against the current system. If no PR exists yet, put the block in the plan
+ document or message; move it into the PR description once the PR exists.
+- **Recap mode** (PR creation and every meaningful update): describe what the
+ diff actually does. Replaces a plan-mode block if one exists.
+
+## Source-of-truth rules (non-negotiable)
+
+1. **Recap mode reads the diff, not memory.** Generate the recap from
+ `git diff ...HEAD` (plus `git diff --stat`) against the PR base branch
+ (`main`). Session context may explain intent, but every claim about what
+ changed must be checkable against the diff.
+2. **Classification uses the primitives taxonomy.** Read
+ [`docs/architecture/primitives.yaml`](../../../docs/architecture/primitives.yaml)
+ for stable `id` / `name` / `group` values. Prefer the classifier script over
+ hand-matching paths:
+
+ ```bash
+ node .claude/skills/visual-recap/scripts/classify-primitives.mjs --base --head HEAD
+ # or: git diff --name-only ...HEAD | node .../classify-primitives.mjs --stdin --json
+ ```
+
+3. **The taxonomy is not a feature changelog.** Update `primitives.yaml` only
+ when this PR **adds, removes, or materially reshapes** a primitive (new `id`,
+ renamed meaning, or code roots that must change) — e.g. adding a new
+ compilation stage, splitting the binder into two components, or renaming a
+ module. Do **not** edit `summary` for ordinary feature work — a new
+ language feature that lives inside the existing binder/parser/emitter is
+ still `extends`, not a taxonomy change. After editing the map, run:
+
+ ```bash
+ node .claude/skills/visual-recap/scripts/check-primitives-map.mjs
+ ```
+
+## Risk classification
+
+Classify each touched primitive, then roll up to the highest severity as the
+overall classification (`adds` > `extends` > `composes`):
+
+| Classification | Meaning | Risk |
+| -------------- | ---------------------------------------------------------- | ------ |
+| `composes` | Uses existing primitives as-is; wiring and call sites only | Low |
+| `extends` | Changes a primitive's behavior, shape, or contract | Medium |
+| `adds` | Introduces a new primitive (must update primitives.yaml) | High |
+
+A change touching an invariant from `primitives.yaml` (for example
+`transpile-sync` or `pncs-is-canonical`) is called out explicitly regardless of
+classification — these are the things reviewers most often miss (e.g. a PR
+that edits `pncs/src/main/scala/Binder.scala` but forgets to run
+`sbt pncs/transpile` and commit the regenerated `pnc/src/Binder.pn`).
+
+The classifier reports which primitives' `code` roots the diff touches; you
+still decide `composes` vs `extends` from the diff (and `adds` when you create a
+new map entry).
+
+## Block format
+
+The block lives in the PR description between HTML comment markers, wrapped in
+``. Fixed section order — a future ingestion process parses this
+structure. Omit optional sections rather than leaving them empty.
+
+````markdown
+
+
+
+System recap — extends existing primitives (medium risk)
+
+**Mode:** recap · **Base:** `main` @ `abc1234` · **Head:** `def5678`
+
+**Classification:** extends — bidirectional inference gains expected-type
+propagation into match arms; no new primitives.
+
+### Primitives touched
+
+| Primitive | Group | Impact |
+| --------- | --------- | ------------------------------------------------ |
+| `binder` | semantics | extends — expected-type flows into match arms |
+| `test-suite` | quality | composes — new TypeTests cases |
+
+### System map
+
+_One sentence: what this diagram shows and the main path through the change._
+
+**Legend:** green = composes (wiring only) · amber = extended by this PR · red =
+new primitive · gray = context (unchanged, included only when an edge crosses
+it).
+
+```mermaid
+flowchart LR
+ parser["parser
Parser & syntax tree"]:::untouched
+ binder["binder
Binder & type inference"]:::extended
+ testSuite["test-suite
Test suite"]:::touched
+ parser -->|"match expression AST"| binder
+ binder -->|"new TypeTests cases"| testSuite
+ classDef touched fill:#1a7f37,color:#fff
+ classDef extended fill:#9a6700,color:#fff
+ classDef added fill:#cf222e,color:#fff
+ classDef untouched fill:#57606a,color:#fff
+```
+
+### Change flow
+
+_Optional: a mermaid flowchart or sequence diagram of the specific change._
+
+### Before / after
+
+_Optional: bytecode/AST shape, CLI flag, or diagnostic message changes as
+compact before/after fenced blocks or tables._
+
+### Invariants
+
+_Optional: only when the change touches an invariant from primitives.yaml —
+e.g. note explicitly that `pnc/src/Binder.pn` was regenerated via
+`sbt pncs/transpile` and committed alongside `Binder.scala`._
+
+### Plan vs actual
+
+_Recap mode only, when a plan-mode block existed: what shipped as planned and
+what drifted, in a short list._
+
+
+
+
+````
+
+Format rules:
+
+- The `` line always carries the overall classification and risk in
+ bold so reviewers see it without expanding.
+- Blank line after `` and around every fenced block, or GitHub will not
+ render the markdown/mermaid inside ``.
+- **System map**: a PR-scoped **change map** — how touched primitives interact
+ _because of this diff_, not the full compiler pipeline. Rules:
+ - Open with one sentence naming the main flow (e.g. "Match-expression type
+ checking flows from the parser through the binder into the new lowering
+ case.").
+ - Include the **legend** line (colors and what they mean) directly above the
+ diagram, using the fixed wording from the template.
+ - Include only primitives the diff touches plus neighbors needed to show a
+ crossing edge — not all 13 nodes, and no gray context nodes unless this PR
+ actually calls through them.
+ - **Label every edge** with what this PR does across that boundary: which
+ AST/bound-tree shape crosses it, which opcode or metadata table, which CLI
+ flag, which invariant. Unlabeled arrows are forbidden; they read as
+ meaningless topology and are the main failure mode reviewers report.
+ - Node labels: primitive `id` plus the `name` from `primitives.yaml` on a
+ second line via `
` (e.g. `binder["binder
Binder & type inference"]`).
+ - Color nodes with the four `classDef` styles (`touched` = composes,
+ `extended`, `added`, `untouched` for context-only nodes).
+ - Prefer fewer, labeled edges over chaining unlabeled `A --> B --> C` hops.
+ Split long chains only when each hop has its own label.
+ - Quote node labels containing spaces or special characters.
+ - When a sequence diagram already in **Change flow** covers the same path, the
+ system map may omit duplicate edges — but still include cross-cutting hops
+ (metadata format, transpile output, VM) that the sequence diagram skips.
+- Keep the whole block scannable: prefer tables and diagrams over prose, and
+ keep it well under ~120 lines.
+
+## Workflow
+
+### Recap mode (PR create/update)
+
+1. Resolve base/head (`gh pr view --json baseRefName,headRefName`).
+2. Classify paths:
+
+ ```bash
+ node .claude/skills/visual-recap/scripts/classify-primitives.mjs --base --head HEAD --json
+ ```
+
+3. Read the full diff for anything you did not author this session; decide
+ composes/extends/adds per matched primitive (and note important unmatched
+ paths if they introduce a new surface — e.g. a brand-new top-level module
+ that doesn't fit any existing `code` root).
+4. Author the block following the format above. Use map `name` for diagram
+ labels; pull behavioral detail from the diff, not by rewriting map
+ summaries.
+5. Upsert it into the PR description:
+
+ ```bash
+ node .claude/skills/visual-recap/scripts/upsert-recap-block.mjs
+ ```
+
+ The script replaces the content between the markers, or appends the block to
+ the end of the description on first run. It never touches text outside the
+ markers.
+
+6. Re-run steps 2-5 after pushing significant new commits to the PR.
+
+### Plan mode
+
+Same steps, except: `**Mode:** plan`, no Base/Head commits required, "Primitives
+touched" describes intended impact, and add a one-line note when the plan
+requires **no** change to any primitive — that is the lowest-risk outcome and
+worth stating explicitly. When implementation later diverges from the plan, the
+recap's "Plan vs actual" section records the drift.
+
+## System map example (weak vs strong)
+
+The diagram must explain **this PR's** crossings, not restate the static
+pipeline. Compare:
+
+**Weak** (unlabeled topology — reviewers cannot tell what the arrows mean):
+
+```mermaid
+flowchart LR
+ parser["parser"]:::touched
+ binder["binder"]:::extended
+ loweringEmit["lowering-emit"]:::extended
+ parser --> binder --> loweringEmit
+```
+
+**Strong** (intro, legend, human names, labeled edges):
+
+Match-expression type checking flows from the parser's new pattern-binding AST
+node through the binder's bidirectional inference into the lowering stage that
+emits the branch dispatch.
+
+**Legend:** green = composes · amber = extended by this PR · red = new primitive
+· gray = context.
+
+```mermaid
+flowchart LR
+ parser["parser
Parser & syntax tree"]:::extended
+ binder["binder
Binder & type inference"]:::extended
+ loweringEmit["lowering-emit
Lowering & bytecode emission"]:::extended
+ testSuite["test-suite
Test suite"]:::touched
+ parser -->|"new pattern-binding AST node"| binder
+ binder -->|"expected-type flows into match arms"| loweringEmit
+ testSuite -->|"new TypeTests + VmTests cases"| binder
+ classDef touched fill:#1a7f37,color:#fff
+ classDef extended fill:#9a6700,color:#fff
+ classDef added fill:#cf222e,color:#fff
+ classDef untouched fill:#57606a,color:#fff
+```
+
+Derive edge labels from the diff (AST/bound-tree shapes, opcodes, metadata
+tables, CLI flags, invariants). The **Change flow** sequence diagram can stay
+for compilation-pass ordering; the system map answers "which primitives does
+this PR connect, and how?"
diff --git a/.claude/skills/visual-recap/scripts/check-primitives-map.mjs b/.claude/skills/visual-recap/scripts/check-primitives-map.mjs
new file mode 100644
index 0000000..8b873ed
--- /dev/null
+++ b/.claude/skills/visual-recap/scripts/check-primitives-map.mjs
@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+// Validate that every code/docs path in docs/architecture/primitives.yaml
+// resolves on disk. Run after editing the map (adding/removing/reshaping a
+// primitive) — see SKILL.md's source-of-truth rules.
+import { checkPrimitivesMapPaths, loadPrimitivesMap } from './primitives-map.mjs'
+
+const map = loadPrimitivesMap()
+const errors = checkPrimitivesMapPaths(map)
+
+if (errors.length === 0) {
+ console.log(
+ `OK: ${map.primitives.length} primitive(s), ${map.invariants.length} invariant(s), all code/docs paths resolve.`,
+ )
+ process.exit(0)
+}
+
+console.error(`${errors.length} problem(s) in docs/architecture/primitives.yaml:`)
+for (const error of errors) {
+ console.error(` [${error.kind} ${error.id}] ${error.path}: ${error.reason}`)
+}
+process.exit(1)
diff --git a/.claude/skills/visual-recap/scripts/classify-primitives.mjs b/.claude/skills/visual-recap/scripts/classify-primitives.mjs
new file mode 100644
index 0000000..321936d
--- /dev/null
+++ b/.claude/skills/visual-recap/scripts/classify-primitives.mjs
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+/**
+ * Classify changed paths against the primitives taxonomy.
+ *
+ * Usage:
+ * node classify-primitives.mjs [--base [] [--head ][]
+ * git diff --name-only main...HEAD | node classify-primitives.mjs --stdin
+ *
+ * Defaults: base=main, head=HEAD (git diff --name-only base...head).
+ */
+import { execFileSync } from 'node:child_process'
+import { createInterface } from 'node:readline'
+import { classifyPaths, loadPrimitivesMap } from './primitives-map.mjs'
+
+const args = process.argv.slice(2)
+
+function readFlag(name, fallback) {
+ const index = args.indexOf(name)
+ if (index === -1) return fallback
+ const value = args[index + 1]
+ if (!value || value.startsWith('--')) {
+ console.error(`missing value for ${name}`)
+ process.exit(1)
+ }
+ return value
+}
+
+async function readStdinPaths() {
+ const paths = []
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
+ for await (const line of rl) {
+ const trimmed = line.trim()
+ if (trimmed) paths.push(trimmed)
+ }
+ return paths
+}
+
+function readGitPaths(base, head) {
+ const output = execFileSync(
+ 'git',
+ ['diff', '--name-only', `${base}...${head}`],
+ { encoding: 'utf8' },
+ )
+ return output
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+}
+
+const useStdin = args.includes('--stdin')
+const jsonOut = args.includes('--json')
+const base = readFlag('--base', 'main')
+const head = readFlag('--head', 'HEAD')
+
+const paths = useStdin ? await readStdinPaths() : readGitPaths(base, head)
+const map = loadPrimitivesMap()
+const { matched, unmatched } = classifyPaths(paths, map)
+
+if (jsonOut) {
+ console.log(
+ JSON.stringify(
+ {
+ base: useStdin ? null : base,
+ head: useStdin ? null : head,
+ pathCount: paths.length,
+ matched: matched.map((entry) => ({
+ id: entry.primitive.id,
+ name: entry.primitive.name,
+ group: entry.primitive.group ?? null,
+ root: entry.root,
+ files: entry.files,
+ })),
+ unmatched,
+ },
+ null,
+ 2,
+ ),
+ )
+ process.exit(0)
+}
+
+if (paths.length === 0) {
+ console.log('No changed paths.')
+ process.exit(0)
+}
+
+console.log(
+ useStdin
+ ? `Classified ${paths.length} path(s) from stdin:\n`
+ : `Classified ${paths.length} path(s) from ${base}...${head}:\n`,
+)
+
+for (const entry of matched) {
+ console.log(
+ `- ${entry.primitive.id} (${entry.primitive.group ?? 'ungrouped'}) via ${entry.root}`,
+ )
+ for (const file of entry.files.slice(0, 8)) {
+ console.log(` ${file}`)
+ }
+ if (entry.files.length > 8) {
+ console.log(` … +${entry.files.length - 8} more`)
+ }
+}
+
+if (unmatched.length > 0) {
+ console.log(`\nUnmatched (${unmatched.length}):`)
+ for (const file of unmatched.slice(0, 20)) {
+ console.log(` ${file}`)
+ }
+ if (unmatched.length > 20) {
+ console.log(` … +${unmatched.length - 20} more`)
+ }
+}
diff --git a/.claude/skills/visual-recap/scripts/primitives-map.mjs b/.claude/skills/visual-recap/scripts/primitives-map.mjs
new file mode 100644
index 0000000..c9675a5
--- /dev/null
+++ b/.claude/skills/visual-recap/scripts/primitives-map.mjs
@@ -0,0 +1,336 @@
+/**
+ * Shared parser + path classifier for docs/architecture/primitives.yaml.
+ *
+ * Constrained YAML subset (version/groups/primitives/invariants with scalar fields
+ * and string lists). Not a general YAML parser.
+ */
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+export const defaultMapPath = resolve(
+ import.meta.dirname,
+ '../../../../docs/architecture/primitives.yaml',
+)
+
+/**
+ * @typedef {{
+ * id: string
+ * name: string
+ * group?: string
+ * summary?: string
+ * code: Array
+ * docs: Array
+ * }} Primitive
+ */
+
+/**
+ * @typedef {{
+ * version: number
+ * groups: Array<{ id: string, name: string }>
+ * primitives: Array
+ * invariants: Array
+ * }} PrimitivesMap
+ */
+
+/**
+ * @param {string} text
+ * @returns {PrimitivesMap}
+ */
+export function parsePrimitivesMap(text) {
+ const lines = text.split(/\r?\n/)
+ /** @type {PrimitivesMap} */
+ const map = {
+ version: 1,
+ groups: [],
+ primitives: [],
+ invariants: [],
+ }
+
+ /** @type {'root' | 'groups' | 'primitives' | 'invariants'} */
+ let section = 'root'
+ /** @type {Primitive | { id: string, name: string } | null} */
+ let current = null
+ /** @type {'code' | 'docs' | null} */
+ let listField = null
+
+ function finishCurrent() {
+ if (!current || !('id' in current)) return
+ if (section === 'groups' && 'name' in current) {
+ map.groups.push({ id: current.id, name: current.name })
+ } else if (section === 'primitives') {
+ const primitive = /** @type {Primitive} */ (current)
+ primitive.code ??= []
+ primitive.docs ??= []
+ map.primitives.push(primitive)
+ } else if (section === 'invariants') {
+ const invariant = /** @type {Primitive} */ (current)
+ invariant.code ??= []
+ invariant.docs ??= []
+ map.invariants.push(invariant)
+ }
+ current = null
+ listField = null
+ }
+
+ for (const rawLine of lines) {
+ const commentIndex = rawLine.indexOf('#')
+ const line =
+ commentIndex === -1
+ ? rawLine
+ : // Keep `#` inside quoted scalars; our map never quotes.
+ /^\s*#/.test(rawLine)
+ ? ''
+ : rawLine
+ if (!line.trim()) continue
+
+ const sectionMatch = line.match(/^(groups|primitives|invariants):\s*$/)
+ if (sectionMatch) {
+ finishCurrent()
+ section = /** @type {'groups' | 'primitives' | 'invariants'} */ (
+ sectionMatch[1]
+ )
+ continue
+ }
+
+ const versionMatch = line.match(/^version:\s+(\d+)\s*$/)
+ if (versionMatch) {
+ map.version = Number(versionMatch[1])
+ continue
+ }
+
+ const itemMatch = line.match(/^ {2}- id:\s+(.+?)\s*$/)
+ if (itemMatch) {
+ finishCurrent()
+ current = {
+ id: unquote(itemMatch[1]),
+ name: '',
+ code: [],
+ docs: [],
+ }
+ listField = null
+ continue
+ }
+
+ if (!current) continue
+
+ const listStart = line.match(/^ {4}(code|docs):\s*$/)
+ if (listStart) {
+ listField = /** @type {'code' | 'docs'} */ (listStart[1])
+ continue
+ }
+
+ const listItem = line.match(/^ {6}- (.+?)\s*$/)
+ if (listItem && listField && 'code' in current) {
+ const value = unquote(listItem[1])
+ if (listField === 'code') current.code.push(value)
+ else current.docs.push(value)
+ continue
+ }
+
+ const emptySummary = line.match(/^ {4}summary:\s*$/)
+ if (emptySummary) {
+ listField = null
+ current.summary = ''
+ continue
+ }
+
+ const summaryContinuation = line.match(/^ {6}(.+?)\s*$/)
+ if (
+ summaryContinuation &&
+ listField === null &&
+ 'code' in current &&
+ current.summary === ''
+ ) {
+ current.summary = unquote(summaryContinuation[1])
+ continue
+ }
+
+ const fieldMatch = line.match(/^ {4}(group|name|summary):\s+(.+?)\s*$/)
+ if (fieldMatch) {
+ listField = null
+ const key = fieldMatch[1]
+ const value = unquote(fieldMatch[2])
+ if (key === 'group') current.group = value
+ else if (key === 'name') current.name = value
+ else current.summary = value
+ continue
+ }
+ }
+
+ finishCurrent()
+ return map
+}
+
+/**
+ * @param {string} value
+ */
+function unquote(value) {
+ if (
+ (value.startsWith("'") && value.endsWith("'")) ||
+ (value.startsWith('"') && value.endsWith('"'))
+ ) {
+ return value.slice(1, -1)
+ }
+ return value
+}
+
+/**
+ * @param {string} filePath
+ * @param {string} root
+ */
+export function pathMatchesRoot(filePath, root) {
+ const normalizedFile = filePath.replaceAll('\\', '/')
+ const isDirRoot = root.endsWith('/')
+ const normalizedRoot = root.replaceAll('\\', '/').replace(/\/$/, '')
+ if (!normalizedRoot) return false
+ if (isDirRoot) {
+ return (
+ normalizedFile === normalizedRoot ||
+ normalizedFile.startsWith(`${normalizedRoot}/`)
+ )
+ }
+ return (
+ normalizedFile === normalizedRoot ||
+ normalizedFile.startsWith(normalizedRoot)
+ )
+}
+
+/**
+ * Longest-prefix match: each path maps to the primitive(s) whose matching
+ * `code` root is longest. Equal-length ties return every tied primitive.
+ *
+ * @param {Array} paths
+ * @param {PrimitivesMap} map
+ */
+export function classifyPaths(paths, map) {
+ /** @type {Map }>} */
+ const byId = new Map()
+ /** @type {Array} */
+ const unmatched = []
+
+ for (const rawPath of paths) {
+ const filePath = rawPath.replaceAll('\\', '/').replace(/^\.\//, '')
+ if (!filePath || filePath.endsWith('/')) continue
+
+ /** @type {Array<{ primitive: Primitive, root: string }>} */
+ const matches = []
+ for (const primitive of map.primitives) {
+ for (const root of primitive.code) {
+ if (pathMatchesRoot(filePath, root)) {
+ matches.push({ primitive, root })
+ }
+ }
+ }
+
+ if (matches.length === 0) {
+ unmatched.push(filePath)
+ continue
+ }
+
+ const maxLen = Math.max(...matches.map((match) => match.root.length))
+ const winners = matches.filter((match) => match.root.length === maxLen)
+ const seen = new Set()
+ for (const winner of winners) {
+ if (seen.has(winner.primitive.id)) continue
+ seen.add(winner.primitive.id)
+ const existing = byId.get(winner.primitive.id)
+ if (existing) {
+ existing.files.push(filePath)
+ } else {
+ byId.set(winner.primitive.id, {
+ primitive: winner.primitive,
+ root: winner.root,
+ files: [filePath],
+ })
+ }
+ }
+ }
+
+ const matched = [...byId.values()].sort((a, b) =>
+ a.primitive.id.localeCompare(b.primitive.id),
+ )
+ return { matched, unmatched }
+}
+
+/**
+ * @param {string} [mapPath]
+ */
+export function loadPrimitivesMap(mapPath = defaultMapPath) {
+ return parsePrimitivesMap(readFileSync(mapPath, 'utf8'))
+}
+
+/**
+ * Validate that every `code` and `docs` path resolves on disk.
+ * Directory roots may end with `/`. Prefix roots (no trailing `/`) must match
+ * at least one existing file or directory under the repo root.
+ *
+ * @param {PrimitivesMap} map
+ * @param {string} [repoRoot]
+ */
+export function checkPrimitivesMapPaths(
+ map,
+ repoRoot = resolve(import.meta.dirname, '../../../..'),
+) {
+ /** @type {Array<{ kind: string, id: string, path: string, reason: string }>} */
+ const errors = []
+
+ /**
+ * @param {Primitive} entry
+ * @param {'primitive' | 'invariant'} kind
+ */
+ function checkEntry(entry, kind) {
+ for (const docPath of entry.docs) {
+ const absolute = resolve(repoRoot, docPath)
+ if (!existsSync(absolute) || !statSync(absolute).isFile()) {
+ errors.push({
+ kind,
+ id: entry.id,
+ path: docPath,
+ reason: 'docs path missing or not a file',
+ })
+ }
+ }
+
+ for (const codePath of entry.code) {
+ const absolute = resolve(repoRoot, codePath.replace(/\/$/, ''))
+ if (existsSync(absolute)) continue
+
+ // Prefix roots (e.g. handlers/community, app/oauth-) must hit something.
+ if (!codePath.endsWith('/')) {
+ const parent = resolve(
+ repoRoot,
+ codePath.split('/').slice(0, -1).join('/'),
+ )
+ const prefix = codePath.split('/').at(-1) ?? ''
+ if (existsSync(parent) && statSync(parent).isDirectory()) {
+ const hit = readdirSafe(parent).some((name) =>
+ name.startsWith(prefix),
+ )
+ if (hit) continue
+ }
+ }
+
+ errors.push({
+ kind,
+ id: entry.id,
+ path: codePath,
+ reason: 'code root missing on disk',
+ })
+ }
+ }
+
+ for (const primitive of map.primitives) checkEntry(primitive, 'primitive')
+ for (const invariant of map.invariants) checkEntry(invariant, 'invariant')
+ return errors
+}
+
+/**
+ * @param {string} dir
+ */
+function readdirSafe(dir) {
+ try {
+ return readdirSync(dir)
+ } catch {
+ return []
+ }
+}
diff --git a/.claude/skills/visual-recap/scripts/upsert-recap-block.mjs b/.claude/skills/visual-recap/scripts/upsert-recap-block.mjs
new file mode 100644
index 0000000..dac8baf
--- /dev/null
+++ b/.claude/skills/visual-recap/scripts/upsert-recap-block.mjs
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+// Upsert a system-recap block into a PR description without touching any
+// text outside the markers. Usage:
+// node upsert-recap-block.mjs
+// The block file must start with the start marker and end with the end
+// marker. Requires the `gh` CLI to be authenticated.
+import { execFileSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+
+const startMarker = ''
+const endMarker = ''
+
+const [prNumber, blockFile] = process.argv.slice(2)
+if (!prNumber || !blockFile) {
+ console.error('usage: upsert-recap-block.mjs ')
+ process.exit(1)
+}
+
+const block = readFileSync(blockFile, 'utf8').trim()
+if (!block.startsWith(startMarker) || !block.endsWith(endMarker)) {
+ console.error(
+ `block file must start with "${startMarker}" and end with "${endMarker}"`,
+ )
+ process.exit(1)
+}
+
+const body = execFileSync(
+ 'gh',
+ ['pr', 'view', prNumber, '--json', 'body', '--jq', '.body'],
+ { encoding: 'utf8' },
+)
+
+const startIndex = body.indexOf(startMarker)
+const endIndex = body.indexOf(endMarker)
+const hasExistingBlock =
+ startIndex !== -1 && endIndex !== -1 && endIndex > startIndex
+
+const nextBody = hasExistingBlock
+ ? body.slice(0, startIndex) + block + body.slice(endIndex + endMarker.length)
+ : `${body.trimEnd()}\n\n${block}\n`
+
+execFileSync('gh', ['pr', 'edit', prNumber, '--body-file', '-'], {
+ input: nextBody,
+})
+
+console.log(
+ hasExistingBlock
+ ? `updated system-recap block on PR #${prNumber}`
+ : `added system-recap block to PR #${prNumber}`,
+)
diff --git a/docs/architecture/primitives.yaml b/docs/architecture/primitives.yaml
new file mode 100644
index 0000000..6065979
--- /dev/null
+++ b/docs/architecture/primitives.yaml
@@ -0,0 +1,359 @@
+# Panther Compiler Kit system primitives map (taxonomy)
+#
+# Stable vocabulary for the visual-recap skill
+# (.claude/skills/visual-recap/SKILL.md). This is NOT a feature changelog and
+# NOT derived source-of-truth for behavior — the source under pncs/, pnc/,
+# runtime/, metadata/, and text/ is. This file exists to give PR recaps and
+# plans a shared, stable set of names for the moving parts of the compiler
+# pipeline.
+#
+# Risk labels used in recaps:
+# composes - wires existing primitives without changing them
+# extends - changes a primitive's behavior, shape, or contract
+# adds - introduces a new primitive (update this file in the same PR)
+#
+# Update this file only when adding, removing, or materially reshaping a
+# primitive (id, name, group, code roots, or meaning). Do NOT edit `summary`
+# for ordinary feature work — that's what commit messages and PR descriptions
+# are for.
+#
+# `code` entries are coarse ownership roots for
+# `classify-primitives.mjs` (longest-prefix match). Prefer directories over
+# file lists, but pncs/ (hand-written Scala) and pnc/ (self-hosted Panther,
+# generated from pncs/runtime/metadata/text via `sbt pncs/transpile`) are both
+# flat directories of one file per concept with no shared prefix, so most
+# primitives below list the Scala file and its generated .pn twin explicitly.
+# Keep the map small — one primitive per pipeline concept, not per file.
+
+version: 1
+
+groups:
+ - id: frontend
+ name: Lexing & parsing
+ - id: semantics
+ name: Binding & type checking
+ - id: backend
+ name: Lowering & code generation
+ - id: runtime
+ name: PVM execution
+ - id: stdlib
+ name: Shared libraries
+ - id: tooling
+ name: Build, CLI & self-hosting
+ - id: quality
+ name: Tests & docs
+
+primitives:
+ # --- Lexing & parsing ----------------------------------------------------
+ - id: lexer
+ group: frontend
+ name: Lexer & tokens
+ summary:
+ Scans Panther source text into a token stream, including trivia
+ (whitespace/comments) and syntax-kind classification.
+ code:
+ - pncs/src/main/scala/Lexer.scala
+ - pnc/src/Lexer.pn
+ - pncs/src/main/scala/SimpleToken.scala
+ - pnc/src/SimpleToken.pn
+ - pncs/src/main/scala/SyntaxToken.scala
+ - pnc/src/SyntaxToken.pn
+ - pncs/src/main/scala/SyntaxTokenValue.scala
+ - pnc/src/SyntaxTokenValue.pn
+ - pncs/src/main/scala/SyntaxTrivia.scala
+ - pnc/src/SyntaxTrivia.pn
+ - pncs/src/main/scala/SyntaxTriviaList.scala
+ - pnc/src/SyntaxTriviaList.pn
+ - pncs/src/main/scala/TokenList.scala
+ - pnc/src/TokenList.pn
+ - pncs/src/main/scala/MakeTokenList.scala
+ - pnc/src/MakeTokenList.pn
+ - pncs/src/main/scala/SyntaxFacts.scala
+ - pnc/src/SyntaxFacts.pn
+ - pncs/src/main/scala/SyntaxKind.scala
+ - pnc/src/SyntaxKind.pn
+
+ - id: parser
+ group: frontend
+ name: Parser & syntax tree
+ summary:
+ Builds the concrete syntax tree from the token stream using
+ operator-precedence parsing.
+ code:
+ - pncs/src/main/scala/Parser.scala
+ - pnc/src/Parser.pn
+ - pncs/src/main/scala/MakeSyntaxTree.scala
+ - pnc/src/MakeSyntaxTree.pn
+ - pncs/src/main/scala/SyntaxTree.scala
+ - pnc/src/SyntaxTree.pn
+ - pncs/src/main/scala/Ast.scala
+ - pnc/src/ast.pn
+ - pncs/src/main/scala/AstPrinter.scala
+ - pnc/src/AstPrinter.pn
+ - pncs/src/main/scala/OperatorPrecedence.scala
+ - pnc/src/OperatorPrecedence.pn
+ - pncs/src/main/scala/BinaryOperator.scala
+ - pnc/src/BinaryOperator.pn
+ - pncs/src/main/scala/BinaryOperatorKind.scala
+ - pnc/src/BinaryOperatorKind.pn
+ - pncs/src/main/scala/BinaryOperators.scala
+ - pnc/src/BinaryOperators.pn
+ - pncs/src/main/scala/UnaryOperator.scala
+ - pnc/src/UnaryOperator.pn
+ - pncs/src/main/scala/UnaryOperatorKind.scala
+ - pnc/src/UnaryOperatorKind.pn
+
+ # --- Binding & type checking ----------------------------------------------
+ - id: binder
+ group: semantics
+ name: Binder & type inference
+ summary:
+ Resolves symbols and scopes, checks types bidirectionally, and produces
+ the bound tree plus diagnostics.
+ code:
+ - pncs/src/main/scala/Binder.scala
+ - pnc/src/Binder.pn
+ - pncs/src/main/scala/ExprBinder.scala
+ - pnc/src/ExprBinder.pn
+ - pncs/src/main/scala/BoundNodes.scala
+ - pnc/src/BoundNodes.pn
+ - pncs/src/main/scala/BoundAssemblyPrinter.scala
+ - pnc/src/BoundAssemblyPrinter.pn
+ - pncs/src/main/scala/TypeInference.scala
+ - pnc/src/TypeInference.pn
+ - pncs/src/main/scala/Inference.scala
+ - pnc/src/Inference.pn
+ - pncs/src/main/scala/Type.scala
+ - pnc/src/Type.pn
+ - pncs/src/main/scala/Variance.scala
+ - pnc/src/Variance.pn
+ - pncs/src/main/scala/Conversion.scala
+ - pnc/src/Conversion.pn
+ - pncs/src/main/scala/Scope.scala
+ - pnc/src/Scope.pn
+ - pncs/src/main/scala/Symbol.scala
+ - pnc/src/Symbol.pn
+ - pncs/src/main/scala/SymbolKind.scala
+ - pnc/src/SymbolKind.pn
+ - pncs/src/main/scala/SymbolChain.scala
+ - pnc/src/SymbolChain.pn
+ - pncs/src/main/scala/SymbolLinks.scala
+ - pnc/src/SymbolLinks.pn
+ - pncs/src/main/scala/SymbolPrinter.scala
+ - pnc/src/SymbolPrinter.pn
+ - pncs/src/main/scala/DiagnosticBag.scala
+ - pnc/src/DiagnosticBag.pn
+
+ # --- Lowering & code generation -------------------------------------------
+ - id: lowering-emit
+ group: backend
+ name: Lowering & bytecode emission
+ summary:
+ Lowers the bound tree to a simplified IR and emits PVM bytecode from it.
+ code:
+ - pncs/src/main/scala/Lowered.scala
+ - pnc/src/Lowered.pn
+ - pncs/src/main/scala/LoweredAssemblyPrinter.scala
+ - pnc/src/LoweredAssemblyPrinter.pn
+ - pncs/src/main/scala/Emitter.scala
+ - pnc/src/Emitter.pn
+
+ - id: metadata-format
+ group: backend
+ name: PVM metadata format
+ summary:
+ Binary metadata tables (types, methods, fields, params, strings, blobs,
+ signatures) shared by the emitter, disassembler, and VM loader.
+ code:
+ - metadata/src/main/scala/
+ - pnc/src/BlobTable.pn
+ - pnc/src/Chunk.pn
+ - pnc/src/FieldMetadata.pn
+ - pnc/src/FieldTable.pn
+ - pnc/src/IntList.pn
+ - pnc/src/MetadataFlags.pn
+ - pnc/src/Metadata.pn
+ - pnc/src/MethodMetadata.pn
+ - pnc/src/MethodTable.pn
+ - pnc/src/Opcode.pn
+ - pnc/src/ParamMetadata.pn
+ - pnc/src/ParamTable.pn
+ - pnc/src/SignatureBuilder.pn
+ - pnc/src/Signature.pn
+ - pnc/src/StringTable.pn
+ - pnc/src/TypeDefMetadata.pn
+ - pnc/src/TypeDefTable.pn
+
+ # --- PVM execution ---------------------------------------------------------
+ - id: vm-runtime
+ group: runtime
+ name: PVM virtual machine
+ summary:
+ Executes PVM bytecode against the heap and value model; includes the
+ disassembler used for bytecode inspection and debugging.
+ code:
+ - pncs/src/main/scala/VM.scala
+ - pnc/src/VM.pn
+ - pncs/src/main/scala/Heap.scala
+ - pnc/src/Heap.pn
+ - pncs/src/main/scala/Value.scala
+ - pnc/src/Value.pn
+ - pncs/src/main/scala/Disassembler.scala
+ - pnc/src/Disassembler.pn
+
+ # --- Shared libraries --------------------------------------------------
+ - id: panther-stdlib
+ group: stdlib
+ name: Panther standard library
+ summary:
+ Core runtime types (collections, Option/Result/Either, string/ANSI/hex
+ helpers) available to every Panther program, including the compiler
+ itself once self-hosted.
+ code:
+ - runtime/src/main/scala/
+ - pnc/src/List.pn
+ - pnc/src/Dictionary.pn
+ - pnc/src/Option.pn
+ - pnc/src/Result.pn
+ - pnc/src/Either.pn
+ - pnc/src/Chain.pn
+ - pnc/src/NonEmptyList.pn
+ - pnc/src/Tuple2.pn
+ - pnc/src/ANSI.pn
+ - pnc/src/Hex.pn
+
+ - id: text-source-model
+ group: stdlib
+ name: Source text model
+ summary:
+ Source file, line, and span/location tracking shared across lexing,
+ diagnostics, and printers.
+ code:
+ - text/src/main/scala/
+ - pnc/src/MakeSourceFile.pn
+ - pnc/src/SourceFile.pn
+ - pnc/src/TextLineList.pn
+ - pnc/src/TextLineParser.pn
+ - pnc/src/TextLine.pn
+ - pnc/src/TextLocationFactory.pn
+ - pnc/src/TextLocation.pn
+ - pnc/src/TextSpanFactory.pn
+ - pnc/src/TextSpan.pn
+
+ - id: shared-utils
+ group: stdlib
+ name: Formatting & string utilities
+ summary:
+ Small shared helpers (indentation, padding, trimming, string building,
+ math) used across the lexer, parser, binder, and printers.
+ code:
+ - pncs/src/main/scala/IndentedStringBuilder.scala
+ - pnc/src/IndentedStringBuilder.pn
+ - pncs/src/main/scala/StringBuilder.scala
+ - pnc/src/StringBuilder.pn
+ - pncs/src/main/scala/Pad.scala
+ - pnc/src/Pad.pn
+ - pncs/src/main/scala/Trim.scala
+ - pnc/src/Trim.pn
+ - pncs/src/main/scala/Math.scala
+ - pnc/src/Math.pn
+
+ # --- Build, CLI & self-hosting -------------------------------------------
+ - id: compiler-driver
+ group: tooling
+ name: Compiler driver & CLI
+ summary:
+ Entry point, argument parsing, and compiler settings that wire
+ lexer -> parser -> binder -> lowering -> emitter into one pipeline.
+ code:
+ - pncs/src/main/scala/Program.scala
+ - pnc/src/Program.pn
+ - pncs/src/main/scala/compilation.scala
+ - pnc/src/compilation.pn
+ - pncs/src/main/scala/CompilerSettings.scala
+ - pnc/src/CompilerSettings.pn
+ - pncs/src/main/scala/ArgsParser.scala
+ - pnc/src/ArgsParser.pn
+ - pncs/src/main/scala/ColorPalette.scala
+ - pnc/src/ColorPalette.pn
+
+ - id: transpiler
+ group: tooling
+ name: Scala-to-Panther transpiler
+ summary:
+ Converts pncs/runtime/metadata/text Scala sources into the self-hosted
+ Panther (.pn) sources under pnc/src, driven by the sbt `transpile` task.
+ code:
+ - pncs/src/main/scala/Transpiler.scala
+ - pnc/src/Transpiler.pn
+ - build.sbt
+ - scripts/stage0.ps1
+ - scripts/stage1.ps1
+ - scripts/stage2.ps1
+ docs:
+ - .github/instructions/copilot.instructions.md
+
+ # --- Tests & docs ----------------------------------------------------------
+ - id: test-suite
+ group: quality
+ name: Test suite
+ summary:
+ ScalaTest coverage for the lexer, parser, binder, type checker, VM,
+ args parser, and metadata format, run via `sbt test/test`.
+ code:
+ - test/
+
+ - id: language-docs
+ group: quality
+ name: Panther language docs (Astro site)
+ summary:
+ User-facing documentation for the Panther language, published from the
+ Astro/Starlight site in docs/.
+ code:
+ - docs/src/
+ - docs/public/
+
+ - id: visual-recap-tooling
+ group: quality
+ name: Visual-recap skill & taxonomy
+ summary:
+ Contributor-facing PR review tooling: this primitives taxonomy itself
+ plus the Claude Code skill and automated PR-recap bot that classify a
+ diff against it.
+ code:
+ - docs/architecture/primitives.yaml
+ - .claude/skills/visual-recap/
+
+invariants:
+ - id: transpile-sync
+ summary:
+ pnc/src/*.pn is generated from pncs/runtime/metadata/text via
+ `sbt pncs/transpile`; never hand-edit pnc/src directly. CI's `transpile`
+ job re-runs the transpile and fails the build if it produces a diff, so
+ a PR that edits a Scala source in a mirrored primitive must include the
+ regenerated .pn output in the same commit.
+ docs:
+ - .github/workflows/ci.yml
+ - .github/instructions/copilot.instructions.md
+
+ - id: pncs-is-canonical
+ summary:
+ pncs/ (Scala) is the actively developed, buildable compiler; pnc/ (self-
+ hosted) is a generated mirror that currently does not build end-to-end
+ (`sbt pnc/compile` fails). Treat pnc/ changes as a byproduct of pncs/
+ changes via transpile, not a separate implementation to hand-edit.
+ docs:
+ - .github/instructions/copilot.instructions.md
+
+ - id: panther-scala-excluded-from-transpile
+ summary:
+ runtime/src/main/scala/panther.scala is a Scala-only interop prelude and
+ is explicitly excluded from transpilation (see the `sourceFiles` filter
+ in build.sbt); it has no pnc/src equivalent.
+ docs:
+ - build.sbt
+
+ - id: diagnostics-not-exceptions
+ summary:
+ Compilation errors surface through DiagnosticBag, not thrown exceptions,
+ so the compiler can recover and report multiple errors from one run.
]