@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.
Files changed (64) hide show
  1. package/dist/cli/index.js +165 -33
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/serve.d.ts +2 -0
  5. package/dist/cli/serve.js +2 -1
  6. package/dist/cli/verify.d.ts +28 -0
  7. package/dist/cli/verify.js +265 -0
  8. package/dist/constitution/experiment.d.ts +1 -1
  9. package/dist/core/agenthook.d.ts +1 -0
  10. package/dist/core/agenthook.js +82 -26
  11. package/dist/core/boundedSpawn.d.ts +57 -0
  12. package/dist/core/boundedSpawn.js +321 -0
  13. package/dist/core/config.d.ts +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/delivery.d.ts +10 -0
  16. package/dist/core/delivery.js +117 -9
  17. package/dist/core/migrate.d.ts +10 -1
  18. package/dist/core/migrate.js +17 -6
  19. package/dist/core/pipeline.d.ts +9 -2
  20. package/dist/core/pipeline.js +13 -3
  21. package/dist/core/procstart.d.ts +17 -3
  22. package/dist/core/procstart.js +26 -5
  23. package/dist/core/projectDna.js +11 -1
  24. package/dist/core/relativeImports.js +6 -2
  25. package/dist/core/taskSelection.d.ts +1 -0
  26. package/dist/core/taskSelection.js +132 -11
  27. package/dist/core/testSelection.d.ts +122 -0
  28. package/dist/core/testSelection.js +588 -0
  29. package/dist/core/whyRecord.d.ts +47 -0
  30. package/dist/core/whyRecord.js +105 -0
  31. package/dist/extractors/git.d.ts +32 -0
  32. package/dist/extractors/git.js +206 -94
  33. package/dist/extractors/indexer.js +42 -6
  34. package/dist/extractors/languages.d.ts +9 -0
  35. package/dist/extractors/languages.js +28 -0
  36. package/dist/extractors/parse.d.ts +20 -0
  37. package/dist/extractors/parse.js +81 -5
  38. package/dist/extractors/workspaces.js +3 -1
  39. package/dist/integrations/health.d.ts +6 -0
  40. package/dist/integrations/health.js +33 -12
  41. package/dist/integrations/hooks.d.ts +4 -2
  42. package/dist/integrations/hooks.js +12 -2
  43. package/dist/integrations/mergeDriver.js +5 -3
  44. package/dist/integrations/optout.d.ts +3 -0
  45. package/dist/integrations/optout.js +17 -0
  46. package/dist/integrations/providers.d.ts +6 -2
  47. package/dist/integrations/providers.js +19 -2
  48. package/dist/integrations/team.js +6 -4
  49. package/dist/integrations/uninstall.d.ts +17 -4
  50. package/dist/integrations/uninstall.js +35 -13
  51. package/dist/mcp/server.js +179 -30
  52. package/dist/serve/app.js +9 -1
  53. package/dist/serve/config.js +5 -0
  54. package/dist/serve/writelock.d.ts +6 -5
  55. package/dist/serve/writelock.js +26 -21
  56. package/dist/store/hunchStore.d.ts +12 -1
  57. package/dist/store/hunchStore.js +86 -4
  58. package/dist/store/jsonStore.d.ts +24 -4
  59. package/dist/store/jsonStore.js +75 -26
  60. package/dist/store/stateBinding.js +17 -4
  61. package/dist/synthesis/provider.js +26 -78
  62. package/dist/synthesis/synthesize.js +1 -1
  63. package/package.json +2 -2
  64. 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. */
@@ -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
- b = { parser, query };
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
- calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true });
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
- run(main, ["fetch", "--prune", "--quiet"], 120_000);
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
- if (!selected && !existsSync(join(root, spec.mcp)) && (!spec.hooks || !existsSync(join(root, spec.hooks))))
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(spec.hooks && existsSync(join(root, spec.hooks)));
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 = !!spec.hooks && !existsSync(join(root, spec.hooks));
207
- if (spec.hooks && !hooksAbsent) {
227
+ const hooksAbsent = !!hooksFile && !existsSync(join(root, hooksFile));
228
+ if (hooksFile && !hooksAbsent) {
208
229
  try {
209
- const config = object(parseJsonc(readFileSync(join(root, spec.hooks), "utf8")));
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(spec.hooks, commands);
234
+ recordPins(hooksFile, commands);
214
235
  if (commands.some(misroutedHook))
215
236
  report.issues.push({
216
- file: spec.hooks, code: "hook-command",
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: spec.hooks, code: "hook-config", detail: e.message });
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 ${spec.hooks}; run hunch init to install this host's lifecycle hooks`;
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: spec.hooks, code: "missing-hook", detail: status.detail });
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
- action: "created" | "appended" | "updated" | "unchanged" | "kept-shared" | "managed-elsewhere" | "unreachable";
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 driver = `${invShell} merge-driver "%O" "%A" "%B" "%P"`;
63
- const groundingDriver = `${invShell} merge-driver-grounding "%O" "%A" "%B" "%P"`;
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,3 @@
1
+ /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
2
+ export declare const OPT_OUT_FILE = ".hunch-removed";
3
+ export declare function isOptedOut(root: string): boolean;
@@ -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
- export declare function writeWindsurfHooks(root: string, inv: Invocation): string;
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 file = join(root, ".windsurf", "hooks.json");
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
- const cloned = spawnSync("git", [
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
- stdio: "ignore",
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: 5_000,
859
+ timeoutMs: pullRemoteTimeoutMs(),
858
860
  });
859
861
  if (!cloned || !overlayMatchesTeamRemote(root, dest)) {
860
862
  if (cloned)
@@ -1,7 +1,20 @@
1
- /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
2
- export declare const OPT_OUT_FILE = ".hunch-removed";
3
- export declare function isOptedOut(root: string): boolean;
4
- export declare function optOutMessage(): string;
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;