This repo uses git worktrees for parallel feature development with isolated Claude contexts.
Create worktree:
make worktree-create BRANCH=feature/my-feature
cd .worktrees/feature/my-featureBenefits:
- Work on multiple features without branch switching
- Isolated Claude Code context per feature (separate history)
- Parallel dev servers on different ports
- Self-contained dependencies per worktree
Management (short aliases):
make wt- Dashboard — shows all worktrees, pod status, URLs, and actions at a glancemake wt-new BRANCH=...- Create containerized worktreemake wt-dev BRANCH=...- Start dev servers (mprocs TUI)make wt-stop BRANCH=...- Pause (preserves data)make wt-start BRANCH=...- Resume paused worktreemake wt-sh BRANCH=...- Shell into containermake wt-rm BRANCH=...- Full cleanup (pod + volumes + worktree)make wt-gc- Garbage-collect worktrees whose branches are merged into mainmake wt-urls BRANCH=...- Show service URLsmake wt-logs BRANCH=...- Tail container logs
Plain worktree management (no containers):
make worktree-create BRANCH=...- Create worktree (auto-detects new vs existing branch)make worktree-list- List all worktreesmake worktree-remove BRANCH=...- Remove worktreemake worktree-clean- Clean stale worktrees
Secrets:
- Dev secrets (WORKOS keys, API keys) are stored as
op://references inapps/frontman_server/envs/.dev.secrets.envand resolved at runtime via 1Password CLI (op run) - The server Makefile wraps
mix phx.serverwithop run --env-file=envs/.dev.secrets.envso secrets are injected as env vars - Requires 1Password CLI (
op) to be installed and authenticated - If the server fails on startup with WORKOS errors, ensure
opis signed in (op signin)
Structure:
.worktrees/<branch-name>/- Worktree directory.worktrees/<branch-name>/.claude/- Isolated Claude context (history, plans, todos)
When working in a containerized worktree (created via make wt-new),
source files live on the host but the toolchain runs inside a Podman container.
File operations (read, write, search, git): Run directly on the host.
Toolchain commands (mix, yarn, node): Prefix with ./bin/pod-exec:
./bin/pod-exec mix test./bin/pod-exec yarn vitest run./bin/pod-exec mix format --check-formatted./bin/pod-exec make rescript-build
Lifecycle:
# One-time infra setup
make infra-up
# Per-feature
make wt-new BRANCH=feature/cool-thing
make wt-dev BRANCH=feature/cool-thing
# Pause/resume
make wt-stop BRANCH=feature/cool-thing
make wt-start BRANCH=feature/cool-thing
# Done
make wt-rm BRANCH=feature/cool-thingArchitecture: Each worktree gets its own Podman pod with a postgres container
and a dev container sharing localhost. Pods publish service ports on the host
(deterministic range derived from the 4-char hash). A single Caddy container
runs with --network=host and routes {hash}.{service}.frontman.local to
127.0.0.1:{port}. dnsmasq resolves *.frontman.local to 127.0.0.1.
- ReScript codebase - functional style, Result types for errors
- File naming:
Client__ComponentName.res(flat folder + namespacing) - Task runner: Makefiles only - never yarn/npm scripts directly
- Test files:
*.test.res.mjs - Story files:
*.story.res(co-located with components) - Prefer
switchoverif/else— use pattern matching for control flow, even for simple boolean/option checks
- Prefer ReScript/WebAPI bindings and typed externals over
%rawJavaScript. - Use
%rawonly when there is no practical typed binding or the browser API cannot be expressed cleanly in ReScript. - Keep
%rawblocks minimal and isolated to small interop boundaries; keep business logic and event handling in ReScript. - For DOM/browser events, prefer typed ReScript handlers plus small externals for missing fields instead of full raw listener implementations.
- Use
Js.typeof(value)for runtime type checks — it compiles directly to JStypeofand returns astring("string","number","boolean","object","function","undefined"). No%rawneeded. - For JS built-ins not in the standard library, prefer typed externals over
%rawwrappers:// GOOD — typed external, compiles to Array.isArray(x) @scope("Array") @val external isArray: 'a => bool = "isArray" // BAD — unnecessary %raw for something that has a clean binding let isArray: 'a => bool = %raw(`function(v) { return Array.isArray(v) }`)
Crash early and obviously. Never swallow exceptions.
- Use
Option.getOrThrow,Result.getOrThrowwhen the value should always exist - Let pattern match failures crash - they surface bugs faster than silent fallbacks
- No defensive
Option.getOr(defaultValue)to hide unexpected states - No catch-all handlers that silently ignore malformed input
- When something unexpected happens, crash loudly so we see the error and fix the root cause
- Server channel handlers: no fallback clauses for invalid payloads (zero silent failures)
Always use Sury schemas for JSON parsing/serialization instead of manual JSON.Decode.* / Dict.get patterns.
Add @schema annotation to type definitions for automatic schema derivation:
@schema
type userConfig = {
name: string,
age: int,
email: option<string>,
}
// Sury automatically generates `userConfigSchema`
// Use it for parsing (wrap in try/catch for error handling):
try {
let config = S.parseJsonOrThrow(json, userConfigSchema)
// use config
} catch {
| _ => // handle error
}
// And serialization:
try {
let jsonString = S.reverseConvertToJsonStringOrThrow(config, userConfigSchema)
// use jsonString
} catch {
| _ => // handle error
}Use @s.describe for field documentation:
@schema
type input = {
@s.describe("The user's full name")
name: string,
@s.describe("Age in years")
age: int,
}- Type-safe: Compile-time guarantees for JSON structure
- Less boilerplate: No manual
Dict.get+Option.flatMapchains - Automatic: Schema derived from type definition
- Bidirectional: Same schema for parsing and serialization
- Better errors: Structured error messages on parse failure
All API calls and side effects MUST go through the StateReducer unless explicitly instructed otherwise.
Client__State.res- Public API:useSelector,Actions,SelectorsClient__State__StateReducer.res- Reducer with actions, effects, and state transitionsClient__State__Store.res- Store instance and dispatchClient__State__Types.res- Type definitions
Always use selectors via useSelector:
let messages = Client__State.useSelector(Client__State.Selectors.messages)
let isStreaming = Client__State.useSelector(Client__State.Selectors.isStreaming)Use Client__State.Actions.* for ALL state changes and API operations:
// User interactions
Client__State.Actions.addUserMessage(~content)
Client__State.Actions.switchTask(~taskId)
// API operations - these trigger side effects
Client__State.Actions.fetchApiKeySettings()
Client__State.Actions.saveOpenRouterKey(~key)-
Define the action in
Client__State__StateReducer.res:type action = | ... | FetchSomething | FetchSomethingSuccess({data: someType}) | FetchSomethingError({error: string})
-
Define the effect for async work:
type effect = | ... | FetchSomethingEffect({apiBaseUrl: string})
-
Handle the action in
nextfunction - return state + effects:| FetchSomething => state->FrontmanReactStatestore.StateReducer.update( ~sideEffects=[FetchSomethingEffect({apiBaseUrl: state.apiBaseUrl})], )
-
Implement the effect handler in
handleEffect:| FetchSomethingEffect({apiBaseUrl}) => let fetch = async () => { let response = await Fetch.fetch(...) if response.ok { dispatch(FetchSomethingSuccess({data: ...})) } else { dispatch(FetchSomethingError({error: "..."})) } } fetch()->ignore
-
Expose action creator in
Client__State.res:module Actions = { let fetchSomething = () => dispatch(FetchSomething) }
// BAD - Direct API call in component
@react.component
let make = () => {
let handleClick = async () => {
let response = await Fetch.fetch("/api/something")
// ...
}
}
// GOOD - Dispatch action that triggers effect
@react.component
let make = () => {
let handleClick = () => {
Client__State.Actions.fetchSomething()
}
}Only bypass the reducer when explicitly requested for:
- One-off debugging/testing
- External library integrations that manage their own state
- Performance-critical operations where the overhead is unacceptable
cd libs/client && make storybookStory files should be co-located with components: Client__MyComponent.story.res
Critical rules:
-
Never use module aliases - They compile to undefined exports that break Storybook:
// BAD - causes runtime errors module Message = Client__State__Types.Message let x = Message.SomeVariant // ALSO BAD - module S = SomeModule gets exported module ACPTypes = FrontmanClient__ACP__Types // GOOD - use fully qualified names or `open` let x = Client__State__Types.Message.SomeVariant // or open Client__State__Types let x = Message.SomeVariant
-
Wrap fixtures/samples in a module - Top-level
letbindings get exported as stories:// BAD - these become story entries in the sidebar let sampleData = [...] let mockEntries = [...] // GOOD - wrap in a module (modules are not exported as stories) module Samples = { let sampleData = [...] let mockEntries = [...] } // Usage in stories render: _ => <MyComponent data={Samples.sampleData} />
-
Prefix private helpers with underscore - Prevents them from being indexed as stories:
// Private helper (won't appear in sidebar) let _stateFromString = str => switch str { ... }
-
Use inline string arrays for tags - Don't use variables:
// GOOD tags: ["autodocs"] // BAD - CSF parser can't resolve variable references tags: [Tags.autodocs]
-
Use ArgsAdapter for variant types - Avoids module aliases and reduces boilerplate:
// Define adapter once (use underscore prefix to hide from story list) let _stateAdapter = ArgsAdapter.fromPairs([ ("streaming", Client__State__Types.Message.InputStreaming), ("available", Client__State__Types.Message.InputAvailable), ("done", Client__State__Types.Message.OutputAvailable), ]) // Use in render render: args => <MyComponent state={_stateAdapter.get(args.state)} />
-
Story structure:
open Bindings__Storybook type args = { myProp: string } let default: Meta.t<args> = { title: "Components/MyComponent", tags: ["autodocs"], decorators: [Decorators.darkBackground], render: args => <MyComponent prop={args.myProp} />, } let primary: Story.t<args> = { name: "Primary", args: { myProp: "value" }, }
-
Browser testing with play functions:
let myStory: Story.t<args> = { name: "My Story", args: { ... }, play: async ({canvasElement}) => { let screen = Browser.within(canvasElement) let element = screen->Browser.getByText("Expected Text") Browser.expect(element)->Browser.toBeVisible }, }
All notable changes must be tracked via changesets.
When making a change that should appear in the changelog, run yarn changeset and follow the prompts. This creates a markdown fragment in .changeset/ describing the change.
- A CI check (
changelog-check.yml) blocks PRs that don't include a changeset or directCHANGELOG.mdupdate - Add the
skip-changeloglabel to bypass for chore/docs-only PRs - Changesets accumulate silently on
main— no auto-PR is created on merge - To release: run
make releasewhich triggers a GitHub workflow that runsyarn changeset version, creates arelease/vX.Y.Zbranch, and opens a PR for review - When the release PR is merged,
release-tag.ymlautomatically creates a git tag and GitHub Release - The marketing site reads
/CHANGELOG.mdat build time for the/changelogpage — keep entries in Keep a Changelog format:## [version] - YYYY-MM-DD
After creating or updating a PR, push your branch as usual.
# After creating a PR
gh pr create --title "..." --body "..."
git push
# Or use the Make target wrapper
make pushThe make push target is a convenience wrapper around git push.
agent_docs/elixir-style.md— FrontmanStyle for Elixir. Must follow when writing any Elixir code.agent_docs/rescript-guide.md— ReScript patterns when needed.