@skyf0xx/hedgehog 4.1.0 → 4.2.2

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.
Files changed (63) hide show
  1. package/README.md +21 -1
  2. package/bin/cli.mjs +108 -8
  3. package/package.json +2 -2
  4. package/src/agents/backend-eng.md +62 -16
  5. package/src/agents/front-end-eng.md +48 -14
  6. package/src/agents/reviewer.md +9 -4
  7. package/src/agents/ux-planner.md +2 -2
  8. package/src/db/next.mjs +95 -8
  9. package/src/db/status.mjs +140 -15
  10. package/src/golden-cores/full-stack-app/.env.example +1 -0
  11. package/src/golden-cores/full-stack-app/apps/api/package.json +19 -1
  12. package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.spec.ts +15 -0
  13. package/src/golden-cores/full-stack-app/apps/api/src/app/app.module.ts +6 -1
  14. package/src/golden-cores/full-stack-app/apps/api/src/app/feature-modules.ts +8 -0
  15. package/src/golden-cores/full-stack-app/apps/api/src/main.ts +10 -1
  16. package/src/golden-cores/full-stack-app/apps/api/tsconfig.app.json +3 -0
  17. package/src/golden-cores/full-stack-app/apps/api/tsconfig.json +3 -0
  18. package/src/golden-cores/full-stack-app/apps/api/tsconfig.spec.json +36 -0
  19. package/src/golden-cores/full-stack-app/apps/api/vitest.config.mts +18 -0
  20. package/src/golden-cores/full-stack-app/apps/web/.env.example +12 -0
  21. package/src/golden-cores/full-stack-app/apps/web/package.json +3 -0
  22. package/src/golden-cores/full-stack-app/apps/web/src/components/theme-toggle.spec.tsx +20 -0
  23. package/src/golden-cores/full-stack-app/apps/web/src/test-setup.ts +1 -0
  24. package/src/golden-cores/full-stack-app/apps/web/tsconfig.json +6 -0
  25. package/src/golden-cores/full-stack-app/apps/web/tsconfig.spec.json +37 -0
  26. package/src/golden-cores/full-stack-app/apps/web/vitest.config.mts +27 -0
  27. package/src/golden-cores/full-stack-app/core.yaml +11 -3
  28. package/src/golden-cores/full-stack-app/nx.json +4 -1
  29. package/src/golden-cores/full-stack-app/package.json +8 -0
  30. package/src/golden-cores/full-stack-app/packages/config/eslint-base.js +108 -13
  31. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.spec.ts +14 -1
  32. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.ts +1 -0
  33. package/src/golden-cores/full-stack-app/packages/config/src/index.ts +1 -1
  34. package/src/golden-cores/full-stack-app/packages/db/src/index.ts +2 -1
  35. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.spec.ts +3 -3
  36. package/src/golden-cores/full-stack-app/packages/db/src/schema/index.ts +4 -0
  37. package/src/golden-cores/full-stack-app/pnpm-lock.yaml +8735 -2907
  38. package/src/golden-cores/full-stack-app/pnpm-workspace.yaml +4 -0
  39. package/src/golden-cores/full-stack-app/tools/generate-feature-modules.cjs +104 -0
  40. package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +283 -0
  41. package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +20 -0
  42. package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +323 -0
  43. package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +20 -0
  44. package/src/golden-cores/full-stack-app/tools/generators/fields.ts +126 -0
  45. package/src/golden-cores/full-stack-app/tools/generators/generators.json +42 -0
  46. package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +274 -0
  47. package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +19 -0
  48. package/src/golden-cores/full-stack-app/tools/generators/lib-shell.ts +124 -0
  49. package/src/golden-cores/full-stack-app/tools/generators/naming.ts +84 -0
  50. package/src/golden-cores/full-stack-app/tools/generators/package.json +6 -0
  51. package/src/golden-cores/full-stack-app/tools/generators/repository/generator.ts +298 -0
  52. package/src/golden-cores/full-stack-app/tools/generators/repository/schema.json +15 -0
  53. package/src/golden-cores/full-stack-app/tools/generators/schema/generator.ts +169 -0
  54. package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +20 -0
  55. package/src/golden-cores/full-stack-app/tools/generators/screen/generator.ts +218 -0
  56. package/src/golden-cores/full-stack-app/tools/generators/screen/schema.json +15 -0
  57. package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +194 -0
  58. package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +15 -0
  59. package/src/golden-cores/full-stack-app/tsconfig.base.json +2 -2
  60. package/src/skills/conventional-commits/SKILL.md +2 -3
  61. package/src/skills/hedgehog-bootstrap/SKILL.md +25 -7
  62. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +135 -20
  63. package/src/skills/hedgehog-loop/SKILL.md +161 -8
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Turn AI from a code generator into a reliable software engineer ⭐
2
2
 
3
- [![Total downloads](https://img.shields.io/npm/dt/%40skyf0xx%2Fhedgehog?style=for-the-badge)](https://www.npmjs.com/package/@skyf0xx/hedgehog)
3
+ [![Total downloads](https://raw.githubusercontent.com/skyf0xx/hedgehog/master/badges/npm-downloads.svg)](https://www.npmjs.com/package/@skyf0xx/hedgehog)
4
4
 
5
5
  AI can write code in seconds.
6
6
 
@@ -68,6 +68,19 @@ Every dependency is explicit, so Hedgehog knows which tasks can run in parallel.
68
68
 
69
69
  Agents fan out to give you great outcomes at **faster speeds**.
70
70
 
71
+ ## Deterministic Code Generation
72
+
73
+ Where a piece of code has one correct shape, Hedgehog generates it instead of asking the AI to write it freehand.
74
+
75
+ ![Deterministic code generation](https://raw.githubusercontent.com/skyf0xx/hedgehog/master/docs/images/deterministic.png)
76
+
77
+ - **Generators over freehand**: schemas, boilerplate, and scaffolding come from generators that create the code
78
+ - **Faster**: no reasoning required to produce a known shape
79
+ - **More correct**: the same input always produces the same, tested output
80
+ - **Fewer tokens**: nothing spent generating code that a template already covers
81
+
82
+ The AI is reserved for the parts a generator can't cover.
83
+
71
84
  ## What Hedgehog builds
72
85
 
73
86
  ### Full-stack applications
@@ -138,6 +151,13 @@ npx @skyf0xx/hedgehog init
138
151
 
139
152
  Then open your coding agent and describe what you want to build.
140
153
 
154
+ The golden cores print a `pnpm install` step as part of their next steps.
155
+ On a fresh project with no warm pnpm store, that first install can take
156
+ several minutes — it's pulling a full monorepo toolchain (Nx, webpack,
157
+ sass-embedded, Playwright, etc.) and, on first commit, running the commit
158
+ gate against the whole workspace. A quiet stretch of output during that
159
+ step is expected, not a hang.
160
+
141
161
  ### Coding agents
142
162
 
143
163
  Hedgehog installs for **Claude Code** by default. Add a host flag to
package/bin/cli.mjs CHANGED
@@ -504,6 +504,18 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
504
504
  // runs the whole workspace (see lefthook.yml), so expect it to take
505
505
  // as long as a full typecheck/lint/test — that is the gate working.
506
506
  console.log(` 1. ${bold('pnpm install')}`);
507
+ console.log(
508
+ dim(' First run can take several minutes — full dependency install plus'),
509
+ );
510
+ console.log(dim(' the commit gate running against the whole workspace. This is'));
511
+ console.log(dim(' expected; let it finish rather than treating a quiet stretch as stuck.'));
512
+ console.log(
513
+ dim(' If an agent is running this: tell the user up front that first install'),
514
+ );
515
+ console.log(
516
+ dim(' can take several minutes and why, then check on progress every ~30s'),
517
+ );
518
+ console.log(dim(' instead of polling tightly or narrating the wait.'));
507
519
  console.log(` 2. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
508
520
  console.log(` 3. Open ${HOSTS[host].label} and describe what you want to build.`);
509
521
  } else {
@@ -653,6 +665,20 @@ async function resolveCorePath() {
653
665
  return null;
654
666
  }
655
667
 
668
+ // The active core's `id` (e.g. `full-stack-app`), or null when no core has
669
+ // landed yet or it fails to parse — packet rendering degrades gracefully
670
+ // either way (see next.mjs's layerShapeLines), so a caller only asking for
671
+ // the id doesn't need its own try/catch.
672
+ async function resolveCoreId() {
673
+ const corePath = await resolveCorePath();
674
+ if (!corePath) return null;
675
+ try {
676
+ return (await loadCore(corePath)).id;
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+
656
682
  // `hedgehog plan --recompile` — the reconciliation path for a core.yaml
657
683
  // edited after `plan` already compiled it.
658
684
  //
@@ -973,7 +999,7 @@ async function nextCommand() {
973
999
  console.error('');
974
1000
  }
975
1001
 
976
- console.log(formatNext(packet));
1002
+ console.log(formatNext(packet, await resolveCoreId()));
977
1003
  }
978
1004
 
979
1005
  function printStalledTasks(stalled) {
@@ -1043,6 +1069,49 @@ async function missingBinariesForTask(db, taskId, owner) {
1043
1069
  return missing.length === 0 ? null : { layer: layer.id, missing };
1044
1070
  }
1045
1071
 
1072
+ // What an out-of-scope path most likely is, and what to do about it.
1073
+ // "Outside allowed scope" is true of every offending path and tells the
1074
+ // reader nothing about which of three quite different situations they're
1075
+ // in: a package shell the layer itself had to create (widen this one
1076
+ // task), shared config a source-level fix touched (commit it separately),
1077
+ // or a genuine stray. Each wants a different next move, and working out
1078
+ // which costs a beat every time.
1079
+ //
1080
+ // Evidence-only, like core.mjs's lint: an unrecognised path gets no
1081
+ // annotation rather than a guessed one.
1082
+ const SCOPE_HINTS = [
1083
+ {
1084
+ // A package root's own scaffolding — package.json, tsconfig*.json,
1085
+ // vitest.config.mts, src/index.ts directly under packages/<pkg>/ or
1086
+ // libs/<a>/<b>/. No {module}-bearing scope glob can cover these, so
1087
+ // the first module through a layer that creates its package always
1088
+ // lands here.
1089
+ test: (p) =>
1090
+ /^(packages\/[^/]+|libs\/[^/]+\/[^/]+)\/(package\.json|tsconfig[^/]*\.json|vitest\.config\.[cm]?ts|project\.json|eslint\.config\.[cm]?js|src\/index\.ts)$/.test(
1091
+ p,
1092
+ ),
1093
+ hint: 'package shell — if this layer is the first to create this package, widen just this task with `hedgehog override add` (see hedgehog-loop, "First arrival in a package") and retry',
1094
+ },
1095
+ {
1096
+ test: (p) =>
1097
+ p === 'pnpm-workspace.yaml' ||
1098
+ p === 'pnpm-lock.yaml' ||
1099
+ p === 'tsconfig.json' ||
1100
+ p === 'nx.json' ||
1101
+ p === 'package.json' ||
1102
+ p.startsWith('packages/config/'),
1103
+ hint: 'shared workspace config — no layer owns it; commit it separately as its own `chore(workspace): …` before retrying',
1104
+ },
1105
+ {
1106
+ test: (p) => p.startsWith('.hedgehog/'),
1107
+ hint: 'build-graph state — intents, overrides and friction are committed by the command that writes them, not by a layer',
1108
+ },
1109
+ ];
1110
+
1111
+ function scopeHintFor(path) {
1112
+ return SCOPE_HINTS.find((h) => h.test(path))?.hint ?? null;
1113
+ }
1114
+
1046
1115
  async function verifyCommand(args) {
1047
1116
  await ensureDb();
1048
1117
 
@@ -1093,7 +1162,11 @@ async function verifyCommand(args) {
1093
1162
  if (result.outcome === 'scope_violation') {
1094
1163
  console.error(`${red(bold('Scope violation.'))} Task ${bold(taskId)} is now ${bold('blocked')}.\n`);
1095
1164
  console.error('Touched paths outside allowed scope:');
1096
- for (const path of result.offending) console.error(` ${red('✗')} ${path}`);
1165
+ for (const path of result.offending) {
1166
+ console.error(` ${red('✗')} ${path}`);
1167
+ const hint = scopeHintFor(path);
1168
+ if (hint) console.error(` ${dim(hint)}`);
1169
+ }
1097
1170
  console.error();
1098
1171
  process.exitCode = 1;
1099
1172
  return;
@@ -1145,14 +1218,15 @@ async function verifyCommand(args) {
1145
1218
  // doesn't print the packet here, the STATUS/ALLOWED SCOPE/VERIFICATION an
1146
1219
  // agent is supposed to be dispatched with is no longer reachable from any
1147
1220
  // command. (`hedgehog show <task-id>` reprints it later.)
1148
- function printPackets(tasks) {
1221
+ async function printPackets(tasks) {
1222
+ const coreId = await resolveCoreId();
1149
1223
  const db = openDb();
1150
1224
  try {
1151
1225
  for (const task of tasks) {
1152
1226
  const packet = taskPacket(db, task.id);
1153
1227
  if (!packet) continue;
1154
1228
  console.log();
1155
- console.log(formatPacket(packet, taskStatusLine(packet.task)));
1229
+ console.log(formatPacket(packet, taskStatusLine(packet.task), coreId));
1156
1230
  }
1157
1231
  } finally {
1158
1232
  db.close();
@@ -1239,7 +1313,7 @@ async function claimCommand(args) {
1239
1313
  if (claimed.length > 1) console.log(` ${bold(task.id)}`);
1240
1314
  console.log(` ${dim('expires')} ${task.lease_expires_at}`);
1241
1315
  }
1242
- printPackets(claimed);
1316
+ await printPackets(claimed);
1243
1317
  }
1244
1318
 
1245
1319
  // The targeted half of `claim`: one named task, or a non-zero exit
@@ -1258,7 +1332,7 @@ async function claimOneCommand(taskId, owner) {
1258
1332
  if (result.claimed) {
1259
1333
  console.log(`${green(bold('Claimed.'))} Task ${bold(taskId)} leased to ${bold(owner)}.`);
1260
1334
  console.log(` ${dim('expires')} ${result.task.lease_expires_at}`);
1261
- printPackets([result.task]);
1335
+ await printPackets([result.task]);
1262
1336
  return;
1263
1337
  }
1264
1338
 
@@ -1382,7 +1456,7 @@ async function showCommand(args) {
1382
1456
  return;
1383
1457
  }
1384
1458
 
1385
- console.log(formatPacket(packet, taskStatusLine(packet.task)));
1459
+ console.log(formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId()));
1386
1460
  }
1387
1461
 
1388
1462
  // `hedgehog release <task-id> --owner <owner>` — hands a claimed task
@@ -1539,7 +1613,27 @@ async function statusCommand() {
1539
1613
  }
1540
1614
  }
1541
1615
 
1542
- const overrides = core ? await loadOverrides() : new Map();
1616
+ // Loaded whether or not a core resolved: drift is the only consumer
1617
+ // that needs `core` composed against these, and graphStatus already
1618
+ // gates that on `core` itself. The orphan check reads task ids out of
1619
+ // the database, so it is answerable — and worth answering — on a
1620
+ // project whose core.yaml is missing or unparseable. A missing
1621
+ // overrides directory reads as an empty Map (loadOverrides), so this
1622
+ // costs nothing on a project that has never written one.
1623
+ //
1624
+ // A malformed override file throws, and for the same reason an
1625
+ // unparseable core.yaml doesn't take `status` down, neither may this:
1626
+ // status is the command every session starts with, and the report of
1627
+ // the broken file is more useful than an aborted overview.
1628
+ let overrides = new Map();
1629
+ try {
1630
+ overrides = await loadOverrides();
1631
+ } catch (err) {
1632
+ console.error(
1633
+ `${yellow('Override file unreadable:')} ${err.message}\n${dim('Scope overrides are ignored until it parses.')}\n`,
1634
+ );
1635
+ }
1636
+
1543
1637
  const db = openDb();
1544
1638
  let result;
1545
1639
  try {
@@ -1711,7 +1805,13 @@ function openInBrowser(url) {
1711
1805
  // either fails silently or blocks on a text browser. Printing the URL is
1712
1806
  // the useful behaviour in that case — a person on the other end of a
1713
1807
  // port-forward can still open it.
1808
+ //
1809
+ // HEDGEHOG_FORCE_HEADLESS bypasses the platform check entirely — the
1810
+ // repro suite's only way to exercise the no-display branch on macOS/
1811
+ // Windows, where DISPLAY/WAYLAND_DISPLAY are never consulted for real
1812
+ // users. Not a documented user-facing flag.
1714
1813
  function hasDisplay() {
1814
+ if (process.env.HEDGEHOG_FORCE_HEADLESS) return false;
1715
1815
  if (process.platform === 'darwin' || process.platform === 'win32') return true;
1716
1816
  return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
1717
1817
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "4.1.0",
3
+ "version": "4.2.2",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -44,4 +44,4 @@
44
44
  "hedgehog"
45
45
  ],
46
46
  "license": "MIT"
47
- }
47
+ }
@@ -36,29 +36,61 @@ project/target config), `nx-generate` (scaffolding a new library/app), and
36
36
  `link-workspace-packages` (wiring a new package into a consumer) as
37
37
  needed.
38
38
 
39
+ **Every layer you own starts from its generator in `tools/generators/`** —
40
+ one per layer (`schema`, `contract`, `repository`, `service`,
41
+ `controller`), each landing that layer's package shell, `nx.tags`,
42
+ port-discipline file suffixes, Nest module/controller pair, and barrel
43
+ wiring in one step. The claimed packet's LAYER SHAPE section prints the
44
+ exact command for the layer you're on; `hedgehog-loop`'s "Scaffolding a
45
+ layer" section owns the full flag contract and the workspace wiring a new
46
+ package needs. Generate first, then author this entity's delta — the field
47
+ list and its types, and the business rules below — on top. A hand-copy of
48
+ a sibling module is the drift `hedgehog verify`'s lint step then has to
49
+ catch.
50
+
39
51
  ## Core Responsibilities
40
52
 
41
53
  - **`schema`**: define the table in `packages/db` (Drizzle). One domain
42
54
  module = one table. Cross-module references are FK-by-ID columns
43
- only — never a foreign schema import.
55
+ only — never a foreign schema import. Add one re-export line for the
56
+ module to `packages/db/src/schema/index.ts` (in scope for this
57
+ layer) so the table is importable outside `packages/db` — the
58
+ package's own `src/index.ts` re-exports that barrel and never
59
+ changes after bootstrap.
44
60
  - **`contract`**: derive the Zod schema from Drizzle (`drizzle-zod`) and
45
- wire the ts-rest contract in `packages/contracts`.
61
+ wire the ts-rest contract in `packages/contracts`. A `date`-mode
62
+ `timestamp` column reflected through `createSelectSchema` is overridden
63
+ to a union of `z.date()` and an ISO datetime string, never left as the
64
+ derived `z.date()` alone and never narrowed to a string-only schema —
65
+ the same field is checked server-side against a real `Date` and
66
+ client-side against JSON's string, and no `.transform()` can satisfy
67
+ both.
46
68
  - **`repository`**: a port (interface) plus a Drizzle adapter in
47
69
  `libs/<module>/repository`. A `findById`-shaped miss returns
48
70
  `undefined` — plain absence, not a thrown error; the service decides
49
- what absence means.
50
- - **`service`**: domain logic in `libs/<module>/service`, importing only
51
- its own ports (`type:port`, `type:util` the Nx boundary rule). Throws
52
- typed, domain-named errors (`OrderNotFoundError`, not a bare `Error` or
53
- an HTTP exception). No logging, no HTTP, no queue mechanics inside a
54
- service method. Multi-write operations wrap in one Drizzle transaction,
55
- passed through the port.
71
+ what absence means. The concrete adapter's file name ends in
72
+ `.adapter.ts` and the lib's entry point exports the port interface and
73
+ its DI token; `packages/config/eslint-base.js` keys its port-discipline
74
+ rule on that suffix, so an adapter named anything else silently opts
75
+ out of the check.
76
+ - **`service`**: domain logic in `libs/<module>/service`, importing its
77
+ own module's port interface from the repository lib's entry point —
78
+ never a `*.adapter` file, never `drizzle-orm` or `packages/db`
79
+ (`no-restricted-imports` in `eslint-base.js` fails lint on either).
80
+ Throws typed, domain-named errors (`OrderNotFoundError`, not a bare
81
+ `Error` or an HTTP exception). No logging, no HTTP, no queue mechanics
82
+ inside a service method. Multi-write operations wrap in one Drizzle
83
+ transaction, passed through the port.
56
84
  - **`controller`**: thin HTTP in `apps/api`, wiring the contract to the
57
85
  service. The only layer that maps domain errors to status codes.
58
86
  Validation happens once, at this boundary, via the Zod contract — past
59
- it, types are trusted. Bundles queue infra (port + BullMQ adapter in
60
- `apps/worker`, same shape as the repository) when the Queue add-on is
61
- on and this operation needs it.
87
+ it, types are trusted. `apps/api` is the composition root: the module's
88
+ `*.module.ts` is the one file that constructs the concrete adapter and
89
+ binds it to the port's DI token, and the only file in `apps/api`
90
+ allowed to import a `*.adapter`. A controller takes the service, or the
91
+ bound token — never the adapter. Bundles queue infra (port + BullMQ
92
+ adapter in `apps/worker`, same shape as the repository) when the Queue
93
+ add-on is on and this operation needs it.
62
94
 
63
95
  ## Workflow
64
96
 
@@ -75,15 +107,29 @@ needed.
75
107
  `complete` — no need to re-derive that by hand. Cross-module FK
76
108
  targets should already have their own schema landed (the packet's
77
109
  dependencies guarantee this); check before writing the FK column.
78
- 2. Build exactly one layer, matching the packet's ALLOWED SCOPE. Run
79
- typecheck, lint, and test yourself as a sanity check before reporting
80
- back — necessary, not sufficient.
110
+ 2. Build exactly one layer, matching the packet's ALLOWED SCOPE: run its
111
+ generator, then author this entity's delta. Run typecheck, lint, and
112
+ test yourself as a sanity check before reporting back — necessary, not
113
+ sufficient. If this layer also has to create the package it lands in
114
+ (the first module through `contract` creates `packages/contracts`), the
115
+ shell files its generator lands sit outside the packet's ALLOWED SCOPE
116
+ and `hedgehog verify` will leave them uncommitted — stop and say so
117
+ before building, so the scope can be widened for this one task
118
+ (`hedgehog-loop`, "First arrival in a package"). Don't build against a
119
+ scope you already know won't commit your work. If the layer wires a new
120
+ or newly-linked package into the workspace, run `pnpm install` and
121
+ `pnpm nx sync` yourself (`hedgehog-loop`, "Scaffolding a layer") and
122
+ name the shared files that changed (typically `pnpm-lock.yaml`, root
123
+ `tsconfig.json`) in your report — the orchestrating session commits
124
+ them separately, since you report but never commit (next step).
81
125
  3. **Report the work as done; do not commit it yourself.** Per the build
82
126
  graph's design, an agent reporting success never moves a task — only
83
127
  `hedgehog verify <task-id>`'s passing exit code does. It checks your
84
128
  changes against the packet's ALLOWED SCOPE, re-runs the real
85
129
  verification command, and on a pass writes the commit (the packet's
86
- exact Conventional Commit message) itself.
130
+ exact Conventional Commit message) itself. Any shared workspace files
131
+ you flagged in step 2 are a separate commit the orchestrating session
132
+ makes before dispatching `hedgehog verify`, not something you commit.
87
133
  4. One layer at a time — never start the next layer before
88
134
  `hedgehog verify` reports the current one `complete`.
89
135
  5. Once `hedgehog verify` reports the `controller` layer (and any bundled
@@ -34,6 +34,19 @@ project/target config), `nx-generate` (scaffolding a new library/app), and
34
34
  `link-workspace-packages` (wiring a new package into a consumer) as
35
35
  needed.
36
36
 
37
+ **Both layers you own start from their generator in `tools/generators/`** —
38
+ `hook` lands `packages/hooks`'s shell, `nx.tags`, the query hook set, and
39
+ its barrel wiring; `screen` lands the route, the screen component wired to
40
+ that hook, and its test file. The claimed packet's LAYER SHAPE section
41
+ prints the exact command for the layer you're on; `hedgehog-loop`'s
42
+ "Scaffolding a layer" section owns the full flag contract and the
43
+ workspace wiring a new package needs. Generate first, then author this
44
+ entity's delta on top. The `screen` generator is skeleton-only by
45
+ design — placeholders for the list, filter shell, empty state, and form,
46
+ with layout, information hierarchy, and interaction pattern left to
47
+ `ux-planner`'s rationale and your build, so no two modules' screens come
48
+ out identical before anyone decided they should.
49
+
37
50
  If the screen step calls for animation or motion — entrances, sequencing,
38
51
  scroll-driven effects, drag, SVG/morph effects — use GSAP, loading the
39
52
  relevant skill from `vendor-skills/GSAP/` (`gsap-core`, `gsap-timeline`,
@@ -46,17 +59,26 @@ don't reach for a second one.
46
59
 
47
60
  - **`hook`**: build the TanStack Query hook in `packages/hooks`, wrapping
48
61
  the ts-rest contract client. One hook per contract operation, typed end
49
- to end from the Zod contract. The client's base URL comes from a
50
- `NEXT_PUBLIC_`-prefixed env var (add to `packages/config/env.schema.ts`
51
- if missing) never a hardcoded `http://localhost:<port>` fallback,
52
- which silently drifts out of sync with `apps/api`'s dev port (`3333`,
53
- per `hedgehog-bootstrap-full-stack-app-core` chosen to not collide
54
- with `apps/web`'s `next dev` default of `3000`) and produces a 404 that
55
- looks like a routing bug, not a config bug.
56
- - **`screen`**: build the screen/component in `apps/web` and/or
57
- `apps/mobile`, consuming the hook and `ux-planner`'s rationale for that
58
- module (screen inventory, interaction pattern, information hierarchy).
59
- No direct data-fetching in the screen — the hook owns that.
62
+ to end from the Zod contract. The client's base URL is
63
+ `process.env.NEXT_PUBLIC_API_BASE_URL`, scaffolded in
64
+ `apps/web/.env.example` and already carrying `apps/api`'s `/api` global
65
+ prefix. Read it as-is: don't append or strip a path segment (the prefix
66
+ is in the value), don't add it to `packages/config/env.schema.ts` (that
67
+ schema is `apps/api`'s server env, and a `NEXT_PUBLIC_` var is inlined
68
+ into the browser bundle by Next, never parsed at runtime by `loadEnv()`),
69
+ and never fall back to a hardcoded `http://localhost:<port>`. A wrong or
70
+ absent base URL 404s against Next's own dev server — a config bug wearing
71
+ a routing bug's clothes, and one unit tests never see, because they mock
72
+ the client.
73
+ - **`screen`**: build the screen/component in `apps/web` (plus
74
+ `apps/mobile` when the Mobile add-on is on), consuming the hook and
75
+ `ux-planner`'s rationale for that module (screen inventory, interaction
76
+ pattern, information hierarchy). No direct data-fetching in the
77
+ screen — the hook owns that. If this module's screen is the first one
78
+ built, wire `apps/web/src/app/page.tsx`'s primary CTA (ShadCN's
79
+ `asChild` + `next/link`, per `button.tsx`'s existing `asChild` prop)
80
+ to this module's own route — a compiling, lint-clean button with no
81
+ `href` or `onClick` still ships silent and unclickable.
60
82
  - Translate design specs into components. If a design tool is wired into
61
83
  this project's MCP config, use it for tokens/spacing/typography;
62
84
  otherwise match existing ShadCN/Tailwind patterns in the repo.
@@ -82,12 +104,24 @@ don't reach for a second one.
82
104
  no such confirmation, stop — you're being asked to build Phase B
83
105
  early.
84
106
  2. Build the hook against the contract client, matching the packet's
85
- ALLOWED SCOPE. Run typecheck, lint, and test yourself as a sanity
86
- check before reporting back necessary, not sufficient.
107
+ ALLOWED SCOPE: run its generator, then author this entity's delta. Run
108
+ typecheck, lint, and test yourself as a sanity check before reporting
109
+ back — necessary, not sufficient. On the first module through this
110
+ layer, the hook also creates `packages/hooks`, and that package's shell
111
+ sits outside the packet's ALLOWED SCOPE — `hedgehog verify` would leave
112
+ it uncommitted. Stop and say so before building, so the scope can be
113
+ widened for this one task (`hedgehog-loop`, "First arrival in a
114
+ package"); run `pnpm install` and `pnpm nx sync` yourself
115
+ (`hedgehog-loop`, "Scaffolding a layer") to wire the new package into
116
+ the workspace, and name the shared files that changed (typically
117
+ `pnpm-lock.yaml`, root `tsconfig.json`) in your report — the
118
+ orchestrating session commits them separately (next step).
87
119
  3. **Report the work as done; do not commit it yourself.** Only
88
120
  `hedgehog verify <task-id>`'s passing exit code moves the task to
89
121
  `complete` and writes the commit (the packet's exact Conventional
90
- Commit message).
122
+ Commit message). Any shared workspace files you flagged in step 2 are a
123
+ separate commit the orchestrating session makes before dispatching
124
+ `hedgehog verify`, not something you commit.
91
125
  4. Build the screen consuming the hook the same way — packet, build,
92
126
  report, `hedgehog verify`.
93
127
  5. One layer at a time — `hook` fully `complete` before the `screen`
@@ -32,10 +32,15 @@ Everything lefthook already enforces (typecheck, lint, unit test
32
32
  pass/fail) is out of scope — don't re-report a green gate. Check what the
33
33
  gate structurally cannot:
34
34
 
35
- - **Port discipline**: does the service import only `type:port` /
36
- `type:util`, per the Nx boundary rule read the actual imports, don't
37
- just trust `nx lint` ran. A boundary violation tagged wrong slips past
38
- the rule. Use `nx show project <name> --json` (per nrwl's
35
+ - **Port discipline**: a module's port interface and its Drizzle adapter
36
+ share one lib, so the tag graph has to allow `type:service
37
+ type:adapter` and the real check is at the import level: does the
38
+ service import the port from the repository lib's entry point, or the
39
+ concrete `*.adapter`? Does anything in `apps/api` outside a
40
+ `*.module.ts` construct an adapter? `eslint-base.js`'s
41
+ `no-restricted-imports` rules catch the named cases — read the actual
42
+ imports anyway, since an adapter file not named `*.adapter.ts` opts
43
+ itself out of the rule. Use `nx show project <name> --json` (per nrwl's
39
44
  [nx-workspace](https://github.com/nrwl/nx-ai-agents-config/tree/main/skills/nx-workspace) skill) to check a project's resolved tags and
40
45
  dependencies rather than reading `project.json` directly — it only
41
46
  holds partial configuration, not tags inferred by plugins.
@@ -127,8 +127,8 @@ conclusion.
127
127
  8. Hand off to `front-end-eng` for the screen step. The file isn't a step in
128
128
  the Domain Module Pattern and isn't committed on its own — it lands in
129
129
  the same commit as the screen step it informs
130
- (`feat(<module>): screen-web` / `screen-mobile`), same as any other
131
- file `front-end-eng` touches while building that step.
130
+ (`feat(<module>): screen-web`), same as any other file
131
+ `front-end-eng` touches while building that step.
132
132
 
133
133
  ## Constraints
134
134
 
package/src/db/next.mjs CHANGED
@@ -234,6 +234,85 @@ export function taskStatusLine(task) {
234
234
  return task.status.toUpperCase();
235
235
  }
236
236
 
237
+ // full-stack-app's own layer → Nx tag shape, mirroring the tag reference
238
+ // table `src/golden-cores/full-stack-app/packages/config/eslint-base.js`
239
+ // ships as a comment. A layer's `depConstraints` failure is a mechanical
240
+ // consequence of this table crossed with `core.yaml`'s `depends_on` chain
241
+ // — printing it in the packet turns that failure into a pre-flight fact
242
+ // instead of something `nx lint` teaches the agent after the fact. Keyed
243
+ // by layer id, not module, since the shape is the same for every module.
244
+ //
245
+ // `scaffold` is the same kind of fact one step earlier: the generator in
246
+ // `tools/generators/` every layer starts from (hedgehog-loop, "Scaffolding
247
+ // a layer", which owns the flag contract these lines instantiate). It sits
248
+ // here rather than in RELEVANT RULES because that section is per-intent —
249
+ // identical across every layer of an intent, by plan.mjs's requirement
250
+ // linkage — while the command differs per layer, which is exactly what
251
+ // LAYER SHAPE already keys on.
252
+ const FULL_STACK_APP_LAYER_TAGS = {
253
+ schema: {
254
+ tags: ['scope:db', 'type:adapter'],
255
+ dependsOnTags: [],
256
+ scaffold: "nx g ./tools/generators:schema --module={module} --fields='<name:type,...>'",
257
+ },
258
+ contract: {
259
+ tags: ['scope:contracts', 'type:contract'],
260
+ dependsOnTags: ['type:adapter', 'type:util'],
261
+ scaffold: "nx g ./tools/generators:contract --module={module} --fields='<the schema layer's list>'",
262
+ },
263
+ repository: {
264
+ tags: ['scope:{module}', 'type:adapter'],
265
+ dependsOnTags: ['type:adapter', 'type:contract', 'type:util'],
266
+ scaffold: 'nx g ./tools/generators:repository --module={module}',
267
+ },
268
+ service: {
269
+ tags: ['scope:{module}', 'type:service'],
270
+ dependsOnTags: ['type:adapter', 'type:contract', 'type:util'],
271
+ scaffold: 'nx g ./tools/generators:service --module={module}',
272
+ },
273
+ controller: {
274
+ tags: ['scope:api'],
275
+ dependsOnTags: ['type:adapter', 'type:service', 'type:contract', 'type:util'],
276
+ scaffold: "nx g ./tools/generators:controller --module={module} --fields='<the schema layer's list>'",
277
+ },
278
+ hook: {
279
+ tags: ['scope:hooks', 'type:hook'],
280
+ dependsOnTags: ['type:contract', 'type:util'],
281
+ scaffold: 'nx g ./tools/generators:hook --module={module} [--toggleField=<boolField>]',
282
+ },
283
+ screen: {
284
+ tags: ['scope:web'],
285
+ dependsOnTags: ['scope:contracts', 'scope:hooks', 'scope:shared', 'type:util'],
286
+ scaffold: 'nx g ./tools/generators:screen --module={module} (skeleton only — UX is yours)',
287
+ },
288
+ };
289
+
290
+ // A LAYER SHAPE section for full-stack-app tasks only (`coreId ===
291
+ // 'full-stack-app'`) — an authored core has no equivalent tag scheme
292
+ // (layer-eng.md already points that agent at reading .hedgehog/core.yaml
293
+ // directly instead), and `join` has no fixed tag shape of its own to
294
+ // state, so both fall through to null and print nothing.
295
+ function layerShapeLines(task, coreId) {
296
+ if (coreId !== 'full-stack-app') return null;
297
+ const shape = FULL_STACK_APP_LAYER_TAGS[task.layer];
298
+ if (!shape) return null;
299
+ const tags = shape.tags.map((t) => t.replace('{module}', task.module));
300
+ const lines = ['LAYER SHAPE', ` this layer's tags: ${tags.join(', ')}`];
301
+ if (shape.dependsOnTags.length === 0) {
302
+ lines.push(' may depend on tags: (nothing in-workspace — floor layer)');
303
+ } else {
304
+ lines.push(` may depend on tags: ${shape.dependsOnTags.join(', ')}`);
305
+ }
306
+ lines.push(` scaffold from: ${shape.scaffold.replace('{module}', task.module)}`);
307
+ lines.push(
308
+ " Confirm against packages/config/eslint-base.js's depConstraints before",
309
+ " writing an import — a mismatch here is a pre-flight fact, not a lint",
310
+ ' failure to discover later. Start from the generator and author only',
311
+ " this entity's delta on top of what it lands.",
312
+ );
313
+ return lines;
314
+ }
315
+
237
316
  // The standing honesty requirement, appended to every packet.
238
317
  //
239
318
  // Every other section is task-specific — this one is constant, which is
@@ -264,8 +343,8 @@ const HONESTY = [
264
343
  ];
265
344
 
266
345
  // Renders a packet into the STATUS / INTENT / RELEVANT RULES /
267
- // INHERITED DEBT / WHY NOW / BLOCKED DOWNSTREAM / ALLOWED SCOPE /
268
- // VERIFICATION / HONESTY format. The spec
346
+ // INHERITED DEBT / WHY NOW / BLOCKED DOWNSTREAM / ALLOWED SCOPE / LAYER
347
+ // SHAPE / VERIFICATION / HONESTY format. The spec
269
348
  // splits this across two examples — the `hedgehog next` display and "The
270
349
  // task packet" (which carries the intent and its rules) — but an agent
271
350
  // receives one thing, so the packet is one thing: everything the worker
@@ -273,12 +352,15 @@ const HONESTY = [
273
352
  //
274
353
  // `statusLine` is what goes on the STATUS row. `next` passes READY
275
354
  // literally, as it always has; `show` passes taskStatusLine(task), which
276
- // names the task's real state.
355
+ // names the task's real state. `coreId` is the active core's `id` (from
356
+ // `core.yaml`) — optional, since a caller with no core resolved yet (a
357
+ // deferred install) still has to be able to render *something*; LAYER
358
+ // SHAPE only ever appears for a recognised full-stack-app layer.
277
359
  //
278
360
  // HONESTY is last deliberately: it's the one section that qualifies the
279
361
  // gate above it, so it reads as the answer to "and what if I can't clear
280
362
  // VERIFICATION honestly" rather than as preamble.
281
- export function formatPacket(packet, statusLine) {
363
+ export function formatPacket(packet, statusLine, coreId = null) {
282
364
  const { task, intent, requirements, dependents, incompleteDeps = [], inheritedDebt = [] } = packet;
283
365
  const scopeGlobs = JSON.parse(task.scope_globs);
284
366
 
@@ -346,6 +428,11 @@ export function formatPacket(packet, statusLine) {
346
428
  lines.push('ALLOWED SCOPE');
347
429
  for (const glob of scopeGlobs) lines.push(` ${glob}`);
348
430
  lines.push('');
431
+ const shapeLines = layerShapeLines(task, coreId);
432
+ if (shapeLines) {
433
+ lines.push(...shapeLines);
434
+ lines.push('');
435
+ }
349
436
  lines.push('VERIFICATION');
350
437
  lines.push(` ${task.verify_command}`);
351
438
  lines.push('');
@@ -354,8 +441,8 @@ export function formatPacket(packet, statusLine) {
354
441
  return lines.join('\n');
355
442
  }
356
443
 
357
- // `hedgehog next`'s rendering, unchanged: its task always came out of the
358
- // readiness SELECT, so STATUS is READY by construction.
359
- export function formatNext(packet) {
360
- return formatPacket(packet, 'READY');
444
+ // `hedgehog next`'s rendering: its task always came out of the readiness
445
+ // SELECT, so STATUS is READY by construction.
446
+ export function formatNext(packet, coreId = null) {
447
+ return formatPacket(packet, 'READY', coreId);
361
448
  }