@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/README.md +37 -10
- package/README.zh-CN.md +35 -7
- package/bin/cli.mjs +604 -10
- package/package.json +3 -2
- package/src/agents/bootstrap.md +10 -5
- package/src/agents/planner.md +19 -9
- package/src/agents/tweaker.md +10 -2
- package/src/db/code-intelligence-requires.mjs +296 -0
- package/src/db/code-intelligence.mjs +291 -0
- package/src/db/community.mjs +26 -1
- package/src/db/core.mjs +3 -2
- package/src/db/drift.mjs +15 -0
- package/src/db/friction.mjs +75 -0
- package/src/db/gate.mjs +1 -1
- package/src/db/next.mjs +39 -2
- package/src/db/plan.mjs +60 -4
- package/src/db/rebuild.mjs +7 -0
- package/src/db/schema.mjs +6 -1
- package/src/db/status.mjs +108 -9
- package/src/hosts/gemini/gemini-extension.json +1 -1
- package/src/hosts/version.mjs +6 -3
- package/src/registry/fetch.mjs +43 -10
- package/src/registry/manifest.mjs +23 -8
- package/src/skills/hedgehog-code-intelligence-setup/SKILL.md +586 -0
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 {
|
|
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
|
-
|
|
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')}
|
|
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')}
|
|
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
|
|
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
|
-
|
|
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) {
|