Internal PM tool. Mirrors GitHub issues from a product repo with two-way sync, adds a planning layer (roadmap / list / kanban), AI-assisted reads, a customer-insights pipeline, and a lightweight accounts mini-CRM. Fastify + better-sqlite3 + Octokit backend serving a Vite + React frontend from the same process. Runs locally; optional Google sign-in for small-team use.
Architecture & locked design decisions live in CLAUDE.md — read it before changing anything.
Six tabs:
- Roadmap — drag issues across a time × bucket grid, with TODO + Backlog meta columns. Configurable bucketing (none / label / assignee / milestone) and time axis (week / month / quarter).
- List — dense sortable table.
- Kanban — mirrors one GitHub Projects v2 board (env-pinned).
- Insights — browse the product repo's
insights/*.md, an inbox of captured drafts, AI-extracted fields, and publish-to-PR. - Accounts — customer axis: account index (signal-derived + CRM-ingested) → drawer with timeline, cares-about issues, an AI "Acme story" read, and an editable mini-CRM profile (ARR / renewal / owner / tier / segment / …). Manual create or bulk JSON/CSV ingest.
- Progress — a PM "how are things today" spine: verdict → AI read → needs-you-now (at-risk) → schedule + momentum, plus an "On your plate" PM-action report.
Cross-cutting:
- Two-way GitHub sync — title / body / state / labels / assignee / milestone / comments mirror
both ways; app-only planning fields (
plannedMonth,roadmapNotes,position, …) stay in SQLite. - Flow engine — per-issue state (shipping / in-review / in-code / discussing / stalled / cold / fresh / closed) derived from PR + comment + event signals; the flow pill renders everywhere.
- At-risk + schedule health — schedule on-time % as the headline, gated momentum as secondary, recalibrated at-risk with daily snapshots.
- AI surfaces — issue summary, Progress read, insight extraction, account read — all regenerable, via any OpenAI-compatible endpoint, with per-task model overrides set in the UI.
- AI cost controls — per-workspace guardrails set in the header "AI" popover (admin only): a
max_tokenscap per request, a requests-per-minute rate limit, and a daily token budget that hard-stops AI (503) until UTC midnight.0= unlimited. Usage meter on/api/meta. - Customer-signal linking — insights ↔ issues ↔ accounts, two-way, bumping at-risk severity.
- Capture API — unauthenticated localhost endpoint (
POST /api/insights/capture) for agents/curl to drop raw signal into the inbox; the Insights header shows a copy-for-agent brief. - Repo file viewer — read-only view of repo files referenced in issues.
- Drawer affordances — expand-to-modal toggle + "copy for agent" on the issue drawer.
- Google OAuth login — optional, with domain whitelist + admin gating (see below).
- Workspace export / import — full backup + restore of the SQLite-held planning layer.
cp .env.example .env # fill in GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO (rest optional)
pnpm install
pnpm dev # tsx watch — boots API + web on one port
# or
pnpm build && pnpm startOpen http://localhost:3000 — the web app is served at /, the API under
/api/*. The port auto-bumps on EADDRINUSE; the live port is written to .runtime-port so
sibling tools can discover it.
pnpm typecheck # tsc --strict over both api and web — the build gate (no test suite)All config is env vars (see .env.example for the annotated list). Highlights:
| Var | Purpose |
|---|---|
GITHUB_OWNER / GITHUB_REPO |
Issues repo. Required. |
GITHUB_TOKEN |
Service-identity PAT (Option A). Set this or the GitHub App trio below. |
GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY |
Service-identity GitHub App (Option B). Take precedence over GITHUB_TOKEN; the installation token is minted in-app and auto-renews. Private key accepts raw PEM, literal-\n PEM, or base64. |
GITHUB_PROJECT_NUMBER |
Pin the Kanban tab to one Projects v2 board. Optional. |
GITHUB_WEBHOOK_SECRET |
HMAC secret for the inbound webhook. Optional. |
INSIGHTS_GITHUB_REPO |
Point insights sync at a different repo than the issues repo (owner/repo). Optional. |
AI_BASE_URL / AI_API_KEY / AI_MODEL |
OpenAI-compatible endpoint for AI surfaces. Unset → AI routes 503 and the UI hides them. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Set both to require Google sign-in. Leave blank for single-user localhost mode. |
ALLOWED_EMAIL_DOMAIN / ADMIN_EMAILS / SESSION_SECRET |
Domain whitelist, admin gating, cookie signing. Optional. |
GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET |
Set both for per-user GitHub write identity (writes act as the caller's GitHub account). Requires Google login. Optional. |
TOKEN_ENC_KEY |
32-byte key (hex/base64) encrypting stored user GitHub tokens at rest. Required when GitHub OAuth is set. |
DB_PATH |
SQLite file location. Defaults to ./data/roadmap.db. |
The Dockerfile builds a production image (Node, port 3000); the GitHub Actions workflow
pushes it via OIDC. Operational notes:
- Persist the data volume. Mount a volume at the
DB_PATHlocation (default/app/data). The SQLite file holds the planning layer (planned weeks, ordering, notes), user roles, linked GitHub tokens, and accounts — none of it re-derivable from GitHub. Losing the volume loses planning work. The in-app export (admin → Data) is the backup path. PORToverrides the listen port (default3000).- Generate
TOKEN_ENC_KEYwithopenssl rand -hex 32. Rotating it invalidates all stored user GitHub tokens (users just reconnect). SESSION_SECRETshould be set explicitly in a team deploy (it defaults to the Google client secret); rotating it logs everyone out.- Public HTTPS origin is assumed by the OAuth callbacks — register
<app-origin>/api/auth/callback(Google) and<app-origin>/api/github/callback(GitHub).
There are three independent identity layers, each toggled by its own env vars. They stack but none requires the next unless noted:
- Service identity (always on) — the shared GitHub client for reads, sync, and (by default) every write. A PAT (
GITHUB_TOKEN) or a GitHub App (GITHUB_APP_ID+GITHUB_APP_INSTALLATION_ID+GITHUB_APP_PRIVATE_KEY). See "GitHub auth: App vs PAT" below. - Login gate (optional) — Google sign-in. Off by default; gates who can open the app. See "Auth modes" + "Roles" below.
- Per-user GitHub write identity (optional, needs layer 2) — each user links their own GitHub account in-app so their writes are attributed to them instead of the service identity. The connect flow (avatar menu button + auto-prompt) is built in; it just needs the OAuth env vars. See "Per-user GitHub write identity" below.
- Single-user (default). Leave
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETblank — no login, every request is a local admin. The original localhost pattern. - Team (Google OAuth). Set both client vars to gate the whole app behind "Sign in with Google".
Register
<app-origin>/api/auth/callbackas the redirect URI.ALLOWED_EMAIL_DOMAINrestricts sign-in to one domain.ADMIN_EMAILS(comma-separated) is the immutable bootstrap-admin list — always admins, never demotable in-app (blank = every signed-in user is an admin).
Three roles: viewer (read-only — every non-GET request 403s, except logout and insight
capture), editor (all app writes), admin (editor + user roles + AI settings +
export/import). Newly signed-in users default to viewer; an admin promotes them via the
header Users panel (GET /api/users, PATCH /api/users/:email/role). With auth off the
role system is dormant — the local user is admin. Viewer write affordances are hidden in the
UI; the server enforces regardless.
Several pods share one instance: one DB and one reconcile loop mirror the whole product repo;
each pod is a read-time view via its own master filter. Planning fields, health snapshots, AI
model overrides, and the Progress/PM-action AI caches are per-pod; accounts + insights stay
shared org-wide. Any signed-in user can view any pod and switch via the header pod control
(hidden with a single pod); admins manage pods there too (create / rename / archive — never
delete). New issues are auto-labeled pod:<slug> for the active pod. Editing a pod's base
filter is admin-only; per-user narrowing is the client-side Filter popover.
By default every GitHub write goes through the shared GITHUB_TOKEN. Set
GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET + TOKEN_ENC_KEY and user-initiated
GitHub writes (issues, comments, Kanban moves, insight PRs) are made as the caller's own
GitHub account instead. One-time setup: register either a classic OAuth App (callback
<app-origin>/api/github/callback, scope repo) or a GitHub App with user authorization
(same callback; disable "Expire user authorization tokens" — no refresh rotation is built;
permissions are then scoped to the app's installation, which orgs tend to prefer). Each user
connects once — either from the avatar menu ("Connect GitHub") or via the prompt that appears
the first time a write is attempted (409 github_not_linked). Tokens are validated against the target repo
before being stored AES-256-GCM-encrypted; a revoked token auto-unlinks on the next write
(409 github_reauth_required). Reads and background sync keep using the service token.
Requires Google login; the server refuses to boot on partial config.
- PAT — fastest. Fine-grained personal access token with
repo(read/write) +projectscope (projectpowers the Kanban tab). Single-user, 5,000 req/hr. - GitHub App — recommended for a team. Installation tokens get 15,000 req/hr, aren't tied to a
person, and writes appear as the app's bot (
app-name[bot]). SetGITHUB_APP_ID+GITHUB_APP_INSTALLATION_ID+GITHUB_APP_PRIVATE_KEY(they take precedence overGITHUB_TOKEN); the app mints + auto-renews the ~1h installation token in-app. Install permissions: Issues, Pull requests, Contents (read/write), Metadata (read), org Projects (read/write, for the Kanban tab).
Boot-time + nightly reconcile catch any missed events, so the webhook is not required.
Production: repo settings → Webhooks → add https://<host>/webhook/github, content type
application/json, secret matches GITHUB_WEBHOOK_SECRET, subscribe to Issues and
Issue comments.
Local dev: forward events via smee.io:
npx smee -u https://smee.io/<your-channel> -t http://localhost:3000/webhook/githubThe OpenAPI definition is the source of truth — it's kept current with every endpoint change:
- JSON:
http://localhost:<port>/api/openapi.json - YAML:
http://localhost:<port>/api/openapi.yaml
Route groups (api/src/routes/):
| Area | Routes |
|---|---|
| Issues | list / patch (two-way) / create, /roadmap PATCH (app-only planning fields) |
| Comments | list / create / patch / delete (two-way) |
| Meta & config | counts, rate limit, current user; workspace_config GET/PATCH (pod base filter — admin) |
| Workspaces (pods) | list, switch active (rm_workspace cookie), create / rename / archive (admin) |
| Roadmap surfaces | flow state, schedule health (live + history + backfill), Projects v2 (Kanban), PRs |
| Progress | morning brief (snapshot + changes since last-seen) |
| AI | issue summary, progress read, account read, insight extraction (all regenerable) |
| Insights | GitHub-API sync, capture, draft lifecycle, publish-PR |
| Accounts | list / detail / AI read + mini-CRM (create, PATCH profile, JSON + CSV ingest) |
| PM actions | quick-action endpoints surfaced in the UI |
| Repo files | read-only viewer for repo files referenced in issues |
| Data | full-workspace export / import (backup + restore) |
| Auth | Google OAuth login / callback / logout / session; GitHub link / callback / unlink (write identity) |
| Webhook | HMAC-verified GitHub receiver |
- GraphQL bulk fetch (50 issues + 50 comments per call) instead of a REST loop
- Webhook-driven inbound; reconcile only on boot + nightly
- Rate-limit headers captured on every response, exposed at
/api/meta; logger warns under 10% - Per-issue write debounce helper in
sync.ts
See CLAUDE.md §8 for the full candidate-work list. Notable open items:
- Conditional ETag caching layer
- Source connectors (Slack / gdoc / Jira) feeding the capture API
- Tests