@skyf0xx/hedgehog 5.4.2 → 6.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "5.4.2",
3
+ "version": "6.0.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": {
@@ -30,7 +30,8 @@
30
30
  "vendor-skills/BMAD"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=22.5.0"
33
+ "node": ">=22.5.0",
34
+ "python": ">=3.10.0"
34
35
  },
35
36
  "keywords": [
36
37
  "claude",
@@ -51,11 +51,16 @@ rather than re-deriving it.
51
51
  ## Closing Bootstrap
52
52
 
53
53
  Once the core's skill says Bootstrap is closed — every step it defines
54
- has its commit landed — run `hedgehog graph` to start (or reuse) the
55
- live graph server and open it, so the build graph is on screen before
56
- the first build step starts. Then state plainly that Bootstrap is closed
57
- and name the loop skill that owns everything from here. Don't hand off
58
- to another instance of yourself.
54
+ has its commit landed — run **`hedgehog plan`**. On a first run, `planner`
55
+ wrote intents at planning intake but deliberately left them uncompiled:
56
+ `hedgehog plan` requires `core.yaml`, which only exists now that this
57
+ workspace is scaffolded (see `planner.md`'s Workflow step 7 and step 9).
58
+ This compiles those intents into tasks so the core's loop skill has
59
+ something to pick up from `hedgehog next`. Then run `hedgehog graph` to
60
+ start (or reuse) the live graph server and open it, so the build graph is
61
+ on screen before the first build step starts. Then state plainly that
62
+ Bootstrap is closed and name the loop skill that owns everything from
63
+ here. Don't hand off to another instance of yourself.
59
64
 
60
65
 
61
66
  ## Constraints
@@ -457,17 +457,22 @@ as full-stack-app's Auth/Queue/Mobile trio.
457
457
  `.hedgehog/chain/00-brief.md` per its own Confirm & Lock, in the shape
458
458
  `hedgehog-landing-loop`'s planning-intake section defines — on a first
459
459
  run only, since re-entry there requires the existing brief to still
460
- hold. Then run **`hedgehog plan`** to compile those intents into
461
- tasks. On re-entry this is append-only: `plan` only reads intents still
462
- `proposed`/`planned`, so already-compiled work is untouched and its
463
- `complete` tasks keep their status.
460
+ hold. **On re-entry**, also run **`hedgehog plan`** here to compile
461
+ those intents into tasks: the workspace and its core.yaml already
462
+ exist, so `plan` has what it needs. This is append-only: `plan` only
463
+ reads intents still `proposed`/`planned`, so already-compiled work is
464
+ untouched and its `complete` tasks keep their status. **On a first
465
+ run**, don't run `plan` yet — no core is installed until step 9's
466
+ `bootstrap` handoff lands one, and `plan` requires `core.yaml` to
467
+ exist. Leave the written intents `proposed` and continue to step 8.
464
468
  8. **Commit planning intake's output as one commit** — not on the
465
469
  adoption re-entry path, where `hedgehog-adopt` already committed its
466
470
  own work as `chore(planning): adopt change` (step 2). Elsewhere:
467
471
  `chore(planning): intake` on a first run, `chore(planning): extend
468
472
  scope` on re-entry, so the passes are distinguishable in the log. It
469
- carries the committed `.hedgehog/hedgehog.db` (its new intent and task
470
- rows), `.hedgehog/addons.yaml` (full-stack-app and pwa-app only, and on
473
+ carries the committed `.hedgehog/hedgehog.db` (its new intent rows,
474
+ plus task rows too on re-entry, where step 7 already ran `plan`),
475
+ `.hedgehog/addons.yaml` (full-stack-app and pwa-app only, and on
471
476
  re-entry only if a trigger actually changed), this core's own archival planning
472
477
  output (`.hedgehog/BMAD/` or `.hedgehog/chain/`, first run only), the
473
478
  authored core's `.hedgehog/core.yaml` and `.hedgehog/core-design.md` if
@@ -479,9 +484,14 @@ as full-stack-app's Auth/Queue/Mobile trio.
479
484
  `bootstrap` agent** once the commit lands. It scaffolds the chosen
480
485
  core's workspace (and, for full-stack-app, whichever add-ons are on;
481
486
  for pwa-app, whichever of sync/remote entities is on) before any build
482
- step starts. On re-entry on any other core the
483
- workspace already exists: hand straight to that core's loop skill
484
- instead, which picks the new work up from `hedgehog next`.
487
+ step starts. Once that workspace exists, `core.yaml` exists too — this
488
+ is the point at which the `hedgehog plan` step 7 deferred on a first
489
+ run can finally succeed. `bootstrap` runs it before closing (see
490
+ `bootstrap.md`'s "Closing Bootstrap"), so the intents planner wrote are
491
+ compiled into tasks before the core's loop skill picks anything up. On
492
+ re-entry on any other core the workspace already exists and step 7
493
+ already ran `plan`: hand straight to that core's loop skill instead,
494
+ which picks the new work up from `hedgehog next`.
485
495
  10. **Return a summary**: which core (naming it as authored or adopted,
486
496
  if it is), the intents added (or subject statement, for
487
497
  landing-page), any open questions.
@@ -157,8 +157,16 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
157
157
  from the user is not a pattern; it stays in the log and move on.
158
158
  Group entries that trace to the same underlying gap into one
159
159
  pattern — don't count them as separate patterns just because
160
- they're separate log entries. Each resulting issue is labeled `bug`
161
- and `help wanted`.
160
+ they're separate log entries. The friction hotspots under
161
+ `hedgehog status`'s FRICTION LOGGED block are the mechanical input
162
+ to that same grouping call: each names a file that the tasks behind
163
+ several notes all reach, so two notes landing on one hotspot are
164
+ evidence they trace to one underlying gap even where their wording
165
+ shares nothing. Read it as evidence for grouping, not as the
166
+ grouping itself — the note's content still decides what the gap
167
+ actually is, and the block states how many notes it couldn't
168
+ correlate so you know how much of the log the ranking covers. Each
169
+ resulting issue is labeled `bug` and `help wanted`.
162
170
  - **User-feedback source.** Ask the user plainly whether they have any
163
171
  feedback on the build — what went well, what didn't, anything
164
172
  they'd want the discipline to do differently. If they say no or give
@@ -0,0 +1,296 @@
1
+ // Whether this project can actually run code intelligence (CodeGraphContext,
2
+ // "CGC"), checked once, at `init`, before there is anything else to fall
3
+ // back to.
4
+ //
5
+ // This is a different contract than requires.mjs: that module is advisory
6
+ // and per-core-declared (a layer names binaries its own verify command
7
+ // needs); this one is engine-declared and blocking (every Hedgehog project
8
+ // needs Python and CGC, full stop). Kept in a separate file rather than
9
+ // merged in so the two contracts stay visibly distinct.
10
+ //
11
+ // No side effects anywhere here: no printing, no installing, no process
12
+ // exit, no writes. `checkCodeIntelligence` only looks and reports; a caller
13
+ // decides what to do with the answer.
14
+
15
+ import { execFileSync } from 'node:child_process';
16
+ import { accessSync, constants } from 'node:fs';
17
+ import { readFile } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import { findBinary } from './requires.mjs';
20
+
21
+ // CGC's documented floor. One source of truth: both the version check
22
+ // below and anything describing the requirement read this rather than a
23
+ // hardcoded "3.10" living in two places.
24
+ export const MIN_PYTHON = { major: 3, minor: 10 };
25
+
26
+ // Resolves the Python 3 interpreter the verify/setup shell would find:
27
+ // `python3` first, and `python` only once confirmed to actually be
28
+ // Python 3 — some systems alias `python` to Python 2, and some have
29
+ // no `python` at all. Returns the resolved path or null.
30
+ export function findPython3(env = process.env) {
31
+ const python3 = findBinary('python3', env);
32
+ if (python3) return python3;
33
+
34
+ const python = findBinary('python', env);
35
+ if (!python) return null;
36
+
37
+ const version = pythonVersion(python);
38
+ return version && version.major === 3 ? python : null;
39
+ }
40
+
41
+ // Reads `sys.version_info` from the interpreter directly, rather than
42
+ // parsing `--version` output (whose format has varied across Python
43
+ // releases and isn't meant as a stable interface). Returns
44
+ // `{ major, minor }` or null if the binary can't be run or doesn't
45
+ // print what's expected.
46
+ export function pythonVersion(pythonPath) {
47
+ try {
48
+ const output = execFileSync(
49
+ pythonPath,
50
+ ['-c', 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'],
51
+ { encoding: 'utf8' }
52
+ ).trim();
53
+ const [major, minor] = output.split('.').map(Number);
54
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) return null;
55
+ return { major, minor };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // Resolves a CodeGraphContext binary on PATH, trying the full name first
62
+ // and its documented shorthand second.
63
+ export function findCodeGraphContext(env = process.env) {
64
+ return findBinary('codegraphcontext', env) ?? findBinary('cgc', env);
65
+ }
66
+
67
+ // Reads and validates `.hedgehog/code-intelligence.json` under `cwd`,
68
+ // matching the shape `loadCodeIntelligenceConfig()` in bin/cli.mjs
69
+ // expects: an object with a non-empty string `command`. Returns the
70
+ // parsed config or null — absent, unreadable, and malformed all collapse
71
+ // to the same null, matching that function's own handling.
72
+ async function loadConfig(cwd) {
73
+ try {
74
+ const raw = await readFile(join(cwd, '.hedgehog', 'code-intelligence.json'), 'utf8');
75
+ const parsed = JSON.parse(raw);
76
+ if (!parsed || typeof parsed !== 'object') return null;
77
+ if (typeof parsed.command !== 'string' || parsed.command === '') return null;
78
+ return parsed;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ // Whether `command` names a file this process can actually execute.
85
+ // Config carrying a path that no longer resolves is the "install broke
86
+ // after the fact" case, which reads as a missing CGC rather than a
87
+ // missing config.
88
+ function isExecutable(command) {
89
+ if (typeof command !== 'string' || command === '') return false;
90
+ try {
91
+ accessSync(command, constants.X_OK);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // The single entry point. Checks python presence, python version, CGC
99
+ // presence, and config presence, in that order, so the result names the
100
+ // *first* real blocker — reporting a missing config to someone who has no
101
+ // Python yet sends them down the wrong path. No side effects: this only
102
+ // looks and reports.
103
+ //
104
+ // Returns `{ ok: true, pythonPath, pythonVersion, cgcPath, config }` when
105
+ // every check passes, or `{ ok: false, reason, detail }` where `reason` is
106
+ // one of `'missing-python' | 'python-too-old' | 'missing-cgc' |
107
+ // 'missing-config'` and `detail` carries whatever's useful for the
108
+ // reason (the version found, for instance).
109
+ export async function checkCodeIntelligence({ env = process.env, cwd = process.cwd() } = {}) {
110
+ const pythonPath = findPython3(env);
111
+ if (!pythonPath) {
112
+ return { ok: false, reason: 'missing-python', detail: null };
113
+ }
114
+
115
+ const version = pythonVersion(pythonPath);
116
+ if (
117
+ !version ||
118
+ version.major !== MIN_PYTHON.major ||
119
+ version.minor < MIN_PYTHON.minor
120
+ ) {
121
+ return { ok: false, reason: 'python-too-old', detail: { pythonPath, version } };
122
+ }
123
+
124
+ // The config's own `command` is the authoritative answer, and PATH is
125
+ // only the fallback for finding CGC without one. Setup installs into a
126
+ // project-owned environment and records an absolute path there, exactly
127
+ // so the interpreter the user's other tools rely on stays untouched —
128
+ // requiring a PATH hit as well would make that correct install fail
129
+ // this check unless the user also edited their shell profile.
130
+ const config = await loadConfig(cwd);
131
+ const configuredPath = config && isExecutable(config.command) ? config.command : null;
132
+ const cgcPath = configuredPath ?? findCodeGraphContext(env);
133
+ if (!cgcPath) {
134
+ return { ok: false, reason: 'missing-cgc', detail: { pythonPath, version } };
135
+ }
136
+
137
+ if (!config) {
138
+ return { ok: false, reason: 'missing-config', detail: { pythonPath, version, cgcPath } };
139
+ }
140
+
141
+ return { ok: true, pythonPath, pythonVersion: version, cgcPath, config };
142
+ }
143
+
144
+ // Renders checkCodeIntelligence()'s failing result as printable lines,
145
+ // mirroring formatMissingRequirements's shape and style. This is the
146
+ // single owning source for this copy — the CLI, the setup skill, the
147
+ // update/status notice, and the README all render from this rather than
148
+ // restating it.
149
+ //
150
+ // Leads with the payoff, not the requirement: what a user gets is an
151
+ // agent that starts each task with the symbols and files it actually
152
+ // needs already loaded instead of searching for them, so tasks cost
153
+ // fewer tokens and finish faster, plus declared verify_radius gaps get
154
+ // flagged against the real blast radius before they bite.
155
+ export function formatCodeIntelligenceGap(result) {
156
+ if (!result || result.ok) return [];
157
+
158
+ const lines = [
159
+ 'CODE INTELLIGENCE NOT SET UP',
160
+ '',
161
+ ' With it, tasks start with the symbols and files they actually need',
162
+ ' already loaded instead of searching for them — fewer tokens burned',
163
+ ' per task, faster runs — and verify_radius gaps get flagged against',
164
+ ' the real blast radius.',
165
+ '',
166
+ ];
167
+
168
+ switch (result.reason) {
169
+ case 'missing-python':
170
+ lines.push(` Python ${MIN_PYTHON.major}.${MIN_PYTHON.minor}+ was not found on PATH.`);
171
+ break;
172
+ case 'python-too-old': {
173
+ const found = result.detail?.version
174
+ ? `${result.detail.version.major}.${result.detail.version.minor}`
175
+ : 'an older version';
176
+ lines.push(
177
+ ` Found Python ${found}, but ${MIN_PYTHON.major}.${MIN_PYTHON.minor}+ is required.`
178
+ );
179
+ break;
180
+ }
181
+ case 'missing-cgc':
182
+ lines.push(' CodeGraphContext (codegraphcontext / cgc) was not found on PATH.');
183
+ break;
184
+ case 'missing-config':
185
+ lines.push(' .hedgehog/code-intelligence.json is missing or unreadable.');
186
+ break;
187
+ default:
188
+ lines.push(' Code intelligence is not usable yet.');
189
+ }
190
+
191
+ lines.push('');
192
+ lines.push(' Run the hedgehog-code-intelligence-setup skill to set it up. The');
193
+ lines.push(' Hedgehog plugin ships it, so it loads before init has installed');
194
+ lines.push(' anything. Without the plugin, the same procedure is readable at');
195
+ lines.push(' src/skills/hedgehog-code-intelligence-setup/SKILL.md inside the');
196
+ lines.push(' @skyf0xx/hedgehog package.');
197
+ return lines;
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Index freshness
202
+ // ---------------------------------------------------------------------------
203
+ //
204
+ // The install check above answers "can CGC run here". This answers the
205
+ // separate question "does the index still describe this code" — a working
206
+ // CGC whose graph was built ten commits ago passes every check above and
207
+ // still feeds `plan` a picture of code that no longer exists.
208
+ //
209
+ // The index carries the commit it was built from, so the claim is
210
+ // checkable. That is the whole mechanism: an index that knows which commit
211
+ // it describes is a cache, and one that doesn't is a second source of
212
+ // truth quietly drifting from the first.
213
+
214
+ // Reads HEAD without requiring a git binary lookup through findBinary:
215
+ // `git` is already a hard requirement everywhere else in this CLI. Returns
216
+ // the full SHA, or null outside a repository or on any git failure —
217
+ // callers treat null as "can't tell", never as "stale".
218
+ export function headSha(cwd = process.cwd()) {
219
+ try {
220
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
221
+ cwd,
222
+ encoding: 'utf8',
223
+ stdio: ['ignore', 'pipe', 'ignore'],
224
+ }).trim() || null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ // Whether the index the config describes still matches HEAD.
231
+ //
232
+ // Returns one of:
233
+ // { state: 'fresh', indexedSha, head }
234
+ // { state: 'stale', indexedSha, head }
235
+ // { state: 'unknown', reason }
236
+ //
237
+ // `unknown` is the honest answer in three distinct cases, and none of them
238
+ // are failures: a config written before this field existed ('no-provenance'),
239
+ // a non-repository or unreadable git ('no-head'), and an absent config
240
+ // ('no-config'). A caller that can't tell says so rather than guessing;
241
+ // nothing here ever blocks.
242
+ export async function checkIndexFreshness({ cwd = process.cwd() } = {}) {
243
+ const config = await loadConfig(cwd);
244
+ if (!config) return { state: 'unknown', reason: 'no-config' };
245
+
246
+ const indexedSha = typeof config.indexedSha === 'string' && config.indexedSha !== ''
247
+ ? config.indexedSha
248
+ : null;
249
+ if (!indexedSha) return { state: 'unknown', reason: 'no-provenance' };
250
+
251
+ const head = headSha(cwd);
252
+ if (!head) return { state: 'unknown', reason: 'no-head' };
253
+
254
+ return { state: indexedSha === head ? 'fresh' : 'stale', indexedSha, head };
255
+ }
256
+
257
+ // Renders a non-fresh freshness result as printable lines, in the same
258
+ // owning-source spirit as formatCodeIntelligenceGap: `plan`, `status`, and
259
+ // `next` all render from here rather than restating the copy.
260
+ //
261
+ // Returns [] for a fresh index and for 'no-config' — a project that never
262
+ // set code intelligence up is not a project with a stale index, and gets
263
+ // the setup gap message instead. The other two unknowns do print: an index
264
+ // with no recorded commit is exactly the drift this check exists to end.
265
+ export function formatIndexStaleness(result, { indexCommand = 'cgc index . --force' } = {}) {
266
+ if (!result || result.state === 'fresh') return [];
267
+ if (result.state === 'unknown' && result.reason === 'no-config') return [];
268
+
269
+ if (result.state === 'unknown') {
270
+ return [
271
+ 'CODE INTELLIGENCE INDEX AGE UNKNOWN',
272
+ '',
273
+ result.reason === 'no-provenance'
274
+ ? ' The index does not record which commit it was built from, so'
275
+ : ' HEAD could not be read, so',
276
+ ' whether it still matches this code cannot be checked.',
277
+ '',
278
+ ` Re-index: ${indexCommand}`,
279
+ ' Then record the commit it was built from in',
280
+ ' .hedgehog/code-intelligence.json (indexedSha) — re-indexing alone',
281
+ ' does not write it.',
282
+ ];
283
+ }
284
+
285
+ return [
286
+ 'CODE INTELLIGENCE INDEX IS STALE',
287
+ '',
288
+ ` Indexed at ${result.indexedSha.slice(0, 8)}, HEAD is ${result.head.slice(0, 8)}.`,
289
+ ' Pre-read context and verify_radius suggestions are drawn from code',
290
+ ' as it was, so they may name symbols that moved and miss ones added.',
291
+ '',
292
+ ` Refresh it: ${indexCommand}`,
293
+ ' Then update indexedSha in .hedgehog/code-intelligence.json to the new',
294
+ ' HEAD — the index command itself does not write that field.',
295
+ ];
296
+ }