@davesheffer/hunch 1.4.2 → 1.6.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 +117 -23
- package/dist/cli/index.js +269 -57
- package/dist/core/agenthook.js +197 -0
- package/dist/core/autoreview.js +52 -0
- package/dist/core/config.js +1 -1
- package/dist/core/drift.js +25 -3
- package/dist/core/hookcache.js +1 -1
- package/dist/core/refrepair.js +33 -0
- package/dist/extractors/git.js +31 -1
- package/dist/integrations/hooks.js +4 -0
- package/dist/integrations/providers.js +177 -23
- package/dist/mcp/server.js +23 -22
- package/dist/store/hunchStore.js +26 -5
- package/dist/synthesis/provider.js +58 -0
- package/dist/synthesis/synthesize.js +32 -17
- package/package.json +6 -2
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider hook dialects → Hunch's one internal event shape.
|
|
3
|
+
*
|
|
4
|
+
* Hook payloads are an integration boundary: every provider is free to rename
|
|
5
|
+
* fields or tools. Keep that variability here so the policy engine receives
|
|
6
|
+
* the same small, fail-open shape regardless of the assistant that emitted it.
|
|
7
|
+
*/
|
|
8
|
+
export const HOOK_PROVIDERS = ["claude", "vscode", "windsurf", "antigravity", "cursor"];
|
|
9
|
+
function obj(value) {
|
|
10
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
function str(value) {
|
|
13
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
function stringAt(input, ...keys) {
|
|
16
|
+
for (const key of keys) {
|
|
17
|
+
const value = str(input[key]);
|
|
18
|
+
if (value)
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function hunchToolName(name, input) {
|
|
24
|
+
if (!name)
|
|
25
|
+
return undefined;
|
|
26
|
+
const lower = name.toLowerCase();
|
|
27
|
+
if (/multi.*(edit|replace)|edit.*files|multi_replace/.test(lower))
|
|
28
|
+
return "MultiEdit";
|
|
29
|
+
if (/^(edit|strreplace|replace_string_in_file|replace_file_content)$/.test(lower) || /replace.*(string|content)/.test(lower))
|
|
30
|
+
return "Edit";
|
|
31
|
+
if (/^(write|create|create_file|write_to_file)$/.test(lower) || /write.*file/.test(lower))
|
|
32
|
+
return "Write";
|
|
33
|
+
if (/(shell|bash|terminal|run_command|run.*command|powershell)/.test(lower))
|
|
34
|
+
return "Bash";
|
|
35
|
+
if (/skill/.test(lower))
|
|
36
|
+
return "Skill";
|
|
37
|
+
// A provider can call an edit tool something new. A file path plus proposed
|
|
38
|
+
// content is enough to safely treat it as a write for policy purposes.
|
|
39
|
+
if (input.file_path && (input.new_string || input.content || input.edits?.length))
|
|
40
|
+
return "Edit";
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
function edits(value) {
|
|
44
|
+
if (!Array.isArray(value))
|
|
45
|
+
return undefined;
|
|
46
|
+
const normalized = value
|
|
47
|
+
.map((item) => obj(item))
|
|
48
|
+
.filter((item) => !!item)
|
|
49
|
+
.map((item) => ({ new_string: stringAt(item, "new_string", "newString", "ReplacementContent", "replacementContent") }));
|
|
50
|
+
return normalized.length ? normalized : undefined;
|
|
51
|
+
}
|
|
52
|
+
function normalizeToolInput(value) {
|
|
53
|
+
const raw = obj(value);
|
|
54
|
+
if (!raw)
|
|
55
|
+
return undefined;
|
|
56
|
+
const replacementChunks = Array.isArray(raw.ReplacementChunks) ? raw.ReplacementChunks : raw.replacementChunks;
|
|
57
|
+
const chunkEdits = Array.isArray(replacementChunks)
|
|
58
|
+
? replacementChunks.map((chunk) => obj(chunk)).filter((chunk) => !!chunk)
|
|
59
|
+
.map((chunk) => ({ new_string: stringAt(chunk, "ReplacementContent", "replacementContent", "new_string", "newString") }))
|
|
60
|
+
: undefined;
|
|
61
|
+
const out = {
|
|
62
|
+
file_path: stringAt(raw, "file_path", "filePath", "path", "uri", "TargetFile", "targetFile", "AbsolutePath", "absolutePath"),
|
|
63
|
+
new_string: stringAt(raw, "new_string", "newString", "ReplacementContent", "replacementContent", "TargetContent", "targetContent"),
|
|
64
|
+
content: stringAt(raw, "content", "contents", "CodeContent", "codeContent"),
|
|
65
|
+
edits: edits(raw.edits) ?? edits(raw.files) ?? chunkEdits,
|
|
66
|
+
command: stringAt(raw, "command", "commandLine", "CommandLine", "cmd"),
|
|
67
|
+
skill: stringAt(raw, "skill", "skillName", "name"),
|
|
68
|
+
};
|
|
69
|
+
return Object.values(out).some((v) => v !== undefined) ? out : undefined;
|
|
70
|
+
}
|
|
71
|
+
function eventName(value, provider) {
|
|
72
|
+
if (typeof value !== "string")
|
|
73
|
+
return undefined;
|
|
74
|
+
const name = value.toLowerCase();
|
|
75
|
+
const map = {
|
|
76
|
+
pretooluse: "PreToolUse",
|
|
77
|
+
posttooluse: "PostToolUse",
|
|
78
|
+
userpromptsubmit: "UserPromptSubmit",
|
|
79
|
+
sessionstart: "SessionStart",
|
|
80
|
+
stop: "Stop",
|
|
81
|
+
};
|
|
82
|
+
if (map[name])
|
|
83
|
+
return map[name];
|
|
84
|
+
if (provider === "cursor") {
|
|
85
|
+
if (name === "beforesubmitprompt")
|
|
86
|
+
return "UserPromptSubmit";
|
|
87
|
+
if (name === "beforetoolexecution" || name === "beforefileedit" || name === "beforeshellexecution")
|
|
88
|
+
return "PreToolUse";
|
|
89
|
+
if (name === "afterfileedit" || name === "aftershellexecution")
|
|
90
|
+
return "PostToolUse";
|
|
91
|
+
}
|
|
92
|
+
if (provider === "windsurf") {
|
|
93
|
+
if (name === "pre_write_code" || name === "pre_run_command")
|
|
94
|
+
return "PreToolUse";
|
|
95
|
+
if (name === "post_write_code" || name === "post_run_command")
|
|
96
|
+
return "PostToolUse";
|
|
97
|
+
if (name === "pre_user_prompt")
|
|
98
|
+
return "UserPromptSubmit";
|
|
99
|
+
}
|
|
100
|
+
// Antigravity's PreInvocation is the lifecycle point which can inject a
|
|
101
|
+
// transient message before the model sees the turn. Internally it provides
|
|
102
|
+
// Hunch's session-orientation behavior.
|
|
103
|
+
if (provider === "antigravity" && name === "preinvocation")
|
|
104
|
+
return "SessionStart";
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
/** Parse a provider name supplied by a hook config. Unknown values intentionally
|
|
108
|
+
* return null so a bad config cannot make an edit fail. */
|
|
109
|
+
export function hookProvider(value) {
|
|
110
|
+
return typeof value === "string" && HOOK_PROVIDERS.includes(value.toLowerCase())
|
|
111
|
+
? value.toLowerCase()
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
/** Normalize a hook stdin payload. Unknown/malformed events return null and the
|
|
115
|
+
* CLI exits successfully without output — the Never Block on Hook Failure rule. */
|
|
116
|
+
export function normalizeHookEvent(raw, provider) {
|
|
117
|
+
const input = obj(raw);
|
|
118
|
+
if (!input)
|
|
119
|
+
return null;
|
|
120
|
+
if (provider === "antigravity") {
|
|
121
|
+
const agEvent = input.toolCall ? "PreToolUse" : input.invocationNum !== undefined ? "PreInvocation" : input.executionNum !== undefined ? "Stop" : undefined;
|
|
122
|
+
const event = eventName(agEvent, provider);
|
|
123
|
+
if (!event)
|
|
124
|
+
return null;
|
|
125
|
+
const call = obj(input.toolCall);
|
|
126
|
+
const toolInput = normalizeToolInput(call?.args);
|
|
127
|
+
return {
|
|
128
|
+
hook_event_name: event,
|
|
129
|
+
session_id: stringAt(input, "conversationId"),
|
|
130
|
+
tool_name: hunchToolName(stringAt(call ?? {}, "name"), toolInput ?? {}),
|
|
131
|
+
tool_input: toolInput,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (provider === "windsurf") {
|
|
135
|
+
const event = eventName(input.event ?? input.hook_event_name, provider);
|
|
136
|
+
if (!event)
|
|
137
|
+
return null;
|
|
138
|
+
const info = obj(input.tool_info) ?? obj(input.toolInput) ?? obj(input.tool_input);
|
|
139
|
+
const toolInput = normalizeToolInput(info);
|
|
140
|
+
return {
|
|
141
|
+
hook_event_name: event,
|
|
142
|
+
session_id: stringAt(input, "trajectory_id", "session_id", "sessionId"),
|
|
143
|
+
tool_name: hunchToolName(stringAt(input, "agent_action_name", "tool_name", "toolName"), toolInput ?? {}),
|
|
144
|
+
tool_input: toolInput,
|
|
145
|
+
prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
|
|
149
|
+
if (!event)
|
|
150
|
+
return null;
|
|
151
|
+
const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput);
|
|
152
|
+
return {
|
|
153
|
+
hook_event_name: event,
|
|
154
|
+
session_id: stringAt(input, "session_id", "sessionId", "conversation_id", "conversationId"),
|
|
155
|
+
tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
|
|
156
|
+
tool_input: toolInput,
|
|
157
|
+
prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Provider-aware hook output. Context output is intentionally omitted for
|
|
161
|
+
* Windsurf because its documented hook protocol has no agent-context channel;
|
|
162
|
+
* its always-on project rule + MCP server remain the grounding delivery path. */
|
|
163
|
+
export function contextHookOutput(provider, event, text) {
|
|
164
|
+
if (provider === "windsurf")
|
|
165
|
+
return null;
|
|
166
|
+
if (provider === "antigravity") {
|
|
167
|
+
return event === "SessionStart" ? { injectSteps: [{ ephemeralMessage: text }] } : { decision: "allow" };
|
|
168
|
+
}
|
|
169
|
+
if (provider === "cursor")
|
|
170
|
+
return { permission: "allow", agent_message: text };
|
|
171
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
|
|
172
|
+
}
|
|
173
|
+
/** Strict-deny response in each native dialect. Windsurf uses documented exit
|
|
174
|
+
* code 2; the caller writes this error to stderr and preserves exit success for
|
|
175
|
+
* every accidental/malformed invocation. */
|
|
176
|
+
export function denyHookOutput(provider, reason) {
|
|
177
|
+
if (provider === "windsurf")
|
|
178
|
+
return { output: null, exitCode: 2, stderr: reason };
|
|
179
|
+
if (provider === "antigravity")
|
|
180
|
+
return { output: { decision: "deny", reason } };
|
|
181
|
+
if (provider === "cursor")
|
|
182
|
+
return { output: { permission: "deny", user_message: reason, agent_message: reason } };
|
|
183
|
+
return {
|
|
184
|
+
output: { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** Stop-gate output in each native dialect. */
|
|
188
|
+
export function stopHookOutput(provider, reason) {
|
|
189
|
+
if (provider === "vscode")
|
|
190
|
+
return { continue: false, stopReason: reason };
|
|
191
|
+
if (provider === "cursor")
|
|
192
|
+
return { followup_message: reason };
|
|
193
|
+
if (provider === "antigravity")
|
|
194
|
+
return { decision: "continue", reason };
|
|
195
|
+
return { decision: "block", reason };
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=agenthook.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { draftDuplicateOf } from "./dupdetect.js";
|
|
2
|
+
import { parseSynth, isReady, READY_MIN_GROUNDED } from "./reviewqueue.js";
|
|
3
|
+
const DEFAULT_MIN_REJECT_CONFIDENCE = 0.7;
|
|
4
|
+
/** Build the plan. `verdicts` maps draft id → harness verdict (absent → the draft
|
|
5
|
+
* was not judged, e.g. no CLI available; it can still be dup-rejected or kept). */
|
|
6
|
+
export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
|
|
7
|
+
const minGrounded = cfg.minGrounded ?? READY_MIN_GROUNDED;
|
|
8
|
+
const minReject = cfg.minRejectConfidence ?? DEFAULT_MIN_REJECT_CONFIDENCE;
|
|
9
|
+
const plan = { accept: [], rejectDuplicate: [], rejectIrrelevant: [], keep: [] };
|
|
10
|
+
for (const d of drafts) {
|
|
11
|
+
const verdict = verdicts.get(d.id);
|
|
12
|
+
const synth = parseSynth(d.provenance?.evidence);
|
|
13
|
+
const grounded = synth.grounded;
|
|
14
|
+
const base = { d, verdict, grounded };
|
|
15
|
+
// 1) Duplicate — deterministic match against accepted records, or the harness
|
|
16
|
+
// naming an existing decision. Deterministic wins first (cheapest, surest).
|
|
17
|
+
const detDup = draftDuplicateOf(d, allDecisions);
|
|
18
|
+
if (detDup) {
|
|
19
|
+
plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `near-duplicate of ${detDup.of.id} "${detDup.of.title}" (${Math.round(detDup.score * 100)}%)` });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (verdict?.duplicate_of && verdict.duplicate_of !== d.id && allDecisions.some((x) => x.id === verdict.duplicate_of)) {
|
|
23
|
+
plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `harness: restates ${verdict.duplicate_of} — ${verdict.reason}` });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// 2) Confidently-irrelevant — delete only on a strong harness "no".
|
|
27
|
+
if (verdict && !verdict.relevant && verdict.confidence >= minReject) {
|
|
28
|
+
plan.rejectIrrelevant.push({ ...base, action: "rejectIrrelevant", reason: `harness: not relevant (conf ${verdict.confidence}) — ${verdict.reason}` });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// 3) Accept — ONLY when the Critic verified + grounded it (isReady) AND the
|
|
32
|
+
// harness judged it relevant. The harness can VETO an accept, never create
|
|
33
|
+
// one on its own (dec_a466655539: the human vouch / Critic gate is the floor).
|
|
34
|
+
const ready = isReady(d, synth, minGrounded);
|
|
35
|
+
if (ready && verdict?.relevant) {
|
|
36
|
+
plan.accept.push({ ...base, action: "accept", reason: `verified + grounded ${grounded ?? "?"} ≥ ${minGrounded}, harness-relevant — ${verdict.reason}` });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// 4) Keep for a human — the safe default (unverified, ungrounded, unjudged, or
|
|
40
|
+
// a low-confidence irrelevant call).
|
|
41
|
+
const why = !verdict ? "not judged (no harness)"
|
|
42
|
+
: !ready ? (verdict.relevant ? "relevant but not Critic-verified/grounded — needs human confirm" : `irrelevant but low confidence (${verdict.confidence})`)
|
|
43
|
+
: "kept";
|
|
44
|
+
plan.keep.push({ ...base, action: "keep", reason: why });
|
|
45
|
+
}
|
|
46
|
+
return plan;
|
|
47
|
+
}
|
|
48
|
+
/** Total drafts the plan would mutate (accept + both delete buckets). */
|
|
49
|
+
export function planMutations(plan) {
|
|
50
|
+
return plan.accept.length + plan.rejectDuplicate.length + plan.rejectIrrelevant.length;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=autoreview.js.map
|
package/dist/core/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Hunch user config (`.hunch/config.json`) — runtime knobs that are NOT schema
|
|
2
2
|
* state (the on-disk schema version lives in manifest.json). Committed alongside
|
|
3
3
|
* the graph, so a whole team shares the same settings — e.g. how firmly the
|
|
4
|
-
*
|
|
4
|
+
* agent lifecycle hooks enforce engineering memory before an edit. */
|
|
5
5
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
7
|
export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
|
package/dist/core/drift.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `hunch wiki --heal`, never a gate.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
-
import { join } from "node:path";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
17
|
import { toPosixTarget } from "./paths.js";
|
|
18
18
|
import { currentForTopic, isLive } from "./topics.js";
|
|
19
19
|
import { parseDocAnchors } from "./docanchors.js";
|
|
@@ -36,7 +36,7 @@ export function computeDrift(store, root) {
|
|
|
36
36
|
for (const f of d.related_files ?? []) {
|
|
37
37
|
if (!f || f.includes("*"))
|
|
38
38
|
continue; // skip globs / empties
|
|
39
|
-
if (!
|
|
39
|
+
if (!referenceExists(store, root, d.id, f)) {
|
|
40
40
|
findings.push({ kind: "dead-ref", id: d.id, detail: `references missing file "${f}"` });
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -66,7 +66,7 @@ export function computeDrift(store, root) {
|
|
|
66
66
|
for (const f of d.related_files ?? []) {
|
|
67
67
|
if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
|
|
68
68
|
continue;
|
|
69
|
-
if (!
|
|
69
|
+
if (!referenceExists(store, root, d.id, f))
|
|
70
70
|
continue; // missing file is history → dead-ref's job
|
|
71
71
|
findings.push({
|
|
72
72
|
kind: "anchor-stale",
|
|
@@ -120,6 +120,28 @@ export function computeDrift(store, root) {
|
|
|
120
120
|
findings.push(...computeWikiDrift(store, root));
|
|
121
121
|
return { findings };
|
|
122
122
|
}
|
|
123
|
+
/** Resolve a decision file reference without making private-memory paths depend on
|
|
124
|
+
* the current machine's overlay location. Normal references are code-repo-relative.
|
|
125
|
+
* A `private:<path>` reference is valid only when the decision itself is in the
|
|
126
|
+
* private overlay and resolves from that overlay repo's root. This lets a private
|
|
127
|
+
* decision cite private docs while preventing a public record from silently
|
|
128
|
+
* depending on unsharable local files. */
|
|
129
|
+
function referenceExists(store, root, decisionId, ref) {
|
|
130
|
+
const prefix = "private:";
|
|
131
|
+
if (!ref.startsWith(prefix))
|
|
132
|
+
return existsSync(join(root, ref));
|
|
133
|
+
const privatePath = ref.slice(prefix.length);
|
|
134
|
+
if (!privatePath || isAbsolute(privatePath) || !store.privateDir || !store.getPrivateRec("decisions", decisionId))
|
|
135
|
+
return false;
|
|
136
|
+
const privateRoot = dirname(store.privateDir);
|
|
137
|
+
const candidate = resolve(privateRoot, privatePath);
|
|
138
|
+
// A private-scoped reference is an overlay-repo-relative path, not an escape
|
|
139
|
+
// hatch into arbitrary local files.
|
|
140
|
+
const rel = relative(privateRoot, candidate);
|
|
141
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\\\" : "/"}`) || isAbsolute(rel))
|
|
142
|
+
return false;
|
|
143
|
+
return existsSync(candidate);
|
|
144
|
+
}
|
|
123
145
|
function safeRead(path) {
|
|
124
146
|
try {
|
|
125
147
|
return readFileSync(path, "utf8");
|
package/dist/core/hookcache.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 20+ times per session buries the agent's working context under repeats — the
|
|
5
5
|
* cost of being grounded starts competing with the work.
|
|
6
6
|
*
|
|
7
|
-
* Mechanism: per
|
|
7
|
+
* Mechanism: per agent session (the hook event carries a provider-normalized session_id), keep
|
|
8
8
|
* a tiny {key → content-hash} map in the OS tmpdir. First injection for a key
|
|
9
9
|
* (or any time the underlying records CHANGE) → "full". Identical repeat →
|
|
10
10
|
* "delta" (the caller emits a one-liner, or nothing).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function replaceExact(values, from, to) {
|
|
2
|
+
let changed = 0;
|
|
3
|
+
const replaced = values.map((value) => {
|
|
4
|
+
if (value !== from)
|
|
5
|
+
return value;
|
|
6
|
+
changed++;
|
|
7
|
+
return to;
|
|
8
|
+
});
|
|
9
|
+
// A decision may already cite the destination. Keep the reference list a set
|
|
10
|
+
// after the repair so a correction cannot create duplicate scope/evidence.
|
|
11
|
+
return { values: [...new Set(replaced)], changed };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Return a corrected copy of a decision, or `null` when the source reference is
|
|
15
|
+
* not present. The decision's semantic content and verification timestamp are
|
|
16
|
+
* intentionally preserved: this repairs a locator, it does not re-approve intent.
|
|
17
|
+
*/
|
|
18
|
+
export function repairDecisionReference(decision, from, to) {
|
|
19
|
+
const files = replaceExact(decision.related_files, from, to);
|
|
20
|
+
const evidence = replaceExact(decision.provenance.evidence, from, to);
|
|
21
|
+
if (!files.changed && !evidence.changed)
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
decision: {
|
|
25
|
+
...decision,
|
|
26
|
+
related_files: files.values,
|
|
27
|
+
provenance: { ...decision.provenance, evidence: evidence.values },
|
|
28
|
+
},
|
|
29
|
+
relatedFiles: files.changed,
|
|
30
|
+
evidence: evidence.changed,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=refrepair.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* No LLM here — just parsing what git already knows. */
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { isAbsolute, resolve, join, basename, dirname } from "node:path";
|
|
5
|
-
import { mkdirSync, rmSync, statSync, realpathSync } from "node:fs";
|
|
5
|
+
import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
|
|
6
6
|
function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
7
7
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
8
8
|
return execFileSync("git", args, {
|
|
@@ -377,6 +377,14 @@ export function stagedFiles(cwd) {
|
|
|
377
377
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
378
378
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
379
379
|
}
|
|
380
|
+
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
381
|
+
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
382
|
+
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
383
|
+
export function workingFiles(cwd) {
|
|
384
|
+
const changed = gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
385
|
+
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
386
|
+
return [...new Set([...changed, ...untracked])].sort();
|
|
387
|
+
}
|
|
380
388
|
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
381
389
|
* unfetched/typo'd ref instead of silently diffing against nothing (a vacuous
|
|
382
390
|
* CI pass), since the diff helpers below swallow git errors to "". */
|
|
@@ -408,6 +416,28 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
|
408
416
|
const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
409
417
|
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
410
418
|
}
|
|
419
|
+
/** Unified diff of the complete local working tree vs HEAD. Git's normal diff
|
|
420
|
+
* includes both staged and unstaged tracked edits; untracked text files are
|
|
421
|
+
* appended as synthetic additions so guards can also see their added symbols.
|
|
422
|
+
* Binary/unreadable files remain in workingFiles (scope checks still apply) but
|
|
423
|
+
* intentionally contribute no synthetic content to regression analysis. */
|
|
424
|
+
export function workingDiff(cwd, maxBytes = 60_000) {
|
|
425
|
+
let out = gitSafe(["diff", "HEAD", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
426
|
+
const tracked = new Set(gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
427
|
+
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
428
|
+
for (const file of untracked) {
|
|
429
|
+
try {
|
|
430
|
+
const text = readFileSync(join(cwd, file), "utf8");
|
|
431
|
+
if (text.includes("\0"))
|
|
432
|
+
continue;
|
|
433
|
+
const lines = text.split("\n");
|
|
434
|
+
const add = lines.map((line) => `+${line}`).join("\n");
|
|
435
|
+
out += `${out ? "\n" : ""}diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
|
|
436
|
+
}
|
|
437
|
+
catch { /* unreadable / directory / binary: scope-only is still safe */ }
|
|
438
|
+
}
|
|
439
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
440
|
+
}
|
|
411
441
|
/** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
|
|
412
442
|
* date of that commit — the instant valid-time windows are filtered against.
|
|
413
443
|
* Undefined if it can't be resolved (not a git repo, or an unknown ref). Single
|
|
@@ -20,6 +20,10 @@ function block(invocation, opts = {}) {
|
|
|
20
20
|
MARK,
|
|
21
21
|
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
22
22
|
" export HUNCH_SYNC=1",
|
|
23
|
+
// A split-private capture must not make a storage-private promise and then
|
|
24
|
+
// ship the commit diff to a subscription CLI. Shared overlays are a separate
|
|
25
|
+
// team policy, so only the explicit local-only mode forces deterministic.
|
|
26
|
+
...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
|
|
23
27
|
` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
|
|
24
28
|
"fi",
|
|
25
29
|
ENDMARK,
|