@davesheffer/hunch 1.45.1 → 1.45.2

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.
@@ -142,6 +142,55 @@ export function promptPaths(prompt, root) {
142
142
  }
143
143
  return [...out];
144
144
  }
145
+ const ID_PREFIX = "id:";
146
+ const MIN_IDENTIFIER_CHARS = 5;
147
+ const IDENTIFIER_WEIGHT = 2;
148
+ const CAMEL_IDENTIFIER = /(?<![A-Za-z0-9])[A-Za-z0-9]*(?:[a-z][A-Z]|[A-Z]{2,}[a-z])[A-Za-z0-9]*/g;
149
+ const SEPARATED_IDENTIFIER = /(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]*(?:[_.\-][A-Za-z0-9]+)+/g;
150
+ const isCompound = (word) => /[a-z][A-Z]|[A-Z]{2,}[a-z]|_/.test(word);
151
+ const camelParts = (word) => word.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).length;
152
+ /** Compound identifiers in raw text (camel/Pascal, snake, dotted, kebab), lowercased with
153
+ * separators removed and marked `id:` so they never collide with word tokens. A dotted
154
+ * name also yields its last segment when that is itself compound (`dbo.ShowInForms` ->
155
+ * `id:dboshowinforms`, `id:showinforms`). `lexicalTokens` splits these into common words
156
+ * that the rare-term filter discards; the whole identifier is the rare, discriminating term.
157
+ * STRONG identifiers (3+ camel parts, or `_`/`.` between alphanumerics) may qualify a record
158
+ * alone; two-part camel words (GitHub, useEffect) are common prose and count as one word. */
159
+ function scanIdentifiers(text) {
160
+ const out = new Map();
161
+ const add = (raw, strong) => {
162
+ const id = raw.replace(/[_.\-]/g, "").toLowerCase();
163
+ if (id.length >= MIN_IDENTIFIER_CHARS)
164
+ out.set(ID_PREFIX + id, (out.get(ID_PREFIX + id) ?? false) || strong);
165
+ };
166
+ for (const m of text.match(CAMEL_IDENTIFIER) ?? [])
167
+ add(m, camelParts(m) >= 3);
168
+ for (const m of text.match(SEPARATED_IDENTIFIER) ?? []) {
169
+ if (/^v?\d+(?:\.\d+)+/i.test(m))
170
+ continue; // version numbers
171
+ if (m.includes(".")) {
172
+ const last = m.slice(m.lastIndexOf(".") + 1);
173
+ if (/^[a-z0-9]{1,8}$/.test(last))
174
+ continue; // file extension: paths own those
175
+ add(m, true);
176
+ if (isCompound(last))
177
+ add(last, true);
178
+ }
179
+ else if (m.includes("_")) {
180
+ const parts = m.split(/[_\-]/);
181
+ if (m.replace(/[_\-]/g, "").length >= 6 && parts.some((part) => part.length >= 3))
182
+ add(m, true);
183
+ }
184
+ else if (/(?<!^|-)[A-Z0-9]/.test(m)) {
185
+ add(m, false); // kebab: a capital/digit past the first letter of a part; plain prose ("Pre-commit") is not an identifier
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ export function identifierTokens(text) {
191
+ return new Set(scanIdentifiers(text).keys());
192
+ }
193
+ const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierTokens(text)]);
145
194
  /** Score live records against one prompt. Text fields per kind (title + rationale):
146
195
  * decision title/context/decision; bug title/symptom/root_cause; constraint
147
196
  * statement/rationale; finding title/observation. Path anchors: decision
@@ -149,7 +198,10 @@ export function promptPaths(prompt, root) {
149
198
  * ties break on the builder profile's kind/severity score in delivery.ts. */
150
199
  export function selectForTask(records, promptText, opts = {}) {
151
200
  const k = Math.max(0, opts.k ?? DEFAULT_K);
152
- const taskTerms = lexicalTokens(promptText ?? "");
201
+ // Stack-frame lines name library APIs (writeFileSync, processTicksAndRejections), not the task:
202
+ // keep their words, but extract no identifiers from them.
203
+ const promptIdentifiers = (promptText ?? "").split(/\r?\n/).filter((line) => !/^\s*at\s+\S/.test(line) && !/node:internal|\(node:/.test(line)).join("\n");
204
+ const taskTerms = new Set([...lexicalTokens(promptText ?? ""), ...identifierTokens(promptIdentifiers)]);
153
205
  // Without an existence check (pure/offline) only a slashed token is trusted as a
154
206
  // path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
155
207
  const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
@@ -159,7 +211,7 @@ export function selectForTask(records, promptText, opts = {}) {
159
211
  // Tokenize every record once: the same tokens feed the document frequency and the score.
160
212
  const candidates = [];
161
213
  const consider = (id, kind, title, text, anchors, priority, listable) => {
162
- candidates.push({ id, kind, title, tokens: lexicalTokens(text.join(" ")), anchors, priority, listable });
214
+ candidates.push({ id, kind, title, tokens: termTokens(text.join(" ")), anchors, priority, listable });
163
215
  };
164
216
  for (const c of records.constraints) {
165
217
  consider(c.id, "constraint", c.statement, [c.statement, c.rationale], c.scope, base.constraints + SEVERITY[c.severity] * 10 + (c.provenance.confidence ?? 0), c.severity !== "blocking");
@@ -174,20 +226,47 @@ export function selectForTask(records, promptText, opts = {}) {
174
226
  consider(f.id, "finding", f.title, [f.title, f.observation], f.affected_files, base.findings + SEVERITY[f.severity] * 10, true);
175
227
  }
176
228
  const dfCap = Math.max(MIN_DF_CAP, Math.ceil(DF_CAP_RATIO * candidates.length));
177
- const rareTerms = [...taskTerms].filter((term) => candidates.filter((c) => c.tokens.has(term)).length <= dfCap);
229
+ const df = new Map();
230
+ for (const c of candidates)
231
+ for (const term of c.tokens)
232
+ df.set(term, (df.get(term) ?? 0) + 1);
233
+ const strongIds = new Set([...scanIdentifiers(promptIdentifiers)].filter(([, strong]) => strong).map(([term]) => term));
234
+ const rareTerms = new Map();
235
+ for (const term of taskTerms) {
236
+ const n = df.get(term) ?? 0;
237
+ if (n > dfCap)
238
+ continue;
239
+ // Integer weights so equal sums tie exactly and fall through to the priority tiebreak.
240
+ const idf = Math.round(Math.log(1 + candidates.length / Math.max(1, n)) * 1000);
241
+ // Only a strong identifier that is near-unique (df <= MIN_DF_CAP) counts, at x2, and qualifies alone.
242
+ // Weak ones (GitHub, useEffect) add nothing: their parts are already counted as words.
243
+ const strong = strongIds.has(term) && n <= MIN_DF_CAP;
244
+ if (term.startsWith(ID_PREFIX) && !strong)
245
+ continue;
246
+ rareTerms.set(term, { weight: strong ? idf * IDENTIFIER_WEIGHT : idf, strong });
247
+ }
178
248
  const scored = [];
179
249
  for (const { tokens, anchors, ...rest } of candidates) {
180
- let terms = 0;
181
- for (const term of rareTerms)
182
- if (tokens.has(term))
183
- terms++;
250
+ let words = 0;
251
+ let identifiers = 0;
252
+ let termScore = 0;
253
+ for (const [term, { weight, strong }] of rareTerms) {
254
+ if (!tokens.has(term))
255
+ continue;
256
+ if (strong)
257
+ identifiers++;
258
+ else
259
+ words++;
260
+ termScore += weight;
261
+ }
184
262
  const pathHits = paths.filter((p) => anchors.some((anchor) => pathMatchesGlob(p, anchor))).length;
185
- if (pathHits > 0 || terms >= MIN_DISTINCT_TERMS)
186
- scored.push({ ...rest, pathHits, terms });
263
+ // One rare identifier alone qualifies (it counts double); a lone rare word does not.
264
+ if (pathHits > 0 || words + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
265
+ scored.push({ ...rest, pathHits, termScore });
187
266
  }
188
267
  // A record anchored to more of the files the prompt names outranks one that
189
- // shares a single (often hub) file with it; rare terms break the remaining ties.
190
- scored.sort((a, b) => b.pathHits - a.pathHits || b.terms - a.terms || b.priority - a.priority || a.id.localeCompare(b.id));
268
+ // shares a single (often hub) file with it; idf-weighted rare terms break the remaining ties.
269
+ scored.sort((a, b) => b.pathHits - a.pathHits || b.termScore - a.termScore || b.priority - a.priority || a.id.localeCompare(b.id));
191
270
  return {
192
271
  qualifying: scored.map((s) => s.id),
193
272
  top: scored.filter((s) => s.listable).slice(0, k).map(({ id, kind, title }) => ({ id, kind, title })),
@@ -0,0 +1,47 @@
1
+ /** `hunch_why(<record id>)` (issue #474): every brief line Hunch delivers ends in
2
+ * `hunch_why("dec_…")`, so why() must resolve a record id, not only a path/symbol.
3
+ * The lookup lives in HunchStore.why(); this module holds the id shape and the one
4
+ * renderer the MCP tool and the CLI share, so the two surfaces cannot drift apart. */
5
+ import type { Bug, Component, Constraint, Decision, Finding } from "./types.js";
6
+ /** Targets why() tries as a record id first. Generated ids are `<prefix>_<hex>`
7
+ * (shortHash), but hand-written/imported ids (`dec_session_transport`) are valid too. */
8
+ export declare const WHY_RECORD_ID: RegExp;
9
+ /** The generated id shape: only a miss on THIS shape is reported as "No record <id>";
10
+ * any other miss (e.g. a symbol named `bug_handler`) falls through to path/symbol lookup. */
11
+ export declare const WHY_GENERATED_ID: RegExp;
12
+ /** One link of a decision's supersession chain, oldest first. */
13
+ export interface WhyChainLink {
14
+ id: string;
15
+ status: string;
16
+ title: string;
17
+ }
18
+ export type WhyRecord = {
19
+ kind: "decision";
20
+ record: Decision;
21
+ chain: WhyChainLink[];
22
+ } | {
23
+ kind: "constraint";
24
+ record: Constraint;
25
+ } | {
26
+ kind: "bug";
27
+ record: Bug;
28
+ } | {
29
+ kind: "finding";
30
+ record: Finding;
31
+ } | {
32
+ kind: "component";
33
+ record: Component;
34
+ };
35
+ /** The by-id half of a WhyResult: `record` is null when no visible record has the id
36
+ * (or, with `asOf`, none was in force at that instant). */
37
+ export interface WhyById {
38
+ id: string;
39
+ record: WhyRecord | null;
40
+ asOf?: string;
41
+ }
42
+ /** Per-list cap in hunch_why output, and the "what was withheld" note after a capped
43
+ * list — shared with the path/symbol view in the MCP server. */
44
+ export declare const WHY_CAP = 6;
45
+ export declare const more: (total: number, cap: number, hint?: string) => string;
46
+ /** Plain-text detail lines for a record looked up by id. */
47
+ export declare function formatWhyRecord(byId: WhyById): string[];
@@ -0,0 +1,105 @@
1
+ /** Targets why() tries as a record id first. Generated ids are `<prefix>_<hex>`
2
+ * (shortHash), but hand-written/imported ids (`dec_session_transport`) are valid too. */
3
+ export const WHY_RECORD_ID = /^(dec|con|bug|cmp|fnd)_[A-Za-z0-9_-]+$/;
4
+ /** The generated id shape: only a miss on THIS shape is reported as "No record <id>";
5
+ * any other miss (e.g. a symbol named `bug_handler`) falls through to path/symbol lookup. */
6
+ export const WHY_GENERATED_ID = /^(dec|con|bug|cmp|fnd)_[0-9a-f]{6,}$/;
7
+ /** Per-list cap in hunch_why output, and the "what was withheld" note after a capped
8
+ * list — shared with the path/symbol view in the MCP server. */
9
+ export const WHY_CAP = 6;
10
+ export const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
11
+ const PROSE_CHARS = 600;
12
+ const ITEM_CHARS = 240;
13
+ const clip = (text, max = PROSE_CHARS) => {
14
+ const flat = text.replace(/\s+/g, " ").trim();
15
+ // By code point, so a cut never splits a surrogate pair (emoji, astral CJK).
16
+ const chars = Array.from(flat);
17
+ return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : flat;
18
+ };
19
+ const list = (label, items) => items.length ? [` ${label}:`, ...items.slice(0, WHY_CAP).map((i) => ` - ${clip(i, ITEM_CHARS)}`), ...(items.length > WHY_CAP ? [` ${more(items.length, WHY_CAP).trim()}`] : [])] : [];
20
+ const files = (label, items) => items.length ? [` ${label}: ${items.slice(0, WHY_CAP).join(", ")}${items.length > WHY_CAP ? ` …(+${items.length - WHY_CAP} more)` : ""}`] : [];
21
+ /** Plain-text detail lines for a record looked up by id. */
22
+ export function formatWhyRecord(byId) {
23
+ const hit = byId.record;
24
+ if (!hit) {
25
+ return [byId.asOf
26
+ ? `No record ${byId.id} as of ${byId.asOf} (it did not exist or was not in force then).`
27
+ : `No record ${byId.id} (no decision, constraint, bug, finding, or component has that id).`];
28
+ }
29
+ const out = [];
30
+ switch (hit.kind) {
31
+ case "decision": {
32
+ const d = hit.record;
33
+ out.push(`DECISION ${d.id} [${d.status}] ${d.title}`);
34
+ if (d.topic)
35
+ out.push(` topic: ${d.topic}`);
36
+ if (d.decision)
37
+ out.push(` decision: ${clip(d.decision)}`);
38
+ if (d.context)
39
+ out.push(` context: ${clip(d.context)}`);
40
+ out.push(...list("rejected alternatives", d.alternatives_rejected), ...list("consequences", d.consequences));
41
+ out.push(...files("files", d.related_files));
42
+ if (d.supersedes)
43
+ out.push(` supersedes: ${d.supersedes}`);
44
+ if (d.superseded_by)
45
+ out.push(` superseded by: ${d.superseded_by}`);
46
+ if (hit.chain.length > 1) {
47
+ // A WHY_CAP window that always holds the queried decision: the newest links
48
+ // when it is near the end, otherwise starting at it.
49
+ const at = Math.max(0, hit.chain.findIndex((c) => c.id === d.id));
50
+ const start = Math.max(0, Math.min(at, hit.chain.length - WHY_CAP));
51
+ const end = Math.min(hit.chain.length, start + WHY_CAP);
52
+ out.push(" chain (oldest first):");
53
+ if (start > 0)
54
+ out.push(` …(+${start} earlier)`);
55
+ out.push(...hit.chain.slice(start, end).map((c) => ` - ${c.id} [${c.status}] ${clip(c.title, ITEM_CHARS)}${c.id === d.id ? " ←" : ""}`));
56
+ if (end < hit.chain.length)
57
+ out.push(` …(+${hit.chain.length - end} later)`);
58
+ }
59
+ break;
60
+ }
61
+ case "constraint": {
62
+ const c = hit.record;
63
+ out.push(`CONSTRAINT ${c.id} [${c.severity}/${c.status}] ${clip(c.statement)}`);
64
+ out.push(...(c.scope.length ? files("scope", c.scope) : [" scope: repo"]));
65
+ if (c.rationale)
66
+ out.push(` rationale: ${clip(c.rationale)}`);
67
+ if (c.source_decision)
68
+ out.push(` source decision: ${c.source_decision}`);
69
+ break;
70
+ }
71
+ case "bug": {
72
+ const b = hit.record;
73
+ out.push(`BUG ${b.id} [${b.status}/${b.severity}] ${b.title}`);
74
+ if (b.symptom)
75
+ out.push(` symptom: ${clip(b.symptom)}`);
76
+ if (b.root_cause)
77
+ out.push(` root cause: ${clip(b.root_cause)}`);
78
+ if (b.lineage.fixed_commit)
79
+ out.push(` fixed in: ${b.lineage.fixed_commit}`);
80
+ out.push(...files("files", b.affected_files));
81
+ break;
82
+ }
83
+ case "finding": {
84
+ const f = hit.record;
85
+ out.push(`FINDING ${f.id} [${f.triage}/${f.severity}] ${f.title}`);
86
+ if (f.observation)
87
+ out.push(` observation: ${clip(f.observation)}`);
88
+ out.push(...list("evidence", f.evidence));
89
+ out.push(...files("files", f.affected_files));
90
+ break;
91
+ }
92
+ case "component": {
93
+ const c = hit.record;
94
+ out.push(`COMPONENT ${c.id} [${c.kind}/${c.status}] ${c.name}`);
95
+ if (c.responsibility)
96
+ out.push(` responsibility: ${clip(c.responsibility)}`);
97
+ out.push(...files("paths", c.paths));
98
+ break;
99
+ }
100
+ }
101
+ const p = hit.record.provenance;
102
+ out.push(` provenance: ${p.source}, confidence ${p.confidence}`);
103
+ return out;
104
+ }
105
+ //# sourceMappingURL=whyRecord.js.map
@@ -19,6 +19,29 @@ export declare function gitWorktreeRoot(cwd: string): string | null;
19
19
  * `.hunch-private/.hunch` can never stage or commit into the code repository. */
20
20
  export declare function isGitRepoRoot(cwd: string): boolean;
21
21
  export declare function gitNullDevice(): string;
22
+ export interface HunchPullFailureDetail {
23
+ /** The mergeRemote/pull step that failed (e.g. "ls-remote", "fetch", "worktree-clean"). */
24
+ step: string;
25
+ /** Node error code (ETIMEDOUT), killing signal, or `exit <status>`. */
26
+ code?: string;
27
+ /** Last 400 characters of git's stderr, when captured. */
28
+ stderr?: string;
29
+ }
30
+ /** Captured git stderr can echo a remote URL with embedded credentials; strip the
31
+ * userinfo (`://user:pass@`, `://token@`) before it is stored or printed. */
32
+ export declare function redactUrlUserinfo(text: string): string;
33
+ /** One-line rendering for logs and refusal messages: `step: fetch, ETIMEDOUT, fatal: ...`. */
34
+ export declare function describePullFailure(detail: HunchPullFailureDetail | undefined): string;
35
+ /** HUNCH_PULL_GIT_TIMEOUT_MS: per-process override of the read-side pull's DEFAULT
36
+ * remote budget (ls-remote/fetch/merge) for slow runners — a caller's explicit
37
+ * timeoutMs still wins; ignored unless a positive integer. */
38
+ export declare function pullGitTimeoutOverride(): number | undefined;
39
+ /** Remote budget for one read-side pull: an explicit caller budget always wins;
40
+ * HUNCH_PULL_GIT_TIMEOUT_MS only replaces the 5s default. */
41
+ export declare function pullRemoteTimeoutMs(callerMs?: number): number;
42
+ /** Budget for the bounded local git helpers (status, rev-parse, ls-tree): 2s by
43
+ * default, scaled with HUNCH_PULL_GIT_TIMEOUT_MS to max(2s, override / 2.5). */
44
+ export declare function pullLocalGitTimeoutMs(): number;
22
45
  /** Compare physical directory identity before path text. Git for Windows can
23
46
  * return an 8.3/short or differently-cased spelling for the same top-level
24
47
  * directory that Node reached through its long path. A nonzero file ID keeps
@@ -111,6 +134,7 @@ export interface HunchRemoteContract {
111
134
  ref: string;
112
135
  verify: () => boolean;
113
136
  }
137
+ export declare const DEFAULT_READ_REMOTE_TIMEOUT_MS = 5000;
114
138
  export declare function commitAndPushHunch(hunchDir: string, message: string, opts: HunchCommitOptions): "pushed" | "committed" | null;
115
139
  /** True when `rel` is tracked with no staged or unstaged changes (untracked counts as
116
140
  * dirty, so a doc the user never committed is never swept into a memory commit). */
@@ -138,6 +162,14 @@ export interface PullHunchOptions {
138
162
  allowUnrelatedHistories?: boolean;
139
163
  }
140
164
  export declare function pullHunchStatus(hunchDir: string, opts?: PullHunchOptions): HunchPullStatus;
165
+ export interface HunchPullResult {
166
+ status: HunchPullStatus;
167
+ /** Present on "failed": which step failed and git's own reason (issue #482). */
168
+ detail?: HunchPullFailureDetail;
169
+ }
170
+ /** pullHunchStatus plus a diagnosable reason for a "failed" pull. The remote budget is
171
+ * the caller's (default 5s) unless HUNCH_PULL_GIT_TIMEOUT_MS overrides it. */
172
+ export declare function pullHunchStatusDetailed(hunchDir: string, opts?: PullHunchOptions): HunchPullResult;
141
173
  export declare function pullHunch(hunchDir: string): boolean;
142
174
  /** Explicit retry path for a clean overlay that already has a local memory commit
143
175
  * stranded by an earlier offline/rejected push. Unlike commitAndPushHunch this