@davesheffer/hunch 1.46.0 → 1.47.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 (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -2,39 +2,26 @@ import { topicCollisions } from "./topics.js";
2
2
  import { liveRewrites, deadRewrites, commitRepairReviewHash, firstFor } from "./commitrepair.js";
3
3
  import { importedAdrReviewHash, importedAdrSourceHash, pendingImportedAdrReviews } from "./importReview.js";
4
4
  import { pathMatchesGlob } from "./glob.js";
5
- /** Escalations gating surfaces (CI exit codes, "needs your call" counts/lists)
6
- * should act on: everything except an entry explicitly marked
7
- * actionable:false (#61). The full list — including non-actionable
8
- * entries — still belongs in any transparency-facing output (e.g. --json). */
5
+ /** The entries a human can act on right now (order preserved). */
9
6
  export function actionableEscalations(items) {
10
7
  return items.filter((e) => e.actionable !== false);
11
8
  }
12
- /** One computation for every "N need your call (+M shown for context)"
13
- * consumer (CLI `escalations`, SessionStart, the `hunch_now`/`hunch_escalations`
14
- * MCP tools) — introduced alongside `actionable` (#61) specifically so a
15
- * future consumer of Escalation[] can't repeat the mistake of hand-rolling
16
- * the filter and forgetting it (the VS Code panel did, before this existed). */
17
9
  export function summarizeEscalations(items) {
18
10
  const actionable = actionableEscalations(items);
19
11
  return { actionable, context: items.length - actionable.length };
20
12
  }
21
- /** The ONE "N need your call (+M shown for context)" headline, shared by the
22
- * CLI `escalations` command and the `hunch_escalations` MCP tool (#61) —
23
- * including its own "nothing actionable, but N item(s) queued for context"
24
- * fallback, so that unreachable-today prose exists in exactly one place
25
- * instead of being hand-copied per consumer (the copy-paste is how the VS
26
- * Code panel missed the filter entirely before `actionable` existed).
27
- * Callers are expected to have already special-cased `items.length === 0`
28
- * (a plainer "nothing needs your decision" message, no tally at all). */
13
+ /** One-line heading for a non-empty escalation list. Callers handle the empty list themselves. */
29
14
  export function escalationHeadline(items, audience) {
30
15
  const { actionable, context } = summarizeEscalations(items);
31
- if (actionable.length) {
32
- const call = audience === "cli" ? "your call — asked here, never decided for you" : "the human's call — ask each inline, don't decide it for them";
33
- const note = audience === "cli" ? "not gating" : "not a decision";
34
- return `${actionable.length} decision(s) need ${call}${context ? ` (+${context} shown below for context only, ${note})` : ""}:`;
16
+ const mcp = audience === "mcp";
17
+ if (actionable.length === 0) {
18
+ const t = items.length;
19
+ return `Nothing needs ${mcp ? "the human's call" : "your decision"} right now — ${t} ${t === 1 ? "entry" : "entries"} shown below for context only (resolving another entry will clear them):`;
35
20
  }
36
- const whose = audience === "cli" ? "your decision" : "the human's call";
37
- return `Nothing needs ${whose} right now — ${items.length} entr${items.length === 1 ? "y" : "ies"} shown below for context only (resolving another entry will clear them):`;
21
+ const note = context > 0 ? ` (+${context} shown below for context only, not ${mcp ? "a decision" : "gating"})` : "";
22
+ return mcp
23
+ ? `${actionable.length} decision(s) need the human's call — ask each inline, don't decide it for them${note}:`
24
+ : `${actionable.length} decision(s) need your call — asked here, never decided for you${note}:`;
38
25
  }
39
26
  /** The decisions a human must make NOW, to be asked INLINE. Empty in a healthy graph. */
40
27
  export function pendingEscalations(decisions) {
@@ -71,123 +58,54 @@ export function pendingEscalations(decisions) {
71
58
  }
72
59
  return out;
73
60
  }
74
- /** A commit-provenance repair the post-merge hook detected and queued
75
- * (src/core/repairqueue.ts) but never applied — the match signal (related_files
76
- * overlap) isn't strong enough to write unattended, so it's a question for the
77
- * human, exactly like every other entry here, never a silent write.
78
- *
79
- * `decisions` is the caller's VISIBLE scope (supplies the title only) —
80
- * MCP/SessionStart pass store.advisoryRecs(), which is public-only in
81
- * private mode. `live` is the scope liveRewrites checks the repair against;
82
- * it defaults to `decisions` but callers on an advisory scope must pass the
83
- * FULL store (store.recs()) here, since repair-provenance itself reads the
84
- * full store — an overlay decision's repair is fully answerable even where
85
- * its title isn't visible, and must not go silent just because the title is
86
- * private.
87
- *
88
- * `withheld` (from repairqueue.ts's withheldRewrites — a git-facing check
89
- * this module deliberately stays free of, so the caller runs it and passes
90
- * the result in) names entries whose proposed `to` doesn't resolve to a
91
- * real commit here. `--apply --only <id>` can never resolve one of these —
92
- * advertising it as the answer would be advice guaranteed to no-op
93
- * forever, which is worse than not asking at all. Such an entry still
94
- * needs a human (only `--drop` can retire it), so it still escalates, just
95
- * with wording that doesn't promise `--apply` will help.
96
- *
97
- * Keyed by OBJECT, not by id, matching withheldForUnresolvableTo's own
98
- * contract: a corrupted queue file can carry two entries sharing an id
99
- * (one resolvable, one not) — an id-keyed set couldn't tell them apart and
100
- * would wrongly tag the resolvable sibling as unresolvable too. Every
101
- * caller of this function already passes the SAME `queued` array to both
102
- * this function and withheldRewrites, so identity holds. */
61
+ /** A commit-provenance repair the post-merge hook queued but never applied is a human
62
+ * question, never a silent write. `decisions` is the caller's VISIBLE scope and supplies
63
+ * titles only. `live` is the scope liveness is checked against: callers on an advisory
64
+ * scope pass the full store so an overlay decision's repair isn't silenced just because
65
+ * its title is private. `withheld` holds entries whose proposed `to` doesn't resolve to a
66
+ * real commit; membership is by object identity, not id, so two corrupted entries
67
+ * sharing an id stay distinguishable. */
103
68
  export function commitRepairEscalations(queued, decisions, live = decisions, withheld = new Set()) {
104
69
  const reviewHash = commitRepairReviewHash(queued, withheld);
105
- const byId = new Map(decisions.map((d) => [d.id, d]));
106
- // Mirrors two of the three queue transformations --apply/--drop actually see
107
- // (src/cli/index.ts): dead entries (deadRewrites) are pruned via `save()`
108
- // before either command ever reads `queue`, so a duplicate-id check against
109
- // the RAW `queued` array can name an entry that's already gone by the time a
110
- // human acts (#59). Deliberately does NOT model the fresh-detection merge
111
- // (mergeRewrites) that also runs before either command: a fresh match can
112
- // silently evict firstFor(queue, id) with no tombstone. That gap predates
113
- // this fix (see #56) and isn't new here — it just means a queue whose first
114
- // entry is about to be evicted by a fresh match still reads as the ordinary,
115
- // ask-normally case rather than something rarer.
116
- // deadRewrites (below) and liveRewrites (in the return) are BOTH called with
117
- // this same `live` array, so for any one id they agree on the same single
118
- // decision (or the same absence) governing it. That decision's `commit`/
119
- // `status` are fixed for the whole id-group — only each entry's OWN `from`
120
- // varies — so liveRewrites' and deadRewrites' predicates are exact
121
- // complements entry-by-entry (never uniform across the group: two entries
122
- // sharing an id can freely land on opposite sides, see the "dead first
123
- // sibling" test, #59). `survivors` (queued minus dead) restricted to one id
124
- // is therefore exactly that id's live entries, in the same queue order.
125
- // Consequence relied on below (#61): the FIRST live entry per id is always
126
- // `dropTarget(id)`, so it always stays actionable — no id-group's
127
- // escalations can ever be entirely actionable:false.
128
- const deadSet = new Set(deadRewrites(queued, live));
129
- const survivors = queued.filter((q) => !deadSet.has(q));
130
- // What `--drop <id>` targets (src/cli/index.ts: `firstFor(queue, opts.drop)`
131
- // on the post-prune queue) — --drop doesn't care whether an entry's `to`
132
- // resolves, so a withheld entry can still be the drop target.
70
+ const visible = new Map(decisions.map((d) => [d.id, d]));
71
+ // Mirror the CLI: dead entries are pruned before --apply/--drop read the queue.
72
+ const dead = new Set(deadRewrites(queued, live));
73
+ const survivors = queued.filter((r) => !dead.has(r));
133
74
  const dropTarget = (id) => firstFor(survivors, id);
134
- // What `--apply --only <id>` targets: its plan is built from
135
- // withheldForUnresolvableTo's `applicable` half (src/cli/index.ts:
136
- // `plan.rewrites = applicable`), which excludes withheld entries entirely —
137
- // so the apply-target can differ from the drop-target when an earlier
138
- // survivor for the same id is withheld. Routed through the shared `firstFor`
139
- // rather than hand-rolled, so this can't independently drift from
140
- // commitrepair.ts's own first-match rule (already drifted three times
141
- // across call sites there — #51/#53/#56 — before firstFor consolidated it).
142
- const applyTarget = (id) => firstFor(survivors.filter((q) => !withheld.has(q)), id);
143
- return liveRewrites(queued, live)
144
- .map((r) => {
145
- const title = byId.get(r.id)?.title;
146
- const named = `${r.id}${title ? ` ("${title}")` : ""}`;
147
- const base = { kind: "commit-repair-pending", topic: r.id, decisionIds: [r.id], detail: `${r.from} → ${r.to}` };
75
+ const applyTarget = (id) => firstFor(survivors.filter((r) => !withheld.has(r)), id);
76
+ return liveRewrites(queued, live).map((r) => {
77
+ const title = visible.get(r.id)?.title;
78
+ const named = title ? `${r.id} ("${title}")` : r.id;
79
+ const base = {
80
+ kind: "commit-repair-pending",
81
+ topic: r.id,
82
+ decisionIds: [r.id],
83
+ detail: `${r.from} → ${r.to}`,
84
+ };
148
85
  const applyQuestion = `${named}'s commit is no longer reachable from HEAD (likely squash-merged away), and one newly-merged commit touches all its related files — apply the proposed replacement?`;
149
- const isDropTarget = dropTarget(r.id) === r;
150
- const isApplyTarget = applyTarget(r.id) === r;
151
- if (!isDropTarget && !isApplyTarget) {
152
- // A duplicate-id queue (corrupted file, hand edit, or a bug upstream —
153
- // #53/#55/#56/#58): an earlier survivor for this id is what BOTH
154
- // commands would act on, never this entry (#59). If this entry is
155
- // itself withheld, say so too — that fact doesn't disappear just
156
- // because it's also unreachable by id right now, and it stays
157
- // drop-only even once it's next in line (never permanently: a fresh
158
- // detection run can still supersede it with a resolvable match, same
159
- // as the ordinary withheld branch below).
86
+ const isDrop = dropTarget(r.id) === r;
87
+ const isApply = applyTarget(r.id) === r;
88
+ if (!isDrop && !isApply) {
160
89
  const alsoWithheld = withheld.has(r)
161
- ? " — and its own proposed replacement doesn't resolve here either, so even once it's next in line it stays drop-only unless a fresh detection run supersedes it with a resolvable match"
90
+ ? ` — and this entry's own proposed replacement doesn't resolve here either, so even once it is next in line it stays drop-only unless a fresh detection run supersedes it with a resolvable match`
162
91
  : "";
163
92
  return {
164
93
  ...base,
165
94
  actionable: false,
166
95
  question: `${named} has a further queued replacement candidate (${r.from} → ${r.to}) sitting behind another entry for the same decision — leave it queued for now?`,
167
- // Dropping the entry ahead brings this one back into play on the next
168
- // run (deadRewrites can no longer see a reason to prune it). Applying
169
- // it does the OPPOSITE for this entry: repairDecisionCommit moves the
170
- // decision's `commit` past the `from` both entries share, so the next
171
- // run's deadRewrites prunes THIS entry as stale — it is never
172
- // reconsidered, not "resolved the normal way".
173
96
  resolution: `not directly actionable by id right now: \`hunch repair-provenance --apply --only ${r.id} --expect ${reviewHash}\`/\`hunch repair-provenance --drop ${r.id} --expect ${reviewHash}\` both act on an entry queued ahead of it, never this one${alsoWithheld}. Dropping that entry brings this one back into consideration on the next run; applying it instead retires this one as stale, since the decision moves past the \`from\` both entries share.`,
174
97
  };
175
98
  }
176
- if (!isDropTarget) {
177
- // isApplyTarget is true here: this entry IS what --apply --only would
178
- // apply, but an earlier, WITHHELD sibling for the same id sits ahead
179
- // of it, so --drop <id> would tombstone that sibling instead (#59).
99
+ if (isApply && !isDrop) {
100
+ // An earlier withheld sibling sits ahead: --drop would tombstone that one instead.
180
101
  return {
181
102
  ...base,
182
103
  question: applyQuestion,
183
104
  resolution: `hunch repair-provenance --apply --only ${r.id} --expect ${reviewHash} to accept just this one — but \`--drop ${r.id}\` won't reject THIS entry: an earlier queued sibling for the same id (whose own replacement doesn't resolve here) sits ahead of it and would be tombstoned instead.`,
184
105
  };
185
106
  }
186
- if (!isApplyTarget) {
187
- // isDropTarget is true here, so this can only mean withheld.has(r):
188
- // a non-withheld first survivor is always its own apply-target too
189
- // (nothing withheld can precede it if it isn't withheld itself), so
190
- // isDropTarget && !isApplyTarget and "withheld" are the same fact.
107
+ if (isDrop && !isApply) {
108
+ // Equivalent to `r` being withheld.
191
109
  return {
192
110
  ...base,
193
111
  question: `${named}'s commit is no longer reachable from HEAD (likely squash-merged away), and the queued replacement commit doesn't resolve in this repository (corrupted queue entry, or the commit has since been garbage-collected) — reject it?`,
@@ -7,18 +7,18 @@ 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
10
+ /** True when `target` is a path the index already knows: an indexed symbol's
11
+ * exact file, or a path covered by an indexed component's `paths` globs (which
12
+ * catches symbol-less but indexed files such as README, package manifests,
13
+ * Dockerfiles). Derived only from already-loaded graph data, never the
14
+ * filesystem, so the answer cannot depend on untracked working-tree state
16
15
  * (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
17
16
  * only HALF the "is this a real path" question: a real file with no symbols and
18
17
  * no covering component is invisible here, so callers OR in `isRepoFile` as a
19
18
  * last resort (issue #334) — `HunchStore.isKnownPath` is that composition. */
20
19
  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 >
20
+ /** Resolve the symbols a `target` names, strictly tiered, first non-empty tier
21
+ * wins: exact id, then exact name, then exact file, then — conditionally —
22
22
  * (only when `target` is NOT a path already known to be real) segment-anchored
23
23
  * suffix. A real file with zero symbols must return [] rather than fall through
24
24
  * to the suffix tier, which would leak an unrelated same-basename file's records
package/dist/core/glob.js CHANGED
@@ -66,12 +66,11 @@ 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
69
+ /** True when `target` is a path the index already knows: an indexed symbol's
70
+ * exact file, or a path covered by an indexed component's `paths` globs (which
71
+ * catches symbol-less but indexed files such as README, package manifests,
72
+ * Dockerfiles). Derived only from already-loaded graph data, never the
73
+ * filesystem, so the answer cannot depend on untracked working-tree state
75
74
  * (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
76
75
  * only HALF the "is this a real path" question: a real file with no symbols and
77
76
  * no covering component is invisible here, so callers OR in `isRepoFile` as a
@@ -80,13 +79,15 @@ export function isIndexedPath(target, symbolFiles, componentPaths) {
80
79
  for (const f of symbolFiles)
81
80
  if (f === target)
82
81
  return true;
83
- for (const globs of componentPaths)
82
+ for (const globs of componentPaths) {
84
83
  for (const g of globs)
85
84
  if (pathMatchesGlob(target, g))
86
85
  return true;
86
+ }
87
87
  return false;
88
88
  }
89
- /** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
89
+ /** Resolve the symbols a `target` names, strictly tiered, first non-empty tier
90
+ * wins: exact id, then exact name, then exact file, then — conditionally —
90
91
  * (only when `target` is NOT a path already known to be real) segment-anchored
91
92
  * suffix. A real file with zero symbols must return [] rather than fall through
92
93
  * to the suffix tier, which would leak an unrelated same-basename file's records
@@ -102,9 +103,9 @@ export function matchSymbolsTiered(target, symbols, indexed) {
102
103
  const byName = symbols.filter((s) => s.name === target);
103
104
  if (byName.length)
104
105
  return byName;
105
- const exact = symbols.filter((s) => s.file === target);
106
- if (exact.length)
107
- return exact;
106
+ const byFile = symbols.filter((s) => s.file === target);
107
+ if (byFile.length)
108
+ return byFile;
108
109
  if (indexed)
109
110
  return [];
110
111
  return symbols.filter((s) => pathsRelated(s.file, target));
@@ -38,16 +38,9 @@ export interface GroundingCounts {
38
38
  }
39
39
  /** Record kinds whose committed count may only ever lag behind the store. */
40
40
  export declare const APPEND_ONLY_COUNT_KINDS: readonly ["decisions", "bugs", "constraints", "components", "policies"];
41
- /** Render the counts sentence's bold `**N decisions, ...**` segment — the
42
- * exact text parseGroundingCounts looks for. The single source of truth for
43
- * the format, shared by the doc generator (claudemd.ts) and the grounding
44
- * merge driver (groundingMerge.ts) so they can never drift out of sync. */
41
+ /** Render the bold counts segment `parseGroundingCounts` reads. Shared by claudemd.ts and the merge driver. */
45
42
  export declare function renderCountsMatch(counts: GroundingCounts): string;
46
- /** Replace a counts-sentence match with a neutral placeholder, so the rest of
47
- * two texts can be compared for equality regardless of their counts. Shared
48
- * by classifyGroundingBlock (committed doc vs computed) and the grounding
49
- * merge driver (ours vs theirs) — both ask "do these two texts differ ONLY
50
- * in the counts sentence?" */
43
+ /** `text` with its counts segment swapped for a neutral token, so two texts compare equal regardless of counts. */
51
44
  export declare function stripCountsMatch(text: string, match: string): string;
52
45
  export declare function parseGroundingCounts(block: string): {
53
46
  counts: GroundingCounts;
@@ -35,20 +35,14 @@ export function groundingTemplate(block) {
35
35
  const COUNTS_RE = /\*\*(\d+) decisions?, (\d+) bugs?, (\d+) constraints?, (\d+) components?, (\d+) polic(?:y|ies)(?:, (\d+) open findings?)?\*\*/;
36
36
  /** Record kinds whose committed count may only ever lag behind the store. */
37
37
  export const APPEND_ONLY_COUNT_KINDS = ["decisions", "bugs", "constraints", "components", "policies"];
38
- /** Render the counts sentence's bold `**N decisions, ...**` segment — the
39
- * exact text parseGroundingCounts looks for. The single source of truth for
40
- * the format, shared by the doc generator (claudemd.ts) and the grounding
41
- * merge driver (groundingMerge.ts) so they can never drift out of sync. */
38
+ /** Render the bold counts segment `parseGroundingCounts` reads. Shared by claudemd.ts and the merge driver. */
42
39
  export function renderCountsMatch(counts) {
43
- return `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components, ${counts.policies} policies${counts.findings ? `, ${counts.findings} open findings` : ""}**`;
40
+ const tail = counts.findings ? `, ${counts.findings} open findings` : "";
41
+ return `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components, ${counts.policies} policies${tail}**`;
44
42
  }
45
- /** Replace a counts-sentence match with a neutral placeholder, so the rest of
46
- * two texts can be compared for equality regardless of their counts. Shared
47
- * by classifyGroundingBlock (committed doc vs computed) and the grounding
48
- * merge driver (ours vs theirs) — both ask "do these two texts differ ONLY
49
- * in the counts sentence?" */
43
+ /** `text` with its counts segment swapped for a neutral token, so two texts compare equal regardless of counts. */
50
44
  export function stripCountsMatch(text, match) {
51
- return text.replace(match, "<counts>");
45
+ return text.replace(match, () => "<counts>");
52
46
  }
53
47
  export function parseGroundingCounts(block) {
54
48
  const m = COUNTS_RE.exec(block);
@@ -1,14 +1,9 @@
1
- /** Given `git merge-file --diff3 -L ours -L base -L theirs` output for a
2
- * generated grounding doc, auto-resolve it if every conflicting hunk is
3
- * confined to the counts sentence; otherwise return it untouched. */
1
+ /** Input is `git merge-file --diff3 -L ours -L base -L theirs` output. */
4
2
  export declare function resolveGroundingConflicts(diff3Text: string): {
5
3
  conflict: boolean;
6
4
  text: string;
7
5
  };
8
- /** Run `git merge-file --diff3` on real files and resolve the result.
9
- * `write: null` means git itself errored (e.g. one side is binary) — never
10
- * guess content in that case; leave the file exactly as git already
11
- * populated it before invoking this driver. */
6
+ /** Three-way merge of a grounding doc; the CLI writes `write` to the ours path and exits 1 on conflict. */
12
7
  export declare function mergeGroundingFile(basePath: string, oursPath: string, theirsPath: string): {
13
8
  conflict: boolean;
14
9
  write: string | null;
@@ -1,37 +1,31 @@
1
1
  /**
2
- * Merge driver for the generated grounding block's record-counts sentence
3
- * (dec_ba5b0dfa22, superseding dec_91da20a46b's "drop the counts sentence"):
4
- * resolves a HARD git conflict when it is confined to that one line by
5
- * taking the ELEMENT-WISE MAX of ours's and theirs's counts, per field, as a
6
- * placeholder. Picking `ours` unconditionally would discard whichever side
7
- * carried the HIGHER count — and a higher count can itself be the
8
- * ahead-of-store signal fnd_6391b4242f depends on (a branch whose doc claims
9
- * a record its own committed store never received). Taking the max instead:
10
- * when neither side already carries that defect, the merged store's counts
11
- * can only be >= max(ours, theirs) (decisions/bugs/constraints/components/
12
- * policies are append-only, and the merged store is the union of both
13
- * sides' committed records), so the doc reads as "lagging" — self-healed by
14
- * the post-merge hook's `hunch grounding --refresh` — never falsely "ahead".
15
- * But when one side DOES already carry the defect, its inflated count
16
- * survives into the resolution and correctly still reads as "ahead" against
17
- * the true merged store, instead of being silently discarded. Any conflict
18
- * outside the counts sentence, anywhere in the file, is left untouched with
19
- * standard diff3 markers for a human to resolve.
2
+ * Merge driver for the generated grounding block's record-counts sentence.
3
+ *
4
+ * When two branches each regenerate the block, git reports a hard conflict on
5
+ * that one line. This driver resolves a conflict confined to it by taking the
6
+ * element-wise maximum of the two sides' counts.
7
+ *
8
+ * "Take ours" would be simpler but could discard a higher count, and a higher
9
+ * count may be the ahead-of-store signal that freshness classification relies
10
+ * on. The maximum is safe: append-only kinds only grow and the merged store is
11
+ * the union of both sides, so the result reads as "lagging" (self-healed by the
12
+ * post-merge `hunch grounding --refresh`), never falsely "ahead". An inflated
13
+ * count from a side that genuinely carries the defect survives and still reads
14
+ * "ahead".
15
+ *
16
+ * A conflict anywhere else is left untouched, with standard diff3 markers.
20
17
  */
21
18
  import { execFileSync } from "node:child_process";
22
19
  import { parseGroundingCounts, stripCountsMatch, renderCountsMatch } from "./groundingLag.js";
23
20
  import { normalizeEol } from "./eol.js";
24
21
  // The entry point normalizes EOL before matching. `^...$` with /m anchors
25
22
  // each marker at its own line start rather than
26
- // requiring a specific preceding/following literal newline, so a hunk whose
27
- // ours or theirs side is EMPTY (one side deleted the line, the other edited
28
- // it) still matches — with a literal `\n` requirement there, git's real
29
- // output for that shape has no such newline to match, so the whole hunk
30
- // silently fails to match at all.
23
+ // demanding a literal newline before or after a marker: a hunk with an empty
24
+ // side (one side deleted the line, the other edited it) has no such newline in
25
+ // git's real output, so a pattern requiring one would silently miss it.
31
26
  const CONFLICT_RE = /^<<<<<<< ours\n([\s\S]*?)^\|\|\|\|\|\|\| base\n[\s\S]*?^=======\n([\s\S]*?)^>>>>>>> theirs[^\n]*\n?/gm;
32
- /** `null` when the hunk isn't confined to the counts sentence; otherwise the
33
- * resolved sentence text, with each field taken as max(ours, theirs). */
34
- function resolveCountsOnlyHunk(ours, theirs) {
27
+ /** Resolve one hunk's ours/theirs text, or null when it is not confined to the counts sentence. */
28
+ function resolveHunk(ours, theirs) {
35
29
  const o = parseGroundingCounts(ours);
36
30
  const t = parseGroundingCounts(theirs);
37
31
  if (!o || !t)
@@ -40,15 +34,13 @@ function resolveCountsOnlyHunk(ours, theirs) {
40
34
  return null;
41
35
  const keys = Object.keys(o.counts);
42
36
  if (keys.every((k) => o.counts[k] >= t.counts[k]))
43
- return ours; // ours already dominates every field verbatim
37
+ return ours;
44
38
  const combined = { ...o.counts };
45
39
  for (const k of keys)
46
40
  combined[k] = Math.max(o.counts[k], t.counts[k]);
47
- return ours.replace(o.match, renderCountsMatch(combined));
41
+ return ours.replace(o.match, () => renderCountsMatch(combined));
48
42
  }
49
- /** Given `git merge-file --diff3 -L ours -L base -L theirs` output for a
50
- * generated grounding doc, auto-resolve it if every conflicting hunk is
51
- * confined to the counts sentence; otherwise return it untouched. */
43
+ /** Input is `git merge-file --diff3 -L ours -L base -L theirs` output. */
52
44
  export function resolveGroundingConflicts(diff3Text) {
53
45
  if (!diff3Text.includes("<<<<<<< ours"))
54
46
  return { conflict: false, text: diff3Text };
@@ -56,42 +48,31 @@ export function resolveGroundingConflicts(diff3Text) {
56
48
  const normalized = normalizeEol(diff3Text);
57
49
  let allResolved = true;
58
50
  const resolved = normalized.replace(CONFLICT_RE, (whole, ours, theirs) => {
59
- const merged = resolveCountsOnlyHunk(ours, theirs);
60
- if (merged === null) {
51
+ const out = resolveHunk(ours, theirs);
52
+ if (out === null) {
61
53
  allResolved = false;
62
54
  return whole;
63
55
  }
64
- return merged;
56
+ return out;
65
57
  });
66
- // Defense in depth: a hunk shape CONFLICT_RE fails to recognize must never
67
- // read as resolved just because the callback never ran on it — if any
68
- // marker survives, this is a real conflict, full stop.
69
58
  if (!allResolved || resolved.includes("<<<<<<< ours"))
70
59
  return { conflict: true, text: diff3Text };
71
60
  return { conflict: false, text: eol === "\n" ? resolved : resolved.replace(/\n/g, eol) };
72
61
  }
73
- /** Run `git merge-file --diff3` on real files and resolve the result.
74
- * `write: null` means git itself errored (e.g. one side is binary) — never
75
- * guess content in that case; leave the file exactly as git already
76
- * populated it before invoking this driver. */
62
+ /** Three-way merge of a grounding doc; the CLI writes `write` to the ours path and exits 1 on conflict. */
77
63
  export function mergeGroundingFile(basePath, oursPath, theirsPath) {
78
64
  try {
79
- const merged = execFileSync("git", ["merge-file", "-p", "--diff3", "-L", "ours", "-L", "base", "-L", "theirs", oursPath, basePath, theirsPath], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
80
- return { conflict: false, write: merged };
65
+ const out = execFileSync("git", ["merge-file", "-p", "--diff3", "-L", "ours", "-L", "base", "-L", "theirs", oursPath, basePath, theirsPath], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
66
+ return { conflict: false, write: out };
81
67
  }
82
68
  catch (e) {
83
69
  const err = e;
84
- const status = typeof err.status === "number" ? err.status : -1;
85
- // `git merge-file`'s exit status is the number of conflicting hunks
86
- // (1-127) on a genuine 3-way merge attempt. Anything else (a negative
87
- // status, or >127) means git itself errored — e.g. "Cannot merge binary
88
- // files" exits 255 with EMPTY stdout — and treating that as diff3 output
89
- // silently truncates the file to nothing.
90
- if (status < 1 || status > 127)
91
- return { conflict: true, write: null };
92
- const out = typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? "");
93
- const res = resolveGroundingConflicts(out);
94
- return { conflict: res.conflict, write: res.text };
70
+ const status = err.status;
71
+ if (typeof status === "number" && status >= 1 && status <= 127) {
72
+ const res = resolveGroundingConflicts(err.stdout == null ? "" : String(err.stdout));
73
+ return { conflict: res.conflict, write: res.text };
74
+ }
75
+ return { conflict: true, write: null };
95
76
  }
96
77
  }
97
78
  //# sourceMappingURL=groundingMerge.js.map
@@ -1,10 +1,9 @@
1
1
  export declare const HUNCH_DIR = ".hunch";
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,
2
+ /** Convert a user- or agent-supplied path/target to forward-slash form. Hunch
3
+ * stores every path with "/" (git emits it on every OS), so a Windows-style
4
+ * backslash path would otherwise never match the stored form. Harmless on
5
+ * symbol names, which carry no backslashes. It does NOT make a path
6
+ * repo-relative: an absolute path only has its separators flipped and is left
8
7
  * unchanged otherwise; use `repoRelativeTarget` for that.
9
8
  *
10
9
  * Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
@@ -15,25 +14,19 @@ export declare const HUNCH_DIR = ".hunch";
15
14
  * needs the correct answer for a real file should decide from that evidence
16
15
  * instead of trusting this blindly. */
17
16
  export declare function toPosixTarget(target: string): string;
18
- /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
19
- * to Write, or a glob whose concrete segments aren't literal): resolve the longest
20
- * existing ancestor, then re-append the missing tail. Idempotent on an
21
- * already-resolved path. */
17
+ /** Canonical real path of `p`, tolerant of paths that do not exist yet (a file
18
+ * about to be written, a glob with non-literal segments): the longest existing
19
+ * ancestor is resolved through the native realpath and the missing trailing
20
+ * segments are re-appended unchanged. Terminates at the filesystem root,
21
+ * is idempotent on a resolved path and never throws for a missing one. */
22
22
  export declare function realpathNorm(p: string): string;
23
- /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
24
- * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
25
- * repo-relative, so a caller that hands over an absolute edit-payload path
26
- * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
27
- * returned empty for an absolute target that plainly had a matching rule). A
28
- * relative target, a glob, or an absolute path outside `root` passes through
29
- * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
30
- *
31
- * BOTH ends are realpath-normalized before the relative-path computation: on
32
- * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
33
- * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
34
- * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
35
- * caller treats the file as outside the repo, silently dropping all context
36
- * (dec_e0a36efbf5). */
23
+ /** Rewrite an ABSOLUTE `target` to repo-relative forward-slash form when it lies
24
+ * inside `root`. Stored Hunch paths are always repo-relative, so an absolute
25
+ * path lifted from an edit payload otherwise matches nothing (issue #296).
26
+ * Both ends go through `realpathNorm`, so a symlinked root (macOS /var vs
27
+ * /private/var, /tmp, a symlinked home) cancels out (dec_e0a36efbf5). Anything
28
+ * with no safe repo-relative form (the root itself, an escape, a drive-letter
29
+ * remainder) is returned in its normalized form unchanged. */
37
30
  export declare function repoRelativeTarget(target: string, root: string): string;
38
31
  /** True when `target` (already repo-relative POSIX form) names a regular FILE
39
32
  * that really exists inside `root`. The LAST-RESORT half of the "is this a real