@davesheffer/hunch 1.39.3 → 1.40.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 +61 -17
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +181 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +12 -6
- package/dist/core/glob.js +12 -6
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +21 -0
- package/dist/core/paths.js +39 -1
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +40 -4
- package/dist/core/taskReportRender.d.ts +3 -0
- package/dist/core/taskReportRender.js +22 -12
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/extractors/indexer.d.ts +5 -0
- package/dist/extractors/indexer.js +56 -11
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +103 -7
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.d.ts +3 -1
- package/dist/extractors/parse.js +12 -3
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hooks.d.ts +43 -4
- package/dist/integrations/hooks.js +309 -22
- package/dist/mcp/server.js +19 -11
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +74 -16
- package/dist/store/stateBinding.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -17,20 +17,25 @@ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpath
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
|
-
import { hooksDir, gitCommonDir } from "../extractors/git.js";
|
|
20
|
+
import { hooksDir, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
|
|
21
21
|
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
22
22
|
import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
|
|
23
23
|
const MARK = "# >>> hunch post-commit >>>";
|
|
24
24
|
const ENDMARK = "# <<< hunch post-commit <<<";
|
|
25
25
|
const GIT_CONTEXT = { checkoutType: "$3" };
|
|
26
26
|
const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE" };
|
|
27
|
-
|
|
27
|
+
const LOCAL_ONLY_LINE = " export HUNCH_SYNTH_PROVIDER=deterministic";
|
|
28
|
+
// Tolerates hand-added quotes: missing the line would let a re-run delete it.
|
|
29
|
+
const LOCAL_ONLY_RE = /^\s*export HUNCH_SYNTH_PROVIDER=["']?deterministic["']?\s*$/m;
|
|
30
|
+
/** How the provider line is named among a result's kept/added options. */
|
|
31
|
+
const LOCAL_ONLY_LABEL = "HUNCH_SYNTH_PROVIDER=deterministic";
|
|
32
|
+
function block(invocation, opts = {}, keep) {
|
|
28
33
|
// --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
|
|
29
34
|
// instead of the public repo. --commit (opt-in) also commits & pushes the repo the
|
|
30
35
|
// decision landed in (the private store under --private, else this repo). The hook
|
|
31
36
|
// script is local (.git/hooks/), never committed.
|
|
32
|
-
const priv = opts.private ? " --private" : "";
|
|
33
|
-
const commit = opts.commit ? " --commit" : "";
|
|
37
|
+
const priv = opts.private || keep?.flags.includes("--private") ? " --private" : "";
|
|
38
|
+
const commit = opts.commit || keep?.flags.includes("--commit") ? " --commit" : "";
|
|
34
39
|
return [
|
|
35
40
|
MARK,
|
|
36
41
|
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
@@ -38,7 +43,7 @@ function block(invocation, opts = {}) {
|
|
|
38
43
|
// A split-private capture must not make a storage-private promise and then
|
|
39
44
|
// ship the commit diff to a subscription CLI. Shared overlays are a separate
|
|
40
45
|
// team policy, so only the explicit local-only mode forces deterministic.
|
|
41
|
-
...(opts.localOnly ? [
|
|
46
|
+
...(opts.localOnly || keep?.localOnly ? [LOCAL_ONLY_LINE] : []),
|
|
42
47
|
` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
|
|
43
48
|
// Deliberately NO workspace-ledger snapshot here (docs/workspace-ledger.md): a commit
|
|
44
49
|
// changes HEAD, not which branches and worktrees exist — post-checkout covers that, and
|
|
@@ -370,6 +375,41 @@ const BLOCK_FLAGS = ["--private", "--commit", "--strict"];
|
|
|
370
375
|
function blockFlags(line) {
|
|
371
376
|
return BLOCK_FLAGS.filter((f) => new RegExp(`(?:^|\\s)${escapeRe(f)}(?=\\s|$)`).test(line));
|
|
372
377
|
}
|
|
378
|
+
/** The flags a freshly BUILT block carries, read the way `inspectBlock` reads an
|
|
379
|
+
* installed one: per command line, only from the text after the subcommand. A
|
|
380
|
+
* whole-block scan would count a flag that is merely part of the launcher path
|
|
381
|
+
* (a Hunch installed under `…/opt --strict x/…`), and so miss a real downgrade.
|
|
382
|
+
*
|
|
383
|
+
* With `liveRoot`, only lines whose launcher works are read — for an INSTALLED
|
|
384
|
+
* block's text, where a dead hand-kept line's `--commit` was never in effect
|
|
385
|
+
* and carrying it into a rebuild would switch it on. */
|
|
386
|
+
function builtFlags(blk, mark, liveRoot) {
|
|
387
|
+
const { re } = blockSubcommand(mark);
|
|
388
|
+
const found = new Set();
|
|
389
|
+
for (const line of blk.replace(/\r/g, "").split("\n")) {
|
|
390
|
+
if (line.trim().startsWith("#"))
|
|
391
|
+
continue;
|
|
392
|
+
const m = re.exec(line);
|
|
393
|
+
if (!m)
|
|
394
|
+
continue;
|
|
395
|
+
if (liveRoot !== undefined && !hookInvocationHealth(line.slice(0, m.index), liveRoot).ok)
|
|
396
|
+
continue;
|
|
397
|
+
for (const f of blockFlags(line.slice(m.index)))
|
|
398
|
+
found.add(f);
|
|
399
|
+
}
|
|
400
|
+
return BLOCK_FLAGS.filter((f) => found.has(f));
|
|
401
|
+
}
|
|
402
|
+
/** The text of `mark`'s block in `cur` (which must contain `mark`): through its
|
|
403
|
+
* closing marker, or — when that is missing — up to the next Hunch marker line,
|
|
404
|
+
* so another block's command further down is never read as this block's. */
|
|
405
|
+
function ownBlockText(cur, mark, blockRe) {
|
|
406
|
+
const m = blockRe.exec(cur);
|
|
407
|
+
if (m)
|
|
408
|
+
return m[0];
|
|
409
|
+
const lines = cur.slice(cur.indexOf(mark)).split("\n");
|
|
410
|
+
const stop = lines.findIndex((l, i) => i > 0 && /^# (?:<<<|>>>) hunch /.test(l));
|
|
411
|
+
return (stop < 0 ? lines : lines.slice(0, stop)).join("\n");
|
|
412
|
+
}
|
|
373
413
|
/** Inspect the command inside `mark`'s block in `file`: the block body is the
|
|
374
414
|
* lines between the mark and the closing marker. A block may legitimately carry
|
|
375
415
|
* several command lines (or a hand-added one); any working line makes it live. */
|
|
@@ -459,6 +499,107 @@ function snippetFor(t, mark, build, localInvocation) {
|
|
|
459
499
|
}
|
|
460
500
|
return build(PORTABLE_HOOK_INVOCATION, GIT_CONTEXT);
|
|
461
501
|
}
|
|
502
|
+
/** The repo's worktree tops as `git worktree list --porcelain` reports them:
|
|
503
|
+
* `linked` is every entry AFTER the first (the first is always the main
|
|
504
|
+
* worktree, or the bare repo) that is not the bare entry, and `count` is how
|
|
505
|
+
* many non-bare worktrees the repo has — the number the shared-hooks note
|
|
506
|
+
* quotes, which must not include a bare repo's phantom entry. Entries are
|
|
507
|
+
* separated by blank lines and each starts with a `worktree <path>` line.
|
|
508
|
+
* Read straight from git rather than the workspace ledger: this runs during
|
|
509
|
+
* `hunch init`, before any ledger exists. */
|
|
510
|
+
function worktreeTops(root) {
|
|
511
|
+
const out = gitRun(["worktree", "list", "--porcelain"], root).stdout;
|
|
512
|
+
if (!out)
|
|
513
|
+
return { linked: [], count: 0 };
|
|
514
|
+
const entries = out.replace(/\r/g, "").split(/\n\s*\n/).map((e) => e.split("\n"));
|
|
515
|
+
const parsed = entries
|
|
516
|
+
.map((lines) => ({
|
|
517
|
+
path: lines.find((l) => l.startsWith("worktree "))?.slice("worktree ".length) ?? "",
|
|
518
|
+
bare: lines.some((l) => l.trim() === "bare"),
|
|
519
|
+
}))
|
|
520
|
+
.filter((e) => e.path !== "");
|
|
521
|
+
return { linked: parsed.slice(1).filter((e) => !e.bare).map((e) => e.path), count: parsed.filter((e) => !e.bare).length };
|
|
522
|
+
}
|
|
523
|
+
/** The LINKED worktree an invocation is bound to — one it can only work from
|
|
524
|
+
* inside, because some token is an absolute path under it (that worktree's own
|
|
525
|
+
* node_modules install, its own source checkout) — or null.
|
|
526
|
+
*
|
|
527
|
+
* "Inside" counts textually OR physically: a node_modules symlinked out of a
|
|
528
|
+
* worktree still dies with `git worktree remove`, and a path spelled through
|
|
529
|
+
* the main checkout that realpaths into a worktree is just as bound. A
|
|
530
|
+
* RELATIVE path token is bound to nothing: the shell resolves it against the
|
|
531
|
+
* hook's own cwd at run time, which is whichever worktree git is running in,
|
|
532
|
+
* so it stays correct for all of them. */
|
|
533
|
+
function worktreeBound(invocation, tops) {
|
|
534
|
+
const toks = shellWords(invocation);
|
|
535
|
+
if (toks === null)
|
|
536
|
+
return null;
|
|
537
|
+
const norm = (p) => (process.platform === "win32" ? resolve(p).toLowerCase() : resolve(p));
|
|
538
|
+
const textuallyInside = (top, tok) => {
|
|
539
|
+
const rel = relative(norm(top), norm(tok));
|
|
540
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
541
|
+
};
|
|
542
|
+
for (const top of tops) {
|
|
543
|
+
if (toks.some((t) => isAbs(t) && (textuallyInside(top, t) || isInside(top, t))))
|
|
544
|
+
return top;
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
/** The (hookName, mark) pairs of every managed block Hunch writes — the sibling
|
|
549
|
+
* blocks that share one hooks dir, scanned for a working shared launcher.
|
|
550
|
+
* Resolved inside a function (like `preCommitId`) because the marks are
|
|
551
|
+
* declared further down. */
|
|
552
|
+
function managedBlocks() {
|
|
553
|
+
return [
|
|
554
|
+
["post-commit", MARK],
|
|
555
|
+
["pre-commit", PRE_MARK],
|
|
556
|
+
["post-merge", GROUNDING_MERGE_MARK],
|
|
557
|
+
["post-merge", REPAIR_MERGE_MARK],
|
|
558
|
+
["post-checkout", CHECKOUT_MARK],
|
|
559
|
+
];
|
|
560
|
+
}
|
|
561
|
+
/** The repo's existing SHARED invocation to install instead of `invocation`, or
|
|
562
|
+
* null to go on as normal (issue #316).
|
|
563
|
+
*
|
|
564
|
+
* Linked worktrees share ONE `<git-common-dir>/hooks` directory. Rebuilding a
|
|
565
|
+
* block from a path inside a linked worktree therefore re-points the main
|
|
566
|
+
* checkout's and every sibling worktree's hooks at code that disappears with
|
|
567
|
+
* `git worktree remove` — leaving all of them dead. So when the repo already
|
|
568
|
+
* runs a Hunch bound to NO linked worktree, that invocation wins. The gate is
|
|
569
|
+
* the caller's invocation, not `root`: an install run from the MAIN checkout
|
|
570
|
+
* with a worktree-bound launcher poisons the shared hooks exactly the same way.
|
|
571
|
+
*
|
|
572
|
+
* `own: true` means the block for THIS mark already carries it (the caller must
|
|
573
|
+
* also preserve the flags it carries). Otherwise the answer came from a sibling
|
|
574
|
+
* block: `hunch index` self-heals a missing post-merge/post-checkout hook and
|
|
575
|
+
* agents run it from worktrees constantly — a missing block must not become the
|
|
576
|
+
* way a worktree path gets into the shared hooks while the repo already has a
|
|
577
|
+
* working shared launcher. */
|
|
578
|
+
function sharedInvocationFor(root, mark, invocation, own) {
|
|
579
|
+
const tops = worktreeTops(root).linked;
|
|
580
|
+
if (tops.length === 0 || !worktreeBound(invocation, tops))
|
|
581
|
+
return null;
|
|
582
|
+
if (own.state === "installed") {
|
|
583
|
+
// A bound invocation always differs from an unbound one, so no separate
|
|
584
|
+
// "did it change?" test is needed to avoid a pointless rewrite.
|
|
585
|
+
return own.invocation && !worktreeBound(own.invocation, tops)
|
|
586
|
+
? { invocation: own.invocation, own: true, flags: own.flags ?? [] }
|
|
587
|
+
: null;
|
|
588
|
+
}
|
|
589
|
+
for (const [hookName, m] of managedBlocks()) {
|
|
590
|
+
if (m === mark)
|
|
591
|
+
continue;
|
|
592
|
+
const info = blockInfo(resolveHookTarget(root, hookName, m), m, root);
|
|
593
|
+
if (info.state === "installed" && info.invocation && !worktreeBound(info.invocation, tops)) {
|
|
594
|
+
// A rebuilt block gets its options from the CALLER, so `own.flags` are
|
|
595
|
+
// the ones the old block carried that this run may be dropping — reported
|
|
596
|
+
// (droppedFlags), never restored: restoring one would fabricate a request
|
|
597
|
+
// nobody made.
|
|
598
|
+
return { invocation: info.invocation, own: false, flags: own.flags ?? [] };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
462
603
|
/** Shared idempotent create/append/update-in-place logic for every hunch git
|
|
463
604
|
* hook: write a fresh hook file, replace our own managed block in place if the
|
|
464
605
|
* invocation changed, or append after any pre-existing (non-hunch) hook body
|
|
@@ -487,30 +628,91 @@ function installManagedBlock(root, hookName, mark, end, build, invocation) {
|
|
|
487
628
|
...(info.state === "stale" ? { stale: true } : {}),
|
|
488
629
|
};
|
|
489
630
|
}
|
|
490
|
-
|
|
631
|
+
// Issue #316: the hooks dir is shared with every linked worktree, so prefer
|
|
632
|
+
// the repo's existing unbound command over a caller path that dies with a
|
|
633
|
+
// worktree. The invocation text comes from blockInfo, i.e. straight out of the block's
|
|
634
|
+
// command line with its quoting intact, and inspectBlock only calls a block
|
|
635
|
+
// healthy when shellWords parsed it into plain words with no shell
|
|
636
|
+
// metacharacters — so re-embedding that exact text in build() is safe.
|
|
637
|
+
const info = blockInfo(t, mark, root);
|
|
638
|
+
const shared = sharedInvocationFor(root, mark, invocation, info);
|
|
639
|
+
const blk = build(shared?.invocation ?? invocation, GIT_CONTEXT);
|
|
640
|
+
// A dead own block rebuilt from a SIBLING's launcher keeps neither its command
|
|
641
|
+
// nor its options; say which options went, so a downgraded strict guard is not
|
|
642
|
+
// the user's to discover later.
|
|
643
|
+
// The local-only provider line is such an option too: a privacy promise that
|
|
644
|
+
// goes must be named, exactly like a flag.
|
|
491
645
|
const hookPath = t.hookPath;
|
|
646
|
+
const cur = existsSync(hookPath) ? readFileSync(hookPath, "utf8") : null;
|
|
647
|
+
const blockRe = new RegExp(`${escapeRe(mark)}[\\s\\S]*?${escapeRe(end)}`);
|
|
648
|
+
const own = cur?.includes(mark) ? ownBlockText(cur, mark, blockRe) : "";
|
|
649
|
+
const lost = shared && !shared.own
|
|
650
|
+
? [...shared.flags.filter((f) => !builtFlags(blk, mark).includes(f)), ...(LOCAL_ONLY_RE.test(own) && !LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])]
|
|
651
|
+
: [];
|
|
652
|
+
const sharedNote = shared ? { sharedInvocation: shared.invocation, ...(lost.length > 0 ? { droppedFlags: lost } : {}) } : {};
|
|
492
653
|
mkdirSync(dirname(hookPath), { recursive: true });
|
|
493
|
-
if (
|
|
654
|
+
if (cur === null) {
|
|
494
655
|
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
495
656
|
chmodSync(hookPath, 0o755);
|
|
496
|
-
return { path: hookPath, action: "created" };
|
|
657
|
+
return { path: hookPath, action: "created", ...sharedNote };
|
|
497
658
|
}
|
|
498
|
-
const cur = readFileSync(hookPath, "utf8");
|
|
499
659
|
if (cur.includes(mark)) {
|
|
500
|
-
const
|
|
660
|
+
const closed = blockRe.test(cur);
|
|
661
|
+
let next = blk;
|
|
662
|
+
let note = sharedNote;
|
|
663
|
+
if (shared?.own) {
|
|
664
|
+
// A worktree-local re-run may ADD options to the shared block, never
|
|
665
|
+
// remove them or re-point it: an advisory `hunch init` from a worktree
|
|
666
|
+
// must not silently downgrade the repo's strict pre-commit guard. So the
|
|
667
|
+
// block is rebuilt as the UNION of what it carries and what this run asks
|
|
668
|
+
// for. Refusing the whole write over one omitted option would also discard
|
|
669
|
+
// the one the run DID ask for — a `hunch private` whose --private never
|
|
670
|
+
// lands keeps capturing into the public store behind a ✓ line.
|
|
671
|
+
//
|
|
672
|
+
// What the block carries is read from ALL its WORKING command lines (the
|
|
673
|
+
// options of a hand-added second line are merged in — the line itself is
|
|
674
|
+
// not kept: the block is rebuilt from the template), and the provider line
|
|
675
|
+
// is an option like any flag: a privacy promise is not a re-run's to withdraw.
|
|
676
|
+
const carried = builtFlags(own, mark, root);
|
|
677
|
+
const asked = builtFlags(blk, mark);
|
|
678
|
+
const keepsLocalOnly = LOCAL_ONLY_RE.test(own);
|
|
679
|
+
const kept = [...carried.filter((f) => !asked.includes(f)), ...(keepsLocalOnly && !LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])];
|
|
680
|
+
const added = [...asked.filter((f) => !carried.includes(f)), ...(!keepsLocalOnly && LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])];
|
|
681
|
+
const keptNote = { ...sharedNote, ...(kept.length > 0 ? { keptFlags: kept } : {}) };
|
|
682
|
+
// Nothing to add: the block stays byte-identical. Rebuilding it anyway would
|
|
683
|
+
// let a re-run that asked for nothing rewrite a line someone shaped by hand
|
|
684
|
+
// (a `--strict || true` softened guard would come back blocking).
|
|
685
|
+
if (added.length === 0)
|
|
686
|
+
return { path: hookPath, action: "kept-shared", ...keptNote };
|
|
687
|
+
next = build(shared.invocation, GIT_CONTEXT, { flags: carried, localOnly: keepsLocalOnly });
|
|
688
|
+
note = { ...keptNote, addedFlags: added };
|
|
689
|
+
}
|
|
690
|
+
// No closing marker: there is nothing to replace in place, and saying
|
|
691
|
+
// "unchanged" (or "kept") would hide that this run's block never landed.
|
|
692
|
+
if (!closed) {
|
|
693
|
+
return {
|
|
694
|
+
path: hookPath,
|
|
695
|
+
action: "unreachable",
|
|
696
|
+
reason: `the Hunch block in ${basename(hookPath)} has no closing \`${end}\` line, so it cannot be updated in place — replace it with the snippet below`,
|
|
697
|
+
snippet: next,
|
|
698
|
+
stale: true,
|
|
699
|
+
live: true,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
const updated = cur.replace(blockRe, () => next);
|
|
501
703
|
if (updated === cur)
|
|
502
|
-
return { path: hookPath, action: "unchanged" };
|
|
704
|
+
return { path: hookPath, action: "unchanged", ...sharedNote };
|
|
503
705
|
writeFileSync(hookPath, updated);
|
|
504
706
|
chmodSync(hookPath, 0o755);
|
|
505
|
-
return { path: hookPath, action: "updated" };
|
|
707
|
+
return { path: hookPath, action: "updated", ...note };
|
|
506
708
|
}
|
|
507
709
|
const appended = cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`;
|
|
508
710
|
writeFileSync(hookPath, appended);
|
|
509
711
|
chmodSync(hookPath, 0o755);
|
|
510
|
-
return { path: hookPath, action: "appended" };
|
|
712
|
+
return { path: hookPath, action: "appended", ...sharedNote };
|
|
511
713
|
}
|
|
512
714
|
export function installPostCommitHook(root, invocation, opts = {}) {
|
|
513
|
-
return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv) => block(inv, opts), invocation);
|
|
715
|
+
return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv, _ctx, keep) => block(inv, opts, keep), invocation);
|
|
514
716
|
}
|
|
515
717
|
const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
|
|
516
718
|
const PRE_END = "# <<< hunch pre-commit <<<";
|
|
@@ -520,9 +722,10 @@ const PRE_END = "# <<< hunch pre-commit <<<";
|
|
|
520
722
|
* blocking invariant (see strictgate.ts), so it's safe on a shared repo.
|
|
521
723
|
* Preserves any existing pre-commit hook. */
|
|
522
724
|
export function installPreCommitHook(root, invocation, strict = false) {
|
|
523
|
-
const build = (inv) => {
|
|
524
|
-
const
|
|
525
|
-
|
|
725
|
+
const build = (inv, _ctx, keep) => {
|
|
726
|
+
const on = strict || keep?.flags.includes("--strict");
|
|
727
|
+
const cmd = `${inv} check --staged${on ? " --strict" : ""}`;
|
|
728
|
+
return [PRE_MARK, on ? cmd : `${cmd} || true`, PRE_END].join("\n");
|
|
526
729
|
};
|
|
527
730
|
return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
|
|
528
731
|
}
|
|
@@ -567,7 +770,7 @@ function repairProvenanceMergeBlock(invocation) {
|
|
|
567
770
|
* (the file itself is new) outranks "appended"/"updated" (an existing file
|
|
568
771
|
* changed), which outrank "unchanged". Non-writing results are handled before
|
|
569
772
|
* ranking (they must never be masked by a sibling's success). */
|
|
570
|
-
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "managed-elsewhere": 0, unreachable: 0 };
|
|
773
|
+
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0 };
|
|
571
774
|
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
|
|
572
775
|
/** Install a post-merge hook carrying TWO independently-managed blocks:
|
|
573
776
|
* re-sync the committed grounding docs when a merge brought memory in behind
|
|
@@ -593,7 +796,17 @@ export function installPostMergeHook(root, invocation) {
|
|
|
593
796
|
return grounding;
|
|
594
797
|
if (!writes(repair))
|
|
595
798
|
return repair;
|
|
596
|
-
|
|
799
|
+
const picked = ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
|
|
800
|
+
const other = picked === repair ? grounding : repair;
|
|
801
|
+
// Either half may be the one that kept the repo's shared invocation (issue
|
|
802
|
+
// #316); the ranking above can pick the other one. Carry the shared-hook facts
|
|
803
|
+
// over so the CLI line stays true about what the file now runs.
|
|
804
|
+
return {
|
|
805
|
+
...picked,
|
|
806
|
+
...(picked.sharedInvocation ?? other.sharedInvocation ? { sharedInvocation: picked.sharedInvocation ?? other.sharedInvocation } : {}),
|
|
807
|
+
...(picked.keptFlags ?? other.keptFlags ? { keptFlags: picked.keptFlags ?? other.keptFlags } : {}),
|
|
808
|
+
...(picked.droppedFlags ?? other.droppedFlags ? { droppedFlags: picked.droppedFlags ?? other.droppedFlags } : {}),
|
|
809
|
+
};
|
|
597
810
|
}
|
|
598
811
|
/** When two snippets for the same file are joined, drop the second's leading
|
|
599
812
|
* instruction comment (the first already says where it goes). */
|
|
@@ -687,19 +900,93 @@ export function hookInvocationLines(report, running) {
|
|
|
687
900
|
grouped.set(e.invocation, [...(grouped.get(e.invocation) ?? []), name]);
|
|
688
901
|
return [...grouped].map(([inv, names]) => `${names.join(", ")} → ${inv}${running && inv !== running ? ` (differs from the running Hunch: ${running})` : ""}`);
|
|
689
902
|
}
|
|
903
|
+
const KEPT_HOW = " — to change its options, re-run `hunch init` with a Hunch that is not inside a linked worktree (a global install, or the main checkout's)";
|
|
690
904
|
/** CLI lines for one install result: the usual ✓ line when the block is in a
|
|
691
905
|
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
692
906
|
* the manager's own file. */
|
|
693
907
|
export function formatHookInstall(root, label, h, detail = "") {
|
|
694
|
-
|
|
695
|
-
|
|
908
|
+
// kept-shared deliberately drops `detail`: it describes the options this run
|
|
909
|
+
// ASKED for, and a run that asked for FEWER than the shared block carries (an
|
|
910
|
+
// advisory re-run of a strict guard) would describe a block that is not there
|
|
911
|
+
// (issue #316).
|
|
912
|
+
if (h.action === "kept-shared") {
|
|
913
|
+
const flags = h.keptFlags?.length ? ` with ${h.keptFlags.join(" ")}` : "";
|
|
914
|
+
// The block kept options this run did not ask for, so the way to remove them
|
|
915
|
+
// is the one thing worth saying next.
|
|
916
|
+
const how = h.keptFlags?.length ? KEPT_HOW : "";
|
|
917
|
+
return [` ✓ ${label} kept — the shared hook keeps running ${h.sharedInvocation}${flags} (not re-pointed at this worktree)${how}`];
|
|
918
|
+
}
|
|
919
|
+
if (writes(h)) {
|
|
920
|
+
// A union write (issue #316). Once an option was KEPT, `detail` — which
|
|
921
|
+
// describes only what this run asked for — is no longer the truth about the
|
|
922
|
+
// block: an "advisory" detail beside a kept --strict contradicts itself.
|
|
923
|
+
const opts = [h.addedFlags?.length ? `added ${h.addedFlags.join(" ")}` : "", h.keptFlags?.length ? `kept its existing ${h.keptFlags.join(" ")}` : ""].filter(Boolean).join(", ");
|
|
924
|
+
const union = opts ? ` — ${opts} (a worktree re-run adds options, never removes them)${h.keptFlags?.length ? KEPT_HOW : ""}` : "";
|
|
925
|
+
const said = h.keptFlags?.length ? "" : detail;
|
|
926
|
+
const lost = h.droppedFlags?.length ? ` — the previous block's ${h.droppedFlags.join(" ")} was not carried over; re-run with the option to restore it` : "";
|
|
927
|
+
if (h.sharedInvocation)
|
|
928
|
+
return [` ✓ ${label} ${h.action}${said} — runs the repo's shared Hunch (${h.sharedInvocation}), not this worktree's${union}${lost}`];
|
|
929
|
+
return [` ✓ ${label} ${h.action}${detail}${lost}`];
|
|
930
|
+
}
|
|
696
931
|
const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
|
|
697
932
|
return [
|
|
698
|
-
` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
|
|
933
|
+
` ⚠ ${label} NOT ${h.live ? "updated" : "installed"} — ${h.reason ?? "a hook manager owns this hook"}`,
|
|
699
934
|
// A stale block is already there and the reason said to replace it; anything
|
|
700
935
|
// else is missing and has to be added.
|
|
701
936
|
` ${h.stale ? "replace the Hunch block in" : "add this to"} ${shown} yourself:`,
|
|
702
937
|
...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
|
|
703
938
|
];
|
|
704
939
|
}
|
|
940
|
+
/** The closing line `hunch init` prints about the SHARED hooks dir (issue #316).
|
|
941
|
+
* Setup used to claim "sharing the repo's hooks + memory" unconditionally —
|
|
942
|
+
* true of memory, but the hooks dir is genuinely shared, so a block pointing
|
|
943
|
+
* inside a linked worktree dies for every checkout when that worktree goes.
|
|
944
|
+
*
|
|
945
|
+
* The verdict is read from what the shared blocks ACTUALLY run, not from this
|
|
946
|
+
* run's actions: a second `hunch init` writes nothing yet the hooks are just as
|
|
947
|
+
* broken, and the main checkout — which never used to get a note at all — is
|
|
948
|
+
* where the user can most easily fix it. `installs` only decides between the
|
|
949
|
+
* healthy variants. `[]` when the repo has no linked worktree. Read-only. */
|
|
950
|
+
export function sharedHooksNote(root, installs) {
|
|
951
|
+
const { linked, count } = worktreeTops(root);
|
|
952
|
+
if (linked.length === 0)
|
|
953
|
+
return [];
|
|
954
|
+
const d = hooksDir(root);
|
|
955
|
+
const dir = isAbsolute(d) ? d : join(root, d);
|
|
956
|
+
// A hooks dir is shared when git resolves it inside the COMMON dir, or points
|
|
957
|
+
// outside this checkout entirely. A relative `core.hooksPath` (husky's
|
|
958
|
+
// `.husky/_`) resolves per checkout, so it is this worktree's alone.
|
|
959
|
+
const common = gitCommonDir(root);
|
|
960
|
+
const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
|
|
961
|
+
const dirShared = (common !== "" && isInside(common, dir)) || (top !== "" && !isInside(top, dir));
|
|
962
|
+
if (dirShared) {
|
|
963
|
+
const bound = [];
|
|
964
|
+
const tops = new Set();
|
|
965
|
+
for (const [hookName, m] of managedBlocks()) {
|
|
966
|
+
const info = blockInfo(resolveHookTarget(root, hookName, m), m, root);
|
|
967
|
+
const at = info.invocation ? worktreeBound(info.invocation, linked) : null;
|
|
968
|
+
if (!at)
|
|
969
|
+
continue;
|
|
970
|
+
tops.add(at);
|
|
971
|
+
if (!bound.includes(hookName))
|
|
972
|
+
bound.push(hookName);
|
|
973
|
+
}
|
|
974
|
+
if (bound.length > 0) {
|
|
975
|
+
const one = bound.length === 1;
|
|
976
|
+
// Several blocks may be bound to different worktrees; naming the first one
|
|
977
|
+
// is enough to find the problem, and the plural says there are more.
|
|
978
|
+
const where = tops.size > 1 ? `linked worktrees (${[...tops][0]})` : `a linked worktree (${[...tops][0]})`;
|
|
979
|
+
return [` ⚠ shared hooks dir (${dir}) — ${bound.join(", ")} run${one ? "s" : ""} Hunch from inside ${where}; removing that worktree breaks ${one ? "it" : "them"} for every checkout of this repo — install Hunch globally or in the main checkout, then re-run \`hunch init\` with it to re-point ${one ? "it" : "them"}`];
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (!isLinkedWorktree(root))
|
|
983
|
+
return [];
|
|
984
|
+
const wrote = installs.some((h) => writes(h));
|
|
985
|
+
if (!dirShared || !wrote)
|
|
986
|
+
return [" ✓ linked worktree — sharing the repo's memory"];
|
|
987
|
+
if (installs.some((h) => h.sharedInvocation)) {
|
|
988
|
+
return [` ✓ linked worktree — sharing the repo's memory; the hooks dir is shared by ${count} worktrees (${dir}) and keeps running the repo's Hunch, not this worktree's`];
|
|
989
|
+
}
|
|
990
|
+
return [` ✓ linked worktree — sharing the repo's hooks + memory (hooks dir is shared by ${count} worktrees: ${dir})`];
|
|
991
|
+
}
|
|
705
992
|
//# sourceMappingURL=hooks.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,7 +13,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
13
13
|
import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
|
|
16
|
-
import {
|
|
16
|
+
import { matchSymbolsTiered } from "../core/glob.js";
|
|
17
17
|
import { resolveMcpToolset } from "./toolset.js";
|
|
18
18
|
import { readConfig } from "../core/config.js";
|
|
19
19
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
@@ -465,13 +465,16 @@ export function resolveSymbols(store, target) {
|
|
|
465
465
|
target = repoRelativeTarget(target, store.publicRoot);
|
|
466
466
|
const syms = store.json.loadAll("symbols");
|
|
467
467
|
const components = store.json.loadAll("components");
|
|
468
|
-
// A target that IS a real
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
468
|
+
// A target that IS a real path (has a symbol, is covered by an indexed
|
|
469
|
+
// component even with zero symbols — README.md, package.json, ... — or, last
|
|
470
|
+
// resort, is a real working-tree file the index cannot see at all, issue
|
|
471
|
+
// #334) never falls through to the suffix tier: bare `endsWith` matched
|
|
472
|
+
// unrelated files that merely end in the same characters — "db.ts" matched
|
|
473
|
+
// "mongodb.ts" (issue #300), and a same-basename file in another directory
|
|
474
|
+
// leaked its rules onto a real target with no symbols of its own (issue
|
|
475
|
+
// #299). `store.isKnownPath` is the one definition of that question; it is
|
|
476
|
+
// asked here over the PUBLIC graph only, matching the records resolved below.
|
|
477
|
+
const indexed = store.isKnownPath(target, { symbols: syms, components });
|
|
475
478
|
return matchSymbolsTiered(target, syms, indexed);
|
|
476
479
|
}
|
|
477
480
|
/** Resolve a target to canonical indexed file path(s) (for file-granular blast
|
|
@@ -583,7 +586,7 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
|
|
|
583
586
|
* per-host prose (CLAUDE.md, AGENTS.md) and hooks add to it, never replace it. */
|
|
584
587
|
export const MCP_INSTRUCTIONS = [
|
|
585
588
|
"Hunch is this repository's engineering memory: decisions, bug history, invariants, components, with provenance.",
|
|
586
|
-
"Per user task: (1) hunch_task(action:\"start\", title) once —
|
|
589
|
+
"Per user task: (1) hunch_task(action:\"start\", title) once — skip it when the host's prompt hook already opened the task and printed its verify command, and reuse that task_id; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response, showing its contribution card — required, with one exception: the host's prompt hook opened the task AND its instruction said that host closes the task AND the task used no Hunch (no hunch_* call on this task_id, no task verify check, no hook context you acted on, nothing to claim). A task you started with hunch_task is always finished by you.",
|
|
587
590
|
"Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_decision after a non-trivial choice, hunch_record_correction when a human corrects you.",
|
|
588
591
|
"Hosts without lifecycle hooks (Windsurf, Cursor, or Codex before its hooks are trusted) receive no automatic grounding: call these tools yourself.",
|
|
589
592
|
].join("\n");
|
|
@@ -932,8 +935,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
932
935
|
if (!all.has(d.id))
|
|
933
936
|
all.set(d.id, d);
|
|
934
937
|
const deps = [...all.values()].sort((a, b) => a.depth - b.depth);
|
|
935
|
-
|
|
936
|
-
|
|
938
|
+
// A real path the index has no symbols for reaches here too (resolveSymbols
|
|
939
|
+
// correctly refuses to suffix-resolve it, #334) — don't tell the caller a
|
|
940
|
+
// path we can confirm is real "isn't indexed".
|
|
941
|
+
if (!deps.length) {
|
|
942
|
+
const known = store.isKnownPath(repoRelativeTarget(symbol, store.publicRoot));
|
|
943
|
+
return ok(`Nothing depends on "${symbol}" (${known ? "leaf node, or no indexed symbols for it" : "leaf node, or not indexed"}).`);
|
|
944
|
+
}
|
|
937
945
|
// Nearest dependents first (sorted by depth); cap the tail so a high-fan-in
|
|
938
946
|
// symbol can't flood the session context.
|
|
939
947
|
const lines = deps.slice(0, DEP_CAP).map((d) => ` • [depth ${d.depth}] ${d.via} (${d.id})`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { verificationLauncherFor } from "../core/verifyLauncher.js";
|
|
2
3
|
import { readTaskReport } from "../core/taskReport.js";
|
|
3
4
|
import type { HunchStore } from "../store/hunchStore.js";
|
|
4
5
|
/** A tool result must fit the host's round-trip. The full report (every
|
|
@@ -193,13 +194,8 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
|
|
|
193
194
|
};
|
|
194
195
|
full_report: string;
|
|
195
196
|
};
|
|
196
|
-
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
|
|
200
|
-
* `tsx` is a devDependency absent from every published install (#261). */
|
|
201
|
-
export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string): {
|
|
202
|
-
argv: string[];
|
|
203
|
-
shell: string;
|
|
204
|
-
};
|
|
197
|
+
/** Re-exported so existing imports keep working; the implementation lives in
|
|
198
|
+
* src/core/verifyLauncher.ts because the prompt hook needs it too and a hook
|
|
199
|
+
* must not pull in the MCP SDK. */
|
|
200
|
+
export { verificationLauncherFor };
|
|
205
201
|
export declare function registerTaskReportTools(server: McpServer, getRoot: () => string, getStore: () => HunchStore): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { verificationLauncherFor } from "../core/verifyLauncher.js";
|
|
3
3
|
import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
|
|
4
4
|
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
5
5
|
import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
|
|
@@ -49,30 +49,19 @@ export function boundedTaskReportForHost(report) {
|
|
|
49
49
|
return { ...minimal, deliveries: [], omitted: { ...minimal.omitted, deliveries: report.deliveries.length } };
|
|
50
50
|
}
|
|
51
51
|
/** Reuse the MCP server's installation, not a potentially stale global binary.
|
|
52
|
-
*
|
|
52
|
+
* `src/core/` and `src/mcp/` are siblings, so the relative entry URL is the
|
|
53
|
+
* same from either — but each caller passes ITS OWN import.meta. */
|
|
53
54
|
function verificationLauncher() {
|
|
54
55
|
return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
|
|
55
56
|
}
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
* `tsx` is a devDependency absent from every published install (#261). */
|
|
61
|
-
export function verificationLauncherFor(metaUrl, resolve) {
|
|
62
|
-
const dev = metaUrl.endsWith(".ts");
|
|
63
|
-
const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
|
|
64
|
-
// `--import` takes a URL. Converting the resolved loader to a path made Node on
|
|
65
|
-
// Windows reject it ("Received protocol 'c:'"), so every verification launched
|
|
66
|
-
// from a source checkout there failed before running and cards showed no check.
|
|
67
|
-
const loader = dev ? resolve("tsx") : null;
|
|
68
|
-
const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
|
|
69
|
-
const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
|
|
70
|
-
return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
|
|
71
|
-
}
|
|
57
|
+
/** Re-exported so existing imports keep working; the implementation lives in
|
|
58
|
+
* src/core/verifyLauncher.ts because the prompt hook needs it too and a hook
|
|
59
|
+
* must not pull in the MCP SDK. */
|
|
60
|
+
export { verificationLauncherFor };
|
|
72
61
|
export function registerTaskReportTools(server, getRoot, getStore) {
|
|
73
62
|
server.registerTool("hunch_task", {
|
|
74
63
|
title: "Start or finish a task's contribution report",
|
|
75
|
-
description: "Start once per user task
|
|
64
|
+
description: "Start once per user task, unless the host's prompt hook already opened the task and printed its verify command — then reuse that task_id and do not start. Pass the task_id to hunch_context. Finish before your final response and include the returned concise contribution card, without asking the user; skip finish only when the prompt hook's own instruction said this host closes the task and the task used no Hunch (no hunch_* call on this task_id, no verified check, no hook context you acted on, nothing to claim), and a task you started with this tool must always be finished. Applications are explicitly agent-reported and must name an exact delivered occurrence and record hash. Completion never implies successful verification. Not for storing decisions or claiming tests passed; use the CLI task verify wrapper for observed command results.",
|
|
76
65
|
inputSchema: {
|
|
77
66
|
action: z.enum(["start", "finish"]), task_id: TaskIdSchema.optional(),
|
|
78
67
|
title: z.string().min(1).max(200).optional(),
|
|
@@ -99,7 +88,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
|
|
|
99
88
|
task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
|
|
100
89
|
}
|
|
101
90
|
const launcher = verificationLauncher();
|
|
102
|
-
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
|
|
91
|
+
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note}. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
|
|
103
92
|
}
|
|
104
93
|
if (!task_id)
|
|
105
94
|
throw new Error("finish requires the exact task_id");
|
|
@@ -128,9 +128,15 @@ export declare function compactLedger(hunchDir: string, scope: Scope, opts?: {
|
|
|
128
128
|
/** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
|
|
129
129
|
* appended to the same partition. The union of events is kept (identity = what changed, to
|
|
130
130
|
* which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
|
|
131
|
-
* the higher floor
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* the higher floor. When that renumbering moves an event either side had already published,
|
|
132
|
+
* no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
|
|
133
|
+
* event that now holds a lower seq and never see it (issue #285). The floor is then raised
|
|
134
|
+
* past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
|
|
135
|
+
* floor_seq` and resynchronizes from the floor; a cursor equal to the larger head is exactly
|
|
136
|
+
* the failing case, hence the `+ 1`. A merge that renumbers nothing keeps today's seqs (no
|
|
137
|
+
* cursor could have seen a different numbering) and the floor stays put. Idempotency entries
|
|
138
|
+
* are unioned; a key both sides used for different records is a conflict the caller must
|
|
139
|
+
* surface (ours is kept). */
|
|
134
140
|
export declare function mergeLedgers(base: Ledger | null, ours: Ledger, theirs: Ledger): {
|
|
135
141
|
ledger: Ledger;
|
|
136
142
|
conflicts: string[];
|