@skyf0xx/hedgehog 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/bin/cli.mjs +156 -0
- package/package.json +30 -0
- package/src/agents/planner.md +245 -0
- package/src/agents/reviewer.md +95 -0
- package/src/agents/ui-builder.md +73 -0
- package/src/agents/ux-planner.md +149 -0
- package/src/skills/conventional-commits/SKILL.md +147 -0
- package/src/skills/hedgehog-bootstrap/SKILL.md +403 -0
- package/src/skills/hedgehog-loop/SKILL.md +197 -0
- package/src/templates/CLAUDE.md +171 -0
- package/src/templates/TODO.md +43 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hedgehog-bootstrap
|
|
3
|
+
description: Use once, at the start of a new Hedgehog project, to scaffold Project Bootstrap and wire in the enforcement config that makes the stack and build order mechanically true (Nx boundaries, lefthook, commitlint, env validation, phase gate). Triggers on "bootstrap this project", "set up the hedgehog stack", "scaffold the workspace". Not for per-module work — that's the `hedgehog-loop` skill, one step at a time.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Hedgehog Bootstrap
|
|
7
|
+
|
|
8
|
+
Scaffolds Project Bootstrap once per project, plus the enforcement config
|
|
9
|
+
that makes the stack and build order mechanically true rather than merely
|
|
10
|
+
documented. After this runs, `hedgehog-loop` takes over per module, one
|
|
11
|
+
step at a time. This skill touches no domain modules — no schema, no
|
|
12
|
+
contract, nothing under `libs/<module>/`. That's Phase A, started fresh
|
|
13
|
+
after Bootstrap closes.
|
|
14
|
+
|
|
15
|
+
Run the `nx g` commands below via nrwl's [nx-generate](https://github.com/nrwl/nx-ai-agents-config/tree/main/skills/nx-generate) skill — it dry-runs
|
|
16
|
+
and verifies generator flags against the installed Nx version. Run the
|
|
17
|
+
`nx run` / `nx affected` commands in Enforcement wiring via
|
|
18
|
+
[nx-run-tasks](https://github.com/nrwl/nx-ai-agents-config/tree/main/skills/nx-run-tasks) the same way. The commands below are the spec; those
|
|
19
|
+
skills execute it correctly.
|
|
20
|
+
|
|
21
|
+
## The Stack (locked)
|
|
22
|
+
|
|
23
|
+
One opinionated stack, applied the same way on every project:
|
|
24
|
+
|
|
25
|
+
| Layer | Choice |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Monorepo | Nx |
|
|
28
|
+
| Package manager | pnpm |
|
|
29
|
+
| Backend framework | NestJS |
|
|
30
|
+
| ORM | Drizzle (+ `drizzle-zod`) |
|
|
31
|
+
| Database | PostgreSQL |
|
|
32
|
+
| Platform | Railway |
|
|
33
|
+
| API contract | ts-rest |
|
|
34
|
+
| Validation | Zod |
|
|
35
|
+
| Auth | Better Auth (+ `@thallesp/nestjs-better-auth`, Drizzle adapter) |
|
|
36
|
+
| Data fetching / hooks | TanStack Query |
|
|
37
|
+
| Web UI | Next.js (frontend only) + ShadCN + Tailwind |
|
|
38
|
+
| Mobile UI (optional) | Expo + React Native Reusables + NativeWind |
|
|
39
|
+
| Queues / jobs | BullMQ + Redis |
|
|
40
|
+
| Logging | Pino (`nestjs-pino`) |
|
|
41
|
+
| Lint / format | ESLint (flat config) + Prettier (+ `prettier-plugin-tailwindcss`) |
|
|
42
|
+
| Testing | Vitest (unit/integration) + Playwright (web e2e) |
|
|
43
|
+
| Commits | Conventional Commits + commitlint + lefthook |
|
|
44
|
+
| Observability | Sentry |
|
|
45
|
+
|
|
46
|
+
Constraint-contingent substitutions: Prisma for Drizzle when the team
|
|
47
|
+
isn't SQL-comfortable; cloud + Pulumi/SST for Railway when full
|
|
48
|
+
declarative IaC is a hard requirement; tRPC for ts-rest when the client is
|
|
49
|
+
committed TypeScript-only.
|
|
50
|
+
|
|
51
|
+
### Monorepo layout
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
apps/
|
|
55
|
+
web (Next.js — UI only)
|
|
56
|
+
mobile (Expo — optional)
|
|
57
|
+
api (NestJS — owns all domain logic + DB access)
|
|
58
|
+
worker (BullMQ consumers)
|
|
59
|
+
|
|
60
|
+
packages/
|
|
61
|
+
db (Drizzle schema + client)
|
|
62
|
+
contracts (ts-rest + Zod contracts)
|
|
63
|
+
hooks (TanStack Query — shared web + mobile)
|
|
64
|
+
jobs (typed job registry / queue definitions)
|
|
65
|
+
auth (Better Auth config)
|
|
66
|
+
config (locked ESLint/Prettier/tsconfig/env schema)
|
|
67
|
+
shared (cross-cutting types + utils)
|
|
68
|
+
|
|
69
|
+
docs/
|
|
70
|
+
design (<module>.md per module — `ux-planner` agent output)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`packages/auth` and `packages/jobs` are infra, built once, here — not
|
|
74
|
+
touched again per module. `docs/design` fills in per module during
|
|
75
|
+
Phase B; nothing to scaffold here beyond the empty directory.
|
|
76
|
+
|
|
77
|
+
### Queues: seam in, usage deferred
|
|
78
|
+
|
|
79
|
+
The queue seam is a day-one standing default: Redis provisioned on
|
|
80
|
+
Railway, a `worker` app in the monorepo, a `Queue` port with a BullMQ
|
|
81
|
+
adapter (same pattern as repositories). Usage stays last-responsible-
|
|
82
|
+
moment: an operation goes async only when it genuinely needs to
|
|
83
|
+
(long-running work, retries, fan-out). Services don't know how their
|
|
84
|
+
results are returned — the enqueue-vs-await decision lives at the
|
|
85
|
+
application/controller layer. Workers are idempotent (at-least-once
|
|
86
|
+
delivery).
|
|
87
|
+
|
|
88
|
+
## Before running
|
|
89
|
+
|
|
90
|
+
Confirm Intake already happened — a scope boundary and domain vocabulary
|
|
91
|
+
should exist (`planner` produces these). Bootstrap doesn't need the
|
|
92
|
+
vocabulary to scaffold infra, but starting before Intake signals work
|
|
93
|
+
getting ahead of itself. No scope boundary yet: stop and point to
|
|
94
|
+
`planner`.
|
|
95
|
+
|
|
96
|
+
Confirm this hasn't already run: check for an existing Nx workspace
|
|
97
|
+
(`nx.json` at repo root) or a prior Bootstrap commit
|
|
98
|
+
(`git log --grep="^feat(config)"`). Re-running Bootstrap against an
|
|
99
|
+
existing workspace is a Correction Protocol case (patch the specific
|
|
100
|
+
config step at its source, per `hedgehog-loop`), not a re-scaffold.
|
|
101
|
+
|
|
102
|
+
## Steps (run in sequence, one commit per step)
|
|
103
|
+
|
|
104
|
+
### 1. Nx workspace + `packages/config`
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npx create-nx-workspace@latest . --preset=ts --pm=pnpm --nxCloud=skip
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Scaffold `packages/config` as a plain `@nx/js` lib holding the locked,
|
|
111
|
+
shared files:
|
|
112
|
+
|
|
113
|
+
- `packages/config/eslint-base.js` — flat config, extended by every
|
|
114
|
+
app/lib. Include `@nx/enforce-module-boundaries` and `depConstraints`
|
|
115
|
+
from Enforcement wiring below, verbatim.
|
|
116
|
+
- `packages/config/prettier.js` — includes `prettier-plugin-tailwindcss`.
|
|
117
|
+
- `packages/config/env.schema.ts` — the Zod env schema from Enforcement
|
|
118
|
+
wiring below (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `REDIS_URL`,
|
|
119
|
+
`NODE_ENV`; extend per project as new infra is added later).
|
|
120
|
+
- Root `eslint.config.js` extends `packages/config/eslint-base.js` and
|
|
121
|
+
declares the project tags table below (`scope:*`, `type:*`) as comments
|
|
122
|
+
or a lookup, so every later generator step tags its project correctly.
|
|
123
|
+
|
|
124
|
+
Commit: `feat(config): workspace + shared config`
|
|
125
|
+
|
|
126
|
+
### 2. `packages/db` — Drizzle client + connection
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx nx g @nx/js:lib packages/db --bundler=none --unitTestRunner=vitest
|
|
130
|
+
pnpm add drizzle-orm pg && pnpm add -D drizzle-kit drizzle-zod
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Wire a Postgres connection reading `DATABASE_URL` via `loadEnv()`
|
|
134
|
+
(step 1's env schema). No domain schema files yet — that's Phase A, per
|
|
135
|
+
module. Tag: `scope:db`, `type:adapter`.
|
|
136
|
+
|
|
137
|
+
Commit: `feat(db): drizzle client + connection`
|
|
138
|
+
|
|
139
|
+
### 3. `packages/auth` — Better Auth config
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npx nx g @nx/js:lib packages/auth --bundler=none --unitTestRunner=vitest
|
|
143
|
+
pnpm add better-auth @thallesp/nestjs-better-auth
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Configure the Drizzle adapter against `packages/db`. Tag: `scope:auth`,
|
|
147
|
+
`type:adapter`.
|
|
148
|
+
|
|
149
|
+
Commit: `feat(auth): better auth config`
|
|
150
|
+
|
|
151
|
+
### 4. `apps/api` — Nest app shell, global guard, Pino
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
npx nx g @nx/nest:app apps/api
|
|
155
|
+
pnpm add nestjs-pino pino-http && pnpm add @thallesp/nestjs-better-auth
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Wire the global auth guard (secure-by-default) and `nestjs-pino` for
|
|
159
|
+
structured logging. Call `loadEnv()` at the top of `apps/api/src/main.ts`.
|
|
160
|
+
Tag: `scope:api`. No controllers beyond a health check — domain
|
|
161
|
+
controllers arrive per module in Phase A.
|
|
162
|
+
|
|
163
|
+
Commit: `feat(api): nest shell + global guard + pino`
|
|
164
|
+
|
|
165
|
+
### 5. `apps/worker` — BullMQ seam (Redis, no consumers yet)
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npx nx g @nx/node:app apps/worker
|
|
169
|
+
pnpm add bullmq ioredis
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Provision the Redis connection and a `Queue` port shape (port + BullMQ
|
|
173
|
+
adapter, same pattern repositories use later) with no consumers — usage
|
|
174
|
+
is deferred (see Queues, above). Call `loadEnv()` at the top of
|
|
175
|
+
`apps/worker/src/main.ts`. Tag: `scope:worker`.
|
|
176
|
+
|
|
177
|
+
Commit: `feat(worker): bullmq seam, no consumers`
|
|
178
|
+
|
|
179
|
+
### 6. `apps/web` — Next shell, TanStack Query provider, base theme
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npx nx g @nx/next:app apps/web
|
|
183
|
+
pnpm add @tanstack/react-query
|
|
184
|
+
pnpm dlx shadcn@latest init
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Wire the TanStack Query provider at the root layout. `shadcn init` writes
|
|
188
|
+
`apps/web`'s base theme (CSS variables for color, radius, light/dark
|
|
189
|
+
mode) — set the actual palette here, once, rather than leaving ShadCN's
|
|
190
|
+
placeholder values for `ui-builder` to inherit unnoticed on the first
|
|
191
|
+
screen. Light/dark mode toggle wiring belongs here too, not as a
|
|
192
|
+
per-screen decision later. No screens or hooks yet — Phase B doesn't
|
|
193
|
+
start until Phase A closes for at least one module. Tag: `scope:web`.
|
|
194
|
+
|
|
195
|
+
Commit: `feat(web): next shell + query provider + base theme`
|
|
196
|
+
|
|
197
|
+
### 7. `apps/mobile` — Expo shell (only if mobile is in scope)
|
|
198
|
+
|
|
199
|
+
Skip entirely if mobile isn't in the scope boundary from Intake — don't
|
|
200
|
+
scaffold speculative infra.
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
npx nx g @nx/expo:app apps/mobile
|
|
204
|
+
pnpm add react-native-reusables nativewind
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Configure NativeWind's theme (`tailwind.config.js` colors, light/dark) to
|
|
208
|
+
match `apps/web`'s base theme from step 6 — one visual identity across
|
|
209
|
+
platforms, set once here rather than drifting per-screen. Tag:
|
|
210
|
+
`scope:mobile`.
|
|
211
|
+
|
|
212
|
+
Commit: `feat(mobile): expo shell + base theme`
|
|
213
|
+
|
|
214
|
+
## Enforcement wiring (within step 1, not a separate pass)
|
|
215
|
+
|
|
216
|
+
These are config *files*, not extra steps — write them as part of step 1
|
|
217
|
+
so the commit gate is live before step 2 starts. Every rule below is a
|
|
218
|
+
compiler error, lint failure, or blocked commit — this is what makes the
|
|
219
|
+
stack and build order mechanically true.
|
|
220
|
+
|
|
221
|
+
### Nx module boundaries
|
|
222
|
+
|
|
223
|
+
Encodes "service imports only ports" as a build-time failure.
|
|
224
|
+
`@nx/enforce-module-boundaries` reasons at Nx-project granularity, so each
|
|
225
|
+
domain module's repository and service are their own Nx lib; `apps/api`
|
|
226
|
+
itself is wiring (controllers + module registration) importing those
|
|
227
|
+
libs. This makes cross-module isolation (FK-by-ID only, per
|
|
228
|
+
`hedgehog-loop`) mechanically true.
|
|
229
|
+
|
|
230
|
+
**Tags:**
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
apps/api → scope:api
|
|
234
|
+
apps/worker → scope:worker
|
|
235
|
+
apps/web → scope:web
|
|
236
|
+
apps/mobile → scope:mobile
|
|
237
|
+
packages/db → scope:db, type:adapter
|
|
238
|
+
packages/contracts → scope:contracts, type:contract
|
|
239
|
+
packages/hooks → scope:hooks, type:hook
|
|
240
|
+
packages/auth → scope:auth, type:adapter
|
|
241
|
+
packages/shared → scope:shared, type:util
|
|
242
|
+
libs/<module>/port → scope:<module>, type:port
|
|
243
|
+
libs/<module>/repository → scope:<module>, type:adapter
|
|
244
|
+
libs/<module>/service → scope:<module>, type:service
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
One `libs/<module>/` triplet per domain module (one table = one module) —
|
|
248
|
+
e.g. `libs/orders/port`, `libs/orders/repository`, `libs/orders/service`.
|
|
249
|
+
|
|
250
|
+
**Root `eslint.config.js` rule:**
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
'@nx/enforce-module-boundaries': ['error', {
|
|
254
|
+
depConstraints: [
|
|
255
|
+
// domain services never import adapters directly — only ports
|
|
256
|
+
{ sourceTag: 'type:service', onlyDependOnLibsWithTags: ['type:port', 'type:util'] },
|
|
257
|
+
// web/mobile never import db or api internals — only contracts + hooks
|
|
258
|
+
{ sourceTag: 'scope:web', onlyDependOnLibsWithTags: ['scope:contracts', 'scope:hooks', 'scope:shared'] },
|
|
259
|
+
{ sourceTag: 'scope:mobile', onlyDependOnLibsWithTags: ['scope:contracts', 'scope:hooks', 'scope:shared'] },
|
|
260
|
+
// worker only reaches domain logic through ports, same as api
|
|
261
|
+
{ sourceTag: 'scope:worker', onlyDependOnLibsWithTags: ['type:port', 'type:util', 'scope:shared'] },
|
|
262
|
+
],
|
|
263
|
+
}],
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Building out of order (a controller before a service exists, a hook
|
|
267
|
+
reaching into `apps/api` directly) fails `nx lint`.
|
|
268
|
+
|
|
269
|
+
### Commit gate (lefthook + commitlint)
|
|
270
|
+
|
|
271
|
+
Enforces one coherent change, tested, conventionally committed. Runs on
|
|
272
|
+
staged files only — fast regardless of repo size.
|
|
273
|
+
|
|
274
|
+
**`lefthook.yml`:**
|
|
275
|
+
|
|
276
|
+
```yaml
|
|
277
|
+
pre-commit:
|
|
278
|
+
parallel: true
|
|
279
|
+
commands:
|
|
280
|
+
typecheck:
|
|
281
|
+
glob: "*.{ts,tsx}"
|
|
282
|
+
run: npx nx affected -t typecheck --files={staged_files}
|
|
283
|
+
lint:
|
|
284
|
+
glob: "*.{ts,tsx}"
|
|
285
|
+
run: npx nx affected -t lint --files={staged_files}
|
|
286
|
+
test:
|
|
287
|
+
glob: "*.{ts,tsx}"
|
|
288
|
+
run: npx nx affected -t test --files={staged_files}
|
|
289
|
+
|
|
290
|
+
commit-msg:
|
|
291
|
+
commands:
|
|
292
|
+
commitlint:
|
|
293
|
+
run: npx commitlint --edit {1}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**`commitlint.config.js`:**
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
module.exports = {
|
|
300
|
+
extends: ['@commitlint/config-conventional'],
|
|
301
|
+
};
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Scope is open — a domain module (`orders`, `users`, ...) or an infra area
|
|
305
|
+
(`db`, `contracts`, `auth`, `hooks`, `api`, `worker`, `web`, `mobile`,
|
|
306
|
+
`config`). `@commitlint/config-conventional` validates type and subject
|
|
307
|
+
case; scope isn't restricted to a fixed list since new modules enter play
|
|
308
|
+
throughout a project's life.
|
|
309
|
+
|
|
310
|
+
A commit that fails typecheck, lint, or test does not happen. A commit
|
|
311
|
+
exists once it compiles and passes.
|
|
312
|
+
|
|
313
|
+
Run `pnpm dlx lefthook install` once `lefthook.yml` exists so the gate is
|
|
314
|
+
active from step 2 onward.
|
|
315
|
+
|
|
316
|
+
### Env validation (fail fast)
|
|
317
|
+
|
|
318
|
+
Types-first extended to config. Boot fails immediately on a missing or
|
|
319
|
+
malformed env var.
|
|
320
|
+
|
|
321
|
+
**`packages/config/env.schema.ts`:**
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { z } from 'zod';
|
|
325
|
+
|
|
326
|
+
export const envSchema = z.object({
|
|
327
|
+
DATABASE_URL: z.string().url(),
|
|
328
|
+
BETTER_AUTH_SECRET: z.string().min(32),
|
|
329
|
+
REDIS_URL: z.string().url(),
|
|
330
|
+
NODE_ENV: z.enum(['development', 'test', 'production']),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
export type Env = z.infer<typeof envSchema>;
|
|
334
|
+
|
|
335
|
+
export function loadEnv(): Env {
|
|
336
|
+
const parsed = envSchema.safeParse(process.env);
|
|
337
|
+
if (!parsed.success) {
|
|
338
|
+
console.error(parsed.error.format());
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
return parsed.data;
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Called once, at the top of `apps/api/src/main.ts` and
|
|
346
|
+
`apps/worker/src/main.ts`.
|
|
347
|
+
|
|
348
|
+
### Phase gate (CI)
|
|
349
|
+
|
|
350
|
+
Encodes "Phase A closes before Phase B opens" as a CI check. Blocks a PR
|
|
351
|
+
introducing a `feat(<module>): hooks` or `feat(<module>): screen-*` commit
|
|
352
|
+
for a module with no prior `feat(<module>): api` commit on the branch. Can
|
|
353
|
+
land in step 1's commit or as its own `feat(config): phase gate` commit.
|
|
354
|
+
|
|
355
|
+
**`.github/workflows/phase-gate.yml` (logic sketch):**
|
|
356
|
+
|
|
357
|
+
```yaml
|
|
358
|
+
- name: Enforce Phase A before Phase B
|
|
359
|
+
run: |
|
|
360
|
+
node tools/phase-gate.js
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**`tools/phase-gate.js` (logic):**
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
for each commit in PR:
|
|
367
|
+
if commit matches /^feat\(([a-z-]+)\): (hooks|screen-\w+)/:
|
|
368
|
+
module = capture group 1
|
|
369
|
+
fail unless a commit matching /^feat\(<module>\): api/ (module
|
|
370
|
+
substituted in) already exists on main or earlier in this branch
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Locked format/lint config
|
|
374
|
+
|
|
375
|
+
One shared config, extended everywhere:
|
|
376
|
+
|
|
377
|
+
- `packages/config/eslint-base.js` — flat config, extended by every
|
|
378
|
+
app/lib.
|
|
379
|
+
- `packages/config/prettier.js` — includes `prettier-plugin-tailwindcss`.
|
|
380
|
+
|
|
381
|
+
A per-app override request signals to fix the base config at the source.
|
|
382
|
+
|
|
383
|
+
## After Bootstrap
|
|
384
|
+
|
|
385
|
+
Update `TODO.md`: check off every Bootstrap line now built, leave Phase
|
|
386
|
+
A/B sections as-is (per-module, filled in by `planner` during Intake or
|
|
387
|
+
when new scope enters play). Hand off to `hedgehog-loop` — from here,
|
|
388
|
+
every domain module goes through Phase A steps 1–5(a) one at a time,
|
|
389
|
+
gated by lefthook, each its own commit.
|
|
390
|
+
|
|
391
|
+
## Constraints
|
|
392
|
+
|
|
393
|
+
- Run once per project. Not a per-module or per-feature tool.
|
|
394
|
+
- Don't scaffold `apps/mobile` unless mobile is explicitly in scope.
|
|
395
|
+
- Don't add domain schema, contracts, or any `libs/<module>/*` content —
|
|
396
|
+
that's Phase A, started after Bootstrap, one module at a time.
|
|
397
|
+
- Don't deviate from the package/library choices above. If a generator or
|
|
398
|
+
package name changed upstream since this was written, verify against
|
|
399
|
+
current docs before running the command — don't substitute a different
|
|
400
|
+
library.
|
|
401
|
+
- Each of the 7 steps is its own commit, in order — same unit-of-work
|
|
402
|
+
discipline as every other step in the discipline, even though this is
|
|
403
|
+
infra rather than a domain module.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hedgehog-loop
|
|
3
|
+
description: Use for every unit of work once a Hedgehog project is bootstrapped — building one Order step (schema, contract, repository, service, controller, hook, screen), gating it, committing it, and checking it off TODO.md. Triggers on "next step", "build this module", "what's next", or the start of any work session on a bootstrapped project. Also covers the Correction Protocol for fixing a wrong upstream step.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Hedgehog Loop
|
|
7
|
+
|
|
8
|
+
The operating loop for a bootstrapped Hedgehog project: pick the next step,
|
|
9
|
+
build it, gate it, commit it, check it off. `TODO.md` at repo root is the
|
|
10
|
+
live list — read it before starting. It's thin: a context blurb plus a
|
|
11
|
+
checklist mirroring the phase/step structure below. Checked/unchecked is
|
|
12
|
+
its only state.
|
|
13
|
+
|
|
14
|
+
## Determine phase
|
|
15
|
+
|
|
16
|
+
Before touching code, know which phase applies to the module in scope:
|
|
17
|
+
|
|
18
|
+
- **Phase A** — building/extending the backend. Every module in scope
|
|
19
|
+
needs schema → contract → repository → service → controller (→ queue)
|
|
20
|
+
before Phase B starts for any of them.
|
|
21
|
+
- **Phase B** — Phase A is closed for the module. Build hooks and screens.
|
|
22
|
+
|
|
23
|
+
Check `TODO.md`, or the commit log for `feat(<module>): api` commits. No
|
|
24
|
+
such commit means the module is in Phase A.
|
|
25
|
+
|
|
26
|
+
## The Domain Module Pattern
|
|
27
|
+
|
|
28
|
+
A **domain module = one table.** `users`, `orders`, `order_items` are each
|
|
29
|
+
their own module, carrying the full step sequence below. The schema is the
|
|
30
|
+
source of truth for module boundaries.
|
|
31
|
+
|
|
32
|
+
**Cross-module references are FK-by-ID only.** If `orders.user_id`
|
|
33
|
+
references `users`, the `orders` schema holds a plain FK column. The
|
|
34
|
+
`orders` repository and service depend only on their own ports — a service
|
|
35
|
+
knows related entities only as an ID.
|
|
36
|
+
|
|
37
|
+
- Need the related row? Resolve it at the contract/controller layer
|
|
38
|
+
(parallel calls to each module's own endpoint), or join against the
|
|
39
|
+
other module's *schema* directly inside the repository (Drizzle query).
|
|
40
|
+
- This keeps every service importing only its own ports, so the Nx rule
|
|
41
|
+
`type:service → onlyDependOnLibsWithTags: ['type:port', 'type:util']`
|
|
42
|
+
holds uniformly (wired at bootstrap).
|
|
43
|
+
|
|
44
|
+
A junction table (e.g. `order_items`) is one table, one module, with two
|
|
45
|
+
FK-by-ID columns instead of one, each resolved the same way.
|
|
46
|
+
|
|
47
|
+
Every module goes through the same shape, in order:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
schema (Drizzle) — types before data
|
|
51
|
+
contract (Zod / ts-rest) — the boundary
|
|
52
|
+
repository (port + Drizzle adapter)
|
|
53
|
+
service (domain logic) — imports only ports
|
|
54
|
+
controller (thin HTTP)
|
|
55
|
+
hook (TanStack Query) — Phase B only
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Plus, when an operation needs async: **queue = port + BullMQ adapter**,
|
|
59
|
+
same port/adapter shape as the repository. The service imports only ports.
|
|
60
|
+
|
|
61
|
+
Standard Nx generators (`@nx/nest`, `@nx/next`, `@nx/expo`, `@nx/js`)
|
|
62
|
+
scaffold the app/lib shell. Each step's actual content (schema, contract,
|
|
63
|
+
repository, service, controller, hook) is hand-built, following this
|
|
64
|
+
sequence.
|
|
65
|
+
|
|
66
|
+
## Domain Module — Backend Steps (Phase A, every module in scope)
|
|
67
|
+
|
|
68
|
+
A horizontal pass across the whole backend — every module goes through
|
|
69
|
+
these before any module gets a hook or screen.
|
|
70
|
+
|
|
71
|
+
| # | Step | Lives in | Commit |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| 1 | Schema | `packages/db` (Drizzle) | `feat(<module>): schema` |
|
|
74
|
+
| 2 | Contract | `packages/contracts` (Zod via `drizzle-zod` + ts-rest) | `feat(<module>): contract` |
|
|
75
|
+
| 3 | Repository | `libs/<module>/repository` (port + Drizzle adapter) | `feat(<module>): repository` |
|
|
76
|
+
| 4 | Service | `libs/<module>/service` (domain logic — imports only ports) | `feat(<module>): service` |
|
|
77
|
+
| 5 | Controller | `apps/api` (thin HTTP, wires contract → service) | `feat(<module>): api` |
|
|
78
|
+
| 5a | Queue *(if needed)* | `apps/worker` (port + BullMQ adapter) | `feat(<module>): queue` |
|
|
79
|
+
|
|
80
|
+
Repeat 1–5(a) per module in scope. The API is complete, typed, and
|
|
81
|
+
callable (Postman/curl/contract tests) before frontend work starts.
|
|
82
|
+
|
|
83
|
+
## Domain Module — Frontend Steps (Phase B, after Phase A closes for the module)
|
|
84
|
+
|
|
85
|
+
| # | Step | Lives in | Commit |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| 6 | Hook | `packages/hooks` (TanStack Query) | `feat(<module>): hooks` |
|
|
88
|
+
| 6a | UX rationale | `docs/design/<module>.md`, `ux-planner` agent | bundled into step 7's commit |
|
|
89
|
+
| 7 | Screen | `apps/web` and/or `apps/mobile` | `feat(<module>): screen-web` / `feat(<module>): screen-mobile` |
|
|
90
|
+
|
|
91
|
+
Phase B starts once Phase A is done for the scope. The frontend is a pure
|
|
92
|
+
consumer of an already-finished API. Step 6a is where "how it should feel"
|
|
93
|
+
gets decided — once per module, after the hook exists and before
|
|
94
|
+
`ui-builder` starts the screen — via `ux-planner`, starting from whatever
|
|
95
|
+
`planner` filed in `docs/design/<module>-notes.md` at Intake. Its first run
|
|
96
|
+
for a module also signals to the user that Phase B has started, and is the
|
|
97
|
+
point a mockup, screenshot, or export (Google Stitch, Figma) can be handed
|
|
98
|
+
over. It writes `docs/design/<module>.md`, not its own step commit;
|
|
99
|
+
`TODO.md` tracks only hooks/screen-web/screen-mobile per module.
|
|
100
|
+
|
|
101
|
+
## The Loop (every unit of work)
|
|
102
|
+
|
|
103
|
+
1. **Pick the next step** per the tables above, from `TODO.md`. One step
|
|
104
|
+
at a time, in order.
|
|
105
|
+
2. **Check the gate.** The prior step compiles and passes tests first.
|
|
106
|
+
3. **Build exactly one step.** One schema, one contract, one repository.
|
|
107
|
+
4. **Run the gate on your own work**: typecheck, lint, test (mirrors
|
|
108
|
+
lefthook, wired at bootstrap).
|
|
109
|
+
5. **Commit** using the exact Conventional Commit format above.
|
|
110
|
+
6. **Check off the line in `TODO.md`.**
|
|
111
|
+
7. **Repeat.**
|
|
112
|
+
|
|
113
|
+
Each commit batches exactly one step, built right for what's known now; a
|
|
114
|
+
wrong step is fixed forward later via the Correction Protocol.
|
|
115
|
+
|
|
116
|
+
## Intra-step conventions
|
|
117
|
+
|
|
118
|
+
The Nx boundaries, phase gate, and lint own the *structural* rules
|
|
119
|
+
(what imports what, what gets built when). These are the conventions
|
|
120
|
+
*inside* a step that those gates can't see — apply them uniformly so a
|
|
121
|
+
fresh-context session builds module N the same way it built module 1. The
|
|
122
|
+
`reviewer` agent checks these at a phase boundary.
|
|
123
|
+
|
|
124
|
+
- **Errors are thrown, typed, and domain-named.** A service throws a
|
|
125
|
+
domain error (`OrderNotFoundError`, not a bare `Error` or an HTTP
|
|
126
|
+
exception) — services don't know they're behind HTTP. The controller is
|
|
127
|
+
the only layer that maps domain errors to status codes. Never return
|
|
128
|
+
`null`/`undefined` to signal a failure a caller must branch on.
|
|
129
|
+
- **Repository not-found returns `undefined`; the service decides.** A
|
|
130
|
+
`findById` that misses returns `undefined` (a plain absence, not an
|
|
131
|
+
error); the service turns that into a thrown domain error when the
|
|
132
|
+
operation requires the row. Adapters don't throw domain errors — they
|
|
133
|
+
report absence, the service interprets it.
|
|
134
|
+
- **Validation lives at the contract boundary, once.** Input is
|
|
135
|
+
Zod-validated at the controller via the ts-rest contract. Past that
|
|
136
|
+
boundary, types are trusted — services and repositories don't re-parse.
|
|
137
|
+
A service-level invariant that isn't expressible in the Zod schema
|
|
138
|
+
(e.g. "can't cancel after payment") is enforced in the service as a
|
|
139
|
+
thrown domain error, not a second validation pass.
|
|
140
|
+
- **Multi-write operations are transactional.** A service method that
|
|
141
|
+
writes more than once wraps the writes in one Drizzle transaction,
|
|
142
|
+
passed through the port — partial writes never escape a failed
|
|
143
|
+
operation.
|
|
144
|
+
- **Services are pure domain logic.** No logging, no HTTP, no queue
|
|
145
|
+
mechanics inside a service method — those live at the controller /
|
|
146
|
+
adapter edge. A service reads as the business rule and nothing else.
|
|
147
|
+
|
|
148
|
+
## Correction Protocol
|
|
149
|
+
|
|
150
|
+
When a downstream step reveals an upstream step was wrong:
|
|
151
|
+
|
|
152
|
+
1. Stop.
|
|
153
|
+
2. Patch the upstream step directly, in place.
|
|
154
|
+
3. Fast-forward every dependent step that breaks, each its own small
|
|
155
|
+
commit.
|
|
156
|
+
4. The commit messages are the explanation.
|
|
157
|
+
5. Resume the loop.
|
|
158
|
+
|
|
159
|
+
Use `conventional-commits` when a correction touches several steps in one
|
|
160
|
+
working-tree pass and needs splitting back into per-step commits.
|
|
161
|
+
|
|
162
|
+
## Phase Transition Checks
|
|
163
|
+
|
|
164
|
+
Before starting Phase B for a module, confirm:
|
|
165
|
+
|
|
166
|
+
- A `feat(<module>): api` commit exists for that module.
|
|
167
|
+
- The contract is callable and typed (contract tests pass).
|
|
168
|
+
|
|
169
|
+
Use the `reviewer` agent for this — it checks what the mechanical gate
|
|
170
|
+
can't (port discipline, FK-by-ID discipline, contract shape).
|
|
171
|
+
|
|
172
|
+
Before starting Phase A for a module, confirm it's inside the stated scope
|
|
173
|
+
boundary from Intake (`planner`). If not, stop and ask.
|
|
174
|
+
|
|
175
|
+
## Rules
|
|
176
|
+
|
|
177
|
+
- **Phase A closes before Phase B opens.** Every module in scope has a
|
|
178
|
+
working, tested API before any hook or screen starts.
|
|
179
|
+
- **Sequential within a phase.** A step starts once the one before it
|
|
180
|
+
compiles and passes tests.
|
|
181
|
+
- **Step 5a is conditional** — only when an operation genuinely needs
|
|
182
|
+
async (long-running, retries, fan-out); the normal case has no queue.
|
|
183
|
+
- **A wrong step gets fixed at its source** — the Correction Protocol, not
|
|
184
|
+
a downstream workaround.
|
|
185
|
+
- **Tests gate every commit** in the sequence.
|
|
186
|
+
- A module's frontend code (hook, screen) is built after its API is
|
|
187
|
+
committed.
|
|
188
|
+
- The screen step doesn't start blank — `ux-planner` runs once per module,
|
|
189
|
+
after the hook is committed, before `ui-builder` starts the screen.
|
|
190
|
+
- `packages/config` is the single source for shared config; a per-app
|
|
191
|
+
override request signals to fix the base config at the source.
|
|
192
|
+
|
|
193
|
+
## Stop Condition
|
|
194
|
+
|
|
195
|
+
A build session ends when every module in scope has completed both Phase
|
|
196
|
+
A and Phase B, or when scope is ambiguous enough that continuing means
|
|
197
|
+
guessing — ask one question and wait.
|