The development map for the TypeScript app. (Transcribing photos instead? That's a
separate activity — see transcribe/PROMPT.md, not this file.)
This repo holds two halves that meet only at the recipe collection:
transcribe/— the producer: an LLM transcription brief (PROMPT.md), the canonical format standard (SPEC.md), worked examples (spec/), and the version guard (format-version.json+verify-format.mjs). Dependency-free; produces<slug>/recipe.cook(the clean recipe) and, when the photo left something unsettled, a sibling<slug>/clarifications.mdchecklist into a collection. The render code never imports from here.src/— the consumer: reads a collection via--recipes-dirand produces print artifacts. No extraction/OCR code, by design.
cli.ts— entry point. Commands:queue-recipes,config(read-only path diagnostic),audit,audit-review,audit-approve. (render/build-tocwere removed 2026-06-20 — unused, redundant withqueue-recipes --all+ the auto-TOC.) Paths are config-first: the collection + output dirs resolve fromconfig.json(flag > config > fail-fast, no silent default);parseOptionsreturns raw flags andresolveOptionsapplies the ladder, throwingConfigError(caught at top level → verbose, non-zero) when a needed path can't be resolved.queue-recipesis now the DIGITAL render activity's pure touchpoints (stage 2; dispatch AND print-state are NOT here — F4 moved the print log + its verbs to print-hardware,pnpm print-hardware …): barequeue-recipes(produces every recipe whose PDF is stale against its source — new / edited / PDF deleted /--all— sorted reverse-alphabetical by title (Z→A) so the printed stack files A-on-top; gated purely on render-freshness, no print-state, no binder TOC), andqueue-recipes --tidy-slugs [--apply](rename folders that have drifted from their title back into line — dry-run by default,--applyperforms it; safe because identity rides the id, so the print history is untouched by the move). The pending queue, marking, clearing history, and the binder TOC are print-hardware's now (viasrc/print-bridge.ts— see below). Option parsing (--recipes-dir,--out,--examples,--state,--all,--tidy-slugs,--apply,--no-format-warnings,--with-clarifications) lives here. The producing run (barequeue-recipes) calls the audit preflight first;--tidy-slugs(a filesystem tidy, no render) skips it. Clarification enforcement: a clean produce (barequeue-recipes) HOLDS BACK any recipe with unresolved clarifications and reports it loudly (never renders a clean page over an unsettled ambiguity);--with-clarificationsinstead renders those items onto the page as a review proof. (print-hardware's--listcarries each entry'sunresolvedClarificationscount + aprintableflag for the print gate.)config.ts— config-first path loader:configDir()(envRECIPE_KIT_CONFIG_DIR$XDG_CONFIG_HOME/~/.config, +recipe-kit),loadConfig()(parses<configDir>/config.json; null if absent),resolveCollectionDir/resolveOutputDir(flag > config > throwConfigError), and theEXAMPLES_DIRconstant. The zero-deptranscribe/resolve-config.mjsmirrors this resolution for the transcribe half (keep in sync).config.jsonholds the paths; the chosen printer lives beside it in the agent-writtenprinter.md(not here).configure/— the PATH-ONLY, cross-platform setup helper the conversational setup SKILL drives (JSON in/out; theconfigurecommand incli.tsparses args + emits). It is the deterministic half of "conversational config setup":suggestDefaults(propose absolute default locations under a base),inspectDir(probe a candidate dir — exists / fileCount /isGitRepo/looksLikeCollection+recipeCountvialoadCollection, so the skill can offer to ADOPT an existing collection),mergeConfig(pure, additive — only the given keys change, so setting one path never clobbers another) +writeConfig(atomic temp-then-rename). Paths persist absolute (the CLIresolve()s before merging) becauseconfig.jsonis host-local. It NEVER surveys or writes a printer — that cross-platform, judgment-needing work is the OS-aware setup agent's job (→printer.md), the same code/agent ownership seam dispatch already uses. Noprocess.platformbranch exists here; Node'spath/os/fsmake the path side OS-agnostic by construction. Pure helpers are unit-tested (configure/configure.test.ts).collection.ts— load a recipe collection from disk (parses eachrecipe.cookand attaches itsclarifications.mdsidecar, if any).recipe-id.ts— the recipe identity layer. A hidden, git-tracked sidecar<slug>/.recipe-id(system-minted UUID) is the collection's true primary key, because the slug (folder name) is mutable and git persists file content but not xattrs.ensureIdentitiesis the heal-on-load pass every queue-recipes touchpoint runs: it mints a sidecar where missing and re-mints a duplicate id (the folder-copy case — a duplicate primary key), deterministically and idempotently. Purely a queue-recipes concern — the tracker keys on the id internally, but the agent-facing surface stays slug-addressed (--listemits slugs;--mark/--clear-historytake slugs; the code resolves slug→id), so print-hardware never learns the id and no new stage coupling appears.slug.ts—slugifyTitle(the kebab-case folder name a title should have) and the lenientslugMatchesTitle(the non-strict slug↔title compliance check). Powers the--listslugInSync/suggestedSlugfields, the produce-time drift nudge, and the--tidy-slugsrename command (all share this one matcher — the flag and the fix can't disagree). A user edits titles freely; the folder is flagged (never blocked, never auto-renamed) when it drifts, and--tidy-slugs --applyrenames it back into line on request. Safe because identity rides the id. The slug derivation here is the same fixed rule the transcribe brief now mandates, so freshly-transcribed folders never false-flag.clarifications.ts— the transcription-clarifications sidecar:Clarificationtype,parseClarifications(markdown task-list- [ ]/- [x]),loadClarifications(per recipe folder),unresolvedCount. Lives out ofcook/because it is a sibling artifact, not part of the.cookgrammar. Unresolved items block a clean print (cli.ts) and render as page warnings only under--with-clarifications(render/view-model.ts).cook/— the Cooklang dialect (the format standard this program reads):model.ts(types +recipeTitle),parse.ts(our own deserializer; extended dialect == sections, ranges, inline-- notes --,>> metadata),parse.test.ts.parse.tsis the reader half of the format contract — it assumes well-formed, conformant input (the transcriber's clean output) and turns it into the typedRecipethe renderer consumes.format.ts— the format-version half of the contract:CURRENT_FORMAT(derived fromtranscribe/format-version.json, the single source of truth — so producer and consumer can't disagree about the version),MACHINE_META_KEYS(the content-vs-machine metadata split), and the version tripwire that flags a recipe whose>> format:differs fromCURRENT_FORMAT.format.test.tsis the conformance check (parses every committed example, asserts valid current-format) plus the tripwire/suppression/meta-exclusion tests.units/—convert.ts(only cleanly-convertible quantities, render-time),config.ts(DEFAULT_UNITS_CONFIG; display = show both units), tests.render/—view-model.ts(presenter-neutral shared layer: aggregates ingredients, orders metadata, partitions notes, and computes page warnings —UPPERCASE: ...standalone notes and a format-version mismatch, default-on and suppressed by--no-format-warnings; also unresolved clarifications, but only underRenderOptions.clarifications/--with-clarifications, off by default — without emitting any markup),pdf.tsx(the PDF presenter: turns the view model into@react-pdf/renderercomponents — Yoga/flexbox layout, WASM not native, no browser — with an embedded Liberation Serif font underfonts/so output is byte-identical across machines),toc.ts(alphabetical TOC — pure: takes whatever recipe list it's given; the binder TOC lists the filed subset, which print-hardware hands in via the print-bridge'srenderBinderToc), tests. (The former Puppeteer/Chromiumpdf.ts+html.ts/theme.tsHTML path was dropped — see the react-pdf migration; layout now lives inview-model.ts.)freshness.tsis queue-recipes' render-freshness — its OWN, print-agnostic answer to "does the rendered PDF still match the source?" (contentSignature+ the sharedchangedSincequery + arender-manifest.jsonrecording id→last-rendered signature, living in the gitignored output dir beside the PDFs). The producing run re-renders a recipe iff its source changed since the last render (or the PDF is missing /--all), so a manual.cookedit re-produces its page with no reference to the print tracker — wipe the output and it all correctly regenerates. (The bindertoc.pdfis NOT on this manifest: F4 made it print-hardware's, rendered in full every time with no stored state — seeprint-bridge.tsrenderBinderToc. This general freshness machinery still serves recipe pages, and later the digital whole-collection TOC.)contentSignatureis the single definition of the version identity: the print-bridge computes print-currency from it too, so render-freshness and print-currency judge "current" by construction-identical means (F2 collapsed the old duplicatehashContentinto it).print/— after F4, justconsole.ts(box/prompt UI) + tests. F4 relocated the print log out ofsrc/: the physical tracker now lives atprint-hardware/print-state.ts, and queue-recipes no longer imports it. Noprinter.ts: dispatch to a physical (or virtual PDF) printer is the agent layer's job, not a code function (seeactivities-not-orchestrated).print-bridge.ts— the entire code-level interface between print-hardware and queue-recipes (F4), two functions, both READ-ONLY over print-state (print-hardware is its sole owner/mutator):revisePrintState(prior, dirs, {all?, render?, renderOpts?})→{ revised, pending, produced }—revisedis the caller's prior filings ∩ still-fresh (stale entries pruned; records pass through OPAQUELY via a generic<R>, so print-hardware'sPrintRecordtype never crosses),pendingis the new/changed/reprint dispatch set (titles + signatures + pdf paths print-hardware can't derive), and it renders the stale pages as a side effect whenrender; andrenderBinderToc(filedIds, destPath, dirs)renders a TOC of exactly those filed recipes in full, unconditionally (no currency, no manifest entry — the binder TOC carries no state). print-hardware passes its state IN and applies the results to its own store; this module never touchesprint-state.jsonand imports no print-hardware code, so the dependency is strictly one-directional (print-hardware → queue-recipes). Printed-currency = the samechangedSince(contentSignature(...))render-freshness uses, so the two halves can't disagree.render/produce.ts— the print-state-FREE render core shared by barequeue-recipesproduce and the bridge:loadSignedCollection(load + heal.recipe-id+contentSignature),outputPaths,staleRenders(manifest-gated freshness),partitionByReview(clarification hold-back),renderJobs, the slug-drift/held-back reporters,byTitle(the Z→A stacking order).- The print-hardware code home (
print-hardware/, outsidesrc/):print-state.tsis a pure store —print-state.jsonrecords only{id → signature, printedAt}(id-keyed, so a folder rename keeps the record;migrateLegacyStaterekeys a pre-feature slug-keyedversion: 1file,loadStatecoerces pre-F2hashrecords + drops a pre-F3tocrecord), withmarkPrinted(upsert — a reprinted edited recipe re-files at its new signature),clearPrinted,filedIds(the filed set it hands the bridge for the TOC), and imports nothing fromsrc/; andcli.tsis print-hardware's ownpnpm print-hardwaresurface (--list/produce/--mark/--clear-history/--toc) that calls only the bridge +config. The agent side isprint-hardware/PROMPT.md(the/print-hardwarebrief — all viapnpm print-hardware, the brief never naming queue-recipes [the F4 Layer-1 containment boundary]: list → produce → read the configured printer → confirm the batch at a review gate → dispatch (reverse-alphabetical) → mark → offer the refreshed binder TOC [pnpm print-hardware --tocre-renderstoc.pdfin full → dispatch it; no status query, nothing to mark], only when it filed something); it is NOT code and a coding session doesn't need it. There is no ~5 batch ceiling; an always-on review gate confirms the batch before any hardware (skippable with an explicit--yes/unattended intent). The brief reads the chosen printer from the config dir'sprinter.mdand fails fast if it's missing/stale — it no longer discovers or picks a printer (that moved to the setup agent,configure/PROMPT.md//configure, which surveys printers and writesprinter.md; the orchestrator isorchestrate/PROMPT.md, also not code). photos/— photo intake (thenormalize-photoscommand): make an inbox of phone/ browser/scanner images vision-readable before transcribe.detect.ts(magic-byte format detection — content, not extension; pure),normalize.ts(dispositionForready/convert/unsupported split +toReadableJpeg+ the non-destructivenormalizeDirorchestration that archives converted originals underoriginals/),__fixtures__/(a vendored HEVC-HEIC sample + an AVIF sample — see its README), tests. Two decoders, both pure-JS/WASM (no native addons, so the command embeds in the single-file binary):heic-convert(libheif WASM) for HEIC/HEIF — the HEVC-coded iPhone case — andjimp(pure JS) for TIFF + EXIF-orientation (its read path auto-rotates) + the JPEG re-encode. AVIF is recognized but unsupported: the bundled libheif is HEVC-only (no AV1 decoder) and jimp can't read AVIF, sodispositionForclassifies itunsupported(detected, named in the report, never attempted) rather than failing mid-batch. Already-readable inputs (JPEG/PNG/GIF/WebP) are passed through untouched. (Replacedsharp— native libvips — in the react-pdf/SEA migration, leaving zero native code in the binary.)env/—doctor.ts: the read-onlydoctorfirst-run environment self-check (Node inenginesrange, runtime-critical deps installed). PDF rendering is in-process (@react-pdf/renderer), so there's no separate browser/Chromium probe. Pure verdict logic (evaluateEnvironment+satisfiesRange) is unit-tested; the impure probe (gatherEnv) lives incli.ts. Diagnoses only — never installs (the front door does).audit/— dependency-audit heartbeat.heartbeat.ts(pure, offline: staleness vs the committedaudit-state.json+pnpm-lock.yamlhash, the verbose non-fatal warning, theNODE_ENV=productiongate),approve.ts(reviewAuditrunspnpm auditand records an ephemeral.audit-review.jsonwithout stamping;approveAuditis the explicit offline "I approve" that stamps from that review), tests.
- Folder-per-recipe:
<slug>/recipe.cook+<slug>/sources/(origin images) + an optional<slug>/clarifications.md(the transcription-uncertainty checklist) + a hidden<slug>/.recipe-id(the machine-owned immutable identity — seerecipe-id.ts; committed with the collection). The slug is a cosmetic, mutable label; the id is the key. - The collection is separate data (its own git repo), resolved config-first
(
config.jsoncollectionDir, overridable with--recipes-dir). This repo ships onlyexamples/as a demo. - Rendered artifacts and
print-state.jsonare gitignored / live with the collection — never commitoutput/. - The contract with the transcriber is the dialect
cook/parse.tsreads, plus the convention that anything the author could not resolve from the source is recorded out of the recipe, in theclarifications.mdsidecar (a- [ ]checklist) — not inline.recipe.cookstays clean; a clean print is blocked until every clarification is checked off (resolved). We do not validate or "repair" incoming files; interpretation is the author's job. A malformed file simply renders poorly and is fixed upstream. (The renderer still promotes any in-fileUPPERCASE: ...standalone note and a format-version mismatch to a page warning — that surface remains, but transcription uncertainty no longer rides it by default.) - Format versioning. Each recipe carries a
>> format: Nmachine-metadata marker;transcribe/SPEC.mdowns the canonical number. The standard moves forward, never forks —cook/format.tsunderstands one version and parses only that grammar; a different version is flagged, never silently reinterpreted. The marker is read but never rendered. When the format changes, bump the version intranscribe/format-version.json+ the>> format:lines, thennode transcribe/verify-format.mjs --record(the guard fails otherwise).
- Node pinned via
.nvmrc(Node 20); pnpm via thepackageManagerfield (Corepack —corepack enable, no global install). All-TypeScript, run withtsx— no JS emit,.tsimport specifiers. PDF rendering is in-process via@react-pdf/renderer(no browser/Chromium download);pnpm.onlyBuiltDependenciesgoverns which deps may run install scripts. Committedpnpm-lock.yamlpins exact versions. pnpm test— runs the format-version drift guard first (transcribe/verify-format.mjs, so spec drift fails the suite), then the node:test suite (parser, format/conformance, units, render, print tracker, audit).pnpm run typecheckbefore relying on a change.pnpm run verify-format— the format-version drift guard on its own.- Packaging to a single-file binary (
scripts/, gitignoreddist/output):pnpm build→ esbuild bundlessrc/cli.ts→ one CommonJS filedist/cli.cjs(build.mjs). CJS because Node SEA runs its main as CommonJS (no top-level await — the entry is wrapped inmain()). Everything is bundled in: there are no native addons, and the WASM (react-pdf's yoga, heic-convert's libheif) is base64-inlined inside those packages' own JS, so the one file is self-contained. Theinline-fontsplugin replacessrc/render/fonts/loader.tswith the.ttfbytes as base64data:URIs (react-pdf accepts them), so fonts ride inside the bundle too. A--defineinjectsengines.nodeas__RECIPE_KIT_NODE_RANGE__, whose presence also tellscli.tsit's packaged (skip the dev-onlypackage.json/node_modulesprobes).pnpm package→buildthenpackage-binary.mjswrapscli.cjsin a Node SEA (blob +postjectinto a copy ofnode) →dist/recipe-kit(~100 MB). Because all assets inline into the bundle, the SEA needs noassetsmap — so it works on Node 20.10 (the asset API would need 20.12+). OS-aware: ad-hoc codesign on macOS.- The bundle/binary is purely a distribution artifact — development always runs via
tsx(pnpm recipes …,pnpm test), unchanged. Both modes share one source.
- Audit heartbeat — two deliberate, separate steps:
pnpm run audit:reviewrunspnpm auditand records that you looked (no stamp), thenpnpm run audit:approveis the explicit "I approve" that stamps the committedaudit-state.json. The run commands warn (non-fatal) once approval is >14 days old or the lockfile changed;pnpm run audit:checkis the exit-code gate for CI.
- Prefer typed, self-contained code; declare every dependency (no host tools).
- Never commit without explicit approval; never auto-commit.