@davesheffer/hunch 1.43.0 → 1.45.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 (57) hide show
  1. package/README.md +16 -16
  2. package/dist/cli/index.js +267 -89
  3. package/dist/core/agenthook.d.ts +1 -0
  4. package/dist/core/agenthook.js +11 -0
  5. package/dist/core/changeProof.js +2 -1
  6. package/dist/core/commitrepair.d.ts +9 -3
  7. package/dist/core/commitrepair.js +23 -6
  8. package/dist/core/correction.d.ts +1 -1
  9. package/dist/core/correction.js +4 -3
  10. package/dist/core/docanchors.d.ts +11 -2
  11. package/dist/core/docanchors.js +232 -65
  12. package/dist/core/drift.d.ts +1 -1
  13. package/dist/core/drift.js +10 -3
  14. package/dist/core/eol.d.ts +2 -0
  15. package/dist/core/eol.js +6 -0
  16. package/dist/core/escalations.d.ts +20 -0
  17. package/dist/core/escalations.js +47 -0
  18. package/dist/core/hookText.js +1 -1
  19. package/dist/core/procstart.d.ts +15 -4
  20. package/dist/core/procstart.js +56 -18
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskTouched.d.ts +2 -0
  24. package/dist/core/taskTouched.js +1 -1
  25. package/dist/extractors/git.d.ts +36 -6
  26. package/dist/extractors/git.js +86 -9
  27. package/dist/extractors/indexer.js +53 -14
  28. package/dist/extractors/k8sManifest.d.ts +31 -1
  29. package/dist/extractors/k8sManifest.js +133 -8
  30. package/dist/integrations/claudemd.d.ts +2 -0
  31. package/dist/integrations/claudemd.js +5 -3
  32. package/dist/integrations/gitignore.d.ts +23 -0
  33. package/dist/integrations/gitignore.js +48 -0
  34. package/dist/integrations/hooks.d.ts +15 -0
  35. package/dist/integrations/hooks.js +49 -1
  36. package/dist/integrations/mergeDriver.d.ts +2 -0
  37. package/dist/integrations/mergeDriver.js +2 -2
  38. package/dist/integrations/providers.d.ts +16 -0
  39. package/dist/integrations/providers.js +35 -6
  40. package/dist/integrations/scaffold.d.ts +5 -0
  41. package/dist/integrations/scaffold.js +13 -8
  42. package/dist/integrations/uninstall.d.ts +40 -0
  43. package/dist/integrations/uninstall.js +435 -0
  44. package/dist/mcp/leanToolList.d.ts +9 -0
  45. package/dist/mcp/leanToolList.js +89 -0
  46. package/dist/mcp/server.d.ts +2 -1
  47. package/dist/mcp/server.js +62 -6
  48. package/dist/serve/mcpHttp.js +2 -0
  49. package/dist/serve/writelock.d.ts +7 -0
  50. package/dist/serve/writelock.js +30 -3
  51. package/dist/store/changeLedger.d.ts +5 -2
  52. package/dist/store/changeLedger.js +88 -37
  53. package/dist/store/hunchStore.js +4 -1
  54. package/package.json +3 -6
  55. package/server.json +2 -2
  56. package/tooling/md1-benchmark.mjs +1 -1
  57. package/tooling/competitive-watch.mjs +0 -142
@@ -68,5 +68,35 @@ export declare function namespacesCompatible(a: string | null, b: string | null)
68
68
  * paths below aren't hand-duplicated per kind. */
69
69
  export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
70
70
  export declare const LABELS_PATH_BY_KIND: Record<string, string>;
71
- export declare function extractK8sManifest(source: string): K8sManifestDocument[];
71
+ /** `namespace` is a Kustomize transformer namespace for this file (from
72
+ * kustomizeNamespaces): undefined = no transformer, null = UNKNOWN (two
73
+ * kustomizations render this file into different namespaces). */
74
+ export declare function extractK8sManifest(source: string, opts?: {
75
+ namespace?: string | null;
76
+ }): K8sManifestDocument[];
77
+ export interface KustomizationInfo {
78
+ /** Top-level `namespace:` when it is a valid literal namespace, else null. */
79
+ namespace: string | null;
80
+ /** `resources:` entries exactly as written (paths relative to the kustomization's directory). */
81
+ resources: string[];
82
+ }
83
+ export declare function isKustomizationFile(path: string): boolean;
84
+ /** Reads only the two top-level fields the namespace transformer needs:
85
+ * `namespace:` and the `resources:` list (block `- item` lines or a one-line
86
+ * flow `[a, b]`). One linear pass; everything else in the file is ignored. */
87
+ export declare function extractKustomization(source: string): KustomizationInfo;
88
+ /** The Kustomize namespace each manifest file is rendered into, keyed by repo
89
+ * path. `kustomizations` is keyed by the kustomization's DIRECTORY ("" for
90
+ * the repo root); `files` is the indexed file set.
91
+ *
92
+ * A `resources:` entry naming a file applies the namespace to that file; an
93
+ * entry naming a directory that holds its own kustomization is a base, and
94
+ * the referring (outer) kustomization's namespace wins over the base's, as
95
+ * Kustomize applies the overlay transformer to the base's output. Only
96
+ * kustomizations no other kustomization references are rendered as roots.
97
+ * A file rendered into two different namespaces (one base shared by dev and
98
+ * prod overlays, the common layout) maps to null = UNKNOWN; a file rendered
99
+ * only without any transformer is absent. Not modelled: remote resources,
100
+ * `components:`, the deprecated `bases:` field, and globs. */
101
+ export declare function kustomizeNamespaces(kustomizations: ReadonlyMap<string, KustomizationInfo>, files: ReadonlySet<string>): Map<string, string | null>;
72
102
  export {};
@@ -1,4 +1,5 @@
1
- import { sourceLines } from "../core/eol.js";
1
+ import { posix } from "node:path";
2
+ import { eolLengthAt, sourceLines } from "../core/eol.js";
2
3
  // ReplicaSet is included alongside the pod-spec-embedding/networking kinds
3
4
  // specifically because it's the dominant real-world bearer of
4
5
  // ownerReferences (Deployment -> ReplicaSet -> Pod is the common chain) --
@@ -606,7 +607,16 @@ function extractLiteralLabelMap(prefix, entries, unresolvedContainers, valueless
606
607
  }
607
608
  return pairs.length > 0 ? Object.fromEntries(pairs) : null;
608
609
  }
609
- function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents) {
610
+ /** Cluster-scoped kinds Kustomize's namespace transformer leaves alone. None
611
+ * of them is in ALLOWED_KINDS today, so this is a guard for the override in
612
+ * buildDocument rather than a live branch: an allowlist widening must not
613
+ * start stamping a namespace onto a resource that cannot have one. */
614
+ const CLUSTER_SCOPED_KINDS = new Set([
615
+ "Namespace", "Node", "PersistentVolume", "StorageClass", "CustomResourceDefinition",
616
+ "ClusterRole", "ClusterRoleBinding", "PriorityClass", "IngressClass", "RuntimeClass",
617
+ "MutatingWebhookConfiguration", "ValidatingWebhookConfiguration", "APIService",
618
+ ]);
619
+ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents, namespaceOverride) {
610
620
  const kindEntry = findEntry(entries, "kind");
611
621
  const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
612
622
  if (!kind || !ALLOWED_KINDS.has(kind))
@@ -615,8 +625,13 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
615
625
  // scanner's quote-stripping, comment-stripping and CRLF tolerance for free.
616
626
  // Read OUTSIDE the `resource` branch below: a document with no
617
627
  // metadata.name still emits references, and those references carry this
618
- // document's namespace.
619
- const namespace = namespaceAt(entries, "metadata.namespace");
628
+ // document's namespace. A Kustomize namespace transformer (see
629
+ // kustomizeNamespaces) REPLACES metadata.namespace on every namespaced
630
+ // resource, explicit or not, so it wins here; an explicit backendRefs
631
+ // namespace sibling is not rewritten and keeps its own.
632
+ const namespace = namespaceOverride !== undefined && !CLUSTER_SCOPED_KINDS.has(kind)
633
+ ? namespaceOverride
634
+ : namespaceAt(entries, "metadata.namespace");
620
635
  const nameEntry = findEntry(entries, "metadata.name");
621
636
  const resource = nameEntry
622
637
  ? { kind, name: nameEntry.value, namespace, startChar: docStartChar, endChar: docStartChar + text.length }
@@ -643,12 +658,18 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
643
658
  // and it still isn't here: the dashes must be followed by whitespace or end
644
659
  // of line.
645
660
  const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*$/;
646
- export function extractK8sManifest(source) {
661
+ /** `namespace` is a Kustomize transformer namespace for this file (from
662
+ * kustomizeNamespaces): undefined = no transformer, null = UNKNOWN (two
663
+ * kustomizations render this file into different namespaces). */
664
+ export function extractK8sManifest(source, opts = {}) {
647
665
  const docs = [];
648
666
  const boundaries = [];
649
667
  for (const line of sourceLines(source)) {
650
- if (DOC_SEPARATOR.test(line.content))
651
- boundaries.push(line.start, line.end);
668
+ if (!DOC_SEPARATOR.test(line.content))
669
+ continue;
670
+ // The next document starts AFTER the separator's line terminator, so the
671
+ // same document text has the same span wherever it sits (#451).
672
+ boundaries.push(line.start, line.end + eolLengthAt(source, line.end));
652
673
  }
653
674
  const starts = [0, ...boundaries.filter((_, i) => i % 2 === 1)];
654
675
  const ends = [...boundaries.filter((_, i) => i % 2 === 0), source.length];
@@ -657,8 +678,112 @@ export function extractK8sManifest(source) {
657
678
  const end = ends[i];
658
679
  const text = source.slice(start, end);
659
680
  const { entries, unresolvedContainers, valuelessKeyParents } = scanFieldPaths(text, start);
660
- docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents));
681
+ docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents, opts.namespace));
661
682
  }
662
683
  return docs;
663
684
  }
685
+ const KUSTOMIZATION_FILE = /(?:^|\/)(?:kustomization\.ya?ml|Kustomization)$/;
686
+ export function isKustomizationFile(path) {
687
+ return KUSTOMIZATION_FILE.test(path);
688
+ }
689
+ /** Reads only the two top-level fields the namespace transformer needs:
690
+ * `namespace:` and the `resources:` list (block `- item` lines or a one-line
691
+ * flow `[a, b]`). One linear pass; everything else in the file is ignored. */
692
+ export function extractKustomization(source) {
693
+ let namespace = null;
694
+ const resources = [];
695
+ let inResources = false;
696
+ for (const { content } of sourceLines(source)) {
697
+ const line = stripTrailingComment(content).trimEnd();
698
+ if (line.trim() === "")
699
+ continue;
700
+ const item = /^\s*-\s+(.+)$/.exec(line);
701
+ if (inResources && item) {
702
+ resources.push(stripQuotes(item[1].trim()));
703
+ continue;
704
+ }
705
+ if (/^\s/.test(line))
706
+ continue; // nested content of some other top-level key
707
+ inResources = false;
708
+ const top = /^([A-Za-z]+):[ \t]*(.*)$/.exec(line);
709
+ if (!top)
710
+ continue;
711
+ const value = top[2].trim();
712
+ if (top[1] === "namespace") {
713
+ const ns = stripQuotes(value);
714
+ namespace = DNS_LABEL.test(ns) && ns !== "null" ? ns : null;
715
+ }
716
+ else if (top[1] === "resources") {
717
+ if (value === "")
718
+ inResources = true;
719
+ else if (value.startsWith("[") && value.endsWith("]")) {
720
+ for (const part of value.slice(1, -1).split(","))
721
+ if (part.trim())
722
+ resources.push(stripQuotes(part.trim()));
723
+ }
724
+ }
725
+ }
726
+ return { namespace, resources };
727
+ }
728
+ /** The Kustomize namespace each manifest file is rendered into, keyed by repo
729
+ * path. `kustomizations` is keyed by the kustomization's DIRECTORY ("" for
730
+ * the repo root); `files` is the indexed file set.
731
+ *
732
+ * A `resources:` entry naming a file applies the namespace to that file; an
733
+ * entry naming a directory that holds its own kustomization is a base, and
734
+ * the referring (outer) kustomization's namespace wins over the base's, as
735
+ * Kustomize applies the overlay transformer to the base's output. Only
736
+ * kustomizations no other kustomization references are rendered as roots.
737
+ * A file rendered into two different namespaces (one base shared by dev and
738
+ * prod overlays, the common layout) maps to null = UNKNOWN; a file rendered
739
+ * only without any transformer is absent. Not modelled: remote resources,
740
+ * `components:`, the deprecated `bases:` field, and globs. */
741
+ export function kustomizeNamespaces(kustomizations, files) {
742
+ const KEEP = "\0keep";
743
+ const childFiles = new Map();
744
+ const childDirs = new Map();
745
+ const referenced = new Set();
746
+ for (const [dir, k] of kustomizations) {
747
+ for (const entry of k.resources) {
748
+ let p = posix.normalize(posix.join(dir, entry)).replace(/\/+$/, "");
749
+ if (p === ".")
750
+ p = "";
751
+ if (p === ".." || p.startsWith("../"))
752
+ continue;
753
+ if (files.has(p))
754
+ (childFiles.get(dir) ?? childFiles.set(dir, []).get(dir)).push(p);
755
+ else if (p !== dir && kustomizations.has(p)) {
756
+ (childDirs.get(dir) ?? childDirs.set(dir, []).get(dir)).push(p);
757
+ referenced.add(p);
758
+ }
759
+ }
760
+ }
761
+ const seen = new Map();
762
+ const visited = new Set();
763
+ const render = (dir, outer) => {
764
+ const ns = outer ?? kustomizations.get(dir).namespace;
765
+ const key = `${dir}\0${ns ?? KEEP}`;
766
+ if (visited.has(key))
767
+ return; // memoised per (dir, namespace); also breaks cycles
768
+ visited.add(key);
769
+ for (const f of childFiles.get(dir) ?? [])
770
+ (seen.get(f) ?? seen.set(f, new Set()).get(f)).add(ns ?? KEEP);
771
+ for (const child of childDirs.get(dir) ?? [])
772
+ render(child, ns);
773
+ };
774
+ for (const dir of [...kustomizations.keys()].sort())
775
+ if (!referenced.has(dir))
776
+ render(dir, null);
777
+ const out = new Map();
778
+ for (const [file, set] of seen) {
779
+ if (set.size > 1)
780
+ out.set(file, null);
781
+ else {
782
+ const only = [...set][0];
783
+ if (only !== KEEP)
784
+ out.set(file, only);
785
+ }
786
+ }
787
+ return out;
788
+ }
664
789
  //# sourceMappingURL=k8sManifest.js.map
@@ -1,5 +1,7 @@
1
1
  import type { HunchStore } from "../store/hunchStore.js";
2
2
  import { groundingTemplate } from "../core/groundingLag.js";
3
+ export declare const HUNCH_START = "<!-- HUNCH:START \u2014 auto-generated, do not edit by hand -->";
4
+ export declare const HUNCH_END = "<!-- HUNCH:END -->";
3
5
  /** Version of the block's PROSE. Bump it whenever renderHunchSection's wording
4
6
  * changes. A block without the stamp is template 1. */
5
7
  export declare const GROUNDING_TEMPLATE = 2;
@@ -9,8 +9,10 @@ import { basename, join, dirname } from "node:path";
9
9
  import { wikiSummary } from "../wiki/wiki.js";
10
10
  import { PolicyRepository } from "../constitution/repository.js";
11
11
  import { renderCountsMatch, parseGroundingCounts, groundingTemplate } from "../core/groundingLag.js";
12
- const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
13
- const END = "<!-- HUNCH:END -->";
12
+ export const HUNCH_START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
13
+ export const HUNCH_END = "<!-- HUNCH:END -->";
14
+ const START = HUNCH_START;
15
+ const END = HUNCH_END;
14
16
  /** Version of the block's PROSE. Bump it whenever renderHunchSection's wording
15
17
  * changes. A block without the stamp is template 1. */
16
18
  export const GROUNDING_TEMPLATE = 2;
@@ -96,7 +98,7 @@ export function renderHunchSection(store, root) {
96
98
  lines.push("This repo has **Hunch**, a graph of *why* the code is the way it is. It holds " +
97
99
  `${renderCountsMatch(counts)}. Use the \`hunch_*\` MCP tools by moment:`);
98
100
  lines.push("");
99
- lines.push("- **Start:** reuse the task ID and `task verify` command the prompt hook printed; with none, call `hunch_task(action: \"start\", title)` once. Then `hunch_context(target, task_id)` first. Orient with `hunch_structure`, `hunch_workspaces`, `hunch_runbook(task)`. Ask the user about each `hunch_escalations()` entry; silence is never approval.");
101
+ lines.push("- **Start:** reuse the task ID and `task verify` command the prompt hook printed; with none, call `hunch_task(action: \"start\", title)` once. Then `hunch_context(target, task_id)` first. Orient with `hunch_structure`, `hunch_workspaces`, `hunch_runbook(task)`. When the prompt hook raises an escalation this task touches, ask the user; silence is never approval. `hunch_escalations()` lists the rest.");
100
102
  lines.push("- **Design:** `hunch_why(target)` (includes what was rejected), `hunch_current_decision(topic)`, `hunch_bug_lineage(symptom_or_symbol)`, `hunch_compare(candidates)`, `hunch_query(query)`.");
101
103
  lines.push("- **Edit:** `hunch_check_constraints(scope)`, `hunch_get_dependents(symbol)` / `hunch_blast_radius(target)`, `hunch_findings(scope?)`.");
102
104
  lines.push("- **Merge:** `hunch_conformance()`, `hunch_pr_impact(base?)`, `hunch_merge_verdict`." +
@@ -39,3 +39,26 @@ export interface ManagedGitignoreUpgrade {
39
39
  export declare function upgradeManagedGitignore(root: string): ManagedGitignoreUpgrade;
40
40
  /** Output lines describing what an upgrade changed; empty when nothing did. */
41
41
  export declare function describeGitignoreUpgrade(upgrade: ManagedGitignoreUpgrade): string[];
42
+ export interface MemoryIgnoreRule {
43
+ /** Ignore file that holds the rule, as git reports it (e.g. `.gitignore`). */
44
+ source: string;
45
+ /** 1-based line of the rule in `source`. */
46
+ line: number;
47
+ pattern: string;
48
+ }
49
+ /** The user's ignore rule that keeps engineering memory out of Git, or null.
50
+ * A repository that ignores `.hunch/` (or `.hunch/decisions/`) silently loses every
51
+ * capture: the records are written, never committed, and gone on a fresh clone.
52
+ * Hunch's own private-only block ignores the same paths on purpose (memory lives
53
+ * in an overlay), so a rule inside that block is not reported. Unknown git state
54
+ * yields null: the caller then says nothing rather than guessing. */
55
+ export declare function memoryIgnoreRule(root: string): MemoryIgnoreRule | null;
56
+ /** One line telling the user their memory will not reach Git, and how to fix it. */
57
+ export declare function describeMemoryIgnoreRule(rule: MemoryIgnoreRule): string;
58
+ /** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
59
+ * every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
60
+ * marker line has no matching end line: the block is never guessed at. */
61
+ export declare function stripManagedGitignore(text: string): {
62
+ next: string;
63
+ damaged: boolean;
64
+ };
@@ -12,6 +12,7 @@
12
12
  * block written by an older release gains entries added later) and is otherwise
13
13
  * byte-identical; everything outside the markers is left untouched.
14
14
  */
15
+ import { spawnSync } from "node:child_process";
15
16
  import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
16
17
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
17
18
  import { writeFileAtomic } from "../core/io.js";
@@ -231,4 +232,51 @@ export function describeGitignoreUpgrade(upgrade) {
231
232
  }
232
233
  return out;
233
234
  }
235
+ /** The user's ignore rule that keeps engineering memory out of Git, or null.
236
+ * A repository that ignores `.hunch/` (or `.hunch/decisions/`) silently loses every
237
+ * capture: the records are written, never committed, and gone on a fresh clone.
238
+ * Hunch's own private-only block ignores the same paths on purpose (memory lives
239
+ * in an overlay), so a rule inside that block is not reported. Unknown git state
240
+ * yields null: the caller then says nothing rather than guessing. */
241
+ export function memoryIgnoreRule(root) {
242
+ const probe = ".hunch/decisions/dec_0000000000.json";
243
+ const r = spawnSync("git", ["check-ignore", "-v", "--no-index", "--", probe], { cwd: root, encoding: "utf8", windowsHide: true });
244
+ if (r.error || r.status !== 0)
245
+ return null;
246
+ const m = /^(.*):(\d+):(.*)\t/.exec(r.stdout ?? "");
247
+ if (!m)
248
+ return null;
249
+ const rule = { source: m[1], line: Number(m[2]), pattern: m[3] };
250
+ if (rule.pattern.startsWith("!"))
251
+ return null;
252
+ const file = resolve(root, rule.source);
253
+ if (existsSync(file)) {
254
+ const span = findBlock(readFileSync(file, "utf8").split("\n"), MEM_MARK, MEM_END);
255
+ if (span && rule.line - 1 > span.start && rule.line - 1 < span.end)
256
+ return null;
257
+ }
258
+ return rule;
259
+ }
260
+ /** One line telling the user their memory will not reach Git, and how to fix it. */
261
+ export function describeMemoryIgnoreRule(rule) {
262
+ return `${rule.source} line ${rule.line} (\`${rule.pattern}\`) ignores .hunch/, so captured memory is never committed or shared and is lost on a fresh clone. Remove that line, or keep memory in a private repository with \`hunch private --repo <url>\`.`;
263
+ }
264
+ /** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
265
+ * every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
266
+ * marker line has no matching end line: the block is never guessed at. */
267
+ export function stripManagedGitignore(text) {
268
+ let lines = text.split("\n");
269
+ let damaged = false;
270
+ for (const [mark, end] of [[MARK, END], [MEM_MARK, MEM_END]]) {
271
+ if (!lines.some((l) => cleanLine(l) === mark))
272
+ continue;
273
+ const span = findBlock(lines, mark, end);
274
+ if (!span) {
275
+ damaged = true;
276
+ continue;
277
+ }
278
+ lines = [...lines.slice(0, span.start), ...lines.slice(span.end + 1)];
279
+ }
280
+ return { next: lines.join("\n"), damaged };
281
+ }
234
282
  //# sourceMappingURL=gitignore.js.map
@@ -144,6 +144,21 @@ export declare function hookStatus(root: string): {
144
144
  postMerge: boolean;
145
145
  postCheckout: boolean;
146
146
  };
147
+ /** One git hook file `hunch uninstall` would change. `next === null` deletes the
148
+ * file (only a shebang was left); a `reason` means nothing is edited and the
149
+ * user removes the block by hand. */
150
+ export interface HookRemoval {
151
+ path: string;
152
+ hookName: string;
153
+ next?: string | null;
154
+ reason?: string;
155
+ }
156
+ /** Plan the removal of every Hunch-managed block from the hooks git runs (#449).
157
+ * Read-only. Only a hook file Hunch itself writes (manager "none", or an
158
+ * exec/exit file whose block it appended) is edited, and only a block with both
159
+ * markers; a hook manager's file, a tracked hooks dir, or an unclosed block is
160
+ * reported instead — the same lines Hunch refuses to write on install. */
161
+ export declare function planHookRemoval(root: string): HookRemoval[];
147
162
  /** Doctor lines for where each installed/stale hook points (issue #315): hooks
148
163
  * sharing one invocation share one line, and a line whose invocation is not the
149
164
  * Hunch currently running says so. Informational — a hook may legitimately
@@ -20,7 +20,7 @@ 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
22
  import { HUNCH_NPX_PACKAGE_SPEC, HUNCH_PACKAGE_NAME } from "../core/version.js";
23
- import { normalizeEol } from "../core/eol.js";
23
+ import { eolLengthAt, normalizeEol } from "../core/eol.js";
24
24
  const MARK = "# >>> hunch post-commit >>>";
25
25
  const ENDMARK = "# <<< hunch post-commit <<<";
26
26
  const GIT_CONTEXT = { checkoutType: "$3", portable: false };
@@ -942,6 +942,54 @@ export function hookStatus(root) {
942
942
  postCheckout: r.postCheckout.state === "installed",
943
943
  };
944
944
  }
945
+ /** Plan the removal of every Hunch-managed block from the hooks git runs (#449).
946
+ * Read-only. Only a hook file Hunch itself writes (manager "none", or an
947
+ * exec/exit file whose block it appended) is edited, and only a block with both
948
+ * markers; a hook manager's file, a tracked hooks dir, or an unclosed block is
949
+ * reported instead — the same lines Hunch refuses to write on install. */
950
+ export function planHookRemoval(root) {
951
+ const ends = {
952
+ [MARK]: ENDMARK, [PRE_MARK]: PRE_END, [GROUNDING_MERGE_MARK]: GROUNDING_MERGE_END,
953
+ [REPAIR_MERGE_MARK]: REPAIR_MERGE_END, [CHECKOUT_MARK]: CHECKOUT_END,
954
+ };
955
+ const byHook = new Map();
956
+ for (const [hookName, mark] of managedBlocks())
957
+ byHook.set(hookName, [...(byHook.get(hookName) ?? []), mark]);
958
+ const out = [];
959
+ for (const [hookName, marks] of byHook) {
960
+ const t = resolveHookTarget(root, hookName, marks[0]);
961
+ if (t.manager !== "none" && t.manager !== "exec-exit") {
962
+ const owner = readText(t.ownerFile);
963
+ if (owner !== null && marks.some((m) => owner.includes(m))) {
964
+ out.push({ path: t.ownerFile, hookName, reason: `${t.manager} owns this hook; remove the Hunch block from it by hand` });
965
+ }
966
+ continue;
967
+ }
968
+ const cur = readText(t.hookPath);
969
+ if (cur === null || !marks.some((m) => cur.includes(m)))
970
+ continue;
971
+ let next = cur;
972
+ let damaged = false;
973
+ for (const mark of marks) {
974
+ if (!next.includes(mark))
975
+ continue;
976
+ const m = new RegExp(`${escapeRe(mark)}[\\s\\S]*?${escapeRe(ends[mark])}`).exec(next);
977
+ if (!m) {
978
+ damaged = true;
979
+ continue;
980
+ }
981
+ const end = m.index + m[0].length;
982
+ next = next.slice(0, m.index) + next.slice(end + eolLengthAt(next, end));
983
+ }
984
+ if (damaged) {
985
+ out.push({ path: t.hookPath, hookName, reason: "a Hunch block here has no closing marker; remove it by hand" });
986
+ continue;
987
+ }
988
+ const body = normalizeEol(next).split("\n").filter((l, i) => !(i === 0 && l.startsWith("#!")));
989
+ out.push({ path: t.hookPath, hookName, next: body.every((l) => l.trim() === "") ? null : next });
990
+ }
991
+ return out;
992
+ }
945
993
  /** Doctor lines for where each installed/stale hook points (issue #315): hooks
946
994
  * sharing one invocation share one line, and a line whose invocation is not the
947
995
  * Hunch currently running says so. Informational — a hook may legitimately
@@ -1,3 +1,5 @@
1
+ export declare const ATTR_LINES: readonly string[];
2
+ export declare function targetRepositoryEnv(): NodeJS.ProcessEnv;
1
3
  export declare function installMergeDriver(root: string, invShell: string): {
2
4
  action: string;
3
5
  };
@@ -20,12 +20,12 @@ import { GROUNDING_DOC_PATHS } from "./providers.js";
20
20
  //
21
21
  // The five generated grounding docs get the OTHER driver — narrower in scope
22
22
  // (it only ever touches a hard conflict confined to the counts sentence).
23
- const ATTR_LINES = [
23
+ export const ATTR_LINES = [
24
24
  ".hunch/**/*.json merge=hunch",
25
25
  ".hunch/manifest.json merge=text",
26
26
  ...GROUNDING_DOC_PATHS.map((f) => `${f} merge=hunch-grounding`),
27
27
  ];
28
- function targetRepositoryEnv() {
28
+ export function targetRepositoryEnv() {
29
29
  const env = { ...process.env };
30
30
  for (const key of [
31
31
  "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
@@ -1,5 +1,10 @@
1
1
  import type { HunchStore } from "../store/hunchStore.js";
2
2
  import type { Invocation } from "./scaffold.js";
3
+ /** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
4
+ * non-empty file we cannot parse THROWS — overwriting it would silently wipe the
5
+ * user's other MCP servers. */
6
+ export declare function readJsonObj(file: string): Record<string, unknown>;
7
+ export declare function isHunchProviderHook(entry: unknown): boolean;
3
8
  /** Cursor: .cursor/mcp.json — same `mcpServers` shape as Claude Desktop/Code. */
4
9
  export declare function writeCursorMcp(root: string, inv: Invocation): string;
5
10
  /** VS Code (Copilot agent mode): .vscode/mcp.json — root key is `servers`, and
@@ -20,6 +25,8 @@ export declare function writeAntigravityMcp(inv: Invocation, home?: string): str
20
25
  * committed with the repository, so every clone gets the same private/local
21
26
  * Hunch server without touching a user's home directory. */
22
27
  export declare function writeAntigravityWorkspaceMcp(root: string, inv: Invocation): string;
28
+ export declare const TOML_START = "# >>> hunch mcp (managed) >>>";
29
+ export declare const TOML_END = "# <<< hunch mcp <<<";
23
30
  /** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
24
31
  * a marker-delimited block; any other TOML the user has is preserved. Paths use
25
32
  * TOML single-quote LITERAL strings so Windows backslashes need no escaping. */
@@ -108,7 +115,16 @@ export interface ProviderScaffold {
108
115
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
109
116
  * a writer that refuses to clobber a malformed file degrades to a warning rather
110
117
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
118
+ /** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
119
+ export declare const PROVIDER_ASSISTANTS: readonly ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
120
+ export type ProviderAssistant = typeof PROVIDER_ASSISTANTS[number];
121
+ /** Assistants with a footprint here: a config already in the repository (a teammate
122
+ * set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
123
+ * configures only these, so a repository is not filled with settings for tools nobody
124
+ * on it uses; when none is found it configures every assistant, as before. */
125
+ export declare function detectAssistants(root: string, home?: string, env?: NodeJS.ProcessEnv): Set<ProviderAssistant>;
111
126
  export declare function scaffoldProviders(root: string, inv: Invocation, store: HunchStore, options?: {
112
127
  agentHooks?: boolean;
113
128
  home?: string;
129
+ only?: ReadonlySet<string>;
114
130
  }): ProviderScaffold[];
@@ -20,7 +20,7 @@
20
20
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
21
  import { writeFileAtomic } from "../core/io.js";
22
22
  import { homedir } from "node:os";
23
- import { join, dirname } from "node:path";
23
+ import { join, dirname, delimiter } from "node:path";
24
24
  import { isHunchHookCommand } from "./hookmatch.js";
25
25
  import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd, preserveNewerTemplate } from "./claudemd.js";
26
26
  import { headFileContent, isGitCleanPath } from "../extractors/git.js";
@@ -50,7 +50,7 @@ function hasJsoncComments(raw) {
50
50
  /** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
51
51
  * non-empty file we cannot parse THROWS — overwriting it would silently wipe the
52
52
  * user's other MCP servers. */
53
- function readJsonObj(file) {
53
+ export function readJsonObj(file) {
54
54
  if (!existsSync(file))
55
55
  return {};
56
56
  const raw = readFileSync(file, "utf8");
@@ -128,7 +128,7 @@ function shellToken(part) {
128
128
  function hookCommand(inv, provider) {
129
129
  return [inv.command, ...inv.args, "hook", "--provider", provider].map(shellToken).join(" ");
130
130
  }
131
- function isHunchProviderHook(entry) {
131
+ export function isHunchProviderHook(entry) {
132
132
  const e = entry && typeof entry === "object" ? entry : null;
133
133
  // Codex (like Claude Code) nests commands under a matcher entry: an entry
134
134
  // whose every nested hook is ours is ours; a mixed entry stays foreign.
@@ -210,8 +210,8 @@ export function writeAntigravityWorkspaceMcp(root, inv) {
210
210
  json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
211
211
  return writeJson(file, json);
212
212
  }
213
- const TOML_START = "# >>> hunch mcp (managed) >>>";
214
- const TOML_END = "# <<< hunch mcp <<<";
213
+ export const TOML_START = "# >>> hunch mcp (managed) >>>";
214
+ export const TOML_END = "# <<< hunch mcp <<<";
215
215
  /** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
216
216
  * a marker-delimited block; any other TOML the user has is preserved. Paths use
217
217
  * TOML single-quote LITERAL strings so Windows backslashes need no escaping. */
@@ -560,6 +560,32 @@ function runProvider(writers) {
560
560
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
561
561
  * a writer that refuses to clobber a malformed file degrades to a warning rather
562
562
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
563
+ /** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
564
+ export const PROVIDER_ASSISTANTS = ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
565
+ function onPath(names, env) {
566
+ const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
567
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").concat("") : [""];
568
+ return dirs.some((dir) => names.some((name) => exts.some((ext) => existsSync(join(dir, name + ext)))));
569
+ }
570
+ /** Assistants with a footprint here: a config already in the repository (a teammate
571
+ * set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
572
+ * configures only these, so a repository is not filled with settings for tools nobody
573
+ * on it uses; when none is found it configures every assistant, as before. */
574
+ export function detectAssistants(root, home = homedir(), env = process.env) {
575
+ const has = (...paths) => paths.some((p) => existsSync(p));
576
+ const found = new Set();
577
+ if (has(join(root, ".cursor"), join(home, ".cursor")) || onPath(["cursor", "cursor-agent"], env))
578
+ found.add("Cursor");
579
+ if (has(join(root, ".vscode"), join(root, ".github", "copilot-instructions.md"), join(home, ".vscode")) || onPath(["code", "code-insiders"], env))
580
+ found.add("VS Code (Copilot)");
581
+ if (has(join(root, ".codex"), join(home, ".codex")) || onPath(["codex"], env))
582
+ found.add("Codex CLI");
583
+ if (has(join(root, ".windsurf"), join(home, ".codeium", "windsurf")) || onPath(["windsurf"], env))
584
+ found.add("Windsurf");
585
+ if (has(join(root, ".agents", "mcp_config.json"), join(home, ".gemini")) || onPath(["antigravity"], env))
586
+ found.add("Google Antigravity");
587
+ return found;
588
+ }
563
589
  export function scaffoldProviders(root, inv, store, options = {}) {
564
590
  const hooks = options.agentHooks !== false;
565
591
  const home = options.home;
@@ -584,6 +610,9 @@ export function scaffoldProviders(root, inv, store, options = {}) {
584
610
  }],
585
611
  ["Any (AGENTS.md)", () => runProvider([() => writeAgentsMd(root, store)])],
586
612
  ];
587
- return tasks.map(([assistant, run]) => ({ assistant, ...run() }));
613
+ const only = options.only;
614
+ return tasks
615
+ .filter(([assistant]) => !only || only.has(assistant) || assistant === "Any (AGENTS.md)")
616
+ .map(([assistant, run]) => ({ assistant, ...run() }));
588
617
  }
589
618
  //# sourceMappingURL=providers.js.map
@@ -23,6 +23,11 @@ export interface ClaudeHookInstall {
23
23
  * non-empty file we cannot parse THROWS rather than clobbering the user's config.
24
24
  */
25
25
  export declare function installClaudeHooks(root: string, hookCmd: string): ClaudeHookInstall;
26
+ /** Ownership marker for generated slash commands: its presence means Hunch may
27
+ * refresh the file; deleting the line hands the file to the user for good. */
28
+ export declare const CMD_MARKER = "<!-- hunch:generated \u2014 refreshed by hunch init; delete this line to take ownership -->";
29
+ /** File names `writeSlashCommands` writes under .claude/commands/. */
30
+ export declare const SLASH_COMMAND_FILES: readonly string[];
26
31
  export declare function writeSlashCommands(root: string): {
27
32
  written: string[];
28
33
  skipped: string[];
@@ -62,7 +62,7 @@ Follow the Hunch-grounded workflow (DESIGN §5) — do NOT skip the memory looku
62
62
  2. Identify the suspect symbol/file, then \`hunch_get_dependents(<symbol>)\` to learn the blast radius.
63
63
  3. \`hunch_check_constraints(<scope>)\` — list invariants you must preserve.
64
64
  4. Propose a fix that honors past root causes AND constraints. Apply it and run the tests.
65
- 5. If the fix encodes a non-trivial choice, \`hunch_record_decision(...)\` so the next session is grounded in it.
65
+ 5. If the human decided or corrected something during the fix, record it (\`hunch_record_decision\` / \`hunch_record_correction\`); the commit itself is captured automatically.
66
66
  `;
67
67
  const FRAGILE_CMD = `---
68
68
  description: Report the most fragile parts of this codebase, with evidence
@@ -233,13 +233,9 @@ export function installClaudeHooks(root, hookCmd) {
233
233
  }
234
234
  /** Ownership marker for generated slash commands: its presence means Hunch may
235
235
  * refresh the file; deleting the line hands the file to the user for good. */
236
- const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
237
- export function writeSlashCommands(root) {
238
- const dir = join(root, ".claude", "commands");
239
- mkdirSync(dir, { recursive: true });
240
- const written = [];
241
- const skipped = [];
242
- const files = [
236
+ export const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
237
+ function slashCommandFiles() {
238
+ return [
243
239
  ["hunch-why.md", WHY_CMD],
244
240
  ["hunch-fix.md", FIX_CMD],
245
241
  ["hunch-fragile.md", FRAGILE_CMD],
@@ -248,6 +244,15 @@ export function writeSlashCommands(root) {
248
244
  ["audit.md", AUDIT_CMD],
249
245
  ["worktrees.md", WORKTREES_CMD],
250
246
  ];
247
+ }
248
+ /** File names `writeSlashCommands` writes under .claude/commands/. */
249
+ export const SLASH_COMMAND_FILES = Object.freeze(slashCommandFiles().map(([name]) => name));
250
+ export function writeSlashCommands(root) {
251
+ const dir = join(root, ".claude", "commands");
252
+ mkdirSync(dir, { recursive: true });
253
+ const written = [];
254
+ const skipped = [];
255
+ const files = slashCommandFiles();
251
256
  for (const [name, body] of files) {
252
257
  const p = join(dir, name);
253
258
  // Generic names (capture/heal/audit) are plausibly the USER'S OWN commands;