@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.
Files changed (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -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
- 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;
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 a stored symbol's bodyText — large enough for review context, small
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 (Helm chart / Jinja CI config) isn't {spec.id} yet — a real
31
- // grammar correctly reports ERROR nodes for the delimiters. Still run the
32
- // parse below (a well-formed anchor elsewhere in the file still contributes
33
- // a real symbol, same as any other YAML file) — just don't let those
34
- // expected errors fail-close the whole-repo scan on content that was never
35
- // meant to stand alone (#33). Heavy top-level templating can break error
36
- // recovery badly enough that even the whole-file root node never forms
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
- // Keep the FIRST classification a node id receives: a query may have
96
- // several patterns matching the same node at different specificity
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[cname]) {
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
- calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true });
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 resolvedName = name ?? spec.fallbackDefName?.(file);
144
- if (!resolvedName)
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: resolvedName, kind,
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
- // Every other successfully-parsed YAML file gets at least a file-root symbol
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
- kind: "file",
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
- /** Walk up to the nearest node whose type is a definition this language recognizes. */
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
- * Keyed by the symbol's position (index) in `parsed.symbols` — NOT its
228
- * startByte, which is not a reliable per-symbol identity: a language whose
229
- * extractor merges a synthetic whole-file symbol with independently-derived
230
- * symbols (e.g. YAML's fallback-root synthetic symbol alongside Helm's
231
- * regex-derived `define` blocks) can produce two distinct symbols that both
232
- * start at byte 0. Indexing by array position is unique by construction,
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
- if (!out.has(bestIndex))
254
- out.set(bestIndex, new Map());
255
- const m = out.get(bestIndex);
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
- 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;
@@ -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 detected-but-unconfirmed commit-provenance
36
- // repairs — clone-local scratch state, never committed (`hunch repair-provenance
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 commit-repair matches a human rejected via `--drop` — clone-local
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
- 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;
@@ -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 a post-merge hook carrying TWO independently-managed blocks:
113
- * re-sync the committed grounding docs when a merge brought memory in behind
114
- * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
115
- * files and can never fail the merge), and opportunistically DETECT a
116
- * decision's commit provenance going orphaned right after a squash-merged
117
- * branch lands locally (including a fast-forward from `git pull`) — while
118
- * the original commits are still fully intact and matchable — queuing the
119
- * match for a human to confirm via `hunch repair-provenance --apply`
120
- * (HUNCH_MERGE_SYNC-guarded, backgrounded; own env var since this hook makes
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
- // Original marker, kept byte-for-byte for backward compat: an existing install's
791
- // grounding-refresh block must still be found and updated in place by its own
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
- // Distinct marker for the (newer) repair-provenance half, so the two blocks
796
- // never collide inside the same post-merge hook file and each can be
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
- // No --apply: this only detects a squash-merge orphaning a decision's commit
817
- // and queues the match (.hunch/pending-commit-repairs.json, local-only) for a
818
- // human to confirm via `hunch repair-provenance --apply` — the match signal
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
- /** How significant a combined install result is, for picking one HookInstall
827
- * action out of two independent sub-installs into the same file — "created"
828
- * (the file itself is new) outranks "appended"/"updated" (an existing file
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 a post-merge hook carrying TWO independently-managed blocks:
834
- * re-sync the committed grounding docs when a merge brought memory in behind
835
- * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
836
- * files and can never fail the merge), and opportunistically DETECT a
837
- * decision's commit provenance going orphaned right after a squash-merged
838
- * branch lands locally (including a fast-forward from `git pull`) — while
839
- * the original commits are still fully intact and matchable — queuing the
840
- * match for a human to confirm via `hunch repair-provenance --apply`
841
- * (HUNCH_MERGE_SYNC-guarded, backgrounded; own env var since this hook makes
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