@davesheffer/hunch 1.45.2 → 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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -33,76 +33,34 @@ export interface Escalation {
33
33
  detail: string;
34
34
  /** the concrete resolution the human's answer maps to. */
35
35
  resolution: string;
36
- /** false ONLY for an entry whose own resolution requires acting on a
37
- * DIFFERENT entry first (a duplicate-id queue's non-drop, non-apply
38
- * follower) — it still surfaces, for transparency, but gating surfaces
39
- * (CI exit code, "needs your call" tallies) must not count it: resolving
40
- * the actionable entry ahead of it is what clears it, not anything a
41
- * human can do to this row directly (#61). Omitted (undefined) means
42
- * true — every other escalation kind is always directly actionable. */
36
+ /** `false` marks an entry whose resolution depends on first acting on a DIFFERENT
37
+ * entry: the non-drop, non-apply follower in a duplicate-id commit-repair queue.
38
+ * It still surfaces for transparency, but gating surfaces (the CI exit code and
39
+ * "needs your call" tallies) must not count it. Omitted/undefined = actionable. */
43
40
  actionable?: boolean;
44
41
  }
45
- /** Escalations gating surfaces (CI exit codes, "needs your call" counts/lists)
46
- * should act on: everything except an entry explicitly marked
47
- * actionable:false (#61). The full list — including non-actionable
48
- * entries — still belongs in any transparency-facing output (e.g. --json). */
42
+ /** The entries a human can act on right now (order preserved). */
49
43
  export declare function actionableEscalations(items: readonly Escalation[]): Escalation[];
50
44
  export interface EscalationSummary {
51
- /** the subset a gating surface should count/exit/ask on. */
45
+ /** the gating subset. */
52
46
  actionable: Escalation[];
53
- /** how many entries were filtered out — informational-only, shown for
54
- * transparency but not itself a decision to raise. */
47
+ /** how many entries were filtered out as context-only. */
55
48
  context: number;
56
49
  }
57
- /** One computation for every "N need your call (+M shown for context)"
58
- * consumer (CLI `escalations`, SessionStart, the `hunch_now`/`hunch_escalations`
59
- * MCP tools) — introduced alongside `actionable` (#61) specifically so a
60
- * future consumer of Escalation[] can't repeat the mistake of hand-rolling
61
- * the filter and forgetting it (the VS Code panel did, before this existed). */
62
50
  export declare function summarizeEscalations(items: readonly Escalation[]): EscalationSummary;
63
- /** "you" (the CLI, talking directly to the operator) vs "mcp" (an assistant
64
- * relaying a question to the human, third person). */
51
+ /** "cli" speaks to the operator (second person); "mcp" is an assistant relaying to the human (third person). */
65
52
  export type EscalationAudience = "cli" | "mcp";
66
- /** The ONE "N need your call (+M shown for context)" headline, shared by the
67
- * CLI `escalations` command and the `hunch_escalations` MCP tool (#61) —
68
- * including its own "nothing actionable, but N item(s) queued for context"
69
- * fallback, so that unreachable-today prose exists in exactly one place
70
- * instead of being hand-copied per consumer (the copy-paste is how the VS
71
- * Code panel missed the filter entirely before `actionable` existed).
72
- * Callers are expected to have already special-cased `items.length === 0`
73
- * (a plainer "nothing needs your decision" message, no tally at all). */
53
+ /** One-line heading for a non-empty escalation list. Callers handle the empty list themselves. */
74
54
  export declare function escalationHeadline(items: readonly Escalation[], audience: EscalationAudience): string;
75
55
  /** The decisions a human must make NOW, to be asked INLINE. Empty in a healthy graph. */
76
56
  export declare function pendingEscalations(decisions: readonly Decision[]): Escalation[];
77
- /** A commit-provenance repair the post-merge hook detected and queued
78
- * (src/core/repairqueue.ts) but never applied — the match signal (related_files
79
- * overlap) isn't strong enough to write unattended, so it's a question for the
80
- * human, exactly like every other entry here, never a silent write.
81
- *
82
- * `decisions` is the caller's VISIBLE scope (supplies the title only) —
83
- * MCP/SessionStart pass store.advisoryRecs(), which is public-only in
84
- * private mode. `live` is the scope liveRewrites checks the repair against;
85
- * it defaults to `decisions` but callers on an advisory scope must pass the
86
- * FULL store (store.recs()) here, since repair-provenance itself reads the
87
- * full store — an overlay decision's repair is fully answerable even where
88
- * its title isn't visible, and must not go silent just because the title is
89
- * private.
90
- *
91
- * `withheld` (from repairqueue.ts's withheldRewrites — a git-facing check
92
- * this module deliberately stays free of, so the caller runs it and passes
93
- * the result in) names entries whose proposed `to` doesn't resolve to a
94
- * real commit here. `--apply --only <id>` can never resolve one of these —
95
- * advertising it as the answer would be advice guaranteed to no-op
96
- * forever, which is worse than not asking at all. Such an entry still
97
- * needs a human (only `--drop` can retire it), so it still escalates, just
98
- * with wording that doesn't promise `--apply` will help.
99
- *
100
- * Keyed by OBJECT, not by id, matching withheldForUnresolvableTo's own
101
- * contract: a corrupted queue file can carry two entries sharing an id
102
- * (one resolvable, one not) — an id-keyed set couldn't tell them apart and
103
- * would wrongly tag the resolvable sibling as unresolvable too. Every
104
- * caller of this function already passes the SAME `queued` array to both
105
- * this function and withheldRewrites, so identity holds. */
57
+ /** A commit-provenance repair the post-merge hook queued but never applied is a human
58
+ * question, never a silent write. `decisions` is the caller's VISIBLE scope and supplies
59
+ * titles only. `live` is the scope liveness is checked against: callers on an advisory
60
+ * scope pass the full store so an overlay decision's repair isn't silenced just because
61
+ * its title is private. `withheld` holds entries whose proposed `to` doesn't resolve to a
62
+ * real commit; membership is by object identity, not id, so two corrupted entries
63
+ * sharing an id stay distinguishable. */
106
64
  export declare function commitRepairEscalations(queued: readonly CommitRewrite[], decisions: readonly Decision[], live?: readonly Decision[], withheld?: ReadonlySet<CommitRewrite>): Escalation[];
107
65
  /** The minimal policy shape the escalation scan needs — a structural subset of
108
66
  * constitution PolicySpec, so this module stays dependency-free of the
@@ -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
@@ -10,6 +10,9 @@ export interface Migration {
10
10
  /** The version this migration PRODUCES (i.e. it upgrades version-1 → `version`). */
11
11
  version: number;
12
12
  description: string;
13
+ /** The kinds `up` changes. Omitted = it may touch any kind (conservative: a
14
+ * failed record of any kind then holds the manifest version). */
15
+ kinds?: EntityKind[];
13
16
  /** Transform one raw record (untrusted JSON) of `kind` to the next shape. */
14
17
  up(kind: EntityKind, raw: Record<string, unknown>): Record<string, unknown>;
15
18
  }
@@ -24,5 +27,11 @@ export declare function readManifest(paths: HunchPaths): Manifest;
24
27
  /** Write `.hunch/manifest.json` at `version` (default: the current SCHEMA_VERSION). */
25
28
  export declare function writeManifest(paths: HunchPaths, version?: number): void;
26
29
  /** Apply every migration in (fromVersion, toVersion] to a single raw record. Skips
27
- * non-object input untouched (the loader's Zod pass will reject it). */
30
+ * non-object input untouched (the loader's Zod pass will reject it). All-or-nothing:
31
+ * the steps run on a deep copy, so `raw` is never mutated and a caller that rejects
32
+ * the result (or a step that throws) keeps the original record intact — never a
33
+ * half-migrated one. */
28
34
  export declare function migrateRaw(kind: EntityKind, raw: unknown, fromVersion: number, migrations?: Migration[], toVersion?: number): unknown;
35
+ /** Whether some migration in (fromVersion, toVersion] may change a record of
36
+ * `kind` — i.e. whether re-running migrations could ever fix a failed one. */
37
+ export declare function hasPendingMigration(kind: EntityKind, fromVersion: number, migrations?: Migration[], toVersion?: number): boolean;