@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.
- package/dist/cli/index.js +151 -321
- package/dist/cli/invocation.d.ts +8 -13
- package/dist/cli/invocation.js +26 -29
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/correction.js +9 -9
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +45 -64
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +126 -178
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +28 -37
- package/dist/extractors/languages.js +55 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +7 -13
- package/dist/extractors/parse.js +39 -51
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/hooks.d.ts +9 -14
- package/dist/integrations/hooks.js +19 -28
- package/dist/integrations/mergeDriver.js +13 -14
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +60 -73
- package/dist/store/hunchStore.js +12 -18
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +67 -76
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +32 -49
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/core/escalations.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
return
|
|
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
|
|
37
|
-
return
|
|
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
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
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
|
-
?
|
|
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 (!
|
|
177
|
-
//
|
|
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 (!
|
|
187
|
-
//
|
|
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?`,
|
package/dist/core/glob.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
|
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
|
|
106
|
-
if (
|
|
107
|
-
return
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
-
/**
|
|
33
|
-
|
|
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;
|
|
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
|
-
/**
|
|
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
|
|
60
|
-
if (
|
|
51
|
+
const out = resolveHunk(ours, theirs);
|
|
52
|
+
if (out === null) {
|
|
61
53
|
allResolved = false;
|
|
62
54
|
return whole;
|
|
63
55
|
}
|
|
64
|
-
return
|
|
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
|
-
/**
|
|
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
|
|
80
|
-
return { conflict: false, write:
|
|
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 =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export declare const HUNCH_DIR = ".hunch";
|
|
2
|
-
/**
|
|
3
|
-
* path with "/"
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
19
|
-
* to
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
24
|
-
* `root`. Hunch
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|