@davesheffer/hunch 1.45.1 → 1.46.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 +165 -33
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +82 -26
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +10 -0
- package/dist/core/delivery.js +117 -9
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +132 -11
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +206 -94
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- 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 +6 -4
- package/dist/integrations/uninstall.d.ts +17 -4
- package/dist/integrations/uninstall.js +35 -13
- package/dist/mcp/server.js +179 -30
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +6 -5
- package/dist/serve/writelock.js +26 -21
- package/dist/store/hunchStore.d.ts +12 -1
- package/dist/store/hunchStore.js +86 -4
- package/dist/store/jsonStore.d.ts +24 -4
- package/dist/store/jsonStore.js +75 -26
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -17,7 +17,14 @@ const TS_QUERY = `
|
|
|
17
17
|
(variable_declarator
|
|
18
18
|
name: (identifier) @arrow.name
|
|
19
19
|
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
20
|
+
;; Class-field arrows (\`handler = () => {}\`) are methods in every way that matters.
|
|
21
|
+
(public_field_definition
|
|
22
|
+
name: (property_identifier) @method.name
|
|
23
|
+
value: [(arrow_function) (function_expression)]) @method.def
|
|
20
24
|
(import_statement source: (string) @import.src)
|
|
25
|
+
;; require("./x") and dynamic import("./x") are imports too, when the specifier is literal.
|
|
26
|
+
(call_expression function: (identifier) @_require arguments: (arguments . (string) @import.src .) (#eq? @_require "require"))
|
|
27
|
+
(call_expression function: (import) arguments: (arguments . (string) @import.src .))
|
|
21
28
|
(call_expression function: (identifier) @call.id)
|
|
22
29
|
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
23
30
|
;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
|
|
@@ -27,6 +34,24 @@ const TS_QUERY = `
|
|
|
27
34
|
(new_expression constructor: (identifier) @call.id)
|
|
28
35
|
(new_expression constructor: (member_expression property: (property_identifier) @call.member))
|
|
29
36
|
`;
|
|
37
|
+
/** Rendering a component IS a call; lowercase tags are intrinsic elements, not symbols. */
|
|
38
|
+
const TSX_QUERY = `${TS_QUERY}
|
|
39
|
+
(jsx_opening_element name: (identifier) @call.id (#match? @call.id "^[A-Z]"))
|
|
40
|
+
(jsx_self_closing_element name: (identifier) @call.id (#match? @call.id "^[A-Z]"))
|
|
41
|
+
(jsx_opening_element name: (member_expression property: (property_identifier) @call.member))
|
|
42
|
+
(jsx_self_closing_element name: (member_expression property: (property_identifier) @call.member))
|
|
43
|
+
`;
|
|
44
|
+
/** Module-object bindings: \`import * as ns\`, \`const m = require()\`, \`const m = await import()\`. */
|
|
45
|
+
const TS_BINDING_QUERY = `
|
|
46
|
+
(import_statement (import_clause (namespace_import (identifier) @bind.local)) source: (string) @bind.src)
|
|
47
|
+
(variable_declarator
|
|
48
|
+
name: (identifier) @bind.local
|
|
49
|
+
value: (call_expression function: (identifier) @_require arguments: (arguments . (string) @bind.src .))
|
|
50
|
+
(#eq? @_require "require"))
|
|
51
|
+
(variable_declarator
|
|
52
|
+
name: (identifier) @bind.local
|
|
53
|
+
value: (await_expression (call_expression function: (import) arguments: (arguments . (string) @bind.src .))))
|
|
54
|
+
`;
|
|
30
55
|
const TS_BUILTIN_METHODS = new Set([
|
|
31
56
|
"map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
|
|
32
57
|
"push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
|
|
@@ -45,6 +70,7 @@ const TS_SHARED = {
|
|
|
45
70
|
defNodeTypes: new Set([
|
|
46
71
|
"function_declaration", "generator_function_declaration", "method_definition",
|
|
47
72
|
"class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
|
|
73
|
+
"public_field_definition",
|
|
48
74
|
]),
|
|
49
75
|
defKindOf: {
|
|
50
76
|
"fn.def": "function", "method.def": "method", "class.def": "class",
|
|
@@ -55,6 +81,7 @@ const TS_SHARED = {
|
|
|
55
81
|
"iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
|
|
56
82
|
},
|
|
57
83
|
builtinMethods: TS_BUILTIN_METHODS,
|
|
84
|
+
bindingQuery: TS_BINDING_QUERY,
|
|
58
85
|
// ES2018 relaxed template literals: an INVALID escape (`\x` with no hex, `\u`
|
|
59
86
|
// short, `\u{` unterminated) is legal inside a TAGGED template — the cooked
|
|
60
87
|
// value is undefined and the raw text survives, which is the entire point of
|
|
@@ -81,6 +108,7 @@ const TSX = {
|
|
|
81
108
|
extensions: [".tsx", ".jsx"],
|
|
82
109
|
grammarKey: "tsx",
|
|
83
110
|
loadGrammar: () => loadNativeTreeSitter().tsx,
|
|
111
|
+
query: TSX_QUERY,
|
|
84
112
|
// tree-sitter-javascript rejects a bare ampersand in JSX text even though JSX
|
|
85
113
|
// accepts it (tree-sitter-javascript#366). The ERROR is a direct jsx_element
|
|
86
114
|
// child and its text begins at the ampersand. Require both facts plus text that
|
|
@@ -15,6 +15,20 @@ export interface ParsedCall {
|
|
|
15
15
|
endByte: number;
|
|
16
16
|
/** true for `x.foo()` (property access), false for a direct `foo()` call. */
|
|
17
17
|
member: boolean;
|
|
18
|
+
/** For a member call on a plain identifier (`x.foo()`), that identifier. */
|
|
19
|
+
qualifier?: string;
|
|
20
|
+
}
|
|
21
|
+
/** A local name bound by an import: a module object (`imported` null — `import *
|
|
22
|
+
* as ns`, `require()`, Go package alias) or one aliased member of a module. */
|
|
23
|
+
export interface ParsedBinding {
|
|
24
|
+
local: string;
|
|
25
|
+
module: string;
|
|
26
|
+
imported: string | null;
|
|
27
|
+
}
|
|
28
|
+
/** A call whose target is pinned to one imported module (`ns.fn()`, aliased `g()`). */
|
|
29
|
+
export interface QualifiedCall {
|
|
30
|
+
callee: string;
|
|
31
|
+
module: string;
|
|
18
32
|
}
|
|
19
33
|
export interface ParsedRelation {
|
|
20
34
|
target: string;
|
|
@@ -28,6 +42,7 @@ export interface ParsedFile {
|
|
|
28
42
|
imports: string[];
|
|
29
43
|
calls: ParsedCall[];
|
|
30
44
|
relations: ParsedRelation[];
|
|
45
|
+
bindings: ParsedBinding[];
|
|
31
46
|
namespace: string | null;
|
|
32
47
|
parseable: boolean;
|
|
33
48
|
}
|
|
@@ -50,6 +65,11 @@ export declare function parseSource(file: string, source: string, opts?: {
|
|
|
50
65
|
* `memberOnly` (true iff every occurrence was a `x.foo()` member call, never
|
|
51
66
|
* a direct `foo()`), so the indexer can resolve member calls conservatively. */
|
|
52
67
|
export declare function attributeCalls(parsed: ParsedFile): Map<number, Map<string, boolean>>;
|
|
68
|
+
/** Map each call bound to an imported module — `ns.fn()` through a module-object
|
|
69
|
+
* binding, or `g()` through an aliased member import — to the innermost symbol
|
|
70
|
+
* containing it (same index identity as attributeCalls). A local bound to two
|
|
71
|
+
* different modules is ambiguous and dropped: never guess a binding. */
|
|
72
|
+
export declare function attributeQualifiedCalls(parsed: ParsedFile): Map<number, QualifiedCall[]>;
|
|
53
73
|
/** Map static semantic relationships (extends/implements/trait use) to the
|
|
54
74
|
* innermost enclosing declaration using the same stable symbol-index identity
|
|
55
75
|
* as call attribution. */
|
package/dist/extractors/parse.js
CHANGED
|
@@ -14,7 +14,8 @@ 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;
|
|
@@ -41,7 +42,7 @@ export function parseSource(file, source, opts = {}) {
|
|
|
41
42
|
// statefulness here even if it forgets to keep its pattern flag-free.
|
|
42
43
|
const templated = (spec.alwaysTemplatedExtensions?.some((ext) => file.endsWith(ext)) ?? false)
|
|
43
44
|
|| (spec.templatingMarkers?.some((marker) => source.search(marker) !== -1) ?? false);
|
|
44
|
-
const { parser, query } = bundleFor(spec);
|
|
45
|
+
const { parser, query, bindingQuery } = bundleFor(spec);
|
|
45
46
|
// The native binding caps its scratch buffer at 32 KB unless bufferSize is
|
|
46
47
|
// given — without this, any source >= 32768 bytes throws "Invalid argument"
|
|
47
48
|
// and would abort the whole index run. Guard with try/catch as a backstop.
|
|
@@ -81,6 +82,7 @@ export function parseSource(file, source, opts = {}) {
|
|
|
81
82
|
const imports = [];
|
|
82
83
|
const calls = [];
|
|
83
84
|
const relations = [];
|
|
85
|
+
const bindings = [];
|
|
84
86
|
let namespace = null;
|
|
85
87
|
const originalText = (node) => usingRecoveredTree
|
|
86
88
|
? source.slice(node.startIndex, node.endIndex)
|
|
@@ -125,8 +127,11 @@ export function parseSource(file, source, opts = {}) {
|
|
|
125
127
|
else if (cname === "call.member") {
|
|
126
128
|
// skip builtin method names to avoid false edges to similarly-named symbols
|
|
127
129
|
const text = originalText(node);
|
|
128
|
-
if (!spec.builtinMethods.has(text))
|
|
129
|
-
|
|
130
|
+
if (!spec.builtinMethods.has(text)) {
|
|
131
|
+
const object = node.parent?.childForFieldName("object") ?? node.parent?.childForFieldName("operand");
|
|
132
|
+
const qualifier = object?.type === "identifier" ? originalText(object) : undefined;
|
|
133
|
+
calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true, qualifier });
|
|
134
|
+
}
|
|
130
135
|
}
|
|
131
136
|
else if (spec.relationKindOf?.[cname]) {
|
|
132
137
|
const relation = spec.relationKindOf[cname];
|
|
@@ -139,6 +144,29 @@ export function parseSource(file, source, opts = {}) {
|
|
|
139
144
|
});
|
|
140
145
|
}
|
|
141
146
|
}
|
|
147
|
+
for (const match of bindingQuery?.matches(tree.rootNode) ?? []) {
|
|
148
|
+
const text = (capture) => {
|
|
149
|
+
const node = match.captures.find((c) => c.name === capture)?.node;
|
|
150
|
+
return node ? originalText(node) : null;
|
|
151
|
+
};
|
|
152
|
+
const src = text("bind.src")?.replace(STR_QUOTES, "");
|
|
153
|
+
if (!src)
|
|
154
|
+
continue;
|
|
155
|
+
const imported = text("bind.name");
|
|
156
|
+
if (imported) {
|
|
157
|
+
const local = text("bind.local") ?? imported;
|
|
158
|
+
// `from X import Y [as Z]`: Y may be a submodule or a member; record both
|
|
159
|
+
// readings — only the one that resolves to a tracked file/symbol yields an edge.
|
|
160
|
+
if (spec.joinSubmodule)
|
|
161
|
+
bindings.push({ local, module: spec.joinSubmodule(src, imported), imported: null });
|
|
162
|
+
bindings.push({ local, module: src, imported });
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const local = text("bind.local") ?? spec.defaultBindingLocal?.(src) ?? null;
|
|
166
|
+
if (local)
|
|
167
|
+
bindings.push({ local, module: src, imported: null });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
142
170
|
for (const { kind, def, name } of pendingDefs.values()) {
|
|
143
171
|
const resolvedName = name ?? spec.fallbackDefName?.(file);
|
|
144
172
|
if (!resolvedName)
|
|
@@ -167,7 +195,7 @@ export function parseSource(file, source, opts = {}) {
|
|
|
167
195
|
});
|
|
168
196
|
}
|
|
169
197
|
symbols.sort((a, b) => a.startByte - b.startByte);
|
|
170
|
-
return { symbols, imports, calls, relations, namespace, parseable: templated || parseable };
|
|
198
|
+
return { symbols, imports, calls, relations, bindings, namespace, parseable: templated || parseable };
|
|
171
199
|
}
|
|
172
200
|
/** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
|
|
173
201
|
* language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
|
|
@@ -259,6 +287,54 @@ export function attributeCalls(parsed) {
|
|
|
259
287
|
}
|
|
260
288
|
return out;
|
|
261
289
|
}
|
|
290
|
+
/** Map each call bound to an imported module — `ns.fn()` through a module-object
|
|
291
|
+
* binding, or `g()` through an aliased member import — to the innermost symbol
|
|
292
|
+
* containing it (same index identity as attributeCalls). A local bound to two
|
|
293
|
+
* different modules is ambiguous and dropped: never guess a binding. */
|
|
294
|
+
export function attributeQualifiedCalls(parsed) {
|
|
295
|
+
const unique = (entries) => {
|
|
296
|
+
const out = new Map();
|
|
297
|
+
for (const [local, target] of entries) {
|
|
298
|
+
const prev = out.get(local);
|
|
299
|
+
out.set(local, prev === undefined || (prev && prev.module === target.module && prev.callee === target.callee) ? target : null);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
};
|
|
303
|
+
const modules = unique(parsed.bindings.filter((b) => b.imported === null).map((b) => [b.local, { callee: "", module: b.module }]));
|
|
304
|
+
const aliases = unique(parsed.bindings.filter((b) => b.imported !== null).map((b) => [b.local, { callee: b.imported, module: b.module }]));
|
|
305
|
+
const out = new Map();
|
|
306
|
+
if (modules.size === 0 && aliases.size === 0)
|
|
307
|
+
return out;
|
|
308
|
+
for (const call of parsed.calls) {
|
|
309
|
+
let target = null;
|
|
310
|
+
if (call.member && call.qualifier) {
|
|
311
|
+
const binding = modules.get(call.qualifier);
|
|
312
|
+
if (binding)
|
|
313
|
+
target = { callee: call.callee, module: binding.module };
|
|
314
|
+
}
|
|
315
|
+
else if (!call.member) {
|
|
316
|
+
target = aliases.get(call.callee) ?? null;
|
|
317
|
+
}
|
|
318
|
+
if (!target)
|
|
319
|
+
continue;
|
|
320
|
+
let best = null;
|
|
321
|
+
let bestIndex = -1;
|
|
322
|
+
for (let i = 0; i < parsed.symbols.length; i++) {
|
|
323
|
+
const s = parsed.symbols[i];
|
|
324
|
+
if (call.atByte >= s.startByte && call.atByte < s.endByte && (!best || s.endByte - s.startByte < best.endByte - best.startByte)) {
|
|
325
|
+
best = s;
|
|
326
|
+
bestIndex = i;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (bestIndex < 0)
|
|
330
|
+
continue;
|
|
331
|
+
const list = out.get(bestIndex) ?? [];
|
|
332
|
+
if (!list.some((t) => t.callee === target.callee && t.module === target.module))
|
|
333
|
+
list.push(target);
|
|
334
|
+
out.set(bestIndex, list);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
262
338
|
/** Map static semantic relationships (extends/implements/trait use) to the
|
|
263
339
|
* innermost enclosing declaration using the same stable symbol-index identity
|
|
264
340
|
* 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;
|
|
@@ -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;
|
|
@@ -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);
|
|
@@ -828,8 +834,8 @@ function repairProvenanceMergeBlock(invocation) {
|
|
|
828
834
|
* (the file itself is new) outranks "appended"/"updated" (an existing file
|
|
829
835
|
* changed), which outrank "unchanged". Non-writing results are handled before
|
|
830
836
|
* 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";
|
|
837
|
+
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0, "opted-out": 0 };
|
|
838
|
+
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable" && h.action !== "opted-out";
|
|
833
839
|
/** Install a post-merge hook carrying TWO independently-managed blocks:
|
|
834
840
|
* re-sync the committed grounding docs when a merge brought memory in behind
|
|
835
841
|
* them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
|
|
@@ -850,6 +856,8 @@ export function installPostMergeHook(root, invocation) {
|
|
|
850
856
|
// passed invocation unchanged — see selfBuildInvocation.
|
|
851
857
|
const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, (inv, ctx) => groundingMergeBlock(ctx.portable ? inv : selfBuildInvocation(root, inv)), invocation);
|
|
852
858
|
const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock, invocation);
|
|
859
|
+
if (grounding.action === "opted-out")
|
|
860
|
+
return grounding;
|
|
853
861
|
if (!writes(grounding) && !writes(repair)) {
|
|
854
862
|
return { ...grounding, snippet: `${grounding.snippet}\n${stripComment(repair.snippet ?? "", grounding.manager)}` };
|
|
855
863
|
}
|
|
@@ -1025,6 +1033,8 @@ export function formatHookInstall(root, label, h, detail = "") {
|
|
|
1025
1033
|
const how = h.keptFlags?.length ? KEPT_HOW : "";
|
|
1026
1034
|
return [` ✓ ${label} kept — the shared hook keeps running ${h.sharedInvocation}${flags} (not re-pointed at this worktree)${how}`];
|
|
1027
1035
|
}
|
|
1036
|
+
if (h.action === "opted-out")
|
|
1037
|
+
return [` - ${label} skipped — ${h.reason}`];
|
|
1028
1038
|
if (writes(h)) {
|
|
1029
1039
|
// A union write (issue #316). Once an option was KEPT, `detail` — which
|
|
1030
1040
|
// describes only what this run asked for — is no longer the truth about the
|
|
@@ -14,6 +14,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
14
14
|
import { writeFileAtomic } from "../core/io.js";
|
|
15
15
|
import { assertSafeTopLevelConfigFile } from "./gitignore.js";
|
|
16
16
|
import { GROUNDING_DOC_PATHS } from "./providers.js";
|
|
17
|
+
import { mergeDriverShell } from "../cli/invocation.js";
|
|
17
18
|
// Route the .hunch JSON records through the structured driver — but NOT the
|
|
18
19
|
// manifest (an id-less `{schema_version}` object the driver can't merge by id; a
|
|
19
20
|
// normal text merge with conflict markers is the right behavior for it).
|
|
@@ -58,9 +59,10 @@ export function installMergeDriver(root, invShell) {
|
|
|
58
59
|
}
|
|
59
60
|
// 2. Local git config — the driver definitions are per-clone (they reference
|
|
60
61
|
// this machine's node + cli path), so they are NOT committed; teammates
|
|
61
|
-
// re-run init.
|
|
62
|
-
const
|
|
63
|
-
const
|
|
62
|
+
// re-run init. Dev mode swaps `npx tsx` for Hunch's own loader (#492).
|
|
63
|
+
const shell = mergeDriverShell(invShell);
|
|
64
|
+
const driver = `${shell} merge-driver "%O" "%A" "%B" "%P"`;
|
|
65
|
+
const groundingDriver = `${shell} merge-driver-grounding "%O" "%A" "%B" "%P"`;
|
|
64
66
|
const env = targetRepositoryEnv();
|
|
65
67
|
try {
|
|
66
68
|
execFileSync("git", ["config", "merge.hunch.name", "hunch structured JSON merge"], { cwd: root, env });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-out marker `hunch uninstall` leaves (#449), apart from uninstall.ts so the
|
|
3
|
+
* hook installers can honor it without an import cycle (uninstall.ts imports hooks.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { lstatSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
/** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
|
|
8
|
+
export const OPT_OUT_FILE = ".hunch-removed";
|
|
9
|
+
export function isOptedOut(root) {
|
|
10
|
+
try {
|
|
11
|
+
return lstatSync(join(root, OPT_OUT_FILE)).isFile();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=optout.js.map
|
|
@@ -73,8 +73,12 @@ export declare function writeCodexHooks(root: string, inv: Invocation): string;
|
|
|
73
73
|
export declare function writeVscodeHooks(root: string, inv: Invocation): string;
|
|
74
74
|
/** Windsurf's documented workspace hooks. It only supports deterministic
|
|
75
75
|
* pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
|
|
76
|
-
* reserves the hook for strict edit protection and pipeline observation.
|
|
77
|
-
|
|
76
|
+
* reserves the hook for strict edit protection and pipeline observation.
|
|
77
|
+
* Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
|
|
78
|
+
* legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
|
|
79
|
+
* merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
|
|
80
|
+
* parsed before either is written: an unparseable one refuses the whole write. */
|
|
81
|
+
export declare function writeWindsurfHooks(root: string, inv: Invocation): string[];
|
|
78
82
|
/** Antigravity keeps hook groups at the top level (not under `hooks`). Hunch
|
|
79
83
|
* owns only the `hunch` group and replaces its own old entries idempotently. */
|
|
80
84
|
export declare function writeAntigravityHooks(root: string, inv: Invocation): string;
|
|
@@ -419,9 +419,26 @@ function publishedPinEvents(file) {
|
|
|
419
419
|
}
|
|
420
420
|
/** Windsurf's documented workspace hooks. It only supports deterministic
|
|
421
421
|
* pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
|
|
422
|
-
* reserves the hook for strict edit protection and pipeline observation.
|
|
422
|
+
* reserves the hook for strict edit protection and pipeline observation.
|
|
423
|
+
* Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
|
|
424
|
+
* legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
|
|
425
|
+
* merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
|
|
426
|
+
* parsed before either is written: an unparseable one refuses the whole write. */
|
|
423
427
|
export function writeWindsurfHooks(root, inv) {
|
|
424
|
-
const
|
|
428
|
+
const files = [join(root, ".windsurf", "hooks.json"), join(root, ".devin", "hooks.json")];
|
|
429
|
+
// Same shape checks writeHookConfig applies at write time, so neither file is
|
|
430
|
+
// written when the other would refuse.
|
|
431
|
+
for (const file of files) {
|
|
432
|
+
const hooks = objectField(readJsonObj(file), "hooks", file);
|
|
433
|
+
for (const event of WINDSURF_EVENTS) {
|
|
434
|
+
if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
|
|
435
|
+
throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return files.map((file) => writeWindsurfHookFile(file, inv));
|
|
440
|
+
}
|
|
441
|
+
function writeWindsurfHookFile(file, inv) {
|
|
425
442
|
const published = hookCommand(publishedMcpInvocation(), "windsurf");
|
|
426
443
|
let command = hookCommand(inv, "windsurf");
|
|
427
444
|
// A source-checkout run (tsx / local dist) must never replace a tracked
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs";
|
|
9
9
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { boundedSpawnSync } from "../core/boundedSpawn.js";
|
|
11
12
|
import { writeFileAtomic } from "../core/io.js";
|
|
12
13
|
import { machineFile } from "../core/machine.js";
|
|
13
14
|
import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
|
|
14
15
|
import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
|
|
15
|
-
import { canonicalRemoteUrl, checkoutCommonDir, gitNullDevice, mainWorktreeRoot, sameFilesystemEntry, sameRemoteUrl } from "../extractors/git.js";
|
|
16
|
+
import { canonicalRemoteUrl, checkoutCommonDir, gitNullDevice, mainWorktreeRoot, pullRemoteTimeoutMs, sameFilesystemEntry, sameRemoteUrl } from "../extractors/git.js";
|
|
16
17
|
import { HunchStore } from "../store/hunchStore.js";
|
|
17
18
|
import { JsonStore } from "../store/jsonStore.js";
|
|
18
19
|
import { ensureSharedOverlayPointer } from "./worktree.js";
|
|
@@ -654,13 +655,14 @@ export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination
|
|
|
654
655
|
}
|
|
655
656
|
const cloneEnv = boundedTeamGitEnv();
|
|
656
657
|
stage = "clone";
|
|
657
|
-
|
|
658
|
+
// Bounded so a timeout kills git's whole tree, not just the root (#498).
|
|
659
|
+
const cloned = boundedSpawnSync("git", [
|
|
658
660
|
"-c", "protocol.ext.allow=never",
|
|
659
661
|
"-c", `core.hooksPath=${emptyHooks}`,
|
|
660
662
|
"clone", "--no-checkout", `--template=${emptyTemplate}`,
|
|
661
663
|
"--", repo, stagedDest,
|
|
662
664
|
], {
|
|
663
|
-
|
|
665
|
+
stdout: "ignore",
|
|
664
666
|
env: cloneEnv,
|
|
665
667
|
timeout: timeoutMs,
|
|
666
668
|
});
|
|
@@ -854,7 +856,7 @@ export function ensureTeamOverlay(root) {
|
|
|
854
856
|
if (!existsSync(dest)) {
|
|
855
857
|
const cloned = cloneValidatedTeamOverlay(team.shared_repo, root, dest, {
|
|
856
858
|
sharedRef: team.shared_ref,
|
|
857
|
-
timeoutMs:
|
|
859
|
+
timeoutMs: pullRemoteTimeoutMs(),
|
|
858
860
|
});
|
|
859
861
|
if (!cloned || !overlayMatchesTeamRemote(root, dest)) {
|
|
860
862
|
if (cloned)
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
|
|
2
|
+
export { isOptedOut, OPT_OUT_FILE };
|
|
3
|
+
/** Whether `start`, or the repository containing it, opted out: walks up through
|
|
4
|
+
* `start`'s ancestors (which need not exist yet), stopping after the first one holding
|
|
5
|
+
* `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
|
|
6
|
+
* init --root ./partitions/x`) is gated like the repository itself (#458). A `.hunch`
|
|
7
|
+
* store is deliberately not a boundary: a partition store nested in an opted-out
|
|
8
|
+
* repository stays gated (fail closed). */
|
|
9
|
+
export declare function optedOutAt(start: string): boolean;
|
|
10
|
+
/** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
|
|
11
|
+
* `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
|
|
12
|
+
* an ancestor's marker gates nothing. */
|
|
13
|
+
export declare function optOutMarkerAt(start: string, opts?: {
|
|
14
|
+
withinRepo?: boolean;
|
|
15
|
+
}): string | null;
|
|
16
|
+
/** The refusal, naming the marker of the repository actually checked (#488). */
|
|
17
|
+
export declare function optOutMessage(marker?: string): string;
|
|
5
18
|
export interface UninstallStep {
|
|
6
19
|
/** Repo-relative (POSIX) where possible, else absolute. */
|
|
7
20
|
path: string;
|