@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/src/db/status.mjs CHANGED
@@ -11,9 +11,10 @@
11
11
  // every task meeting that condition, not just the one `hedgehog next`
12
12
  // would pick.
13
13
 
14
+ import { radiusGaps } from './code-intelligence.mjs';
14
15
  import { listDebt } from './debt.mjs';
15
16
  import { detectDrift, formatDrift } from './drift.mjs';
16
- import { listFriction } from './friction.mjs';
17
+ import { frictionByModule, listFriction } from './friction.mjs';
17
18
  import { orphanedOverrides } from './overrides.mjs';
18
19
  import { formatMissingRequirements } from './requires.mjs';
19
20
  import { readyTasks, heldBackReason } from './ready.mjs';
@@ -102,6 +103,33 @@ function loadDebtByTask(db) {
102
103
  .sort((a, b) => a.taskId.localeCompare(b.taskId));
103
104
  }
104
105
 
106
+ // Advisory radius suggestions: for every task whose context was resolved
107
+ // against a code-intelligence index (context_files non-NULL), the files
108
+ // its computed blast radius reaches that no verify_radius/scope_globs
109
+ // glob covers (code-intelligence.mjs#radiusGaps). Skips every task with
110
+ // no resolved context outright, which is what a task planned without a
111
+ // working index leaves behind — the same NULL a broken or removed CGC
112
+ // install produces, not a state `status` distinguishes from an
113
+ // intentionally unindexed run.
114
+ //
115
+ // Wrapped the way countFriction is: `status` reads through a read-only
116
+ // handle, which never migrates, so on a graph predating the context
117
+ // columns this SELECT throws on a column that isn't there yet. An
118
+ // unmigrated graph reads as no suggestions, not as a failed `status`.
119
+ function loadRadiusSuggestions(db) {
120
+ try {
121
+ const tasks = db.prepare('SELECT * FROM tasks WHERE context_files IS NOT NULL').all();
122
+ const suggestions = [];
123
+ for (const task of tasks) {
124
+ const gaps = radiusGaps(task);
125
+ if (gaps.length > 0) suggestions.push({ taskId: task.id, files: gaps });
126
+ }
127
+ return suggestions;
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
105
133
  // The friction row count, or 0. `listFriction` reads the table
106
134
  // unguarded, so a build graph predating it throws here where `listDebt`
107
135
  // would return [] — caught rather than propagated for the same reason
@@ -117,11 +145,13 @@ function countFriction(db) {
117
145
  }
118
146
 
119
147
  // Returns { counts, ready, heldBack, inFlight, attention, drift,
120
- // orphanedOverrides, debt, frictionCount, total } — counts keyed by
121
- // every status in the tasks CHECK constraint (present even at zero),
122
- // ready the full list of currently-pickable tasks, heldBack the subset
123
- // of those that `hedgehog claim` would skip over right now because they
124
- // conflict with in-flight work or another ready task ahead of them
148
+ // orphanedOverrides, debt, frictionCount, frictionHotspots,
149
+ // radiusSuggestions, total } —
150
+ // counts keyed by every status in the tasks CHECK constraint (present
151
+ // even at zero), ready the full list of currently-pickable tasks,
152
+ // heldBack the subset of those that `hedgehog claim` would skip over
153
+ // right now because they conflict with in-flight work or another ready
154
+ // task ahead of them
125
155
  // (ready.mjs's own simulation, reused rather than reimplemented —
126
156
  // without this a task can sit in READY indefinitely with no visible
127
157
  // reason, the same invisible-stall shape blocked tasks had before
@@ -159,6 +189,20 @@ function countFriction(db) {
159
189
  // only the existence signal: `debt list` needs a task id the operator
160
190
  // has no way to guess, and `friction list` needs the operator to
161
191
  // already suspect there is something to read.
192
+ //
193
+ // `frictionHotspots` (friction.mjs#frictionByModule) is the one reading
194
+ // of friction that isn't a count: the files the friction-carrying tasks
195
+ // reach, ranked. It rides along here because it is a read of `friction`
196
+ // joined to `tasks` and needs nothing else passed in. On a project whose
197
+ // tasks carry no resolved context it is an empty hotspot list and a full
198
+ // `uncorrelated` count, which is the same signal `frictionCount` alone
199
+ // already gave.
200
+ //
201
+ // `radiusSuggestions` (code-intelligence.mjs#radiusGaps) is advisory,
202
+ // never enforcement: files a task's computed blast radius reaches that
203
+ // its declared verify_radius/scope_globs doesn't cover. Computed only
204
+ // for tasks with a resolved context_files, same gate as the hotspots
205
+ // read — a project with no index attached contributes nothing here.
162
206
  export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
163
207
  const counts = countTasksByStatus(db);
164
208
  const ready = loadReadyTasks(db);
@@ -169,6 +213,8 @@ export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
169
213
  const orphaned = orphanedOverrides(db, overrides);
170
214
  const debt = loadDebtByTask(db);
171
215
  const frictionCount = countFriction(db);
216
+ const frictionHotspots = frictionByModule(db);
217
+ const radiusSuggestions = loadRadiusSuggestions(db);
172
218
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
173
219
  return {
174
220
  counts,
@@ -180,6 +226,8 @@ export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
180
226
  orphanedOverrides: orphaned,
181
227
  debt,
182
228
  frictionCount,
229
+ frictionHotspots,
230
+ radiusSuggestions,
183
231
  total,
184
232
  };
185
233
  }
@@ -213,8 +261,15 @@ export function formatStatus({
213
261
  orphanedOverrides = [],
214
262
  debt = [],
215
263
  frictionCount = 0,
264
+ frictionHotspots = { hotspots: [], uncorrelated: 0 },
265
+ radiusSuggestions = [],
216
266
  total,
217
267
  missingRequirements,
268
+ // Rendered lines from formatIndexStaleness, or [] when the index is
269
+ // fresh or absent. Passed in rather than computed: this module reads
270
+ // the graph, and index provenance lives on disk and in git, which is
271
+ // the CLI's side of the line.
272
+ indexStaleness = [],
218
273
  }) {
219
274
  const lines = [];
220
275
  lines.push(`TASKS ${total}`);
@@ -266,9 +321,9 @@ export function formatStatus({
266
321
  lines.push(' Fix the work, then: hedgehog retry <task-id> && hedgehog claim <task-id> --owner <owner>');
267
322
  }
268
323
 
269
- // Last two, because both are conditions of the graph as a whole rather
270
- // than of any one task, and because an operator reading top-down should
271
- // reach them after knowing what's ready and what's stuck.
324
+ // These three, because each is a condition of the graph as a whole
325
+ // rather than of any one task, and because an operator reading top-down
326
+ // should reach them after knowing what's ready and what's stuck.
272
327
  if (drift && drift.length > 0) {
273
328
  lines.push('');
274
329
  lines.push(formatDrift(drift));
@@ -290,6 +345,35 @@ export function formatStatus({
290
345
  lines.push(' Each widens nothing until the id matches a real task. See: hedgehog override list');
291
346
  }
292
347
 
348
+ // Advisory, never a defect count: verify_radius/scope_globs is what
349
+ // actually gates `hedgehog verify`, and stays the sole authority on
350
+ // that whether or not this prints. This only names what a
351
+ // code-intelligence index thinks a task's blast radius reaches beyond
352
+ // its declared radius — a widening the operator may or may not want.
353
+ if (radiusSuggestions.length > 0) {
354
+ lines.push('');
355
+ lines.push('RADIUS SUGGESTIONS');
356
+ for (const { taskId, files } of radiusSuggestions) {
357
+ lines.push(` ${taskId} verify_radius may be missing:`);
358
+ for (const file of files) lines.push(` ${file}`);
359
+ }
360
+ lines.push('');
361
+ lines.push(
362
+ indexStaleness.length > 0
363
+ ? ' Sourced from a code-intelligence index that is out of date (below). Widen with: hedgehog plan --recompile'
364
+ : ' Sourced from a code-intelligence index. Widen with: hedgehog plan --recompile',
365
+ );
366
+ }
367
+
368
+ // Below the suggestions it qualifies, and printed whether or not any
369
+ // were raised: an index that has drifted from HEAD also under-reports,
370
+ // so an empty RADIUS SUGGESTIONS section is exactly when a reader most
371
+ // needs to know the index is stale rather than the code clean.
372
+ if (indexStaleness.length > 0) {
373
+ lines.push('');
374
+ lines.push(...indexStaleness);
375
+ }
376
+
293
377
  // Bottom, below every condition above, and two sections rather than
294
378
  // one. Everything above is something wrong — work stalled, a graph
295
379
  // that disagrees with its core, an override doing nothing. These two
@@ -320,6 +404,21 @@ export function formatStatus({
320
404
  if (frictionCount > 0) {
321
405
  lines.push('');
322
406
  lines.push(`FRICTION LOGGED ${frictionCount}`);
407
+ // The top three only: this is a lead for tweaker's grouping pass, not
408
+ // a listing, and a ranked tail nobody acts on costs the whole block
409
+ // its readability. The uncorrelated count prints whenever it's
410
+ // non-zero — including with no hotspots at all, which is how a
411
+ // project whose tasks carry no resolved context reads.
412
+ const { hotspots = [], uncorrelated = 0 } = frictionHotspots ?? {};
413
+ if (hotspots.length > 0 || uncorrelated > 0) {
414
+ lines.push('');
415
+ for (const { path, frictionCount: n } of hotspots.slice(0, 3)) {
416
+ lines.push(` ${path} ${n} note${n === 1 ? '' : 's'}`);
417
+ }
418
+ if (uncorrelated > 0) {
419
+ lines.push(` ${uncorrelated} note${uncorrelated === 1 ? '' : 's'} not correlated to any file`);
420
+ }
421
+ }
323
422
  lines.push('');
324
423
  lines.push(' Reviewed as a batch at the end of a build. See: hedgehog friction list');
325
424
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "5.4.2",
3
+ "version": "6.0.2",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -101,16 +101,19 @@ export async function latestVersion(root, { force = false } = {}) {
101
101
  if (!latest) return null;
102
102
 
103
103
  // Cache alongside the install stamp rather than in a separate file, so
104
- // one read answers both halves of the question.
104
+ // one read answers both halves of the question. Written only into a
105
+ // `.hedgehog/` that already exists — a version lookup is a read, and
106
+ // creating the directory here would leave one behind in a project that
107
+ // never completed `init`, which `init`'s own preconditions rely on
108
+ // being absent when they abort.
105
109
  const path = join(root, VERSION_PATH);
106
110
  try {
107
- await mkdir(dirname(path), { recursive: true });
108
111
  await writeFile(
109
112
  path,
110
113
  `${JSON.stringify({ ...cached, latest, checkedAt: new Date().toISOString() }, null, 2)}\n`,
111
114
  );
112
115
  } catch {
113
- // A read-only or missing .hedgehog just means no caching; the
116
+ // A read-only or absent .hedgehog just means no caching; the
114
117
  // answer we fetched is still good for this call.
115
118
  }
116
119
  return latest;
@@ -61,9 +61,10 @@ function packSpec(entry) {
61
61
  * Resolve, extract, and read one core package.
62
62
  *
63
63
  * @param {object} entry - a cores.json entry (`name`, `package`, `version`)
64
- * @returns {Promise<{manifest: object, root: string, version: string}>}
65
- * the parsed hedgehog-core.yaml, the directory the package extracted to,
66
- * and the exact version resolved.
64
+ * @returns {Promise<{manifest: object, root: string, version: string,
65
+ * engineAdvisory: string|null}>} the parsed hedgehog-core.yaml, the
66
+ * directory the package extracted to, the exact version resolved, and a
67
+ * line to print when the CLI is ahead of the core's engine line.
67
68
  */
68
69
  export async function fetchCore(entry) {
69
70
  const staged = await packAndExtract(entry);
@@ -74,9 +75,9 @@ export async function fetchCore(entry) {
74
75
  // `cores list` reports as reusable and what an offline `update` reads
75
76
  // back, so only a core this engine can actually install belongs in it —
76
77
  // a manifest that fails here throws before anything is cached.
77
- let manifest;
78
+ let read;
78
79
  try {
79
- manifest = await readManifest(staged.root, entry);
80
+ read = await readManifest(staged.root, entry);
80
81
  } catch (err) {
81
82
  await rm(staged.tmp, { recursive: true, force: true });
82
83
  throw err;
@@ -86,13 +87,13 @@ export async function fetchCore(entry) {
86
87
  // and now; its copy is as good as this one, so keep it and drop ours.
87
88
  if (await exists(cached)) {
88
89
  await rm(staged.tmp, { recursive: true, force: true });
89
- return { manifest, root: cached, version };
90
+ return { ...read, root: cached, version };
90
91
  }
91
92
 
92
93
  await mkdir(join(CORE_CACHE_ROOT, entry.name), { recursive: true });
93
94
  await rename(staged.root, cached);
94
95
  await rm(staged.tmp, { recursive: true, force: true });
95
- return { manifest, root: cached, version };
96
+ return { ...read, root: cached, version };
96
97
  }
97
98
 
98
99
  /**
@@ -104,7 +105,7 @@ export async function fetchCore(entry) {
104
105
  export async function cachedCore(entry, version) {
105
106
  const root = join(CORE_CACHE_ROOT, entry.name, version);
106
107
  if (!(await exists(join(root, MANIFEST_NAME)))) return null;
107
- return { manifest: await readManifest(root, entry), root, version };
108
+ return { ...(await readManifest(root, entry)), root, version };
108
109
  }
109
110
 
110
111
  async function packAndExtract(entry) {
@@ -159,8 +160,12 @@ async function readManifest(root, entry) {
159
160
  `The package and the registry entry have to agree on the core's name.`,
160
161
  );
161
162
  }
162
- assertEngineSatisfies(manifest, ENGINE_VERSION, `${entry.package}/${MANIFEST_NAME}`);
163
- return manifest;
163
+ const engineAdvisory = assertEngineSatisfies(
164
+ manifest,
165
+ ENGINE_VERSION,
166
+ `${entry.package}/${MANIFEST_NAME}`,
167
+ );
168
+ return { manifest, engineAdvisory };
164
169
  }
165
170
 
166
171
  // npm's own failure text is a wall of registry detail; what a user can act
@@ -178,6 +183,34 @@ function unresolvableMessage(entry, spec, err) {
178
183
  );
179
184
  }
180
185
 
186
+ /**
187
+ * The engine line the newest cached extraction of a core declares, or
188
+ * null when that core has never been fetched. `cores list` reports it so
189
+ * the engine line each core is written against is visible at the point a
190
+ * core is chosen, rather than only once an install has run.
191
+ */
192
+ export async function cachedEngine(name, versions) {
193
+ const newest = [...versions].sort(compareVersions).pop();
194
+ if (!newest) return null;
195
+ try {
196
+ const text = await readFile(join(CORE_CACHE_ROOT, name, newest, MANIFEST_NAME), 'utf8');
197
+ return parseCoreManifest(text, `${name}/${MANIFEST_NAME}`).engine ?? null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ // Orders `1.0.5` after `1.0.0` rather than before it, which a plain
204
+ // string sort would not.
205
+ function compareVersions(a, b) {
206
+ const parts = (v) => String(v).split('.').map(Number);
207
+ const [x, y] = [parts(a), parts(b)];
208
+ for (let i = 0; i < 3; i += 1) {
209
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
210
+ }
211
+ return 0;
212
+ }
213
+
181
214
  /**
182
215
  * List which versions of a core are already extracted in the cache.
183
216
  * Reported by `hedgehog cores list` so a user can see what an install
@@ -137,17 +137,22 @@ export function parseCoreManifest(text, source = 'hedgehog-core.yaml') {
137
137
  /**
138
138
  * Check a core's declared `engine` range against the CLI installing it.
139
139
  *
140
- * A core states which engine line it installs against; an engine older
140
+ * A core states which engine line it installs against. An engine *older*
141
141
  * than that line writes a payload the core's own skills expect features
142
- * from, so the install stops here rather than part-way through.
142
+ * from, so the install stops here rather than part-way through. An engine
143
+ * *newer* than that line is a different situation: the CLI carries every
144
+ * feature the core asks for, plus more, so the install proceeds and the
145
+ * skew is reported back to the caller as an advisory instead.
143
146
  *
144
147
  * @param {object} manifest - a parsed manifest, carrying `engine`
145
148
  * @param {string} engineVersion - the CLI's own package.json `version`
146
149
  * @param {string} source - what to name in the error
150
+ * @returns {string|null} an advisory line when the CLI is ahead of the
151
+ * core's line, `null` when the two agree
147
152
  */
148
153
  export function assertEngineSatisfies(manifest, engineVersion, source = 'hedgehog-core.yaml') {
149
154
  const required = manifest.engine;
150
- if (required == null) return;
155
+ if (required == null) return null;
151
156
 
152
157
  const range = ENGINE_RANGE.exec(String(required).trim());
153
158
  if (!range) {
@@ -166,12 +171,12 @@ export function assertEngineSatisfies(manifest, engineVersion, source = 'hedgeho
166
171
  const [, wantMajor, wantMinor, wantPatch] = range.map(Number);
167
172
  const [, haveMajor, haveMinor, havePatch] = installed.map(Number);
168
173
 
169
- // Caret semantics: the same major line, at or above the stated version.
170
- const satisfied =
171
- haveMajor === wantMajor &&
172
- (haveMinor > wantMinor || (haveMinor === wantMinor && havePatch >= wantPatch));
174
+ const behind =
175
+ haveMajor < wantMajor ||
176
+ (haveMajor === wantMajor &&
177
+ (haveMinor < wantMinor || (haveMinor === wantMinor && havePatch < wantPatch)));
173
178
 
174
- if (!satisfied) {
179
+ if (behind) {
175
180
  throw new Error(
176
181
  `This core needs Hedgehog ${required}, and the CLI running is ${engineVersion}.\n\n` +
177
182
  `The core's agents and skills are written against that engine line, so installing\n` +
@@ -180,4 +185,14 @@ export function assertEngineSatisfies(manifest, engineVersion, source = 'hedgeho
180
185
  ` npx @skyf0xx/hedgehog@latest init\n`,
181
186
  );
182
187
  }
188
+
189
+ if (haveMajor > wantMajor) {
190
+ return (
191
+ `This core is written against Hedgehog ${required}, and the CLI running is ${engineVersion}.\n` +
192
+ `Installing it anyway: a newer engine carries everything the core asks for. If the\n` +
193
+ `core's own skills behave oddly, that skew is the first thing to suspect.`
194
+ );
195
+ }
196
+
197
+ return null;
183
198
  }