@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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Pre-computed task context: the symbols and files a task's scope
|
|
2
|
+
// actually touches, resolved from a code-intelligence index at compile
|
|
3
|
+
// time and written onto the task row by plan.mjs.
|
|
4
|
+
//
|
|
5
|
+
// This is the only file in the build graph that knows the shape of the
|
|
6
|
+
// CodeGraphContext calls. It imports nothing from CGC — the `provider`
|
|
7
|
+
// is injected by the caller (bin/cli.mjs builds it, rebuild.mjs and
|
|
8
|
+
// every repro harness pass null or a fake), so the graph keeps
|
|
9
|
+
// compiling with no index present and no dependency to install.
|
|
10
|
+
//
|
|
11
|
+
// The index the calls read models a repo as: Repository(path, name),
|
|
12
|
+
// File(path, language, hash), Module(name), Class(name, path,
|
|
13
|
+
// start_line, end_line), Function(name, path, signature, docstring,
|
|
14
|
+
// complexity, start_line, end_line); joined by CONTAINS, IMPORTS,
|
|
15
|
+
// CALLS, INHERITS, IMPLEMENTS. File.path and Function.path are what map
|
|
16
|
+
// a symbol back onto a Hedgehog scope glob.
|
|
17
|
+
//
|
|
18
|
+
// Failure is always null, never a throw. Resolution runs inside
|
|
19
|
+
// plan.mjs's BEGIN IMMEDIATE transaction, where an escaping error rolls
|
|
20
|
+
// the whole plan back and a hang holds a write lock — so a missing,
|
|
21
|
+
// slow, or broken index costs a task its PRE-READ section and nothing
|
|
22
|
+
// more.
|
|
23
|
+
//
|
|
24
|
+
// CGC is mandatory at `init`, so a working index is the ordinary case,
|
|
25
|
+
// not this one. Null-everywhere is what an install that worked and
|
|
26
|
+
// later broke falls back to: the environment deleted by hand, disk full
|
|
27
|
+
// mid-reindex, the MCP subprocess crashed, or `.hedgehog/` copied to a
|
|
28
|
+
// machine that never ran `init`. Mandatory-at-`init` guarantees the
|
|
29
|
+
// moment `init` succeeds; it is not an invariant enforced for the rest
|
|
30
|
+
// of the project's life, and this module is what absorbs the gap.
|
|
31
|
+
|
|
32
|
+
import { matchesGlob } from './core.mjs';
|
|
33
|
+
|
|
34
|
+
// How many symbols and files a single task row may carry. A row long
|
|
35
|
+
// enough to be worth skimming is the point; a 500-symbol row bloats
|
|
36
|
+
// every packet that reads it and gets skipped whole.
|
|
37
|
+
export const MAX_SYMBOLS = 40;
|
|
38
|
+
export const MAX_FILES = 30;
|
|
39
|
+
|
|
40
|
+
// Transitive-caller depth for the blast radius. Deep enough to reach
|
|
41
|
+
// past a task's own layer, shallow enough that a hub function doesn't
|
|
42
|
+
// pull in the repo.
|
|
43
|
+
export const CALLER_DEPTH = 3;
|
|
44
|
+
|
|
45
|
+
// Wall-clock budget for the whole walk, seed query and every caller
|
|
46
|
+
// call together. Spent, the walk returns whatever it has.
|
|
47
|
+
export const RESOLVE_TIMEOUT_MS = 10_000;
|
|
48
|
+
|
|
49
|
+
// How many seeds get a find_all_callers call. The seed set is already
|
|
50
|
+
// capped by the Cypher query's own LIMIT; this bounds the round trips.
|
|
51
|
+
const MAX_SEED_EXPANSIONS = 20;
|
|
52
|
+
|
|
53
|
+
// The kinds this module reads out of the index. Both carry name, path
|
|
54
|
+
// and start_line, which is the whole of what a symbol row holds.
|
|
55
|
+
const SYMBOL_KINDS = ['Function', 'Class'];
|
|
56
|
+
|
|
57
|
+
// A scope glob reduced to the literal directory prefix every path it
|
|
58
|
+
// matches must start with: segments up to the first one carrying a
|
|
59
|
+
// wildcard. `apps/{module}/**` has already had {module} filled by
|
|
60
|
+
// plan.mjs, so what arrives here is concrete. An empty prefix means the
|
|
61
|
+
// glob constrains nothing at the front and matches repo-wide.
|
|
62
|
+
export function globPrefix(glob) {
|
|
63
|
+
const segments = String(glob).split('/');
|
|
64
|
+
const literal = [];
|
|
65
|
+
for (const segment of segments) {
|
|
66
|
+
if (/[*?[]/.test(segment)) break;
|
|
67
|
+
if (segment !== '') literal.push(segment);
|
|
68
|
+
}
|
|
69
|
+
return literal.join('/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The distinct prefixes a task's scope reduces to. A repo-wide prefix
|
|
73
|
+
// ('') absorbs the rest — matching inside it is matching anywhere.
|
|
74
|
+
function scopePrefixes(scopeGlobs) {
|
|
75
|
+
const prefixes = new Set();
|
|
76
|
+
for (const glob of scopeGlobs) {
|
|
77
|
+
const prefix = globPrefix(glob);
|
|
78
|
+
if (prefix === '') return [''];
|
|
79
|
+
prefixes.add(prefix);
|
|
80
|
+
}
|
|
81
|
+
return [...prefixes];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// task.scope_globs is stored as a JSON string (plan.mjs#layerTaskFields).
|
|
85
|
+
// A row written by hand, or by an older compile, may hold something
|
|
86
|
+
// else — a parse failure is a task with no resolvable scope, not an
|
|
87
|
+
// error worth aborting a plan run for.
|
|
88
|
+
function parseScopeGlobs(task) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(task?.scope_globs ?? 'null');
|
|
91
|
+
if (!Array.isArray(parsed)) return [];
|
|
92
|
+
return parsed.filter((g) => typeof g === 'string' && g !== '');
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rejects the whole walk once the budget is spent. Racing each call
|
|
99
|
+
// against this bounds total wall clock rather than per-call latency, so
|
|
100
|
+
// twenty slow-but-finishing calls can't add up past the budget.
|
|
101
|
+
function withDeadline(promise, deadline) {
|
|
102
|
+
const remaining = deadline - Date.now();
|
|
103
|
+
if (remaining <= 0) return Promise.reject(new Error('code-intelligence timeout'));
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const timer = setTimeout(() => reject(new Error('code-intelligence timeout')), remaining);
|
|
106
|
+
Promise.resolve(promise).then(
|
|
107
|
+
(value) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve(value);
|
|
110
|
+
},
|
|
111
|
+
(error) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(error);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Providers hand results back in whatever envelope their transport
|
|
120
|
+
// used — a bare array, or one wrapped under `results`/`records`/`data`.
|
|
121
|
+
// Anything else reads as no rows.
|
|
122
|
+
function rowsOf(result) {
|
|
123
|
+
if (Array.isArray(result)) return result;
|
|
124
|
+
if (!result || typeof result !== 'object') return [];
|
|
125
|
+
for (const key of ['results', 'records', 'rows', 'data', 'nodes']) {
|
|
126
|
+
if (Array.isArray(result[key])) return result[key];
|
|
127
|
+
}
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One graph node reduced to the symbol shape a task row stores. The
|
|
132
|
+
// node may arrive as the row itself or under a `node`/`symbol` key, and
|
|
133
|
+
// its kind may come from a label list rather than a field.
|
|
134
|
+
function toSymbol(row) {
|
|
135
|
+
const node = row?.node ?? row?.symbol ?? row;
|
|
136
|
+
if (!node || typeof node !== 'object') return null;
|
|
137
|
+
|
|
138
|
+
const name = node.name ?? node.symbol ?? null;
|
|
139
|
+
const path = node.path ?? node.file ?? node.file_path ?? null;
|
|
140
|
+
if (typeof name !== 'string' || typeof path !== 'string') return null;
|
|
141
|
+
|
|
142
|
+
const labels = Array.isArray(node.labels) ? node.labels : [];
|
|
143
|
+
const kind =
|
|
144
|
+
node.kind ?? labels.find((label) => SYMBOL_KINDS.includes(label)) ?? labels[0] ?? 'Symbol';
|
|
145
|
+
|
|
146
|
+
const startLine = node.start_line ?? node.startLine ?? null;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
name,
|
|
150
|
+
kind: String(kind),
|
|
151
|
+
path,
|
|
152
|
+
start_line: typeof startLine === 'number' ? startLine : null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Seeds: every Function and Class the index has inside the task's own
|
|
157
|
+
// scope. Bound in the query rather than interpolated — `prefixes` comes
|
|
158
|
+
// off a task row, and the provider runs this against a live graph.
|
|
159
|
+
const SEED_CYPHER = `
|
|
160
|
+
MATCH (f:File)-[:CONTAINS]->(s)
|
|
161
|
+
WHERE (s:Function OR s:Class)
|
|
162
|
+
AND any(prefix IN $prefixes WHERE prefix = '' OR f.path STARTS WITH prefix)
|
|
163
|
+
RETURN s.name AS name, labels(s) AS labels, s.path AS path, s.start_line AS start_line
|
|
164
|
+
LIMIT $limit
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
// Resolves the symbols and files one task's scope reaches: the symbols
|
|
168
|
+
// declared inside its scope, plus the transitive callers of each — the
|
|
169
|
+
// blast radius a change inside that scope carries.
|
|
170
|
+
//
|
|
171
|
+
// Returns { symbols, files } — symbols as { name, kind, path,
|
|
172
|
+
// start_line }, files as repo-relative path strings, both capped — or
|
|
173
|
+
// null when there is no provider, no scope, no index content, or
|
|
174
|
+
// anything at all goes wrong.
|
|
175
|
+
export async function resolveTaskContext(task, provider) {
|
|
176
|
+
if (!provider || typeof provider.execute_cypher_query !== 'function') return null;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const deadline = Date.now() + RESOLVE_TIMEOUT_MS;
|
|
180
|
+
|
|
181
|
+
const scopeGlobs = parseScopeGlobs(task);
|
|
182
|
+
if (scopeGlobs.length === 0) return null;
|
|
183
|
+
const prefixes = scopePrefixes(scopeGlobs);
|
|
184
|
+
|
|
185
|
+
const seedResult = await withDeadline(
|
|
186
|
+
provider.execute_cypher_query({
|
|
187
|
+
cypher_query: SEED_CYPHER,
|
|
188
|
+
params: { prefixes, limit: MAX_SYMBOLS },
|
|
189
|
+
}),
|
|
190
|
+
deadline,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Keyed by name+path so the same symbol reached from two seeds
|
|
194
|
+
// lands once; insertion order keeps the in-scope seeds ahead of
|
|
195
|
+
// the callers they pulled in, which is the order the caps trim to.
|
|
196
|
+
const symbols = new Map();
|
|
197
|
+
const files = new Set();
|
|
198
|
+
|
|
199
|
+
const remember = (row) => {
|
|
200
|
+
const symbol = toSymbol(row);
|
|
201
|
+
if (!symbol) return null;
|
|
202
|
+
const key = `${symbol.path}::${symbol.name}`;
|
|
203
|
+
if (!symbols.has(key)) symbols.set(key, symbol);
|
|
204
|
+
files.add(symbol.path);
|
|
205
|
+
return symbol;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const seeds = [];
|
|
209
|
+
for (const row of rowsOf(seedResult)) {
|
|
210
|
+
const symbol = remember(row);
|
|
211
|
+
if (symbol) seeds.push(symbol);
|
|
212
|
+
}
|
|
213
|
+
if (seeds.length === 0) return null;
|
|
214
|
+
|
|
215
|
+
// Blast radius: one transitive-caller call per seed, each scoped by
|
|
216
|
+
// the seed's own file so a common name resolves to the right symbol.
|
|
217
|
+
if (typeof provider.analyze_code_relationships === 'function') {
|
|
218
|
+
for (const seed of seeds.slice(0, MAX_SEED_EXPANSIONS)) {
|
|
219
|
+
if (symbols.size >= MAX_SYMBOLS && files.size >= MAX_FILES) break;
|
|
220
|
+
const callers = await withDeadline(
|
|
221
|
+
provider.analyze_code_relationships({
|
|
222
|
+
query_type: 'find_all_callers',
|
|
223
|
+
target: seed.name,
|
|
224
|
+
context: seed.path,
|
|
225
|
+
depth: CALLER_DEPTH,
|
|
226
|
+
}),
|
|
227
|
+
deadline,
|
|
228
|
+
);
|
|
229
|
+
for (const row of rowsOf(callers)) remember(row);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
symbols: [...symbols.values()].slice(0, MAX_SYMBOLS),
|
|
235
|
+
files: [...files].slice(0, MAX_FILES),
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// task.context_files is stored as a JSON array of repo-relative path
|
|
243
|
+
// strings (resolveTaskContext, above). A row written by hand, or by an
|
|
244
|
+
// older compile, may hold something else — a parse failure means no
|
|
245
|
+
// computed radius to check, not an error worth surfacing.
|
|
246
|
+
function parseContextFiles(task) {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(task?.context_files ?? 'null');
|
|
249
|
+
if (!Array.isArray(parsed)) return [];
|
|
250
|
+
return parsed.filter((f) => typeof f === 'string' && f !== '');
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The declared radius a task's verify command is expected to cover:
|
|
257
|
+
// verify_radius when set, scope_globs otherwise — the same fallback
|
|
258
|
+
// conflict.mjs's verifyRadius(task) applies, kept in sync with it by
|
|
259
|
+
// hand since the two read different columns off the same row.
|
|
260
|
+
function declaredRadiusGlobs(task) {
|
|
261
|
+
const source = task?.verify_radius ?? task?.scope_globs;
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(source ?? 'null');
|
|
264
|
+
if (!Array.isArray(parsed)) return [];
|
|
265
|
+
return parsed.filter((g) => typeof g === 'string' && g !== '');
|
|
266
|
+
} catch {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Advisory only — never called from a path that blocks or gates. The
|
|
272
|
+
// computed blast radius (context_files) against the declared radius
|
|
273
|
+
// (verify_radius, falling back to scope_globs): files the index says the
|
|
274
|
+
// task's code reaches that no declared glob covers. A wrong or stale
|
|
275
|
+
// answer here costs a reader one ignored suggestion, never a build.
|
|
276
|
+
//
|
|
277
|
+
// Returns [] when context_files is absent, unparseable, or empty — "no
|
|
278
|
+
// index available" and "index found nothing uncovered" read the same to
|
|
279
|
+
// a caller, which is correct: neither is ever worth blocking on.
|
|
280
|
+
export function radiusGaps(task) {
|
|
281
|
+
const files = parseContextFiles(task);
|
|
282
|
+
if (files.length === 0) return [];
|
|
283
|
+
|
|
284
|
+
const globs = declaredRadiusGlobs(task);
|
|
285
|
+
if (globs.length === 0) return files;
|
|
286
|
+
|
|
287
|
+
return files.filter((file) => {
|
|
288
|
+
const segments = file.split('/').filter((segment) => segment !== '');
|
|
289
|
+
return !globs.some((glob) => matchesGlob(segments, glob));
|
|
290
|
+
});
|
|
291
|
+
}
|
package/src/db/community.mjs
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// project. Both stated plainly.
|
|
16
16
|
//
|
|
17
17
|
// State lives in `.hedgehog/community.json`, per project rather than in
|
|
18
|
-
// `~/.hedgehog/`.
|
|
18
|
+
// `~/.hedgehog/`. That file is also where the other per-project
|
|
19
|
+
// "already said once" record lives — the code-intelligence advisory at
|
|
20
|
+
// the bottom of this file — so one read answers both.
|
|
19
21
|
|
|
20
22
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
21
23
|
import { dirname, join } from 'node:path';
|
|
@@ -128,3 +130,26 @@ export function formatStarPrompt() {
|
|
|
128
130
|
' and carry on with the build.',
|
|
129
131
|
].join('\n');
|
|
130
132
|
}
|
|
133
|
+
|
|
134
|
+
// Whether the code-intelligence advisory should be shown by `status`
|
|
135
|
+
// now. `status` runs at the start of every session, so a notice that
|
|
136
|
+
// repeated there would be nagging rather than informing — this shows it
|
|
137
|
+
// once and then goes quiet, leaving `update` (run deliberately, and far
|
|
138
|
+
// less often) as the place the gap keeps being reported.
|
|
139
|
+
//
|
|
140
|
+
// Shares `.hedgehog/community.json` with the star prompt rather than
|
|
141
|
+
// adding a second state file: both are per-project records of "this was
|
|
142
|
+
// already said once," both are engine state excluded from every scope
|
|
143
|
+
// check, and one file means one read.
|
|
144
|
+
export async function shouldNoteCodeIntelligence(root) {
|
|
145
|
+
const { codeIntelligenceNotice } = await readState(root);
|
|
146
|
+
return codeIntelligenceNotice !== 'shown';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Record that the code-intelligence advisory has been shown. */
|
|
150
|
+
export async function recordCodeIntelligenceNotice(root) {
|
|
151
|
+
await writeState(root, {
|
|
152
|
+
codeIntelligenceNotice: 'shown',
|
|
153
|
+
codeIntelligenceNoticeAt: new Date().toISOString(),
|
|
154
|
+
});
|
|
155
|
+
}
|
package/src/db/core.mjs
CHANGED
|
@@ -319,8 +319,9 @@ function segmentMatches(pattern, segment) {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
// Does a concrete path (as segments) match a glob? Standard `**`-aware
|
|
322
|
-
// walk. Used
|
|
323
|
-
|
|
322
|
+
// walk. Used on paths this file generated, to prove non-containment, and
|
|
323
|
+
// by code-intelligence.mjs's radiusGaps against paths the index reports.
|
|
324
|
+
export function matchesGlob(pathSegments, glob) {
|
|
324
325
|
const g = segmentsOf(glob);
|
|
325
326
|
const walk = (gi, pi) => {
|
|
326
327
|
if (gi === g.length) return pi === pathSegments.length;
|
package/src/db/drift.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
onceTaskId,
|
|
31
31
|
taskId,
|
|
32
32
|
CORE_MODULE,
|
|
33
|
+
loadIntentDependencies,
|
|
33
34
|
} from './plan.mjs';
|
|
34
35
|
import { loadOverrides } from './overrides.mjs';
|
|
35
36
|
|
|
@@ -166,6 +167,20 @@ export function taskDrift(db, task, core, overrides = new Map()) {
|
|
|
166
167
|
} else {
|
|
167
168
|
expectedParents = [taskId(task.intent_id, parentLayer.id)];
|
|
168
169
|
}
|
|
170
|
+
|
|
171
|
+
// planTasks's "Cross-intent edge" adds, on every per-intent layer, one
|
|
172
|
+
// parent per intent this task's intent declared `--depends-on` —
|
|
173
|
+
// core.yaml alone can't say this (it comes from intent_dependencies),
|
|
174
|
+
// so a task whose intent has such a declaration always expects it here
|
|
175
|
+
// too, or every one of its layers reads as permanently drifted.
|
|
176
|
+
if (!isOnce) {
|
|
177
|
+
for (const dep of loadIntentDependencies(db)) {
|
|
178
|
+
if (dep.intent_id === task.intent_id) {
|
|
179
|
+
expectedParents = [...expectedParents, taskId(dep.depends_on_intent_id, layer.id)];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
169
184
|
const actualParents = recordedParents(db, task);
|
|
170
185
|
const sortedExpected = [...expectedParents].sort();
|
|
171
186
|
const sortedActual = [...actualParents].sort();
|
package/src/db/friction.mjs
CHANGED
|
@@ -38,3 +38,78 @@ export function listFriction(db) {
|
|
|
38
38
|
.prepare(`SELECT id, task_id AS taskId, note, logged_at AS loggedAt FROM friction ORDER BY id ASC`)
|
|
39
39
|
.all();
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// Correlates friction onto the files the friction-carrying tasks
|
|
43
|
+
// actually reach, via each task's `context_files` (the blast-radius file
|
|
44
|
+
// set plan.mjs resolves at compile). Two notes on tasks whose radii
|
|
45
|
+
// overlap on one file are evidence of a single underlying gap, which is
|
|
46
|
+
// the grouping call tweaker makes by hand from the notes' wording alone.
|
|
47
|
+
//
|
|
48
|
+
// Returns { hotspots, uncorrelated }: hotspots as
|
|
49
|
+
// [{ path, frictionCount, taskIds }] sorted by frictionCount descending,
|
|
50
|
+
// uncorrelated the count of notes that reached no file. Both halves ship
|
|
51
|
+
// together because a hotspot list is only readable against how much of
|
|
52
|
+
// the log it covers — three correlated notes out of twenty, reported as
|
|
53
|
+
// three, is worse than reporting nothing.
|
|
54
|
+
//
|
|
55
|
+
// Rows with a NULL task_id (a reviewed-marker row, see tweaker.md) trace
|
|
56
|
+
// to no task and carry no radius, so they are neither correlated nor
|
|
57
|
+
// counted as uncorrelated. Rows whose task has NULL `context_files` —
|
|
58
|
+
// every task on a project with no index — are the uncorrelated total.
|
|
59
|
+
//
|
|
60
|
+
// Wrapped the way status.mjs#countFriction is: a build graph predating
|
|
61
|
+
// the `friction` table throws on the read, and `status` is the command
|
|
62
|
+
// every session starts with.
|
|
63
|
+
export function frictionByModule(db) {
|
|
64
|
+
try {
|
|
65
|
+
const rows = db
|
|
66
|
+
.prepare(`
|
|
67
|
+
SELECT f.id AS id, f.task_id AS taskId, t.context_files AS contextFiles
|
|
68
|
+
FROM friction f
|
|
69
|
+
JOIN tasks t ON t.id = f.task_id
|
|
70
|
+
WHERE f.task_id IS NOT NULL
|
|
71
|
+
ORDER BY f.id ASC
|
|
72
|
+
`)
|
|
73
|
+
.all();
|
|
74
|
+
|
|
75
|
+
let uncorrelated = 0;
|
|
76
|
+
// Per path, the distinct tasks whose radius reaches it — a task with
|
|
77
|
+
// two notes counts twice, so frictionCount is over notes, not tasks.
|
|
78
|
+
const byPath = new Map();
|
|
79
|
+
|
|
80
|
+
for (const { taskId, contextFiles } of rows) {
|
|
81
|
+
// Falsy covers both NULL and the `undefined` a read-only handle
|
|
82
|
+
// returns on a graph that never migrated the column in.
|
|
83
|
+
const paths = contextFiles ? parseContextFiles(contextFiles) : null;
|
|
84
|
+
if (!paths || paths.length === 0) {
|
|
85
|
+
uncorrelated += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for (const path of new Set(paths)) {
|
|
89
|
+
const entry = byPath.get(path) ?? { path, frictionCount: 0, taskIds: [] };
|
|
90
|
+
entry.frictionCount += 1;
|
|
91
|
+
if (!entry.taskIds.includes(taskId)) entry.taskIds.push(taskId);
|
|
92
|
+
byPath.set(path, entry);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const hotspots = [...byPath.values()].sort(
|
|
97
|
+
(a, b) => b.frictionCount - a.frictionCount || a.path.localeCompare(b.path),
|
|
98
|
+
);
|
|
99
|
+
return { hotspots, uncorrelated };
|
|
100
|
+
} catch {
|
|
101
|
+
return { hotspots: [], uncorrelated: 0 };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `context_files` is a JSON array of repo-relative paths. A row written
|
|
106
|
+
// by a provider that returned something else is worth no more than an
|
|
107
|
+
// absent one, so anything unparseable or non-array reads as no files.
|
|
108
|
+
function parseContextFiles(raw) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
return Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string') : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/db/gate.mjs
CHANGED
|
@@ -93,7 +93,7 @@ export async function commitGateStatus(projectRoot) {
|
|
|
93
93
|
applicable: true,
|
|
94
94
|
state: 'not-installed',
|
|
95
95
|
detail: 'lefthook.yml is committed, but no pre-commit hook is installed.',
|
|
96
|
-
repair: 'pnpm
|
|
96
|
+
repair: 'pnpm dlx lefthook install',
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
|
package/src/db/next.mjs
CHANGED
|
@@ -314,6 +314,38 @@ function layerShapeLines(task, coreId) {
|
|
|
314
314
|
return lines;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// Files a code-intelligence index found in this task's blast radius —
|
|
318
|
+
// scope says what the agent may write, this says what it should read
|
|
319
|
+
// first, so it sits directly after ALLOWED SCOPE. `context_files` is a
|
|
320
|
+
// JSON array of repo-relative paths written by plan.mjs at compile time
|
|
321
|
+
// (code-intelligence.mjs); NULL/undefined and a parse failure both mean
|
|
322
|
+
// no section, the same way layerShapeLines returns null and prints
|
|
323
|
+
// nothing.
|
|
324
|
+
//
|
|
325
|
+
// NULL here means the row was planned without a working index —
|
|
326
|
+
// CGC's own environment gone missing, a reindex that never finished, a
|
|
327
|
+
// dead MCP subprocess, or a graph moved onto a machine that hasn't run
|
|
328
|
+
// `init` — or a read-only handle that predates the column. `next`
|
|
329
|
+
// degrades to no PRE-READ section rather than to an error.
|
|
330
|
+
//
|
|
331
|
+
// Kept short like HONESTY: capped at ten files, because a section long
|
|
332
|
+
// enough to skim is a section that isn't read.
|
|
333
|
+
function preReadLines(task) {
|
|
334
|
+
if (!task.context_files) return null;
|
|
335
|
+
let files;
|
|
336
|
+
try {
|
|
337
|
+
files = JSON.parse(task.context_files);
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
if (!Array.isArray(files) || files.length === 0) return null;
|
|
342
|
+
|
|
343
|
+
const lines = ['PRE-READ', " Outside ALLOWED SCOPE, this layer's code calls into:"];
|
|
344
|
+
for (const file of files.slice(0, 10)) lines.push(` - ${file}`);
|
|
345
|
+
if (files.length > 10) lines.push(` ...and ${files.length - 10} more`);
|
|
346
|
+
return lines;
|
|
347
|
+
}
|
|
348
|
+
|
|
317
349
|
// The standing honesty requirement, appended to every packet.
|
|
318
350
|
//
|
|
319
351
|
// Every other section is task-specific — this one is constant, which is
|
|
@@ -344,8 +376,8 @@ const HONESTY = [
|
|
|
344
376
|
];
|
|
345
377
|
|
|
346
378
|
// Renders a packet into the STATUS / INTENT / RELEVANT RULES /
|
|
347
|
-
// INHERITED DEBT / WHY NOW / BLOCKED DOWNSTREAM / ALLOWED SCOPE /
|
|
348
|
-
// SHAPE / VERIFICATION / HONESTY format. The spec
|
|
379
|
+
// INHERITED DEBT / WHY NOW / BLOCKED DOWNSTREAM / ALLOWED SCOPE /
|
|
380
|
+
// PRE-READ / LAYER SHAPE / VERIFICATION / HONESTY format. The spec
|
|
349
381
|
// splits this across two examples — the `hedgehog next` display and "The
|
|
350
382
|
// task packet" (which carries the intent and its rules) — but an agent
|
|
351
383
|
// receives one thing, so the packet is one thing: everything the worker
|
|
@@ -524,6 +556,11 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
|
|
|
524
556
|
lines.push('ALLOWED SCOPE');
|
|
525
557
|
for (const glob of scopeGlobs) lines.push(` ${glob}`);
|
|
526
558
|
lines.push('');
|
|
559
|
+
const preRead = preReadLines(task);
|
|
560
|
+
if (preRead) {
|
|
561
|
+
lines.push(...preRead);
|
|
562
|
+
lines.push('');
|
|
563
|
+
}
|
|
527
564
|
if (firstArrival.length > 0) {
|
|
528
565
|
lines.push(...firstArrivalLines(task, firstArrival));
|
|
529
566
|
lines.push('');
|
package/src/db/plan.mjs
CHANGED
|
@@ -267,7 +267,11 @@ function loadPendingIntents(db) {
|
|
|
267
267
|
.all(...PENDING_INTENT_STATUSES);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
// Exported for drift.mjs: expected cross-intent task edges (see
|
|
271
|
+
// planTasks's "Cross-intent edge" comment below) depend on which
|
|
272
|
+
// intents depend on which, and that's `intent_dependencies` regardless
|
|
273
|
+
// of the intents' own compiled/pending status.
|
|
274
|
+
export function loadIntentDependencies(db) {
|
|
271
275
|
return db.prepare('SELECT * FROM intent_dependencies').all();
|
|
272
276
|
}
|
|
273
277
|
|
|
@@ -392,10 +396,42 @@ const insertTask = (db) =>
|
|
|
392
396
|
db.prepare(`
|
|
393
397
|
INSERT INTO tasks
|
|
394
398
|
(id, intent_id, module, layer, objective, scope_globs, verify_command,
|
|
395
|
-
commit_message, priority, exclusive, verify_radius, status
|
|
396
|
-
|
|
399
|
+
commit_message, priority, exclusive, verify_radius, status,
|
|
400
|
+
context_symbols, context_files, context_indexed_at)
|
|
401
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'planned', ?, ?, ?)
|
|
397
402
|
`);
|
|
398
403
|
|
|
404
|
+
// The three context columns for one task row: symbols and files as JSON,
|
|
405
|
+
// an ISO timestamp of the walk, or `null, null, null` when there's no
|
|
406
|
+
// provider, the walk finds nothing, or anything at all goes wrong.
|
|
407
|
+
//
|
|
408
|
+
// A missing provider here is not the ordinary shape of a `plan` run —
|
|
409
|
+
// CGC is mandatory at `init`, so a project reaching this function
|
|
410
|
+
// normally has one. `null` is what a broken or removed install falls
|
|
411
|
+
// back to instead of failing the plan outright: the CGC environment
|
|
412
|
+
// deleted by hand, disk full mid-reindex, the MCP subprocess dead, or
|
|
413
|
+
// `.hedgehog/` copied onto a machine that never ran `init` there. The
|
|
414
|
+
// three columns land NULL and the graph keeps compiling.
|
|
415
|
+
//
|
|
416
|
+
// `provider` exposes a synchronous `resolveTaskContext(task)` returning
|
|
417
|
+
// `{ symbols, files }` or null — code-intelligence.mjs's own
|
|
418
|
+
// resolveTaskContext is async (it queries a live index), so the CLI
|
|
419
|
+
// layer that constructs `provider` is what awaits it and hands plan.mjs
|
|
420
|
+
// a synchronous face. That keeps planTasks itself synchronous, which
|
|
421
|
+
// every existing caller (several inside a `try { ... } finally {
|
|
422
|
+
// db.close() }`, some returning its result straight out of a callback)
|
|
423
|
+
// depends on.
|
|
424
|
+
function contextColumns(task, provider) {
|
|
425
|
+
try {
|
|
426
|
+
if (!provider || typeof provider.resolveTaskContext !== 'function') return [null, null, null];
|
|
427
|
+
const context = provider.resolveTaskContext(task);
|
|
428
|
+
if (!context || typeof context !== 'object') return [null, null, null];
|
|
429
|
+
return [JSON.stringify(context.symbols), JSON.stringify(context.files), new Date().toISOString()];
|
|
430
|
+
} catch {
|
|
431
|
+
return [null, null, null];
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
399
435
|
const insertDependency = (db) =>
|
|
400
436
|
db.prepare(`
|
|
401
437
|
INSERT OR IGNORE INTO dependencies (task_id, depends_on_task_id)
|
|
@@ -446,7 +482,13 @@ const insertTaskRequirement = (db) =>
|
|
|
446
482
|
// override written after the fact needs `hedgehog plan --recompile`
|
|
447
483
|
// (drift.mjs composes the same overrides Map) the same as any other
|
|
448
484
|
// core.yaml-derived field would.
|
|
449
|
-
|
|
485
|
+
//
|
|
486
|
+
// `provider`, when given, exposes a synchronous `resolveTaskContext(task)`
|
|
487
|
+
// (see contextColumns above) — every task row compiled in this run is
|
|
488
|
+
// resolved against it and written to context_symbols/context_files/
|
|
489
|
+
// context_indexed_at. Absent (the default), every row gets NULL in all
|
|
490
|
+
// three, which every read path treats as "no index available".
|
|
491
|
+
export function planTasks(db, core, overrides = new Map(), { provider = null } = {}) {
|
|
450
492
|
const intents = loadPendingIntents(db);
|
|
451
493
|
const intentDependencies = loadIntentDependencies(db);
|
|
452
494
|
const ordered = orderIntents(intents, intentDependencies);
|
|
@@ -486,6 +528,10 @@ export function planTasks(db, core, overrides = new Map()) {
|
|
|
486
528
|
const { tasks, dependencies } = compileOnceTasks(core, overrides);
|
|
487
529
|
for (const t of tasks) {
|
|
488
530
|
if (taskExists(db, t.id)) continue;
|
|
531
|
+
const [contextSymbols, contextFiles, contextIndexedAt] = contextColumns(
|
|
532
|
+
t,
|
|
533
|
+
provider,
|
|
534
|
+
);
|
|
489
535
|
runInsert.run(
|
|
490
536
|
t.id,
|
|
491
537
|
t.intent_id,
|
|
@@ -498,6 +544,9 @@ export function planTasks(db, core, overrides = new Map()) {
|
|
|
498
544
|
t.priority,
|
|
499
545
|
t.exclusive,
|
|
500
546
|
t.verify_radius,
|
|
547
|
+
contextSymbols,
|
|
548
|
+
contextFiles,
|
|
549
|
+
contextIndexedAt,
|
|
501
550
|
);
|
|
502
551
|
compiledOnceTaskIds.push(t.id);
|
|
503
552
|
}
|
|
@@ -523,6 +572,10 @@ export function planTasks(db, core, overrides = new Map()) {
|
|
|
523
572
|
`task "${t.id}" for intent "${intent.id}" collides with the id of once: true layer "${t.id.toLowerCase()}" — rename the layer or the intent`,
|
|
524
573
|
);
|
|
525
574
|
}
|
|
575
|
+
const [contextSymbols, contextFiles, contextIndexedAt] = contextColumns(
|
|
576
|
+
t,
|
|
577
|
+
provider,
|
|
578
|
+
);
|
|
526
579
|
runInsert.run(
|
|
527
580
|
t.id,
|
|
528
581
|
t.intent_id,
|
|
@@ -535,6 +588,9 @@ export function planTasks(db, core, overrides = new Map()) {
|
|
|
535
588
|
t.priority,
|
|
536
589
|
t.exclusive,
|
|
537
590
|
t.verify_radius,
|
|
591
|
+
contextSymbols,
|
|
592
|
+
contextFiles,
|
|
593
|
+
contextIndexedAt,
|
|
538
594
|
);
|
|
539
595
|
for (const requirementId of requirementIds) {
|
|
540
596
|
runInsertTaskReq.run(t.id, requirementId);
|
package/src/db/rebuild.mjs
CHANGED
|
@@ -328,6 +328,13 @@ export async function rebuildDb(
|
|
|
328
328
|
|
|
329
329
|
const core = await loadCore(corePath);
|
|
330
330
|
const overrides = await loadOverrides(overridesDir);
|
|
331
|
+
|
|
332
|
+
// No provider: every task this call plans lands with NULL context
|
|
333
|
+
// columns, the same as an unindexed project (see plan.mjs's
|
|
334
|
+
// contextColumns). Passing one here isn't a missing argument —
|
|
335
|
+
// building it needs an async pre-resolve pass equivalent to what
|
|
336
|
+
// buildCodeIntelligenceProvider does in the CLI layer, and rebuildDb
|
|
337
|
+
// has no such pass. That's real, separate work this call doesn't do.
|
|
331
338
|
planTasks(db, core, overrides);
|
|
332
339
|
|
|
333
340
|
const commitSubjects = loadCommitSubjects();
|
package/src/db/schema.mjs
CHANGED
|
@@ -121,7 +121,12 @@ CREATE TABLE IF NOT EXISTS friction (
|
|
|
121
121
|
// IF NOT EXISTS` above is a no-op against a DB created by an earlier
|
|
122
122
|
// version, so a new column has to be ALTERed in explicitly or every
|
|
123
123
|
// statement naming it fails on that DB. Each entry is `[name, ddl]`.
|
|
124
|
-
const TASK_COLUMN_MIGRATIONS = [
|
|
124
|
+
const TASK_COLUMN_MIGRATIONS = [
|
|
125
|
+
['claim_snapshot', 'claim_snapshot TEXT'],
|
|
126
|
+
['context_symbols', 'context_symbols TEXT'],
|
|
127
|
+
['context_files', 'context_files TEXT'],
|
|
128
|
+
['context_indexed_at', 'context_indexed_at TEXT'],
|
|
129
|
+
];
|
|
125
130
|
|
|
126
131
|
// Brings an already-created `tasks` table up to the current column set.
|
|
127
132
|
// Idempotent and cheap (one PRAGMA), so callers that must not fail on a
|