@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 +35 -1
- package/package.json +1 -1
- package/src/agents/planner.md +20 -13
- package/src/db/core.mjs +156 -1
- package/src/hosts/gemini/gemini-extension.json +1 -1
- package/src/skills/pr-writing/SKILL.md +42 -3
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))
|
|
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
package/src/agents/planner.md
CHANGED
|
@@ -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**.
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
393
|
-
`
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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: 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
|
-
- **
|
|
50
|
+
- **PR title**: `<type>(<scope>): <summary>`, imperative mood, under ~70
|
|
51
51
|
chars.
|
|
52
|
-
- **
|
|
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
|