@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
@@ -28,6 +28,14 @@ export interface DeliverySupplement {
28
28
  text: string;
29
29
  /** Higher values are attempted first after ranked memory. */
30
30
  priority?: number;
31
+ /** Stable IDENTITY of the records behind `text`, for callers that dedup
32
+ * injections by hash (the pre-edit hook). Some supplements are
33
+ * self-invalidating: serving them writes delivery receipts, and the next
34
+ * render's wording, scores or slot order move with no record change. Set this
35
+ * to what is a property of the record — never of delivery state or the clock —
36
+ * and `deliveryDedupeInput` swaps it in for `text` when hashing. Omitted →
37
+ * `text` is its own identity. Never shown to the agent. */
38
+ hash_text?: string;
31
39
  }
32
40
  export interface DeliveredSupplement {
33
41
  id: string;
@@ -106,5 +114,31 @@ export interface DeliveryOptions {
106
114
  }
107
115
  /** Validate the public receipt without trusting a caller-supplied identity. */
108
116
  export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
117
+ /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
118
+ * key on instead of the rendered block: a STABLE IDENTITY PROJECTION of this
119
+ * envelope.
120
+ *
121
+ * THE RULE, for every caller and every provider: nothing that depends on
122
+ * delivery receipts, ranking warmth or scores, slot labels, line ORDER, or
123
+ * `now` may enter the dedupe hash; a record entering, leaving, or changing
124
+ * content MUST. Grounding that violates it is self-invalidating — serving the
125
+ * block writes delivery receipts, the next call's ranking reads them back, the
126
+ * wording moves ("today" → "delivered today"), the hash changes, and the full
127
+ * block is re-sent for records the agent already has.
128
+ *
129
+ * The projection, done line-wise over the one text the assembler produced:
130
+ * - a supplement that declared `hash_text` contributes that identity (kind +
131
+ * record id + the record's own content hash) instead of its rendered line —
132
+ * that is where reason text, slot labels and age words live;
133
+ * - every other line contributes verbatim, because it is already a property of
134
+ * the records (id, statement, scope, provenance tier, validation state);
135
+ * - each contiguous run of `- ` list lines is SORTED, so a pure re-ordering of
136
+ * the same records is not a change while a record entering or leaving the run
137
+ * still is.
138
+ *
139
+ * Nothing is removed from what the agent SEES — this string is never emitted.
140
+ * Never throws: on any surprise the caller gets `envelope.text` back, i.e. the
141
+ * original hash-the-presentation behaviour (a full block, never a wrong delta). */
142
+ export declare function deliveryDedupeInput(envelope: DeliveryEnvelope, supplements?: readonly DeliverySupplement[]): string;
109
143
  /** Build the one envelope used by CLI, MCP, and the edit hook. */
110
144
  export declare function buildDeliveryEnvelope(ctx: AssembledContext, options?: DeliveryOptions): DeliveryEnvelope;
@@ -21,6 +21,9 @@ const MIN_ADVISORY_CONFIDENCE = 0.5;
21
21
  const MIN_UNCONDITIONED_CONFIDENCE = 0.7;
22
22
  const MAX_ACTIONABLE_HYPOTHESES = 2;
23
23
  const MAX_PROFILE_HEADLINES = 8;
24
+ /** How far a supplement's text is clipped in the rendered line. Shared so
25
+ * `deliveryDedupeInput` can reconstruct that exact line to project over it. */
26
+ const SUPPLEMENT_HEADLINE_CHARS = 700;
24
27
  const PROFILE_BASE_SCORE = {
25
28
  builder: {
26
29
  constraints: 900,
@@ -457,6 +460,62 @@ export function assertDeliveryEnvelope(envelope) {
457
460
  throw new Error("delivery envelope receipt does not match its content");
458
461
  }
459
462
  }
463
+ /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
464
+ * key on instead of the rendered block: a STABLE IDENTITY PROJECTION of this
465
+ * envelope.
466
+ *
467
+ * THE RULE, for every caller and every provider: nothing that depends on
468
+ * delivery receipts, ranking warmth or scores, slot labels, line ORDER, or
469
+ * `now` may enter the dedupe hash; a record entering, leaving, or changing
470
+ * content MUST. Grounding that violates it is self-invalidating — serving the
471
+ * block writes delivery receipts, the next call's ranking reads them back, the
472
+ * wording moves ("today" → "delivered today"), the hash changes, and the full
473
+ * block is re-sent for records the agent already has.
474
+ *
475
+ * The projection, done line-wise over the one text the assembler produced:
476
+ * - a supplement that declared `hash_text` contributes that identity (kind +
477
+ * record id + the record's own content hash) instead of its rendered line —
478
+ * that is where reason text, slot labels and age words live;
479
+ * - every other line contributes verbatim, because it is already a property of
480
+ * the records (id, statement, scope, provenance tier, validation state);
481
+ * - each contiguous run of `- ` list lines is SORTED, so a pure re-ordering of
482
+ * the same records is not a change while a record entering or leaving the run
483
+ * still is.
484
+ *
485
+ * Nothing is removed from what the agent SEES — this string is never emitted.
486
+ * Never throws: on any surprise the caller gets `envelope.text` back, i.e. the
487
+ * original hash-the-presentation behaviour (a full block, never a wrong delta). */
488
+ export function deliveryDedupeInput(envelope, supplements = []) {
489
+ try {
490
+ const identity = new Map();
491
+ for (const s of supplements) {
492
+ if (s.hash_text === undefined)
493
+ continue;
494
+ // Key on the line exactly as the assembler rendered it, so the swap is a
495
+ // substitution and never a guess about the text's shape.
496
+ identity.set(`- supplemental/${s.kind} | ${clipHeadline(s.text, SUPPLEMENT_HEADLINE_CHARS)}`, `- supplemental/${s.kind} | identity\u0000${s.hash_text}`);
497
+ }
498
+ const projected = [];
499
+ let run = [];
500
+ const flush = () => { if (run.length) {
501
+ projected.push(...run.sort());
502
+ run = [];
503
+ } };
504
+ for (const line of envelope.text.split("\n")) {
505
+ if (!line.startsWith("- ")) {
506
+ flush();
507
+ projected.push(line);
508
+ continue;
509
+ }
510
+ run.push(identity.get(line) ?? line);
511
+ }
512
+ flush();
513
+ return projected.join("\n");
514
+ }
515
+ catch {
516
+ return envelope.text; // the projection is an optimization, never a correctness dependency
517
+ }
518
+ }
460
519
  /** Build the one envelope used by CLI, MCP, and the edit hook. */
461
520
  export function buildDeliveryEnvelope(ctx, options = {}) {
462
521
  const profile = options.profile ?? "builder";
@@ -742,7 +801,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
742
801
  ...(options.supplements ?? []),
743
802
  ].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.id.localeCompare(right.id));
744
803
  for (const [index, supplement] of supplementalCandidates.entries()) {
745
- const content = clipHeadline(supplement.text, 700);
804
+ const content = clipHeadline(supplement.text, SUPPLEMENT_HEADLINE_CHARS);
746
805
  if (!content) {
747
806
  supplements.push({ id: supplement.id, kind: supplement.kind, delivered: false, reason: "empty", rank: index + 1, token_cost: 0 });
748
807
  continue;
@@ -1,27 +1,199 @@
1
1
  import { currentForTopic, rejectedForTopic } from "./topics.js";
2
2
  const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
3
+ /** Expand the leading whitespace of a line to columns. A tab advances to the
4
+ * NEXT 4-column tab stop (CommonMark), not a flat 4 columns: after two
5
+ * spaces a tab is worth 2, so ` \t``` ` sits at column 4 like `\t``` `.
6
+ * Matching-only — offsets always come from the original line. */
7
+ function expandTabs(line) {
8
+ const ws = /^[ \t]*/.exec(line)[0];
9
+ if (!ws.includes("\t"))
10
+ return line;
11
+ let col = 0;
12
+ for (const c of ws)
13
+ col += c === "\t" ? 4 - (col % 4) : 1;
14
+ return " ".repeat(col) + line.slice(ws.length);
15
+ }
16
+ /** For every index i of `probe`, whether probe.slice(i) is a thematic break
17
+ * (`---`, `* * *`, `___`: ≤3 leading spaces, then ≥3 of one char separated by
18
+ * spaces only). Computed right-to-left in ONE pass so the list-marker walk
19
+ * below can ask the question at each nesting level without re-scanning the
20
+ * rest of the line each time — the difference between O(L) and O(L²) on a
21
+ * line that is nothing but list markers (`- - - …`, an adversarial doc). */
22
+ function thematicBreakSuffixes(probe) {
23
+ const L = probe.length;
24
+ // runFrom[i] = the break-so-far state of probe.slice(i) when it consists only
25
+ // of spaces and one repeated break char: its char, and how many were seen.
26
+ const ok = new Array(L + 1).fill(false);
27
+ let ch = "";
28
+ let count = 0;
29
+ let onlySpaceSoFar = true;
30
+ for (let i = L - 1; i >= 0; i--) {
31
+ const c = probe[i];
32
+ if (c === " " || c === "\t") {
33
+ // A space never breaks the run; it is allowed anywhere, including the
34
+ // ≤3 leading columns handled by the indent check at the call site.
35
+ ok[i] = ok[i + 1];
36
+ continue;
37
+ }
38
+ if (c === "-" || c === "*" || c === "_") {
39
+ if (onlySpaceSoFar) {
40
+ ch = c;
41
+ count = 1;
42
+ onlySpaceSoFar = false;
43
+ }
44
+ else if (c === ch)
45
+ count++;
46
+ // Two different break chars: every suffix starting at or before i now
47
+ // mixes them (`- - - * -` is five nested items, not a break), so stop.
48
+ else
49
+ return ok;
50
+ }
51
+ else {
52
+ // Any other char means no suffix starting at or before i is a break;
53
+ // ok is already false there, so stop.
54
+ return ok;
55
+ }
56
+ ok[i] = count >= 3;
57
+ }
58
+ return ok;
59
+ }
3
60
  /** Character ranges covered by fenced code blocks (``` or ~~~), so a
4
61
  * documentation EXAMPLE of a marker never registers as a live anchor.
5
62
  * CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
6
63
  * line of ≥N of the same char and nothing else; an unclosed fence runs to
7
- * EOF; a backtick fence's info string may not itself contain a backtick. */
64
+ * EOF; a backtick fence's info string may not itself contain a backtick.
65
+ * The "≤3 leading spaces" is measured relative to the enclosing LIST ITEM's
66
+ * content offset, so a fence indented under `1. step` (issue #331) is still a
67
+ * fence and not an indented code block.
68
+ *
69
+ * A fence hosted in a list item ends where the ITEM ends: at the first
70
+ * non-blank line dedented below the item's content base. That is what the
71
+ * rendered document shows — the closing ``` of a sloppily indented example
72
+ * sits outside the item, so it terminates the item's fence and opens a new
73
+ * top-level one — and this scanner's job is to agree with the rendering a
74
+ * reader sees, not with the author's intent. In sloppy docs that differs from
75
+ * a flat fence scan; the rendering is the tiebreaker.
76
+ *
77
+ * Tabs expand to the next 4-column tab stop (not a flat 4), so `\t``` ` sits
78
+ * at column 4 exactly as a renderer places it. Offsets always come from the
79
+ * ORIGINAL line, never from the expanded probe.
80
+ *
81
+ * Deliberate limits: no lazy continuations and no blockquote containers — a
82
+ * `>` prefix is still read as ordinary text. With no list open the item stack
83
+ * is empty, the base is 0 and behaviour is the plain CommonMark-lite one.
84
+ * Expects LF-normalized text — see parseDocAnchors's normalization; a caller
85
+ * that skips it re-opens the CRLF fence-detection bug. */
8
86
  function fencedRanges(text) {
87
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
9
88
  const ranges = [];
10
89
  let open = null;
90
+ // Content offsets (columns) of the currently open list items, outermost first.
91
+ const items = [];
11
92
  let offset = 0;
93
+ // Whether the previous line was a paragraph-continuation line: non-blank,
94
+ // and not itself a container/leaf opener. Only a bullet or a `1.`/`1)` with
95
+ // non-empty content may interrupt such a paragraph (CommonMark), so prose
96
+ // like "see item\n2. the second point" stays one paragraph.
97
+ let inParagraph = false;
12
98
  for (const line of text.split("\n")) {
13
- const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
14
- if (m) {
15
- const ch = m[1][0];
16
- if (!open) {
17
- if (!(ch === "`" && m[2].includes("`")))
18
- open = { ch, len: m[1].length, start: offset };
19
- }
20
- else if (ch === open.ch && m[1].length >= open.len && m[2].trim() === "") {
21
- ranges.push([open.start, offset + line.length]);
99
+ // Matching-only copy: tabs advance to the next 4-column tab stop.
100
+ const probe = expandTabs(line);
101
+ const indent = probe.length - probe.replace(/^ +/, "").length;
102
+ const blank = probe.trim() === "";
103
+ if (open) {
104
+ if (!blank && indent < open.base) {
105
+ // The list item holding the fence ended, which ends the fence too.
106
+ ranges.push([open.start, offset - 1]);
22
107
  open = null;
23
108
  }
109
+ else {
110
+ const m = FENCE.exec(probe.slice(Math.min(open.base, indent)));
111
+ if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === "") {
112
+ ranges.push([open.start, offset + line.length]);
113
+ open = null;
114
+ }
115
+ offset += line.length + 1;
116
+ continue; // the item stack is frozen while a fence is open
117
+ }
118
+ }
119
+ if (blank) {
120
+ offset += line.length + 1;
121
+ inParagraph = false;
122
+ continue; // a blank line neither opens nor closes an item here
123
+ }
124
+ // A dedent that closes an item also closes the paragraph inside it, so the
125
+ // line is a fresh block start: `1. one\n2. two` is two sibling items, not
126
+ // item one's paragraph being "interrupted" by a `2.`.
127
+ let popped = false;
128
+ while (items.length && items.at(-1) > indent) {
129
+ items.pop();
130
+ popped = true;
131
+ }
132
+ const isBreak = thematicBreakSuffixes(probe);
133
+ // Walk the line with an INDEX, peeling one list marker per step. Each step
134
+ // is O(marker) and the cheap checks are O(1), so a line costs O(length)
135
+ // however many markers it carries.
136
+ let base = items.at(-1) ?? 0;
137
+ let opened = false;
138
+ let thematic = false;
139
+ for (;;) {
140
+ // Leading spaces of this nesting level: ≤3, else it is indented content
141
+ // (and ` ---` is paragraph text or code, never a thematic break).
142
+ let i = base;
143
+ while (i < probe.length && probe[i] === " " && i - base < 4)
144
+ i++;
145
+ const lead = i - base;
146
+ if (lead > 3)
147
+ break;
148
+ if (isBreak[i]) {
149
+ thematic = true;
150
+ break;
151
+ } // thematic break, not a list marker
152
+ const c = probe[i];
153
+ let markerLen = 0;
154
+ if (c === "-" || c === "*" || c === "+")
155
+ markerLen = 1;
156
+ else if (c !== undefined && c >= "0" && c <= "9") {
157
+ let d = i;
158
+ while (d < probe.length && probe[d] >= "0" && probe[d] <= "9" && d - i < 9)
159
+ d++;
160
+ if (probe[d] === "." || probe[d] === ")")
161
+ markerLen = d - i + 1;
162
+ }
163
+ if (!markerLen)
164
+ break;
165
+ // Spaces between the marker and the item's content.
166
+ let s = i + markerLen;
167
+ while (s < probe.length && probe[s] === " ")
168
+ s++;
169
+ const gap = s - (i + markerLen);
170
+ const emptyItem = s >= probe.length;
171
+ if (!emptyItem && gap === 0)
172
+ break; // `-foo` / `1.foo` is not a marker
173
+ if (!opened && !popped && inParagraph && items.length === 0) {
174
+ // Interrupting a paragraph: only a bullet, or `1.`/`1)`, and never with
175
+ // empty content. `2. the second point` mid-prose stays paragraph text.
176
+ const ordinal = markerLen > 1 ? probe.slice(i, i + markerLen - 1) : "";
177
+ if (emptyItem || (ordinal !== "" && ordinal !== "1"))
178
+ break;
179
+ }
180
+ // ≥5 spaces after the marker starts an indented code block, so the
181
+ // item's content begins one column after the marker instead.
182
+ const w = gap >= 1 && gap <= 4 ? gap : 1;
183
+ base = base + lead + markerLen + w;
184
+ items.push(base);
185
+ opened = true;
186
+ continue; // `- 1. x` nests, and "- ```js" opens a fence on the marker line
187
+ }
188
+ const m = FENCE.exec(probe.slice(base));
189
+ if (m) {
190
+ const ch = m[1][0];
191
+ if (!(ch === "`" && m[2].includes("`")))
192
+ open = { ch, len: m[1].length, start: offset, base };
24
193
  }
194
+ // A fence line or a thematic break ends the paragraph it follows; ordinary
195
+ // text (including an item's own content) continues or starts one.
196
+ inParagraph = !m && !thematic;
25
197
  offset += line.length + 1;
26
198
  }
27
199
  if (open)
@@ -31,7 +203,11 @@ function fencedRanges(text) {
31
203
  /** Character ranges covered by inline code spans (`…`), same rationale as
32
204
  * fencedRanges: prose quoting a marker in backticks is showing an example.
33
205
  * CommonMark-lite: an opener run pairs with the next run of the SAME length
34
- * on the same line; unpaired runs never open a span. */
206
+ * on the same line; unpaired runs never open a span.
207
+ * Expects LF-normalized text — see parseDocAnchors's normalization.
208
+ * `split("\n")` does not split a bare CR, so on CR-only input the whole
209
+ * document reads as one line and stray backticks on different lines
210
+ * falsely pair. */
35
211
  function inlineSpanRanges(text) {
36
212
  const ranges = [];
37
213
  let offset = 0;
@@ -55,6 +231,14 @@ function inlineSpanRanges(text) {
55
231
  * fenced code blocks or inline code spans are examples, not declarations,
56
232
  * and are skipped. */
57
233
  export function parseDocAnchors(text) {
234
+ // fencedRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
235
+ // "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
236
+ // lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
237
+ // docblock). Either way an example marker inside a fence/span registered as
238
+ // a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
239
+ // fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
240
+ // and with the line numbers reported below.
241
+ text = text.replace(/\r\n?/g, "\n");
58
242
  const out = [];
59
243
  const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
60
244
  MARKER.lastIndex = 0;
@@ -16,8 +16,13 @@ export function formatSearchHit(hit, record) {
16
16
  /** Render a StructureView as a compact orientation brief (hunch_structure). */
17
17
  export function formatStructure(v) {
18
18
  const NL = "\n";
19
- if (v.kind === "none")
19
+ if (v.kind === "none") {
20
+ // A real working-tree file with zero indexed symbols is a different answer from
21
+ // an unknown path — never tell the user a file we just stat'd doesn't exist.
22
+ if (v.realFile)
23
+ return `"${v.target}" is a real file, but the index holds no symbols for it — nothing to outline. Run hunch index if the repo changed, or hunch_why for its recorded decisions and invariants.`;
20
24
  return `Nothing indexed matches "${v.target}" — not a known file, directory, or symbol. Run hunch index if the repo changed, or hunch_query for fuzzy search.`;
25
+ }
21
26
  if (v.kind === "repo") {
22
27
  const out = [`# Repo structure (from the graph — no grep needed)`];
23
28
  if (v.components.length) {
@@ -7,3 +7,28 @@ export declare function pathMatchesGlob(path: string, glob: string): boolean;
7
7
  /** Do two concrete repo paths identify the same path at different prefix depth?
8
8
  * Segment-anchored: `x/scenario.ts` relates to `scenario.ts`, never `io.ts`. */
9
9
  export declare function pathsRelated(left: string, right: string): boolean;
10
+ /** True when `target` is a path Hunch's index already knows about: an indexed
11
+ * symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
12
+ * The latter catches a symbol-less-but-indexed file (README.md, package.json,
13
+ * Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
14
+ * known file. Derived entirely from already-loaded graph data — never the
15
+ * filesystem — so the answer doesn't depend on untracked working-tree state
16
+ * (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
17
+ * only HALF the "is this a real path" question: a real file with no symbols and
18
+ * no covering component is invisible here, so callers OR in `isRepoFile` as a
19
+ * last resort (issue #334) — `HunchStore.isKnownPath` is that composition. */
20
+ export declare function isIndexedPath(target: string, symbolFiles: Iterable<string>, componentPaths: Iterable<readonly string[]>): boolean;
21
+ /** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
22
+ * (only when `target` is NOT a path already known to be real) segment-anchored
23
+ * suffix. A real file with zero symbols must return [] rather than fall through
24
+ * to the suffix tier, which would leak an unrelated same-basename file's records
25
+ * (issues #299/#334). Callers decide what counts as "real": one that ATTRIBUTES
26
+ * records silently (why(), the pre-edit hook) passes `HunchStore.isKnownPath`, the
27
+ * wider graph-or-working-tree answer that also covers a file about to be created;
28
+ * one that NAMES the file it resolved to (resolveNodeIds, structure()) passes the
29
+ * narrower `isRepoFile`, since glob coverage alone is not existence. */
30
+ export declare function matchSymbolsTiered<S extends {
31
+ id: string;
32
+ file: string;
33
+ name: string;
34
+ }>(target: string, symbols: readonly S[], indexed: boolean): S[];
package/dist/core/glob.js CHANGED
@@ -66,4 +66,47 @@ export function pathsRelated(left, right) {
66
66
  const b = norm(right);
67
67
  return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
68
68
  }
69
+ /** True when `target` is a path Hunch's index already knows about: an indexed
70
+ * symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
71
+ * The latter catches a symbol-less-but-indexed file (README.md, package.json,
72
+ * Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
73
+ * known file. Derived entirely from already-loaded graph data — never the
74
+ * filesystem — so the answer doesn't depend on untracked working-tree state
75
+ * (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
76
+ * only HALF the "is this a real path" question: a real file with no symbols and
77
+ * no covering component is invisible here, so callers OR in `isRepoFile` as a
78
+ * last resort (issue #334) — `HunchStore.isKnownPath` is that composition. */
79
+ export function isIndexedPath(target, symbolFiles, componentPaths) {
80
+ for (const f of symbolFiles)
81
+ if (f === target)
82
+ return true;
83
+ for (const globs of componentPaths)
84
+ for (const g of globs)
85
+ if (pathMatchesGlob(target, g))
86
+ return true;
87
+ return false;
88
+ }
89
+ /** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
90
+ * (only when `target` is NOT a path already known to be real) segment-anchored
91
+ * suffix. A real file with zero symbols must return [] rather than fall through
92
+ * to the suffix tier, which would leak an unrelated same-basename file's records
93
+ * (issues #299/#334). Callers decide what counts as "real": one that ATTRIBUTES
94
+ * records silently (why(), the pre-edit hook) passes `HunchStore.isKnownPath`, the
95
+ * wider graph-or-working-tree answer that also covers a file about to be created;
96
+ * one that NAMES the file it resolved to (resolveNodeIds, structure()) passes the
97
+ * narrower `isRepoFile`, since glob coverage alone is not existence. */
98
+ export function matchSymbolsTiered(target, symbols, indexed) {
99
+ const byId = symbols.find((s) => s.id === target);
100
+ if (byId)
101
+ return [byId];
102
+ const byName = symbols.filter((s) => s.name === target);
103
+ if (byName.length)
104
+ return byName;
105
+ const exact = symbols.filter((s) => s.file === target);
106
+ if (exact.length)
107
+ return exact;
108
+ if (indexed)
109
+ return [];
110
+ return symbols.filter((s) => pathsRelated(s.file, target));
111
+ }
69
112
  //# sourceMappingURL=glob.js.map
@@ -1,7 +1,14 @@
1
1
  /** Decide whether this injection should be the FULL grounding block or a delta
2
2
  * one-liner. Records the content hash as a side effect (so the next identical
3
- * call dedups). Never throws. */
4
- export declare function injectionMode(sessionId: string | undefined, key: string, content: string): "full" | "delta";
3
+ * call dedups). Never throws.
4
+ *
5
+ * `hashInput` lets a caller dedup on a STABLE PROJECTION of the block instead
6
+ * of its presentation: some grounding is self-invalidating —
7
+ * serving it writes delivery receipts, and the next call's wording moves
8
+ * ("today" → "delivered today") with no record change, so hashing the rendered
9
+ * text re-sends the full block forever. Callers pass the identity of the
10
+ * underlying records; omitting it hashes `content`, the original contract. */
11
+ export declare function injectionMode(sessionId: string | undefined, key: string, content: string, hashInput?: string): "full" | "delta";
5
12
  /** Forget everything injected into a session. Compaction summarizes injected
6
13
  * grounding out of the agent's context while the dedup map still says
7
14
  * "delivered" — so on PreCompact / SessionStart[source=compact] the map must
@@ -23,8 +23,15 @@ const MAX_KEYS = 300;
23
23
  const SWEEP_AGE_MS = 48 * 3600 * 1000;
24
24
  /** Decide whether this injection should be the FULL grounding block or a delta
25
25
  * one-liner. Records the content hash as a side effect (so the next identical
26
- * call dedups). Never throws. */
27
- export function injectionMode(sessionId, key, content) {
26
+ * call dedups). Never throws.
27
+ *
28
+ * `hashInput` lets a caller dedup on a STABLE PROJECTION of the block instead
29
+ * of its presentation: some grounding is self-invalidating —
30
+ * serving it writes delivery receipts, and the next call's wording moves
31
+ * ("today" → "delivered today") with no record change, so hashing the rendered
32
+ * text re-sends the full block forever. Callers pass the identity of the
33
+ * underlying records; omitting it hashes `content`, the original contract. */
34
+ export function injectionMode(sessionId, key, content, hashInput = content) {
28
35
  try {
29
36
  if (!sessionId || process.env.HUNCH_HOOK_DEDUP === "0")
30
37
  return "full";
@@ -32,7 +39,7 @@ export function injectionMode(sessionId, key, content) {
32
39
  mkdirSync(dir, { recursive: true });
33
40
  sweep(dir);
34
41
  const file = join(dir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
35
- const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
42
+ const hash = createHash("sha256").update(hashInput).digest("hex").slice(0, 16);
36
43
  let map;
37
44
  try {
38
45
  const raw = JSON.parse(readFileSync(file, "utf8"));
@@ -1,10 +1,53 @@
1
1
  export declare const HUNCH_DIR = ".hunch";
2
- /** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
3
- * every path with forward slashes (git emits "/" on all OSes), so any user- or
4
- * agent-supplied target must be normalized before comparison — otherwise a
5
- * Windows caller passing `src\auth\session.ts` never matches the stored
6
- * `src/auth/session.ts`. Safe on symbol names too: they contain no backslashes. */
2
+ /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
3
+ * path with "/" — git emits it on all OSes — so any user- or agent-supplied
4
+ * target must be normalized before comparison, otherwise a Windows caller passing
5
+ * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
6
+ * symbol names too: they contain no backslashes. This does NOT make a path
7
+ * repo-relative — an absolute path passes through with its separators flipped,
8
+ * unchanged otherwise; use `repoRelativeTarget` for that. */
7
9
  export declare function toPosixTarget(target: string): string;
10
+ /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
11
+ * to Write, or a glob whose concrete segments aren't literal): resolve the longest
12
+ * existing ancestor, then re-append the missing tail. Idempotent on an
13
+ * already-resolved path. */
14
+ export declare function realpathNorm(p: string): string;
15
+ /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
16
+ * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
17
+ * repo-relative, so a caller that hands over an absolute edit-payload path
18
+ * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
19
+ * returned empty for an absolute target that plainly had a matching rule). A
20
+ * relative target, a glob, or an absolute path outside `root` passes through
21
+ * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
22
+ *
23
+ * BOTH ends are realpath-normalized before the relative-path computation: on
24
+ * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
25
+ * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
26
+ * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
27
+ * caller treats the file as outside the repo, silently dropping all context
28
+ * (dec_e0a36efbf5). */
29
+ export declare function repoRelativeTarget(target: string, root: string): string;
30
+ /** True when `target` (already repo-relative POSIX form) names a regular FILE
31
+ * that really exists inside `root`. The LAST-RESORT half of the "is this a real
32
+ * path" question: `isIndexedPath` answers it from graph data alone, but a real
33
+ * working-tree file with zero tree-sitter symbols and no covering component
34
+ * glob is invisible to it, so it fell through to the suffix tier and leaked an
35
+ * unrelated same-basename file's records (issue #334). Callers consult the
36
+ * index FIRST and only fall back here, so a deleted-but-still-indexed path and
37
+ * a time-travel (`asOf`) query keep answering exactly as before.
38
+ *
39
+ * Deliberately narrow: an absolute path (or a Windows drive letter) and any
40
+ * target escaping `root` via ".." are rejected rather than resolved — the
41
+ * caller has already run `repoRelativeTarget`, so anything still absolute is
42
+ * outside the repo. A DIRECTORY is false: directory targets must keep flowing
43
+ * to `structure()`'s dir tier. Any fs error (missing, EACCES, ...) → false.
44
+ *
45
+ * Containment is checked twice: lexically, then again on the REALPATHS. `statSync`
46
+ * follows symlinks, so an in-repo `link -> /outside` would otherwise make
47
+ * `isRepoFile(root, "link/secret.ts")` true and turn this into a one-bit existence
48
+ * oracle for paths outside the repo. A symlinked file pointing at another file
49
+ * INSIDE the repo stays true. */
50
+ export declare function isRepoFile(root: string, target: string): boolean;
8
51
  export interface HunchPaths {
9
52
  /** Repo root (where .hunch/ lives). */
10
53
  root: string;