@davesheffer/hunch 1.39.2 → 1.40.0

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.
Files changed (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
@@ -128,9 +128,15 @@ export declare function compactLedger(hunchDir: string, scope: Scope, opts?: {
128
128
  /** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
129
129
  * appended to the same partition. The union of events is kept (identity = what changed, to
130
130
  * which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
131
- * the higher floor; every subscriber's cursor is therefore invalid after a merge and the gap
132
- * rule makes it resynchronize. Idempotency entries are unioned; a key both sides used for
133
- * different records is a conflict the caller must surface (ours is kept). */
131
+ * the higher floor. When that renumbering moves an event either side had already published,
132
+ * no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
133
+ * event that now holds a lower seq and never see it (issue #285). The floor is then raised
134
+ * past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
135
+ * floor_seq` and resynchronizes from the floor; a cursor equal to the larger head is exactly
136
+ * the failing case, hence the `+ 1`. A merge that renumbers nothing keeps today's seqs (no
137
+ * cursor could have seen a different numbering) and the floor stays put. Idempotency entries
138
+ * are unioned; a key both sides used for different records is a conflict the caller must
139
+ * surface (ours is kept). */
134
140
  export declare function mergeLedgers(base: Ledger | null, ours: Ledger, theirs: Ledger): {
135
141
  ledger: Ledger;
136
142
  conflicts: string[];
@@ -5,9 +5,9 @@
5
5
  * other record, appended atomically (con_902759b3dc). It holds the strictly ordered
6
6
  * ChangeEvent stream for that scope (seq 1, 2, 3 … with no gaps) plus the idempotency
7
7
  * table the write verb replays from. Seq is per scope, assigned by the writer in the
8
- * home the scope lives in; a scope has exactly ONE ledger, so there is never a second
9
- * sequence to reconcile. Merging two clones' ledgers for the same scope is not decided
10
- * here (see docs/nuryel-state-contract.md, "Not decided here").
8
+ * home the scope lives in; a scope has exactly ONE ledger per clone, so within a clone there
9
+ * is never a second sequence to reconcile. Two clones that both appended to one scope are
10
+ * reconciled by `mergeLedgers` (below), for the git merge driver.
11
11
  */
12
12
  import { mkdirSync } from "node:fs";
13
13
  import { basename, join, resolve } from "node:path";
@@ -34,8 +34,9 @@ export const LedgerSchema = z.object({
34
34
  schema: z.literal(LEDGER_SCHEMA_VERSION),
35
35
  scope: ScopeSchema,
36
36
  head_seq: z.number().int().nonnegative(),
37
- /** Events below this seq were compacted away. `events` starts at floor_seq + 1. A subscriber
38
- * whose cursor is below the floor must resynchronize (the contract's gap rule, made explicit). */
37
+ /** Events below this seq were compacted away, or were invalidated by a merge that renumbered
38
+ * them (see `mergeLedgers`). `events` starts at floor_seq + 1. A subscriber whose cursor is
39
+ * below the floor must resynchronize (the contract's gap rule, made explicit). */
39
40
  floor_seq: z.number().int().nonnegative().default(0),
40
41
  events: z.array(ChangeEventSchema),
41
42
  idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
@@ -151,9 +152,15 @@ const eventIdentity = (e) => [e.change, e.facet, e.record_id, e.record_hash, e.a
151
152
  /** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
152
153
  * appended to the same partition. The union of events is kept (identity = what changed, to
153
154
  * which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
154
- * the higher floor; every subscriber's cursor is therefore invalid after a merge and the gap
155
- * rule makes it resynchronize. Idempotency entries are unioned; a key both sides used for
156
- * different records is a conflict the caller must surface (ours is kept). */
155
+ * the higher floor. When that renumbering moves an event either side had already published,
156
+ * no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
157
+ * event that now holds a lower seq and never see it (issue #285). The floor is then raised
158
+ * past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
159
+ * floor_seq` and resynchronizes from the floor; a cursor equal to the larger head is exactly
160
+ * the failing case, hence the `+ 1`. A merge that renumbers nothing keeps today's seqs (no
161
+ * cursor could have seen a different numbering) and the floor stays put. Idempotency entries
162
+ * are unioned; a key both sides used for different records is a conflict the caller must
163
+ * surface (ours is kept). */
157
164
  export function mergeLedgers(base, ours, theirs) {
158
165
  if (scopePath(ours.scope) !== scopePath(theirs.scope))
159
166
  throw new Error("ledgers for different scopes cannot be merged");
@@ -169,9 +176,28 @@ export function mergeLedgers(base, ours, theirs) {
169
176
  add(e);
170
177
  for (const e of theirs.events)
171
178
  add(e);
172
- const ranked = order.map((e, i) => ({ e, i, side: (ours.events.includes(e) ? 0 : 1) }));
179
+ // The tiebreak is "ours before theirs", so only an event theirs ALONE contributed sorts late:
180
+ // side 1 iff theirs holds it and neither ours nor base does. Deciding by object identity
181
+ // (`includes`) would instead put anything ours compacted away — base's object, which `add`
182
+ // keeps — after its same-`at` siblings and silently reorder a batch both sides already had.
183
+ const ourIdentities = new Set(ours.events.map(eventIdentity));
184
+ const baseIdentities = new Set((base?.events ?? []).map(eventIdentity));
185
+ const theirsOnly = (e) => {
186
+ const k = eventIdentity(e);
187
+ return !ourIdentities.has(k) && !baseIdentities.has(k);
188
+ };
189
+ const ranked = order.map((e, i) => ({ e, i, side: (theirsOnly(e) ? 1 : 0) }));
173
190
  ranked.sort((a, b) => a.e.at.localeCompare(b.e.at) || a.side - b.side || a.i - b.i);
174
- const floor = Math.max(base?.floor_seq ?? 0, ours.floor_seq, theirs.floor_seq);
191
+ const theirSeqs = new Map(theirs.events.map((e) => [eventIdentity(e), e.seq]));
192
+ const ourSeqs = new Map(ours.events.map((e) => [eventIdentity(e), e.seq]));
193
+ let floor = Math.max(base?.floor_seq ?? 0, ours.floor_seq, theirs.floor_seq);
194
+ const renumbers = ranked.some(({ e }, i) => {
195
+ const k = eventIdentity(e);
196
+ const merged = floor + i + 1;
197
+ return (ourSeqs.get(k) ?? merged) !== merged || (theirSeqs.get(k) ?? merged) !== merged;
198
+ });
199
+ if (renumbers)
200
+ floor = Math.max(floor, Math.max(ours.head_seq, theirs.head_seq) + 1);
175
201
  const events = ranked.map(({ e }, i) => ({ ...e, seq: floor + i + 1 }));
176
202
  const conflicts = [];
177
203
  const idempotency = { ...(base?.idempotency ?? {}), ...theirs.idempotency, ...ours.idempotency };
@@ -335,6 +335,29 @@ export declare class HunchStore {
335
335
  * present in the indexed symbol/component tables can enter the frontier, so
336
336
  * shared external-package hubs never become context or bridge unrelated symbols. */
337
337
  private graphExpand;
338
+ /** The WIDE definition of "is `t` a real path?" — the gate used by the matchers
339
+ * that ATTRIBUTE records to a target silently (why(), liveFindingsFor, tasksFor,
340
+ * `resolveSymbols` in the MCP server). They also run for a file an agent is
341
+ * about to create, where the covering component glob is the only signal there
342
+ * is, so the answer must not depend on the file already being on disk. The
343
+ * resolvers that NAME the file they resolved to (resolveNodeIds, structure())
344
+ * deliberately use the narrower `isRepoFile` instead.
345
+ *
346
+ * Graph data FIRST (a symbol's exact file, or an indexed component's path glob),
347
+ * so a deleted-but-still-indexed path and a time-travel (`asOf`) query answer the
348
+ * same as before (issue #299). The working tree is consulted only as a LAST
349
+ * resort, for the case the index cannot see: a real file with zero tree-sitter
350
+ * symbols and no covering component glob, which otherwise suffix-leaked an
351
+ * unrelated same-basename file's records (issue #334). When the working tree is
352
+ * unavailable or the file is gone, behaviour is exactly the pre-#334 behaviour.
353
+ *
354
+ * `t` must already be repo-relative (`repoRelativeTarget`). `graph` lets a caller
355
+ * that has already narrowed the graph (why()'s `canRead` visibility filter) ask
356
+ * the question over exactly the records it may see, rather than all of them. */
357
+ isKnownPath(t: string, graph?: {
358
+ symbols: readonly Symbol[];
359
+ components: readonly Component[];
360
+ }): boolean;
338
361
  /** All decisions/bugs/constraints/symbols/components touching a file path or
339
362
  * symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
340
363
  * return only decisions/constraints whose valid-time window contained that
@@ -379,7 +402,20 @@ export declare class HunchStore {
379
402
  * or the id itself when unindexed. */
380
403
  nodeLabel(id: string): string;
381
404
  /** Resolve a free-form target (symbol id / name / file path, component id / name)
382
- * to graph node ids — symbols win over components, exact file before suffix. */
405
+ * to graph node ids — symbols win over components, exact file before suffix.
406
+ * Shares why()/resolveSymbols' tiered matcher rather than one un-tiered SQL OR:
407
+ * the flat query matched every tier at once, so a root `index.ts` also returned
408
+ * `a/index.ts`'s symbols, and an absolute target matched nothing at all (#335).
409
+ *
410
+ * The suffix gate here is `isRepoFile`, NOT the wider `isKnownPath`: the
411
+ * exact-file tier has already run, so the only case left is a real on-disk file
412
+ * with zero symbols (#334). Glob coverage alone would wrongly block a target
413
+ * that no file actually carries — with a `src/**` component, `src/x.ts` living
414
+ * at `packages/a/src/x.ts` stopped resolving. This resolver names the file it
415
+ * resolved to in its result, so a suffix resolution is visible and only a file
416
+ * that really exists may block it; why()/the pre-edit hook attribute records
417
+ * silently and also run for a file about to be created, so they keep the wider
418
+ * `isKnownPath`. */
383
419
  resolveNodeIds(target: string): string[];
384
420
  /** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
385
421
  * of a change. Composes the SAME primitives as buildCheckReport (blast radius,
@@ -590,9 +626,13 @@ export type StructureView = {
590
626
  callers: string[];
591
627
  callees: string[];
592
628
  }>;
593
- } | {
629
+ }
630
+ /** `realFile`: the target IS a regular file in the working tree, it just has no
631
+ * indexed symbols — a different answer from "no such path" (#334). */
632
+ | {
594
633
  kind: "none";
595
634
  target: string;
635
+ realFile?: boolean;
596
636
  };
597
637
  export interface StaleRecord {
598
638
  kind: string;
@@ -12,14 +12,14 @@
12
12
  */
13
13
  import { resolve, join, dirname, isAbsolute, relative } from "node:path";
14
14
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
15
- import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
15
+ import { toPosixTarget, repoRelativeTarget, isRepoFile, hunchPathsForDir } from "../core/paths.js";
16
16
  import { ENTITY_KINDS } from "../core/types.js";
17
17
  import { openDb, withTx } from "./db.js";
18
18
  import { RESET_SQL, embedHash } from "./schema.js";
19
19
  import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
21
  import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
22
- import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
22
+ import { pathMatchesGlob, pathsRelated, isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
23
23
  import { cochangeFor } from "../core/cochange.js";
24
24
  import { withServedDatabase } from "../core/served.js";
25
25
  import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectTaskSlots } from "../core/taskRanking.js";
@@ -1178,13 +1178,40 @@ export class HunchStore {
1178
1178
  }
1179
1179
  return hits;
1180
1180
  }
1181
+ /** The WIDE definition of "is `t` a real path?" — the gate used by the matchers
1182
+ * that ATTRIBUTE records to a target silently (why(), liveFindingsFor, tasksFor,
1183
+ * `resolveSymbols` in the MCP server). They also run for a file an agent is
1184
+ * about to create, where the covering component glob is the only signal there
1185
+ * is, so the answer must not depend on the file already being on disk. The
1186
+ * resolvers that NAME the file they resolved to (resolveNodeIds, structure())
1187
+ * deliberately use the narrower `isRepoFile` instead.
1188
+ *
1189
+ * Graph data FIRST (a symbol's exact file, or an indexed component's path glob),
1190
+ * so a deleted-but-still-indexed path and a time-travel (`asOf`) query answer the
1191
+ * same as before (issue #299). The working tree is consulted only as a LAST
1192
+ * resort, for the case the index cannot see: a real file with zero tree-sitter
1193
+ * symbols and no covering component glob, which otherwise suffix-leaked an
1194
+ * unrelated same-basename file's records (issue #334). When the working tree is
1195
+ * unavailable or the file is gone, behaviour is exactly the pre-#334 behaviour.
1196
+ *
1197
+ * `t` must already be repo-relative (`repoRelativeTarget`). `graph` lets a caller
1198
+ * that has already narrowed the graph (why()'s `canRead` visibility filter) ask
1199
+ * the question over exactly the records it may see, rather than all of them. */
1200
+ isKnownPath(t, graph) {
1201
+ const symbols = graph?.symbols ?? this.recs("symbols");
1202
+ const components = graph?.components ?? this.recs("components");
1203
+ return isIndexedPath(t, symbols.map((s) => s.file), components.map((c) => c.paths))
1204
+ || isRepoFile(this.paths.root, t);
1205
+ }
1181
1206
  /** All decisions/bugs/constraints/symbols/components touching a file path or
1182
1207
  * symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
1183
1208
  * return only decisions/constraints whose valid-time window contained that
1184
1209
  * instant — "what did we believe as of commit X?". Omit `asOf` for the full,
1185
1210
  * history-inclusive view (backward-compatible default). */
1186
1211
  why(target, opts = {}) {
1187
- target = toPosixTarget(target);
1212
+ // An absolute target (an agent's edit-payload path, verbatim) never matched
1213
+ // the repo-relative stored file paths below (issue #299).
1214
+ target = repoRelativeTarget(target, this.paths.root);
1188
1215
  const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1189
1216
  const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1190
1217
  const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
@@ -1194,11 +1221,24 @@ export class HunchStore {
1194
1221
  // pathsRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
1195
1222
  // so an unanchored suffix pulled unrelated files' records into why()/the
1196
1223
  // pre-edit grounding block (issue #32). Segment-anchored matching only.
1197
- const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathsRelated(s.file, target));
1224
+ //
1225
+ // A target that IS a path Hunch's index already knows about (README.md,
1226
+ // vscode-extension/index.ts, ...) names exactly one file — suffix-matching
1227
+ // it too pulls in every OTHER file that merely shares a basename (issue
1228
+ // #299: root index.ts delivered vscode-extension/index.ts's rules and vice
1229
+ // versa). "Known to the index" is answered from already-loaded graph data
1230
+ // first (a symbol's exact file, or an indexed component's path glob — so a
1231
+ // symbol-less indexed file still counts as real), and only then from the
1232
+ // working tree, for a real file the index cannot see at all (issue #334).
1233
+ // See isKnownPath. Suffix matching stays reserved for a target that is not
1234
+ // itself a real path, e.g. a short/partial reference like "x/scenario.ts".
1235
+ const indexed = this.isKnownPath(target, { symbols, components });
1236
+ const suffixMatch = (file) => !indexed && pathsRelated(file, target);
1237
+ const matchedSymbols = matchSymbolsTiered(target, symbols, indexed);
1198
1238
  const symIds = new Set(matchedSymbols.map((s) => s.id));
1199
1239
  const fileSet = new Set(matchedSymbols.map((s) => s.file));
1200
1240
  const isPath = target.includes("/") || target.includes(".");
1201
- const fileMatch = (files) => files.some((f) => f === target || (isPath && pathsRelated(f, target)) || fileSet.has(f));
1241
+ const fileMatch = (files) => files.some((f) => f === target || (isPath && suffixMatch(f)) || fileSet.has(f));
1202
1242
  return {
1203
1243
  target,
1204
1244
  decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
@@ -1320,10 +1360,23 @@ export class HunchStore {
1320
1360
  return c?.v ?? id;
1321
1361
  }
1322
1362
  /** Resolve a free-form target (symbol id / name / file path, component id / name)
1323
- * to graph node ids — symbols win over components, exact file before suffix. */
1363
+ * to graph node ids — symbols win over components, exact file before suffix.
1364
+ * Shares why()/resolveSymbols' tiered matcher rather than one un-tiered SQL OR:
1365
+ * the flat query matched every tier at once, so a root `index.ts` also returned
1366
+ * `a/index.ts`'s symbols, and an absolute target matched nothing at all (#335).
1367
+ *
1368
+ * The suffix gate here is `isRepoFile`, NOT the wider `isKnownPath`: the
1369
+ * exact-file tier has already run, so the only case left is a real on-disk file
1370
+ * with zero symbols (#334). Glob coverage alone would wrongly block a target
1371
+ * that no file actually carries — with a `src/**` component, `src/x.ts` living
1372
+ * at `packages/a/src/x.ts` stopped resolving. This resolver names the file it
1373
+ * resolved to in its result, so a suffix resolution is visible and only a file
1374
+ * that really exists may block it; why()/the pre-edit hook attribute records
1375
+ * silently and also run for a file about to be created, so they keep the wider
1376
+ * `isKnownPath`. */
1324
1377
  resolveNodeIds(target) {
1325
- const t = toPosixTarget(target);
1326
- const sym = this.db.prepare(`SELECT id FROM symbols WHERE id = ? OR name = ? OR file = ? OR file LIKE ? LIMIT 20`).all(t, t, t, `%/${t}`);
1378
+ const t = repoRelativeTarget(target, this.paths.root);
1379
+ const sym = matchSymbolsTiered(t, this.recs("symbols"), isRepoFile(this.paths.root, t)).slice(0, 20);
1327
1380
  if (sym.length)
1328
1381
  return sym.map((r) => r.id);
1329
1382
  const cmp = this.db.prepare(`SELECT id FROM components WHERE id = ? OR name = ? LIMIT 5`).all(t, t);
@@ -1380,10 +1433,27 @@ export class HunchStore {
1380
1433
  dirs: [...dirs.entries()].map(([dir, v]) => ({ dir, ...v })).sort((a, b) => b.symbols - a.symbols),
1381
1434
  };
1382
1435
  }
1383
- const t = toPosixTarget(target.trim()).replace(/\/+$/, "");
1436
+ // An absolute target (an agent's edit-payload path, verbatim) never matched the
1437
+ // repo-relative stored file paths below and fell all the way to "none" (#335).
1438
+ const t = repoRelativeTarget(target.trim(), this.paths.root).replace(/\/+$/, "");
1384
1439
  // FILE: exact path or unique suffix
1385
1440
  const fileRows = this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(t);
1386
- const fileHit = fileRows.length ? t : this.db.prepare(`SELECT DISTINCT file FROM symbols WHERE file LIKE ?`).all(`%/${t}`).map((r) => r.file);
1441
+ // Only the SUFFIX fallback is gated, and by `isRepoFile` rather than the wider
1442
+ // `isKnownPath`: the exact-path tier above has already run, so the only case
1443
+ // left is a real on-disk file with zero symbols, whose outline must not come
1444
+ // from an unrelated same-basename file (#334). Glob coverage alone is not
1445
+ // existence — with a `src/**` component, `src/x.ts` living at
1446
+ // `packages/a/src/x.ts` would stop resolving. structure() names the file it
1447
+ // resolved to, so a suffix resolution is visible and only a file that really
1448
+ // exists may block it; why()/the pre-edit hook attribute records silently and
1449
+ // also run for a file about to be created, so they keep `isKnownPath`.
1450
+ // Directory targets are unaffected — a directory is never an `isRepoFile`
1451
+ // (regular files only), so they keep flowing to the dir tier below.
1452
+ const fileHit = fileRows.length
1453
+ ? t
1454
+ : isRepoFile(this.paths.root, t)
1455
+ ? []
1456
+ : this.db.prepare(`SELECT DISTINCT file FROM symbols WHERE file LIKE ?`).all(`%/${t}`).map((r) => r.file);
1387
1457
  const file = typeof fileHit === "string" ? fileHit : fileHit.length === 1 ? fileHit[0] : null;
1388
1458
  if (file) {
1389
1459
  const syms = fileRows.length ? fileRows : this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(file);
@@ -1412,7 +1482,10 @@ export class HunchStore {
1412
1482
  matches: named.map((m) => ({ ...m, callers: this.edgeNeighbors(m.id, "in", 6), callees: this.edgeNeighbors(m.id, "out", 6) })),
1413
1483
  };
1414
1484
  }
1415
- return { kind: "none", target: t };
1485
+ // A real on-disk file with zero indexed symbols reaches "none" too (#334). Flag
1486
+ // it so the renderer doesn't tell the user it isn't a known file when we just
1487
+ // stat'd it.
1488
+ return { kind: "none", target: t, ...(isRepoFile(this.paths.root, t) ? { realFile: true } : {}) };
1416
1489
  }
1417
1490
  /** Labelled one-hop edge neighbors of a node ("in" = who reaches it, "out" = what it reaches). */
1418
1491
  edgeNeighbors(id, dir, limit) {
@@ -1426,6 +1499,10 @@ export class HunchStore {
1426
1499
  * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
1427
1500
  * that instant (time-travel: "what must I not have broken as of commit X?"). */
1428
1501
  checkConstraints(scope, opts = {}) {
1502
+ // Constraint scopes are always repo-relative; an absolute target (an
1503
+ // agent's edit-payload path, verbatim) matched nothing even when a
1504
+ // blocking rule plainly applied (issue #296).
1505
+ scope = repoRelativeTarget(scope, this.paths.root);
1429
1506
  const all = this.recs("constraints");
1430
1507
  const asOf = opts.asOf;
1431
1508
  return all
@@ -1439,21 +1516,29 @@ export class HunchStore {
1439
1516
  * glob, and the queried scope may be either too. Advisory only — findings never
1440
1517
  * enter any block path. Sorted worst-first, then id for stable output. */
1441
1518
  liveFindingsFor(scope) {
1442
- const t = toPosixTarget(scope);
1519
+ // Same absolute-target and same-basename-leak gaps as why() (issue #299): an
1520
+ // un-rewritten absolute scope never matched the repo-relative `affected_files`
1521
+ // below, and an un-guarded suffix fallback could pull in an unrelated file
1522
+ // that merely shares a basename. Normalize once and reuse the same "is this
1523
+ // path known to the index" guard.
1524
+ const t = repoRelativeTarget(scope, this.paths.root);
1525
+ const indexed = this.isKnownPath(t);
1443
1526
  const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
1444
1527
  return this.recs("findings")
1445
1528
  .filter(live)
1446
- .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathsRelated(toPosixTarget(af), t))
1447
- || f.affected_symbols.some((s) => s === scope))
1529
+ .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || (!indexed && pathsRelated(toPosixTarget(af), t)))
1530
+ || f.affected_symbols.some((s) => s === t))
1448
1531
  .sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
1449
1532
  }
1450
1533
  /** Finished agent tasks that touched a file or glob (rule-checked changes and
1451
1534
  * denied edits), newest first. Graph memory, so it spans machines and survives
1452
1535
  * the local ledger's retention window. */
1453
1536
  tasksFor(scope, limit = 8) {
1454
- const t = toPosixTarget(scope);
1537
+ // Same normalization + known-path suffix guard as liveFindingsFor/why() (issue #299).
1538
+ const t = repoRelativeTarget(scope, this.paths.root);
1539
+ const indexed = this.isKnownPath(t);
1455
1540
  return this.recs("tasks")
1456
- .filter((r) => r.files.some((f) => pathMatchesGlob(t, f) || pathMatchesGlob(f, t) || pathsRelated(toPosixTarget(f), t)))
1541
+ .filter((r) => r.files.some((f) => pathMatchesGlob(t, f) || pathMatchesGlob(f, t) || (!indexed && pathsRelated(toPosixTarget(f), t))))
1457
1542
  .sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
1458
1543
  .slice(0, Math.max(1, limit));
1459
1544
  }
@@ -2048,7 +2133,10 @@ export class HunchStore {
2048
2133
  * a task on `target`, ordered by what matters most — invariants first, then the
2049
2134
  * why, then blast radius and bug history — trimmed to a rough token budget. */
2050
2135
  assembleContext(target, budget = 1500, opts = {}) {
2051
- target = toPosixTarget(target);
2136
+ // An absolute target (an agent's edit-payload path, verbatim) must be
2137
+ // repo-relative BEFORE it reaches why()/liveFindingsFor() below, or an
2138
+ // absolute path never matches the repo-relative stored data (issue #299).
2139
+ target = repoRelativeTarget(target, this.paths.root);
2052
2140
  const w = this.why(target, opts);
2053
2141
  const symIds = w.symbols.map((s) => s.id);
2054
2142
  const blast = new Map();
@@ -78,10 +78,12 @@ export declare class JsonStore {
78
78
  * The long-lived MCP server and CLI hooks write the same `.hunch/` concurrently;
79
79
  * two unsynchronized RMWs over index.json each read the same base array and the
80
80
  * second rename silently erases the first's record. `mkdirSync` is the atomic
81
- * acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
82
- * against a live contender we wait briefly and then refuse the write. Proceeding
83
- * without the lock would reintroduce the record-loss race this mutex exists to
84
- * prevent. */
81
+ * acquire (EEXIST = held). A stale lock (killed process, or one whose pid was
82
+ * RECYCLED) is taken over behind an exclusive `.reclaim` claim — see
83
+ * `takeOverRmwLock`, because judging and removing are two steps and two
84
+ * contenders could otherwise both "win". Against a live contender we wait
85
+ * briefly and then refuse the write. Proceeding without the lock would
86
+ * reintroduce the record-loss race this mutex exists to prevent. */
85
87
  private withSingleFileLock;
86
88
  /** Write a single record (validated) to its JSON file / into the index array. */
87
89
  put<K extends EntityKind>(kind: K, record: EntityFor[K]): EntityFor[K];