@davesheffer/hunch 1.45.2 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/extractors/parse.js
CHANGED
|
@@ -14,34 +14,37 @@ function bundleFor(spec) {
|
|
|
14
14
|
const grammar = spec.loadGrammar();
|
|
15
15
|
parser.setLanguage(grammar);
|
|
16
16
|
const query = new Parser.Query(grammar, spec.query);
|
|
17
|
-
|
|
17
|
+
const bindingQuery = spec.bindingQuery ? new Parser.Query(grammar, spec.bindingQuery) : null;
|
|
18
|
+
b = { parser, query, bindingQuery };
|
|
18
19
|
cache.set(spec.grammarKey, b);
|
|
19
20
|
}
|
|
20
21
|
return b;
|
|
21
22
|
}
|
|
23
|
+
/** Per-spec templating markers with g/y stripped, so `.test()` is stateless.
|
|
24
|
+
* Built once per language spec rather than on every parseSource call. */
|
|
25
|
+
const statelessMarkers = new WeakMap();
|
|
26
|
+
function templatingMarkersFor(spec) {
|
|
27
|
+
let markers = statelessMarkers.get(spec);
|
|
28
|
+
if (!markers) {
|
|
29
|
+
markers = (spec.templatingMarkers ?? []).map((re) => new RegExp(re.source, re.flags.replace(/[gy]/g, "")));
|
|
30
|
+
statelessMarkers.set(spec, markers);
|
|
31
|
+
}
|
|
32
|
+
return markers;
|
|
33
|
+
}
|
|
22
34
|
const STR_QUOTES = /^['"`]|['"`]$/g;
|
|
23
|
-
/** Cap on
|
|
24
|
-
* enough that a huge function/file doesn't bloat every JSON symbol record. */
|
|
35
|
+
/** Cap on stored symbol body text: enough context for review without bloating JSON records. */
|
|
25
36
|
export const MAX_BODY_TEXT_CHARS = 4000;
|
|
26
37
|
export function parseSource(file, source, opts = {}) {
|
|
27
38
|
const spec = languageFor(file);
|
|
28
39
|
if (!spec)
|
|
29
40
|
return null;
|
|
30
|
-
// Templated text
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// (root.type becomes "ERROR", not "stream") — the fallback-symbol synthesis
|
|
38
|
-
// below covers that case so the file doesn't vanish from the component graph.
|
|
39
|
-
// String.prototype.search ignores lastIndex (unlike RegExp.test with a /g or
|
|
40
|
-
// /y flag), so a future templatingMarkers entry can't introduce cross-call
|
|
41
|
-
// statefulness here even if it forgets to keep its pattern flag-free.
|
|
42
|
-
const templated = (spec.alwaysTemplatedExtensions?.some((ext) => file.endsWith(ext)) ?? false)
|
|
43
|
-
|| (spec.templatingMarkers?.some((marker) => source.search(marker) !== -1) ?? false);
|
|
44
|
-
const { parser, query } = bundleFor(spec);
|
|
41
|
+
// Templated text is not valid language text until rendered. Still parse, so real
|
|
42
|
+
// anchors/symbols are extracted, but do not let the expected errors mark the file
|
|
43
|
+
// unparseable. Severe templating may stop even the root document node from
|
|
44
|
+
// forming; the fallback whole-file symbol below covers that case.
|
|
45
|
+
const templated = (spec.alwaysTemplatedExtensions ?? []).some((ext) => file.endsWith(ext))
|
|
46
|
+
|| templatingMarkersFor(spec).some((re) => re.test(source));
|
|
47
|
+
const { parser, query, bindingQuery } = bundleFor(spec);
|
|
45
48
|
// The native binding caps its scratch buffer at 32 KB unless bufferSize is
|
|
46
49
|
// given — without this, any source >= 32768 bytes throws "Invalid argument"
|
|
47
50
|
// and would abort the whole index run. Guard with try/catch as a backstop.
|
|
@@ -81,6 +84,7 @@ export function parseSource(file, source, opts = {}) {
|
|
|
81
84
|
const imports = [];
|
|
82
85
|
const calls = [];
|
|
83
86
|
const relations = [];
|
|
87
|
+
const bindings = [];
|
|
84
88
|
let namespace = null;
|
|
85
89
|
const originalText = (node) => usingRecoveredTree
|
|
86
90
|
? source.slice(node.startIndex, node.endIndex)
|
|
@@ -92,15 +96,12 @@ export function parseSource(file, source, opts = {}) {
|
|
|
92
96
|
const cname = cap.name;
|
|
93
97
|
const node = cap.node;
|
|
94
98
|
if (cname.endsWith(".def")) {
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// (e.g. a Python method inside a class body matches both a class-nested
|
|
98
|
-
// "method.def" pattern and a general "fn.def" pattern — Task 4 relies on
|
|
99
|
-
// this to classify methods correctly without special-casing Python here).
|
|
99
|
+
// First classification wins: a node matched by several patterns keeps the
|
|
100
|
+
// kind it received first (Python nested methods, Go struct/interface specs).
|
|
100
101
|
if (!pendingDefs.has(node.id))
|
|
101
102
|
pendingDefs.set(node.id, { kind: spec.defKindOf[cname], def: node });
|
|
102
103
|
}
|
|
103
|
-
else if (spec.nameToDef
|
|
104
|
+
else if (Object.hasOwn(spec.nameToDef, cname)) {
|
|
104
105
|
// name capture: find its parent def node id by walking up to the def type
|
|
105
106
|
const defNode = ascendToDef(node, spec.defNodeTypes);
|
|
106
107
|
if (defNode) {
|
|
@@ -125,8 +126,11 @@ export function parseSource(file, source, opts = {}) {
|
|
|
125
126
|
else if (cname === "call.member") {
|
|
126
127
|
// skip builtin method names to avoid false edges to similarly-named symbols
|
|
127
128
|
const text = originalText(node);
|
|
128
|
-
if (!spec.builtinMethods.has(text))
|
|
129
|
-
|
|
129
|
+
if (!spec.builtinMethods.has(text)) {
|
|
130
|
+
const object = node.parent?.childForFieldName("object") ?? node.parent?.childForFieldName("operand");
|
|
131
|
+
const qualifier = object?.type === "identifier" ? originalText(object) : undefined;
|
|
132
|
+
calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true, qualifier });
|
|
133
|
+
}
|
|
130
134
|
}
|
|
131
135
|
else if (spec.relationKindOf?.[cname]) {
|
|
132
136
|
const relation = spec.relationKindOf[cname];
|
|
@@ -139,35 +143,50 @@ export function parseSource(file, source, opts = {}) {
|
|
|
139
143
|
});
|
|
140
144
|
}
|
|
141
145
|
}
|
|
146
|
+
for (const match of bindingQuery?.matches(tree.rootNode) ?? []) {
|
|
147
|
+
const text = (capture) => {
|
|
148
|
+
const node = match.captures.find((c) => c.name === capture)?.node;
|
|
149
|
+
return node ? originalText(node) : null;
|
|
150
|
+
};
|
|
151
|
+
const src = text("bind.src")?.replace(STR_QUOTES, "");
|
|
152
|
+
if (!src)
|
|
153
|
+
continue;
|
|
154
|
+
const imported = text("bind.name");
|
|
155
|
+
if (imported) {
|
|
156
|
+
const local = text("bind.local") ?? imported;
|
|
157
|
+
// `from X import Y [as Z]`: Y may be a submodule or a member; record both
|
|
158
|
+
// readings — only the one that resolves to a tracked file/symbol yields an edge.
|
|
159
|
+
if (spec.joinSubmodule)
|
|
160
|
+
bindings.push({ local, module: spec.joinSubmodule(src, imported), imported: null });
|
|
161
|
+
bindings.push({ local, module: src, imported });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const local = text("bind.local") ?? spec.defaultBindingLocal?.(src) ?? null;
|
|
165
|
+
if (local)
|
|
166
|
+
bindings.push({ local, module: src, imported: null });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
142
169
|
for (const { kind, def, name } of pendingDefs.values()) {
|
|
143
|
-
const
|
|
144
|
-
if (!
|
|
170
|
+
const symbolName = name ?? spec.fallbackDefName?.(file);
|
|
171
|
+
if (!symbolName)
|
|
145
172
|
continue;
|
|
146
173
|
const loc = def.endPosition.row - def.startPosition.row + 1;
|
|
147
174
|
symbols.push({
|
|
148
|
-
name:
|
|
175
|
+
name: symbolName, kind,
|
|
149
176
|
startByte: def.startIndex, endByte: def.endIndex, loc,
|
|
150
177
|
bodyText: originalText(def).slice(0, MAX_BODY_TEXT_CHARS),
|
|
151
178
|
});
|
|
152
179
|
}
|
|
153
|
-
//
|
|
154
|
-
// (fallbackDefName). If templating broke error recovery badly enough that
|
|
155
|
-
// the doc.def capture never fired, synthesize the same fallback here rather
|
|
156
|
-
// than let the file silently drop out of the component graph. Push it before
|
|
157
|
-
// the sort below — parse()'s callers (indexer.ts) rely on symbols staying in
|
|
158
|
-
// start-byte order.
|
|
180
|
+
// Severe templating can stop the root node from forming; synthesize the whole-file symbol.
|
|
159
181
|
if (templated && spec.fallbackDefName && !symbols.some((s) => s.kind === "file")) {
|
|
160
182
|
symbols.push({
|
|
161
|
-
name: spec.fallbackDefName(file),
|
|
162
|
-
|
|
163
|
-
startByte: 0,
|
|
164
|
-
endByte: source.length,
|
|
165
|
-
loc: source.split("\n").length,
|
|
183
|
+
name: spec.fallbackDefName(file), kind: "file",
|
|
184
|
+
startByte: 0, endByte: source.length, loc: source.split("\n").length,
|
|
166
185
|
bodyText: source.slice(0, MAX_BODY_TEXT_CHARS),
|
|
167
186
|
});
|
|
168
187
|
}
|
|
169
188
|
symbols.sort((a, b) => a.startByte - b.startByte);
|
|
170
|
-
return { symbols, imports, calls, relations, namespace, parseable: templated || parseable };
|
|
189
|
+
return { symbols, imports, calls, relations, bindings, namespace, parseable: templated || parseable };
|
|
171
190
|
}
|
|
172
191
|
/** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
|
|
173
192
|
* language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
|
|
@@ -213,7 +232,7 @@ function inToleratedScope(node, scopes) {
|
|
|
213
232
|
}
|
|
214
233
|
return false;
|
|
215
234
|
}
|
|
216
|
-
/**
|
|
235
|
+
/** Nearest ancestor of `node` whose type is a definition type, or null. */
|
|
217
236
|
function ascendToDef(node, defNodeTypes) {
|
|
218
237
|
let cur = node.parent;
|
|
219
238
|
while (cur) {
|
|
@@ -224,17 +243,12 @@ function ascendToDef(node, defNodeTypes) {
|
|
|
224
243
|
return null;
|
|
225
244
|
}
|
|
226
245
|
/** Map each call site to the innermost symbol whose byte-range contains it.
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* regardless of byte overlap — the caller must consume the exact same
|
|
234
|
-
* `parsed.symbols` array (or an equivalently-ordered copy) to look up a
|
|
235
|
-
* symbol by the index this function returns. The value maps callee name ->
|
|
236
|
-
* `memberOnly` (true iff every occurrence was a `x.foo()` member call, never
|
|
237
|
-
* a direct `foo()`), so the indexer can resolve member calls conservatively. */
|
|
246
|
+
* The result is keyed by the symbol's INDEX in `parsed.symbols`, not its start
|
|
247
|
+
* byte: two distinct symbols can share a start byte (a synthetic whole-file root
|
|
248
|
+
* beside others starting at byte 0), so callers must look symbols up in the same
|
|
249
|
+
* array order. Each value maps callee name -> memberOnly, true iff every
|
|
250
|
+
* occurrence of that callee inside the symbol was a member call, so the indexer
|
|
251
|
+
* can resolve member calls conservatively. */
|
|
238
252
|
export function attributeCalls(parsed) {
|
|
239
253
|
const out = new Map();
|
|
240
254
|
for (const call of parsed.calls) {
|
|
@@ -250,15 +264,65 @@ export function attributeCalls(parsed) {
|
|
|
250
264
|
}
|
|
251
265
|
}
|
|
252
266
|
if (best && best.name !== call.callee) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
267
|
+
let m = out.get(bestIndex);
|
|
268
|
+
if (!m) {
|
|
269
|
+
m = new Map();
|
|
270
|
+
out.set(bestIndex, m);
|
|
271
|
+
}
|
|
256
272
|
const prev = m.get(call.callee);
|
|
257
273
|
m.set(call.callee, prev === undefined ? call.member : prev && call.member);
|
|
258
274
|
}
|
|
259
275
|
}
|
|
260
276
|
return out;
|
|
261
277
|
}
|
|
278
|
+
/** Map each call bound to an imported module — `ns.fn()` through a module-object
|
|
279
|
+
* binding, or `g()` through an aliased member import — to the innermost symbol
|
|
280
|
+
* containing it (same index identity as attributeCalls). A local bound to two
|
|
281
|
+
* different modules is ambiguous and dropped: never guess a binding. */
|
|
282
|
+
export function attributeQualifiedCalls(parsed) {
|
|
283
|
+
const unique = (entries) => {
|
|
284
|
+
const out = new Map();
|
|
285
|
+
for (const [local, target] of entries) {
|
|
286
|
+
const prev = out.get(local);
|
|
287
|
+
out.set(local, prev === undefined || (prev && prev.module === target.module && prev.callee === target.callee) ? target : null);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
};
|
|
291
|
+
const modules = unique(parsed.bindings.filter((b) => b.imported === null).map((b) => [b.local, { callee: "", module: b.module }]));
|
|
292
|
+
const aliases = unique(parsed.bindings.filter((b) => b.imported !== null).map((b) => [b.local, { callee: b.imported, module: b.module }]));
|
|
293
|
+
const out = new Map();
|
|
294
|
+
if (modules.size === 0 && aliases.size === 0)
|
|
295
|
+
return out;
|
|
296
|
+
for (const call of parsed.calls) {
|
|
297
|
+
let target = null;
|
|
298
|
+
if (call.member && call.qualifier) {
|
|
299
|
+
const binding = modules.get(call.qualifier);
|
|
300
|
+
if (binding)
|
|
301
|
+
target = { callee: call.callee, module: binding.module };
|
|
302
|
+
}
|
|
303
|
+
else if (!call.member) {
|
|
304
|
+
target = aliases.get(call.callee) ?? null;
|
|
305
|
+
}
|
|
306
|
+
if (!target)
|
|
307
|
+
continue;
|
|
308
|
+
let best = null;
|
|
309
|
+
let bestIndex = -1;
|
|
310
|
+
for (let i = 0; i < parsed.symbols.length; i++) {
|
|
311
|
+
const s = parsed.symbols[i];
|
|
312
|
+
if (call.atByte >= s.startByte && call.atByte < s.endByte && (!best || s.endByte - s.startByte < best.endByte - best.startByte)) {
|
|
313
|
+
best = s;
|
|
314
|
+
bestIndex = i;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (bestIndex < 0)
|
|
318
|
+
continue;
|
|
319
|
+
const list = out.get(bestIndex) ?? [];
|
|
320
|
+
if (!list.some((t) => t.callee === target.callee && t.module === target.module))
|
|
321
|
+
list.push(target);
|
|
322
|
+
out.set(bestIndex, list);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
262
326
|
/** Map static semantic relationships (extends/implements/trait use) to the
|
|
263
327
|
* innermost enclosing declaration using the same stable symbol-index identity
|
|
264
328
|
* as call attribution. */
|
|
@@ -12,6 +12,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
|
|
12
12
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { foreignRepoEnv, gitCommonDir, mainWorktreeRoot, stableRepositoryName } from "./git.js";
|
|
15
|
+
import { boundedSpawnSync } from "../core/boundedSpawn.js";
|
|
15
16
|
import { extracted } from "../core/types.js";
|
|
16
17
|
import { CONTROL_CHARS, MAX_BRANCHES, MAX_WORKTREES, WORKSPACE_SCHEMA_VERSION, WorkspaceSchema, isSafeBranchName, workspaceId, worktreeId, } from "../core/workspace.js";
|
|
17
18
|
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
@@ -353,8 +354,9 @@ function fetchedAt(root) {
|
|
|
353
354
|
export function snapshotWorkspace(root, opts) {
|
|
354
355
|
const now = opts.now ?? new Date();
|
|
355
356
|
const main = mainWorktreeRoot(root);
|
|
357
|
+
// Network fetch: bounded so a timeout kills git's whole tree, not just the root (#498).
|
|
356
358
|
if (opts.fetch)
|
|
357
|
-
|
|
359
|
+
boundedSpawnSync("git", ["fetch", "--prune", "--quiet"], { cwd: main, env: env(), stdout: "ignore", timeout: 120_000 });
|
|
358
360
|
const def = defaultBranch(main);
|
|
359
361
|
const searched = opts.squashSearchCommits ?? DEFAULT_SQUASH_SEARCH_COMMITS;
|
|
360
362
|
let patchIds = null;
|
|
@@ -82,6 +82,7 @@ export function renderHunchSection(store, root) {
|
|
|
82
82
|
bugs: store.json.loadAll("bugs").length,
|
|
83
83
|
constraints: store.json.loadAll("constraints").length,
|
|
84
84
|
components: store.json.loadAll("components").length,
|
|
85
|
+
// Public policies only: private-overlay policies must not change the committed block.
|
|
85
86
|
policies: root ? new PolicyRepository(root, store).listPolicies({ publicOnly: true }).length : 0,
|
|
86
87
|
findings: store.json.loadAll("findings").filter((f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled").length,
|
|
87
88
|
};
|
|
@@ -32,12 +32,10 @@ const ENTRIES = [
|
|
|
32
32
|
".hunch/local.json",
|
|
33
33
|
// The strict hook's append-only catch-log: clone-local, never a memory record.
|
|
34
34
|
".hunch/events.log",
|
|
35
|
-
// The post-merge hook's queue of
|
|
36
|
-
//
|
|
37
|
-
// --apply` confirms and clears it).
|
|
35
|
+
// The post-merge hook's clone-local queue of unconfirmed provenance repairs —
|
|
36
|
+
// never committed; cleared by `hunch repair-provenance --apply`.
|
|
38
37
|
".hunch/pending-commit-repairs.json",
|
|
39
|
-
// Tombstones for
|
|
40
|
-
// scratch state, same discipline as the queue above.
|
|
38
|
+
// Tombstones for matches rejected via `--drop` — clone-local, never committed.
|
|
41
39
|
".hunch/dropped-commit-repairs.json",
|
|
42
40
|
// A local PRIVATE overlay store (HUNCH_PRIVATE_DIR) for sensitive memory — never
|
|
43
41
|
// committed. This is the conventional in-repo path; point the env elsewhere for a
|
|
@@ -40,6 +40,12 @@ export declare const HARNESSES: {
|
|
|
40
40
|
};
|
|
41
41
|
};
|
|
42
42
|
export type Harness = keyof typeof HARNESSES;
|
|
43
|
+
/** Devin Desktop (formerly Windsurf) reads workspace hooks from .devin/hooks.json
|
|
44
|
+
* and falls back to the legacy .windsurf/hooks.json only when that file is absent
|
|
45
|
+
* or defines no hooks (docs.devin.ai/desktop/cascade/hooks, #494). */
|
|
46
|
+
export declare const DEVIN_HOOKS = ".devin/hooks.json";
|
|
47
|
+
/** The hooks file the host actually reads, so doctor inspects the active one. */
|
|
48
|
+
export declare function activeHooksFile(root: string, harness: Harness): string;
|
|
43
49
|
export interface HealthIssue {
|
|
44
50
|
file: string;
|
|
45
51
|
code: string;
|
|
@@ -29,6 +29,26 @@ export const HARNESSES = {
|
|
|
29
29
|
windsurf: { mcp: ".windsurf/mcp_config.json", hooks: ".windsurf/hooks.json", key: "mcpServers", events: ["", "pre_write_code", "post_run_command", ""] },
|
|
30
30
|
antigravity: { mcp: ".agents/mcp_config.json", hooks: ".agents/hooks.json", key: "mcpServers", events: ["PreInvocation", "PreToolUse", "", ""] },
|
|
31
31
|
};
|
|
32
|
+
/** Devin Desktop (formerly Windsurf) reads workspace hooks from .devin/hooks.json
|
|
33
|
+
* and falls back to the legacy .windsurf/hooks.json only when that file is absent
|
|
34
|
+
* or defines no hooks (docs.devin.ai/desktop/cascade/hooks, #494). */
|
|
35
|
+
export const DEVIN_HOOKS = ".devin/hooks.json";
|
|
36
|
+
/** The hooks file the host actually reads, so doctor inspects the active one. */
|
|
37
|
+
export function activeHooksFile(root, harness) {
|
|
38
|
+
const legacy = HARNESSES[harness].hooks;
|
|
39
|
+
if (harness !== "windsurf" || !existsSync(join(root, DEVIN_HOOKS)))
|
|
40
|
+
return legacy;
|
|
41
|
+
try {
|
|
42
|
+
const hooks = parseJsonc(readFileSync(join(root, DEVIN_HOOKS), "utf8"))?.hooks;
|
|
43
|
+
if (hooks && typeof hooks === "object" && Object.values(hooks).some(v => Array.isArray(v) && v.length > 0))
|
|
44
|
+
return DEVIN_HOOKS;
|
|
45
|
+
return legacy;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// An unparseable .devin/hooks.json is reported against itself, not masked.
|
|
49
|
+
return DEVIN_HOOKS;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
32
52
|
const exactVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
33
53
|
const pinPattern = /@davesheffer\/hunch@([^\s"'\],;]+)/g;
|
|
34
54
|
const object = (v) => {
|
|
@@ -160,12 +180,13 @@ export function inspectIntegrations(root, selected) {
|
|
|
160
180
|
}
|
|
161
181
|
for (const harness of selected ? [selected] : Object.keys(HARNESSES)) {
|
|
162
182
|
const spec = HARNESSES[harness];
|
|
163
|
-
|
|
183
|
+
const hooksFile = activeHooksFile(root, harness);
|
|
184
|
+
if (!selected && !existsSync(join(root, spec.mcp)) && (!hooksFile || !existsSync(join(root, hooksFile))))
|
|
164
185
|
continue;
|
|
165
186
|
const capabilities = Object.fromEntries(CAPABILITIES.map(c => [c, { status: "untested", detail: "No runtime evidence" }]));
|
|
166
187
|
report.harnesses.push({ harness, capabilities });
|
|
167
188
|
const mcpPath = join(root, spec.mcp);
|
|
168
|
-
const hooksFileExists = Boolean(
|
|
189
|
+
const hooksFileExists = Boolean(hooksFile && existsSync(join(root, hooksFile)));
|
|
169
190
|
let mcpEntryAbsent = false;
|
|
170
191
|
try {
|
|
171
192
|
lstatSync(mcpPath);
|
|
@@ -203,22 +224,22 @@ export function inspectIntegrations(root, selected) {
|
|
|
203
224
|
// A hooks file that was never written is a coverage gap (the adapter is
|
|
204
225
|
// not installed), not configuration drift: report it, never fail on it.
|
|
205
226
|
// `--require` still refuses, because nothing unverified counts.
|
|
206
|
-
const hooksAbsent = !!
|
|
207
|
-
if (
|
|
227
|
+
const hooksAbsent = !!hooksFile && !existsSync(join(root, hooksFile));
|
|
228
|
+
if (hooksFile && !hooksAbsent) {
|
|
208
229
|
try {
|
|
209
|
-
const config = object(parseJsonc(readFileSync(join(root,
|
|
230
|
+
const config = object(parseJsonc(readFileSync(join(root, hooksFile), "utf8")));
|
|
210
231
|
disabled = config.disableAllHooks === true;
|
|
211
232
|
events = object(harness === "antigravity" ? config.hunch : config.hooks);
|
|
212
233
|
const commands = Object.values(events).flatMap(v => hookCommands(v));
|
|
213
|
-
recordPins(
|
|
234
|
+
recordPins(hooksFile, commands);
|
|
214
235
|
if (commands.some(misroutedHook))
|
|
215
236
|
report.issues.push({
|
|
216
|
-
file:
|
|
237
|
+
file: hooksFile, code: "hook-command",
|
|
217
238
|
detail: "Hunch hooks invoke the MCP subcommand instead of the hook handler; run hunch integrations repair-pins",
|
|
218
239
|
});
|
|
219
240
|
}
|
|
220
241
|
catch (e) {
|
|
221
|
-
report.issues.push({ file:
|
|
242
|
+
report.issues.push({ file: hooksFile, code: "hook-config", detail: e.message });
|
|
222
243
|
}
|
|
223
244
|
}
|
|
224
245
|
for (const [i, capability] of ["context", "edit-blocking", "failure-capture", "compaction"].entries()) {
|
|
@@ -235,7 +256,7 @@ export function inspectIntegrations(root, selected) {
|
|
|
235
256
|
status.detail = capability === "context" ? "Hunch relies on instructions and voluntary MCP calls on this adapter" : "No Hunch lifecycle adapter for this capability";
|
|
236
257
|
}
|
|
237
258
|
else if (hooksAbsent) {
|
|
238
|
-
status.detail = `No ${
|
|
259
|
+
status.detail = `No ${hooksFile}; run hunch init to install this host's lifecycle hooks`;
|
|
239
260
|
}
|
|
240
261
|
else if (disabled || firmness === "off" || ((capability === "failure-capture") && process.env.HUNCH_PIPELINE === "0")) {
|
|
241
262
|
status.status = "unsupported";
|
|
@@ -248,7 +269,7 @@ export function inspectIntegrations(root, selected) {
|
|
|
248
269
|
}
|
|
249
270
|
else {
|
|
250
271
|
status.detail = `Missing Hunch ${event} handler`;
|
|
251
|
-
report.issues.push({ file:
|
|
272
|
+
report.issues.push({ file: hooksFile, code: "missing-hook", detail: status.detail });
|
|
252
273
|
}
|
|
253
274
|
}
|
|
254
275
|
else if (capability === "edit-blocking" && firmness !== "strict") {
|
|
@@ -302,7 +323,7 @@ export function repairIntegrationPins(root, opts = {}) {
|
|
|
302
323
|
const version = expectedVersion(root);
|
|
303
324
|
const pending = [];
|
|
304
325
|
for (const [name, spec] of Object.entries(HARNESSES)) {
|
|
305
|
-
for (const file of [spec.mcp, spec.hooks].filter(Boolean)) {
|
|
326
|
+
for (const file of [spec.mcp, spec.hooks, ...(name === "windsurf" ? [DEVIN_HOOKS] : [])].filter(Boolean)) {
|
|
306
327
|
const path = join(root, file);
|
|
307
328
|
if (!existsSync(path) || opts.skip?.(file))
|
|
308
329
|
continue;
|
|
@@ -342,7 +363,7 @@ export function repairIntegrationPins(root, opts = {}) {
|
|
|
342
363
|
const replacements = new Map(values.map(v => {
|
|
343
364
|
// Every recognized npm token is shell-safe. Bare tokens also repair
|
|
344
365
|
// the legacy quoted executable, which PowerShell treats as a string.
|
|
345
|
-
const published = file === spec.hooks ? publishedHookCommand(v) : undefined;
|
|
366
|
+
const published = file === spec.hooks || file === DEVIN_HOOKS ? publishedHookCommand(v) : undefined;
|
|
346
367
|
const repaired = published?.replace(" hunch mcp hook", " hunch hook") ?? v;
|
|
347
368
|
return [v, replace(repaired)];
|
|
348
369
|
}).filter(([a, b]) => a !== b));
|
|
@@ -16,8 +16,10 @@ export interface HookInstall {
|
|
|
16
16
|
* managed-elsewhere: a hook manager owns the hook — nothing was written.
|
|
17
17
|
* unreachable: the existing hook ends in exec/exit before our block, or the
|
|
18
18
|
* block in it has no closing marker and so cannot be replaced in place —
|
|
19
|
-
* nothing was written.
|
|
20
|
-
|
|
19
|
+
* nothing was written.
|
|
20
|
+
* opted-out: the main checkout opted out (#488) and git hooks live in the
|
|
21
|
+
* common dir every worktree shares — nothing was written, on purpose. */
|
|
22
|
+
action: "created" | "appended" | "updated" | "unchanged" | "kept-shared" | "managed-elsewhere" | "unreachable" | "opted-out";
|
|
21
23
|
manager?: HookManagerKind;
|
|
22
24
|
/** Why nothing was written (non-writing actions only). */
|
|
23
25
|
reason?: string;
|
|
@@ -109,20 +111,15 @@ export declare function installPostCommitHook(root: string, invocation: string,
|
|
|
109
111
|
* blocking invariant (see strictgate.ts), so it's safe on a shared repo.
|
|
110
112
|
* Preserves any existing pre-commit hook. */
|
|
111
113
|
export declare function installPreCommitHook(root: string, invocation: string, strict?: boolean): HookInstall;
|
|
112
|
-
/** Install
|
|
113
|
-
* re-
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* no commit of its own and so can't reuse HUNCH_SYNC's re-trigger guard).
|
|
122
|
-
* Each block is keyed by its own marker pair (installManagedBlock), so
|
|
123
|
-
* re-running updates only its own block, preserves the other untouched, and
|
|
124
|
-
* a repo carrying only one half (an older install, or a hand-edited hook)
|
|
125
|
-
* gets the other appended rather than clobbered. */
|
|
114
|
+
/** Install the post-merge hook. It carries two independently managed blocks:
|
|
115
|
+
* - grounding: re-syncs committed grounding docs when a merge brought in memory.
|
|
116
|
+
* Guarded by HUNCH_SYNC, runs in the foreground, never fails the merge.
|
|
117
|
+
* - repair: detects a decision's commit provenance being orphaned by a
|
|
118
|
+
* squash-merge (including a fast-forward from `git pull`) and queues the match
|
|
119
|
+
* for `hunch repair-provenance --apply`. Guarded by HUNCH_MERGE_SYNC, runs in
|
|
120
|
+
* the background.
|
|
121
|
+
* Each block is keyed by its own marker pair, so a re-run updates only its own
|
|
122
|
+
* block, and a hook that has only one half gets the other appended, not clobbered. */
|
|
126
123
|
export declare function installPostMergeHook(root: string, invocation: string): HookInstall;
|
|
127
124
|
export declare function installPostCheckoutHook(root: string, invocation: string): HookInstall;
|
|
128
125
|
export type HookState = BlockState;
|
|
@@ -19,6 +19,7 @@ import { join, isAbsolute, dirname, basename, relative, resolve } from "node:pat
|
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { hooksDir, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
21
21
|
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
22
|
+
import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
|
|
22
23
|
import { HUNCH_NPX_PACKAGE_SPEC, HUNCH_PACKAGE_NAME } from "../core/version.js";
|
|
23
24
|
import { eolLengthAt, normalizeEol } from "../core/eol.js";
|
|
24
25
|
const MARK = "# >>> hunch post-commit >>>";
|
|
@@ -617,6 +618,11 @@ function sharedInvocationFor(root, mark, invocation, own) {
|
|
|
617
618
|
* or the block would sit after an exec/exit, nothing is written and the result
|
|
618
619
|
* carries the snippet to add by hand (issue #311). */
|
|
619
620
|
function installManagedBlock(root, hookName, mark, end, build, invocation) {
|
|
621
|
+
// A linked worktree whose branch predates the opt-out marker shares the main
|
|
622
|
+
// checkout's hooks dir: never re-hook a repository `hunch uninstall` opted out (#488).
|
|
623
|
+
const main = mainWorktreeRoot(root);
|
|
624
|
+
if (isOptedOut(main))
|
|
625
|
+
return { path: resolve(root, hooksDir(root), hookName), action: "opted-out", reason: `${join(main, OPT_OUT_FILE)} opts this repository out` };
|
|
620
626
|
const t = resolveHookTarget(root, hookName, mark);
|
|
621
627
|
if (t.manager !== "none") {
|
|
622
628
|
const info = blockInfo(t, mark, root);
|
|
@@ -787,14 +793,12 @@ export function installPreCommitHook(root, invocation, strict = false) {
|
|
|
787
793
|
};
|
|
788
794
|
return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
|
|
789
795
|
}
|
|
790
|
-
//
|
|
791
|
-
// grounding
|
|
792
|
-
// exact marker text (fnd_c402046ac7).
|
|
796
|
+
// The original grounding marker is kept unchanged for backward compatibility, so an
|
|
797
|
+
// existing grounding block is still found and updated in place.
|
|
793
798
|
const GROUNDING_MERGE_MARK = "# >>> hunch post-merge >>>";
|
|
794
799
|
const GROUNDING_MERGE_END = "# <<< hunch post-merge <<<";
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
// independently created/updated/removed without touching the other.
|
|
800
|
+
// The repair-provenance block gets a SEPARATE marker so the two blocks never collide
|
|
801
|
+
// in one post-merge file and each can be created, updated or removed on its own.
|
|
798
802
|
const REPAIR_MERGE_MARK = "# >>> hunch post-merge (repair-provenance) >>>";
|
|
799
803
|
const REPAIR_MERGE_END = "# <<< hunch post-merge (repair-provenance) <<<";
|
|
800
804
|
function groundingMergeBlock(invocation) {
|
|
@@ -813,43 +817,38 @@ function repairProvenanceMergeBlock(invocation) {
|
|
|
813
817
|
REPAIR_MERGE_MARK,
|
|
814
818
|
'if [ -z "$HUNCH_MERGE_SYNC" ]; then',
|
|
815
819
|
" export HUNCH_MERGE_SYNC=1",
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
// (file-set overlap, not git's own rename detection) isn't strong enough to
|
|
820
|
-
// trust an unattended, backgrounded write into shared team memory.
|
|
820
|
+
// Detect-and-queue only, never --apply: the file-overlap match is not strong
|
|
821
|
+
// enough to trust an unattended write to shared memory. A human confirms the
|
|
822
|
+
// queue (.hunch/pending-commit-repairs.json) with `repair-provenance --apply`.
|
|
821
823
|
` ( ${invocation} repair-provenance --from-hook --quiet >/dev/null 2>&1 || true ) &`,
|
|
822
824
|
"fi",
|
|
823
825
|
REPAIR_MERGE_END,
|
|
824
826
|
].join("\n");
|
|
825
827
|
}
|
|
826
|
-
/**
|
|
827
|
-
*
|
|
828
|
-
*
|
|
828
|
+
/** Rank used to pick ONE HookInstall action out of two independent sub-installs
|
|
829
|
+
* into the same file. "created" (the file itself is new) outranks "appended" /
|
|
830
|
+
* "updated" (an existing file
|
|
829
831
|
* changed), which outrank "unchanged". Non-writing results are handled before
|
|
830
832
|
* ranking (they must never be masked by a sibling's success). */
|
|
831
|
-
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0 };
|
|
832
|
-
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
|
|
833
|
-
/** Install
|
|
834
|
-
* re-
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
* no commit of its own and so can't reuse HUNCH_SYNC's re-trigger guard).
|
|
843
|
-
* Each block is keyed by its own marker pair (installManagedBlock), so
|
|
844
|
-
* re-running updates only its own block, preserves the other untouched, and
|
|
845
|
-
* a repo carrying only one half (an older install, or a hand-edited hook)
|
|
846
|
-
* gets the other appended rather than clobbered. */
|
|
833
|
+
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0, "opted-out": 0 };
|
|
834
|
+
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable" && h.action !== "opted-out";
|
|
835
|
+
/** Install the post-merge hook. It carries two independently managed blocks:
|
|
836
|
+
* - grounding: re-syncs committed grounding docs when a merge brought in memory.
|
|
837
|
+
* Guarded by HUNCH_SYNC, runs in the foreground, never fails the merge.
|
|
838
|
+
* - repair: detects a decision's commit provenance being orphaned by a
|
|
839
|
+
* squash-merge (including a fast-forward from `git pull`) and queues the match
|
|
840
|
+
* for `hunch repair-provenance --apply`. Guarded by HUNCH_MERGE_SYNC, runs in
|
|
841
|
+
* the background.
|
|
842
|
+
* Each block is keyed by its own marker pair, so a re-run updates only its own
|
|
843
|
+
* block, and a hook that has only one half gets the other appended, not clobbered. */
|
|
847
844
|
export function installPostMergeHook(root, invocation) {
|
|
848
845
|
// Only the grounding half regenerates committed docs; repair-provenance writes
|
|
849
846
|
// no docs (it only queues a match for a human to confirm), so it keeps the
|
|
850
847
|
// passed invocation unchanged — see selfBuildInvocation.
|
|
851
848
|
const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, (inv, ctx) => groundingMergeBlock(ctx.portable ? inv : selfBuildInvocation(root, inv)), invocation);
|
|
852
849
|
const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock, invocation);
|
|
850
|
+
if (grounding.action === "opted-out")
|
|
851
|
+
return grounding;
|
|
853
852
|
if (!writes(grounding) && !writes(repair)) {
|
|
854
853
|
return { ...grounding, snippet: `${grounding.snippet}\n${stripComment(repair.snippet ?? "", grounding.manager)}` };
|
|
855
854
|
}
|
|
@@ -1025,6 +1024,8 @@ export function formatHookInstall(root, label, h, detail = "") {
|
|
|
1025
1024
|
const how = h.keptFlags?.length ? KEPT_HOW : "";
|
|
1026
1025
|
return [` ✓ ${label} kept — the shared hook keeps running ${h.sharedInvocation}${flags} (not re-pointed at this worktree)${how}`];
|
|
1027
1026
|
}
|
|
1027
|
+
if (h.action === "opted-out")
|
|
1028
|
+
return [` - ${label} skipped — ${h.reason}`];
|
|
1028
1029
|
if (writes(h)) {
|
|
1029
1030
|
// A union write (issue #316). Once an option was KEPT, `detail` — which
|
|
1030
1031
|
// describes only what this run asked for — is no longer the truth about the
|