@skyf0xx/hedgehog 4.3.4 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -50,6 +50,7 @@ import { rebuildDb } from '../src/db/rebuild.mjs';
50
50
  import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
51
51
  import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
52
52
  import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
53
+ import { recordVersion, checkForUpdate, installedVersion } from '../src/hosts/version.mjs';
53
54
 
54
55
  const AUTHORED_CORE_PATH = '.hedgehog/core.yaml';
55
56
 
@@ -65,6 +66,12 @@ const DEST_ROOT = process.cwd();
65
66
  const CORES_ROOT = join(PKG_ROOT, 'src/golden-cores');
66
67
  const DEFAULT_CORE = 'full-stack-app';
67
68
 
69
+ // The version of the payload this CLI carries — what `init` and
70
+ // `update` stamp into the project they write to.
71
+ const PKG_VERSION = JSON.parse(
72
+ await readFile(join(PKG_ROOT, 'package.json'), 'utf8'),
73
+ ).version;
74
+
68
75
  // One install flag per core, named for what a user is asking to build
69
76
  // rather than the internal src/golden-cores/<name> directory — the two
70
77
  // diverge deliberately so the CLI's public surface can stay stable
@@ -384,6 +391,7 @@ ${bold('Usage')}
384
391
  npx @skyf0xx/hedgehog init --all-hosts install for every supported coding agent
385
392
  npx @skyf0xx/hedgehog init --force overwrite existing files
386
393
  npx @skyf0xx/hedgehog update refresh the installed agents + skills
394
+ npx @skyf0xx/hedgehog update --check report whether a newer release is published
387
395
  npx @skyf0xx/hedgehog db init create .hedgehog/hedgehog.db if absent
388
396
  npx @skyf0xx/hedgehog db rebuild re-derive the build graph from committed intents + git history
389
397
  npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
@@ -449,6 +457,15 @@ those directories. The instructions file, the build graph, the core
449
457
  workspace, and vendor-skills/BMAD and vendor-skills/GSAP stay as they
450
458
  are — those are project-specific or updated deliberately, not by this
451
459
  command.
460
+
461
+ Both ${bold('init')} and ${bold('update')} stamp the version they wrote into
462
+ .hedgehog/version.json. ${bold('update --check')} compares that stamp against
463
+ the newest published release and exits 1 when a newer one exists, without
464
+ writing anything. The same comparison rides on ${bold('status')} and
465
+ ${bold('next')} as a one-line notice, from a once-a-day cached lookup, so a
466
+ stale project surfaces itself; set HEDGEHOG_NO_UPDATE_CHECK=1 to silence it.
467
+ Run ${bold('npx @skyf0xx/hedgehog@latest update')} to pull a newer release —
468
+ the @latest tag matters, since a bare npx may reuse a cached older CLI.
452
469
  `);
453
470
  }
454
471
 
@@ -500,6 +517,7 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
500
517
  // Recorded before anything is written: the routing doc is generated
501
518
  // from this list, so it has to already name the host being installed.
502
519
  await recordHosts(DEST_ROOT, [host]);
520
+ await recordVersion(DEST_ROOT, PKG_VERSION);
503
521
 
504
522
  let written = 0;
505
523
  let overwritten = 0;
@@ -548,31 +566,22 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
548
566
  );
549
567
  console.log(dim(' instead of polling tightly or narrating the wait.'));
550
568
  console.log(` 2. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
551
- console.log(
552
- ` 3. Start a ${bold('new')} ${HOSTS[host].label} session and describe what you want to build.`,
553
- );
569
+ console.log(` 3. Describe what you want to build.`);
554
570
  } else {
555
571
  console.log(` 1. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
556
- console.log(
557
- ` 2. Start a ${bold('new')} ${HOSTS[host].label} session and describe what you want to build.`,
558
- );
559
- }
560
- console.log(
561
- dim(
562
- ` The ${bold('planner')} agent runs planning intake, then hands off to bootstrap.`,
563
- ),
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.
572
+ console.log(` 2. Describe what you want to build.`);
573
+ }
574
+ // Hand off by path, not by name. Reading the file works on every host in
575
+ // the session that ran this install — no restart, no dependence on whether
576
+ // the harness re-scans its agent directory, and no chance of a bare
577
+ // `planner` resolving to an unrelated global agent of the same name.
578
+ const agents = HOSTS[host].agentsDir;
579
+ const skills = HOSTS[host].skillsDir;
570
580
  console.log(
571
581
  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.',
582
+ ` Read ${bold(`${agents}/planner.md`)} and follow it it runs planning\n` +
583
+ ` intake (${skills}/hedgehog-planning-intake/SKILL.md), then hands\n` +
584
+ ` off to ${agents}/bootstrap.md.`,
576
585
  ),
577
586
  );
578
587
  console.log();
@@ -597,6 +606,42 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
597
606
  }
598
607
  }
599
608
 
609
+ // `update --check` answers the question without acting on it: what's
610
+ // installed, what's published, and whether they differ. Exit code 0 when
611
+ // current or unknown, 1 when an update is available, so a script or a
612
+ // host's own tooling can branch on it without parsing the output.
613
+ async function updateCheck() {
614
+ const { installed, latest, stale } = await checkForUpdate(DEST_ROOT, {
615
+ force: true,
616
+ });
617
+
618
+ if (!installed) {
619
+ console.log(
620
+ `${yellow('Installed version unknown.')} ${dim(
621
+ 'This project predates version stamping — run `hedgehog update` to refresh and stamp it.',
622
+ )}\n`,
623
+ );
624
+ return;
625
+ }
626
+ if (!latest) {
627
+ console.log(
628
+ `Installed: ${bold(installed)}\n${dim(
629
+ "Couldn't reach the npm registry — no update check performed.",
630
+ )}\n`,
631
+ );
632
+ return;
633
+ }
634
+ if (stale) {
635
+ console.log(
636
+ `${yellow(bold('Update available.'))} installed ${bold(installed)} → latest ${bold(latest)}\n\n` +
637
+ ` Run ${bold('npx @skyf0xx/hedgehog@latest update')} to refresh this project's agents and skills.\n`,
638
+ );
639
+ process.exitCode = 1;
640
+ return;
641
+ }
642
+ console.log(`${green('Up to date.')} ${dim(`installed ${installed}, latest ${latest}`)}\n`);
643
+ }
644
+
600
645
  async function update({ hosts }) {
601
646
  const targets = hosts?.length ? hosts : await installedHosts(DEST_ROOT);
602
647
 
@@ -623,10 +668,19 @@ async function update({ hosts }) {
623
668
  }
624
669
  }
625
670
 
671
+ // Stamped after the writes land, so the recorded version always
672
+ // describes the payload actually on disk.
673
+ const previous = await installedVersion(DEST_ROOT);
674
+ await recordVersion(DEST_ROOT, PKG_VERSION);
675
+
626
676
  const label = targets.map((h) => HOSTS[h].label).join(', ');
677
+ const versionNote =
678
+ previous && previous !== PKG_VERSION
679
+ ? `${previous} → ${PKG_VERSION}`
680
+ : PKG_VERSION;
627
681
  console.log(
628
682
  `\n${green(bold('Hedgehog agents/skills updated.'))} ${dim(
629
- `${written} files written for ${label}`,
683
+ `${versionNote} — ${written} files written for ${label}`,
630
684
  )}\n`,
631
685
  );
632
686
  console.log('Next steps:');
@@ -1113,6 +1167,34 @@ function warnSingularModuleIdsAtPlan(core, db) {
1113
1167
  );
1114
1168
  }
1115
1169
 
1170
+ // The passive half of update detection. Printed to stderr, after a
1171
+ // command's real output, on the commands every host's loop runs anyway —
1172
+ // so a stale project surfaces itself without anyone thinking to ask.
1173
+ //
1174
+ // This lives in the CLI rather than in any one host's hook because every
1175
+ // host drives the same binary: Claude Code, Cursor, and Gemini CLI all
1176
+ // reach it through `hedgehog next`, and one implementation here covers
1177
+ // all of them.
1178
+ //
1179
+ // Never throws and never blocks: the answer comes from a day-long cache,
1180
+ // a missing or unreachable registry reads as "no news", and the notice is
1181
+ // skipped entirely when output isn't a terminal so it can't corrupt
1182
+ // anything parsing stdout.
1183
+ async function noteAvailableUpdate() {
1184
+ if (process.env.HEDGEHOG_NO_UPDATE_CHECK) return;
1185
+ try {
1186
+ const { installed, latest, stale } = await checkForUpdate(DEST_ROOT);
1187
+ if (!stale) return;
1188
+ console.error(
1189
+ `\n${yellow(bold('Hedgehog update available.'))} ${dim(
1190
+ `${installed} → ${latest}. Run \`npx @skyf0xx/hedgehog@latest update\` to refresh this project's agents and skills.`,
1191
+ )}`,
1192
+ );
1193
+ } catch {
1194
+ // Advisory only — a failed check is never worth failing a command over.
1195
+ }
1196
+ }
1197
+
1116
1198
  async function nextCommand() {
1117
1199
  await ensureDb();
1118
1200
 
@@ -1149,6 +1231,7 @@ async function nextCommand() {
1149
1231
  return;
1150
1232
  }
1151
1233
  console.log(`${dim('No ready task.')} Nothing is planned with all dependencies complete.\n`);
1234
+ await noteAvailableUpdate();
1152
1235
  return;
1153
1236
  }
1154
1237
 
@@ -1165,6 +1248,7 @@ async function nextCommand() {
1165
1248
  }
1166
1249
 
1167
1250
  console.log(formatNext(packet, await resolveCoreId(), packetExists));
1251
+ await noteAvailableUpdate();
1168
1252
  }
1169
1253
 
1170
1254
  function printStalledTasks(stalled) {
@@ -1834,6 +1918,8 @@ async function statusCommand() {
1834
1918
 
1835
1919
  const warningLines = await coreWarningLines();
1836
1920
  if (warningLines.length > 0) console.log(warningLines.join('\n'));
1921
+
1922
+ await noteAvailableUpdate();
1837
1923
  }
1838
1924
 
1839
1925
  // `hedgehog ready` — read-only preview of what a `hedgehog claim` call
@@ -2450,6 +2536,10 @@ async function main() {
2450
2536
  }
2451
2537
 
2452
2538
  if (cmd === 'update') {
2539
+ if (args.includes('--check')) {
2540
+ await updateCheck();
2541
+ return;
2542
+ }
2453
2543
  await update({ hosts });
2454
2544
  return;
2455
2545
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "4.3.4",
3
+ "version": "4.4.0",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -87,7 +87,12 @@ and the scaffolded workspace.
87
87
  real app beyond a single page. If in doubt between this and
88
88
  landing-page because the project has *both* a marketing page and a
89
89
  real app behind it, this is `full-stack-app` — the page becomes routes
90
- 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.
91
96
  - **`landing-page`** — the description is a marketing/announcement/
92
97
  waitlist/portfolio page (or a small handful of such pages) with no
93
98
  persistent domain data of its own. A page that only collects an email
@@ -0,0 +1,154 @@
1
+ // What version of the payload a project has, and whether a newer one exists.
2
+ //
3
+ // `init` and `update` stamp the version they wrote into
4
+ // `.hedgehog/version.json`. Without that stamp a project's installed
5
+ // agents and skills are indistinguishable from any other release's, so
6
+ // staleness can only be discovered by reading the files themselves.
7
+ //
8
+ // The registry lookup is cached and best-effort: a project that can't
9
+ // reach npm, or is offline entirely, gets no answer rather than an error.
10
+ // Nothing here is on the path of any command that has real work to do.
11
+
12
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ const VERSION_PATH = '.hedgehog/version.json';
16
+ const REGISTRY_URL = 'https://registry.npmjs.org/@skyf0xx/hedgehog/latest';
17
+
18
+ // How long a registry answer stays good. The payload ships daily, so a
19
+ // once-a-day question matches how often the answer can actually change,
20
+ // and keeps a session that runs many commands to a single network call.
21
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
22
+
23
+ // The check is advisory — it must never delay a command that has real
24
+ // work to do, so a registry that hangs is treated the same as one that
25
+ // says nothing.
26
+ const FETCH_TIMEOUT_MS = 1500;
27
+
28
+ /**
29
+ * Record the payload version now installed in `root`. Called by both
30
+ * `init` and `update`, so the stamp tracks the payload on disk rather
31
+ * than whichever release first landed.
32
+ */
33
+ export async function recordVersion(root, version) {
34
+ const path = join(root, VERSION_PATH);
35
+ const prior = await readVersionFile(root);
36
+ await mkdir(dirname(path), { recursive: true });
37
+ await writeFile(
38
+ path,
39
+ `${JSON.stringify({ ...prior, version, installedAt: new Date().toISOString() }, null, 2)}\n`,
40
+ );
41
+ return version;
42
+ }
43
+
44
+ async function readVersionFile(root) {
45
+ try {
46
+ return JSON.parse(await readFile(join(root, VERSION_PATH), 'utf8'));
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The payload version installed in `root`, or null for a project
54
+ * installed before the stamp existed.
55
+ */
56
+ export async function installedVersion(root) {
57
+ const { version } = await readVersionFile(root);
58
+ return typeof version === 'string' ? version : null;
59
+ }
60
+
61
+ /**
62
+ * Compare two semver strings. Returns true when `a` is strictly newer
63
+ * than `b`. Prerelease tags sort before their release, matching semver;
64
+ * anything unparseable compares as equal so a malformed version can
65
+ * never fabricate an update prompt.
66
+ */
67
+ export function isNewer(a, b) {
68
+ const parse = (v) => {
69
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v ?? '').trim());
70
+ return m
71
+ ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] ?? null }
72
+ : null;
73
+ };
74
+ const pa = parse(a);
75
+ const pb = parse(b);
76
+ if (!pa || !pb) return false;
77
+ for (let i = 0; i < 3; i++) {
78
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i];
79
+ }
80
+ // Equal release numbers: a release outranks its own prereleases.
81
+ if (pa.pre === pb.pre) return false;
82
+ if (pa.pre === null) return true;
83
+ if (pb.pre === null) return false;
84
+ return pa.pre > pb.pre;
85
+ }
86
+
87
+ /**
88
+ * The newest published version, from cache when it's fresh and from the
89
+ * registry otherwise. Returns null on any failure — offline, timeout,
90
+ * rate limit, malformed response. A null answer means "don't know", and
91
+ * every caller treats that as "say nothing".
92
+ */
93
+ export async function latestVersion(root, { force = false } = {}) {
94
+ const cached = await readVersionFile(root);
95
+ if (!force && cached.checkedAt && cached.latest) {
96
+ const age = Date.now() - Date.parse(cached.checkedAt);
97
+ if (age >= 0 && age < CACHE_TTL_MS) return cached.latest;
98
+ }
99
+
100
+ const latest = await fetchLatest();
101
+ if (!latest) return null;
102
+
103
+ // Cache alongside the install stamp rather than in a separate file, so
104
+ // one read answers both halves of the question.
105
+ const path = join(root, VERSION_PATH);
106
+ try {
107
+ await mkdir(dirname(path), { recursive: true });
108
+ await writeFile(
109
+ path,
110
+ `${JSON.stringify({ ...cached, latest, checkedAt: new Date().toISOString() }, null, 2)}\n`,
111
+ );
112
+ } catch {
113
+ // A read-only or missing .hedgehog just means no caching; the
114
+ // answer we fetched is still good for this call.
115
+ }
116
+ return latest;
117
+ }
118
+
119
+ async function fetchLatest() {
120
+ try {
121
+ // No `accept` header. The abbreviated-metadata type
122
+ // (application/vnd.npm.install-v1+json) is only valid on the
123
+ // packument root — sending it to /latest returns 406, which this
124
+ // function would swallow as "no answer" and the check would
125
+ // silently never fire. Plain /latest returns just the one version
126
+ // object, which is also far smaller than the full packument.
127
+ const res = await fetch(REGISTRY_URL, {
128
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
129
+ });
130
+ if (!res.ok) return null;
131
+ const { version } = await res.json();
132
+ return typeof version === 'string' ? version : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The full staleness picture for a project: what's installed, what's
140
+ * published, and whether the gap is worth telling anyone about.
141
+ *
142
+ * `stale` is true only when both versions are known and the published
143
+ * one is newer — an unknown on either side reads as "no", so a project
144
+ * installed before stamping and an offline machine both stay quiet.
145
+ */
146
+ export async function checkForUpdate(root, { force = false } = {}) {
147
+ const installed = await installedVersion(root);
148
+ const latest = await latestVersion(root, { force });
149
+ return {
150
+ installed,
151
+ latest,
152
+ stale: Boolean(installed && latest && isNewer(latest, installed)),
153
+ };
154
+ }
@@ -38,20 +38,19 @@ Lock and the `bootstrap` handoff. This same rule holds at every later
38
38
  handoff to a newly-installed agent or skill in this session: read its
39
39
  file, don't assume its content.
40
40
 
41
- Dispatch is the part that can't be worked around by reading. A host lists
42
- its agents once, at session start, and this install wrote them after
43
- that so in this session `bootstrap` and the rest may not be
44
- dispatchable at all. Two symptoms, and neither means a broken install:
45
- an error naming the available agents without Hedgehog's in it, or
46
- worse, because it has no symptom a name like `planner` or `reviewer`
47
- silently resolving to the user's own unrelated global agent and running a
48
- different discipline. If either happens, read the agent's file from
49
- `.claude/agents/` and follow it inline here; each one is self-contained.
50
- Everything delegates normally from the next session on, which is why the
51
- installer's next step is to start a fresh one. Don't re-explain the discipline or summarize this
52
- file; the greeting is one line, not a tour. Skip this entirely once the
53
- placeholder is filled in — every later session starts with `hedgehog
54
- status`, not a greeting.
41
+ The build continues in this session nothing here needs a restart. Hosts
42
+ differ in whether they pick up agents written mid-session, so prefer
43
+ reading an agent's file from `.claude/agents/` and following it inline;
44
+ each one is self-contained, and that path works everywhere. Reading also
45
+ avoids the quiet failure of dispatch by name, where `planner` or
46
+ `reviewer` resolves to the user's own unrelated global agent and runs a
47
+ different discipline with no symptom. If a dispatch does error with a
48
+ list of available agents that omits Hedgehog's, that is the same case
49
+ read the file and carry on.
50
+
51
+ Don't re-explain the discipline or summarize this file; the greeting is
52
+ one line, not a tour. Skip this entirely once the placeholder is filled
53
+ in — every later session starts with `hedgehog status`, not a greeting.
55
54
 
56
55
  ## How to work here
57
56
 
@@ -86,6 +85,16 @@ loop) is what a generic skill pack has no notion of, and running one
86
85
  alongside Hedgehog's own skills produces work that bypasses the very
87
86
  discipline this file describes.
88
87
 
88
+ **Build through the loop, including when the work looks too small to need
89
+ it.** A task that seems like a quick edit is not a reason to skip the
90
+ graph, the step sequence, or the gate — scope grows, and the shortcut is
91
+ what turns a small change into debt the next step has to work around.
92
+ Don't propose going around the discipline to save a step, and don't treat
93
+ a user's impatience as license to: say what the loop's next step is and
94
+ take it. Where a rule genuinely conflicts with what the user is asking
95
+ for, name the conflict and let them decide — never resolve it by quietly
96
+ taking the faster path.
97
+
89
98
  {{CORE_SECTION}}
90
99
 
91
100
  ## Consuming the graph