@davesheffer/hunch 1.38.1 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1,7 +1,36 @@
|
|
|
1
|
+
/** Which hook manager (if any) owns the hook file git would run.
|
|
2
|
+
* - `none`: a plain local hooks dir Hunch may write into.
|
|
3
|
+
* - `pre-commit`: the pre-commit framework's generated hook (ends in `exec`).
|
|
4
|
+
* - `husky`: husky v9 (`core.hooksPath=.husky/_`, stubs source `h`, which exits).
|
|
5
|
+
* - `husky-legacy`: husky ≤8 (`core.hooksPath=.husky`, a tracked directory).
|
|
6
|
+
* - `tracked-hooks-path`: any other `core.hooksPath` inside the work tree that git tracks.
|
|
7
|
+
* - `exec-exit`: no known manager, but the existing hook ends in exec/exit before our block. */
|
|
8
|
+
export type HookManagerKind = "none" | "pre-commit" | "husky" | "husky-legacy" | "tracked-hooks-path" | "exec-exit";
|
|
1
9
|
export interface HookInstall {
|
|
10
|
+
/** The hook file written; for a non-writing result, the file the user should edit. */
|
|
2
11
|
path: string;
|
|
3
|
-
|
|
12
|
+
/** created/appended/updated/unchanged: the block is (now) in a file git reaches.
|
|
13
|
+
* managed-elsewhere: a hook manager owns the hook — nothing was written.
|
|
14
|
+
* unreachable: the existing hook ends in exec/exit before our block — nothing was written. */
|
|
15
|
+
action: "created" | "appended" | "updated" | "unchanged" | "managed-elsewhere" | "unreachable";
|
|
16
|
+
manager?: HookManagerKind;
|
|
17
|
+
/** Why nothing was written (non-writing actions only). */
|
|
18
|
+
reason?: string;
|
|
19
|
+
/** What to add to `path` by hand (non-writing actions only). */
|
|
20
|
+
snippet?: string;
|
|
4
21
|
}
|
|
22
|
+
/** Portable invocation for text that lands in a TRACKED file (a husky script, a
|
|
23
|
+
* committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
|
|
24
|
+
* package reference the committed MCP/provider configs use — never this
|
|
25
|
+
* machine's absolute node/CLI path. */
|
|
26
|
+
export declare const PORTABLE_HOOK_INVOCATION: string;
|
|
27
|
+
/** The line that ends the script unconditionally before anything appended after
|
|
28
|
+
* it could run, or null. Deliberately conservative (a heuristic, not a shell
|
|
29
|
+
* parser): an `exec`/`exit` statement outside any if/case/loop/function block,
|
|
30
|
+
* or a trailing top-level `if … else … fi` whose every branch ends in
|
|
31
|
+
* `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
|
|
32
|
+
export declare function terminalExitLine(content: string): string | null;
|
|
33
|
+
type BlockState = "installed" | "unreachable" | "missing";
|
|
5
34
|
export declare function installPostCommitHook(root: string, invocation: string, opts?: {
|
|
6
35
|
private?: boolean;
|
|
7
36
|
commit?: boolean;
|
|
@@ -28,14 +57,41 @@ export declare function installPreCommitHook(root: string, invocation: string, s
|
|
|
28
57
|
* a repo carrying only one half (an older install, or a hand-edited hook)
|
|
29
58
|
* gets the other appended rather than clobbered. */
|
|
30
59
|
export declare function installPostMergeHook(root: string, invocation: string): HookInstall;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
export declare function installPostCheckoutHook(root: string, invocation: string): HookInstall;
|
|
61
|
+
export type HookState = BlockState;
|
|
62
|
+
export interface HookReportEntry {
|
|
63
|
+
state: HookState;
|
|
64
|
+
manager: HookManagerKind;
|
|
65
|
+
/** The file where the block lives (or should live). */
|
|
66
|
+
path: string;
|
|
67
|
+
/** Why an `unreachable` block never runs. */
|
|
68
|
+
reason?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface HookReport {
|
|
71
|
+
postCommit: HookReportEntry;
|
|
72
|
+
preCommit: HookReportEntry;
|
|
73
|
+
postMerge: HookReportEntry;
|
|
74
|
+
postCheckout: HookReportEntry;
|
|
75
|
+
}
|
|
76
|
+
/** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
|
|
77
|
+
* `installed` (present where git will run it), `unreachable` (present, but
|
|
78
|
+
* after an exec/exit or in a manager-owned file git never reaches), or
|
|
79
|
+
* `missing`. postMerge requires BOTH halves (grounding-refresh and
|
|
80
|
+
* repair-provenance) — a repo carrying only one is a partial install, same as
|
|
81
|
+
* `installPostMergeHook` self-healing it. Never writes anything. */
|
|
82
|
+
export declare function hookReport(root: string): HookReport;
|
|
83
|
+
/** Boolean view of `hookReport`: a hook counts as installed only when its
|
|
84
|
+
* managed block is present where git will actually run it, regardless of
|
|
85
|
+
* whether the invocation inside it happens to be stale. An unreachable block
|
|
86
|
+
* (issue #311) is NOT installed. */
|
|
37
87
|
export declare function hookStatus(root: string): {
|
|
38
88
|
postCommit: boolean;
|
|
39
89
|
preCommit: boolean;
|
|
40
90
|
postMerge: boolean;
|
|
91
|
+
postCheckout: boolean;
|
|
41
92
|
};
|
|
93
|
+
/** CLI lines for one install result: the usual ✓ line when the block is in a
|
|
94
|
+
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
95
|
+
* the manager's own file. */
|
|
96
|
+
export declare function formatHookInstall(root: string, label: string, h: HookInstall, detail?: string): string[];
|
|
97
|
+
export {};
|
|
@@ -3,12 +3,26 @@
|
|
|
3
3
|
* learning loop after every commit. Loop-guarded via the HUNCH_SYNC env var, and
|
|
4
4
|
* backgrounded so it never slows a commit down. Existing hooks are preserved —
|
|
5
5
|
* we append a guarded block rather than clobbering.
|
|
6
|
+
*
|
|
7
|
+
* Hook managers (issue #311): an appended block is only worth writing when git
|
|
8
|
+
* will actually reach it and the file is this machine's own. The pre-commit
|
|
9
|
+
* framework's generated hook ends in `exec`, husky v9 routes every hook through
|
|
10
|
+
* `.husky/_/h` (which ends in `exit $c` and is regenerated on `npm install`), and
|
|
11
|
+
* husky ≤8 / any committed `core.hooksPath` would put this machine's absolute
|
|
12
|
+
* CLI path into tracked files. In those cases nothing is written: the installer
|
|
13
|
+
* returns a portable snippet for the manager's own configuration instead, and
|
|
14
|
+
* `hookReport` tells a present-but-dead block apart from a working one.
|
|
6
15
|
*/
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from "node:fs";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpathSync } from "node:fs";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
|
|
19
|
+
import { hooksDir, gitCommonDir } from "../extractors/git.js";
|
|
20
|
+
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
21
|
+
import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
|
|
10
22
|
const MARK = "# >>> hunch post-commit >>>";
|
|
11
23
|
const ENDMARK = "# <<< hunch post-commit <<<";
|
|
24
|
+
const GIT_CONTEXT = { checkoutType: "$3" };
|
|
25
|
+
const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE" };
|
|
12
26
|
function block(invocation, opts = {}) {
|
|
13
27
|
// --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
|
|
14
28
|
// instead of the public repo. --commit (opt-in) also commits & pushes the repo the
|
|
@@ -25,6 +39,12 @@ function block(invocation, opts = {}) {
|
|
|
25
39
|
// team policy, so only the explicit local-only mode forces deterministic.
|
|
26
40
|
...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
|
|
27
41
|
` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
|
|
42
|
+
// Deliberately NO workspace-ledger snapshot here (docs/workspace-ledger.md): a commit
|
|
43
|
+
// changes HEAD, not which branches and worktrees exist — post-checkout covers that, and
|
|
44
|
+
// a ledger read publishes a fresh observation when someone actually asks. Snapshotting
|
|
45
|
+
// per commit would add git work (up to a patch-id walk) to the most frequent operation
|
|
46
|
+
// there is, and its backgrounded child outliving `git commit` is what held a Windows
|
|
47
|
+
// clone directory open and broke team-matrix-e2e's teardown with EBUSY.
|
|
28
48
|
"fi",
|
|
29
49
|
ENDMARK,
|
|
30
50
|
].join("\n");
|
|
@@ -32,21 +52,243 @@ function block(invocation, opts = {}) {
|
|
|
32
52
|
function escapeRe(s) {
|
|
33
53
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
34
54
|
}
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
/** Portable invocation for text that lands in a TRACKED file (a husky script, a
|
|
56
|
+
* committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
|
|
57
|
+
* package reference the committed MCP/provider configs use — never this
|
|
58
|
+
* machine's absolute node/CLI path. */
|
|
59
|
+
export const PORTABLE_HOOK_INVOCATION = `npx -y --package=${HUNCH_NPX_PACKAGE_SPEC} hunch`;
|
|
60
|
+
const EXIT_LINE = /^(?:exec|exit)(?:\s|;|$)/;
|
|
61
|
+
const INDENTED_EXIT_LINE = /^\s*(?:exec|exit)(?:\s|;|$)/;
|
|
62
|
+
const BLOCK_OPEN = /^(?:if|case|for|while|until|select)\b|\{\s*$/;
|
|
63
|
+
const BLOCK_CLOSE = /^(?:fi|esac|done)\b|^\}|[;\s](?:fi|esac|done|\})\s*;?\s*$/;
|
|
64
|
+
/** The line that ends the script unconditionally before anything appended after
|
|
65
|
+
* it could run, or null. Deliberately conservative (a heuristic, not a shell
|
|
66
|
+
* parser): an `exec`/`exit` statement outside any if/case/loop/function block,
|
|
67
|
+
* or a trailing top-level `if … else … fi` whose every branch ends in
|
|
68
|
+
* `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
|
|
69
|
+
export function terminalExitLine(content) {
|
|
70
|
+
const lines = content.replace(/\r/g, "").split("\n");
|
|
71
|
+
let depth = 0;
|
|
72
|
+
for (const raw of lines) {
|
|
73
|
+
const line = raw.trim();
|
|
74
|
+
if (line === "" || line.startsWith("#"))
|
|
75
|
+
continue;
|
|
76
|
+
if (depth === 0 && EXIT_LINE.test(line))
|
|
77
|
+
return line;
|
|
78
|
+
depth = Math.max(0, depth + (BLOCK_OPEN.test(line) ? 1 : 0) - (BLOCK_CLOSE.test(line) ? 1 : 0));
|
|
79
|
+
}
|
|
80
|
+
const meaningful = lines.filter((l) => l.trim() !== "" && !/^\s*#/.test(l));
|
|
81
|
+
const last = meaningful.at(-1);
|
|
82
|
+
if (last === undefined || !/^fi\s*(?:;|$)/.test(last))
|
|
83
|
+
return null;
|
|
84
|
+
const start = meaningful.slice(0, -1).findLastIndex((l) => /^if\s/.test(l));
|
|
85
|
+
if (start < 0)
|
|
86
|
+
return null;
|
|
87
|
+
const branches = [];
|
|
88
|
+
let current = [];
|
|
89
|
+
let hasElse = false;
|
|
90
|
+
for (const line of meaningful.slice(start + 1, -1)) {
|
|
91
|
+
if (/^elif\s/.test(line) || /^else\s*(?:;|$)/.test(line)) {
|
|
92
|
+
if (/^else/.test(line))
|
|
93
|
+
hasElse = true;
|
|
94
|
+
branches.push(current);
|
|
95
|
+
current = [];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
current.push(line);
|
|
99
|
+
}
|
|
100
|
+
branches.push(current);
|
|
101
|
+
if (!hasElse)
|
|
102
|
+
return null;
|
|
103
|
+
const allExit = branches.every((b) => {
|
|
104
|
+
const tail = b.filter((l) => !/^\s*then\s*$/.test(l)).at(-1);
|
|
105
|
+
return tail !== undefined && INDENTED_EXIT_LINE.test(tail);
|
|
106
|
+
});
|
|
107
|
+
return allExit ? last.trim() : null;
|
|
108
|
+
}
|
|
109
|
+
function readText(path) {
|
|
110
|
+
try {
|
|
111
|
+
return readFileSync(path, "utf8");
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Whether `mark` is present in `file`, and if so whether git would reach it. */
|
|
118
|
+
function markerState(file, mark) {
|
|
119
|
+
const text = readText(file);
|
|
120
|
+
if (text === null)
|
|
121
|
+
return "absent";
|
|
122
|
+
const at = text.indexOf(mark);
|
|
123
|
+
if (at < 0)
|
|
124
|
+
return "absent";
|
|
125
|
+
return terminalExitLine(text.slice(0, at)) ? "unreachable" : "reachable";
|
|
126
|
+
}
|
|
127
|
+
function realish(p) {
|
|
128
|
+
try {
|
|
129
|
+
return realpathSync.native(p);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return resolve(p);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function isInside(parent, child) {
|
|
136
|
+
const norm = (p) => (process.platform === "win32" ? realish(p).toLowerCase() : realish(p));
|
|
137
|
+
const rel = relative(norm(parent), norm(child));
|
|
138
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
139
|
+
}
|
|
140
|
+
function gitRun(args, cwd) {
|
|
141
|
+
const p = spawnSync("git", args, { cwd, encoding: "utf8", env: initiatorChildEnv(), stdio: ["ignore", "pipe", "ignore"] });
|
|
142
|
+
return { status: p.status, stdout: (p.stdout ?? "").trim() };
|
|
143
|
+
}
|
|
144
|
+
function isPreCommitFrameworkHook(text) {
|
|
145
|
+
return /File generated by pre-commit/i.test(text) || /-m\s*pre_commit\b/.test(text) || /^\s*exec\s+pre-commit\b/m.test(text);
|
|
146
|
+
}
|
|
147
|
+
/** Work out who owns `hookName` in `root` and whether an appended block would run.
|
|
148
|
+
* `mark` is our block's marker: when an existing block sits BEFORE a trailing
|
|
149
|
+
* exec/exit it is reachable, so only the text above it is inspected. Read-only. */
|
|
150
|
+
function resolveHookTarget(root, hookName, mark) {
|
|
42
151
|
const dir = hooksDir(root);
|
|
43
152
|
// `git rev-parse --git-path hooks` returns a path relative to the repo in a
|
|
44
153
|
// normal checkout, but an ABSOLUTE one inside a linked worktree (the shared
|
|
45
154
|
// hooks dir). isAbsolute() handles both POSIX (/…) and Windows (C:\… / C:/…);
|
|
46
155
|
// a bare startsWith("/") misfired on Windows worktrees → a doubled junk path.
|
|
47
156
|
const abs = isAbsolute(dir) ? dir : join(root, dir);
|
|
48
|
-
mkdirSync(abs, { recursive: true });
|
|
49
157
|
const hookPath = join(abs, hookName);
|
|
158
|
+
const slashed = abs.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
159
|
+
const huskyH = readText(join(abs, "h"));
|
|
160
|
+
if (slashed.endsWith(".husky/_") || (huskyH !== null && /husky/.test(huskyH))) {
|
|
161
|
+
return {
|
|
162
|
+
hookName, hookPath, manager: "husky", ownerFile: join(dirname(abs), hookName),
|
|
163
|
+
reason: "husky v9 runs every hook through .husky/_/h, which exits before any appended line (and regenerates .husky/_ on install)",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const common = gitCommonDir(root);
|
|
167
|
+
const insideGitDir = common !== "" && isInside(common, abs);
|
|
168
|
+
if (!insideGitDir) {
|
|
169
|
+
const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
|
|
170
|
+
if (top && isInside(top, abs)) {
|
|
171
|
+
const rel = relative(realish(top), realish(abs)).replace(/\\/g, "/") || ".";
|
|
172
|
+
const tracked = gitRun(["ls-files", "--", rel], top).stdout !== "";
|
|
173
|
+
// Not yet committed but not ignored either: the next `git add -A` would commit it.
|
|
174
|
+
const committable = tracked || gitRun(["check-ignore", "-q", "--", `${rel}/${hookName}`], top).status === 1;
|
|
175
|
+
if (committable) {
|
|
176
|
+
const legacy = basename(abs) === ".husky";
|
|
177
|
+
return {
|
|
178
|
+
hookName, hookPath, ownerFile: hookPath,
|
|
179
|
+
manager: legacy ? "husky-legacy" : "tracked-hooks-path",
|
|
180
|
+
reason: `${rel}/ is ${tracked ? "tracked by git" : "inside the work tree and not ignored"}${legacy ? " (husky ≤8)" : ""} — Hunch will not write this machine's CLI path into a committed hook`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const text = readText(hookPath);
|
|
186
|
+
if (text !== null && isPreCommitFrameworkHook(text)) {
|
|
187
|
+
const config = /--config=(\S+?)["')\s]/.exec(text)?.[1] ?? ".pre-commit-config.yaml";
|
|
188
|
+
const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout || root;
|
|
189
|
+
return {
|
|
190
|
+
hookName, hookPath, manager: "pre-commit", ownerFile: isAbsolute(config) ? config : join(top, config),
|
|
191
|
+
reason: `the pre-commit framework generated ${hookName}; it ends in exec, so an appended block never runs (and \`pre-commit install\` rewrites the file)`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (text !== null) {
|
|
195
|
+
const at = text.indexOf(mark);
|
|
196
|
+
const terminal = terminalExitLine(at >= 0 ? text.slice(0, at) : text);
|
|
197
|
+
if (terminal) {
|
|
198
|
+
return {
|
|
199
|
+
hookName, hookPath, manager: "exec-exit", ownerFile: hookPath,
|
|
200
|
+
reason: `${hookName} ends in \`${terminal}\`, so a block after it never runs`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { hookName, hookPath, manager: "none", ownerFile: hookPath };
|
|
205
|
+
}
|
|
206
|
+
/** Stable pre-commit framework hook id per managed block (detection + snippet). */
|
|
207
|
+
function preCommitId(mark) {
|
|
208
|
+
return {
|
|
209
|
+
[MARK]: "hunch-post-commit",
|
|
210
|
+
[PRE_MARK]: "hunch-pre-commit",
|
|
211
|
+
[GROUNDING_MERGE_MARK]: "hunch-post-merge-grounding",
|
|
212
|
+
[REPAIR_MERGE_MARK]: "hunch-post-merge-repair-provenance",
|
|
213
|
+
[CHECKOUT_MARK]: "hunch-post-checkout",
|
|
214
|
+
}[mark] ?? "hunch";
|
|
215
|
+
}
|
|
216
|
+
/** Whether our block for `mark` is present where git will actually run it. */
|
|
217
|
+
function blockState(t, mark) {
|
|
218
|
+
const inHook = markerState(t.hookPath, mark);
|
|
219
|
+
switch (t.manager) {
|
|
220
|
+
case "none":
|
|
221
|
+
return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
|
|
222
|
+
case "husky":
|
|
223
|
+
if (markerState(t.ownerFile, mark) === "reachable")
|
|
224
|
+
return "installed";
|
|
225
|
+
if (markerState(t.ownerFile, mark) === "unreachable" || inHook !== "absent")
|
|
226
|
+
return "unreachable";
|
|
227
|
+
return "missing";
|
|
228
|
+
case "pre-commit": {
|
|
229
|
+
const config = readText(t.ownerFile);
|
|
230
|
+
if (config !== null && new RegExp(`\\bid:\\s*["']?${escapeRe(preCommitId(mark))}["']?\\s*$`, "m").test(config))
|
|
231
|
+
return "installed";
|
|
232
|
+
// Migration mode: `pre-commit install` moved the previous hook to <hook>.legacy and runs it first.
|
|
233
|
+
if (markerState(`${t.hookPath}.legacy`, mark) === "reachable")
|
|
234
|
+
return "installed";
|
|
235
|
+
return inHook !== "absent" ? "unreachable" : "missing";
|
|
236
|
+
}
|
|
237
|
+
default: // husky-legacy, tracked-hooks-path, exec-exit: the hook file itself
|
|
238
|
+
return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function stripMarkers(blk) {
|
|
242
|
+
return blk.split("\n").filter((l) => !/^# (?:>>>|<<<) hunch /.test(l)).join("\n");
|
|
243
|
+
}
|
|
244
|
+
/** The hand-applied equivalent of our block, in the manager's own format. */
|
|
245
|
+
function snippetFor(t, mark, build, localInvocation) {
|
|
246
|
+
if (t.manager === "pre-commit") {
|
|
247
|
+
const script = stripMarkers(build(PORTABLE_HOOK_INVOCATION, PRE_COMMIT_CONTEXT));
|
|
248
|
+
return [
|
|
249
|
+
`# under \`repos:\` in ${basename(t.ownerFile)} (and run \`pre-commit install --hook-type ${t.hookName}\`)`,
|
|
250
|
+
"- repo: local",
|
|
251
|
+
" hooks:",
|
|
252
|
+
` - id: ${preCommitId(mark)}`,
|
|
253
|
+
` name: hunch ${t.hookName}`,
|
|
254
|
+
" entry: sh",
|
|
255
|
+
` args: ["-c", ${JSON.stringify(script)}]`,
|
|
256
|
+
" language: system",
|
|
257
|
+
` stages: [${t.hookName}]`,
|
|
258
|
+
" always_run: true",
|
|
259
|
+
" pass_filenames: false",
|
|
260
|
+
].join("\n");
|
|
261
|
+
}
|
|
262
|
+
if (t.manager === "exec-exit") {
|
|
263
|
+
// Untracked local hook: this machine's invocation is fine; placement is the fix.
|
|
264
|
+
return `# insert ABOVE the final exec/exit in ${t.hookName}\n${build(localInvocation, GIT_CONTEXT)}`;
|
|
265
|
+
}
|
|
266
|
+
return build(PORTABLE_HOOK_INVOCATION, GIT_CONTEXT);
|
|
267
|
+
}
|
|
268
|
+
/** Shared idempotent create/append/update-in-place logic for every hunch git
|
|
269
|
+
* hook: write a fresh hook file, replace our own managed block in place if the
|
|
270
|
+
* invocation changed, or append after any pre-existing (non-hunch) hook body
|
|
271
|
+
* without clobbering it. Used by all hook installers below — the copies had
|
|
272
|
+
* already drifted (installPreCommitHook was missing the chmodSync on its
|
|
273
|
+
* "updated" path) before this was unified. When a hook manager owns the file,
|
|
274
|
+
* or the block would sit after an exec/exit, nothing is written and the result
|
|
275
|
+
* carries the snippet to add by hand (issue #311). */
|
|
276
|
+
function installManagedBlock(root, hookName, mark, end, build, invocation) {
|
|
277
|
+
const t = resolveHookTarget(root, hookName, mark);
|
|
278
|
+
if (t.manager !== "none") {
|
|
279
|
+
if (blockState(t, mark) === "installed")
|
|
280
|
+
return { path: t.ownerFile, action: "unchanged", manager: t.manager };
|
|
281
|
+
return {
|
|
282
|
+
path: t.ownerFile,
|
|
283
|
+
action: t.manager === "exec-exit" ? "unreachable" : "managed-elsewhere",
|
|
284
|
+
manager: t.manager,
|
|
285
|
+
reason: t.reason,
|
|
286
|
+
snippet: snippetFor(t, mark, build, invocation),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const blk = build(invocation, GIT_CONTEXT);
|
|
290
|
+
const hookPath = t.hookPath;
|
|
291
|
+
mkdirSync(dirname(hookPath), { recursive: true });
|
|
50
292
|
if (!existsSync(hookPath)) {
|
|
51
293
|
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
52
294
|
chmodSync(hookPath, 0o755);
|
|
@@ -67,7 +309,7 @@ function installManagedBlock(root, hookName, mark, end, blk) {
|
|
|
67
309
|
return { path: hookPath, action: "appended" };
|
|
68
310
|
}
|
|
69
311
|
export function installPostCommitHook(root, invocation, opts = {}) {
|
|
70
|
-
return installManagedBlock(root, "post-commit", MARK, ENDMARK, block(
|
|
312
|
+
return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv) => block(inv, opts), invocation);
|
|
71
313
|
}
|
|
72
314
|
const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
|
|
73
315
|
const PRE_END = "# <<< hunch pre-commit <<<";
|
|
@@ -77,9 +319,11 @@ const PRE_END = "# <<< hunch pre-commit <<<";
|
|
|
77
319
|
* blocking invariant (see strictgate.ts), so it's safe on a shared repo.
|
|
78
320
|
* Preserves any existing pre-commit hook. */
|
|
79
321
|
export function installPreCommitHook(root, invocation, strict = false) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
322
|
+
const build = (inv) => {
|
|
323
|
+
const cmd = `${inv} check --staged${strict ? " --strict" : ""}`;
|
|
324
|
+
return [PRE_MARK, strict ? cmd : `${cmd} || true`, PRE_END].join("\n");
|
|
325
|
+
};
|
|
326
|
+
return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
|
|
83
327
|
}
|
|
84
328
|
// Original marker, kept byte-for-byte for backward compat: an existing install's
|
|
85
329
|
// grounding-refresh block must still be found and updated in place by its own
|
|
@@ -120,8 +364,10 @@ function repairProvenanceMergeBlock(invocation) {
|
|
|
120
364
|
/** How significant a combined install result is, for picking one HookInstall
|
|
121
365
|
* action out of two independent sub-installs into the same file — "created"
|
|
122
366
|
* (the file itself is new) outranks "appended"/"updated" (an existing file
|
|
123
|
-
* changed), which outrank "unchanged".
|
|
124
|
-
|
|
367
|
+
* changed), which outrank "unchanged". Non-writing results are handled before
|
|
368
|
+
* ranking (they must never be masked by a sibling's success). */
|
|
369
|
+
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "managed-elsewhere": 0, unreachable: 0 };
|
|
370
|
+
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
|
|
125
371
|
/** Install a post-merge hook carrying TWO independently-managed blocks:
|
|
126
372
|
* re-sync the committed grounding docs when a merge brought memory in behind
|
|
127
373
|
* them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
|
|
@@ -137,31 +383,97 @@ const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1 };
|
|
|
137
383
|
* a repo carrying only one half (an older install, or a hand-edited hook)
|
|
138
384
|
* gets the other appended rather than clobbered. */
|
|
139
385
|
export function installPostMergeHook(root, invocation) {
|
|
140
|
-
const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, groundingMergeBlock
|
|
141
|
-
const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock
|
|
386
|
+
const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, groundingMergeBlock, invocation);
|
|
387
|
+
const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock, invocation);
|
|
388
|
+
if (!writes(grounding) && !writes(repair)) {
|
|
389
|
+
return { ...grounding, snippet: `${grounding.snippet}\n${stripComment(repair.snippet ?? "", grounding.manager)}` };
|
|
390
|
+
}
|
|
391
|
+
if (!writes(grounding))
|
|
392
|
+
return grounding;
|
|
393
|
+
if (!writes(repair))
|
|
394
|
+
return repair;
|
|
142
395
|
return ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
|
|
143
396
|
}
|
|
144
|
-
/**
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
397
|
+
/** When two snippets for the same file are joined, drop the second's leading
|
|
398
|
+
* instruction comment (the first already says where it goes). */
|
|
399
|
+
function stripComment(snippet, manager) {
|
|
400
|
+
if (manager !== "pre-commit" && manager !== "exec-exit")
|
|
401
|
+
return snippet;
|
|
402
|
+
return snippet.split("\n").filter((l, i) => !(i === 0 && l.startsWith("# "))).join("\n");
|
|
403
|
+
}
|
|
404
|
+
const CHECKOUT_MARK = "# >>> hunch post-checkout (workspace ledger) >>>";
|
|
405
|
+
const CHECKOUT_END = "# <<< hunch post-checkout (workspace ledger) <<<";
|
|
406
|
+
/** post-checkout is where branches and worktrees actually change (`git checkout`,
|
|
407
|
+
* `git switch`, `git worktree add`). git passes `$3 = 1` for a branch checkout and `0`
|
|
408
|
+
* for a file checkout; only the former can change the ledger. Constant argv (nothing
|
|
409
|
+
* from repository content), HUNCH_SYNC-guarded, backgrounded, offline. */
|
|
410
|
+
function checkoutBlock(invocation, ctx = GIT_CONTEXT) {
|
|
411
|
+
return [
|
|
412
|
+
CHECKOUT_MARK,
|
|
413
|
+
`if [ -z "$HUNCH_SYNC" ] && [ "${ctx.checkoutType}" = "1" ]; then`,
|
|
414
|
+
` ( HUNCH_SYNC=1 ${invocation} workspaces snapshot --quiet >/dev/null 2>&1 || true ) &`,
|
|
415
|
+
"fi",
|
|
416
|
+
CHECKOUT_END,
|
|
417
|
+
].join("\n");
|
|
418
|
+
}
|
|
419
|
+
export function installPostCheckoutHook(root, invocation) {
|
|
420
|
+
return installManagedBlock(root, "post-checkout", CHECKOUT_MARK, CHECKOUT_END, checkoutBlock, invocation);
|
|
421
|
+
}
|
|
422
|
+
function reportEntry(root, hookName, marks) {
|
|
423
|
+
const states = marks.map((mark) => {
|
|
424
|
+
const t = resolveHookTarget(root, hookName, mark);
|
|
425
|
+
return { t, state: blockState(t, mark) };
|
|
426
|
+
});
|
|
427
|
+
const unreachable = states.find((s) => s.state === "unreachable");
|
|
428
|
+
const pick = unreachable ?? states.find((s) => s.state === "missing") ?? states[0];
|
|
429
|
+
if (!pick)
|
|
430
|
+
throw new Error("reportEntry needs at least one marker");
|
|
431
|
+
const state = unreachable ? "unreachable" : states.every((s) => s.state === "installed") ? "installed" : "missing";
|
|
432
|
+
return {
|
|
433
|
+
state,
|
|
434
|
+
manager: pick.t.manager,
|
|
435
|
+
path: state === "unreachable" && pick.t.manager !== "husky" ? pick.t.hookPath : pick.t.ownerFile,
|
|
436
|
+
...(state === "unreachable" ? { reason: pick.t.reason } : {}),
|
|
160
437
|
};
|
|
438
|
+
}
|
|
439
|
+
/** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
|
|
440
|
+
* `installed` (present where git will run it), `unreachable` (present, but
|
|
441
|
+
* after an exec/exit or in a manager-owned file git never reaches), or
|
|
442
|
+
* `missing`. postMerge requires BOTH halves (grounding-refresh and
|
|
443
|
+
* repair-provenance) — a repo carrying only one is a partial install, same as
|
|
444
|
+
* `installPostMergeHook` self-healing it. Never writes anything. */
|
|
445
|
+
export function hookReport(root) {
|
|
161
446
|
return {
|
|
162
|
-
postCommit:
|
|
163
|
-
preCommit:
|
|
164
|
-
postMerge:
|
|
447
|
+
postCommit: reportEntry(root, "post-commit", [MARK]),
|
|
448
|
+
preCommit: reportEntry(root, "pre-commit", [PRE_MARK]),
|
|
449
|
+
postMerge: reportEntry(root, "post-merge", [GROUNDING_MERGE_MARK, REPAIR_MERGE_MARK]),
|
|
450
|
+
postCheckout: reportEntry(root, "post-checkout", [CHECKOUT_MARK]),
|
|
165
451
|
};
|
|
166
452
|
}
|
|
453
|
+
/** Boolean view of `hookReport`: a hook counts as installed only when its
|
|
454
|
+
* managed block is present where git will actually run it, regardless of
|
|
455
|
+
* whether the invocation inside it happens to be stale. An unreachable block
|
|
456
|
+
* (issue #311) is NOT installed. */
|
|
457
|
+
export function hookStatus(root) {
|
|
458
|
+
const r = hookReport(root);
|
|
459
|
+
return {
|
|
460
|
+
postCommit: r.postCommit.state === "installed",
|
|
461
|
+
preCommit: r.preCommit.state === "installed",
|
|
462
|
+
postMerge: r.postMerge.state === "installed",
|
|
463
|
+
postCheckout: r.postCheckout.state === "installed",
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/** CLI lines for one install result: the usual ✓ line when the block is in a
|
|
467
|
+
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
468
|
+
* the manager's own file. */
|
|
469
|
+
export function formatHookInstall(root, label, h, detail = "") {
|
|
470
|
+
if (writes(h))
|
|
471
|
+
return [` ✓ ${label} ${h.action}${detail}`];
|
|
472
|
+
const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
|
|
473
|
+
return [
|
|
474
|
+
` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
|
|
475
|
+
` add this to ${shown} yourself:`,
|
|
476
|
+
...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
|
|
477
|
+
];
|
|
478
|
+
}
|
|
167
479
|
//# sourceMappingURL=hooks.js.map
|
|
@@ -83,6 +83,16 @@ Capture the decision for **$ARGUMENTS** into Hunch's graph.
|
|
|
83
83
|
5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose.
|
|
84
84
|
6. On CONFLICT for the topic, do NOT auto-supersede — Hunch refuses and presents both; let me choose supersede (link) / split the topic / discard.
|
|
85
85
|
`;
|
|
86
|
+
const WORKTREES_CMD = `---
|
|
87
|
+
description: Which worktrees and branches are open on which machine, what is merged and deletable — from Hunch's workspace ledger, not from git spelunking
|
|
88
|
+
---
|
|
89
|
+
Answer **$ARGUMENTS** (default: "what is open, and what can I delete?") from the workspace ledger.
|
|
90
|
+
|
|
91
|
+
1. Call \`hunch_workspaces(view: "branches")\` (and \`view: "inventory"\` for the worktree list). Do NOT run \`git branch\`, \`git worktree list\` or \`git log\` yourself — the tool already read this machine live and every other machine from memory.
|
|
92
|
+
2. Report the rows as they are: MACHINES, WORKTREE (dirty), UPSTREAM, MERGED (with its method) and the ACTION column. A verdict of \`unknown\` or a machine marked \`unverified\` is reported as such, never upgraded to a guess.
|
|
93
|
+
3. Recommend only what the ACTION column says. You never delete a branch or remove a worktree from this command; the human runs the printed git commands (or \`hunch workspaces prune\` when it ships) on the machine that holds them.
|
|
94
|
+
4. If a machine is missing or stale, say so: it has not run \`hunch workspaces snapshot\` (the post-checkout hook / MCP session start does this) or it is not sharing an overlay.
|
|
95
|
+
`;
|
|
86
96
|
const AUDIT_CMD = `---
|
|
87
97
|
description: Run an audit and record what it finds into Hunch as findings (observed gaps, no code change)
|
|
88
98
|
---
|
|
@@ -232,6 +242,7 @@ export function writeSlashCommands(root) {
|
|
|
232
242
|
["capture.md", CAPTURE_CMD],
|
|
233
243
|
["heal.md", HEAL_CMD],
|
|
234
244
|
["audit.md", AUDIT_CMD],
|
|
245
|
+
["worktrees.md", WORKTREES_CMD],
|
|
235
246
|
];
|
|
236
247
|
for (const [name, body] of files) {
|
|
237
248
|
const p = join(dir, name);
|