@davesheffer/hunch 1.39.2 → 1.39.3
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 +97 -50
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/core/correction.js +12 -7
- package/dist/core/docanchors.js +16 -2
- package/dist/core/glob.d.ts +19 -0
- package/dist/core/glob.js +37 -0
- package/dist/core/paths.d.ts +27 -5
- package/dist/core/paths.js +51 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.js +14 -2
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +17 -1
- package/dist/extractors/k8sManifest.js +104 -16
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +38 -7
- package/dist/integrations/hooks.js +250 -24
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +21 -13
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/hunchStore.js +41 -11
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpathSync } from "node:fs";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
19
20
|
import { hooksDir, gitCommonDir } from "../extractors/git.js";
|
|
20
21
|
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
21
22
|
import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
|
|
@@ -213,29 +214,222 @@ function preCommitId(mark) {
|
|
|
213
214
|
[CHECKOUT_MARK]: "hunch-post-checkout",
|
|
214
215
|
}[mark] ?? "hunch";
|
|
215
216
|
}
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*$/;
|
|
218
|
+
const UNQUOTED_META = /[;|&$`<>()]/;
|
|
219
|
+
const LAUNCHER_EXT = /\.(?:exe|cmd|bat|ps1)$/;
|
|
220
|
+
const HUNCH_ENTRY = /\/cli\/index\.(?:js|ts)$/;
|
|
221
|
+
/** Split an invocation into simple shell words. Hunch writes its own paths with
|
|
222
|
+
* JSON.stringify, so a double-quoted token decodes with JSON.parse. Anything a
|
|
223
|
+
* shell would treat as more than a plain command (metacharacters, expansions,
|
|
224
|
+
* an unterminated quote) yields null — we refuse to guess. */
|
|
225
|
+
function shellWords(s) {
|
|
226
|
+
const out = [];
|
|
227
|
+
let i = 0;
|
|
228
|
+
while (i < s.length) {
|
|
229
|
+
const c = s[i];
|
|
230
|
+
if (/\s/.test(c)) {
|
|
231
|
+
i++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (c === '"' || c === "'") {
|
|
235
|
+
const end = s.indexOf(c, i + 1);
|
|
236
|
+
if (end < 0)
|
|
237
|
+
return null;
|
|
238
|
+
const raw = s.slice(i, end + 1);
|
|
239
|
+
if (c === '"') {
|
|
240
|
+
if (/[$`]/.test(raw))
|
|
241
|
+
return null;
|
|
242
|
+
try {
|
|
243
|
+
out.push(JSON.parse(raw));
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
out.push(raw.slice(1, -1));
|
|
251
|
+
i = end + 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let j = i;
|
|
255
|
+
while (j < s.length && !/\s/.test(s[j]))
|
|
256
|
+
j++;
|
|
257
|
+
const word = s.slice(i, j);
|
|
258
|
+
if (UNQUOTED_META.test(word))
|
|
259
|
+
return null;
|
|
260
|
+
out.push(word);
|
|
261
|
+
i = j;
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
/** The launcher's own name: last path segment, lower-cased, without a Windows
|
|
266
|
+
* executable extension — so `/usr/local/bin/hunch`, `hunch.cmd` and `hunch` all match. */
|
|
267
|
+
function base(tok) {
|
|
268
|
+
return (tok.split(/[\\/]/).at(-1) ?? "").toLowerCase().replace(LAUNCHER_EXT, "");
|
|
269
|
+
}
|
|
270
|
+
function isAbs(tok) {
|
|
271
|
+
return isAbsolute(tok) || /^[A-Za-z]:[\\/]/.test(tok);
|
|
272
|
+
}
|
|
273
|
+
function isPathish(tok) {
|
|
274
|
+
return tok.includes("/") || tok.includes("\\") || isAbs(tok);
|
|
275
|
+
}
|
|
276
|
+
function pathExists(tok, root) {
|
|
277
|
+
// A hand-written hook may spell this machine's install as `~/…`; the shell
|
|
278
|
+
// expands it, so we must too before asking the filesystem.
|
|
279
|
+
const path = tok === "~" ? homedir() : tok.startsWith("~/") ? join(homedir(), tok.slice(2)) : tok;
|
|
280
|
+
return existsSync(isAbs(path) ? path : resolve(root, path));
|
|
281
|
+
}
|
|
282
|
+
const NOT_OURS = "not a command Hunch writes";
|
|
283
|
+
/** Whether the command in front of a managed block's subcommand is a Hunch
|
|
284
|
+
* launcher that could actually run (issue #315): a block whose command was
|
|
285
|
+
* hand-edited into something else, or whose absolute node/CLI path no longer
|
|
286
|
+
* exists after a reinstall, is present but dead. `command` is the text BEFORE
|
|
287
|
+
* the subcommand; env assignments and a leading `(` belong to the block's own
|
|
288
|
+
* shape and are stripped. Never spawns anything, never touches the network. */
|
|
289
|
+
export function hookInvocationHealth(command, root) {
|
|
290
|
+
let rest = command.trim().replace(/^\(\s*/, "");
|
|
291
|
+
for (;;) {
|
|
292
|
+
const m = /^(\S+)\s+/.exec(rest);
|
|
293
|
+
if (!m || !ENV_ASSIGN.test(m[1]))
|
|
294
|
+
break;
|
|
295
|
+
rest = rest.slice(m[0].length);
|
|
296
|
+
}
|
|
297
|
+
const invocation = rest.trim();
|
|
298
|
+
const bad = (reason) => ({ ok: false, invocation, reason });
|
|
299
|
+
const toks = shellWords(invocation);
|
|
300
|
+
if (toks === null || toks.length === 0)
|
|
301
|
+
return bad(NOT_OURS);
|
|
302
|
+
const [head, ...rest2] = toks;
|
|
303
|
+
const missing = (tok) => `${tok} does not exist`;
|
|
304
|
+
// `<runtime> <script…>`: every non-flag token after the runtime is a script
|
|
305
|
+
// path (`<node> <cli>`, or tsx's `<node> <tsx> <cli>`) — a runtime flag like
|
|
306
|
+
// `--no-warnings` is not a path, but a runtime with no script at all is not ours.
|
|
307
|
+
const runtime = () => {
|
|
308
|
+
if (isPathish(head) && !pathExists(head, root))
|
|
309
|
+
return bad(missing(head));
|
|
310
|
+
const scripts = rest2.filter((t) => !t.startsWith("-"));
|
|
311
|
+
if (scripts.length === 0)
|
|
312
|
+
return bad(NOT_OURS);
|
|
313
|
+
const gone = scripts.find((t) => !pathExists(t, root));
|
|
314
|
+
return gone === undefined ? { ok: true, invocation } : bad(missing(gone));
|
|
315
|
+
};
|
|
316
|
+
switch (base(head)) {
|
|
317
|
+
case "hunch":
|
|
318
|
+
if (rest2.length > 0)
|
|
319
|
+
return bad(NOT_OURS);
|
|
320
|
+
if (isPathish(head) && !pathExists(head, root))
|
|
321
|
+
return bad(missing(head));
|
|
322
|
+
return { ok: true, invocation };
|
|
323
|
+
case "node":
|
|
324
|
+
return rest2.length === 0 ? bad(NOT_OURS) : runtime();
|
|
325
|
+
case "npx":
|
|
326
|
+
case "pnpm":
|
|
327
|
+
case "yarn":
|
|
328
|
+
case "bunx":
|
|
329
|
+
case "npm":
|
|
330
|
+
case "env": {
|
|
331
|
+
// A package runner in front of the launcher: `npx -y --package=…@x.y.z hunch`
|
|
332
|
+
// (PORTABLE_HOOK_INVOCATION), `npx hunch`, and the shapes users adapt the
|
|
333
|
+
// snippet into by hand (`pnpm exec hunch`, `yarn run hunch`, `npm exec --
|
|
334
|
+
// hunch`, `env X=1 hunch`). What the runner would resolve is never verified —
|
|
335
|
+
// doing so would mean a network install — so only the launcher it ends in is read.
|
|
336
|
+
const args = rest2.filter((t) => !t.startsWith("-"));
|
|
337
|
+
const last = args.at(-1);
|
|
338
|
+
if (last !== undefined && (base(last) === "hunch" || /^@davesheffer\/hunch(?:@\S+)?$/.test(last)))
|
|
339
|
+
return { ok: true, invocation };
|
|
340
|
+
const tsx = args.indexOf("tsx");
|
|
341
|
+
if (tsx >= 0 && args.length === tsx + 2) {
|
|
342
|
+
return pathExists(args[tsx + 1], root) ? { ok: true, invocation } : bad(missing(args[tsx + 1]));
|
|
343
|
+
}
|
|
344
|
+
return bad(NOT_OURS);
|
|
345
|
+
}
|
|
346
|
+
default:
|
|
347
|
+
// `resolveInvocation` writes process.execPath, which is not always named
|
|
348
|
+
// `node` (Debian's `nodejs`, a version-suffixed binary). Any other runtime
|
|
349
|
+
// is ours only as an absolute path running Hunch's own CLI entry — else
|
|
350
|
+
// `hunch init` would rewrite the same block and doctor would call it stale forever.
|
|
351
|
+
return isAbs(head) && rest2.length > 0 && HUNCH_ENTRY.test(rest2.at(-1).replace(/\\/g, "/")) ? runtime() : bad(NOT_OURS);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** The command each managed block runs, keyed by mark. Resolved inside a
|
|
355
|
+
* function (like `preCommitId`) because the marks are declared further down. */
|
|
356
|
+
function blockSubcommand(mark) {
|
|
357
|
+
return {
|
|
358
|
+
[MARK]: { re: /(?:^|\s)sync\s+--from-hook\b/, words: "sync --from-hook" },
|
|
359
|
+
[PRE_MARK]: { re: /(?:^|\s)check\s+--staged\b/, words: "check --staged" },
|
|
360
|
+
[GROUNDING_MERGE_MARK]: { re: /(?:^|\s)grounding\s+--refresh\b/, words: "grounding --refresh" },
|
|
361
|
+
[REPAIR_MERGE_MARK]: { re: /(?:^|\s)repair-provenance\b/, words: "repair-provenance" },
|
|
362
|
+
[CHECKOUT_MARK]: { re: /(?:^|\s)workspaces\s+snapshot\b/, words: "workspaces snapshot" },
|
|
363
|
+
}[mark] ?? { re: /(?:^|\s)hunch\b/, words: "hunch" };
|
|
364
|
+
}
|
|
365
|
+
/** Hunch's OWN option flags a block's command line may carry after the
|
|
366
|
+
* subcommand — the ones `hunch init` decides from its options, so a regenerated
|
|
367
|
+
* block silently drops any the re-run does not ask for again. Fixed set, in a
|
|
368
|
+
* stable order; anything else on the line is not ours to report. */
|
|
369
|
+
const BLOCK_FLAGS = ["--private", "--commit", "--strict"];
|
|
370
|
+
function blockFlags(line) {
|
|
371
|
+
return BLOCK_FLAGS.filter((f) => new RegExp(`(?:^|\\s)${escapeRe(f)}(?=\\s|$)`).test(line));
|
|
372
|
+
}
|
|
373
|
+
/** Inspect the command inside `mark`'s block in `file`: the block body is the
|
|
374
|
+
* lines between the mark and the closing marker. A block may legitimately carry
|
|
375
|
+
* several command lines (or a hand-added one); any working line makes it live. */
|
|
376
|
+
function inspectBlock(file, mark, root) {
|
|
377
|
+
const { re, words } = blockSubcommand(mark);
|
|
378
|
+
const text = readText(file) ?? "";
|
|
379
|
+
const at = text.indexOf(mark);
|
|
380
|
+
const body = at < 0 ? [] : text.slice(at).replace(/\r/g, "").split("\n").slice(1);
|
|
381
|
+
let first = null;
|
|
382
|
+
for (const line of body) {
|
|
383
|
+
if (/^# <<< hunch /.test(line))
|
|
384
|
+
break;
|
|
385
|
+
if (line.trim().startsWith("#"))
|
|
386
|
+
continue;
|
|
387
|
+
const m = re.exec(line);
|
|
388
|
+
if (!m)
|
|
389
|
+
continue;
|
|
390
|
+
// Flags belong to the SUBCOMMAND, so they are read from the text after it.
|
|
391
|
+
const health = { ...hookInvocationHealth(line.slice(0, m.index), root), flags: blockFlags(line.slice(m.index)) };
|
|
392
|
+
if (health.ok)
|
|
393
|
+
return health;
|
|
394
|
+
first ??= health;
|
|
395
|
+
}
|
|
396
|
+
return first ?? { ok: false, invocation: "", reason: `the block has no \`hunch ${words}\` command`, flags: [] };
|
|
397
|
+
}
|
|
398
|
+
/** Whether our block for `mark` is present where git will actually run it AND
|
|
399
|
+
* its command is a Hunch launcher that exists (issue #315 — a marker alone
|
|
400
|
+
* proves nothing about what the block does). */
|
|
401
|
+
function blockInfo(t, mark, root) {
|
|
218
402
|
const inHook = markerState(t.hookPath, mark);
|
|
403
|
+
// A reachable marker only earns "installed" once its command checks out.
|
|
404
|
+
const live = (file) => {
|
|
405
|
+
const health = inspectBlock(file, mark, root);
|
|
406
|
+
return health.ok
|
|
407
|
+
? { state: "installed", invocation: health.invocation, flags: health.flags, file }
|
|
408
|
+
: { state: "stale", invocation: health.invocation, staleReason: health.reason, flags: health.flags, file };
|
|
409
|
+
};
|
|
219
410
|
switch (t.manager) {
|
|
220
411
|
case "none":
|
|
221
|
-
return inHook === "reachable" ?
|
|
412
|
+
return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
|
|
222
413
|
case "husky":
|
|
223
414
|
if (markerState(t.ownerFile, mark) === "reachable")
|
|
224
|
-
return
|
|
415
|
+
return live(t.ownerFile);
|
|
225
416
|
if (markerState(t.ownerFile, mark) === "unreachable" || inHook !== "absent")
|
|
226
|
-
return "unreachable";
|
|
227
|
-
return "missing";
|
|
417
|
+
return { state: "unreachable", file: t.hookPath };
|
|
418
|
+
return { state: "missing", file: t.ownerFile };
|
|
228
419
|
case "pre-commit": {
|
|
229
420
|
const config = readText(t.ownerFile);
|
|
421
|
+
// The framework's own config carries the command in its own format; the
|
|
422
|
+
// `id:` is the contract, so it is taken at face value.
|
|
230
423
|
if (config !== null && new RegExp(`\\bid:\\s*["']?${escapeRe(preCommitId(mark))}["']?\\s*$`, "m").test(config))
|
|
231
|
-
return "installed";
|
|
424
|
+
return { state: "installed", file: t.ownerFile };
|
|
232
425
|
// Migration mode: `pre-commit install` moved the previous hook to <hook>.legacy and runs it first.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
426
|
+
const legacy = `${t.hookPath}.legacy`;
|
|
427
|
+
if (markerState(legacy, mark) === "reachable")
|
|
428
|
+
return live(legacy);
|
|
429
|
+
return inHook !== "absent" ? { state: "unreachable", file: t.hookPath } : { state: "missing", file: t.ownerFile };
|
|
236
430
|
}
|
|
237
431
|
default: // husky-legacy, tracked-hooks-path, exec-exit: the hook file itself
|
|
238
|
-
return inHook === "reachable" ?
|
|
432
|
+
return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
|
|
239
433
|
}
|
|
240
434
|
}
|
|
241
435
|
function stripMarkers(blk) {
|
|
@@ -276,14 +470,21 @@ function snippetFor(t, mark, build, localInvocation) {
|
|
|
276
470
|
function installManagedBlock(root, hookName, mark, end, build, invocation) {
|
|
277
471
|
const t = resolveHookTarget(root, hookName, mark);
|
|
278
472
|
if (t.manager !== "none") {
|
|
279
|
-
|
|
473
|
+
const info = blockInfo(t, mark, root);
|
|
474
|
+
if (info.state === "installed")
|
|
280
475
|
return { path: t.ownerFile, action: "unchanged", manager: t.manager };
|
|
281
476
|
return {
|
|
282
477
|
path: t.ownerFile,
|
|
283
478
|
action: t.manager === "exec-exit" ? "unreachable" : "managed-elsewhere",
|
|
284
479
|
manager: t.manager,
|
|
285
|
-
|
|
480
|
+
// A stale block IS reachable — the manager's file already carries one, it
|
|
481
|
+
// just cannot run. Saying "a hook manager owns this hook" would send the
|
|
482
|
+
// user looking for a block that is right there (issue #315).
|
|
483
|
+
reason: info.state === "stale"
|
|
484
|
+
? `the Hunch block in ${basename(info.file)} is stale (${info.staleReason ?? "its command is not a Hunch launcher"}) — replace it with the snippet below`
|
|
485
|
+
: t.reason,
|
|
286
486
|
snippet: snippetFor(t, mark, build, invocation),
|
|
487
|
+
...(info.state === "stale" ? { stale: true } : {}),
|
|
287
488
|
};
|
|
288
489
|
}
|
|
289
490
|
const blk = build(invocation, GIT_CONTEXT);
|
|
@@ -422,23 +623,34 @@ export function installPostCheckoutHook(root, invocation) {
|
|
|
422
623
|
function reportEntry(root, hookName, marks) {
|
|
423
624
|
const states = marks.map((mark) => {
|
|
424
625
|
const t = resolveHookTarget(root, hookName, mark);
|
|
425
|
-
return { t,
|
|
626
|
+
return { t, ...blockInfo(t, mark, root) };
|
|
426
627
|
});
|
|
427
628
|
const unreachable = states.find((s) => s.state === "unreachable");
|
|
428
|
-
const
|
|
629
|
+
const stale = states.find((s) => s.state === "stale");
|
|
630
|
+
// A dead block outranks a stale one, which outranks a missing one: each is a
|
|
631
|
+
// stronger statement about why this hook is not doing its job.
|
|
632
|
+
const pick = unreachable ?? stale ?? states.find((s) => s.state === "missing") ?? states[0];
|
|
429
633
|
if (!pick)
|
|
430
634
|
throw new Error("reportEntry needs at least one marker");
|
|
431
|
-
const state = unreachable ? "unreachable" : states.every((s) => s.state === "installed") ? "installed" : "missing";
|
|
635
|
+
const state = unreachable ? "unreachable" : stale ? "stale" : states.every((s) => s.state === "installed") ? "installed" : "missing";
|
|
636
|
+
const invocation = [...new Set(states.map((s) => s.invocation).filter((i) => !!i))].join(" · ");
|
|
637
|
+
// Union across the hook's marks, in BLOCK_FLAGS order — a hook is re-installed
|
|
638
|
+
// as a whole, so a flag on any of its blocks is one a bare re-run would drop.
|
|
639
|
+
const flags = BLOCK_FLAGS.filter((f) => states.some((s) => s.flags?.includes(f)));
|
|
432
640
|
return {
|
|
433
641
|
state,
|
|
434
642
|
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 } : {}),
|
|
643
|
+
path: state === "unreachable" && pick.t.manager !== "husky" ? pick.t.hookPath : state === "stale" ? pick.file : pick.t.ownerFile,
|
|
644
|
+
...(state === "unreachable" ? { reason: pick.t.reason } : state === "stale" ? { reason: pick.staleReason } : {}),
|
|
645
|
+
...(invocation ? { invocation } : {}),
|
|
646
|
+
...(flags.length ? { flags } : {}),
|
|
437
647
|
};
|
|
438
648
|
}
|
|
439
649
|
/** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
|
|
440
|
-
* `installed` (present where git will run it
|
|
441
|
-
* after an exec/exit or in a
|
|
650
|
+
* `installed` (present where git will run it, running a Hunch launcher that
|
|
651
|
+
* exists), `unreachable` (present, but after an exec/exit or in a
|
|
652
|
+
* manager-owned file git never reaches), `stale` (present where git runs it,
|
|
653
|
+
* but its command is not a working Hunch launcher — issue #315), or
|
|
442
654
|
* `missing`. postMerge requires BOTH halves (grounding-refresh and
|
|
443
655
|
* repair-provenance) — a repo carrying only one is a partial install, same as
|
|
444
656
|
* `installPostMergeHook` self-healing it. Never writes anything. */
|
|
@@ -451,9 +663,9 @@ export function hookReport(root) {
|
|
|
451
663
|
};
|
|
452
664
|
}
|
|
453
665
|
/** Boolean view of `hookReport`: a hook counts as installed only when its
|
|
454
|
-
* managed block is present where git will actually run it
|
|
455
|
-
*
|
|
456
|
-
* (issue #
|
|
666
|
+
* managed block is present where git will actually run it AND the command
|
|
667
|
+
* inside it is a Hunch launcher that exists. An unreachable block (issue #311)
|
|
668
|
+
* and a stale one (issue #315) are both NOT installed. */
|
|
457
669
|
export function hookStatus(root) {
|
|
458
670
|
const r = hookReport(root);
|
|
459
671
|
return {
|
|
@@ -463,6 +675,18 @@ export function hookStatus(root) {
|
|
|
463
675
|
postCheckout: r.postCheckout.state === "installed",
|
|
464
676
|
};
|
|
465
677
|
}
|
|
678
|
+
/** Doctor lines for where each installed/stale hook points (issue #315): hooks
|
|
679
|
+
* sharing one invocation share one line, and a line whose invocation is not the
|
|
680
|
+
* Hunch currently running says so. Informational — a hook may legitimately
|
|
681
|
+
* point at a different install. Pure; the caller adds indentation and dim(). */
|
|
682
|
+
export function hookInvocationLines(report, running) {
|
|
683
|
+
const named = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
|
|
684
|
+
.filter(([, e]) => e.invocation && (e.state === "installed" || e.state === "stale"));
|
|
685
|
+
const grouped = new Map();
|
|
686
|
+
for (const [name, e] of named)
|
|
687
|
+
grouped.set(e.invocation, [...(grouped.get(e.invocation) ?? []), name]);
|
|
688
|
+
return [...grouped].map(([inv, names]) => `${names.join(", ")} → ${inv}${running && inv !== running ? ` (differs from the running Hunch: ${running})` : ""}`);
|
|
689
|
+
}
|
|
466
690
|
/** CLI lines for one install result: the usual ✓ line when the block is in a
|
|
467
691
|
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
468
692
|
* the manager's own file. */
|
|
@@ -472,7 +696,9 @@ export function formatHookInstall(root, label, h, detail = "") {
|
|
|
472
696
|
const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
|
|
473
697
|
return [
|
|
474
698
|
` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
|
|
475
|
-
|
|
699
|
+
// A stale block is already there and the reason said to replace it; anything
|
|
700
|
+
// else is missing and has to be added.
|
|
701
|
+
` ${h.stale ? "replace the Hunch block in" : "add this to"} ${shown} yourself:`,
|
|
476
702
|
...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
|
|
477
703
|
];
|
|
478
704
|
}
|
|
@@ -21,6 +21,7 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
|
21
21
|
import { writeFileAtomic } from "../core/io.js";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join, dirname } from "node:path";
|
|
24
|
+
import { isHunchHookCommand } from "./hookmatch.js";
|
|
24
25
|
import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
25
26
|
import { headFileContent, isGitCleanPath } from "../extractors/git.js";
|
|
26
27
|
import { parseJsonc } from "../core/jsonc.js";
|
|
@@ -106,23 +107,7 @@ function isHunchProviderHook(entry) {
|
|
|
106
107
|
// whose every nested hook is ours is ours; a mixed entry stays foreign.
|
|
107
108
|
if (e && Array.isArray(e.hooks) && e.hooks.length && typeof e.command !== "string")
|
|
108
109
|
return e.hooks.every(isHunchProviderHook);
|
|
109
|
-
|
|
110
|
-
// Anchored to the shapes hookCommand() writes — a Hunch launcher (the pinned
|
|
111
|
-
// npm package spec, or a …/index.js|ts path for source installs) plus a tail
|
|
112
|
-
// of `hook --provider <name>`, tokens quoted or bare. The old unanchored
|
|
113
|
-
// /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
|
|
114
|
-
// `node ./hook/index.js` as ours and silently deleted them, violating the
|
|
115
|
-
// leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41), so
|
|
116
|
-
// the bare tail must still carry --provider to match; only the LEGACY
|
|
117
|
-
// fully-quoted form (written before this quoting fix, and by hunch versions
|
|
118
|
-
// that predate --provider) may omit it, and its quotes keep it unambiguous.
|
|
119
|
-
// Source-checkout invocations generated by resolveInvocation point at the
|
|
120
|
-
// CLI entry specifically. A generic `other-tool/index.js hook` is foreign
|
|
121
|
-
// even when it happens to use Hunch's old command tail.
|
|
122
|
-
const launcher = /@davesheffer\/hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)(?=["\s]|$)/.test(command);
|
|
123
|
-
const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
|
|
124
|
-
const tail = /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command);
|
|
125
|
-
return launcher && (legacyTail || tail);
|
|
110
|
+
return isHunchHookCommand(typeof e?.command === "string" ? e.command : "", true);
|
|
126
111
|
}
|
|
127
112
|
/** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
|
|
128
113
|
* We replace only old Hunch commands and leave every foreign hook in place. */
|
|
@@ -19,7 +19,7 @@ export interface ClaudeHookInstall {
|
|
|
19
19
|
* - UserPromptSubmit → remind the agent to consult Hunch.
|
|
20
20
|
* Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
|
|
21
21
|
* at run time — so changing firmness needs no settings.json edit. We own only our
|
|
22
|
-
*
|
|
22
|
+
* commands (matched by isHunchHookCommand): other hooks and settings are preserved, and a
|
|
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;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { writeFileAtomic } from "../core/io.js";
|
|
8
8
|
import { join, dirname } from "node:path";
|
|
9
|
+
import { isHunchHookCommand } from "./hookmatch.js";
|
|
9
10
|
/** Merge a `hunch` server entry into .mcp.json, preserving other servers.
|
|
10
11
|
* A non-empty file we cannot parse THROWS instead of being silently replaced
|
|
11
12
|
* (con_8460b6770f — it may hold the user's other MCP servers); the caller
|
|
@@ -115,25 +116,27 @@ Reconcile decision-grounding drift for **$ARGUMENTS** (or the whole repo).
|
|
|
115
116
|
3. Only if I explicitly say "the DECISION is stale, not the doc" (Heal B): run /capture to record a superseding decision, then return to step 2 — the prose re-derives from the new decision as a separate confirm.
|
|
116
117
|
4. Report: healed (Heal A), superseded (Heal B), skipped. Never touch the graph except via an explicit Heal B capture.
|
|
117
118
|
`;
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
119
|
+
/** Strip Hunch's own commands out of one settings.json hook entry, matching with
|
|
120
|
+
* the SAME anchored rule the provider writers use (isHunchHookCommand, issue
|
|
121
|
+
* #41) so an unrelated tool that merely shares our layout — `node
|
|
122
|
+
* tools/lint/dist/cli/index.js hook` — is never classified as ours. Claude
|
|
123
|
+
* Code's hooks carry no `--provider`, hence the bare-tail variant.
|
|
124
|
+
*
|
|
125
|
+
* Filtering per COMMAND rather than per entry is what keeps a MIXED entry (our
|
|
126
|
+
* hook and the user's own command side by side) intact: the entry survives with
|
|
127
|
+
* its matcher and the user's remaining commands in order, and is dropped only
|
|
128
|
+
* when nothing of the user's is left. Dropping the whole entry deleted user
|
|
129
|
+
* hooks (con_8460b6770f, issue #310). */
|
|
130
|
+
function withoutHunchCommands(entry, hookCmd) {
|
|
131
|
+
const hooks = entry.hooks;
|
|
132
|
+
if (!Array.isArray(hooks))
|
|
133
|
+
return entry;
|
|
134
|
+
// The command being installed is ours by definition, whatever shape a future
|
|
135
|
+
// launcher takes — so a re-run stays idempotent even if the matcher lags it.
|
|
136
|
+
const kept = hooks.filter((h) => !(typeof h?.command === "string" && (h.command === hookCmd || isHunchHookCommand(h.command, false))));
|
|
137
|
+
if (kept.length === hooks.length)
|
|
138
|
+
return entry;
|
|
139
|
+
return kept.length ? { ...entry, hooks: kept } : null;
|
|
137
140
|
}
|
|
138
141
|
/**
|
|
139
142
|
* Install the Claude Code AGENT hooks into `.claude/settings.json` so the agent
|
|
@@ -143,7 +146,7 @@ function isHunchHook(entry) {
|
|
|
143
146
|
* - UserPromptSubmit → remind the agent to consult Hunch.
|
|
144
147
|
* Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
|
|
145
148
|
* at run time — so changing firmness needs no settings.json edit. We own only our
|
|
146
|
-
*
|
|
149
|
+
* commands (matched by isHunchHookCommand): other hooks and settings are preserved, and a
|
|
147
150
|
* non-empty file we cannot parse THROWS rather than clobbering the user's config.
|
|
148
151
|
*/
|
|
149
152
|
export function installClaudeHooks(root, hookCmd) {
|
|
@@ -176,7 +179,7 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
176
179
|
throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
|
|
177
180
|
}
|
|
178
181
|
}
|
|
179
|
-
const keep = (arr) => (Array.isArray(arr) ? arr.filter((e) =>
|
|
182
|
+
const keep = (arr) => (Array.isArray(arr) ? arr.map((entry) => withoutHunchCommands(entry, hookCmd)).filter((e) => e !== null) : []);
|
|
180
183
|
json.hooks.PreToolUse = [
|
|
181
184
|
...keep(json.hooks.PreToolUse),
|
|
182
185
|
{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
|
|
@@ -33,6 +33,16 @@ export type SnapshotOutcome = {
|
|
|
33
33
|
status: "collision";
|
|
34
34
|
record: Workspace;
|
|
35
35
|
reason: string;
|
|
36
|
+
}
|
|
37
|
+
/** A PUBLIC-home snapshot is a commit on the checked-out code branch. While git is replaying
|
|
38
|
+
* history (rebase / merge / cherry-pick / revert / bisect) or HEAD is detached, that write
|
|
39
|
+
* would land in the middle of the operation — an untracked `ws_*.json` that makes
|
|
40
|
+
* `git rebase --continue` abort, or a commit no branch owns. Nothing is lost: the next
|
|
41
|
+
* branch checkout or ledger read records this machine. */
|
|
42
|
+
| {
|
|
43
|
+
status: "deferred";
|
|
44
|
+
record: Workspace;
|
|
45
|
+
reason: "git-operation-in-progress" | "detached-head";
|
|
36
46
|
} | {
|
|
37
47
|
status: "written";
|
|
38
48
|
record: Workspace;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
13
|
import { createInterface } from "node:readline";
|
|
14
|
-
import { foreignRepoEnv, mainWorktreeRoot } from "../extractors/git.js";
|
|
14
|
+
import { foreignRepoEnv, gitHeadUnsettled, mainWorktreeRoot } from "../extractors/git.js";
|
|
15
15
|
import { hunchPaths } from "../core/paths.js";
|
|
16
16
|
import { readConfig, workspacesConfig } from "../core/config.js";
|
|
17
17
|
import { loadOrCreateMachine } from "../core/machine.js";
|
|
@@ -47,6 +47,14 @@ export function recordWorkspaceSnapshot(store, root, opts = {}) {
|
|
|
47
47
|
if (previous && Date.now() - Date.parse(previous.observed_at) < 86_400_000 && sameWorkspaceContent(previous, record)) {
|
|
48
48
|
return { status: "unchanged", record, previous };
|
|
49
49
|
}
|
|
50
|
+
// Checked as late as possible (the live snapshot above takes time, and a rebase can start or
|
|
51
|
+
// finish while it runs). A private overlay is its own repository, whose commits never touch
|
|
52
|
+
// the code branch, so only a PUBLIC home defers.
|
|
53
|
+
if (!isPrivate) {
|
|
54
|
+
const unsettled = gitHeadUnsettled(root);
|
|
55
|
+
if (unsettled)
|
|
56
|
+
return { status: "deferred", record, reason: unsettled };
|
|
57
|
+
}
|
|
50
58
|
try {
|
|
51
59
|
store.putCapture("workspaces", record, isPrivate);
|
|
52
60
|
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -7,7 +7,17 @@
|
|
|
7
7
|
* diagnostics go to stderr.
|
|
8
8
|
*/
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { HunchStore } from "../store/hunchStore.js";
|
|
11
|
+
import type { Symbol } from "../core/types.js";
|
|
10
12
|
export declare const publicationVocabulary: (hunchDir: string) => RegExp[];
|
|
13
|
+
/** Resolve a free-form target (symbol id / name / file path) to symbol records.
|
|
14
|
+
* Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
|
|
15
|
+
* shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
|
|
16
|
+
* apart on the same question. */
|
|
17
|
+
export declare function resolveSymbols(store: HunchStore, target: string): Symbol[];
|
|
18
|
+
/** Resolve a target to canonical indexed file path(s) (for file-granular blast
|
|
19
|
+
* radius). Falls back to the literal target so direct-scope checks still run. */
|
|
20
|
+
export declare function resolveFiles(store: HunchStore, target: string): string[];
|
|
11
21
|
export type RootControlledServer = {
|
|
12
22
|
server: McpServer;
|
|
13
23
|
getRoot: () => string;
|
package/dist/mcp/server.js
CHANGED
|
@@ -12,7 +12,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
13
|
import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
-
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
15
|
+
import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
|
|
16
|
+
import { isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
|
|
16
17
|
import { resolveMcpToolset } from "./toolset.js";
|
|
17
18
|
import { readConfig } from "../core/config.js";
|
|
18
19
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
@@ -454,23 +455,30 @@ function qualityNudge(rec) {
|
|
|
454
455
|
}
|
|
455
456
|
return "";
|
|
456
457
|
}
|
|
457
|
-
/** Resolve a free-form target (symbol id / name / file path) to symbol records.
|
|
458
|
-
|
|
459
|
-
|
|
458
|
+
/** Resolve a free-form target (symbol id / name / file path) to symbol records.
|
|
459
|
+
* Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
|
|
460
|
+
* shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
|
|
461
|
+
* apart on the same question. */
|
|
462
|
+
export function resolveSymbols(store, target) {
|
|
463
|
+
// An absolute target (an agent's edit-payload path, verbatim) never matched
|
|
464
|
+
// the repo-relative stored file paths below (issue #296).
|
|
465
|
+
target = repoRelativeTarget(target, store.publicRoot);
|
|
460
466
|
const syms = store.json.loadAll("symbols");
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
467
|
+
const components = store.json.loadAll("components");
|
|
468
|
+
// A target that IS a real indexed path (has a symbol, or is covered by an
|
|
469
|
+
// indexed component even with zero symbols — README.md, package.json, ...)
|
|
470
|
+
// never falls through to the suffix tier: bare `endsWith` matched unrelated
|
|
471
|
+
// files that merely end in the same characters — "db.ts" matched "mongodb.ts"
|
|
472
|
+
// (issue #300), and a same-basename file in another directory leaked its
|
|
473
|
+
// rules onto a real indexed target with no symbols of its own (issue #299).
|
|
474
|
+
const indexed = isIndexedPath(target, syms.map((s) => s.file), components.map((c) => c.paths));
|
|
475
|
+
return matchSymbolsTiered(target, syms, indexed);
|
|
468
476
|
}
|
|
469
477
|
/** Resolve a target to canonical indexed file path(s) (for file-granular blast
|
|
470
478
|
* radius). Falls back to the literal target so direct-scope checks still run. */
|
|
471
|
-
function resolveFiles(store, target) {
|
|
479
|
+
export function resolveFiles(store, target) {
|
|
472
480
|
const files = new Set(resolveSymbols(store, target).map((s) => s.file));
|
|
473
|
-
return files.size ? [...files] : [
|
|
481
|
+
return files.size ? [...files] : [repoRelativeTarget(target, store.publicRoot)];
|
|
474
482
|
}
|
|
475
483
|
function pullBackoff(status, finishedAt, failures) {
|
|
476
484
|
if (status === "updated" || status === "current") {
|
|
@@ -3,11 +3,25 @@
|
|
|
3
3
|
export declare const STALE_AFTER_MS = 60000;
|
|
4
4
|
/** Longest a writer waits before giving up and telling the caller. */
|
|
5
5
|
export declare const ACQUIRE_TIMEOUT_MS = 10000;
|
|
6
|
+
/** Longest a takeover claim may sit before another contender may clear it. The
|
|
7
|
+
* claimed section is a few syscalls, so anything this old means its claimer
|
|
8
|
+
* crashed inside it. */
|
|
9
|
+
export declare const CLAIM_STALE_MS = 10000;
|
|
6
10
|
export interface LockOwner {
|
|
7
11
|
pid: number;
|
|
8
12
|
host: string;
|
|
9
13
|
nonce: string;
|
|
10
14
|
at: string;
|
|
15
|
+
start?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Token seam: the caller's own process-instance token and a probe for another
|
|
18
|
+
* pid's, plus the platform the decision table should reason about (the own-pid
|
|
19
|
+
* fallback differs on linux, where pid namespaces exist). Injected by tests so
|
|
20
|
+
* the identity paths are exercised off linux. */
|
|
21
|
+
export interface StartTokenProbe {
|
|
22
|
+
self: string | null;
|
|
23
|
+
of: (pid: number) => string | null;
|
|
24
|
+
platform?: NodeJS.Platform;
|
|
11
25
|
}
|
|
12
26
|
export declare class WriteLockTimeout extends Error {
|
|
13
27
|
readonly path: string;
|
|
@@ -16,7 +30,13 @@ export declare class WriteLockTimeout extends Error {
|
|
|
16
30
|
constructor(path: string, heldBy: LockOwner | undefined, waitedMs: number);
|
|
17
31
|
}
|
|
18
32
|
export declare function writeLockPath(hunchDir: string): string;
|
|
19
|
-
/** Hold the partition's lock across `fn` (sync or async); released even if it throws.
|
|
33
|
+
/** Hold the partition's lock across `fn` (sync or async); released even if it throws.
|
|
34
|
+
*
|
|
35
|
+
* `opts.startToken` is an INTERNAL TEST SEAM, not API: it lets the test suite
|
|
36
|
+
* drive the process-identity rows of the decision table on a platform that has
|
|
37
|
+
* no such identity. Callers outside this repo must leave it unset — the real
|
|
38
|
+
* probe (`selfStartToken`/`startTokenOf`) is the only sound one. */
|
|
20
39
|
export declare function withWriteLock<T>(hunchDir: string, fn: () => T | Promise<T>, opts?: {
|
|
21
40
|
timeoutMs?: number;
|
|
41
|
+
startToken?: StartTokenProbe;
|
|
22
42
|
}): Promise<T>;
|