@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.
@@ -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
+ }
@@ -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 only on paths this file generated, to prove non-containment.
323
- function matchesGlob(pathSegments, glob) {
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();
@@ -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 install (or: pnpm dlx lefthook install)',
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 / LAYER
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
- function loadIntentDependencies(db) {
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
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'planned')
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
- export function planTasks(db, core, overrides = new Map()) {
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);
@@ -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 = [['claim_snapshot', 'claim_snapshot TEXT']];
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