@skyf0xx/hedgehog 4.1.0 → 4.2.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.
Files changed (27) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.mjs +96 -8
  3. package/package.json +1 -1
  4. package/src/agents/backend-eng.md +46 -14
  5. package/src/agents/front-end-eng.md +30 -13
  6. package/src/agents/reviewer.md +9 -4
  7. package/src/agents/ux-planner.md +2 -2
  8. package/src/db/next.mjs +57 -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/src/main.ts +10 -1
  12. package/src/golden-cores/full-stack-app/apps/web/.env.example +12 -0
  13. package/src/golden-cores/full-stack-app/apps/web/package.json +3 -0
  14. package/src/golden-cores/full-stack-app/core.yaml +11 -3
  15. package/src/golden-cores/full-stack-app/packages/config/eslint-base.js +108 -13
  16. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.spec.ts +14 -1
  17. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.ts +1 -0
  18. package/src/golden-cores/full-stack-app/packages/config/src/index.ts +1 -1
  19. package/src/golden-cores/full-stack-app/packages/db/src/index.ts +2 -1
  20. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.spec.ts +3 -3
  21. package/src/golden-cores/full-stack-app/packages/db/src/schema/index.ts +4 -0
  22. package/src/golden-cores/full-stack-app/pnpm-workspace.yaml +4 -0
  23. package/src/golden-cores/full-stack-app/tsconfig.base.json +2 -2
  24. package/src/skills/conventional-commits/SKILL.md +2 -3
  25. package/src/skills/hedgehog-bootstrap/SKILL.md +25 -7
  26. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +72 -18
  27. package/src/skills/hedgehog-loop/SKILL.md +91 -4
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
 
package/bin/cli.mjs CHANGED
@@ -653,6 +653,20 @@ async function resolveCorePath() {
653
653
  return null;
654
654
  }
655
655
 
656
+ // The active core's `id` (e.g. `full-stack-app`), or null when no core has
657
+ // landed yet or it fails to parse — packet rendering degrades gracefully
658
+ // either way (see next.mjs's layerShapeLines), so a caller only asking for
659
+ // the id doesn't need its own try/catch.
660
+ async function resolveCoreId() {
661
+ const corePath = await resolveCorePath();
662
+ if (!corePath) return null;
663
+ try {
664
+ return (await loadCore(corePath)).id;
665
+ } catch {
666
+ return null;
667
+ }
668
+ }
669
+
656
670
  // `hedgehog plan --recompile` — the reconciliation path for a core.yaml
657
671
  // edited after `plan` already compiled it.
658
672
  //
@@ -973,7 +987,7 @@ async function nextCommand() {
973
987
  console.error('');
974
988
  }
975
989
 
976
- console.log(formatNext(packet));
990
+ console.log(formatNext(packet, await resolveCoreId()));
977
991
  }
978
992
 
979
993
  function printStalledTasks(stalled) {
@@ -1043,6 +1057,49 @@ async function missingBinariesForTask(db, taskId, owner) {
1043
1057
  return missing.length === 0 ? null : { layer: layer.id, missing };
1044
1058
  }
1045
1059
 
1060
+ // What an out-of-scope path most likely is, and what to do about it.
1061
+ // "Outside allowed scope" is true of every offending path and tells the
1062
+ // reader nothing about which of three quite different situations they're
1063
+ // in: a package shell the layer itself had to create (widen this one
1064
+ // task), shared config a source-level fix touched (commit it separately),
1065
+ // or a genuine stray. Each wants a different next move, and working out
1066
+ // which costs a beat every time.
1067
+ //
1068
+ // Evidence-only, like core.mjs's lint: an unrecognised path gets no
1069
+ // annotation rather than a guessed one.
1070
+ const SCOPE_HINTS = [
1071
+ {
1072
+ // A package root's own scaffolding — package.json, tsconfig*.json,
1073
+ // vitest.config.mts, src/index.ts directly under packages/<pkg>/ or
1074
+ // libs/<a>/<b>/. No {module}-bearing scope glob can cover these, so
1075
+ // the first module through a layer that creates its package always
1076
+ // lands here.
1077
+ test: (p) =>
1078
+ /^(packages\/[^/]+|libs\/[^/]+\/[^/]+)\/(package\.json|tsconfig[^/]*\.json|vitest\.config\.[cm]?ts|project\.json|eslint\.config\.[cm]?js|src\/index\.ts)$/.test(
1079
+ p,
1080
+ ),
1081
+ 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',
1082
+ },
1083
+ {
1084
+ test: (p) =>
1085
+ p === 'pnpm-workspace.yaml' ||
1086
+ p === 'pnpm-lock.yaml' ||
1087
+ p === 'tsconfig.json' ||
1088
+ p === 'nx.json' ||
1089
+ p === 'package.json' ||
1090
+ p.startsWith('packages/config/'),
1091
+ hint: 'shared workspace config — no layer owns it; commit it separately as its own `chore(workspace): …` before retrying',
1092
+ },
1093
+ {
1094
+ test: (p) => p.startsWith('.hedgehog/'),
1095
+ hint: 'build-graph state — intents, overrides and friction are committed by the command that writes them, not by a layer',
1096
+ },
1097
+ ];
1098
+
1099
+ function scopeHintFor(path) {
1100
+ return SCOPE_HINTS.find((h) => h.test(path))?.hint ?? null;
1101
+ }
1102
+
1046
1103
  async function verifyCommand(args) {
1047
1104
  await ensureDb();
1048
1105
 
@@ -1093,7 +1150,11 @@ async function verifyCommand(args) {
1093
1150
  if (result.outcome === 'scope_violation') {
1094
1151
  console.error(`${red(bold('Scope violation.'))} Task ${bold(taskId)} is now ${bold('blocked')}.\n`);
1095
1152
  console.error('Touched paths outside allowed scope:');
1096
- for (const path of result.offending) console.error(` ${red('✗')} ${path}`);
1153
+ for (const path of result.offending) {
1154
+ console.error(` ${red('✗')} ${path}`);
1155
+ const hint = scopeHintFor(path);
1156
+ if (hint) console.error(` ${dim(hint)}`);
1157
+ }
1097
1158
  console.error();
1098
1159
  process.exitCode = 1;
1099
1160
  return;
@@ -1145,14 +1206,15 @@ async function verifyCommand(args) {
1145
1206
  // doesn't print the packet here, the STATUS/ALLOWED SCOPE/VERIFICATION an
1146
1207
  // agent is supposed to be dispatched with is no longer reachable from any
1147
1208
  // command. (`hedgehog show <task-id>` reprints it later.)
1148
- function printPackets(tasks) {
1209
+ async function printPackets(tasks) {
1210
+ const coreId = await resolveCoreId();
1149
1211
  const db = openDb();
1150
1212
  try {
1151
1213
  for (const task of tasks) {
1152
1214
  const packet = taskPacket(db, task.id);
1153
1215
  if (!packet) continue;
1154
1216
  console.log();
1155
- console.log(formatPacket(packet, taskStatusLine(packet.task)));
1217
+ console.log(formatPacket(packet, taskStatusLine(packet.task), coreId));
1156
1218
  }
1157
1219
  } finally {
1158
1220
  db.close();
@@ -1239,7 +1301,7 @@ async function claimCommand(args) {
1239
1301
  if (claimed.length > 1) console.log(` ${bold(task.id)}`);
1240
1302
  console.log(` ${dim('expires')} ${task.lease_expires_at}`);
1241
1303
  }
1242
- printPackets(claimed);
1304
+ await printPackets(claimed);
1243
1305
  }
1244
1306
 
1245
1307
  // The targeted half of `claim`: one named task, or a non-zero exit
@@ -1258,7 +1320,7 @@ async function claimOneCommand(taskId, owner) {
1258
1320
  if (result.claimed) {
1259
1321
  console.log(`${green(bold('Claimed.'))} Task ${bold(taskId)} leased to ${bold(owner)}.`);
1260
1322
  console.log(` ${dim('expires')} ${result.task.lease_expires_at}`);
1261
- printPackets([result.task]);
1323
+ await printPackets([result.task]);
1262
1324
  return;
1263
1325
  }
1264
1326
 
@@ -1382,7 +1444,7 @@ async function showCommand(args) {
1382
1444
  return;
1383
1445
  }
1384
1446
 
1385
- console.log(formatPacket(packet, taskStatusLine(packet.task)));
1447
+ console.log(formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId()));
1386
1448
  }
1387
1449
 
1388
1450
  // `hedgehog release <task-id> --owner <owner>` — hands a claimed task
@@ -1539,7 +1601,27 @@ async function statusCommand() {
1539
1601
  }
1540
1602
  }
1541
1603
 
1542
- const overrides = core ? await loadOverrides() : new Map();
1604
+ // Loaded whether or not a core resolved: drift is the only consumer
1605
+ // that needs `core` composed against these, and graphStatus already
1606
+ // gates that on `core` itself. The orphan check reads task ids out of
1607
+ // the database, so it is answerable — and worth answering — on a
1608
+ // project whose core.yaml is missing or unparseable. A missing
1609
+ // overrides directory reads as an empty Map (loadOverrides), so this
1610
+ // costs nothing on a project that has never written one.
1611
+ //
1612
+ // A malformed override file throws, and for the same reason an
1613
+ // unparseable core.yaml doesn't take `status` down, neither may this:
1614
+ // status is the command every session starts with, and the report of
1615
+ // the broken file is more useful than an aborted overview.
1616
+ let overrides = new Map();
1617
+ try {
1618
+ overrides = await loadOverrides();
1619
+ } catch (err) {
1620
+ console.error(
1621
+ `${yellow('Override file unreadable:')} ${err.message}\n${dim('Scope overrides are ignored until it parses.')}\n`,
1622
+ );
1623
+ }
1624
+
1543
1625
  const db = openDb();
1544
1626
  let result;
1545
1627
  try {
@@ -1711,7 +1793,13 @@ function openInBrowser(url) {
1711
1793
  // either fails silently or blocks on a text browser. Printing the URL is
1712
1794
  // the useful behaviour in that case — a person on the other end of a
1713
1795
  // port-forward can still open it.
1796
+ //
1797
+ // HEDGEHOG_FORCE_HEADLESS bypasses the platform check entirely — the
1798
+ // repro suite's only way to exercise the no-display branch on macOS/
1799
+ // Windows, where DISPLAY/WAYLAND_DISPLAY are never consulted for real
1800
+ // users. Not a documented user-facing flag.
1714
1801
  function hasDisplay() {
1802
+ if (process.env.HEDGEHOG_FORCE_HEADLESS) return false;
1715
1803
  if (process.platform === 'darwin' || process.platform === 'win32') return true;
1716
1804
  return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
1717
1805
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
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": {
@@ -34,31 +34,54 @@ build exactly what its ALLOWED SCOPE names, one layer at a time, gated by
34
34
  Use `nx-run-tasks` (build/lint/test/typecheck), `nx-workspace` (inspecting
35
35
  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
- needed.
37
+ needed. A layer's first-arrival package shell (`contract`, `repository`,
38
+ `service`) is a generator call, not a hand-copy of a sibling package —
39
+ `hedgehog-loop`'s "First arrival in a package" section names the exact
40
+ command and tags for each.
38
41
 
39
42
  ## Core Responsibilities
40
43
 
41
44
  - **`schema`**: define the table in `packages/db` (Drizzle). One domain
42
45
  module = one table. Cross-module references are FK-by-ID columns
43
- only — never a foreign schema import.
46
+ only — never a foreign schema import. Add one re-export line for the
47
+ module to `packages/db/src/schema/index.ts` (in scope for this
48
+ layer) so the table is importable outside `packages/db` — the
49
+ package's own `src/index.ts` re-exports that barrel and never
50
+ changes after bootstrap.
44
51
  - **`contract`**: derive the Zod schema from Drizzle (`drizzle-zod`) and
45
- wire the ts-rest contract in `packages/contracts`.
52
+ wire the ts-rest contract in `packages/contracts`. A `date`-mode
53
+ `timestamp` column reflected through `createSelectSchema` is overridden
54
+ to a union of `z.date()` and an ISO datetime string, never left as the
55
+ derived `z.date()` alone and never narrowed to a string-only schema —
56
+ the same field is checked server-side against a real `Date` and
57
+ client-side against JSON's string, and no `.transform()` can satisfy
58
+ both.
46
59
  - **`repository`**: a port (interface) plus a Drizzle adapter in
47
60
  `libs/<module>/repository`. A `findById`-shaped miss returns
48
61
  `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.
62
+ what absence means. The concrete adapter's file name ends in
63
+ `.adapter.ts` and the lib's entry point exports the port interface and
64
+ its DI token; `packages/config/eslint-base.js` keys its port-discipline
65
+ rule on that suffix, so an adapter named anything else silently opts
66
+ out of the check.
67
+ - **`service`**: domain logic in `libs/<module>/service`, importing its
68
+ own module's port interface from the repository lib's entry point —
69
+ never a `*.adapter` file, never `drizzle-orm` or `packages/db`
70
+ (`no-restricted-imports` in `eslint-base.js` fails lint on either).
71
+ Throws typed, domain-named errors (`OrderNotFoundError`, not a bare
72
+ `Error` or an HTTP exception). No logging, no HTTP, no queue mechanics
73
+ inside a service method. Multi-write operations wrap in one Drizzle
74
+ transaction, passed through the port.
56
75
  - **`controller`**: thin HTTP in `apps/api`, wiring the contract to the
57
76
  service. The only layer that maps domain errors to status codes.
58
77
  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.
78
+ it, types are trusted. `apps/api` is the composition root: the module's
79
+ `*.module.ts` is the one file that constructs the concrete adapter and
80
+ binds it to the port's DI token, and the only file in `apps/api`
81
+ allowed to import a `*.adapter`. A controller takes the service, or the
82
+ bound token — never the adapter. Bundles queue infra (port + BullMQ
83
+ adapter in `apps/worker`, same shape as the repository) when the Queue
84
+ add-on is on and this operation needs it.
62
85
 
63
86
  ## Workflow
64
87
 
@@ -77,7 +100,16 @@ needed.
77
100
  dependencies guarantee this); check before writing the FK column.
78
101
  2. Build exactly one layer, matching the packet's ALLOWED SCOPE. Run
79
102
  typecheck, lint, and test yourself as a sanity check before reporting
80
- back — necessary, not sufficient.
103
+ back — necessary, not sufficient. If this layer also has to create the
104
+ package it lands in (the first module through `contract` creates
105
+ `packages/contracts`), its shell files sit outside the packet's ALLOWED
106
+ SCOPE and `hedgehog verify` will leave them uncommitted — stop and say
107
+ so before building, so the scope can be widened for this one task
108
+ (`hedgehog-loop`, "First arrival in a package"). Don't build against a
109
+ scope you already know won't commit your work. That section also owns
110
+ the workspace wiring a new package needs (`pnpm install`, `pnpm nx
111
+ sync`, and its own `chore(workspace):` commit) — follow it rather than
112
+ leaving the package unlinked for a later layer to trip over.
81
113
  3. **Report the work as done; do not commit it yourself.** Per the build
82
114
  graph's design, an agent reporting success never moves a task — only
83
115
  `hedgehog verify <task-id>`'s passing exit code does. It checks your
@@ -32,7 +32,9 @@ before the next starts.
32
32
  Use `nx-run-tasks` (build/lint/test/typecheck), `nx-workspace` (inspecting
33
33
  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
- needed.
35
+ needed. The `hook` layer's first-arrival package shell is a generator
36
+ call, not a hand-copy of a sibling package — `hedgehog-loop`'s "First
37
+ arrival in a package" section names the exact command and tags.
36
38
 
37
39
  If the screen step calls for animation or motion — entrances, sequencing,
38
40
  scroll-driven effects, drag, SVG/morph effects — use GSAP, loading the
@@ -46,17 +48,26 @@ don't reach for a second one.
46
48
 
47
49
  - **`hook`**: build the TanStack Query hook in `packages/hooks`, wrapping
48
50
  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.
51
+ to end from the Zod contract. The client's base URL is
52
+ `process.env.NEXT_PUBLIC_API_BASE_URL`, scaffolded in
53
+ `apps/web/.env.example` and already carrying `apps/api`'s `/api` global
54
+ prefix. Read it as-is: don't append or strip a path segment (the prefix
55
+ is in the value), don't add it to `packages/config/env.schema.ts` (that
56
+ schema is `apps/api`'s server env, and a `NEXT_PUBLIC_` var is inlined
57
+ into the browser bundle by Next, never parsed at runtime by `loadEnv()`),
58
+ and never fall back to a hardcoded `http://localhost:<port>`. A wrong or
59
+ absent base URL 404s against Next's own dev server — a config bug wearing
60
+ a routing bug's clothes, and one unit tests never see, because they mock
61
+ the client.
62
+ - **`screen`**: build the screen/component in `apps/web` (plus
63
+ `apps/mobile` when the Mobile add-on is on), consuming the hook and
64
+ `ux-planner`'s rationale for that module (screen inventory, interaction
65
+ pattern, information hierarchy). No direct data-fetching in the
66
+ screen — the hook owns that. If this module's screen is the first one
67
+ built, wire `apps/web/src/app/page.tsx`'s primary CTA (ShadCN's
68
+ `asChild` + `next/link`, per `button.tsx`'s existing `asChild` prop)
69
+ to this module's own route — a compiling, lint-clean button with no
70
+ `href` or `onClick` still ships silent and unclickable.
60
71
  - Translate design specs into components. If a design tool is wired into
61
72
  this project's MCP config, use it for tokens/spacing/typography;
62
73
  otherwise match existing ShadCN/Tailwind patterns in the repo.
@@ -83,7 +94,13 @@ don't reach for a second one.
83
94
  early.
84
95
  2. Build the hook against the contract client, matching the packet's
85
96
  ALLOWED SCOPE. Run typecheck, lint, and test yourself as a sanity
86
- check before reporting back — necessary, not sufficient.
97
+ check before reporting back — necessary, not sufficient. On the first
98
+ module through this layer, the hook also creates `packages/hooks`, and
99
+ that package's shell sits outside the packet's ALLOWED SCOPE —
100
+ `hedgehog verify` would leave it uncommitted. Stop and say so before
101
+ building, so the scope can be widened for this one task
102
+ (`hedgehog-loop`, "First arrival in a package") — that section also
103
+ owns the workspace wiring the new package needs.
87
104
  3. **Report the work as done; do not commit it yourself.** Only
88
105
  `hedgehog verify <task-id>`'s passing exit code moves the task to
89
106
  `complete` and writes the commit (the packet's exact Conventional
@@ -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,47 @@ 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
+ const FULL_STACK_APP_LAYER_TAGS = {
245
+ schema: { tags: ['scope:db', 'type:adapter'], dependsOnTags: [] },
246
+ contract: { tags: ['scope:contracts', 'type:contract'], dependsOnTags: ['type:adapter', 'type:util'] },
247
+ repository: { tags: ['scope:{module}', 'type:adapter'], dependsOnTags: ['type:adapter', 'type:contract', 'type:util'] },
248
+ service: { tags: ['scope:{module}', 'type:service'], dependsOnTags: ['type:adapter', 'type:contract', 'type:util'] },
249
+ controller: { tags: ['scope:api'], dependsOnTags: ['type:adapter', 'type:service', 'type:contract', 'type:util'] },
250
+ hook: { tags: ['scope:hooks', 'type:hook'], dependsOnTags: ['type:contract', 'type:util'] },
251
+ screen: { tags: ['scope:web'], dependsOnTags: ['scope:contracts', 'scope:hooks', 'scope:shared', 'type:util'] },
252
+ };
253
+
254
+ // A LAYER SHAPE section for full-stack-app tasks only (`coreId ===
255
+ // 'full-stack-app'`) — an authored core has no equivalent tag scheme
256
+ // (layer-eng.md already points that agent at reading .hedgehog/core.yaml
257
+ // directly instead), and `join` has no fixed tag shape of its own to
258
+ // state, so both fall through to null and print nothing.
259
+ function layerShapeLines(task, coreId) {
260
+ if (coreId !== 'full-stack-app') return null;
261
+ const shape = FULL_STACK_APP_LAYER_TAGS[task.layer];
262
+ if (!shape) return null;
263
+ const tags = shape.tags.map((t) => t.replace('{module}', task.module));
264
+ const lines = ['LAYER SHAPE', ` this layer's tags: ${tags.join(', ')}`];
265
+ if (shape.dependsOnTags.length === 0) {
266
+ lines.push(' may depend on tags: (nothing in-workspace — floor layer)');
267
+ } else {
268
+ lines.push(` may depend on tags: ${shape.dependsOnTags.join(', ')}`);
269
+ }
270
+ lines.push(
271
+ " Confirm against packages/config/eslint-base.js's depConstraints before",
272
+ " writing an import — a mismatch here is a pre-flight fact, not a lint",
273
+ ' failure to discover later.',
274
+ );
275
+ return lines;
276
+ }
277
+
237
278
  // The standing honesty requirement, appended to every packet.
238
279
  //
239
280
  // Every other section is task-specific — this one is constant, which is
@@ -264,8 +305,8 @@ const HONESTY = [
264
305
  ];
265
306
 
266
307
  // Renders a packet into the STATUS / INTENT / RELEVANT RULES /
267
- // INHERITED DEBT / WHY NOW / BLOCKED DOWNSTREAM / ALLOWED SCOPE /
268
- // VERIFICATION / HONESTY format. The spec
308
+ // INHERITED DEBT / WHY NOW / BLOCKED DOWNSTREAM / ALLOWED SCOPE / LAYER
309
+ // SHAPE / VERIFICATION / HONESTY format. The spec
269
310
  // splits this across two examples — the `hedgehog next` display and "The
270
311
  // task packet" (which carries the intent and its rules) — but an agent
271
312
  // receives one thing, so the packet is one thing: everything the worker
@@ -273,12 +314,15 @@ const HONESTY = [
273
314
  //
274
315
  // `statusLine` is what goes on the STATUS row. `next` passes READY
275
316
  // literally, as it always has; `show` passes taskStatusLine(task), which
276
- // names the task's real state.
317
+ // names the task's real state. `coreId` is the active core's `id` (from
318
+ // `core.yaml`) — optional, since a caller with no core resolved yet (a
319
+ // deferred install) still has to be able to render *something*; LAYER
320
+ // SHAPE only ever appears for a recognised full-stack-app layer.
277
321
  //
278
322
  // HONESTY is last deliberately: it's the one section that qualifies the
279
323
  // gate above it, so it reads as the answer to "and what if I can't clear
280
324
  // VERIFICATION honestly" rather than as preamble.
281
- export function formatPacket(packet, statusLine) {
325
+ export function formatPacket(packet, statusLine, coreId = null) {
282
326
  const { task, intent, requirements, dependents, incompleteDeps = [], inheritedDebt = [] } = packet;
283
327
  const scopeGlobs = JSON.parse(task.scope_globs);
284
328
 
@@ -346,6 +390,11 @@ export function formatPacket(packet, statusLine) {
346
390
  lines.push('ALLOWED SCOPE');
347
391
  for (const glob of scopeGlobs) lines.push(` ${glob}`);
348
392
  lines.push('');
393
+ const shapeLines = layerShapeLines(task, coreId);
394
+ if (shapeLines) {
395
+ lines.push(...shapeLines);
396
+ lines.push('');
397
+ }
349
398
  lines.push('VERIFICATION');
350
399
  lines.push(` ${task.verify_command}`);
351
400
  lines.push('');
@@ -354,8 +403,8 @@ export function formatPacket(packet, statusLine) {
354
403
  return lines.join('\n');
355
404
  }
356
405
 
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');
406
+ // `hedgehog next`'s rendering: its task always came out of the readiness
407
+ // SELECT, so STATUS is READY by construction.
408
+ export function formatNext(packet, coreId = null) {
409
+ return formatPacket(packet, 'READY', coreId);
361
410
  }