@skyf0xx/hedgehog 4.3.1 → 4.3.6

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 (29) hide show
  1. package/bin/cli.mjs +157 -10
  2. package/package.json +1 -1
  3. package/src/agents/planner.md +44 -7
  4. package/src/agents/ux-planner.md +29 -13
  5. package/src/db/core.mjs +10 -8
  6. package/src/db/next.mjs +72 -3
  7. package/src/db/rebuild.mjs +72 -14
  8. package/src/golden-cores/full-stack-app/apps/web/package.json +12 -0
  9. package/src/golden-cores/full-stack-app/apps/web/src/app/module-routes.ts +13 -0
  10. package/src/golden-cores/full-stack-app/apps/web/src/app/page.tsx +21 -1
  11. package/src/golden-cores/full-stack-app/nx.json +7 -3
  12. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.ts +15 -1
  13. package/src/golden-cores/full-stack-app/tools/generate-module-routes.cjs +89 -0
  14. package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +59 -6
  15. package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +5 -1
  16. package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +75 -8
  17. package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +5 -1
  18. package/src/golden-cores/full-stack-app/tools/generators/fields.ts +38 -4
  19. package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +29 -12
  20. package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +1 -1
  21. package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +1 -1
  22. package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +82 -6
  23. package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +4 -0
  24. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +26 -0
  25. package/src/skills/hedgehog-core-design/SKILL.md +9 -1
  26. package/src/skills/hedgehog-loop/SKILL.md +39 -7
  27. package/src/skills/hedgehog-planning-intake/SKILL.md +103 -6
  28. package/src/templates/CLAUDE.core.full-stack-app.md +7 -2
  29. package/src/templates/CLAUDE.md +29 -5
package/bin/cli.mjs CHANGED
@@ -13,13 +13,13 @@
13
13
  // npx @skyf0xx/hedgehog --help
14
14
 
15
15
  import { cp, mkdir, access, readdir, stat, rm, readFile, writeFile } from 'node:fs/promises';
16
- import { constants } from 'node:fs';
16
+ import { constants, existsSync } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { dirname, join, relative, resolve } from 'node:path';
19
19
  import { spawn } from 'node:child_process';
20
20
  import { dbInit, DB_PATH, dbAbsPath, openDb } from '../src/db/init.mjs';
21
- import { loadCore, lintCore } from '../src/db/core.mjs';
22
- import { planTasks } from '../src/db/plan.mjs';
21
+ import { loadCore, lintCore, isModuleAxis } from '../src/db/core.mjs';
22
+ import { planTasks, CORE_INTENT_ID } from '../src/db/plan.mjs';
23
23
  import { addIntent, INTENTS_DIR } from '../src/db/intent.mjs';
24
24
  import {
25
25
  nextTask,
@@ -281,6 +281,26 @@ function warnRebuildDrift({ drift }, corePath) {
281
281
  );
282
282
  }
283
283
 
284
+ // Debt and friction notes are operator-recorded and have no committed
285
+ // source, so a rebuild carries them across by task id. A note whose task
286
+ // is no longer in the recompiled graph — its intent file was renamed,
287
+ // deleted, or its layer sequence changed — has nowhere to re-attach.
288
+ // Friction notes survive unattached (their task_id is nullable); debt
289
+ // notes are lost, so both are printed with their text rather than
290
+ // disappearing into a count.
291
+ function warnOrphanedNotes({ orphanedNotes }) {
292
+ if (!orphanedNotes || orphanedNotes.length === 0) return;
293
+ console.log(
294
+ `${yellow(bold('Notes without a task after rebuild.'))} ${orphanedNotes.length} note(s) referenced a\n` +
295
+ 'task the recompiled graph no longer holds. Friction notes were kept unattached;\n' +
296
+ 'debt notes could not be, so they are reproduced here:\n',
297
+ );
298
+ for (const note of orphanedNotes) {
299
+ console.log(` ${dim(note.kind)} ${bold(note.taskId)} ${note.note}`);
300
+ }
301
+ console.log('');
302
+ }
303
+
284
304
  // Writes one planned file to disk — a straight copy, or for a `merge`
285
305
  // entry, the shell template with {{CORE_SECTION}} replaced by the
286
306
  // chosen core's include.
@@ -528,14 +548,22 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
528
548
  );
529
549
  console.log(dim(' instead of polling tightly or narrating the wait.'));
530
550
  console.log(` 2. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
531
- console.log(` 3. Open ${HOSTS[host].label} and describe what you want to build.`);
551
+ console.log(` 3. Describe what you want to build.`);
532
552
  } else {
533
553
  console.log(` 1. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
534
- console.log(` 2. Open ${HOSTS[host].label} and describe what you want to build.`);
535
- }
554
+ console.log(` 2. Describe what you want to build.`);
555
+ }
556
+ // Hand off by path, not by name. Reading the file works on every host in
557
+ // the session that ran this install — no restart, no dependence on whether
558
+ // the harness re-scans its agent directory, and no chance of a bare
559
+ // `planner` resolving to an unrelated global agent of the same name.
560
+ const agents = HOSTS[host].agentsDir;
561
+ const skills = HOSTS[host].skillsDir;
536
562
  console.log(
537
563
  dim(
538
- ` The ${bold('planner')} agent runs planning intake, then hands off to bootstrap.`,
564
+ ` Read ${bold(`${agents}/planner.md`)} and follow it it runs planning\n` +
565
+ ` intake (${skills}/hedgehog-planning-intake/SKILL.md), then hands\n` +
566
+ ` off to ${agents}/bootstrap.md.`,
539
567
  ),
540
568
  );
541
569
  console.log();
@@ -636,6 +664,7 @@ async function dbRebuildCommand() {
636
664
  console.log(
637
665
  `${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
638
666
  );
667
+ warnOrphanedNotes(result);
639
668
  warnRebuildDrift(result, corePath);
640
669
  }
641
670
 
@@ -667,6 +696,13 @@ async function dbCommand(args) {
667
696
  // along with the rest of src/golden-cores/<core>). Neither exists yet on
668
697
  // a deferred install (plain `init`, no explicit core flag) until
669
698
  // `bootstrap` runs — this returns null until then.
699
+ // Synchronous on purpose: formatPacket renders in one pass and takes this
700
+ // as a predicate, so the FIRST ARRIVAL check cannot be an await. Paths are
701
+ // repo-relative, the same way scope globs are.
702
+ function packetExists(path) {
703
+ return existsSync(join(DEST_ROOT, path));
704
+ }
705
+
670
706
  async function resolveCorePath() {
671
707
  if (await exists(join(DEST_ROOT, AUTHORED_CORE_PATH))) {
672
708
  return join(DEST_ROOT, AUTHORED_CORE_PATH);
@@ -821,6 +857,7 @@ async function planCommand(args = []) {
821
857
  const driftDb = openDb({ readOnly: true });
822
858
  let drifted;
823
859
  try {
860
+ warnSingularModuleIdsAtPlan(core, driftDb);
824
861
  drifted = detectDrift(driftDb, core, { overrides });
825
862
  } finally {
826
863
  driftDb.close();
@@ -957,6 +994,114 @@ async function intentCommand(args) {
957
994
 
958
995
  console.log(` ${green('added')} ${intent.id}`);
959
996
  console.log(` ${dim(`${intent.requirements.length} requirement(s), ${intent.depends_on.length} dependency(ies)`)}`);
997
+ await warnSingularModuleId(intent);
998
+ }
999
+
1000
+ // On a module-axis core the intent id becomes {module} in every layer's
1001
+ // scope glob and verify command, and the generators the packet points at
1002
+ // take the module name plural. A singular id compiles a whole graph
1003
+ // scoped to a directory the generator will never write, and nothing
1004
+ // downstream catches it: plan compiles it, claim hands out a packet whose
1005
+ // own scaffold command contradicts its ALLOWED SCOPE.
1006
+ //
1007
+ // A warning rather than a refusal: plenty of real modules are singular
1008
+ // (`billing`, `search`), so the convention cannot be enforced without
1009
+ // rejecting correct ids. Raised here because this is the last moment the
1010
+ // fix is one file rename — three layers later it is a Correction Protocol
1011
+ // case across every compiled task.
1012
+ //
1013
+ // The convention is a heuristic, not a checkable property of the graph:
1014
+ // `tasks` and `task` are both structurally valid, and nothing downstream
1015
+ // knows which one the generator wants. So this only ever reports.
1016
+ function looksSingular(id) {
1017
+ return !/(s|ae|ia|people|children)$/i.test(id);
1018
+ }
1019
+
1020
+ // `intent add` is one route to the compiler among several — `--file`, a
1021
+ // hand-written intents/*.json, and `db rebuild` all reach it without
1022
+ // passing through here — so `plan` re-raises the same check where every
1023
+ // route converges. See planCommand.
1024
+ async function warnSingularModuleId(intent) {
1025
+ const corePath = await resolveCorePath();
1026
+ if (!corePath) return;
1027
+
1028
+ let core;
1029
+ try {
1030
+ core = await loadCore(corePath);
1031
+ } catch {
1032
+ // A core that will not load is `plan`'s error to report, not this
1033
+ // advisory's.
1034
+ return;
1035
+ }
1036
+ if (!isModuleAxis(core)) return;
1037
+ if (!looksSingular(intent.id)) return;
1038
+
1039
+ console.log(
1040
+ `\n${yellow(bold('Module id looks singular.'))} On this core the intent id is ${bold('{module}')} in\n` +
1041
+ `every layer's scope, and the generators take it plural — so ${bold(intent.id)} compiles\n` +
1042
+ `scope for ${bold(`${intent.id}/`)} while the scaffold command writes ${bold(`${intent.id}s/`)}.\n` +
1043
+ `If ${bold(`${intent.id}s`)} is the name you meant, fix it now, before ${bold('hedgehog plan')}:\n` +
1044
+ ` ${dim(`git mv ${INTENTS_DIR}/${intent.id}.json ${INTENTS_DIR}/${intent.id}s.json`)}\n` +
1045
+ ` ${dim(`# edit "id" and every ${intent.id.toUpperCase()}-* requirement id, then:`)}\n` +
1046
+ ` ${dim('hedgehog db rebuild')}\n`,
1047
+ );
1048
+ }
1049
+
1050
+ // The same advisory at `plan`, where `--file`, a hand-written intent
1051
+ // file, and `db rebuild` all converge — none of them passes through
1052
+ // `intent add`, so on those routes this is the first and last cheap
1053
+ // warning before a whole graph is compiled against the id. It reports
1054
+ // while every one of an intent's tasks is still unstarted; once work has
1055
+ // landed the fix is a Correction Protocol case rather than a rename, and
1056
+ // repeating the `git mv` advice would point at the wrong recovery.
1057
+ //
1058
+ // One block for however many ids trip it, rather than repeating the
1059
+ // per-intent explanation: the reasoning is identical every time, and
1060
+ // stacking it once per intent buries the list of names under it. The
1061
+ // recovery is still per-id, so those lines are listed one per name.
1062
+ function warnSingularModuleIdsAtPlan(core, db) {
1063
+ if (!isModuleAxis(core)) return;
1064
+
1065
+ // Every intent whose tasks have all yet to start, not just the ones
1066
+ // this run compiled. `db rebuild` replays the committed intent files
1067
+ // *and* compiles their tasks, so a `plan` after it compiles nothing —
1068
+ // and that is precisely the route where nothing else has ever looked at
1069
+ // the id. The cheap-fix window is "no work has landed yet", which
1070
+ // outlasts any single `plan` invocation.
1071
+ const singular = db
1072
+ .prepare(
1073
+ `SELECT i.id FROM intents i
1074
+ WHERE i.id <> ?
1075
+ AND NOT EXISTS (
1076
+ SELECT 1 FROM tasks t
1077
+ WHERE t.intent_id = i.id
1078
+ AND t.status NOT IN ('proposed','planned','ready')
1079
+ )
1080
+ ORDER BY i.id`,
1081
+ )
1082
+ .all(CORE_INTENT_ID)
1083
+ .map((row) => row.id)
1084
+ .filter(looksSingular);
1085
+ if (singular.length === 0) return;
1086
+
1087
+ const many = singular.length > 1;
1088
+ console.log(
1089
+ `${yellow(bold(many ? `${singular.length} module ids look singular.` : 'Module id looks singular.'))} ` +
1090
+ `On this core an intent id is ${bold('{module}')} in\n` +
1091
+ `every layer's scope, and the generators take it plural — so ${many ? 'each of these compiles' : 'this compiles'}\n` +
1092
+ `scope for a directory the scaffold command will never write:\n` +
1093
+ singular.map((id) => ` ${bold(id)} ${dim(`— scope ${id}/, scaffold writes ${id}s/`)}`).join('\n') +
1094
+ `\n\nThis is a naming convention, not a rule — ${bold('billing')} and ${bold('search')} are ` +
1095
+ `legitimately\nsingular. If the plural is what you meant, it is still cheap to fix:\n` +
1096
+ singular
1097
+ .map(
1098
+ (id) =>
1099
+ ` ${dim(`git mv ${INTENTS_DIR}/${id}.json ${INTENTS_DIR}/${id}s.json`)}\n` +
1100
+ ` ${dim(`# edit "id" and every ${id.toUpperCase()}-* requirement id`)}`,
1101
+ )
1102
+ .join('\n') +
1103
+ `\n ${dim('hedgehog db rebuild')}\n`,
1104
+ );
960
1105
  }
961
1106
 
962
1107
  async function nextCommand() {
@@ -1010,7 +1155,7 @@ async function nextCommand() {
1010
1155
  console.error('');
1011
1156
  }
1012
1157
 
1013
- console.log(formatNext(packet, await resolveCoreId()));
1158
+ console.log(formatNext(packet, await resolveCoreId(), packetExists));
1014
1159
  }
1015
1160
 
1016
1161
  function printStalledTasks(stalled) {
@@ -1237,7 +1382,7 @@ async function printPackets(tasks) {
1237
1382
  const packet = taskPacket(db, task.id);
1238
1383
  if (!packet) continue;
1239
1384
  console.log();
1240
- console.log(formatPacket(packet, taskStatusLine(packet.task), coreId));
1385
+ console.log(formatPacket(packet, taskStatusLine(packet.task), coreId, packetExists));
1241
1386
  }
1242
1387
  } finally {
1243
1388
  db.close();
@@ -1467,7 +1612,9 @@ async function showCommand(args) {
1467
1612
  return;
1468
1613
  }
1469
1614
 
1470
- console.log(formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId()));
1615
+ console.log(
1616
+ formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId(), packetExists),
1617
+ );
1471
1618
  }
1472
1619
 
1473
1620
  // `hedgehog release <task-id> --owner <owner>` — hands a claimed task
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "4.3.1",
3
+ "version": "4.3.6",
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": {
@@ -50,6 +50,28 @@ follows delegates normally. Re-entry stays a subagent dispatch: its
50
50
  questions are short, scoped, and answer-shaped, not a facilitated
51
51
  session.
52
52
 
53
+ A hard rule stated in this file or `hedgehog-planning-intake` (this one
54
+ included) is not one option to weigh against a user's earlier
55
+ instruction — it's a constraint to work within. If a user instruction
56
+ genuinely conflicts with one (e.g. "don't ask clarifying questions"
57
+ against Phase 0's live elicitation requirement), say plainly that the
58
+ two conflict and ask the user how to proceed. Never resolve the conflict
59
+ by defaulting to a recommendation that bypasses the rule, and never
60
+ present bypassing it as an equally-weighted option alongside following
61
+ it — that smuggles the bypass in as the path of least resistance instead
62
+ of surfacing the actual conflict.
63
+
64
+ "Don't ask clarifying questions" is the common case, and on full-stack-app
65
+ or an authored core it has a defined destination once the user has chosen
66
+ it: **compressed intake** (`hedgehog-planning-intake`'s Phase 0). Surface
67
+ the conflict first, exactly as above — compressed intake is what the
68
+ user's answer can select, never what you recommend to avoid the
69
+ conversation. Say what it costs when you name it: one batched round of
70
+ questions instead of the shelf, a thinner archive, and an architecture
71
+ (on an authored core) designed from a brief rather than elicited drivers.
72
+ Landing-page has no such destination — see that skill for why — so there
73
+ the conflict is surfaced and resolved with the user, not routed.
74
+
53
75
  ## Phase 0 — which core applies
54
76
 
55
77
  Before invoking any planning-intake skill, on a first run only (Workflow
@@ -65,7 +87,12 @@ and the scaffolded workspace.
65
87
  real app beyond a single page. If in doubt between this and
66
88
  landing-page because the project has *both* a marketing page and a
67
89
  real app behind it, this is `full-stack-app` — the page becomes routes
68
- inside `apps/web`, not a separate project.
90
+ inside `apps/web`, not a separate project. Data that gets stored is
91
+ this core, at any size: a todo list, a notes app, a tracker of any
92
+ kind. Never talk the user down to browser-local storage, an in-memory
93
+ array, or a single-file page because the app sounds small, and never
94
+ offer that as a quicker start — the core ships a real database, and
95
+ reaching for less is the drift this discipline exists to prevent.
69
96
  - **`landing-page`** — the description is a marketing/announcement/
70
97
  waitlist/portfolio page (or a small handful of such pages) with no
71
98
  persistent domain data of its own. A page that only collects an email
@@ -189,6 +216,11 @@ anything here a background job, or is it all instant reads and writes?",
189
216
  default an add-on on or off without either a concrete trigger in the PRD
190
217
  or a direct answer.
191
218
 
219
+ This gate holds identically on compressed intake — it is the reason that
220
+ mode has a batched round of questions at all. Whatever the brief doesn't
221
+ concretely trigger goes into that round; nothing here is inferred from
222
+ silence because the user asked not to be asked.
223
+
192
224
  Write the decision to `.hedgehog/addons.yaml`, one entry per add-on with
193
225
  its on/off state and the one-line reason it landed there:
194
226
 
@@ -219,7 +251,9 @@ accounts get added where there were none).
219
251
  - Decide which core applies before running any planning-intake skill —
220
252
  Phase 0 above.
221
253
  - **full-stack-app**: owns `.hedgehog/BMAD/` (archival, written once,
222
- never edited after) and `.hedgehog/addons.yaml` as artifacts; the
254
+ never edited after including its `00-manifest.md`, which records
255
+ which intake mode produced it) and `.hedgehog/addons.yaml` as
256
+ artifacts; the
223
257
  intent records Phase 1 writes via `hedgehog intent add` live in the
224
258
  build graph, not a file this agent owns.
225
259
  - **landing-page**: owns `.hedgehog/BMAD/` and
@@ -277,11 +311,14 @@ accounts get added where there were none).
277
311
  that turns out to be wrong is a Correction Protocol case, not a quiet
278
312
  rewrite here.
279
313
  5. **Run planning intake**, in the shape this path calls for:
280
- - **First run, full-stack-app**: run the vendored BMAD shelf, then
281
- mine `04-prd.md` only into intent records per the PRD→graph-row
282
- table (spec: "Mapping BMAD output to intents") and the Add-ons
283
- decision (see above) — asking the user directly only for whatever
284
- the PRD leaves unresolved.
314
+ - **First run, full-stack-app**: run the vendored BMAD shelf — or, if
315
+ the user has explicitly chosen it after the conflict was surfaced,
316
+ compressed intake's batched round then mine `04-prd.md` only into
317
+ intent records per the PRD→graph-row table (spec: "Mapping BMAD
318
+ output to intents") and the Add-ons decision (see above) — asking
319
+ the user directly only for whatever the PRD leaves unresolved. The
320
+ mining step is the same either way; only how the archive was
321
+ produced differs.
285
322
  - **First run, landing-page**: run the same vendored BMAD shelf in
286
323
  full, then mine `.hedgehog/BMAD/` into a draft subject statement
287
324
  (subject, audience, single page job) — asking the user directly only
@@ -35,15 +35,26 @@ material `planner` files per module at planning intake, for you to act on here.
35
35
  If it's thin, or a specific detail you need (information architecture, a
36
36
  named flow, visual identity) isn't in it, read the full source directly:
37
37
  `.hedgehog/BMAD/05-ux-spec/DESIGN.md` and `EXPERIENCE.md`, the un-mined
38
- UX spec `planner`'s notes were drawn from. Read the notes file if present,
39
- then say so plainly and ask for anything further
40
- before producing the rationale: "Phase A is closed for `<module>` — this
41
- is the UX planning step before the screen gets built. [If notes exist:
42
- "I've got what was noted at planning intake for this module here's a quick
43
- recap: (one-line summary)."] If you have a mockup, screenshot, an export
44
- from a tool like Google Stitch or Figma, or an existing screen you want
45
- this to resemble, hand it over now; otherwise I'll propose the layout
46
- from the contract, hook, and any notes on file." Treat whatever's
38
+ UX spec `planner`'s notes were drawn from. Read whichever of the two the
39
+ archive holds a project planned through compressed intake
40
+ (`hedgehog-planning-intake`'s Phase 0) has `EXPERIENCE.md` only, and one
41
+ whose brief stated no flows may have neither. **An absent file is your
42
+ cue to ask, not to invent.** Visual identity is the first thing a
43
+ compressed brief omits, so where the archive is silent, say so and
44
+ propose from the contract and hook rather than inferring a direction the
45
+ user never gave that inference is the improvisation this step exists to
46
+ replace.
47
+
48
+ Read the notes file if present, then say so plainly and ask for anything
49
+ further before producing the rationale: "Phase A is closed for
50
+ `<module>` — this is the UX planning step before the screen gets built.
51
+ [If notes exist: "I've got what was noted at planning intake for this
52
+ module — here's a quick recap: (one-line summary)."] [If the archive
53
+ holds no UX spec: "This project was planned through compressed intake, so
54
+ there's no visual direction on file."] If you have a mockup, screenshot,
55
+ an export from a tool like Google Stitch or Figma, or an existing screen
56
+ you want this to resemble, hand it over now; otherwise I'll propose the
57
+ layout from the contract, hook, and any notes on file." Treat whatever's
47
58
  supplied or on file the same way — a source of screen inventory and
48
59
  hierarchy, not something to transcribe pixel-for-pixel. No visual tool or
49
60
  prior note is required; the rationale stands on its own when nothing is
@@ -70,7 +81,10 @@ mockup, not a design system, not code:
70
81
  was (a screenshot, a Stitch/Figma export, a named reference app,
71
82
  planning-intake notes from `docs/design/<module>-notes.md`, or the
72
83
  raw UX spec at `.hedgehog/BMAD/05-ux-spec/`) and what was drawn from
73
- it versus decided independently.
84
+ it versus decided independently. Where there was none — a compressed
85
+ archive with no UX spec and nothing supplied — say that plainly here,
86
+ so `front-end-eng` and `reviewer` read the rationale as reasoned from
87
+ the contract and hook rather than from a direction on file.
74
88
 
75
89
  Keep it short — a few bullets per screen, not a document. This is a
76
90
  rationale `front-end-eng` reads once before starting, and `reviewer` can
@@ -110,9 +124,11 @@ conclusion.
110
124
  1. Confirm the module's hook step is committed (`feat(<module>): hooks`)
111
125
  — if not, stop, this is being asked for too early.
112
126
  2. Check for `docs/design/<module>-notes.md` and read it if present. If
113
- it's thin or missing a detail you need, read
114
- `.hedgehog/BMAD/05-ux-spec/DESIGN.md` and `EXPERIENCE.md` directly for
115
- the full material it was drawn from.
127
+ it's thin or missing a detail you need, read whichever of
128
+ `.hedgehog/BMAD/05-ux-spec/DESIGN.md` and `EXPERIENCE.md` the archive
129
+ holds, for the full material it was drawn from. Where neither the
130
+ notes nor the spec covers what you need, that gap goes into step 3's
131
+ ask — it is not something to fill in yourself.
116
132
  3. Announce the Phase B transition and ask for visual input, per "When
117
133
  you run," above.
118
134
  4. Read the contract (`packages/contracts`) for the module: what
package/src/db/core.mjs CHANGED
@@ -480,6 +480,14 @@ function tokenInsideGlob(token, glob) {
480
480
  return false;
481
481
  }
482
482
 
483
+ // True when the core compiles one task per domain module rather than one
484
+ // per layer — i.e. some layer's scope varies by {module}. The single
485
+ // owner of that question: validateCore, lintCore, and `intent add`'s
486
+ // plural-id check all read it from here.
487
+ export function isModuleAxis(core) {
488
+ return core.layers.some((layer) => layer.scope.join('').includes('{module}'));
489
+ }
490
+
483
491
  // Enforces the interview's rule (spec: "Authored cores") — a layer without
484
492
  // scope or without a verify command is rejected. Applied uniformly to
485
493
  // shipped and authored cores alike; the loader has no shipped-core-only
@@ -600,10 +608,7 @@ export function validateCore(core) {
600
608
  // is nothing for a {module} in its scope to isolate — and requiring one
601
609
  // would reject exactly the cross-cutting layers `once` exists to
602
610
  // express.
603
- const isModuleAxis = core.layers.some((layer) =>
604
- layer.scope.join('').includes('{module}'),
605
- );
606
- if (isModuleAxis) {
611
+ if (isModuleAxis(core)) {
607
612
  for (const layer of core.layers) {
608
613
  if (layer.exclusive || layer.once) continue;
609
614
  if (!layer.scope.join('').includes('{module}')) {
@@ -662,10 +667,7 @@ export function validateCore(core) {
662
667
  // hedgehog-core-design carries the question as an authoring rule too.
663
668
  export function lintCore(core) {
664
669
  const warnings = [];
665
- const isModuleAxis = core.layers.some((layer) =>
666
- layer.scope.join('').includes('{module}'),
667
- );
668
- if (isModuleAxis) {
670
+ if (isModuleAxis(core)) {
669
671
  for (const layer of core.layers) {
670
672
  if (layer.exclusive || layer.once) continue;
671
673
  if (!layer.scope.join('').includes('{module}')) continue; // validateCore already rejects this
package/src/db/next.mjs CHANGED
@@ -360,9 +360,74 @@ const HONESTY = [
360
360
  // HONESTY is last deliberately: it's the one section that qualifies the
361
361
  // gate above it, so it reads as the answer to "and what if I can't clear
362
362
  // VERIFICATION honestly" rather than as preamble.
363
- export function formatPacket(packet, statusLine, coreId = null) {
363
+ // The package root a scope glob lives inside: the literal path above the
364
+ // glob's first wildcard, truncated at `src/` when one appears — a package
365
+ // root is where `package.json` sits, which is always above the `src/` a
366
+ // module directory hangs off.
367
+ //
368
+ // `{module}` is substituted first rather than treated as a wildcard,
369
+ // because on this core it is a real directory name in the compiled task
370
+ // and the package root can sit *below* it: `packages/contracts/src/tasks/**`
371
+ // has its root two segments up, while `libs/tasks/repository/**` has its
372
+ // root at the full literal path. Only `*` genuinely ends the literal part.
373
+ export function scopePackageRoot(glob, module) {
374
+ const segments = glob.replace('{module}', module).split('/');
375
+ const wildcard = segments.findIndex((s) => s.includes('*'));
376
+ const literal = wildcard === -1 ? segments.slice(0, -1) : segments.slice(0, wildcard);
377
+ const src = literal.indexOf('src');
378
+ const root = src === -1 ? literal : literal.slice(0, src);
379
+ // A package root is at least `<area>/<name>`; anything shallower is the
380
+ // repo itself, which is never a first arrival.
381
+ return root.length >= 2 ? root.join('/') : null;
382
+ }
383
+
384
+ // A task is the first arrival in its package when the package the scope
385
+ // points into has no package.json on disk yet: the generator that
386
+ // scaffolds this layer will create the package shell (package.json,
387
+ // tsconfig*.json, vitest.config.mts, src/index.ts) alongside the module's
388
+ // own files, and every one of those lands outside a {module}-bearing
389
+ // glob. Detected here rather than left to be discovered from a failed
390
+ // verify, which is where the override stops being available and the
391
+ // recovery becomes a five-command round trip.
392
+ //
393
+ // `exists` is injected so this module keeps no filesystem dependency of
394
+ // its own; the CLI passes a real one and the packet degrades to no
395
+ // section when a caller supplies none.
396
+ export function firstArrivalPackages(task, exists) {
397
+ if (!exists) return [];
398
+ const roots = new Set();
399
+ for (const glob of JSON.parse(task.scope_globs)) {
400
+ const root = scopePackageRoot(glob, task.module);
401
+ if (root && !exists(`${root}/package.json`)) roots.add(root);
402
+ }
403
+ return [...roots].sort();
404
+ }
405
+
406
+ function firstArrivalLines(task, roots) {
407
+ const scopes = roots.flatMap((root) => [`${root}/*`, `${root}/src/*`]);
408
+ return [
409
+ 'FIRST ARRIVAL',
410
+ ` ${roots.join(', ')} ${roots.length > 1 ? 'do' : 'does'} not exist yet, so this task's`,
411
+ " generator also creates the package shell (package.json, tsconfig*.json,",
412
+ ' vitest.config.mts, src/index.ts) plus any package-wide source it writes',
413
+ ' at the src/ root. All of that lands OUTSIDE the scope above. Widen this',
414
+ ' one task before building it, or verify will reject those paths and',
415
+ ' block the task:',
416
+ '',
417
+ ` hedgehog override add ${task.id} \\`,
418
+ ...scopes.map((s) => ` --scope '${s}' \\`),
419
+ ` --reason 'first module through ${task.layer} also creates the package shell'`,
420
+ '',
421
+ ' src/* is non-recursive on purpose: it covers src/index.ts and any',
422
+ " shared util beside it without re-granting the module directory the",
423
+ ' scope above already covers.',
424
+ ];
425
+ }
426
+
427
+ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
364
428
  const { task, intent, requirements, dependents, incompleteDeps = [], inheritedDebt = [] } = packet;
365
429
  const scopeGlobs = JSON.parse(task.scope_globs);
430
+ const firstArrival = firstArrivalPackages(task, exists);
366
431
 
367
432
  const lines = [];
368
433
  lines.push(`TASK ${task.id}`);
@@ -428,6 +493,10 @@ export function formatPacket(packet, statusLine, coreId = null) {
428
493
  lines.push('ALLOWED SCOPE');
429
494
  for (const glob of scopeGlobs) lines.push(` ${glob}`);
430
495
  lines.push('');
496
+ if (firstArrival.length > 0) {
497
+ lines.push(...firstArrivalLines(task, firstArrival));
498
+ lines.push('');
499
+ }
431
500
  const shapeLines = layerShapeLines(task, coreId);
432
501
  if (shapeLines) {
433
502
  lines.push(...shapeLines);
@@ -443,6 +512,6 @@ export function formatPacket(packet, statusLine, coreId = null) {
443
512
 
444
513
  // `hedgehog next`'s rendering: its task always came out of the readiness
445
514
  // SELECT, so STATUS is READY by construction.
446
- export function formatNext(packet, coreId = null) {
447
- return formatPacket(packet, 'READY', coreId);
515
+ export function formatNext(packet, coreId = null, exists = null) {
516
+ return formatPacket(packet, 'READY', coreId, exists);
448
517
  }