Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1c3ad4d
ci(ci): move mutation to a release gate on main
systemfsoftware-maker Oct 5, 2026
c27ada8
ci(ci): run the release gate on github-hosted runners
systemfsoftware-maker Oct 6, 2026
3f6961a
fix(ci): refuse a mutation shard whose mutate globs match no files
systemfsoftware-maker Oct 6, 2026
1257804
deps(deps): move to effect 4 stable at exact pins
systemfsoftware-maker Oct 5, 2026
792a109
build(repo): move lint, test and mutation settings to the root
systemfsoftware-maker Oct 5, 2026
0ecf1c3
build(repo): declare the effect/http unstable-api opt-in once at the …
systemfsoftware-maker Oct 5, 2026
9a8cee1
build(repo): extend the root unstable-api opt-in to effect/observability
systemfsoftware-maker Oct 5, 2026
34c2111
build(repo): add the one-command local stack
systemfsoftware-maker Oct 5, 2026
946ef5a
build(repo): bound the local stack's shutdown
systemfsoftware-maker Oct 5, 2026
3ec7e5b
fix(repo): keep every tempo path inside the repo
systemfsoftware-maker Oct 6, 2026
ea247d9
build(repo): start the local stack with no terminal
systemfsoftware-maker Oct 6, 2026
c2767fa
fix(repo): keep Grafana off the network at startup
systemfsoftware-maker Oct 6, 2026
4f8ea78
ci(ci): run the headless local stack check as its own linux leg
systemfsoftware-maker Oct 6, 2026
3d52ac0
ci(ci): let the local-stack leg create a user namespace
systemfsoftware-maker Oct 6, 2026
bf84ca9
feat(repo): serve the home page as markdown or html from one worker
systemfsoftware-maker Oct 5, 2026
090e367
fix(repo): refine llms.txt page and origin schemas to valid link parts
systemfsoftware-maker Oct 5, 2026
c5e5a8b
chore(repo): stop tracking tanstack router generator temp files
systemfsoftware-maker Oct 5, 2026
7da7166
build(repo): bound the site process's shutdown
systemfsoftware-maker Oct 5, 2026
65dad48
fix(repo): generate the route tree before lint and typecheck
systemfsoftware-maker Oct 6, 2026
f513e9c
chore(repo): stop tracking the tanstack router temp file again
systemfsoftware-maker Oct 6, 2026
eb40105
fix(repo): brand the front-door origin, derive routes from one catalo…
systemfsoftware-maker Oct 6, 2026
a3dd5e7
fix(repo): validate README home markers at build time and cover the o…
systemfsoftware-maker Oct 6, 2026
ca9338c
docs(repo): link llms.txt from the home Markdown body
systemfsoftware-maker Oct 6, 2026
cbb357b
test(repo): assert the llms.txt link and the composed render request
systemfsoftware-maker Oct 6, 2026
8bb3cbd
test(repo): bound every site request with a deadline
systemfsoftware-maker Oct 6, 2026
88cefb0
test(repo): cover the front door's negotiated 500 on a failed render
systemfsoftware-maker Oct 6, 2026
29a8785
build(repo): declare the site's mutate globs where the shard planner …
systemfsoftware-maker Oct 6, 2026
1c7bbf5
deps(deps): pin the workers vite plugin, wrangler and miniflare
systemfsoftware-maker Oct 6, 2026
a276687
fix(repo): run dev and preview in workerd through the workers vite pl…
systemfsoftware-maker Oct 6, 2026
c9f0922
test(repo): boot the built worker in miniflare from a scoped effect l…
systemfsoftware-maker Oct 6, 2026
08eab1b
test(repo): prove the front door's negotiated 500 inside workerd
systemfsoftware-maker Oct 6, 2026
f4c7662
refactor(repo): delete the unreachable markdown failure branch
systemfsoftware-maker Oct 6, 2026
892ff74
fix(repo): let alchemy's deploy build skip the official workers plugin
systemfsoftware-maker Oct 6, 2026
fe3c48d
ci(ci): run the e2e journeys on every pr
systemfsoftware-maker Oct 5, 2026
5c3bf46
ci(ci): make a red stack job name the process that failed
systemfsoftware-maker Oct 6, 2026
22e6b34
refactor(repo): delete the hello seed package
systemfsoftware-maker Oct 5, 2026
6cc0868
feat(repo): trace the front door locally and in cloudflare
systemfsoftware-maker Oct 5, 2026
f097b34
test(repo): give the stack import scenario a cold-start budget
systemfsoftware-maker Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pnpm change --bump <none|patch|minor|major> --summary "<changelog entry>" [<pkg>
- This README is NOT a changeset: the gate requires a file whose frontmatter
parses as `"<pkg>": <none|patch|minor|major>`.

Publishing uses npm OIDC trusted publishing from `.github/workflows/release.yml`.
Register `@TODO/starter` (TODO: your package name) as a trusted publisher on npmjs.com
pointing at this repository and that workflow filename before the first new
version can ship. OIDC cannot debut a package npm has never seen.
Releases are git tags and GitHub Releases from `.github/workflows/release.yml`;
nothing is published to a registry. A version in a package manifest with no
matching `<name>@v<version>` tag is owed: merging the Release PR tags it and
publishes its authored changelog as a GitHub Release.
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- leg: dist
runs-on: ubuntu-latest
gate: pnpm gate:dist
- leg: local-stack
runs-on: ubuntu-latest
gate: pnpm check:ci-run
- leg: macos
runs-on: macos-latest
gate: pnpm check:ci
Expand Down Expand Up @@ -62,5 +65,39 @@ jobs:
restore-keys: |
turbo-${{ runner.os }}-dist-
turbo-${{ runner.os }}-
- name: Lift the ubuntu-24.04 AppArmor block on user namespaces for the network-denied stack check
if: matrix.leg == 'local-stack'
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Gate
run: ${{ matrix.gate }}

stack:
name: stack
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CI: "true"
steps:
- uses: actions/checkout@v7
- uses: cachix/install-nix-action@v31
- uses: pnpm/setup@v2
with:
runtime: node@24
cache: true
- run: pnpm install --frozen-lockfile
- name: Realise the dev shell and local stack
run: nix develop --command true
- name: Local stack journeys
run: nix develop --command pnpm stack:ci
- name: Local stack report
if: failure() && hashFiles('local-stack/data/stack-report.md') != ''
run: cat local-stack/data/stack-report.md >> "$GITHUB_STEP_SUMMARY"
- if: always()
uses: actions/upload-artifact@v6
with:
name: stack-artifacts
path: |
apps/site-e2e/artifacts/
local-stack/data/process-compose.log
local-stack/data/stack-report.md
if-no-files-found: ignore
63 changes: 63 additions & 0 deletions .github/workflows/release-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Release gate

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: release-gate-${{ github.ref }}
cancel-in-progress: false

jobs:
plan:
name: plan · mutation shards
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
packages: ${{ steps.shards.outputs.packages }}
steps:
- uses: actions/checkout@v7
- uses: denoland/setup-deno@v2
- id: shards
run: ./scripts/mutation-shards.ts --output "$GITHUB_OUTPUT"

mutation:
name: mutation · ${{ matrix.package }}
needs: [plan]
runs-on: ubuntu-latest
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.plan.outputs.packages) }}
env:
CI: "true"
steps:
- uses: actions/checkout@v7
- uses: pnpm/setup@v2
with:
runtime: node@24
cache: true
- run: pnpm install --frozen-lockfile
- name: Stryker incremental cache
uses: actions/cache@v6
with:
path: |
apps/*/reports/stryker-incremental.json
packages/*/reports/stryker-incremental.json
key: stryker-${{ matrix.package }}-${{ github.sha }}
restore-keys: stryker-${{ matrix.package }}-
- name: Mutation at break 100
run: pnpm exec turbo run mutation --filter="${{ matrix.package }}"
- if: always()
uses: actions/upload-artifact@v6
with:
name: mutation-report-${{ strategy.job-index }}
path: |
apps/*/reports/mutation-report.*
packages/*/reports/mutation-report.*
if-no-files-found: ignore
11 changes: 3 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,11 @@ jobs:
BASE: ${{ github.event.repository.default_branch }}
run: ./scripts/open-release-pr.sh

publish:
if: needs.plan.outputs.phase == 'publish'
release:
if: needs.plan.outputs.phase == 'release'
permissions:
contents: write
id-token: write
name: publish · oidc · tag
name: release · tag
needs: [plan]
runs-on: ubuntu-latest
env:
Expand All @@ -101,10 +100,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/create-github-releases.ts --assert --captured /tmp/captured.json
- name: Build
run: pnpm build
- name: Publish unpublished versions via OIDC
run: ./scripts/tag-released-packages.ts --unpublished --captured /tmp/captured.json --publish
- name: Tag released versions
run: ./scripts/tag-released-packages.ts --captured /tmp/captured.json
- name: GitHub Releases from authored changelogs
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ reports/
.direnv/
result
result-*
local-stack/data/
.alchemy/
apps/site/.wrangler/
apps/site/src/routeTree.gen.ts
apps/site/.tanstack/
apps/site/artifacts/
apps/site-e2e/artifacts/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Starter template for TypeScript / Effect libraries and tools.
| `START-2` | Typechecking succeeds workspace-wide with no errors | `pnpm typecheck` |
| `START-3` | All test suites pass | `pnpm test` |
| `START-4` | Full CI validation passes before completion | `pnpm check:ci` |
| `START-5` | The local stack's e2e journeys pass | `pnpm stack:ci` |

Workspace roots: `packages/` holds libraries, `apps/` holds publishable
applications — both are workspace globs in `pnpm-workspace.yaml`. Turbo declares
Expand Down
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
# starter

[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![Effect: 4.x](https://img.shields.io/badge/Effect-4.0_RC-purple.svg)](https://effect.website)
[![Effect: 4.0](https://img.shields.io/badge/Effect-4.0-purple.svg)](https://effect.website)
[![CI](https://github.com/systemfsoftware/starter/actions/workflows/ci.yml/badge.svg)](https://github.com/systemfsoftware/starter/actions/workflows/ci.yml)

> 🏛️ **starter** is an opinionated monorepo template for serious TypeScript with Effect and AI coding agents.
> 🔒 One architecture, zero knobs, and mechanical gates that reject the slop agents produce when left unconstrained.
> 🚀 Built for engineers accountable for codebases where AI writes the commits.

<!-- home:start -->

# Build with agents you can still trust

An agent will happily hand you code that compiles, passes the tests it wrote itself, and quietly
breaks the architecture underneath. The build is green and the codebase is no longer yours.

This is the substrate that refuses that trade. Every boundary is decoded rather than asserted,
every decision is a pure function under a complexity ceiling, and every claim a test makes is
checked by mutation. What reaches `main` is what you would have written by hand.

Point an agent at a capability and let it grow one layer at a time.

Read this page as plain text at [llms.txt](/llms.txt).

<!-- home:end -->

---

## 💡 Why
Expand Down Expand Up @@ -62,8 +79,7 @@ Phase ordering is guaranteed at compile time: each phase returns branded markers
| 🎨 **dprint** | Fast, deterministic code and markdown formatting (`dprint.json`) |
| 🧪 **Vitest** | Fast unit and integration test runner with TypeScript support |
| 🔬 **Stryker** | Mutation testing ensuring tests fail when bugs are introduced |
| 📦 **tsdown** | Fast TypeScript bundler building dual ESM and type declarations |
| 📝 **Changesets** | Automated versioning and changelog generation with npm OIDC provenance |
| 📝 **Changesets** | Automated versioning, changelogs, git tags and GitHub Releases |
| 🪝 **Husky & Commitlint** | Git hooks enforcing conventional commit standards |
| 🌳 **Worktrunk Scripts** | Deno-powered git worktree lifecycle hooks for isolated agent work |

Expand All @@ -76,13 +92,13 @@ The repository is structured into two workspace roots defined in `pnpm-workspace
```text
.
├── packages/ # Reusable libraries, engines, and domain cores
│ └── starter/ # Seed library template (rename to your package)
├── apps/ # Declared workspace root for apps and CLI tools (added as needed)
├── apps/ # The Worker and its end-to-end journeys
├── repos/ # Vendored subtrees (constitution, worktrunk-scripts)
└── docs/ # Solutions, tooling decisions, and plans
```

- [`packages/starter`](packages/starter) — The starter package scaffold with pre-configured build, lint, test, and mutation configs.
- [`apps/site`](apps/site) — The one Cloudflare Worker: serves the home page as Markdown or HTML, plus `/llms.txt`.
- [`apps/site-e2e`](apps/site-e2e) — The end-to-end journeys that run against the local stack (`pnpm stack:ci`).

---

Expand All @@ -103,13 +119,7 @@ cd my-effect-project
pnpm install
```

### 3. Claim the Package

1. Rename `packages/starter` to your desired package name (e.g., `packages/my-lib`).
2. Update `name`, `description`, and `author` in `packages/starter/package.json`.
3. Remove `"private": true` from `package.json` when you are ready to publish.

### 4. Build and Verify
### 3. Build and Verify

```bash
pnpm build
Expand Down Expand Up @@ -147,7 +157,7 @@ pnpm check:ci
## ❓ Frequently Asked Questions

<details>
<summary><strong>Why does starter pin Effect 4 RC instead of Effect 3?</strong></summary>
<summary><strong>Why does starter require Effect 4 instead of Effect 3?</strong></summary>

Effect 4 introduces first-class primitives for cell composition, branded type ordering, and modern schema transformations that enable the endgame architecture. `starter` targets the future of Effect rather than supporting legacy patterns.

Expand Down
4 changes: 4 additions & 0 deletions apps/site-e2e/oxlint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineConfig } from 'oxlint'
import shared from '../../oxlint.shared.ts'

export default defineConfig({ extends: [shared] })
27 changes: 27 additions & 0 deletions apps/site-e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"author": "TODO <todo@example.com>",
"description": "Seam-only journeys against the running site Worker",
"devDependencies": {
"@systemfsoftware/effect-gherkin-spec": "catalog:",
"@systemfsoftware/oxlint-config-recommended": "catalog:",
"@systemfsoftware/tsconfig": "catalog:",
"@systemfsoftware/vitest": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"oxlint": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
"license": "Apache-2.0",
"name": "@endgame/site-e2e",
"private": true,
"scripts": {
"lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --format=${f:-default}",
"test:deployed": "vitest run --project deployed",
"test:local": "vitest run --project local",
"typecheck": "tsc --noEmit --incremental"
},
"type": "module",
"version": "0.0.0"
}
90 changes: 90 additions & 0 deletions apps/site-e2e/tests/__fixtures__/site.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Config, ConfigProvider, Data, Duration, Effect } from 'effect'
import { HttpClient, HttpClientError, HttpClientRequest } from 'effect/http'

import readmeSource from '../../../../README.md?raw'

const README_HOME_START = '<!-- home:start -->'
const README_HOME_END = '<!-- home:end -->'

const REQUEST_TIMEOUT = Duration.seconds(30)

export const readmeOpening = (): string =>
readmeSource
.slice(readmeSource.indexOf(README_HOME_START) + README_HOME_START.length, readmeSource.indexOf(README_HOME_END))
.trim()

export const readmeH1 = (): string =>
(readmeOpening().split('\n').find((line) => line.startsWith('# ')) ?? '').replace(/^#\s+/, '')

export class SiteRequestTimedOut extends Data.TaggedError('SiteRequestTimedOut')<{
readonly url: string
readonly elapsedMillis: number
}> {
override get message(): string {
return `site request to ${this.url} did not finish within ${this.elapsedMillis}ms`
}
}

export interface FetchedPage {
readonly origin: string
readonly status: number
readonly contentType: string | null
readonly vary: string | null
readonly body: string
}

export interface SiteRequest {
readonly path: string
readonly accept?: string
readonly origin?: string
readonly timeout?: Duration.Input
}

const withDeadline = <A, E, R>(
url: string,
timeout: Duration.Input,
effect: Effect.Effect<A, E, R>,
): Effect.Effect<A, E | SiteRequestTimedOut, R> =>
Effect.gen(function*() {
const startedAt = performance.now()
return yield* effect.pipe(
Effect.timeoutOrElse({
duration: timeout,
orElse: () =>
Effect.fail(
new SiteRequestTimedOut({ url, elapsedMillis: Math.round(performance.now() - startedAt) }),
),
}),
)
})

export const fetchSite = (
site: SiteRequest,
): Effect.Effect<FetchedPage, SiteRequestTimedOut | HttpClientError.HttpClientError, HttpClient.HttpClient> =>
Effect.gen(function*() {
const configuredOrigin = yield* Effect.orDie(
Config.String('SITE_URL').pipe(
Config.withDefault('http://localhost:1337'),
(config) => config.parse(ConfigProvider.fromEnv()),
),
)
const origin = site.origin ?? configuredOrigin
const client = yield* HttpClient.HttpClient
const base = HttpClientRequest.get(`${origin}${site.path}`)
const request = site.accept === undefined ? base : HttpClientRequest.setHeader(base, 'accept', site.accept)
const timeout = site.timeout ?? REQUEST_TIMEOUT
return yield* withDeadline(
`${origin}${site.path}`,
timeout,
Effect.gen(function*() {
const response = yield* client.execute(request)
return {
origin,
status: response.status,
contentType: response.headers['content-type'] ?? null,
vary: response.headers['vary'] ?? null,
body: yield* Effect.orDie(response.text),
}
}),
)
})
Loading
Loading