@skyf0xx/hedgehog 4.3.1 → 4.3.4

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 +164 -8
  2. package/package.json +1 -1
  3. package/src/agents/planner.md +38 -6
  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 +21 -6
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,16 +548,33 @@ 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(
552
+ ` 3. Start a ${bold('new')} ${HOSTS[host].label} session and describe what you want to build.`,
553
+ );
532
554
  } else {
533
555
  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.`);
556
+ console.log(
557
+ ` 2. Start a ${bold('new')} ${HOSTS[host].label} session and describe what you want to build.`,
558
+ );
535
559
  }
536
560
  console.log(
537
561
  dim(
538
562
  ` The ${bold('planner')} agent runs planning intake, then hands off to bootstrap.`,
539
563
  ),
540
564
  );
565
+ // A host enumerates its agents once, at session start. This install just
566
+ // wrote them mid-session, so in THIS session they are not dispatchable —
567
+ // and the failure is worse than a plain error: names like `planner` and
568
+ // `reviewer` often resolve to the user's own unrelated global agents,
569
+ // silently running a different discipline instead of erroring.
570
+ console.log(
571
+ dim(
572
+ ` A new session matters: ${HOSTS[host].label} lists its agents at startup, so the\n` +
573
+ ' ones just installed are not dispatchable in the session that ran this.\n' +
574
+ ' If a handoff must happen here anyway, read the agent file and follow it\n' +
575
+ ' inline rather than dispatching to a name that may resolve elsewhere.',
576
+ ),
577
+ );
541
578
  console.log();
542
579
  if (explicitCore) {
543
580
  console.log(dim(`Core: ${bold(core)}.`));
@@ -636,6 +673,7 @@ async function dbRebuildCommand() {
636
673
  console.log(
637
674
  `${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
638
675
  );
676
+ warnOrphanedNotes(result);
639
677
  warnRebuildDrift(result, corePath);
640
678
  }
641
679
 
@@ -667,6 +705,13 @@ async function dbCommand(args) {
667
705
  // along with the rest of src/golden-cores/<core>). Neither exists yet on
668
706
  // a deferred install (plain `init`, no explicit core flag) until
669
707
  // `bootstrap` runs — this returns null until then.
708
+ // Synchronous on purpose: formatPacket renders in one pass and takes this
709
+ // as a predicate, so the FIRST ARRIVAL check cannot be an await. Paths are
710
+ // repo-relative, the same way scope globs are.
711
+ function packetExists(path) {
712
+ return existsSync(join(DEST_ROOT, path));
713
+ }
714
+
670
715
  async function resolveCorePath() {
671
716
  if (await exists(join(DEST_ROOT, AUTHORED_CORE_PATH))) {
672
717
  return join(DEST_ROOT, AUTHORED_CORE_PATH);
@@ -821,6 +866,7 @@ async function planCommand(args = []) {
821
866
  const driftDb = openDb({ readOnly: true });
822
867
  let drifted;
823
868
  try {
869
+ warnSingularModuleIdsAtPlan(core, driftDb);
824
870
  drifted = detectDrift(driftDb, core, { overrides });
825
871
  } finally {
826
872
  driftDb.close();
@@ -957,6 +1003,114 @@ async function intentCommand(args) {
957
1003
 
958
1004
  console.log(` ${green('added')} ${intent.id}`);
959
1005
  console.log(` ${dim(`${intent.requirements.length} requirement(s), ${intent.depends_on.length} dependency(ies)`)}`);
1006
+ await warnSingularModuleId(intent);
1007
+ }
1008
+
1009
+ // On a module-axis core the intent id becomes {module} in every layer's
1010
+ // scope glob and verify command, and the generators the packet points at
1011
+ // take the module name plural. A singular id compiles a whole graph
1012
+ // scoped to a directory the generator will never write, and nothing
1013
+ // downstream catches it: plan compiles it, claim hands out a packet whose
1014
+ // own scaffold command contradicts its ALLOWED SCOPE.
1015
+ //
1016
+ // A warning rather than a refusal: plenty of real modules are singular
1017
+ // (`billing`, `search`), so the convention cannot be enforced without
1018
+ // rejecting correct ids. Raised here because this is the last moment the
1019
+ // fix is one file rename — three layers later it is a Correction Protocol
1020
+ // case across every compiled task.
1021
+ //
1022
+ // The convention is a heuristic, not a checkable property of the graph:
1023
+ // `tasks` and `task` are both structurally valid, and nothing downstream
1024
+ // knows which one the generator wants. So this only ever reports.
1025
+ function looksSingular(id) {
1026
+ return !/(s|ae|ia|people|children)$/i.test(id);
1027
+ }
1028
+
1029
+ // `intent add` is one route to the compiler among several — `--file`, a
1030
+ // hand-written intents/*.json, and `db rebuild` all reach it without
1031
+ // passing through here — so `plan` re-raises the same check where every
1032
+ // route converges. See planCommand.
1033
+ async function warnSingularModuleId(intent) {
1034
+ const corePath = await resolveCorePath();
1035
+ if (!corePath) return;
1036
+
1037
+ let core;
1038
+ try {
1039
+ core = await loadCore(corePath);
1040
+ } catch {
1041
+ // A core that will not load is `plan`'s error to report, not this
1042
+ // advisory's.
1043
+ return;
1044
+ }
1045
+ if (!isModuleAxis(core)) return;
1046
+ if (!looksSingular(intent.id)) return;
1047
+
1048
+ console.log(
1049
+ `\n${yellow(bold('Module id looks singular.'))} On this core the intent id is ${bold('{module}')} in\n` +
1050
+ `every layer's scope, and the generators take it plural — so ${bold(intent.id)} compiles\n` +
1051
+ `scope for ${bold(`${intent.id}/`)} while the scaffold command writes ${bold(`${intent.id}s/`)}.\n` +
1052
+ `If ${bold(`${intent.id}s`)} is the name you meant, fix it now, before ${bold('hedgehog plan')}:\n` +
1053
+ ` ${dim(`git mv ${INTENTS_DIR}/${intent.id}.json ${INTENTS_DIR}/${intent.id}s.json`)}\n` +
1054
+ ` ${dim(`# edit "id" and every ${intent.id.toUpperCase()}-* requirement id, then:`)}\n` +
1055
+ ` ${dim('hedgehog db rebuild')}\n`,
1056
+ );
1057
+ }
1058
+
1059
+ // The same advisory at `plan`, where `--file`, a hand-written intent
1060
+ // file, and `db rebuild` all converge — none of them passes through
1061
+ // `intent add`, so on those routes this is the first and last cheap
1062
+ // warning before a whole graph is compiled against the id. It reports
1063
+ // while every one of an intent's tasks is still unstarted; once work has
1064
+ // landed the fix is a Correction Protocol case rather than a rename, and
1065
+ // repeating the `git mv` advice would point at the wrong recovery.
1066
+ //
1067
+ // One block for however many ids trip it, rather than repeating the
1068
+ // per-intent explanation: the reasoning is identical every time, and
1069
+ // stacking it once per intent buries the list of names under it. The
1070
+ // recovery is still per-id, so those lines are listed one per name.
1071
+ function warnSingularModuleIdsAtPlan(core, db) {
1072
+ if (!isModuleAxis(core)) return;
1073
+
1074
+ // Every intent whose tasks have all yet to start, not just the ones
1075
+ // this run compiled. `db rebuild` replays the committed intent files
1076
+ // *and* compiles their tasks, so a `plan` after it compiles nothing —
1077
+ // and that is precisely the route where nothing else has ever looked at
1078
+ // the id. The cheap-fix window is "no work has landed yet", which
1079
+ // outlasts any single `plan` invocation.
1080
+ const singular = db
1081
+ .prepare(
1082
+ `SELECT i.id FROM intents i
1083
+ WHERE i.id <> ?
1084
+ AND NOT EXISTS (
1085
+ SELECT 1 FROM tasks t
1086
+ WHERE t.intent_id = i.id
1087
+ AND t.status NOT IN ('proposed','planned','ready')
1088
+ )
1089
+ ORDER BY i.id`,
1090
+ )
1091
+ .all(CORE_INTENT_ID)
1092
+ .map((row) => row.id)
1093
+ .filter(looksSingular);
1094
+ if (singular.length === 0) return;
1095
+
1096
+ const many = singular.length > 1;
1097
+ console.log(
1098
+ `${yellow(bold(many ? `${singular.length} module ids look singular.` : 'Module id looks singular.'))} ` +
1099
+ `On this core an intent id is ${bold('{module}')} in\n` +
1100
+ `every layer's scope, and the generators take it plural — so ${many ? 'each of these compiles' : 'this compiles'}\n` +
1101
+ `scope for a directory the scaffold command will never write:\n` +
1102
+ singular.map((id) => ` ${bold(id)} ${dim(`— scope ${id}/, scaffold writes ${id}s/`)}`).join('\n') +
1103
+ `\n\nThis is a naming convention, not a rule — ${bold('billing')} and ${bold('search')} are ` +
1104
+ `legitimately\nsingular. If the plural is what you meant, it is still cheap to fix:\n` +
1105
+ singular
1106
+ .map(
1107
+ (id) =>
1108
+ ` ${dim(`git mv ${INTENTS_DIR}/${id}.json ${INTENTS_DIR}/${id}s.json`)}\n` +
1109
+ ` ${dim(`# edit "id" and every ${id.toUpperCase()}-* requirement id`)}`,
1110
+ )
1111
+ .join('\n') +
1112
+ `\n ${dim('hedgehog db rebuild')}\n`,
1113
+ );
960
1114
  }
961
1115
 
962
1116
  async function nextCommand() {
@@ -1010,7 +1164,7 @@ async function nextCommand() {
1010
1164
  console.error('');
1011
1165
  }
1012
1166
 
1013
- console.log(formatNext(packet, await resolveCoreId()));
1167
+ console.log(formatNext(packet, await resolveCoreId(), packetExists));
1014
1168
  }
1015
1169
 
1016
1170
  function printStalledTasks(stalled) {
@@ -1237,7 +1391,7 @@ async function printPackets(tasks) {
1237
1391
  const packet = taskPacket(db, task.id);
1238
1392
  if (!packet) continue;
1239
1393
  console.log();
1240
- console.log(formatPacket(packet, taskStatusLine(packet.task), coreId));
1394
+ console.log(formatPacket(packet, taskStatusLine(packet.task), coreId, packetExists));
1241
1395
  }
1242
1396
  } finally {
1243
1397
  db.close();
@@ -1467,7 +1621,9 @@ async function showCommand(args) {
1467
1621
  return;
1468
1622
  }
1469
1623
 
1470
- console.log(formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId()));
1624
+ console.log(
1625
+ formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId(), packetExists),
1626
+ );
1471
1627
  }
1472
1628
 
1473
1629
  // `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.4",
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
@@ -189,6 +211,11 @@ anything here a background job, or is it all instant reads and writes?",
189
211
  default an add-on on or off without either a concrete trigger in the PRD
190
212
  or a direct answer.
191
213
 
214
+ This gate holds identically on compressed intake — it is the reason that
215
+ mode has a batched round of questions at all. Whatever the brief doesn't
216
+ concretely trigger goes into that round; nothing here is inferred from
217
+ silence because the user asked not to be asked.
218
+
192
219
  Write the decision to `.hedgehog/addons.yaml`, one entry per add-on with
193
220
  its on/off state and the one-line reason it landed there:
194
221
 
@@ -219,7 +246,9 @@ accounts get added where there were none).
219
246
  - Decide which core applies before running any planning-intake skill —
220
247
  Phase 0 above.
221
248
  - **full-stack-app**: owns `.hedgehog/BMAD/` (archival, written once,
222
- never edited after) and `.hedgehog/addons.yaml` as artifacts; the
249
+ never edited after including its `00-manifest.md`, which records
250
+ which intake mode produced it) and `.hedgehog/addons.yaml` as
251
+ artifacts; the
223
252
  intent records Phase 1 writes via `hedgehog intent add` live in the
224
253
  build graph, not a file this agent owns.
225
254
  - **landing-page**: owns `.hedgehog/BMAD/` and
@@ -277,11 +306,14 @@ accounts get added where there were none).
277
306
  that turns out to be wrong is a Correction Protocol case, not a quiet
278
307
  rewrite here.
279
308
  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.
309
+ - **First run, full-stack-app**: run the vendored BMAD shelf — or, if
310
+ the user has explicitly chosen it after the conflict was surfaced,
311
+ compressed intake's batched round then mine `04-prd.md` only into
312
+ intent records per the PRD→graph-row table (spec: "Mapping BMAD
313
+ output to intents") and the Add-ons decision (see above) — asking
314
+ the user directly only for whatever the PRD leaves unresolved. The
315
+ mining step is the same either way; only how the archive was
316
+ produced differs.
285
317
  - **First run, landing-page**: run the same vendored BMAD shelf in
286
318
  full, then mine `.hedgehog/BMAD/` into a draft subject statement
287
319
  (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
  }
@@ -43,6 +43,67 @@ function intentExists(db, id) {
43
43
  return db.prepare('SELECT 1 FROM intents WHERE id = ?').get(id) !== undefined;
44
44
  }
45
45
 
46
+ // A rebuild's result is a pure function of the committed sources
47
+ // (`.hedgehog/intents/`, core.yaml, overrides, git history), so the
48
+ // derived graph is cleared before replay rather than replayed on top of
49
+ // whatever the DB already held. Without this, an intent file that was
50
+ // renamed or deleted leaves its tasks behind: the scheduler then sees a
51
+ // ghost task whose scope overlaps the real one and holds the real one
52
+ // back, producing a graph no set of committed intents describes.
53
+ //
54
+ // Deleting `intents` cascades through requirements, tasks,
55
+ // task_requirements, dependencies, artifacts and verifications — every
56
+ // one of which this run re-derives. `debt` and `friction` are the
57
+ // exception: they are operator-recorded notes with no committed source,
58
+ // so they are carried across by task id (deterministic, so a note
59
+ // re-attaches to the same task the replay recompiles). A note whose task
60
+ // no longer exists in the new graph has nowhere to live and is reported
61
+ // rather than silently dropped.
62
+ function clearDerivedGraph(db) {
63
+ const debt = db.prepare('SELECT task_id, note, logged_at FROM debt').all();
64
+ const friction = db.prepare('SELECT task_id, note, logged_at FROM friction').all();
65
+
66
+ db.prepare('DELETE FROM intents').run();
67
+ // `friction.task_id` is ON DELETE SET NULL rather than CASCADE, so its
68
+ // rows outlive the delete above. Clear them too and let restoreNotes be
69
+ // the single writer, so a note is not duplicated against its own copy.
70
+ db.prepare('DELETE FROM friction').run();
71
+
72
+ return { debt, friction };
73
+ }
74
+
75
+ function restoreNotes(db, { debt, friction }) {
76
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
77
+ const insertDebt = db.prepare(
78
+ 'INSERT INTO debt (task_id, note, logged_at) VALUES (?, ?, ?)',
79
+ );
80
+ const insertFriction = db.prepare(
81
+ 'INSERT INTO friction (task_id, note, logged_at) VALUES (?, ?, ?)',
82
+ );
83
+
84
+ const orphaned = [];
85
+
86
+ for (const row of debt) {
87
+ if (taskExists.get(row.task_id) === undefined) {
88
+ orphaned.push({ kind: 'debt', taskId: row.task_id, note: row.note });
89
+ continue;
90
+ }
91
+ insertDebt.run(row.task_id, row.note, row.logged_at);
92
+ }
93
+
94
+ for (const row of friction) {
95
+ // friction.task_id is nullable — an unattached note always survives.
96
+ if (row.task_id !== null && taskExists.get(row.task_id) === undefined) {
97
+ insertFriction.run(null, row.note, row.logged_at);
98
+ orphaned.push({ kind: 'friction', taskId: row.task_id, note: row.note });
99
+ continue;
100
+ }
101
+ insertFriction.run(row.task_id, row.note, row.logged_at);
102
+ }
103
+
104
+ return orphaned;
105
+ }
106
+
46
107
  // Topological sort over `depends_on`, tie-broken by the file order above
47
108
  // so two runs over the same directory always replay identically.
48
109
  //
@@ -71,9 +132,9 @@ function orderIntentsForReplay(records, isSatisfied) {
71
132
 
72
133
  const entry = byId.get(id);
73
134
  if (!entry) {
74
- // Not a file in this directory. Fine if the DB already has it
75
- // (rebuild tolerates an already-populated DB); otherwise the edge
76
- // points at nothing and would fail the FOREIGN KEY.
135
+ // Not a file in this directory, and the derived graph was cleared
136
+ // before replay, so the edge points at nothing and would fail the
137
+ // FOREIGN KEY.
77
138
  if (isSatisfied(id)) return;
78
139
  throw new Error(
79
140
  `intent "${requiredBy}" depends_on "${id}", which has no intent file in ` +
@@ -98,12 +159,6 @@ function orderIntentsForReplay(records, isSatisfied) {
98
159
  // Reads every intent file, normalizes it, orders the set by depends_on,
99
160
  // and inserts the rows. Read-only with respect to the files themselves.
100
161
  //
101
- // The insert is unconditional (an intent id is never re-added through
102
- // `intent add` in normal use), so rebuild — the one caller that must also
103
- // tolerate an already-populated DB, per "re-derive from source-of-truth
104
- // after suspected corruption" — skips any intent whose id is already
105
- // present rather than letting the UNIQUE constraint fail the whole run.
106
- //
107
162
  // Ordering is resolved for the whole set BEFORE any row is written, so a
108
163
  // cycle or a dangling depends_on fails the run without having half-built
109
164
  // the graph.
@@ -232,10 +287,9 @@ function markCompletedTasks(db, commitSubjects) {
232
287
  const setComplete = db.prepare("UPDATE tasks SET status = 'complete' WHERE id = ?");
233
288
  for (const id of complete) setComplete.run(id);
234
289
 
235
- // Rebuild also runs against an already-populated DB ("after suspected
236
- // corruption"), where a once-task may be sitting at `complete` from
237
- // before a re-entry pass added work under it. Reconcile it back rather
238
- // than leaving the stale status untouched.
290
+ // A once-task can be marked complete by the loop above and then fail
291
+ // one of the extra conditions on a later pass of the fixpoint walk.
292
+ // Reconcile it back rather than leaving the stale status untouched.
239
293
  const reopen = db.prepare(
240
294
  "UPDATE tasks SET status = 'planned' WHERE id = ? AND status = 'complete'",
241
295
  );
@@ -268,6 +322,8 @@ export async function rebuildDb(
268
322
  ) {
269
323
  applySchema(db);
270
324
 
325
+ const notes = clearDerivedGraph(db);
326
+
271
327
  const intentsReplayed = await replayIntents(db, intentsDir);
272
328
 
273
329
  const core = await loadCore(corePath);
@@ -277,7 +333,9 @@ export async function rebuildDb(
277
333
  const commitSubjects = loadCommitSubjects();
278
334
  const tasksMarkedComplete = markCompletedTasks(db, commitSubjects);
279
335
 
336
+ const orphanedNotes = restoreNotes(db, notes);
337
+
280
338
  const drift = detectDrift(db, core, { overrides });
281
339
 
282
- return { intentsReplayed, tasksMarkedComplete, drift };
340
+ return { intentsReplayed, tasksMarkedComplete, orphanedNotes, drift };
283
341
  }