@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/bin/cli.mjs CHANGED
@@ -14,13 +14,22 @@
14
14
  // npx @skyf0xx/hedgehog --help
15
15
 
16
16
  import { cp, mkdir, access, readdir, stat, rm, readFile, writeFile } from 'node:fs/promises';
17
- import { constants, existsSync } from 'node:fs';
17
+ import { constants, existsSync, realpathSync } from 'node:fs';
18
18
  import { fileURLToPath } from 'node:url';
19
- import { dirname, join, relative, resolve } from 'node:path';
19
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
20
20
  import { spawn, execFileSync } from 'node:child_process';
21
+ import { createInterface } from 'node:readline/promises';
21
22
  import { dbInit, DB_PATH, dbAbsPath, openDb } from '../src/db/init.mjs';
22
23
  import { loadCore, lintCore, isModuleAxis } from '../src/db/core.mjs';
23
- import { planTasks, CORE_INTENT_ID } from '../src/db/plan.mjs';
24
+ import {
25
+ planTasks,
26
+ CORE_INTENT_ID,
27
+ taskId,
28
+ onceTaskId,
29
+ layerTaskFields,
30
+ onceLayerTaskFields,
31
+ } from '../src/db/plan.mjs';
32
+ import { radiusGaps, resolveTaskContext } from '../src/db/code-intelligence.mjs';
24
33
  import { addIntent, INTENTS_DIR } from '../src/db/intent.mjs';
25
34
  import {
26
35
  nextTask,
@@ -50,6 +59,12 @@ import {
50
59
  missingBinaries,
51
60
  formatMissingRequirements,
52
61
  } from '../src/db/requires.mjs';
62
+ import {
63
+ checkCodeIntelligence,
64
+ checkIndexFreshness,
65
+ formatCodeIntelligenceGap,
66
+ formatIndexStaleness,
67
+ } from '../src/db/code-intelligence-requires.mjs';
53
68
  import { whyPath, formatWhy } from '../src/db/why.mjs';
54
69
  import { addFriction, listFriction } from '../src/db/friction.mjs';
55
70
  import { addDebt, listDebt } from '../src/db/debt.mjs';
@@ -57,6 +72,8 @@ import {
57
72
  shouldPromptForStar,
58
73
  recordStarAnswer,
59
74
  formatStarPrompt,
75
+ shouldNoteCodeIntelligence,
76
+ recordCodeIntelligenceNotice,
60
77
  REPO_URL,
61
78
  } from '../src/db/community.mjs';
62
79
  import { rebuildDb } from '../src/db/rebuild.mjs';
@@ -70,10 +87,19 @@ import {
70
87
  installedVersion,
71
88
  } from '../src/hosts/version.mjs';
72
89
  import { loadRegistry, resolveCore } from '../src/registry/index.mjs';
73
- import { fetchCore, cachedCore, cachedVersions } from '../src/registry/fetch.mjs';
90
+ import { fetchCore, cachedCore, cachedVersions, cachedEngine } from '../src/registry/fetch.mjs';
74
91
  import { recordCore, installedCore } from '../src/registry/installed.mjs';
75
92
 
76
93
  const AUTHORED_CORE_PATH = '.hedgehog/core.yaml';
94
+ const CODE_INTELLIGENCE_CONFIG_PATH = '.hedgehog/code-intelligence.json';
95
+
96
+ // Same pending-intent filter plan.mjs's loadPendingIntents applies —
97
+ // duplicated here (not imported: it's module-private to plan.mjs)
98
+ // because the provider has to be pre-resolved for the tasks a plan run
99
+ // is about to compile, before planTasks itself runs. Kept as a literal
100
+ // rather than a shared export because it names two fixed status strings,
101
+ // not behavior that could drift on its own.
102
+ const PENDING_INTENT_STATUSES = ['proposed', 'planned'];
77
103
 
78
104
  const BLOCKED_REASON_LABELS = {
79
105
  verification_failed: 'verification failed',
@@ -422,7 +448,22 @@ function warnOrphanedNotes({ orphanedNotes }) {
422
448
  async function writePlannedFile(f) {
423
449
  await mkdir(dirname(f.dest), { recursive: true });
424
450
  if (f.merge) {
425
- let out = await readFile(join(PKG_ROOT, f.merge.shell), 'utf8');
451
+ // A re-run (e.g. `init --core <name> --force`, installing a core
452
+ // after a deferred coreless init) must not clobber a destination
453
+ // `planner` already filled in: {{PROJECT_NAME}}/{{PROJECT_SUMMARY}}
454
+ // are filled once, by hand or by `planner`, and never touched again.
455
+ // Starting from the existing file instead of the pristine shell
456
+ // whenever it's still missing only {{CORE_SECTION}} preserves that —
457
+ // the shell is the base only for a destination that doesn't exist
458
+ // yet, or one still holding the raw, unfilled template.
459
+ let out = null;
460
+ if (await exists(f.dest)) {
461
+ const existing = await readFile(f.dest, 'utf8');
462
+ if (!existing.includes('{{PROJECT_NAME}}') && existing.includes('{{CORE_SECTION}}')) {
463
+ out = existing;
464
+ }
465
+ }
466
+ if (out === null) out = await readFile(join(PKG_ROOT, f.merge.shell), 'utf8');
426
467
  // A deferred install has no core yet, so {{CORE_SECTION}} stays put
427
468
  // for whichever bootstrap-core skill runs first to fill in. The host
428
469
  // is always known at install time, so {{HOST_DISPATCH}} never is.
@@ -618,9 +659,141 @@ function ensureGitRepo() {
618
659
  console.log(dim(' (no git repository found here — ran `git init`, since every later step commits)'));
619
660
  }
620
661
 
662
+ // Code intelligence is a precondition of the install, not a feature of it,
663
+ // so this runs before anything is resolved or written. Thin orchestration
664
+ // over src/db/code-intelligence-requires.mjs: that module only looks and
665
+ // reports, and this owns the printing and the exit code, the same division
666
+ // ensureGitRepo() draws for its own external-tool precondition.
667
+ //
668
+ // Returns true to continue, false to abort. On a false return the caller
669
+ // returns immediately, having written nothing — a half-installed
670
+ // .hedgehog/ is worse than no install at all.
671
+ //
672
+ // The offer is a question, not a notice, so it needs an answerable stdin.
673
+ // Without one (CI, piped input, an agent-driven run) asking would hang,
674
+ // which is strictly worse than saying the useful thing outright: the
675
+ // non-interactive path takes the accepted-path message directly, since an
676
+ // agent reading it is exactly what can act on it. HEDGEHOG_FORCE_INTERACTIVE
677
+ // forces the prompt on without a pty, for the repro suite's use; not a
678
+ // documented user-facing flag.
679
+ async function ensureCodeIntelligence() {
680
+ const result = await checkCodeIntelligence({ cwd: DEST_ROOT });
681
+ if (result.ok) return true;
682
+
683
+ const gap = formatCodeIntelligenceGap(result);
684
+ console.error(`\n${red(bold(gap[0]))}`);
685
+ console.error(gap.slice(1).join('\n'));
686
+
687
+ const interactive = Boolean(process.env.HEDGEHOG_FORCE_INTERACTIVE) || process.stdin.isTTY;
688
+ const accepted = interactive ? await confirm('Set it up now?') : true;
689
+
690
+ if (accepted) {
691
+ console.error(
692
+ `\nRun the ${bold('hedgehog-code-intelligence-setup')} skill, then re-run ` +
693
+ `${bold('hedgehog init')} — the install continues from there.\n`,
694
+ );
695
+ } else {
696
+ console.error(
697
+ `\nNothing was installed. Re-run ${bold('hedgehog init')} once code ` +
698
+ `intelligence is set up and the install continues from there.\n`,
699
+ );
700
+ }
701
+
702
+ process.exitCode = 1;
703
+ return false;
704
+ }
705
+
706
+ // A yes/no question on stdin, defaulting to yes — bare Enter accepts.
707
+ // Only ever called with an answerable stdin; see ensureCodeIntelligence.
708
+ async function confirm(question) {
709
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
710
+ try {
711
+ const answer = (await rl.question(`\n${question} ${dim('[Y/n]')} `)).trim().toLowerCase();
712
+ return answer !== 'n' && answer !== 'no';
713
+ } finally {
714
+ rl.close();
715
+ }
716
+ }
717
+
718
+ // The two trees Hedgehog itself puts in a project that are not project
719
+ // code: `.hedgehog/` (generated state, and the home of the CodeGraphContext
720
+ // virtualenv the setup skill builds) and `vendor-skills/` (the vendored
721
+ // BMAD planning shelf installed just above). CGC's own default `.cgcignore`
722
+ // covers the usual dependency directories and neither of these, so left
723
+ // alone the index walks several thousand files of CGC's dependency tree and
724
+ // of vendored shelf content — which then outrank the project's real symbols
725
+ // in lookups, and are what pre-read context and verify_radius gaps get
726
+ // computed against.
727
+ //
728
+ // Written at `init`, before any index exists, because `cgc index .` skips a
729
+ // repository that is already indexed: an exclusion added after the first
730
+ // index buys nothing until someone re-runs with `--force`.
731
+ //
732
+ // Appends only, and only what is missing: a project's own entries are its
733
+ // own, and this is the one file here Hedgehog shares with the user.
734
+ const CGCIGNORE_ENTRIES = ['.hedgehog/', 'vendor-skills/'];
735
+
736
+ async function ensureCgcignore(root = DEST_ROOT) {
737
+ const path = join(root, '.cgcignore');
738
+ let existing = '';
739
+ try {
740
+ existing = await readFile(path, 'utf8');
741
+ } catch {
742
+ // No file yet — the whole set gets written below.
743
+ }
744
+
745
+ const lines = existing.split('\n').map((l) => l.trim());
746
+ const missing = CGCIGNORE_ENTRIES.filter((e) => !lines.includes(e));
747
+ if (missing.length === 0) return { path, added: [] };
748
+
749
+ const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n';
750
+ await writeFile(path, `${existing}${prefix}${missing.join('\n')}\n`);
751
+ return { path, added: missing };
752
+ }
753
+
754
+ // The build graph and its sidecars are derived state, rebuildable from
755
+ // committed intents and git history via `hedgehog db rebuild`, and the
756
+ // CLAUDE.md this installer writes says so. `init`'s own closing step tells
757
+ // the user to `git add -A`, so anything not ignored by then gets committed.
758
+ //
759
+ // A core's own `gitignore.template` carries these lines, but a deferred
760
+ // install copies no core, and the CGC virtualenv is the engine's to ignore
761
+ // on every install — so the engine owns this block rather than leaving it
762
+ // to whichever core arrives later.
763
+ //
764
+ // Appends only, and only what is missing, for the same reason `.cgcignore`
765
+ // does: the project's `.gitignore` is the project's.
766
+ const GITIGNORE_ENTRIES = [
767
+ '.hedgehog/graph-server.json',
768
+ '.hedgehog/hedgehog.db',
769
+ '.hedgehog/hedgehog.db-*',
770
+ '.hedgehog/commit.lock',
771
+ '.hedgehog/code-intelligence/',
772
+ ];
773
+
774
+ async function ensureGitignore(root = DEST_ROOT) {
775
+ const path = join(root, '.gitignore');
776
+ let existing = '';
777
+ try {
778
+ existing = await readFile(path, 'utf8');
779
+ } catch {
780
+ // No file yet — the whole set gets written below.
781
+ }
782
+
783
+ const lines = existing.split('\n').map((l) => l.trim());
784
+ const missing = GITIGNORE_ENTRIES.filter((e) => !lines.includes(e));
785
+ if (missing.length === 0) return { path, added: [] };
786
+
787
+ const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n';
788
+ await writeFile(path, `${existing}${prefix}${missing.join('\n')}\n`);
789
+ return { path, added: missing };
790
+ }
791
+
621
792
  // `core` is the fetched core — `{ manifest, root, version }` — or null on
622
793
  // a deferred install, where planner picks one later.
623
794
  async function init({ force, core, host = DEFAULT_HOST, hostOnly = false, globalInstall = null }) {
795
+ if (!(await ensureCodeIntelligence())) return;
796
+
624
797
  ensureGitRepo();
625
798
 
626
799
  // Resolve the full list of writes up front so we can detect conflicts
@@ -679,6 +852,16 @@ async function init({ force, core, host = DEFAULT_HOST, hostOnly = false, global
679
852
  console.log(` ${dbCreated ? green('create') : dim('exists')} ${dbPath}`);
680
853
  if (dbCreated) written++;
681
854
 
855
+ const cgcignore = await ensureCgcignore();
856
+ if (cgcignore.added.length) {
857
+ console.log(` ${green('update')} ${relative(DEST_ROOT, cgcignore.path)} ${dim(`(${cgcignore.added.join(', ')})`)}`);
858
+ }
859
+
860
+ const gitignore = await ensureGitignore();
861
+ if (gitignore.added.length) {
862
+ console.log(` ${green('update')} ${relative(DEST_ROOT, gitignore.path)} ${dim(`(${gitignore.added.length} entries)`)}`);
863
+ }
864
+
682
865
  console.log(
683
866
  `\n${green(bold('Hedgehog installed.'))} ${dim(
684
867
  `${written} created${overwritten ? `, ${overwritten} overwritten` : ''}`,
@@ -767,6 +950,7 @@ async function init({ force, core, host = DEFAULT_HOST, hostOnly = false, global
767
950
  if (core) {
768
951
  console.log(dim(`Core: ${bold(core.manifest.name)} ${dim(`(${core.version})`)}.`));
769
952
  console.log(dim('bootstrap runs whichever add-on steps this core defines, if any.'));
953
+ if (core.engineAdvisory) console.log(`\n${yellow(core.engineAdvisory)}`);
770
954
  } else {
771
955
  console.log(
772
956
  dim(
@@ -851,6 +1035,21 @@ async function update({ hosts }) {
851
1035
  }
852
1036
  }
853
1037
 
1038
+ // Backfills a project installed before these entries existed. Cheap and
1039
+ // idempotent, and the index is only rebuilt on the next refresh anyway,
1040
+ // so this is the moment the exclusions cost nothing to add.
1041
+ const cgcignore = await ensureCgcignore();
1042
+ if (cgcignore.added.length) {
1043
+ written++;
1044
+ console.log(` ${green('update')} ${relative(DEST_ROOT, cgcignore.path)} ${dim(`(${cgcignore.added.join(', ')})`)}`);
1045
+ }
1046
+
1047
+ const gitignore = await ensureGitignore();
1048
+ if (gitignore.added.length) {
1049
+ written++;
1050
+ console.log(` ${green('update')} ${relative(DEST_ROOT, gitignore.path)} ${dim(`(${gitignore.added.length} entries)`)}`);
1051
+ }
1052
+
854
1053
  // Stamped after the writes land, so the recorded version always
855
1054
  // describes the payload actually on disk.
856
1055
  const previous = await installedVersion(DEST_ROOT);
@@ -893,6 +1092,12 @@ async function update({ hosts }) {
893
1092
  'name-based dispatch reports it as not found.',
894
1093
  ),
895
1094
  );
1095
+
1096
+ // Last, after the refresh has fully landed and reported itself. The
1097
+ // notice never gates the update: a project that predates the check
1098
+ // updates exactly as it always did and hears about the gap while it
1099
+ // does.
1100
+ await noteCodeIntelligenceGap();
896
1101
  }
897
1102
 
898
1103
  // Returned by resolveInstalledCore when the project has a core but its
@@ -972,7 +1177,10 @@ async function dbRebuildCommand() {
972
1177
  const corePath = await resolveCorePath();
973
1178
  if (!corePath) {
974
1179
  console.error(
975
- `${red('No core definition found.')} Expected ${bold(AUTHORED_CORE_PATH)} or a root ${bold('core.yaml')} (from \`hedgehog init\`).\n`,
1180
+ `${red('No core definition found.')} Expected ${bold(AUTHORED_CORE_PATH)} or a root ${bold('core.yaml')}.\n` +
1181
+ `A no-flag \`hedgehog init\` installs no core — planner picks one at planning\n` +
1182
+ `intake. Install one now with \`hedgehog init --core <name>\` (see \`hedgehog\n` +
1183
+ `cores list\`), or run planning intake to have it chosen for you.\n`,
976
1184
  );
977
1185
  process.exitCode = 1;
978
1186
  return;
@@ -1015,6 +1223,14 @@ async function dbCommand(args) {
1015
1223
  ? ` ${green('create')} ${path}`
1016
1224
  : ` ${dim('exists')} ${path} ${dim('(no-op)')}`,
1017
1225
  );
1226
+ if (!created) {
1227
+ console.log(
1228
+ `\n ${dim('This only ensures the schema is present — it does not reset state.')}\n` +
1229
+ ` ${dim('If you deleted a stale hedgehog.db expecting a clean graph and still')}\n` +
1230
+ ` ${dim('see old intents/tasks, the file was not actually gone when this ran.')}\n` +
1231
+ ` ${dim('Run')} ${bold('hedgehog db rebuild')} ${dim('to replay strictly from')} ${bold(INTENTS_DIR)}${dim('.')}\n`,
1232
+ );
1233
+ }
1018
1234
  }
1019
1235
 
1020
1236
  // Resolves the project's core definition: an authored .hedgehog/core.yaml
@@ -1103,6 +1319,205 @@ async function planRecompileCommand(args, { core, corePath }) {
1103
1319
  if (strict && result.skipped.length > 0) process.exitCode = 1;
1104
1320
  }
1105
1321
 
1322
+ // Reads .hedgehog/code-intelligence.json. Absent, unreadable, or
1323
+ // unparseable all mean the same thing: no config, no provider — every
1324
+ // existing project (no such file) takes this branch and plan behaves
1325
+ // exactly as it did before this feature existed.
1326
+ async function loadCodeIntelligenceConfig() {
1327
+ try {
1328
+ const raw = await readFile(CODE_INTELLIGENCE_CONFIG_PATH, 'utf8');
1329
+ const parsed = JSON.parse(raw);
1330
+ if (!parsed || typeof parsed !== 'object') return null;
1331
+ if (typeof parsed.command !== 'string' || parsed.command === '') return null;
1332
+ return parsed;
1333
+ } catch {
1334
+ return null;
1335
+ }
1336
+ }
1337
+
1338
+ // The re-index command to print in a staleness notice, using the binary
1339
+ // this project actually configured rather than a bare `cgc` the user may
1340
+ // not have on PATH — setup installs into a project-owned environment
1341
+ // precisely so PATH stays untouched, so a generic hint would be wrong for
1342
+ // exactly the installs Hedgehog creates.
1343
+ //
1344
+ // `cgc index . --force` is the refresh path: a bare `cgc index .` skips
1345
+ // with "already indexed" and exit 0 once a repository has been indexed
1346
+ // once, which is exactly the situation a staleness notice is printed in,
1347
+ // so the flag is what makes the printed command actually do the thing
1348
+ // the notice asks for.
1349
+ // Relativized only when the binary actually sits inside the project (the
1350
+ // isolated install this setup creates), since that is the form a user
1351
+ // would type. A path outside it stays absolute: `../../../usr/local/bin/cgc`
1352
+ // is technically correct and useless to read.
1353
+ async function indexCommandHint() {
1354
+ const config = await loadCodeIntelligenceConfig();
1355
+ const command = config?.command;
1356
+ if (typeof command !== 'string' || command === '') return 'cgc index . --force';
1357
+
1358
+ const rel = relative(process.cwd(), command);
1359
+ const inProject = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
1360
+ return `${inProject ? rel : command} index . --force`;
1361
+ }
1362
+
1363
+ // A minimal MCP stdio client: spawns the server the config names, speaks
1364
+ // just enough JSON-RPC to initialize and call a tool by name. This is
1365
+ // the only piece that knows the wire protocol — resolveTaskContext
1366
+ // (code-intelligence.mjs) only ever calls the two methods below.
1367
+ //
1368
+ // No SDK dependency: the package carries none, and the two calls this
1369
+ // needs are a handful of JSON-RPC messages over stdio, not a reason to
1370
+ // add one.
1371
+ function startMcpClient(config) {
1372
+ const child = spawn(config.command, config.args ?? [], {
1373
+ stdio: ['pipe', 'pipe', 'ignore'],
1374
+ env: { ...process.env, ...(config.env ?? {}) },
1375
+ });
1376
+ child.unref?.();
1377
+
1378
+ let buffer = '';
1379
+ let nextId = 1;
1380
+ const pending = new Map();
1381
+ // 'error'/'exit' each fire at most once in a child's lifecycle — a
1382
+ // spawn failure (bad command, ENOENT) fires 'error' for whatever is
1383
+ // pending at that moment and then never again, so a call placed after
1384
+ // that point would otherwise sit unrejected until code-intelligence.mjs's
1385
+ // own per-task timeout finally gives up on it. Recording the failure
1386
+ // here lets every later call reject immediately instead.
1387
+ let dead = null;
1388
+
1389
+ child.stdout.setEncoding('utf8');
1390
+ child.stdout.on('data', (chunk) => {
1391
+ buffer += chunk;
1392
+ let newlineAt;
1393
+ while ((newlineAt = buffer.indexOf('\n')) !== -1) {
1394
+ const line = buffer.slice(0, newlineAt);
1395
+ buffer = buffer.slice(newlineAt + 1);
1396
+ if (!line.trim()) continue;
1397
+ let message;
1398
+ try {
1399
+ message = JSON.parse(line);
1400
+ } catch {
1401
+ continue;
1402
+ }
1403
+ const waiter = pending.get(message.id);
1404
+ if (!waiter) continue;
1405
+ pending.delete(message.id);
1406
+ if (message.error) waiter.reject(new Error(message.error.message ?? 'MCP error'));
1407
+ else waiter.resolve(message.result);
1408
+ }
1409
+ });
1410
+ child.on('error', (err) => {
1411
+ dead = err;
1412
+ for (const { reject } of pending.values()) reject(err);
1413
+ pending.clear();
1414
+ });
1415
+ // A dead server's stdin emits EPIPE on the socket, not on the child,
1416
+ // and an 'error' event with no listener is fatal to the process. The
1417
+ // write below is guarded, but a socket reports EPIPE asynchronously,
1418
+ // so the throw never reaches that try/catch. Without this handler a
1419
+ // server that exits mid-session takes the whole command down; with it
1420
+ // the pending calls reject and the caller degrades as it would for any
1421
+ // other provider failure.
1422
+ child.stdin.on('error', (err) => {
1423
+ dead ??= err;
1424
+ for (const { reject } of pending.values()) reject(dead);
1425
+ pending.clear();
1426
+ });
1427
+ child.on('exit', () => {
1428
+ dead ??= new Error('code-intelligence server exited');
1429
+ for (const { reject } of pending.values()) reject(dead);
1430
+ pending.clear();
1431
+ });
1432
+
1433
+ function call(method, params) {
1434
+ if (dead) return Promise.reject(dead);
1435
+ return new Promise((resolve, reject) => {
1436
+ const id = nextId++;
1437
+ pending.set(id, { resolve, reject });
1438
+ try {
1439
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
1440
+ } catch (err) {
1441
+ pending.delete(id);
1442
+ reject(err);
1443
+ }
1444
+ });
1445
+ }
1446
+
1447
+ function callTool(name, args) {
1448
+ return call('tools/call', { name, arguments: args }).then((result) => result?.structuredContent ?? result);
1449
+ }
1450
+
1451
+ return {
1452
+ close: () => child.kill(),
1453
+ execute_cypher_query: (args) => callTool('execute_cypher_query', args),
1454
+ analyze_code_relationships: (args) => callTool('analyze_code_relationships', args),
1455
+ };
1456
+ }
1457
+
1458
+ // Every task about to be compiled by the plan run that's about to
1459
+ // happen, shaped down to what resolveTaskContext actually reads
1460
+ // (task.id, task.scope_globs). Mirrors compileIntentTasks/
1461
+ // compileOnceTasks (plan.mjs) using their own exported field
1462
+ // functions — taskId/onceTaskId/layerTaskFields/onceLayerTaskFields —
1463
+ // rather than reimplementing them, so this can't compute a scope
1464
+ // differently than plan.mjs itself will. Not filtered by taskExists:
1465
+ // a task that turns out already-compiled just resolves an entry
1466
+ // nobody looks up, which costs nothing.
1467
+ function candidateTasks(db, core, overrides) {
1468
+ const candidates = [];
1469
+
1470
+ for (const layer of core.layers.filter((l) => l.once)) {
1471
+ const fields = onceLayerTaskFields(layer, overrides);
1472
+ candidates.push({ id: onceTaskId(layer.id), scope_globs: fields.scope_globs });
1473
+ }
1474
+
1475
+ const placeholders = PENDING_INTENT_STATUSES.map(() => '?').join(',');
1476
+ const pendingIntents = db
1477
+ .prepare(`SELECT id FROM intents WHERE status IN (${placeholders})`)
1478
+ .all(...PENDING_INTENT_STATUSES);
1479
+ const perIntentLayers = core.layers.filter((l) => !l.once);
1480
+ for (const { id: intentId } of pendingIntents) {
1481
+ for (const layer of perIntentLayers) {
1482
+ const fields = layerTaskFields(layer, intentId, overrides);
1483
+ candidates.push({ id: taskId(intentId, layer.id), scope_globs: fields.scope_globs });
1484
+ }
1485
+ }
1486
+
1487
+ return candidates;
1488
+ }
1489
+
1490
+ // Builds the provider planTasks receives: a synchronous
1491
+ // resolveTaskContext(task) that looks up a Map populated by awaiting
1492
+ // code-intelligence.mjs's own async resolveTaskContext for every
1493
+ // candidate task, ahead of time. planTasks (plan.mjs) calls this
1494
+ // synchronously inside a BEGIN IMMEDIATE transaction and must stay
1495
+ // synchronous itself — so all the awaiting happens here, before that
1496
+ // transaction opens, never inside it.
1497
+ //
1498
+ // Returns { provider } — null when there's no config, the server can't
1499
+ // be reached, or anything about the walk fails; a plan run degrades to
1500
+ // the no-provider path rather than failing.
1501
+ async function buildCodeIntelligenceProvider(db, core, overrides) {
1502
+ const config = await loadCodeIntelligenceConfig();
1503
+ if (!config) return { provider: null };
1504
+
1505
+ const client = startMcpClient(config);
1506
+ const results = new Map();
1507
+ try {
1508
+ for (const task of candidateTasks(db, core, overrides)) {
1509
+ const context = await resolveTaskContext(task, client);
1510
+ results.set(task.id, context);
1511
+ }
1512
+ } catch {
1513
+ return { provider: null };
1514
+ } finally {
1515
+ client.close();
1516
+ }
1517
+
1518
+ return { provider: { resolveTaskContext: (task) => results.get(task.id) ?? null } };
1519
+ }
1520
+
1106
1521
  // `hedgehog plan [--open|--no-open]` — compiles pending intents.
1107
1522
  //
1108
1523
  // Starting the live graph server is opt-in (`--open`), not automatic.
@@ -1134,7 +1549,10 @@ async function planCommand(args = []) {
1134
1549
  const corePath = await resolveCorePath();
1135
1550
  if (!corePath) {
1136
1551
  console.error(
1137
- `${red('No core definition found.')} Expected ${bold(AUTHORED_CORE_PATH)} or a root ${bold('core.yaml')} (from \`hedgehog init\`).\n`,
1552
+ `${red('No core definition found.')} Expected ${bold(AUTHORED_CORE_PATH)} or a root ${bold('core.yaml')}.\n` +
1553
+ `A no-flag \`hedgehog init\` installs no core — planner picks one at planning\n` +
1554
+ `intake. Install one now with \`hedgehog init --core <name>\` (see \`hedgehog\n` +
1555
+ `cores list\`), or run planning intake to have it chosen for you.\n`,
1138
1556
  );
1139
1557
  process.exitCode = 1;
1140
1558
  return;
@@ -1155,10 +1573,37 @@ async function planCommand(args = []) {
1155
1573
  }
1156
1574
 
1157
1575
  const overrides = await loadOverrides();
1576
+
1577
+ // Plan is the one command whose output is actually drawn from the index
1578
+ // — pre-read context and radius suggestions both — so it is where a
1579
+ // stale index stops being trivia and starts being wrong answers. Said
1580
+ // before the walk rather than after, so the caveat arrives ahead of the
1581
+ // results it qualifies. Advisory: a stale index still plans, because a
1582
+ // refusal here would strand a project mid-build behind a re-index.
1583
+ const freshness = await checkIndexFreshness({ cwd: process.cwd() });
1584
+ const staleness = formatIndexStaleness(freshness, {
1585
+ indexCommand: await indexCommandHint(),
1586
+ });
1587
+ if (staleness.length > 0) {
1588
+ console.log(`\n${yellow(bold(staleness[0]))}`);
1589
+ console.log(staleness.slice(1).join('\n'));
1590
+ console.log('');
1591
+ }
1592
+
1593
+ // Pre-resolved read-only, before the write handle below opens
1594
+ // planTasks's BEGIN IMMEDIATE — see buildCodeIntelligenceProvider.
1595
+ const readDb = openDb({ readOnly: true });
1596
+ let codeIntelligence;
1597
+ try {
1598
+ codeIntelligence = await buildCodeIntelligenceProvider(readDb, core, overrides);
1599
+ } finally {
1600
+ readDb.close();
1601
+ }
1602
+
1158
1603
  const db = openDb();
1159
1604
  let result;
1160
1605
  try {
1161
- result = planTasks(db, core, overrides);
1606
+ result = planTasks(db, core, overrides, { provider: codeIntelligence.provider });
1162
1607
  } finally {
1163
1608
  db.close();
1164
1609
  }
@@ -1173,6 +1618,30 @@ async function planCommand(args = []) {
1173
1618
  ` ${bold('reopened')} ${id} ${dim('(once — new scope landed under it; it must run again)')}`,
1174
1619
  );
1175
1620
  }
1621
+ // One line per task actually inserted this run, naming whether the
1622
+ // pre-resolve found context for it. Silent when there's no provider —
1623
+ // an absent config file must print nothing extra at all.
1624
+ //
1625
+ // result.once already holds task ids; result.compiled holds intent
1626
+ // ids, so each expands to that intent's per-layer task ids the same
1627
+ // way taskId(intentId, layer.id) names them everywhere else.
1628
+ if (codeIntelligence.provider) {
1629
+ const perIntentLayers = core.layers.filter((l) => !l.once);
1630
+ const compiledTaskIds = [
1631
+ ...result.once,
1632
+ ...result.compiled.flatMap((intentId) =>
1633
+ perIntentLayers.map((layer) => taskId(intentId, layer.id)),
1634
+ ),
1635
+ ];
1636
+ for (const id of compiledTaskIds) {
1637
+ const resolved = Boolean(codeIntelligence.provider.resolveTaskContext({ id }));
1638
+ console.log(
1639
+ resolved
1640
+ ? ` ${dim('context')} ${id} ${dim('resolved against the code-intelligence index')}`
1641
+ : ` ${dim('context')} ${id} ${dim('no context resolved')}`,
1642
+ );
1643
+ }
1644
+ }
1176
1645
  console.log(
1177
1646
  `\n${green(bold('Plan complete.'))} ${dim(`${result.compiled.length} intent(s) compiled, ${result.skipped.length} skipped`)}\n`,
1178
1647
  );
@@ -1183,9 +1652,29 @@ async function planCommand(args = []) {
1183
1652
  // letting the run report "0 compiled" and look like a no-op.
1184
1653
  const driftDb = openDb({ readOnly: true });
1185
1654
  let drifted;
1655
+ let radiusSuggestions = [];
1186
1656
  try {
1187
1657
  warnSingularModuleIdsAtPlan(core, driftDb);
1188
1658
  drifted = detectDrift(driftDb, core, { overrides });
1659
+ // Advisory only, same handle as the drift check above — a suggestion
1660
+ // sourced from a code-intelligence index that may be stale, never a
1661
+ // reason to fail this command. Skipped entirely for a task with no
1662
+ // resolved context_files, same as every other radiusGaps caller.
1663
+ // The read is guarded on its own: this handle is read-only and so
1664
+ // never migrates, and a graph predating the context columns throws
1665
+ // here on a column that isn't there. A plan run that compiled fine
1666
+ // must not fail on an advisory read.
1667
+ try {
1668
+ radiusSuggestions = driftDb
1669
+ .prepare('SELECT * FROM tasks WHERE context_files IS NOT NULL')
1670
+ .all()
1671
+ .flatMap((task) => {
1672
+ const files = radiusGaps(task);
1673
+ return files.length > 0 ? [{ taskId: task.id, files }] : [];
1674
+ });
1675
+ } catch {
1676
+ radiusSuggestions = [];
1677
+ }
1189
1678
  } finally {
1190
1679
  driftDb.close();
1191
1680
  }
@@ -1196,6 +1685,12 @@ async function planCommand(args = []) {
1196
1685
  `or ${bold('hedgehog plan --recompile')} to rewrite the not-started ones.\n`,
1197
1686
  );
1198
1687
  }
1688
+ if (radiusSuggestions.length > 0) {
1689
+ console.log(
1690
+ `${dim('Radius suggestions.')} ${radiusSuggestions.length} task(s) reach files outside their verify_radius, per the code-intelligence index.\n` +
1691
+ `Advisory only — verify still gates on verify_radius/scope_globs alone. Run ${bold('hedgehog status')} to see them.\n`,
1692
+ );
1693
+ }
1199
1694
 
1200
1695
  // Only worth opening when this run actually changed the graph's shape
1201
1696
  // — a plan run that compiled nothing (every intent already had tasks)
@@ -1470,6 +1965,70 @@ async function noteAvailableUpdate() {
1470
1965
  }
1471
1966
  }
1472
1967
 
1968
+ // The upgrade path for a project that predates the `init`-time check —
1969
+ // which is every project installed before code intelligence became a
1970
+ // precondition, and the larger population by far. `init` guarantees a
1971
+ // project set up from this version onward has it; nothing retroactively
1972
+ // requires it of a project already building.
1973
+ //
1974
+ // So this is advisory, and that is the whole point: it prints after the
1975
+ // calling command's own work has finished and returns without ever
1976
+ // touching `process.exitCode`. A project whose check fails keeps
1977
+ // updating and keeps reporting status exactly as it did before, and
1978
+ // hears what it is missing while it does. Blocking here would strand
1979
+ // working projects on old payloads, which costs more than the gap it
1980
+ // would be enforcing.
1981
+ //
1982
+ // Renders the gap from formatCodeIntelligenceGap so the CLI, the setup
1983
+ // skill, and the README all say the same thing, and prints to stderr
1984
+ // after the command's real output, matching noteAvailableUpdate's own
1985
+ // contract. Never throws: the whole body is wrapped, since a check that
1986
+ // errors is never worth failing a command over.
1987
+ //
1988
+ // `once` gates on `.hedgehog/community.json` — used by `status`, which
1989
+ // runs at the start of every session and would otherwise nag. `update`
1990
+ // passes it off: `update` is run deliberately and rarely, and is the
1991
+ // command whose whole job is bringing a project current, so the gap
1992
+ // belongs in its output every time.
1993
+ //
1994
+ // Returns whether a gap was actually printed, so a caller that wants to
1995
+ // say the opposite — `status` with no build graph, which is where the
1996
+ // setup skill verifies — can tell silence-because-fine from
1997
+ // silence-because-`once`.
1998
+ async function noteCodeIntelligenceGap({ once = false } = {}) {
1999
+ try {
2000
+ const result = await checkCodeIntelligence({ cwd: DEST_ROOT });
2001
+ if (result.ok) return false;
2002
+ if (once && !(await shouldNoteCodeIntelligence(DEST_ROOT))) return false;
2003
+
2004
+ const gap = formatCodeIntelligenceGap(result);
2005
+ console.error(`\n${yellow(bold(gap[0]))}`);
2006
+ console.error(gap.slice(1).join('\n'));
2007
+
2008
+ if (once) await recordCodeIntelligenceNotice(DEST_ROOT);
2009
+ return true;
2010
+ } catch {
2011
+ // Advisory only — a failed check is never worth failing a command over.
2012
+ return false;
2013
+ }
2014
+ }
2015
+
2016
+ // Whether two paths name the same directory once symlinks are followed.
2017
+ // `npm link` installs the global package as a symlink to a working tree,
2018
+ // so comparing the resolved-but-not-dereferenced paths reports "not
2019
+ // global" for a linked checkout and `ensureGlobalInstall` then installs
2020
+ // the published release over the link — silently reverting whoever is
2021
+ // testing a branch. Compares real paths so a link counts as global.
2022
+ // Falls back to string equality when either side cannot be resolved (the
2023
+ // global root may legitimately not exist yet).
2024
+ function samePath(a, b) {
2025
+ try {
2026
+ return realpathSync(a) === realpathSync(b);
2027
+ } catch {
2028
+ return a === b;
2029
+ }
2030
+ }
2031
+
1473
2032
  // Hedgehog runs as a global install: every host (Claude Code, Cursor,
1474
2033
  // Gemini CLI) drives it through the `hedgehog` binary on PATH, and `npx
1475
2034
  // @skyf0xx/hedgehog` is expected to arrive at that same global install
@@ -1511,7 +2070,7 @@ async function ensureGlobalInstall({ quiet = false } = {}) {
1511
2070
  encoding: 'utf8',
1512
2071
  timeout: 5_000,
1513
2072
  }).trim();
1514
- const runningFromGlobal = PKG_ROOT === join(globalRoot, '@skyf0xx/hedgehog');
2073
+ const runningFromGlobal = samePath(PKG_ROOT, join(globalRoot, '@skyf0xx/hedgehog'));
1515
2074
  const { latest, stale } = await checkBinaryStaleness(DEST_ROOT, PKG_VERSION);
1516
2075
  if (runningFromGlobal && !stale) return { updated: false };
1517
2076
  if (!latest) return { updated: false };
@@ -2269,7 +2828,19 @@ async function statusCommand() {
2269
2828
  await ensureDb();
2270
2829
 
2271
2830
  if (!(await exists(DB_PATH))) {
2272
- console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
2831
+ // The code-intelligence check runs ahead of this guard rather than
2832
+ // after it. `status` is what the setup skill verifies against, and
2833
+ // that verification happens in the one situation where no graph
2834
+ // exists yet: setting code intelligence up is what `init` gated on,
2835
+ // so `init` never got far enough to create one. Reporting the graph
2836
+ // as missing and stopping would leave that check unanswered at
2837
+ // exactly the point it is asked.
2838
+ if (!(await noteCodeIntelligenceGap())) {
2839
+ console.error(`${green('Code intelligence is set up.')}`);
2840
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog init')} first.\n`);
2841
+ } else {
2842
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
2843
+ }
2273
2844
  process.exitCode = 1;
2274
2845
  return;
2275
2846
  }
@@ -2335,6 +2906,15 @@ async function statusCommand() {
2335
2906
  // nothing to check and reports nothing here either.
2336
2907
  if (core) result.missingRequirements = coreMissingRequirements(core);
2337
2908
 
2909
+ // Same reasoning as missingRequirements above: status is what a fresh
2910
+ // session runs first, and an index built ten commits ago is a setup
2911
+ // fact best learned there rather than inferred later from context that
2912
+ // quietly names the wrong symbols.
2913
+ result.indexStaleness = formatIndexStaleness(
2914
+ await checkIndexFreshness({ cwd: process.cwd() }),
2915
+ { indexCommand: await indexCommandHint() },
2916
+ );
2917
+
2338
2918
  console.log(formatStatus(result));
2339
2919
 
2340
2920
  // Whether the commit gate is actually enforcing. This is reported at
@@ -2352,6 +2932,7 @@ async function statusCommand() {
2352
2932
  const warningLines = await coreWarningLines();
2353
2933
  if (warningLines.length > 0) console.log(warningLines.join('\n'));
2354
2934
 
2935
+ await noteCodeIntelligenceGap({ once: true });
2355
2936
  await noteAvailableUpdate();
2356
2937
  }
2357
2938
 
@@ -2965,16 +3546,29 @@ async function coresCommand(args) {
2965
3546
  const active = await installedCore(DEST_ROOT);
2966
3547
  for (const core of await loadRegistry()) {
2967
3548
  const cached = await cachedVersions(core.name);
3549
+ const engine = await cachedEngine(core.name, cached);
2968
3550
  const marker = active?.name === core.name ? green(' (installed)') : '';
2969
3551
  console.log(`${bold(core.name)}${marker}`);
2970
3552
  console.log(` ${dim('package')} ${core.package}@${core.version}`);
2971
3553
  console.log(` ${dim('flag')} ${core.flag ?? dim('none — chosen at planning intake')}`);
2972
3554
  console.log(` ${dim('cached')} ${cached.length ? cached.join(', ') : dim('not fetched')}`);
3555
+ console.log(` ${dim('engine')} ${engineNote(engine)}`);
2973
3556
  console.log(` ${dim('when')} ${wrapProse(core.selects_when, 68, ' ')}`);
2974
3557
  console.log('');
2975
3558
  }
2976
3559
  }
2977
3560
 
3561
+ // The engine line a core declares, next to the CLI reading it. A core
3562
+ // written against an older line still installs — the engine carries what
3563
+ // it asks for — so this reads as context, not as a refusal.
3564
+ function engineNote(engine) {
3565
+ if (!engine) return dim(`unknown until fetched — CLI is ${PKG_VERSION}`);
3566
+ const wantMajor = Number(String(engine).replace(/^\^/, '').split('.')[0]);
3567
+ const haveMajor = Number(String(PKG_VERSION).split('.')[0]);
3568
+ if (haveMajor > wantMajor) return `${engine} ${yellow(`— CLI is ${PKG_VERSION}, ahead of this core`)}`;
3569
+ return `${engine} ${dim(`— CLI is ${PKG_VERSION}`)}`;
3570
+ }
3571
+
2978
3572
  // Wraps prose to `width`, indenting every line after the first so it sits
2979
3573
  // under the column its label opened.
2980
3574
  function wrapProse(text, width, indent) {