# Projects A **project** is a directory under `REPORTS_ROOT` that holds one piece of work. umtool finds them by walking the tree; nothing registers itself and nothing had to move on disk for this to exist. ## Kind vs template A **kind** is what a thing *is*. A **template** is which configuration of that kind it is. `um-song` is not a kind — it is the one template of the `song` kind that exists so far, and the distinction is the whole point: the next thing this tool has to hold (a supercut, a cover set, a vertical short) will be a new template of an existing kind at least as often as a new kind. Three kinds ship: | kind | template | marker | what it is | |---|---|---|---| | `report-video` | `cited-timeline` | `video.manifest.json` | a sweep report said in the sources' own voices | | `song` | `um-song` | `spec.json`, `verdicts.json`, or any cut | filler sounds playing a game tune | | `sweep-report` | `sweep` | a `*sweep*.md` **and no manifest** | a report that is not yet a video | `sweep-report` earns its place on day one because `~/reports/hasan-bike` is one, and because a kind with no decisions, no build and no rich read is the cheapest possible proof that the registry is extensible. ## Detection `project.json` (`{kind, template}`) wins outright, so a directory can always declare itself. Otherwise every kind's `detect()` runs against the directory's entry NAMES — no reads, no stats. **Two matches is an error, never a guess.** A directory that is two kinds is a bug, and picking one would hide it; it gets an `ambiguous-project` decision instead. ## Identity: an id is a PATH A project's id is its POSIX path relative to `REPORTS_ROOT` — `ferret-rescue`, or `quartering-uh-song/videos/yoshi`. Not the basename: bare names collide across folders (a second `pokemon` is a matter of time) and the path is what makes a link stable. A **bare basename still works as a URL**, because `/browse/yoshi` and `/browse/yoshi/wide` are the addresses that exist in every decision href, every spec, and whatever anybody has open. It resolves **only when unique** — two projects sharing a name is precisely why an id is a path, so that case reports the collision and offers both canonical URLs rather than picking one. ## Two ways a project cannot be opened Both used to fail **silently**, which is worse than either. **`shadowed`** — the first path segment is a static page under `app/browse/` (`decisions`, `faces`, `find`, `sources`, `trim`, `at`). A static segment beats a dynamic one, so `/browse/find` renders the phrase console no matter what is on disk. The project is still listed, with a `blocking` decision, and its link goes to `/browse/at?path=…`. **`unroutable`** — the name fails `isSegment()` (a space is enough). It has no address of its own; it is listed with an `info` decision and reached the same way. `RESERVED_BROWSE` is asserted by an e2e spec to equal the real directory listing of `app/browse/`, so a tenth tool page cannot quietly make a project unreachable. ## Adding a kind **A registry entry and one view. Nothing else.** 1. Add an entry to `lib/projects/kinds.mjs`: `id`, `template`, `label`, `badge`, `detect(names)`, `stages`, `decisionKinds`, and optionally `summarise`, `signature`, `decisions`, `views`. 2. Add a component to `components/projects/` and a case in `ProjectView.tsx`. `e2e/projects.spec.ts` enforces this three ways, and they are the assertions that fail when somebody special-cases a kind in a page — which no page test can see: - **no kind id is special-cased outside `lib/projects/` and `components/projects/`** — a grep for lines carrying both a kind id and the word `kind`; - **`RESERVED_BROWSE` equals the real static pages**; - **a kind injected through `E2E_UMTOOL_EXTRA_KINDS` reaches the index, the chips and the CLI with no code edit at all**. ## Where the code is | file | what | |---|---| | `lib/projects/kinds.mjs` | the registry. Plain ESM, no TypeScript | | `lib/projects/walk.mjs` | the folder walk, routing, collapsing | | `lib/projects/{report,song,sweep}.mjs` | per-kind reading | | `lib/projects/core.mjs` | the CLI's assembled view | | `lib/projects.ts` | the app's façade: caching, dispatch, the index | | `lib/project-types.ts` | types only, **no `node:` import ever** | ## Discovered by getting it wrong once **`lib/projects/kinds.mjs` is server-only**, despite being plain ESM. It imports the per-kind modules and those read the disk, so importing it from a client component drags `node:fs` into the browser bundle — which this repo has already been bitten by: it passed `tsc --noEmit` and then 500'd every page. A client component takes `lib/project-types.ts` and gets the rest as props. **`pnpm build`, not typecheck, is what catches a regression here.** **Watch the import cycle.** `songIds()` lives in its own module (`lib/projects/song-ids.mjs`) because putting it in `song.mjs` closes `song → walk → kinds → song`. Plain node survives that; Turbopack evaluates `kinds.mjs` while `song.mjs` is still initialising and every song page 500s with "Cannot access 'CUT_NAMES' before initialization". `pnpm build` does not see it either — nothing prerenders. A page render does. **Do not write a root path out by hand.** The song-decision dispatch had the production path hard-coded, so under the e2e fixture — whose songs live elsewhere — *no song decisions were produced at all* and the inbox looked clean because it was empty.