Contributing
Thanks for helping build Action Stations. This page covers the project rules, the workflow, the local CI, the tests and where the design documents are. The longer version of the workflow is docs/06-GITHUB-WORKFLOW.md; how the code is wired (handlers, routes, adapters, templates) is in docs/09-CODE-CONVENTIONS.md.
Project rules
Section titled “Project rules”Every change follows these rules, and a pull request that breaks one is not merged. Code comments and documents refer to them by number, for example “CONTRIBUTING.md, Project rules 5”.
- GitHub costs nothing. GitHub Actions runs only where it is free: workflows in this public repository, on standard GitHub-hosted runners, storing no artifacts or caches (
scripts/check-workflows.mjsenforces it). CI isscripts/local-ci.sh(pnpm ci:local) plus the Husky hooks; Actions only builds the release installers. If a new check is needed, add it tolocal-ci.sh(ADR-0023). - GitHub Issues are the task tracker. Every piece of work has an issue, created from a template in
.github/ISSUE_TEMPLATE/with the labels and milestone described indocs/06-GITHUB-WORKFLOW.md. Every commit message references its issue (#NN). When the work ships, close the issue with a comment that says what shipped. - Local-first and private. No telemetry, and no network calls except to providers the user configured or downloads the user started. API keys are encrypted by the OS keychain through Electron
safeStorage; they never go into project files or logs. - Providers only through adapters. Every model and media call goes through the interfaces in
src/main/providers/. No vendor SDK or direct vendor call outside an adapter; ESLint enforces the import rule. - Templates for text, AI for scenes. Tweets, posts, tickets, emails, logs and information boards such as FIDS render from HTML templates, so their text is exact and legible. Image and video generation is for photographic content. An inject whose critical text is AI-rendered pixels does not ship.
- The stack is fixed: Electron, React, TypeScript, Vite and pnpm;
better-sqlite3withsqlite-vec; Vitest and Playwright; ESLint and Prettier;electron-builder. Don’t swap any of them for another framework (ADR-0001). mainalways builds and launches. Work happens onfeat/<issue>-<slug>orfix/<issue>-<slug>branches (see The loop for the others) and is squash-merged through a pull request afterpnpm ci:localpasses.
Set up
Section titled “Set up”Prerequisites are in the README (Git LFS, Node.js 22.12+, pnpm 10, native build tools). Images, PDFs, Office files, fonts and media are stored with Git LFS (.gitattributes); the Husky hooks run its commands, so a clone made before git lfs install needs git lfs pull. Then:
pnpm install # also installs the git hooks (Husky) and rebuilds native modules for Electronpnpm dev # run the app with hot reloadpnpm test # unit testsFor a guided tour of the app itself, see Getting started.
Commands
Section titled “Commands”pnpm install # install deps; builds native modules for Electronpnpm dev # launch the app (electron-vite, HMR)scripts/dev-bundle.sh # macOS: .dev-app/Action Stations.app, a renamed Electron so the menu bar and Dock say Action Stationspnpm test # Vitest unit tests (run under Electron-as-Node, mock providers)pnpm lint && pnpm typecheck # ESLint + tscpnpm ci:local # full local CI: scripts/local-ci.shpnpm badges # full local CI, then refresh the README badges in docs/badges/pnpm stats # local snapshot of downloads, stars and forks into docs/stats/ (CI records the real history)pnpm e2e # Playwright end-to-end tests against the Electron apppnpm bench -- --models ollama:<name> # score local models on the example projects (docs/guides/model-benchmark.md)pnpm bench:report # merge docs/benchmarks/runs/ into docs/benchmarks/model-benchmarks.jsonpnpm build # electron-builder installers (dmg / nsis / AppImage)The live generation tests are listed under Live tests. Releasing is described in docs/RELEASING.md.
Repository layout
Section titled “Repository layout”src/main/ Electron main: ipc/, providers/, ingest/, generate/, render/, export/, db/src/renderer/ React app: routes/, components/, editors/, state/src/shared/ Types, zod schemas, IPC contractstemplates/injects/ HTML/CSS templates per inject typeprompts/ Default prompt files (markdown with frontmatter)comfy/workflows/ Default ComfyUI workflow JSON per media typedocs/ PRD, architecture, taxonomy, methodology, ADRs, guidesreference/ Sample plans per domain + the CSAAP scenario 1–8 gold-standard outputsexamples/ Example projects that ship with the app (Projects → Examples)packs/ bundled.json (repo folders → bundled packs), domains/<id>.json profiles, documents/scripts/ local-ci.sh, release, pack and example builders, dev helperse2e/ Playwright testsreference/ holds the sample plans a user would import and the eight hand-written CSAAP airport exercises the output is measured against; reference/scenario-3/ is the primary benchmark. examples/ holds the example projects built from those plans, with the manifest format and the rules for adding one.
License and contributor agreement
Section titled “License and contributor agreement”Action Stations is source-available under the PolyForm Internal Use License 1.0.0, with commercial use reserved as described in COMMERCIAL.md. Contributions are accepted under the Contributor License Agreement: tick the agreement box in the pull request template. You keep the copyright in your work; the agreement lets the licensor offer it under both the source-available and the commercial licenses.
The loop
Section titled “The loop”- Issue first. Every change starts as a GitHub issue on
actionstations-ai/actionstations, created from a template in.github/ISSUE_TEMPLATE/(feature, bug, new inject type). Applyarea:*,type:*and priority labels and a milestone. If you find more work while you are at it, open an issue for it rather than leaving a placeholder comment in the code. - Branch from an up-to-date
main:feat/<issue>-<slug>for featuresfix/<issue>-<slug>for bugschore/<issue>-<slug>ordocs/<issue>-<slug>for tooling and docsspike/<issue>-<slug>for throwaway investigation
- Implement with tests. Unit tests sit beside the code (
*.test.ts) or intests/. Provider adapters need a recorded-fixture contract test plus an opt-inLIVE=1test. New inject templates need apreview.jsonand a golden PNG (template guide). UI changes need a look in the built app, in light and dark themes. - Run local CI:
pnpm ci:local. It must pass. - Open a PR with
gh pr create, filling.github/PULL_REQUEST_TEMPLATE.md:Closes #NN, what and why, how you tested, the pastedci:localoutput, and screenshots or rendered PNGs for UI and template changes. - Squash-merge after review (
gh pr merge --squash --delete-branch), then close the issue with a comment summarizing what shipped.
main always builds and launches.
Commits
Section titled “Commits”Conventional Commits with the issue number:
feat(ingest): pptx parser (#23)fix(render): block remote font requests (#30)docs(adr): record embedding decision (#13)Types: feat, fix, chore, docs, test, refactor. The scope is the area (ingest, providers, generate, render, templates, editor, …).
Local CI
Section titled “Local CI”scripts/local-ci.sh (pnpm ci:local) is the CI, and it runs again on every git push through the Husky pre-push hook. The pre-commit hook runs lint-staged (ESLint --fix and Prettier on staged files).
| Step | Command | What it checks |
|---|---|---|
| Guard | node scripts/check-workflows.mjs |
Every GitHub Actions workflow runs on standard GitHub-hosted runners and stores no artifacts or caches, so Actions stays free (ADR-0023). |
| Install | pnpm install --frozen-lockfile |
The lockfile matches package.json. |
| Lint | pnpm lint |
ESLint with zero warnings allowed (including the renderer’s import boundaries). |
| Format | pnpm format:check |
Prettier formatting of code, Markdown, JSON and CSS. Fix with pnpm format. |
| Doc links | node scripts/check-doc-links.mjs |
Every relative link and image in the repository’s Markdown points at an existing file, and #anchors into Markdown files match a heading. External URLs are not fetched. |
| Typecheck | pnpm typecheck |
tsc for the main/preload/shared code and for the renderer. |
| Unit tests | pnpm test |
Vitest, run inside Electron’s Node so native modules match. Providers are mocked; no network. |
| Build check | pnpm build:check |
electron-vite build plus electron-builder --dir for this platform. |
| Template render tests | pnpm test:templates |
Renders every inject template × variant in the built app: compares with its golden PNG, checks two renders agree, and checks the render pool (see goldens). |
| Launch smoke | pnpm exec playwright test e2e/smoke.spec.ts |
Starts the built app. Catches startup crashes and ESM/CommonJS interop problems that unit tests cannot see. |
| Full e2e (opt-in) | E2E=1 pnpm ci:local |
Every Playwright spec in e2e/ against the built app with the mock provider. |
| Secrets scan | gitleaks git --redact . |
No secrets in the git history. Skipped with a notice if gitleaks is not installed (brew install gitleaks). |
| Audit (warning only) | pnpm audit --prod |
Known vulnerabilities in production dependencies. Never fails the run. |
| Badges | node scripts/make-badges.mjs |
Off by default. With BADGES=1 (pnpm badges), the last step after everything else passed: rewrites the README badges in docs/badges/ (see Badges). |
bash scripts/local-ci.sh --quick stops after the unit tests (guard, install, lint, format, doc links, typecheck, tests), for fast iteration. Run the full version before you push.
git push --no-verify is acceptable only for work-in-progress pushes to feature branches, and the PR must say so.
Badges
Section titled “Badges”The README badges for the build itself are SVG files in docs/badges/, written offline by scripts/make-badges.mjs as the last step of a full local CI run with BADGES=1 (pnpm badges), so they only ever say “passing” after a green run. The step is off by default: pnpm ci:local and the pre-push hook never touch docs/badges/, so feature branches do not conflict over them. The values come from the run itself and the repository: the unit-test count from the Vitest summary, the e2e count from playwright test --list, the version from package.json (during pnpm release, the version being released, #388), the Electron version from package.json, the license from LICENSE.md and LICENSE-EDUCATION.md, the platforms from electron-builder.yml.
The files are deterministic. docs/badges/badges.json holds the current values and the date on the “local CI” badge. A run that measures the same values keeps the old date and leaves the files untouched, so the tree stays clean. When a value changes (a test was added, the version moved) every changed badge is rewritten and the date becomes today’s. Badges are refreshed in two places only: pnpm badges on an up-to-date main, committed in a small chore(badges) pull request, and pnpm release, which runs local CI with BADGES=1 and on --publish commits the refreshed badges before pnpm version, so the tree is clean for the version bump.
The GitHub social preview image (docs/social-preview.png, 1280 × 640) is built from the logo and a guide screenshot with pnpm exec electron scripts/make-social-preview.mjs. GitHub only takes it through the web UI: Settings → General → Social preview → Edit → Upload an image.
If a new check is needed, add it to scripts/local-ci.sh. GitHub Actions is for the release build only, on free standard runners (ADR-0023).
Repository stats
Section titled “Repository stats”The workflow .github/workflows/stats.yml runs scripts/repo-stats.mjs once a day (and on demand from the Actions tab) and commits the result to the stats branch, never to main (ADR-0067). The branch holds only docs/stats/: history.json, the README chart (downloads-light.svg, downloads-dark.svg) and the downloads badge. The README loads the chart and badge from that branch. The first run creates the branch from docs/stats/history.json on main; a run with no change commits nothing. Releases do not take their own snapshot.
The script reads stars, forks and every release’s download counts with gh api. Only installers count (.dmg, .exe, .AppImage and similar), not checksums, blockmaps or latest-*.yml. GitHub keeps no history and restarts an asset’s count when the file is replaced, so the script keeps a per-file ledger: when a count drops, the earlier total is carried forward. pnpm stats runs the same script locally (sign in with gh auth login first) and writes docs/stats/; use it to try a change, not to record history. --offline redraws from history.json without fetching, and --out DIR writes somewhere else. The stars and forks badges come live from shields.io, and the stars-over-time chart from star-history.com.
Tests in more detail
Section titled “Tests in more detail”- Unit tests:
pnpm test, or one file withpnpm test -- path/to/file.test.ts;pnpm test:watchto re-run on change. Code that importselectronneedsvi.mock('electron', …). Renderer component tests start with// @vitest-environment happy-dom. - End-to-end:
pnpm e2ebuilds the app and runs Playwright against it.e2e/helpers.tslaunch()gives each test an isolated data folder. Playwright emptiestest-results/at the start of every run; copy anything you want to keep elsewhere first. - Packs end to end:
pnpm build:app && TTX_HEADLESS=1 pnpm exec playwright test e2e/packs.spec.tsinstalls, uses, updates, refuses, uninstalls and exports packs against a local catalog on 127.0.0.1, and fails if the app sends any request to a non-loopback host (main-process traffic frome2e/net-recorder.cjs, Chromium’s from--log-net-log). - Screenshot tour:
pnpm build:app && TOUR=1 pnpm exec playwright test e2e/tour.spec.tscaptures every screen in light and dark themes intotest-results/tour/. - Offline app:
TTX_MOCK_PROVIDERS=1 pnpm devroutes every task to a built-in mock provider that returns schema-valid output.
Live tests
Section titled “Live tests”Live tests call real local services. They are skipped unless LIVE=1 is set, never call paid APIs, and print their measurements. Start the services first (see the provider guides).
| Command | Needs | What it does |
|---|---|---|
LIVE=1 pnpm test -- src/main/providers/text/live.test.ts |
LM Studio (qwen3-30b-a3b-2507 loaded), Ollama (qwen2.5:3b), mlx-audio on port 8765 |
Text adapters: streaming, structured output, embeddings; speech through /v1/audio/speech. |
LIVE=1 pnpm test -- src/main/providers/media/audio-fx/live.test.ts |
mlx-audio | Every radio / phone / PA post-FX on real TTS output. |
LIVE=1 pnpm test -- src/main/providers/media/comfyui/live.test.ts |
ComfyUI with the SDXL and LTX-Video models | The default image and video workflows. LIVE_COMFY_VIDEO=0 skips the slower video test. |
LIVE=1 pnpm test -- src/main/retrieve |
Network once (downloads the local embedding model, about 130 MB) | Retrieval quality and indexing speed with the local bge-small model. |
LIVE=1 pnpm test -- tests/live/pipeline.live.test.ts |
LM Studio with the chat and embedding models | Full scenario from the NCIA plans; writes tests/fixtures/generate/sample-run/. Takes minutes. |
LIVE=1 pnpm test -- tests/live/injects.live.test.ts |
LM Studio, ComfyUI, mlx-audio | Scenario 3 inject deck and media; writes tests/fixtures/injects/sample-run/. |
LIVE=1 pnpm exec playwright test e2e/m2-exit.spec.ts |
LM Studio; the built app (pnpm build:app) |
Ideas → full MSEL through the real app; outputs in .tmp/m2-exit/. |
Common overrides: LIVE_LMSTUDIO_URL, LIVE_LMSTUDIO_MODEL, LIVE_OLLAMA_URL, LIVE_OLLAMA_MODEL, LIVE_TTS_URL, LIVE_TTS_MODELS, LIVE_COMFY_URL, LIVE_EMBED=hash|provider, LIVE_OUT. Each live test file lists its own variables in its header comment.
Adapter contract tests run on every pnpm test against recorded fixtures under tests/fixtures/providers/, so CI never needs a live service.
Documentation
Section titled “Documentation”- Keep
README.mdrunnable and the guides indocs/guides/accurate when behaviour changes. Guide screenshots live indocs/guides/images/; retake them from the built app when a screen changes withpnpm build:app && GUIDE_SHOTS=1 pnpm exec playwright test e2e/guide-screenshots.spec.ts(mock provider, 1440 × 900), then look at each changed image before committing it. The Packs screenshots indocs/guides/images/packs/come fromGUIDE_SHOTS=1 pnpm exec playwright test e2e/packs.spec.ts. - When a feature adds or renames a guide section, add or update its tip in
src/shared/tips/tips.json(id, one or two plain sentences,guideasdocs/guides/<file>.md#<anchor>,linkLabel, andsincefor a feature newer than the last release). Never reuse an id.pnpm testfails when a tip links to a heading that does not exist, so renaming a heading means updating its tips. - Any architectural choice (a new library, a storage format change, a new process boundary) gets an ADR in
docs/adr/NNNN-title.md, copied fromdocs/adr/0000-template.md: context, decision, consequences, kept short. To reverse a decision, write a new ADR and mark the old oneSuperseded by ADR-XXXX.
Design documents
Section titled “Design documents”| Doc | Purpose |
|---|---|
docs/01-PRD.md |
Product requirements |
docs/02-ARCHITECTURE.md |
Processes, storage, providers, rendering, export |
docs/03-INJECT-TAXONOMY.md |
Supported inject types, fields and render targets |
docs/04-TABLETOP-METHODOLOGY.md |
HSEEP-style exercise structure the generator reproduces |
docs/05-DATA-MODEL.md |
Project / Scenario / Inject / Asset schema |
docs/06-GITHUB-WORKFLOW.md |
Issues, labels, milestones, branching, local CI |
docs/08-ROADMAP.md |
Milestones M0 to M6 of the first release |
docs/09-CODE-CONVENTIONS.md |
How handlers, routes, adapters and templates are wired |
docs/packs-format.md |
The .aspack format, pack.json and catalogs |
docs/RELEASING.md |
Building installers and cutting a release |
docs/research/domains/ |
Research notes behind each domain profile and pack |
Architecture decision records
Section titled “Architecture decision records”| ADR | Decision |
|---|---|
| 0001 | Application stack: Electron, React, TypeScript, Vite, pnpm, SQLite + sqlite-vec |
| 0002 | Local CI only, no GitHub Actions (superseded by 0023) |
| 0003 | Project folder format |
| 0004 | Native modules and the test runtime |
| 0005 | Embeddings |
| 0006 | Provider adapters over fetch |
| 0007 | Structured output from LLMs |
| 0008 | Template rendering to PNG and PDF |
| 0009 | Audio and TTS (amended by 0010) |
| 0010 | Local TTS through the OpenAI-compatible speech API |
| 0011 | LTX-Video as the default local video model |
| 0012 | Inject stages: schema-guided text, deterministic media plan, keep-with-warning |
| 0013 | Serve the renderer from app://bundle/ |
| 0014 | Domain profiles: domain-neutral generation as data |
| 0015 | Repair a finished scenario in place |
| 0016 | Forward-compatible project files |
| 0017 | Memoized rows and windowed long lists (MSEL grid, inject deck) |
| 0018 | Packs: installable content bundles |
| 0019 | Rename to Action Stations; migrate old settings, projects and keys |
| 0020 | Built-in content ships as bundled packs |
| 0021 | Optional setup of local model tools, or cloud APIs only |
| 0023 | GitHub Actions where it is free (release installers) |
| 0024 | Opt-in update check |
| 0025 | Core Capabilities and Exercise Evaluation Guides from the plans |
| 0026 | Surveys are anonymous by construction |
| 0027 | Live play protocol: two session keys, frames to the controller sealed to it, signed controller frames, no server reads |
| 0028 | Deterministic grounding first, model verification only with validated quotes |
| 0030 | HSEEP documents as shared blocks; the MSEL workbook with exceljs |
| 0031 | One outbound gate at provider resolution, with keyed deterministic placeholders |
| 0039 | Local model benchmark drives the built app with deterministic scoring |
| 0040 | Regulations and control frameworks as pack data (pack format 2) |
| 0041 | Local speech to text for the hot wash |
New ADRs start from docs/adr/0000-template.md.
Code conventions
Section titled “Code conventions”These sit alongside the project rules:
- No secrets in code, project folders or logs.
- Persisted JSON is defined by zod schemas in
src/shared/schemas/. - IPC goes through typed channels in
src/shared/ipc/; no rawipcRendererin components. - UI uses
src/renderer/src/components/ui.tsxand the design tokens instyles/tokens.css; every action must work from the keyboard, and colour contrast must meet WCAG AA. - Generated output is measured against the hand-written exercises in
reference/scenario-3/. A change to prompts or generation that makes the MSEL read worse next to that one is not finished.
