@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.
- package/README.md +16 -16
- package/dist/cli/index.js +267 -89
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +11 -0
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/escalations.d.ts +20 -0
- package/dist/core/escalations.js +47 -0
- package/dist/core/hookText.js +1 -1
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +5 -3
- package/dist/integrations/gitignore.d.ts +23 -0
- package/dist/integrations/gitignore.js +48 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +16 -0
- package/dist/integrations/providers.js +35 -6
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +13 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/leanToolList.d.ts +9 -0
- package/dist/mcp/leanToolList.js +89 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +62 -6
- package/dist/serve/mcpHttp.js +2 -0
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -6
- package/server.json +2 -2
- package/tooling/md1-benchmark.mjs +1 -1
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
const
|
|
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)`.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
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;
|