@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.
- package/README.md +1 -1
- package/bin/cli.mjs +137 -16
- package/package.json +2 -2
- package/src/agents/backend-eng.md +46 -14
- package/src/agents/front-end-eng.md +30 -13
- package/src/agents/reviewer.md +9 -4
- package/src/agents/ux-planner.md +2 -2
- package/src/db/claim.mjs +51 -7
- package/src/db/next.mjs +57 -8
- package/src/db/ready.mjs +5 -3
- package/src/db/status.mjs +150 -11
- package/src/golden-cores/full-stack-app/.env.example +1 -0
- package/src/golden-cores/full-stack-app/apps/api/src/main.ts +10 -1
- package/src/golden-cores/full-stack-app/apps/web/.env.example +12 -0
- package/src/golden-cores/full-stack-app/apps/web/package.json +3 -0
- package/src/golden-cores/full-stack-app/core.yaml +11 -3
- package/src/golden-cores/full-stack-app/packages/config/eslint-base.js +108 -13
- package/src/golden-cores/full-stack-app/packages/config/src/env.schema.spec.ts +14 -1
- package/src/golden-cores/full-stack-app/packages/config/src/env.schema.ts +1 -0
- package/src/golden-cores/full-stack-app/packages/config/src/index.ts +1 -1
- package/src/golden-cores/full-stack-app/packages/db/src/index.ts +2 -1
- package/src/golden-cores/full-stack-app/packages/db/src/lib/db.spec.ts +3 -3
- package/src/golden-cores/full-stack-app/packages/db/src/schema/index.ts +4 -0
- package/src/golden-cores/full-stack-app/pnpm-workspace.yaml +4 -0
- package/src/golden-cores/full-stack-app/tsconfig.base.json +2 -2
- package/src/skills/conventional-commits/SKILL.md +2 -3
- package/src/skills/hedgehog-authored-loop/SKILL.md +15 -8
- package/src/skills/hedgehog-bootstrap/SKILL.md +25 -7
- package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +72 -18
- package/src/skills/hedgehog-landing-loop/SKILL.md +13 -4
- 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
|
-
[](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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
its
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
60
|
-
`
|
|
61
|
-
|
|
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
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
package/src/agents/reviewer.md
CHANGED
|
@@ -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**:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
the
|
|
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.
|
package/src/agents/ux-planner.md
CHANGED
|
@@ -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`
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
208
|
-
//
|
|
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
|
|