@davesheffer/hunch 1.39.0 → 1.39.2
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 +193 -65
- package/dist/cli/integrations.js +10 -0
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +55 -8
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- 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/correction.d.ts +10 -4
- package/dist/core/correction.js +7 -4
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReportEvidence.js +31 -11
- package/dist/core/topics.js +1 -1
- package/dist/core/types.d.ts +1 -0
- package/dist/core/workspace.d.ts +23 -1
- package/dist/core/workspace.js +31 -7
- 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/workspaces.d.ts +10 -0
- package/dist/extractors/workspaces.js +92 -15
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +61 -7
- package/dist/integrations/hooks.js +330 -43
- package/dist/integrations/scaffold.js +1 -1
- package/dist/integrations/workspaceLedger.d.ts +23 -3
- package/dist/integrations/workspaceLedger.js +114 -8
- package/dist/mcp/server.js +115 -56
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +111 -42
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
|
@@ -38,21 +52,243 @@ function block(invocation, opts = {}) {
|
|
|
38
52
|
function escapeRe(s) {
|
|
39
53
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40
54
|
}
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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) {
|
|
48
151
|
const dir = hooksDir(root);
|
|
49
152
|
// `git rev-parse --git-path hooks` returns a path relative to the repo in a
|
|
50
153
|
// normal checkout, but an ABSOLUTE one inside a linked worktree (the shared
|
|
51
154
|
// hooks dir). isAbsolute() handles both POSIX (/…) and Windows (C:\… / C:/…);
|
|
52
155
|
// a bare startsWith("/") misfired on Windows worktrees → a doubled junk path.
|
|
53
156
|
const abs = isAbsolute(dir) ? dir : join(root, dir);
|
|
54
|
-
mkdirSync(abs, { recursive: true });
|
|
55
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 });
|
|
56
292
|
if (!existsSync(hookPath)) {
|
|
57
293
|
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
58
294
|
chmodSync(hookPath, 0o755);
|
|
@@ -73,7 +309,7 @@ function installManagedBlock(root, hookName, mark, end, blk) {
|
|
|
73
309
|
return { path: hookPath, action: "appended" };
|
|
74
310
|
}
|
|
75
311
|
export function installPostCommitHook(root, invocation, opts = {}) {
|
|
76
|
-
return installManagedBlock(root, "post-commit", MARK, ENDMARK, block(
|
|
312
|
+
return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv) => block(inv, opts), invocation);
|
|
77
313
|
}
|
|
78
314
|
const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
|
|
79
315
|
const PRE_END = "# <<< hunch pre-commit <<<";
|
|
@@ -83,9 +319,11 @@ const PRE_END = "# <<< hunch pre-commit <<<";
|
|
|
83
319
|
* blocking invariant (see strictgate.ts), so it's safe on a shared repo.
|
|
84
320
|
* Preserves any existing pre-commit hook. */
|
|
85
321
|
export function installPreCommitHook(root, invocation, strict = false) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
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);
|
|
89
327
|
}
|
|
90
328
|
// Original marker, kept byte-for-byte for backward compat: an existing install's
|
|
91
329
|
// grounding-refresh block must still be found and updated in place by its own
|
|
@@ -126,8 +364,10 @@ function repairProvenanceMergeBlock(invocation) {
|
|
|
126
364
|
/** How significant a combined install result is, for picking one HookInstall
|
|
127
365
|
* action out of two independent sub-installs into the same file — "created"
|
|
128
366
|
* (the file itself is new) outranks "appended"/"updated" (an existing file
|
|
129
|
-
* changed), which outrank "unchanged".
|
|
130
|
-
|
|
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";
|
|
131
371
|
/** Install a post-merge hook carrying TWO independently-managed blocks:
|
|
132
372
|
* re-sync the committed grounding docs when a merge brought memory in behind
|
|
133
373
|
* them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
|
|
@@ -143,50 +383,97 @@ const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1 };
|
|
|
143
383
|
* a repo carrying only one half (an older install, or a hand-edited hook)
|
|
144
384
|
* gets the other appended rather than clobbered. */
|
|
145
385
|
export function installPostMergeHook(root, invocation) {
|
|
146
|
-
const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, groundingMergeBlock
|
|
147
|
-
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;
|
|
148
395
|
return ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
|
|
149
396
|
}
|
|
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
|
+
}
|
|
150
404
|
const CHECKOUT_MARK = "# >>> hunch post-checkout (workspace ledger) >>>";
|
|
151
405
|
const CHECKOUT_END = "# <<< hunch post-checkout (workspace ledger) <<<";
|
|
152
406
|
/** post-checkout is where branches and worktrees actually change (`git checkout`,
|
|
153
407
|
* `git switch`, `git worktree add`). git passes `$3 = 1` for a branch checkout and `0`
|
|
154
408
|
* for a file checkout; only the former can change the ledger. Constant argv (nothing
|
|
155
409
|
* from repository content), HUNCH_SYNC-guarded, backgrounded, offline. */
|
|
156
|
-
function checkoutBlock(invocation) {
|
|
410
|
+
function checkoutBlock(invocation, ctx = GIT_CONTEXT) {
|
|
157
411
|
return [
|
|
158
412
|
CHECKOUT_MARK,
|
|
159
|
-
|
|
413
|
+
`if [ -z "$HUNCH_SYNC" ] && [ "${ctx.checkoutType}" = "1" ]; then`,
|
|
160
414
|
` ( HUNCH_SYNC=1 ${invocation} workspaces snapshot --quiet >/dev/null 2>&1 || true ) &`,
|
|
161
415
|
"fi",
|
|
162
416
|
CHECKOUT_END,
|
|
163
417
|
].join("\n");
|
|
164
418
|
}
|
|
165
419
|
export function installPostCheckoutHook(root, invocation) {
|
|
166
|
-
return installManagedBlock(root, "post-checkout", CHECKOUT_MARK, CHECKOUT_END, checkoutBlock
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
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 } : {}),
|
|
184
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) {
|
|
185
446
|
return {
|
|
186
|
-
postCommit:
|
|
187
|
-
preCommit:
|
|
188
|
-
postMerge:
|
|
189
|
-
postCheckout:
|
|
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]),
|
|
190
451
|
};
|
|
191
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
|
+
}
|
|
192
479
|
//# sourceMappingURL=hooks.js.map
|
|
@@ -80,7 +80,7 @@ Capture the decision for **$ARGUMENTS** into Hunch's graph.
|
|
|
80
80
|
2. Run the GRILLING LOOP: one focused question at a time. Push back on hand-wavy answers. Resolve every branch before committing — an unexamined decision poisons the graph.
|
|
81
81
|
3. Confirm the TOPIC anchor with me before committing. One topic per decision; if it spans two, split into two captures.
|
|
82
82
|
4. Capture REJECTED alternatives explicitly (what, and why not) — this is what makes the decision enforceable (Veto/drift check against it).
|
|
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.
|
|
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. The token is not my signature: confirm the record in the client prompt if one appears; otherwise it stays agent testimony until I run the \`hunch review --confirm <id>\` command the response prints.
|
|
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
86
|
const WORKTREES_CMD = `---
|
|
@@ -58,16 +58,36 @@ export declare function renderBranchTable(view: LedgerView, rows: BranchRow[]):
|
|
|
58
58
|
* view stays fast); null when memory holds no workspace record. */
|
|
59
59
|
export declare function workspaceSummaryLine(records: readonly Workspace[], config: WorkspacesConfig, now?: Date): string | null;
|
|
60
60
|
export { branchRows, worktreeRows };
|
|
61
|
-
|
|
61
|
+
/** This machine's plan: the pure plan from the live record, then two live checks per step.
|
|
62
|
+
* A step `git branch -d` would refuse is moved to `skipped` BEFORE anything runs (removing
|
|
63
|
+
* the worktree and then failing the branch delete would half-apply the step), and a
|
|
64
|
+
* worktree's ignored files — which `git worktree remove` deletes without asking — are
|
|
65
|
+
* attached so the plan and the confirmation name them. */
|
|
66
|
+
export declare function prunePlanFor(view: LedgerView, root: string): PrunePlan;
|
|
67
|
+
/** Why `git branch -d -- <branch>` would refuse right now, or null when it would delete.
|
|
68
|
+
* Mirrors git's own rule (builtin/branch.c `branch_merged`): the branch head must be an
|
|
69
|
+
* ancestor of its upstream when one is configured AND resolves, otherwise of HEAD of the
|
|
70
|
+
* worktree the command runs in (the main worktree). Squash and rebase merges never pass it
|
|
71
|
+
* once the upstream is gone or unset, so such a step is reported, not half-applied. The
|
|
72
|
+
* branch name is matched in JS; only refs git printed and SHAs reach git as arguments. */
|
|
73
|
+
export declare function branchDeleteRefusal(root: string, step: Pick<PruneStep, "branch" | "head" | "method">): string | null;
|
|
62
74
|
export interface PruneResult {
|
|
63
75
|
step: PruneStep;
|
|
64
|
-
|
|
76
|
+
/** `skipped`: a live precondition failed before anything ran — the step is untouched. */
|
|
77
|
+
outcome: "deleted" | "skipped" | "failed";
|
|
65
78
|
detail: string;
|
|
66
79
|
}
|
|
67
80
|
/** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
|
|
68
81
|
* validated by the record schema and is passed after `--`; the worktree path comes from
|
|
69
|
-
* `git worktree list` on this machine.
|
|
82
|
+
* `git worktree list` on this machine. `git branch -d`'s own precondition is re-checked
|
|
83
|
+
* BEFORE the worktree is removed, so a step either runs whole or not at all. A failure
|
|
84
|
+
* stops that step, never the others. */
|
|
70
85
|
export declare function applyPrune(root: string, steps: readonly PruneStep[]): PruneResult[];
|
|
86
|
+
export declare function describeIgnored(ignored: NonNullable<PruneStep["ignored"]>): string;
|
|
87
|
+
/** The confirmation question for `prune --apply`: counts, and — because `git worktree
|
|
88
|
+
* remove` deletes ignored files (.env, build output) without asking — every worktree's
|
|
89
|
+
* ignored paths by name. */
|
|
90
|
+
export declare function pruneConfirmQuestion(view: LedgerView, plan: PrunePlan): string;
|
|
71
91
|
/** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
|
|
72
92
|
export declare function confirmPrune(question: string): Promise<boolean>;
|
|
73
93
|
export declare function renderPrunePlan(view: LedgerView, plan: PrunePlan): string;
|