@skyf0xx/hedgehog 4.0.15 → 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 (31) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.mjs +137 -16
  3. package/package.json +2 -2
  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/claim.mjs +51 -7
  9. package/src/db/next.mjs +57 -8
  10. package/src/db/ready.mjs +5 -3
  11. package/src/db/status.mjs +150 -11
  12. package/src/golden-cores/full-stack-app/.env.example +1 -0
  13. package/src/golden-cores/full-stack-app/apps/api/src/main.ts +10 -1
  14. package/src/golden-cores/full-stack-app/apps/web/.env.example +12 -0
  15. package/src/golden-cores/full-stack-app/apps/web/package.json +3 -0
  16. package/src/golden-cores/full-stack-app/core.yaml +11 -3
  17. package/src/golden-cores/full-stack-app/packages/config/eslint-base.js +108 -13
  18. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.spec.ts +14 -1
  19. package/src/golden-cores/full-stack-app/packages/config/src/env.schema.ts +1 -0
  20. package/src/golden-cores/full-stack-app/packages/config/src/index.ts +1 -1
  21. package/src/golden-cores/full-stack-app/packages/db/src/index.ts +2 -1
  22. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.spec.ts +3 -3
  23. package/src/golden-cores/full-stack-app/packages/db/src/schema/index.ts +4 -0
  24. package/src/golden-cores/full-stack-app/pnpm-workspace.yaml +4 -0
  25. package/src/golden-cores/full-stack-app/tsconfig.base.json +2 -2
  26. package/src/skills/conventional-commits/SKILL.md +2 -3
  27. package/src/skills/hedgehog-authored-loop/SKILL.md +15 -8
  28. package/src/skills/hedgehog-bootstrap/SKILL.md +25 -7
  29. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +72 -18
  30. package/src/skills/hedgehog-landing-loop/SKILL.md +13 -4
  31. package/src/skills/hedgehog-loop/SKILL.md +106 -12
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
  //
@@ -933,10 +947,10 @@ async function nextCommand() {
933
947
 
934
948
  const db = openDb();
935
949
  let packet;
936
- let stalled = [];
950
+ let stalled;
937
951
  try {
938
952
  packet = nextTask(db);
939
- if (!packet) stalled = stalledTasks(db);
953
+ stalled = stalledTasks(db);
940
954
  } finally {
941
955
  db.close();
942
956
  }
@@ -947,10 +961,7 @@ async function nextCommand() {
947
961
  // a failed verification.
948
962
  if (stalled.length > 0) {
949
963
  console.error(`${red(bold('No ready task, but the graph is blocked.'))}\n`);
950
- for (const task of stalled) {
951
- const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
952
- console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
953
- }
964
+ printStalledTasks(stalled);
954
965
  // `verify` refuses anything that isn't `building` and leased to
955
966
  // the caller, so a blocked task has to go back through the queue
956
967
  // — retry, claim, then verify — rather than straight to verify.
@@ -964,7 +975,26 @@ async function nextCommand() {
964
975
  return;
965
976
  }
966
977
 
967
- console.log(formatNext(packet));
978
+ // A blocked task elsewhere in the graph doesn't stop this ready task
979
+ // from being handed out — leases are scoped to disjoint work, so an
980
+ // unrelated block is no reason to halt everything else. But it's easy
981
+ // to miss otherwise: the queue keeps producing ready tasks right up
982
+ // until it doesn't, and a block can sit unnoticed the whole time. This
983
+ // warns without withholding the packet.
984
+ if (stalled.length > 0) {
985
+ console.error(`${yellow(bold(`${stalled.length} task(s) blocked elsewhere in the graph:`))}`);
986
+ printStalledTasks(stalled);
987
+ console.error('');
988
+ }
989
+
990
+ console.log(formatNext(packet, await resolveCoreId()));
991
+ }
992
+
993
+ function printStalledTasks(stalled) {
994
+ for (const task of stalled) {
995
+ const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
996
+ console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
997
+ }
968
998
  }
969
999
 
970
1000
  // Mirrors, read-only, the condition verifyTask's own Phase 0
@@ -1027,6 +1057,49 @@ async function missingBinariesForTask(db, taskId, owner) {
1027
1057
  return missing.length === 0 ? null : { layer: layer.id, missing };
1028
1058
  }
1029
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
+
1030
1103
  async function verifyCommand(args) {
1031
1104
  await ensureDb();
1032
1105
 
@@ -1077,7 +1150,11 @@ async function verifyCommand(args) {
1077
1150
  if (result.outcome === 'scope_violation') {
1078
1151
  console.error(`${red(bold('Scope violation.'))} Task ${bold(taskId)} is now ${bold('blocked')}.\n`);
1079
1152
  console.error('Touched paths outside allowed scope:');
1080
- 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
+ }
1081
1158
  console.error();
1082
1159
  process.exitCode = 1;
1083
1160
  return;
@@ -1129,14 +1206,15 @@ async function verifyCommand(args) {
1129
1206
  // doesn't print the packet here, the STATUS/ALLOWED SCOPE/VERIFICATION an
1130
1207
  // agent is supposed to be dispatched with is no longer reachable from any
1131
1208
  // command. (`hedgehog show <task-id>` reprints it later.)
1132
- function printPackets(tasks) {
1209
+ async function printPackets(tasks) {
1210
+ const coreId = await resolveCoreId();
1133
1211
  const db = openDb();
1134
1212
  try {
1135
1213
  for (const task of tasks) {
1136
1214
  const packet = taskPacket(db, task.id);
1137
1215
  if (!packet) continue;
1138
1216
  console.log();
1139
- console.log(formatPacket(packet, taskStatusLine(packet.task)));
1217
+ console.log(formatPacket(packet, taskStatusLine(packet.task), coreId));
1140
1218
  }
1141
1219
  } finally {
1142
1220
  db.close();
@@ -1185,13 +1263,30 @@ async function claimCommand(args) {
1185
1263
  }
1186
1264
 
1187
1265
  const db = openDb();
1188
- let claimed;
1266
+ let claimed, blocked;
1189
1267
  try {
1190
- claimed = claimTasks(db, { owner, count });
1268
+ ({ claimed, blocked } = claimTasks(db, { owner, count }));
1191
1269
  } finally {
1192
1270
  db.close();
1193
1271
  }
1194
1272
 
1273
+ // Stop-the-line: claimTasks refuses the whole batch, in any module,
1274
+ // while any task anywhere is blocked — see its own comment in
1275
+ // claim.mjs. A targeted `hedgehog claim <task-id>` is unaffected, which
1276
+ // is how the blocked task itself gets reclaimed after `hedgehog retry`.
1277
+ if (blocked.length > 0) {
1278
+ console.error(`${red(bold('Claim refused.'))} ${blocked.length} task(s) blocked:\n`);
1279
+ for (const task of blocked) {
1280
+ const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
1281
+ console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
1282
+ }
1283
+ console.error(
1284
+ `\nFix the work, then ${bold('hedgehog retry <task-id>')} before claiming more.\n`,
1285
+ );
1286
+ process.exitCode = 1;
1287
+ return;
1288
+ }
1289
+
1195
1290
  if (claimed.length === 0) {
1196
1291
  console.log(`${dim('No claimable task.')} Nothing is ready with no lease held.\n`);
1197
1292
  return;
@@ -1206,7 +1301,7 @@ async function claimCommand(args) {
1206
1301
  if (claimed.length > 1) console.log(` ${bold(task.id)}`);
1207
1302
  console.log(` ${dim('expires')} ${task.lease_expires_at}`);
1208
1303
  }
1209
- printPackets(claimed);
1304
+ await printPackets(claimed);
1210
1305
  }
1211
1306
 
1212
1307
  // The targeted half of `claim`: one named task, or a non-zero exit
@@ -1225,7 +1320,7 @@ async function claimOneCommand(taskId, owner) {
1225
1320
  if (result.claimed) {
1226
1321
  console.log(`${green(bold('Claimed.'))} Task ${bold(taskId)} leased to ${bold(owner)}.`);
1227
1322
  console.log(` ${dim('expires')} ${result.task.lease_expires_at}`);
1228
- printPackets([result.task]);
1323
+ await printPackets([result.task]);
1229
1324
  return;
1230
1325
  }
1231
1326
 
@@ -1349,7 +1444,7 @@ async function showCommand(args) {
1349
1444
  return;
1350
1445
  }
1351
1446
 
1352
- console.log(formatPacket(packet, taskStatusLine(packet.task)));
1447
+ console.log(formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId()));
1353
1448
  }
1354
1449
 
1355
1450
  // `hedgehog release <task-id> --owner <owner>` — hands a claimed task
@@ -1506,7 +1601,27 @@ async function statusCommand() {
1506
1601
  }
1507
1602
  }
1508
1603
 
1509
- 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
+
1510
1625
  const db = openDb();
1511
1626
  let result;
1512
1627
  try {
@@ -1678,7 +1793,13 @@ function openInBrowser(url) {
1678
1793
  // either fails silently or blocks on a text browser. Printing the URL is
1679
1794
  // the useful behaviour in that case — a person on the other end of a
1680
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.
1681
1801
  function hasDisplay() {
1802
+ if (process.env.HEDGEHOG_FORCE_HEADLESS) return false;
1682
1803
  if (process.platform === 'darwin' || process.platform === 'win32') return true;
1683
1804
  return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
1684
1805
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "4.0.15",
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": {
@@ -44,4 +44,4 @@
44
44
  "hedgehog"
45
45
  ],
46
46
  "license": "MIT"
47
- }
47
+ }
@@ -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/claim.mjs CHANGED
@@ -180,16 +180,25 @@ function loadTask(db, taskId) {
180
180
  // concurrent `claim` call in the interim would otherwise reach `verify`
181
181
  // with a technically-expired lease that still matches status/lease_owner
182
182
  // and sail through unreaped.
183
+ //
184
+ // Returns the ids it just flipped, as a Set — claimTasks's stop-the-line
185
+ // check uses this to tell a block this very call produced from one that
186
+ // was already sitting there, so a dead agent's lease lapsing doesn't
187
+ // itself become the reason every other module's fan-out refuses.
183
188
  export function reapExpiredLeases(db) {
184
- db.prepare(
185
- `
189
+ const rows = db
190
+ .prepare(
191
+ `
186
192
  UPDATE tasks
187
193
  SET status = 'blocked', blocked_reason = 'lease_expired',
188
194
  lease_owner = NULL, lease_expires_at = NULL, leased_at = NULL,
189
195
  claim_snapshot = NULL
190
196
  WHERE status IN ('building', 'verifying') AND lease_expires_at < datetime('now')
197
+ RETURNING id
191
198
  `,
192
- ).run();
199
+ )
200
+ .all();
201
+ return new Set(rows.map((r) => r.id));
193
202
  }
194
203
 
195
204
  // The atomic claim primitive: the WHERE clause re-checks status and
@@ -204,8 +213,38 @@ const claimOne = (db) =>
204
213
  RETURNING id
205
214
  `);
206
215
 
207
- // Claims up to `count` ready tasks for `owner`. `count` is a maximum, not
208
- // a promise returns however many were actually claimed, possibly zero.
216
+ // Every `blocked` task in the graph, regardless of module or reason —
217
+ // the fan-out claim's stop-the-line check. Ordered the same as the
218
+ // NEEDS ATTENTION list in status.mjs, so the two never disagree about
219
+ // which tasks are outstanding.
220
+ function findBlockedTasks(db) {
221
+ return db.prepare(`SELECT * FROM tasks WHERE status = 'blocked' ORDER BY priority, id`).all();
222
+ }
223
+
224
+ // Claims up to `count` ready tasks for `owner`, returning
225
+ // `{ claimed, blocked }`. `count` is a maximum, not a promise — `claimed`
226
+ // may hold fewer tasks than `count`, or none. `blocked` is non-empty only
227
+ // on the stop-the-line refusal below, in which case `claimed` is always
228
+ // empty.
229
+ //
230
+ // Stop-the-line: if any task anywhere in the graph was already `blocked`
231
+ // before this call, the fan-out refuses to claim anything at all, in any
232
+ // module — a blocked task needs a human/agent decision (retry after a
233
+ // fix, or leave it), and handing out fresh work around it makes that easy
234
+ // to ignore indefinitely. Deliberately stricter than dependency-based
235
+ // blocking: an unrelated module isn't literally stuck on the blocked
236
+ // task, but the fan-out stops anyway until it's retried. A targeted
237
+ // `hedgehog claim <task-id>` (claimTask, below) is exempt — that's how
238
+ // the blocked task itself gets reclaimed after `hedgehog retry`.
239
+ //
240
+ // "Already blocked" excludes a lease this same call's reapExpiredLeases
241
+ // just flipped: a dead agent's lease can lapse on an unrelated module,
242
+ // and the first `claim` call after that lapse is whichever one happens to
243
+ // discover it. That call still claims normally; the reaped task still
244
+ // lands in `blocked`/`lease_expired` and still needs `hedgehog retry`
245
+ // before it's claimable — only this one call's refusal is skipped. Every
246
+ // `claim` call after it sees the same task still blocked and stops as
247
+ // usual.
209
248
  //
210
249
  // Fan-out keeps the batch mutually non-conflicting, and non-conflicting
211
250
  // with every task already in flight, per conflict.mjs's
@@ -229,7 +268,12 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
229
268
  const claimSnapshot = snapshotWorkingTree();
230
269
 
231
270
  return inTransaction(db, () => {
232
- reapExpiredLeases(db);
271
+ const justReaped = reapExpiredLeases(db);
272
+
273
+ const blocked = findBlockedTasks(db).filter((task) => !justReaped.has(task.id));
274
+ if (blocked.length > 0) {
275
+ return { claimed: [], blocked };
276
+ }
233
277
 
234
278
  const candidates = findClaimableTasks(db);
235
279
  const inFlight = findInFlightTasks(db);
@@ -245,7 +289,7 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
245
289
  claimed.push(loadTask(db, candidate.id));
246
290
  }
247
291
 
248
- return claimed;
292
+ return { claimed, blocked: [] };
249
293
  });
250
294
  }
251
295