@skyf0xx/hedgehog 6.1.0 → 6.1.2

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/bin/cli.mjs CHANGED
@@ -424,6 +424,17 @@ async function writePlannedFile(f) {
424
424
  const existing = await readFile(f.dest, 'utf8');
425
425
  if (!existing.includes('{{PROJECT_NAME}}') && existing.includes('{{CORE_SECTION}}')) {
426
426
  out = existing;
427
+ } else if (!existing.includes('{{PROJECT_NAME}}') && !f.merge.include) {
428
+ // Brownfield: hand-written content that predates Hedgehog, with
429
+ // no shell markers at all, on the coreless deferred path (no
430
+ // `include` — there's no core section yet to fill in). Nothing
431
+ // to merge in here; the file is untouched until `hedgehog-adopt`
432
+ // later appends its own core section via appendCoreSection
433
+ // (src/hosts/claude-md-merge.mjs). Overwriting it with the
434
+ // greenfield shell, or even just filling {{HOST_DISPATCH}} into
435
+ // it, would assert a fresh-install shape onto a repo that isn't
436
+ // new.
437
+ return;
427
438
  }
428
439
  }
429
440
  if (out === null) out = await readFile(join(PKG_ROOT, f.merge.shell), 'utf8');
@@ -688,11 +699,26 @@ async function init({ force, core, host = DEFAULT_HOST, hostOnly = false, global
688
699
  // the project, so rewriting it loses nothing and never counts as a
689
700
  // conflict — that's what lets a second host be added to a project the
690
701
  // first one already set up.
702
+ //
703
+ // A deferred (coreless) install's root-instructions merge is exempted
704
+ // too, but only when the existing file is a brownfield one: hand-written
705
+ // content with no {{PROJECT_NAME}} shell marker. That file was never a
706
+ // Hedgehog shell to overwrite — writePlannedFile leaves it untouched,
707
+ // for hedgehog-adopt's own merge step to append its delimited core
708
+ // section into later (src/hosts/claude-md-merge.mjs), once a core is
709
+ // actually known. A file still carrying {{PROJECT_NAME}} (a previous
710
+ // deferred init's untouched shell) is not brownfield content and keeps
711
+ // hitting the ordinary conflict/--force path below.
691
712
  const conflicts = [];
692
713
  for (const { entry, files } of groups) {
693
714
  if (entry.type === 'generated') continue;
694
715
  for (const f of files) {
695
- if (await exists(f.dest)) conflicts.push(f.dest);
716
+ if (!(await exists(f.dest))) continue;
717
+ if (core === null && f.merge) {
718
+ const existing = await readFile(f.dest, 'utf8');
719
+ if (!existing.includes('{{PROJECT_NAME}}')) continue;
720
+ }
721
+ conflicts.push(f.dest);
696
722
  }
697
723
  }
698
724
 
@@ -719,7 +745,15 @@ async function init({ force, core, host = DEFAULT_HOST, hostOnly = false, global
719
745
  for (const { files } of groups) {
720
746
  for (const f of files) {
721
747
  const already = await exists(f.dest);
748
+ // Brownfield CLAUDE.md on the coreless path: writePlannedFile
749
+ // leaves it untouched (see its own comment) rather than overwriting
750
+ // it, so this write must not be logged or counted as one.
751
+ const before = already && f.merge ? await readFile(f.dest, 'utf8') : null;
722
752
  await writePlannedFile(f);
753
+ if (before !== null && (await readFile(f.dest, 'utf8')) === before) {
754
+ console.log(` ${dim('keep')} ${relative(DEST_ROOT, f.dest)}`);
755
+ continue;
756
+ }
723
757
  if (already) overwritten++;
724
758
  else written++;
725
759
  const label = already ? yellow('overwrite') : green('create');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.1.0",
3
+ "version": "6.1.2",
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": {
@@ -202,9 +202,11 @@ none of them is a description matching a `when` paragraph:
202
202
  enforcement on my changes here"). This is a distinct question from
203
203
  everything above: it's not about which core fits new work, because no
204
204
  new workspace gets built at all. This project gets the **adopted
205
- core**. Route straight to `hedgehog-adopt` — bootstrap and every other
206
- Phase 0 outcome are skipped entirely, since there is no workspace to
207
- scaffold and no shipped stack to adopt toward. `hedgehog-adopt` runs
205
+ core**. Run `hedgehog core record-adopted` first a no-flag `init`
206
+ never fetched the `adopted` package, so `hedgehog-adopt` is not yet on
207
+ disk to route to then route to `hedgehog-adopt`. Bootstrap and every
208
+ other Phase 0 outcome are skipped entirely, since there is no workspace
209
+ to scaffold and no shipped stack to adopt toward. `hedgehog-adopt` runs
208
210
  its own read-only intake and writes its own `.hedgehog/core.yaml`;
209
211
  don't run `hedgehog-planning-intake`'s BMAD shelf first — the drivers
210
212
  that skill elicits (persistence, stack, deployment target) are already
@@ -389,18 +391,23 @@ as full-stack-app's Auth/Queue/Mobile trio.
389
391
  run.** Continue at step 3.
390
392
  - **No intents in the graph, and the request is adoption onto an
391
393
  existing repo → brownfield first run.** Skip Phase 0's core
392
- selection and every step below through step 9 go straight to
393
- `hedgehog-adopt`. It runs its own intake and Confirm & Lock, writes
394
- `.hedgehog/core.yaml` and `.hedgehog/adoption.md`, and adds the
395
- first intent(s) itself. Return the summary (step 10) once it's done.
394
+ selection and every step below through step 9. Run `hedgehog core
395
+ record-adopted` first a no-flag `init` never fetched the
396
+ `adopted` package, so `hedgehog-adopt` is not yet on disk — then go
397
+ straight to `hedgehog-adopt`. It runs its own intake and Confirm &
398
+ Lock, writes `.hedgehog/core.yaml` and `.hedgehog/adoption.md`, and
399
+ adds the first intent(s) itself. Return the summary (step 10) once
400
+ it's done.
396
401
  - **One or more intents, on `.hedgehog/core.yaml` written by
397
402
  `hedgehog-adopt` → adoption re-entry.** New change-work on a repo
398
- already under adoption. Skip steps 3 through 9 route straight to
399
- `hedgehog-adopt` again instead, same as brownfield first run above.
400
- It owns everything the other path's steps 5, 7, 8, and 9 would
401
- otherwise do: it sizes the request (a large or ambiguous one gets its
402
- own short clarifying pass, a clear small one doesn't), adds the
403
- intent(s), runs `hedgehog plan`, and commits its own work as `chore
403
+ already under adoption. Skip steps 3 through 9. Run `hedgehog core
404
+ record-adopted` first safe and idempotent to re-run, and the only
405
+ guarantee that `hedgehog-adopt` is on disk in this session then
406
+ route straight to `hedgehog-adopt` again, same as brownfield first
407
+ run above. It owns everything the other path's steps 5, 7, 8, and 9
408
+ would otherwise do: it sizes the request (a large or ambiguous one
409
+ gets its own short clarifying pass, a clear small one doesn't), adds
410
+ the intent(s), runs `hedgehog plan`, and commits its own work as `chore
404
411
  (planning): adopt change`. Don't run `hedgehog-planning-intake`'s
405
412
  Re-entry pass here — there is no BMAD archive to read as context on
406
413
  this path, since adoption never runs one. Return the summary (step
package/src/db/core.mjs CHANGED
@@ -180,9 +180,16 @@ function indentOf(line) {
180
180
  return line.length - line.trimStart().length;
181
181
  }
182
182
 
183
+ // The only values `pattern` may declare — named in every rejection
184
+ // message below, so a typo surfaces the valid set instead of silently
185
+ // degrading to "unset" (which would turn conformance checking off with
186
+ // no signal that anything is wrong).
187
+ const VALID_PATTERNS = ['hexagonal', 'layered', 'vertical-slice', 'none'];
188
+
183
189
  // Parses the narrow subset of YAML a core definition needs:
184
190
  // id: <scalar>
185
191
  // pluralizes: <bool> # optional, default true
192
+ // pattern: <scalar> # optional, one of hexagonal|layered|vertical-slice|none
186
193
  // layers:
187
194
  // - id: <scalar>
188
195
  // depends_on: <scalar> # optional
@@ -202,7 +209,7 @@ export function parseCoreYaml(text) {
202
209
  lines.push({ indent: indentOf(noComment), text: noComment.trim() });
203
210
  }
204
211
 
205
- const core = { id: undefined, pluralizes: true, layers: [] };
212
+ const core = { id: undefined, pluralizes: true, pattern: null, layers: [] };
206
213
  let i = 0;
207
214
 
208
215
  while (i < lines.length && lines[i].indent === 0) {
@@ -224,6 +231,21 @@ export function parseCoreYaml(text) {
224
231
  // advisory stops firing on it for good, rather than every user of
225
232
  // that core re-discovering the same false positive.
226
233
  if (key === 'pluralizes') core.pluralizes = parseScalar(value) === 'true';
234
+ // An architecture claim, checked by validateCore below — see that
235
+ // function's pattern-conformance block for what each value asserts.
236
+ // Rejected here, at parse time, rather than left to validateCore:
237
+ // an unrecognized value must never silently resolve to "unset" (the
238
+ // one value that turns conformance checking off), so a typo has to
239
+ // surface as a parse error, not a quietly-skipped check.
240
+ if (key === 'pattern') {
241
+ const declared = parseScalar(value);
242
+ if (!VALID_PATTERNS.includes(declared)) {
243
+ throw new Error(
244
+ `unknown pattern "${declared}" — must be one of: ${VALID_PATTERNS.join(', ')}`,
245
+ );
246
+ }
247
+ core.pattern = declared;
248
+ }
227
249
  i++;
228
250
  }
229
251
 
@@ -498,6 +520,132 @@ export function isModuleAxis(core) {
498
520
  return core.layers.some((layer) => layer.scope.join('').includes('{module}'));
499
521
  }
500
522
 
523
+ // `layered`'s and `hexagonal`'s checks both anchor on "the head layer" —
524
+ // the first-declared layer, by the same convention `core.layers[0]`
525
+ // already carries informally everywhere else in this file (e.g. the
526
+ // once-layer checks below walk `core.layers` in declaration order too).
527
+ function headLayer(core) {
528
+ return core.layers[0];
529
+ }
530
+
531
+ // `pattern: layered` — a strict linear chain: every layer but the head
532
+ // depends on exactly one other, no two layers share a depends_on parent
533
+ // (that would be branching, not a chain), and every layer is reachable
534
+ // from the head by walking depends_on forward. Throws naming the first
535
+ // layer that breaks the shape, in the order the checks below run.
536
+ function checkLayeredPattern(core) {
537
+ const head = headLayer(core);
538
+ const rest = core.layers.filter((layer) => layer.id !== head.id);
539
+
540
+ for (const layer of rest) {
541
+ if (!layer.depends_on) {
542
+ throw new Error(
543
+ `core "${core.id}" declares pattern: layered, but layer "${layer.id}" has no depends_on — every layer but the head ("${head.id}") must depend on exactly one other layer`,
544
+ );
545
+ }
546
+ }
547
+
548
+ const dependents = new Map(); // parent layer id -> the one layer that depends on it
549
+ for (const layer of rest) {
550
+ const prior = dependents.get(layer.depends_on);
551
+ if (prior) {
552
+ throw new Error(
553
+ `core "${core.id}" declares pattern: layered, but both "${prior}" and "${layer.id}" depend on "${layer.depends_on}" — a layered chain is linear, one dependent per layer`,
554
+ );
555
+ }
556
+ dependents.set(layer.depends_on, layer.id);
557
+ }
558
+
559
+ // Walk forward from the head (parent -> its one dependent) and confirm
560
+ // every layer gets visited. This also catches a chain disconnected from
561
+ // the head entirely — e.g. two layers depending on each other with
562
+ // neither reachable from the head — which the checks above don't rule
563
+ // out on their own: each layer still has exactly one depends_on and no
564
+ // parent is shared, they just never connect back to "${head.id}".
565
+ const visited = new Set([head.id]);
566
+ let current = head;
567
+ while (dependents.has(current.id)) {
568
+ current = core.layers.find((layer) => layer.id === dependents.get(current.id));
569
+ visited.add(current.id);
570
+ }
571
+ for (const layer of core.layers) {
572
+ if (!visited.has(layer.id)) {
573
+ throw new Error(
574
+ `core "${core.id}" declares pattern: layered, but layer "${layer.id}" is not reachable from the head layer "${head.id}" by following depends_on`,
575
+ );
576
+ }
577
+ }
578
+ }
579
+
580
+ // `pattern: hexagonal` — Hedgehog has no adapter marker today, so this
581
+ // checks direction alone rather than an actual domain/adapter boundary:
582
+ // the head layer (the domain, by convention) must have no depends_on, and
583
+ // every other layer's depends_on chain must terminate at the head with no
584
+ // cycle — i.e. dependencies all point one way, inward, and the head is the
585
+ // sink every chain ends at. Weaker than the real hexagonal rule (nothing
586
+ // stops an adapter depending on another adapter instead of the domain
587
+ // directly), and deliberately so — see #314's "Not in this issue" for why
588
+ // a real adapter-boundary marker is a separate design decision.
589
+ function checkHexagonalPattern(core) {
590
+ const head = headLayer(core);
591
+ if (head.depends_on) {
592
+ throw new Error(
593
+ `core "${core.id}" declares pattern: hexagonal, but its head layer "${head.id}" has a depends_on — the domain layer must be the sink every dependency chain points to, not itself a dependent`,
594
+ );
595
+ }
596
+
597
+ const byId = new Map(core.layers.map((layer) => [layer.id, layer]));
598
+ for (const layer of core.layers) {
599
+ if (layer.id === head.id) continue;
600
+ if (!layer.depends_on) {
601
+ throw new Error(
602
+ `core "${core.id}" declares pattern: hexagonal, but layer "${layer.id}" has no depends_on — only the domain layer ("${head.id}") may have none`,
603
+ );
604
+ }
605
+ const seen = new Set([layer.id]);
606
+ let current = layer;
607
+ while (current.depends_on) {
608
+ const next = byId.get(current.depends_on);
609
+ if (seen.has(next.id)) {
610
+ throw new Error(
611
+ `core "${core.id}" declares pattern: hexagonal, but layer "${layer.id}"'s depends_on chain cycles back through "${next.id}" instead of terminating at the domain layer "${head.id}"`,
612
+ );
613
+ }
614
+ seen.add(next.id);
615
+ current = next;
616
+ }
617
+ if (current.id !== head.id) {
618
+ throw new Error(
619
+ `core "${core.id}" declares pattern: hexagonal, but layer "${layer.id}"'s depends_on chain terminates at "${current.id}", not the domain layer "${head.id}" — every layer must point inward toward the domain`,
620
+ );
621
+ }
622
+ }
623
+ }
624
+
625
+ // Dispatches on `core.pattern` to the check above matching what was
626
+ // declared. `null` (never set) and `'none'` (set, explicitly no enforced
627
+ // direction — the adopted-repo default) both skip checking entirely: an
628
+ // absent pattern must validate exactly as it did before this field
629
+ // existed, and `none` recording "no direction" is a fact, not a finding.
630
+ function checkPatternConformance(core) {
631
+ if (!core.pattern || core.pattern === 'none') return;
632
+ if (core.pattern === 'vertical-slice') {
633
+ if (!isModuleAxis(core)) {
634
+ throw new Error(
635
+ `core "${core.id}" declares pattern: vertical-slice, but no layer's scope contains {module} — vertical-slice is a chain instantiated per module, so at least one layer must vary by module`,
636
+ );
637
+ }
638
+ return;
639
+ }
640
+ if (core.pattern === 'layered') {
641
+ checkLayeredPattern(core);
642
+ return;
643
+ }
644
+ if (core.pattern === 'hexagonal') {
645
+ checkHexagonalPattern(core);
646
+ }
647
+ }
648
+
501
649
  // Enforces the interview's rule (spec: "Authored cores") — a layer without
502
650
  // scope or without a verify command is rejected. Applied uniformly to
503
651
  // shipped and authored cores alike; the loader has no shipped-core-only
@@ -575,6 +723,13 @@ export function validateCore(core) {
575
723
  }
576
724
  }
577
725
 
726
+ // An architecture claim, checked mechanically — an unchecked `pattern`
727
+ // is a comment, and a comment that can silently disagree with the graph
728
+ // is worse than no field at all. Depends on depends_on already being
729
+ // resolved to real layer ids (the loop just above), which every check
730
+ // below relies on.
731
+ checkPatternConformance(core);
732
+
578
733
  // A `once: true` layer compiles a single task for the whole build, so
579
734
  // there is no module to substitute into its templates. Left unchecked,
580
735
  // a stray {module} would survive verbatim into scope_globs — a glob
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.1.0",
3
+ "version": "6.1.2",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pr-writing
3
- description: Use whenever writing a PR title/description, a commit message body, a code review comment, or an issue — in Hedgehog's own repo or any consuming project. Triggers on "open a PR", "write the PR description", "comment on this PR", "file an issue". Covers writing style (terse, info-dense, Simplified Technical English) and the pre-open checklist (CI status, scope, verified claims only).
3
+ description: Use whenever writing a PR title/description, a commit message body, a code review comment, or an issue — in Hedgehog's own repo or any consuming project. Triggers on "open a PR", "write the PR description", "comment on this PR", "file an issue". Covers writing style (terse, info-dense, Simplified Technical English), the Why/What shape, folding deep reasoning under `<details>`, and the pre-open checklist (CI status, scope, verified claims only).
4
4
  ---
5
5
 
6
6
  # PR Writing
@@ -47,13 +47,52 @@ as a record of the work session.
47
47
 
48
48
  ## Shape
49
49
 
50
- - **Title**: `<type>(<scope>): <summary>`, imperative mood, under ~70
50
+ - **PR title**: `<type>(<scope>): <summary>`, imperative mood, under ~70
51
51
  chars.
52
- - **Description**: 1-3 bullets — what changed, why. A test plan section
52
+ - **PR description**: 1-3 bullets — what changed, why. A test plan section
53
53
  listing what you actually ran, not what should theoretically pass.
54
+ - **Issue title**: plain English a non-technical reader would say out
55
+ loud, not a commit-style `<type>(<scope>): <summary>`. Name the
56
+ outcome, not the mechanism — "Improve how Hedgehog tracks and enforces
57
+ a project's architecture", not "feat(core): add pattern field".
54
58
  - **Comments**: lead with the concrete finding, then (if needed) the fix
55
59
  requested. No preamble.
56
60
 
61
+ ## Why/What for issues
62
+
63
+ An issue proposing a change — a feature, a fix worth explaining, a
64
+ `ROADMAP.md` item being picked up — states **Why** before **What**:
65
+
66
+ - **Why**: the problem, as a short list of plain-language facts. Each
67
+ bullet is one observation a reader can verify or disagree with, not a
68
+ justification wrapped in caveats. State the problem first, then (if
69
+ the fix isn't obvious from the problem) a short "to fix this" list of
70
+ intended outcomes.
71
+ - **What**: the change itself — the concrete steps, fields, or sub-issues.
72
+ Numbered if sequenced, bulleted if not.
73
+
74
+ Skip the Why section only when the title already states the problem in
75
+ full (a one-line bug report needs no restatement). Never skip What.
76
+
77
+ ## Fold deep reasoning under `<details>`
78
+
79
+ An issue or PR body written for a human reader stays short. Extended
80
+ reasoning — architecture rationale, alternatives considered, prior
81
+ decisions, anything aimed at an AI agent picking up the work or a reader
82
+ who wants the full trail — goes under a collapsed section, not inline:
83
+
84
+ ```markdown
85
+ <details>
86
+ <summary>Full reasoning (for AI agents and anyone who wants the detail)</summary>
87
+
88
+ ...
89
+ </details>
90
+ ```
91
+
92
+ Ask first whether that detail needs to exist in the issue at all — a
93
+ link to an existing doc or prior discussion is often enough. Only fold
94
+ in content that has no better home.
95
+
57
96
  ## When NOT to apply
58
97
 
59
98
  - Internal scratch notes, planning docs, or anything not read by another