@davesheffer/hunch 1.41.6 → 1.43.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/README.md +5 -1
- package/dist/cli/index.js +489 -167
- package/dist/constitution/experiment.d.ts +3 -3
- package/dist/constitution/g3.d.ts +1 -1
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/footprint.d.ts +15 -0
- package/dist/core/footprint.js +167 -0
- package/dist/core/groundingLag.d.ts +15 -0
- package/dist/core/groundingLag.js +27 -0
- package/dist/core/hookText.d.ts +4 -0
- package/dist/core/hookText.js +8 -0
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/pipeline.d.ts +28 -0
- package/dist/core/pipeline.js +50 -0
- package/dist/core/publication.js +17 -3
- package/dist/core/shellwrites.d.ts +7 -0
- package/dist/core/shellwrites.js +131 -0
- package/dist/core/siblingfix.d.ts +124 -0
- package/dist/core/siblingfix.js +814 -0
- package/dist/core/taskReportHook.d.ts +1 -1
- package/dist/core/taskReportHook.js +20 -4
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/extractors/git.d.ts +31 -0
- package/dist/extractors/git.js +180 -1
- package/dist/extractors/nativeTreeSitter.d.ts +2 -1
- package/dist/extractors/nativeTreeSitter.js +128 -30
- package/dist/integrations/claudemd.d.ts +20 -2
- package/dist/integrations/claudemd.js +79 -53
- package/dist/integrations/providers.d.ts +9 -5
- package/dist/integrations/providers.js +36 -23
- package/dist/integrations/scaffold.js +2 -1
- package/dist/integrations/team.d.ts +24 -4
- package/dist/integrations/team.js +154 -16
- package/dist/integrations/worktree.d.ts +3 -2
- package/dist/integrations/worktree.js +7 -4
- package/dist/mcp/server.d.ts +489 -0
- package/dist/mcp/server.js +208 -62
- package/dist/mcp/taskReportTools.js +12 -9
- package/dist/mcp/toolset.d.ts +11 -1
- package/dist/mcp/toolset.js +28 -8
- package/dist/store/hunchStore.d.ts +25 -1
- package/dist/store/hunchStore.js +146 -21
- package/dist/store/jsonStore.js +25 -4
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -39,7 +39,7 @@ export declare function taskInstruction(task: {
|
|
|
39
39
|
}, cwdLiteral: string, provider: HookProvider, launcher?: () => {
|
|
40
40
|
shell: string;
|
|
41
41
|
note?: string;
|
|
42
|
-
}): string;
|
|
42
|
+
}, form?: "full" | "compact"): string;
|
|
43
43
|
/** A prompt the host generated to report a background command's completion,
|
|
44
44
|
* not something the user typed. */
|
|
45
45
|
export declare function isNotificationPrompt(prompt: string | undefined): boolean;
|
|
@@ -9,6 +9,7 @@ import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport
|
|
|
9
9
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
10
10
|
import { renderTaskReport } from "./taskReportRender.js";
|
|
11
11
|
import { verificationLauncher } from "./verifyLauncher.js";
|
|
12
|
+
import { injectionMode } from "./hookcache.js";
|
|
12
13
|
/** The exact task identity a native host prompt maps to. */
|
|
13
14
|
export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
|
|
14
15
|
return `htask_${reportHash([canonicalReportRoot(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
|
|
@@ -144,7 +145,7 @@ export function startHookReport(root, provider, event) {
|
|
|
144
145
|
// this prompt's Stop and hook observations report to that task.
|
|
145
146
|
if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
|
|
146
147
|
aliasReportTask(root, id, previous.task_id);
|
|
147
|
-
return
|
|
148
|
+
return sessionTaskInstruction(previous, cwdLiteral, provider, event.session_id);
|
|
148
149
|
}
|
|
149
150
|
const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
|
|
150
151
|
if (continued)
|
|
@@ -165,7 +166,18 @@ export function startHookReport(root, provider, event) {
|
|
|
165
166
|
throw error;
|
|
166
167
|
task = existing;
|
|
167
168
|
}
|
|
168
|
-
return
|
|
169
|
+
return sessionTaskInstruction(task, cwdLiteral, provider, event.session_id);
|
|
170
|
+
}
|
|
171
|
+
/** The generic rules are the same on every prompt of a session; only the ID and
|
|
172
|
+
* the launcher line change. Print them in full once per session (and again after
|
|
173
|
+
* compaction, which resets the dedup map), then a compact line that still carries
|
|
174
|
+
* everything the prompt needs: its ID, cwd, verify command, no-start and finish
|
|
175
|
+
* rule. The dedup key hashes the full wording for a placeholder task, so a new
|
|
176
|
+
* launcher or rule text is delivered in full again. Fail-open: any cache error
|
|
177
|
+
* yields the full form (injectionMode never throws and defaults to "full"). */
|
|
178
|
+
function sessionTaskInstruction(task, cwdLiteral, provider, sessionId) {
|
|
179
|
+
const rules = taskInstruction({ task_id: "htask_" + "0".repeat(24), title: NATIVE_TASK_TITLE }, "\"<cwd>\"", provider);
|
|
180
|
+
return taskInstruction(task, cwdLiteral, provider, verificationLauncher, injectionMode(sessionId, "prompt-task-rules", rules) === "delta" ? "compact" : "full");
|
|
169
181
|
}
|
|
170
182
|
/** The hook already opened the task, so the model needs no start call: the only
|
|
171
183
|
* thing start used to supply was verification_argv, and the launcher is printed
|
|
@@ -179,12 +191,16 @@ export function startHookReport(root, provider, event) {
|
|
|
179
191
|
* cannot be computed, fall back to asking for the start call — that path is then
|
|
180
192
|
* the only source of both the launcher and the finish instruction, so it carries
|
|
181
193
|
* its own finish sentence. */
|
|
182
|
-
export function taskInstruction(task, cwdLiteral, provider, launcher = verificationLauncher) {
|
|
194
|
+
export function taskInstruction(task, cwdLiteral, provider, launcher = verificationLauncher, form = "full") {
|
|
183
195
|
const head = `Hunch has already opened this prompt's report: ${task.task_id}. Reuse this exact ID; never open another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures.`;
|
|
184
196
|
let verify;
|
|
185
197
|
try {
|
|
186
198
|
const l = launcher();
|
|
187
|
-
|
|
199
|
+
if (form === "compact") {
|
|
200
|
+
const finishRule = HOST_CLOSES_TASK.has(provider) ? "finish only if this task used Hunch" : `finish it yourself with hunch_task(action: "finish", task_id, cwd)`;
|
|
201
|
+
return `Hunch report for this prompt: ${task.task_id} (cwd: ${cwdLiteral}). Use it instead of any earlier ID. Never call hunch_task start. Checks: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Same rules as this session's first report; ${finishRule}.`;
|
|
202
|
+
}
|
|
203
|
+
verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Verify with the tests that cover your change (the files you edited and their tests); the full suite is CI's job. The default budget is 15 min; add --timeout <seconds> before -- only if the whole suite is really needed.`;
|
|
188
204
|
}
|
|
189
205
|
catch {
|
|
190
206
|
return `${head} Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv, and finish with hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-scored memory selection (memory-selection design, point 1-3; Gate A v5).
|
|
3
|
+
*
|
|
4
|
+
* The pre-edit hook used to deliver every record anchored to a file, so a hub
|
|
5
|
+
* file filled the headline cap with memory unrelated to the task. This selector
|
|
6
|
+
* scores the live store against the prompt ONCE per task; file grounding then
|
|
7
|
+
* keeps only the decisions, bugs and findings that qualified (constraints are
|
|
8
|
+
* exempt: they are scoped rules, not relevance guesses).
|
|
9
|
+
*
|
|
10
|
+
* PURE on purpose: no fs, no store. Inputs are passed in, so an offline replay
|
|
11
|
+
* can run it against an old store snapshot. The rule is fixed before
|
|
12
|
+
* measurement (no vectors in this step): a record qualifies on >= 2 distinct
|
|
13
|
+
* task terms in its title/rationale, or on a repo path the prompt names that
|
|
14
|
+
* matches the record's files or scope. Below that, nothing (silence, no filler).
|
|
15
|
+
* A term common to many live records (document frequency above the cap) names
|
|
16
|
+
* no task and does not count toward the two.
|
|
17
|
+
*/
|
|
18
|
+
import type { Bug, Constraint, Decision, Finding } from "./types.js";
|
|
19
|
+
export interface TaskSelectionRecords {
|
|
20
|
+
decisions: readonly Decision[];
|
|
21
|
+
bugs: readonly Bug[];
|
|
22
|
+
constraints: readonly Constraint[];
|
|
23
|
+
findings: readonly Finding[];
|
|
24
|
+
}
|
|
25
|
+
export type TaskSelectionKind = "decision" | "bug" | "constraint" | "finding";
|
|
26
|
+
export interface TaskSelection {
|
|
27
|
+
/** Every record clearing the threshold (ids only). */
|
|
28
|
+
qualifying: string[];
|
|
29
|
+
/** At most k, ordered; blocking constraints are never listed (they arrive at edit time). */
|
|
30
|
+
top: Array<{
|
|
31
|
+
id: string;
|
|
32
|
+
kind: TaskSelectionKind;
|
|
33
|
+
title: string;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
export declare function isFilterableSelectionId(id: string): boolean;
|
|
37
|
+
export declare function isBareFollowUp(prompt: string): boolean;
|
|
38
|
+
export interface TaskSelectionOptions {
|
|
39
|
+
/** Prompt-time list size (design: K = 3). */
|
|
40
|
+
k?: number;
|
|
41
|
+
/** Optional existence check for a repo-relative path the prompt names. */
|
|
42
|
+
pathExists?: (path: string) => boolean;
|
|
43
|
+
/** Repository root, only to strip it from absolute paths in the prompt (string work, no fs). */
|
|
44
|
+
root?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Document-frequency cap, fixed before the pilot replay: a prompt term counts
|
|
47
|
+
* only when at most max(MIN_DF_CAP, ceil(DF_CAP_RATIO × live records)) records
|
|
48
|
+
* contain it. */
|
|
49
|
+
export declare const MIN_DF_CAP = 3;
|
|
50
|
+
export declare const DF_CAP_RATIO = 0.05;
|
|
51
|
+
/** The live slice the pre-edit grounding would ever deliver at HEAD: decisions and
|
|
52
|
+
* constraints still in force (the store's why() window plus delivery's retired
|
|
53
|
+
* test), every bug (why() keeps fixed ones as lessons), and findings whose triage
|
|
54
|
+
* liveFindingsFor() keeps. */
|
|
55
|
+
export declare function liveSelectionRecords(all: TaskSelectionRecords): TaskSelectionRecords;
|
|
56
|
+
/** Repo paths the prompt names: a markdown link `[label](target)` reads as its
|
|
57
|
+
* target, then a whitespace token containing `/` or ending in a file extension,
|
|
58
|
+
* stripped of quoting/punctuation/unmatched brackets, a trailing `:line[:col]`
|
|
59
|
+
* or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
|
|
60
|
+
* case-insensitively (Windows paths are case-insensitive on every host). */
|
|
61
|
+
export declare function promptPaths(prompt: string, root?: string): string[];
|
|
62
|
+
/** Score live records against one prompt. Text fields per kind (title + rationale):
|
|
63
|
+
* decision title/context/decision; bug title/symptom/root_cause; constraint
|
|
64
|
+
* statement/rationale; finding title/observation. Path anchors: decision
|
|
65
|
+
* related_files, bug/finding affected_files, constraint scope globs. Priority
|
|
66
|
+
* ties break on the builder profile's kind/severity score in delivery.ts. */
|
|
67
|
+
export declare function selectForTask(records: TaskSelectionRecords, promptText: string, opts?: TaskSelectionOptions): TaskSelection;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { lexicalTokens, PROFILE_BASE_SCORE, SEVERITY } from "./delivery.js";
|
|
2
|
+
import { pathMatchesGlob } from "./glob.js";
|
|
3
|
+
/** Id prefixes of the kinds file grounding filters by a selection (ids.ts:
|
|
4
|
+
* decisionId/bugId/findingId). Constraints (con_) always pass, so a selection
|
|
5
|
+
* holding no id with one of these prefixes filters nothing useful — it would
|
|
6
|
+
* only hide every decision, bug and finding — and counts as no selection. */
|
|
7
|
+
const FILTERABLE_ID_PREFIXES = ["dec_", "bug_", "fnd_"];
|
|
8
|
+
export function isFilterableSelectionId(id) {
|
|
9
|
+
return FILTERABLE_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
10
|
+
}
|
|
11
|
+
/** Words a bare follow-up is made of ("continue", "go on", "yes do it", "ok,
|
|
12
|
+
* next step please"). A prompt of only these names no task of its own and
|
|
13
|
+
* inherits the continued task's selection; any other word — a verb and an
|
|
14
|
+
* object ("fix sampler"), a path, another language — is a task of its own
|
|
15
|
+
* (fail-safe: its own selection, or unfiltered grounding). */
|
|
16
|
+
const CONTINUATION_WORDS = new Set([
|
|
17
|
+
"continue", "continuing", "go", "on", "ahead", "proceed", "resume", "carry", "keep", "going",
|
|
18
|
+
"yes", "yep", "yeah", "y", "ok", "okay", "sure", "please", "pls", "do", "it", "that", "this",
|
|
19
|
+
"next", "step", "again", "lgtm", "sounds", "good", "fine", "and", "the", "with", "now",
|
|
20
|
+
]);
|
|
21
|
+
export function isBareFollowUp(prompt) {
|
|
22
|
+
const words = (prompt ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
23
|
+
return words.length > 0 && words.every((word) => CONTINUATION_WORDS.has(word));
|
|
24
|
+
}
|
|
25
|
+
const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" };
|
|
26
|
+
const CLOSING_BRACKETS = { ")": "(", "]": "[", "}": "{" };
|
|
27
|
+
/** Every `close` in text[from, to) follows its `open` (properly nested, none left open). */
|
|
28
|
+
function balanced(text, from, to, open, close) {
|
|
29
|
+
let depth = 0;
|
|
30
|
+
for (let i = from; i < to; i++) {
|
|
31
|
+
if (text[i] === open)
|
|
32
|
+
depth++;
|
|
33
|
+
else if (text[i] === close && --depth < 0)
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return depth === 0;
|
|
37
|
+
}
|
|
38
|
+
/** Strip quoting, sentence punctuation, unmatched or wrapping brackets and a
|
|
39
|
+
* possessive `'s` from a prompt token until stable. A bracket the path itself
|
|
40
|
+
* uses (`(auth)/x.ts`, `[id]/page.tsx`, `src/app/(auth)`) is balanced and stays.
|
|
41
|
+
* Works on [a, b) indices with bracket counts kept current, so trimming a run of
|
|
42
|
+
* n unmatched brackets is linear, not n rescans of the token. */
|
|
43
|
+
const MAX_PATH_TOKEN_CHARS = 1024;
|
|
44
|
+
/** Wrapping pairs unwrapped per token; each unwrap rescans the token, and prose never nests deeper. */
|
|
45
|
+
const MAX_UNWRAPS = 8;
|
|
46
|
+
function trimPathToken(raw) {
|
|
47
|
+
const counts = { "(": 0, ")": 0, "[": 0, "]": 0, "{": 0, "}": 0 };
|
|
48
|
+
for (let i = 0; i < raw.length; i++)
|
|
49
|
+
if (raw[i] in counts)
|
|
50
|
+
counts[raw[i]]++;
|
|
51
|
+
const n = (ch) => counts[ch];
|
|
52
|
+
const dropAt = (i) => { if (raw[i] in counts)
|
|
53
|
+
counts[raw[i]]--; };
|
|
54
|
+
let a = 0;
|
|
55
|
+
let b = raw.length;
|
|
56
|
+
let unwraps = 0;
|
|
57
|
+
for (let before = -1; a < b && before !== b - a;) {
|
|
58
|
+
before = b - a;
|
|
59
|
+
// A trailing opening bracket can never close, so it goes first (keeps `[id]/x.ts[` from losing its head).
|
|
60
|
+
const lastOpen = raw[b - 1];
|
|
61
|
+
if (BRACKET_PAIRS[lastOpen] && n(lastOpen) > n(BRACKET_PAIRS[lastOpen]))
|
|
62
|
+
dropAt(--b);
|
|
63
|
+
if (a >= b)
|
|
64
|
+
break;
|
|
65
|
+
const head = raw[a];
|
|
66
|
+
const close = BRACKET_PAIRS[head];
|
|
67
|
+
const headOpen = CLOSING_BRACKETS[head];
|
|
68
|
+
if ("`'\"<\u2018\u201c".includes(head))
|
|
69
|
+
dropAt(a++);
|
|
70
|
+
else if (close && unwraps < MAX_UNWRAPS && b - a >= 2 && raw[b - 1] === close && n(head) === n(close) && balanced(raw, a + 1, b - 1, head, close)) {
|
|
71
|
+
unwraps++;
|
|
72
|
+
dropAt(a++);
|
|
73
|
+
dropAt(--b);
|
|
74
|
+
}
|
|
75
|
+
else if (close && n(head) > n(close))
|
|
76
|
+
dropAt(a++);
|
|
77
|
+
else if (headOpen && n(head) > n(headOpen))
|
|
78
|
+
dropAt(a++);
|
|
79
|
+
if (a >= b)
|
|
80
|
+
break;
|
|
81
|
+
const tail = raw[b - 1];
|
|
82
|
+
const open = CLOSING_BRACKETS[tail];
|
|
83
|
+
if ("`'\">,.;:!?\u2019\u201d".includes(tail))
|
|
84
|
+
dropAt(--b);
|
|
85
|
+
else if (open && n(open) < n(tail))
|
|
86
|
+
dropAt(--b);
|
|
87
|
+
if (b - a >= 2 && "sS".includes(raw[b - 1]) && "'\u2019".includes(raw[b - 2]))
|
|
88
|
+
b -= 2;
|
|
89
|
+
}
|
|
90
|
+
return raw.slice(a, b);
|
|
91
|
+
}
|
|
92
|
+
const MIN_DISTINCT_TERMS = 2;
|
|
93
|
+
/** Document-frequency cap, fixed before the pilot replay: a prompt term counts
|
|
94
|
+
* only when at most max(MIN_DF_CAP, ceil(DF_CAP_RATIO × live records)) records
|
|
95
|
+
* contain it. */
|
|
96
|
+
export const MIN_DF_CAP = 3;
|
|
97
|
+
export const DF_CAP_RATIO = 0.05;
|
|
98
|
+
const DEFAULT_K = 3;
|
|
99
|
+
const LIVE_FINDING_TRIAGE = new Set(["open", "accepted-risk", "scheduled"]);
|
|
100
|
+
/** The live slice the pre-edit grounding would ever deliver at HEAD: decisions and
|
|
101
|
+
* constraints still in force (the store's why() window plus delivery's retired
|
|
102
|
+
* test), every bug (why() keeps fixed ones as lessons), and findings whose triage
|
|
103
|
+
* liveFindingsFor() keeps. */
|
|
104
|
+
export function liveSelectionRecords(all) {
|
|
105
|
+
return {
|
|
106
|
+
decisions: all.decisions.filter((d) => d.status !== "rejected" && d.status !== "superseded" && !d.superseded_by && d.valid_to == null),
|
|
107
|
+
bugs: [...all.bugs],
|
|
108
|
+
constraints: all.constraints.filter((c) => c.status !== "retired" && c.valid_to == null),
|
|
109
|
+
findings: all.findings.filter((f) => LIVE_FINDING_TRIAGE.has(f.triage)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Repo paths the prompt names: a markdown link `[label](target)` reads as its
|
|
113
|
+
* target, then a whitespace token containing `/` or ending in a file extension,
|
|
114
|
+
* stripped of quoting/punctuation/unmatched brackets, a trailing `:line[:col]`
|
|
115
|
+
* or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
|
|
116
|
+
* case-insensitively (Windows paths are case-insensitive on every host). */
|
|
117
|
+
export function promptPaths(prompt, root) {
|
|
118
|
+
const out = new Set();
|
|
119
|
+
const rootPrefix = root ? `${root.replace(/\\/g, "/").replace(/\/+$/, "")}/` : null;
|
|
120
|
+
// Label and target may each hold one level of brackets (`[app/[id]/x.ts](app/[id]/x.ts)`,
|
|
121
|
+
// a linked `src/app/(auth)/page.tsx`); the label never spans a `[`, so a run of `[` fails fast.
|
|
122
|
+
const links = prompt.replace(/\[(?:[^[\]\n]|\[[^[\]\n]{0,256}\]){0,256}\]\(((?:[^()\s]|\([^()\s]{0,256}\)){1,1024})\)/g, " $1 ");
|
|
123
|
+
for (const raw of links.split(/\s+/)) {
|
|
124
|
+
// No repo path is this long; trimming pasted junk one bracket at a time would stall the prompt hook.
|
|
125
|
+
if (raw.length > MAX_PATH_TOKEN_CHARS)
|
|
126
|
+
continue;
|
|
127
|
+
let token = trimPathToken(raw).replace(/\\/g, "/").replace(/:\d+(?:[:-]\d+)?$/, "");
|
|
128
|
+
if (!token || /^[a-z][a-z0-9+.-]*:\/\//i.test(token))
|
|
129
|
+
continue;
|
|
130
|
+
if (!token.includes("/") && !/\.[a-z0-9]{1,8}$/i.test(token))
|
|
131
|
+
continue;
|
|
132
|
+
if (rootPrefix) {
|
|
133
|
+
const foldCase = process.platform === "win32" || /^[a-z]:\//i.test(token);
|
|
134
|
+
if (foldCase ? token.toLowerCase().startsWith(rootPrefix.toLowerCase()) : token.startsWith(rootPrefix))
|
|
135
|
+
token = token.slice(rootPrefix.length);
|
|
136
|
+
}
|
|
137
|
+
token = token.replace(/^(?:\.\/)+/, "");
|
|
138
|
+
// Absolute (outside the root) or parent-relative paths name nothing in this repo.
|
|
139
|
+
if (!token || token.startsWith("/") || /^[a-z]:/i.test(token) || token.split("/").includes(".."))
|
|
140
|
+
continue;
|
|
141
|
+
out.add(token);
|
|
142
|
+
}
|
|
143
|
+
return [...out];
|
|
144
|
+
}
|
|
145
|
+
/** Score live records against one prompt. Text fields per kind (title + rationale):
|
|
146
|
+
* decision title/context/decision; bug title/symptom/root_cause; constraint
|
|
147
|
+
* statement/rationale; finding title/observation. Path anchors: decision
|
|
148
|
+
* related_files, bug/finding affected_files, constraint scope globs. Priority
|
|
149
|
+
* ties break on the builder profile's kind/severity score in delivery.ts. */
|
|
150
|
+
export function selectForTask(records, promptText, opts = {}) {
|
|
151
|
+
const k = Math.max(0, opts.k ?? DEFAULT_K);
|
|
152
|
+
const taskTerms = lexicalTokens(promptText ?? "");
|
|
153
|
+
// Without an existence check (pure/offline) only a slashed token is trusted as a
|
|
154
|
+
// path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
|
|
155
|
+
const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
|
|
156
|
+
if (!taskTerms.size && !paths.length)
|
|
157
|
+
return { qualifying: [], top: [] };
|
|
158
|
+
const base = PROFILE_BASE_SCORE.builder;
|
|
159
|
+
// Tokenize every record once: the same tokens feed the document frequency and the score.
|
|
160
|
+
const candidates = [];
|
|
161
|
+
const consider = (id, kind, title, text, anchors, priority, listable) => {
|
|
162
|
+
candidates.push({ id, kind, title, tokens: lexicalTokens(text.join(" ")), anchors, priority, listable });
|
|
163
|
+
};
|
|
164
|
+
for (const c of records.constraints) {
|
|
165
|
+
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");
|
|
166
|
+
}
|
|
167
|
+
for (const d of records.decisions) {
|
|
168
|
+
consider(d.id, "decision", d.title, [d.title, d.context, d.decision], d.related_files, base.decisions + (d.status === "accepted" ? 20 : 0) + (d.provenance.confidence ?? 0), true);
|
|
169
|
+
}
|
|
170
|
+
for (const b of records.bugs) {
|
|
171
|
+
consider(b.id, "bug", b.title, [b.title, b.symptom, b.root_cause], b.affected_files, base.bugs + SEVERITY[b.severity] * 10 + (b.status === "open" || b.status === "regressed" ? 10 : 0), true);
|
|
172
|
+
}
|
|
173
|
+
for (const f of records.findings) {
|
|
174
|
+
consider(f.id, "finding", f.title, [f.title, f.observation], f.affected_files, base.findings + SEVERITY[f.severity] * 10, true);
|
|
175
|
+
}
|
|
176
|
+
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);
|
|
178
|
+
const scored = [];
|
|
179
|
+
for (const { tokens, anchors, ...rest } of candidates) {
|
|
180
|
+
let terms = 0;
|
|
181
|
+
for (const term of rareTerms)
|
|
182
|
+
if (tokens.has(term))
|
|
183
|
+
terms++;
|
|
184
|
+
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 });
|
|
187
|
+
}
|
|
188
|
+
// 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));
|
|
191
|
+
return {
|
|
192
|
+
qualifying: scored.map((s) => s.id),
|
|
193
|
+
top: scored.filter((s) => s.listable).slice(0, k).map(({ id, kind, title }) => ({ id, kind, title })),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=taskSelection.js.map
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ 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
|
+
/** Compare physical directory identity before path text. Git for Windows can
|
|
23
|
+
* return an 8.3/short or differently-cased spelling for the same top-level
|
|
24
|
+
* directory that Node reached through its long path. A nonzero file ID keeps
|
|
25
|
+
* this exact even on case-sensitive Windows directories; canonical text is a
|
|
26
|
+
* conservative fallback for filesystems that do not expose stable IDs. */
|
|
27
|
+
export declare function sameFilesystemEntry(left: string, right: string): boolean;
|
|
22
28
|
/** Whether two paths resolve to the same repository identity. Comparing only
|
|
23
29
|
* worktree roots is insufficient: linked worktrees have different roots but
|
|
24
30
|
* share one Git common directory and therefore one publishable history. */
|
|
@@ -156,6 +162,31 @@ export declare function gitDir(cwd: string): string;
|
|
|
156
162
|
* `gitDir`, which is per-worktree). Absolute, so callers can anchor worktree-shared
|
|
157
163
|
* state (the private-overlay pointer) at one stable place. "" when not a git repo. */
|
|
158
164
|
export declare function gitCommonDir(cwd: string): string;
|
|
165
|
+
/** The git common dir for `cwd`, but only when Git reached it through the worktree's own
|
|
166
|
+
* `.git` entry and that entry is one Git itself set up (gitDirServesWorktree). Checkout
|
|
167
|
+
* content can never supply a `.git` entry through Git (Git refuses to track `.git`), but
|
|
168
|
+
* an extracted archive can, so the entry alone proves nothing. A directory that merely
|
|
169
|
+
* looks like a repository (HEAD/objects/refs/config committed as ordinary files) is never
|
|
170
|
+
* accepted, whichever Git version runs. "" when there is no such repo. */
|
|
171
|
+
export declare function checkoutCommonDir(cwd: string): string;
|
|
172
|
+
/** Hunch's back-link inside a separate git dir, naming the one checkout it serves. Git
|
|
173
|
+
* records nothing for `git init --separate-git-dir` (no `core.worktree`, no worktree
|
|
174
|
+
* registry entry), so without this a `.git` file could name any repository's git dir. */
|
|
175
|
+
export declare function separateGitDirLink(gitdir: string): string;
|
|
176
|
+
/** Explicit setup (`hunch private` / `hunch shared` / `hunch worktree`) run in a checkout
|
|
177
|
+
* created by `git init --separate-git-dir`: record that this checkout owns its git dir,
|
|
178
|
+
* then resolve it through checkoutCommonDir. Called ONLY from a command the user ran in
|
|
179
|
+
* this checkout — never while opening a store, so repository content can never claim a
|
|
180
|
+
* git dir. Refuses another checkout's own `.git` directory, a git dir inside the
|
|
181
|
+
* checkout, a symlinked `.git`, and a git dir whose back-link names a different checkout
|
|
182
|
+
* whose `.git` file still names it. "" when this is not such a layout. */
|
|
183
|
+
export declare function claimSeparateGitDir(cwd: string): string;
|
|
184
|
+
/** The separate git dir claimSeparateGitDir WOULD claim for `cwd`, without writing
|
|
185
|
+
* anything (setup snapshots it for rollback). null when the layout is not claimable. */
|
|
186
|
+
export declare function separateGitDirCandidate(cwd: string): {
|
|
187
|
+
gitDir: string;
|
|
188
|
+
top: string;
|
|
189
|
+
} | null;
|
|
159
190
|
/** True when `cwd` is inside a LINKED worktree (not the main checkout): its own git
|
|
160
191
|
* dir differs from the shared common dir. Used by `hunch doctor` and setup messaging. */
|
|
161
192
|
export declare function isLinkedWorktree(cwd: string): boolean;
|
package/dist/extractors/git.js
CHANGED
|
@@ -7,6 +7,7 @@ import { isAbsolute, resolve, join, basename, dirname, relative, sep, posix } fr
|
|
|
7
7
|
import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
10
11
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
11
12
|
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
12
13
|
import { DIFF_TRUNCATED_LINE } from "./diff.js";
|
|
@@ -227,7 +228,7 @@ function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
|
|
|
227
228
|
* directory that Node reached through its long path. A nonzero file ID keeps
|
|
228
229
|
* this exact even on case-sensitive Windows directories; canonical text is a
|
|
229
230
|
* conservative fallback for filesystems that do not expose stable IDs. */
|
|
230
|
-
function sameFilesystemEntry(left, right) {
|
|
231
|
+
export function sameFilesystemEntry(left, right) {
|
|
231
232
|
try {
|
|
232
233
|
const leftStat = statSync(left, { bigint: true });
|
|
233
234
|
const rightStat = statSync(right, { bigint: true });
|
|
@@ -1916,6 +1917,184 @@ export function gitCommonDir(cwd) {
|
|
|
1916
1917
|
return "";
|
|
1917
1918
|
return isAbsolute(p) ? p : resolve(cwd, p);
|
|
1918
1919
|
}
|
|
1920
|
+
/** The git common dir for `cwd`, but only when Git reached it through the worktree's own
|
|
1921
|
+
* `.git` entry and that entry is one Git itself set up (gitDirServesWorktree). Checkout
|
|
1922
|
+
* content can never supply a `.git` entry through Git (Git refuses to track `.git`), but
|
|
1923
|
+
* an extracted archive can, so the entry alone proves nothing. A directory that merely
|
|
1924
|
+
* looks like a repository (HEAD/objects/refs/config committed as ordinary files) is never
|
|
1925
|
+
* accepted, whichever Git version runs. "" when there is no such repo. */
|
|
1926
|
+
export function checkoutCommonDir(cwd) {
|
|
1927
|
+
// Isolated env: a hook's exported GIT_DIR/GIT_COMMON_DIR must not redirect resolution
|
|
1928
|
+
// away from `cwd`'s own layout.
|
|
1929
|
+
const [top, own, commonOut] = gitSafeIsolated(["-c", "safe.bareRepository=explicit", "rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir"], cwd).split("\n");
|
|
1930
|
+
if (!top || !own || !commonOut)
|
|
1931
|
+
return "";
|
|
1932
|
+
const common = isAbsolute(commonOut) ? commonOut : resolve(cwd, commonOut);
|
|
1933
|
+
const dotGit = join(top, ".git");
|
|
1934
|
+
try {
|
|
1935
|
+
// lstat: Git never creates `.git` as a symlink, so a linked `.git` is treated like a
|
|
1936
|
+
// `.git` file — it must be vouched for by the repository it reaches.
|
|
1937
|
+
const entry = lstatSync(dotGit);
|
|
1938
|
+
const stat = entry.isSymbolicLink() ? statSync(dotGit) : entry;
|
|
1939
|
+
const indirect = entry.isSymbolicLink() || stat.isFile();
|
|
1940
|
+
if (stat.isDirectory()) {
|
|
1941
|
+
if (!sameFilesystemEntry(own, dotGit))
|
|
1942
|
+
return "";
|
|
1943
|
+
}
|
|
1944
|
+
else if (stat.isFile()) {
|
|
1945
|
+
const named = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
|
|
1946
|
+
if (!named || !sameFilesystemEntry(own, resolve(top, named)))
|
|
1947
|
+
return "";
|
|
1948
|
+
}
|
|
1949
|
+
else {
|
|
1950
|
+
return "";
|
|
1951
|
+
}
|
|
1952
|
+
if (!gitDirServesWorktree(own, common, top, dotGit, indirect, entry.isSymbolicLink()))
|
|
1953
|
+
return "";
|
|
1954
|
+
}
|
|
1955
|
+
catch {
|
|
1956
|
+
return "";
|
|
1957
|
+
}
|
|
1958
|
+
if (!insideDirectory(cwd, top))
|
|
1959
|
+
return "";
|
|
1960
|
+
return canonicalPath(common);
|
|
1961
|
+
}
|
|
1962
|
+
/** Whether `path` physically is `dir` or lies below it, by filesystem identity rather than
|
|
1963
|
+
* path text, so a case variant or firmlink spelling of the same directory still counts.
|
|
1964
|
+
* The walk starts from the resolved path: a symlink inside `dir` that leads elsewhere does
|
|
1965
|
+
* not count as inside. */
|
|
1966
|
+
function insideDirectory(path, dir) {
|
|
1967
|
+
for (let at = canonicalPath(path);; at = dirname(at)) {
|
|
1968
|
+
if (sameFilesystemEntry(at, dir))
|
|
1969
|
+
return true;
|
|
1970
|
+
if (dirname(at) === at)
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
/** Whether Git itself set up `gitdir` to serve the worktree at `top`, so that `common` is
|
|
1975
|
+
* genuinely this checkout's repository:
|
|
1976
|
+
* - `gitdir` IS the common dir (no `commondir` indirection): a real `.git` directory, or,
|
|
1977
|
+
* reached through a `.git` file or symlink, a repository whose `core.worktree` resolves
|
|
1978
|
+
* to `top` (a submodule). `git init --separate-git-dir` sets no `core.worktree`, so that
|
|
1979
|
+
* layout is accepted only through a `.git` FILE (never a symlink) whose git dir lies
|
|
1980
|
+
* outside the checkout and carries Hunch's back-link naming `top` — written only by
|
|
1981
|
+
* explicit setup run in that checkout (claimSeparateGitDir);
|
|
1982
|
+
* - otherwise `gitdir` must be registered in the common dir's own `worktrees/` directory,
|
|
1983
|
+
* its `gitdir` back-link must name `top/.git`, and that `.git` must not be a symlink.
|
|
1984
|
+
* A git dir anywhere else can name any repository through a `commondir` file, so it is
|
|
1985
|
+
* never accepted. */
|
|
1986
|
+
function gitDirServesWorktree(gitdir, common, top, dotGit, indirect, symlinked) {
|
|
1987
|
+
if (sameFilesystemEntry(gitdir, common)) {
|
|
1988
|
+
if (!indirect)
|
|
1989
|
+
return true;
|
|
1990
|
+
const worktree = gitSafeIsolated(["config", "--file", join(gitdir, "config"), "--get", "core.worktree"], gitdir);
|
|
1991
|
+
if (worktree)
|
|
1992
|
+
return sameFilesystemEntry(resolve(gitdir, worktree), top);
|
|
1993
|
+
return !symlinked && separateGitDirLinksTo(gitdir, top);
|
|
1994
|
+
}
|
|
1995
|
+
// A linked worktree's back-link names its own `.git` file, and a symlink resolves to the
|
|
1996
|
+
// very file it points at, so the back-link cannot vouch for a symlinked `.git`.
|
|
1997
|
+
if (symlinked)
|
|
1998
|
+
return false;
|
|
1999
|
+
const registry = dirname(canonicalPath(gitdir));
|
|
2000
|
+
if (basename(registry) !== "worktrees" || !sameFilesystemEntry(dirname(registry), common))
|
|
2001
|
+
return false;
|
|
2002
|
+
try {
|
|
2003
|
+
const backLink = readFileSync(join(gitdir, "gitdir"), "utf8").trim();
|
|
2004
|
+
if (!backLink)
|
|
2005
|
+
return false;
|
|
2006
|
+
// Same file AND same parent directory: a hard link to another worktree's `.git` file
|
|
2007
|
+
// shares its inode but not its directory. Identity, not path text, so case variants
|
|
2008
|
+
// and firmlink spellings that Git may have written still match.
|
|
2009
|
+
const named = resolve(gitdir, backLink);
|
|
2010
|
+
return basename(named) === ".git" && sameFilesystemEntry(named, dotGit) && sameFilesystemEntry(dirname(named), top);
|
|
2011
|
+
}
|
|
2012
|
+
catch {
|
|
2013
|
+
return false;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
/** Hunch's back-link inside a separate git dir, naming the one checkout it serves. Git
|
|
2017
|
+
* records nothing for `git init --separate-git-dir` (no `core.worktree`, no worktree
|
|
2018
|
+
* registry entry), so without this a `.git` file could name any repository's git dir. */
|
|
2019
|
+
export function separateGitDirLink(gitdir) {
|
|
2020
|
+
return join(gitdir, "hunch", "checkout-root");
|
|
2021
|
+
}
|
|
2022
|
+
/** Whether a separate git dir is vouched for `top`: it lies outside the checkout (so no
|
|
2023
|
+
* checkout or archive content can supply it or its back-link) and its Hunch back-link
|
|
2024
|
+
* names `top`. */
|
|
2025
|
+
function separateGitDirLinksTo(gitdir, top) {
|
|
2026
|
+
if (insideDirectory(gitdir, top))
|
|
2027
|
+
return false;
|
|
2028
|
+
try {
|
|
2029
|
+
const linked = readFileSync(separateGitDirLink(gitdir), "utf8").trim();
|
|
2030
|
+
return !!linked && isAbsolute(linked) && sameFilesystemEntry(linked, top);
|
|
2031
|
+
}
|
|
2032
|
+
catch {
|
|
2033
|
+
return false;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
/** Explicit setup (`hunch private` / `hunch shared` / `hunch worktree`) run in a checkout
|
|
2037
|
+
* created by `git init --separate-git-dir`: record that this checkout owns its git dir,
|
|
2038
|
+
* then resolve it through checkoutCommonDir. Called ONLY from a command the user ran in
|
|
2039
|
+
* this checkout — never while opening a store, so repository content can never claim a
|
|
2040
|
+
* git dir. Refuses another checkout's own `.git` directory, a git dir inside the
|
|
2041
|
+
* checkout, a symlinked `.git`, and a git dir whose back-link names a different checkout
|
|
2042
|
+
* whose `.git` file still names it. "" when this is not such a layout. */
|
|
2043
|
+
export function claimSeparateGitDir(cwd) {
|
|
2044
|
+
const candidate = separateGitDirCandidate(cwd);
|
|
2045
|
+
if (!candidate)
|
|
2046
|
+
return "";
|
|
2047
|
+
try {
|
|
2048
|
+
const link = separateGitDirLink(candidate.gitDir);
|
|
2049
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
2050
|
+
writeFileAtomic(link, `${canonicalPath(candidate.top)}\n`);
|
|
2051
|
+
}
|
|
2052
|
+
catch {
|
|
2053
|
+
return "";
|
|
2054
|
+
}
|
|
2055
|
+
return checkoutCommonDir(cwd);
|
|
2056
|
+
}
|
|
2057
|
+
/** The separate git dir claimSeparateGitDir WOULD claim for `cwd`, without writing
|
|
2058
|
+
* anything (setup snapshots it for rollback). null when the layout is not claimable. */
|
|
2059
|
+
export function separateGitDirCandidate(cwd) {
|
|
2060
|
+
const [top, own, commonOut] = gitSafeIsolated(["-c", "safe.bareRepository=explicit", "rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir"], cwd).split("\n");
|
|
2061
|
+
if (!top || !own || !commonOut)
|
|
2062
|
+
return null;
|
|
2063
|
+
const common = isAbsolute(commonOut) ? commonOut : resolve(cwd, commonOut);
|
|
2064
|
+
if (!sameFilesystemEntry(own, common) || !insideDirectory(cwd, top))
|
|
2065
|
+
return null;
|
|
2066
|
+
const dotGit = join(top, ".git");
|
|
2067
|
+
const namesOwn = (file) => {
|
|
2068
|
+
try {
|
|
2069
|
+
if (!lstatSync(file).isFile())
|
|
2070
|
+
return false;
|
|
2071
|
+
const named = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(file, "utf8"))?.[1];
|
|
2072
|
+
return !!named && sameFilesystemEntry(own, resolve(dirname(file), named));
|
|
2073
|
+
}
|
|
2074
|
+
catch {
|
|
2075
|
+
return false;
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
if (!namesOwn(dotGit))
|
|
2079
|
+
return null;
|
|
2080
|
+
if (gitSafeIsolated(["config", "--file", join(own, "config"), "--get", "core.worktree"], own))
|
|
2081
|
+
return null;
|
|
2082
|
+
if (insideDirectory(own, top))
|
|
2083
|
+
return null;
|
|
2084
|
+
try {
|
|
2085
|
+
const parentDotGit = join(dirname(canonicalPath(own)), ".git");
|
|
2086
|
+
if (lstatSync(parentDotGit).isDirectory() && sameFilesystemEntry(parentDotGit, own))
|
|
2087
|
+
return null;
|
|
2088
|
+
}
|
|
2089
|
+
catch { /* not another checkout's embedded .git directory */ }
|
|
2090
|
+
try {
|
|
2091
|
+
const prior = readFileSync(separateGitDirLink(own), "utf8").trim();
|
|
2092
|
+
if (prior && !sameFilesystemEntry(prior, top) && namesOwn(join(prior, ".git")))
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
catch { /* no back-link yet */ }
|
|
2096
|
+
return { gitDir: canonicalPath(own), top };
|
|
2097
|
+
}
|
|
1919
2098
|
/** True when `cwd` is inside a LINKED worktree (not the main checkout): its own git
|
|
1920
2099
|
* dir differs from the shared common dir. Used by `hunch doctor` and setup messaging. */
|
|
1921
2100
|
export function isLinkedWorktree(cwd) {
|
|
@@ -33,7 +33,8 @@ export declare class NativeTreeSitterLoadError extends Error {
|
|
|
33
33
|
* survives both. */
|
|
34
34
|
export declare function isParserLoadError(e: unknown): boolean;
|
|
35
35
|
/** Load all native tree-sitter addons (the parser runtime + every grammar) from
|
|
36
|
-
*
|
|
36
|
+
* content-addressed copies in a per-user temp cache — never from the installed
|
|
37
|
+
* package. Windows keeps loaded `.node` files locked for the
|
|
37
38
|
* process lifetime; redirecting the upstream loaders means npm can replace the
|
|
38
39
|
* installed package during an active MCP session without killing that session
|
|
39
40
|
* or falling back to a stale binary. */
|