@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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Files a shell command wrote.
|
|
2
|
+
*
|
|
3
|
+
* Pre-edit grounding keys on the host's edit tools (Edit/Write/apply_patch).
|
|
4
|
+
* An agent that edits through the shell — a `python` heredoc, `sed -i`, `perl
|
|
5
|
+
* -pi`, a PowerShell `Set-Content` — never passes through them, so the file it
|
|
6
|
+
* changed arrives with no grounding at all. The shell tool's post-execution
|
|
7
|
+
* hook sees every command, but not what the command touched; parsing arbitrary
|
|
8
|
+
* shell for write targets is guesswork.
|
|
9
|
+
*
|
|
10
|
+
* Instead: fingerprint the working tree's dirty files (`git status`, then
|
|
11
|
+
* mtime+size) per session, repository and agent. A prompt and every tool call refresh
|
|
12
|
+
* the fingerprint; after a shell command, a dirty file whose fingerprint moved
|
|
13
|
+
* was written by that command. Deterministic, shell-agnostic, and bounded by the
|
|
14
|
+
* dirty set (never a tree walk). Any failure yields no files — never an error. */
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
/** Dirty paths fingerprinted; beyond it the rest are ignored (a vendored
|
|
21
|
+
* untracked tree must not make every command slow). */
|
|
22
|
+
const MAX_PATHS = 2000;
|
|
23
|
+
/** Hunch's own state: written by the hook itself and by capture tools. */
|
|
24
|
+
const OWN_STATE = /^\.hunch(?:-cache)?\//;
|
|
25
|
+
/** Concurrent subagents share the session but not their commands: each keeps
|
|
26
|
+
* its own baseline, keyed by its agent id (hashed with the rest, never kept
|
|
27
|
+
* raw) like the pre-edit dedupe. No agent id keeps the session's baseline. */
|
|
28
|
+
function snapshotFile(root, sessionId, agentId) {
|
|
29
|
+
const key = createHash("sha256").update(`${sessionId}\u0000${root}${agentId ? `\u0000${agentId}` : ""}`).digest("hex").slice(0, 24);
|
|
30
|
+
// Same directory as the session injection cache: its sweep drops stale files.
|
|
31
|
+
return join(tmpdir(), "hunch-hookcache", `shell-${key}.json`);
|
|
32
|
+
}
|
|
33
|
+
/** Repo-relative dirty paths (tracked changes and untracked files), NUL-safe. */
|
|
34
|
+
function dirtyPaths(root) {
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = execFileSync("git", ["-C", root, "status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
timeout: 2_000,
|
|
40
|
+
maxBuffer: 8_000_000,
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// Porcelain paths are relative to the git toplevel, which can sit above the
|
|
49
|
+
// Hunch root (a package inside a monorepo): re-relativize, drop the outside.
|
|
50
|
+
let prefix = "";
|
|
51
|
+
try {
|
|
52
|
+
prefix = execFileSync("git", ["-C", root, "rev-parse", "--show-prefix"], { encoding: "utf8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const out = [];
|
|
58
|
+
const fields = raw.split("\u0000");
|
|
59
|
+
for (let i = 0; i < fields.length && out.length < MAX_PATHS; i++) {
|
|
60
|
+
const entry = fields[i];
|
|
61
|
+
if (entry.length < 4)
|
|
62
|
+
continue;
|
|
63
|
+
const status = entry.slice(0, 2);
|
|
64
|
+
const path = entry.slice(3);
|
|
65
|
+
if (path.startsWith(prefix))
|
|
66
|
+
out.push(path.slice(prefix.length));
|
|
67
|
+
// A rename/copy entry is followed by its source path.
|
|
68
|
+
if (status.includes("R") || status.includes("C"))
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function fingerprint(root) {
|
|
74
|
+
const paths = dirtyPaths(root);
|
|
75
|
+
if (!paths)
|
|
76
|
+
return null;
|
|
77
|
+
const fp = {};
|
|
78
|
+
for (const p of paths) {
|
|
79
|
+
if (OWN_STATE.test(p))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
const st = statSync(join(root, p));
|
|
83
|
+
if (st.isFile())
|
|
84
|
+
fp[p] = `${st.mtimeMs}:${st.size}`;
|
|
85
|
+
}
|
|
86
|
+
catch { /* deleted: nothing to ground */ }
|
|
87
|
+
}
|
|
88
|
+
return fp;
|
|
89
|
+
}
|
|
90
|
+
function load(file) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
93
|
+
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function save(file, fp) {
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(join(tmpdir(), "hunch-hookcache"), { recursive: true });
|
|
102
|
+
writeFileSync(file, JSON.stringify(fp));
|
|
103
|
+
}
|
|
104
|
+
catch { /* the next refresh retries */ }
|
|
105
|
+
}
|
|
106
|
+
/** Record the working tree as it stands, so later shell writes are measured
|
|
107
|
+
* from here (a prompt, or any tool call that is not a shell command). */
|
|
108
|
+
export function refreshShellBaseline(root, sessionId, agentId) {
|
|
109
|
+
if (!sessionId)
|
|
110
|
+
return;
|
|
111
|
+
const fp = fingerprint(root);
|
|
112
|
+
if (fp)
|
|
113
|
+
save(snapshotFile(root, sessionId, agentId), fp);
|
|
114
|
+
}
|
|
115
|
+
/** Repo-relative files the shell command that just ran wrote (created or
|
|
116
|
+
* modified), and the baseline moves forward. Empty without a baseline: a
|
|
117
|
+
* session's first observation cannot tell its own writes from earlier ones. */
|
|
118
|
+
export function shellWrittenFiles(root, sessionId, agentId) {
|
|
119
|
+
if (!sessionId)
|
|
120
|
+
return [];
|
|
121
|
+
const file = snapshotFile(root, sessionId, agentId);
|
|
122
|
+
const before = load(file);
|
|
123
|
+
const now = fingerprint(root);
|
|
124
|
+
if (!now)
|
|
125
|
+
return [];
|
|
126
|
+
save(file, now);
|
|
127
|
+
if (!before)
|
|
128
|
+
return [];
|
|
129
|
+
return Object.keys(now).filter((p) => before[p] !== now[p]).sort();
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=shellwrites.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export interface SiblingCommit {
|
|
2
|
+
sha: string;
|
|
3
|
+
subject: string;
|
|
4
|
+
fix: boolean;
|
|
5
|
+
/** The commit's changed lines inside the sibling, in hunk order, `+`/`-` prefixed. */
|
|
6
|
+
change: string[];
|
|
7
|
+
/** Test files the same commit changed: the proof that came with the fix. */
|
|
8
|
+
tests?: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface SiblingLesson {
|
|
11
|
+
symbol: string;
|
|
12
|
+
file: string;
|
|
13
|
+
line: number;
|
|
14
|
+
sibling: string;
|
|
15
|
+
siblingFile: string;
|
|
16
|
+
siblingStart: number;
|
|
17
|
+
siblingEnd: number;
|
|
18
|
+
similarity: number;
|
|
19
|
+
commits: SiblingCommit[];
|
|
20
|
+
}
|
|
21
|
+
export interface SiblingOptions {
|
|
22
|
+
/** Candidate pairs whose history is inspected (best similarity first). */
|
|
23
|
+
maxPairs?: number;
|
|
24
|
+
/** Lessons returned. */
|
|
25
|
+
maxLessons?: number;
|
|
26
|
+
/** Per git call; each call is also capped by what is left of `budgetMs`. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
/** Wall-clock budget for the whole computation; past it the result is incomplete. */
|
|
29
|
+
budgetMs?: number;
|
|
30
|
+
/** Cap on the no-index inventory scan (tests force truncation with it). */
|
|
31
|
+
inventoryBudgetMs?: number;
|
|
32
|
+
/** Set false to bypass the .hunch-cache read/write (tests). */
|
|
33
|
+
cache?: boolean;
|
|
34
|
+
/** Clock for the incomplete-run retry window (tests). */
|
|
35
|
+
now?: number;
|
|
36
|
+
}
|
|
37
|
+
interface IndexedSymbol {
|
|
38
|
+
file: string;
|
|
39
|
+
name: string;
|
|
40
|
+
kind: string;
|
|
41
|
+
}
|
|
42
|
+
/** `isHunchProviderHook` → {is, hunch, provider, hook}. */
|
|
43
|
+
export declare function nameTokens(name: string): Set<string>;
|
|
44
|
+
/** Identifier-ish tokens of a body's CODE, including those inside regex/string
|
|
45
|
+
* literals. Comment lines are dropped: a fix usually adds an explanation, and
|
|
46
|
+
* its prose would otherwise swamp the shared shape. */
|
|
47
|
+
export declare function codeTokens(text: string): Set<string>;
|
|
48
|
+
/** Overlap coefficient: shared / smaller set. A hardened copy grows, so
|
|
49
|
+
* containment — not Jaccard — is what "same shape" means for bodies. */
|
|
50
|
+
export declare function overlap(a: ReadonlySet<string>, b: ReadonlySet<string>): number;
|
|
51
|
+
export declare function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number;
|
|
52
|
+
export declare function isFixSubject(subject: string): boolean;
|
|
53
|
+
export interface LineLogCommit {
|
|
54
|
+
sha: string;
|
|
55
|
+
subject: string;
|
|
56
|
+
added: string[];
|
|
57
|
+
removed: string[];
|
|
58
|
+
/** Added and removed lines in hunk order, `+`/`-` prefixed. */
|
|
59
|
+
diff: string[];
|
|
60
|
+
/** Largest old/new line span of any hunk — how far git's range reached. */
|
|
61
|
+
span: number;
|
|
62
|
+
/** Every hunk starts from nothing (`@@ -0,0 …`): the function was created here. */
|
|
63
|
+
created: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** Parse `git log -L … --format=%x1eC %H%x1f%s` output. */
|
|
66
|
+
export declare function parseLineLog(output: string): LineLogCommit[];
|
|
67
|
+
/** A commit whose removed and added lines differ only in whitespace/line
|
|
68
|
+
* endings (a reformat, a CRLF→LF sweep, a re-indent) changed nothing. */
|
|
69
|
+
export declare function isSubstantive(commit: LineLogCommit): boolean;
|
|
70
|
+
/** git follows a line range backwards only while it can map it. A whole-file
|
|
71
|
+
* rewrite (a line-ending sweep, a reformat) breaks the mapping: from that
|
|
72
|
+
* commit on, every hunk spans the whole file and nothing in it is attributable
|
|
73
|
+
* to the function. Keep the history before that commit and report where the
|
|
74
|
+
* mapping was lost. */
|
|
75
|
+
export declare function attributable(commits: readonly LineLogCommit[], fnLines: number): {
|
|
76
|
+
kept: LineLogCommit[];
|
|
77
|
+
lostAt: string | null;
|
|
78
|
+
};
|
|
79
|
+
/** Candidate scans run (tests: a cache hit must not run one). */
|
|
80
|
+
export declare const siblingCounters: {
|
|
81
|
+
candidateScans: number;
|
|
82
|
+
lessonComputes: number;
|
|
83
|
+
};
|
|
84
|
+
/** Sibling-fix lessons for one repo-relative file. */
|
|
85
|
+
export declare function siblingLessonsFor(root: string, file: string, symbols: readonly IndexedSymbol[], options?: SiblingOptions): SiblingLesson[];
|
|
86
|
+
/** Stable identity for hook dedupe: which fixes were surfaced, not the wording. */
|
|
87
|
+
export declare function siblingLessonsIdentity(lessons: readonly SiblingLesson[]): string;
|
|
88
|
+
/** A commit's diff inside the function, blank-only lines dropped, capped
|
|
89
|
+
* (a fix commit gets the larger cap). */
|
|
90
|
+
export declare function changeLines(diff: readonly string[], fix?: boolean): string[];
|
|
91
|
+
export declare const SIBLING_HEADING = "## \u26A0 Fix not carried to this function";
|
|
92
|
+
/** Callers of each lesson's function, by lesson symbol: names of functions in
|
|
93
|
+
* the same working-tree file whose body calls it. */
|
|
94
|
+
export type SiblingCallers = ReadonlyMap<string, readonly string[]>;
|
|
95
|
+
/** Written to be acted on, not skimmed: an agent reads "possible, heuristic,
|
|
96
|
+
* elsewhere" as out of scope and moves on (trap-310: three deliveries, zero
|
|
97
|
+
* follow-ups). So the lesson says what this copy lacks, which code here runs
|
|
98
|
+
* through it, and asks for an explicit outcome. It stays advisory: "does not
|
|
99
|
+
* apply, because …" is always an accepted answer. */
|
|
100
|
+
/** A blocking invariant whose scope covers the lesson's file. */
|
|
101
|
+
export interface SiblingInvariant {
|
|
102
|
+
id: string;
|
|
103
|
+
statement: string;
|
|
104
|
+
}
|
|
105
|
+
/** "Pre-existing" and "outside this task" are how an agent that agrees with the
|
|
106
|
+
* lesson still leaves it (trap-310 v4: gap confirmed, reported, not fixed).
|
|
107
|
+
* The change in hand runs through the copy, so only "cannot reach it" or
|
|
108
|
+
* "already handled" count as not applying. */
|
|
109
|
+
export declare const NOT_A_REASON = "\"Pre-existing\" or \"outside this task\" does not count: the change you are making runs through this copy, so leaving it ships the gap again inside your change.";
|
|
110
|
+
export declare function renderSiblingLessons(lessons: readonly SiblingLesson[], callers?: SiblingCallers, invariants?: readonly SiblingInvariant[]): string;
|
|
111
|
+
/** Hash of the function's current body, or null when it cannot be found. A
|
|
112
|
+
* changed hash means the agent touched the function after the lesson. */
|
|
113
|
+
export declare function functionBodyHash(root: string, file: string, symbol: string): string | null;
|
|
114
|
+
export interface SiblingGrounding {
|
|
115
|
+
text: string;
|
|
116
|
+
identity: string;
|
|
117
|
+
lessons: SiblingLesson[];
|
|
118
|
+
callers: SiblingCallers;
|
|
119
|
+
}
|
|
120
|
+
/** Grounding-path entry: the rendered block plus its dedupe identity for one
|
|
121
|
+
* repo-relative file. Never throws — a parser load failure or a git error
|
|
122
|
+
* means no sibling lessons, not a failed edit hook. */
|
|
123
|
+
export declare function siblingGrounding(root: string, file: string, symbols: readonly IndexedSymbol[], invariants?: readonly SiblingInvariant[]): SiblingGrounding;
|
|
124
|
+
export {};
|