nexusmem 0.10.2 → 0.10.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import { Command } from "commander";
4
+ import { Command, Option } from "commander";
5
5
  import pc22 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
@@ -334,7 +334,7 @@ function toSpawnError(err, cwd, args) {
334
334
  }
335
335
  var BASE_ARGS = ["-c", "core.quotePath=false", "-c", "core.pager=", "--no-pager"];
336
336
  var RETRY_DELAYS_MS = [50, 150, 400];
337
- var realSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
337
+ var realSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
338
338
  async function* gitStream(cwd, args, opts = {}) {
339
339
  const sleep = opts.sleep ?? realSleep;
340
340
  for (let attempt = 0; ; attempt += 1) {
@@ -361,9 +361,9 @@ async function* runGitOnce(cwd, args, opts) {
361
361
  child.stderr.on("data", (chunk2) => {
362
362
  if (stderr.length < 64 * 1024) stderr += chunk2;
363
363
  });
364
- const exited = new Promise((resolve2, reject) => {
364
+ const exited = new Promise((resolve3, reject) => {
365
365
  child.once("error", (err) => reject(toSpawnError(err, cwd, fullArgs)));
366
- child.once("close", (code2, signal2) => resolve2({ code: code2 ?? 0, signal: signal2 ?? null }));
366
+ child.once("close", (code2, signal2) => resolve3({ code: code2 ?? 0, signal: signal2 ?? null }));
367
367
  });
368
368
  exited.catch(() => {
369
369
  });
@@ -507,16 +507,128 @@ async function resolvePowerShellProfilePath(exe = "powershell") {
507
507
  return null;
508
508
  }
509
509
  }
510
+ function resolveBashProfilePath() {
511
+ return join3(homedir2(), ".bashrc");
512
+ }
513
+ function resolveZshProfilePath() {
514
+ return join3(homedir2(), ".zshrc");
515
+ }
510
516
 
511
- // src/hooks/powershell.ts
517
+ // src/hooks/bash.ts
512
518
  var MARK_START = "# >>> nexusmem shell hook >>>";
513
519
  var MARK_END = "# <<< nexusmem shell hook <<<";
514
- function toPowerShellLiteral(s) {
515
- return `'${s.replace(/'/g, "''")}'`;
520
+ function toBashLiteral(s) {
521
+ return `'${s.replace(/'/g, `'\\''`)}'`;
516
522
  }
517
523
  function renderHookSnippet(logPath) {
518
524
  return [
519
525
  MARK_START,
526
+ `__nxm_log_path=${toBashLiteral(logPath)}`,
527
+ '__nxm_cmd_start=""',
528
+ '__nxm_last_cmd=""',
529
+ '__nxm_debug_trap_owned=""',
530
+ "",
531
+ "__nxm_json_escape() {",
532
+ " local s=$1",
533
+ " s=${s//\\\\/\\\\\\\\}",
534
+ ' s=${s//\\"/\\\\\\"}',
535
+ " s=${s//$'\\n'/\\\\n}",
536
+ " s=${s//$'\\t'/\\\\t}",
537
+ ` printf '%s' "$s"`,
538
+ "}",
539
+ "",
540
+ "# Sets $__nxm_ms rather than echoing, to avoid a subshell fork on the bash 5+ fast path.",
541
+ "__nxm_now_ms() {",
542
+ ' if [ -n "${EPOCHREALTIME:-}" ]; then',
543
+ " local es=${EPOCHREALTIME/./}",
544
+ " __nxm_ms=$(( es / 1000 ))",
545
+ " else",
546
+ " __nxm_ms=$(( $(date -u +%s) * 1000 ))",
547
+ " fi",
548
+ "}",
549
+ "",
550
+ // A DEBUG trap fires per simple command, so a pipeline sets __nxm_cmd_start
551
+ // once (first stage) and precmd clears it after logging.
552
+ "__nxm_preexec() {",
553
+ ' case "$BASH_COMMAND" in __nxm_*) return ;; esac',
554
+ ' if [ -z "$__nxm_cmd_start" ]; then',
555
+ " __nxm_now_ms; __nxm_cmd_start=$__nxm_ms",
556
+ " __nxm_last_cmd=$BASH_COMMAND",
557
+ " fi",
558
+ "}",
559
+ "",
560
+ "__nxm_precmd() {",
561
+ // Must be first: nothing above may run a command that would overwrite $?.
562
+ " local __nxm_exit=$?",
563
+ ' [ -z "$__nxm_debug_trap_owned" ] && return',
564
+ ' [ -z "$__nxm_last_cmd" ] && return',
565
+ "",
566
+ " local __nxm_dur=null",
567
+ ' if [ -n "$__nxm_cmd_start" ]; then',
568
+ " __nxm_now_ms",
569
+ " __nxm_dur=$(( __nxm_ms - __nxm_cmd_start ))",
570
+ " fi",
571
+ "",
572
+ " local __nxm_dir",
573
+ ' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
574
+ ' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
575
+ ` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"bash-hook"}\\n' \\`,
576
+ ' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
577
+ ' "$(__nxm_json_escape "$__nxm_last_cmd")" >> "$__nxm_log_path" 2>/dev/null',
578
+ "",
579
+ ' __nxm_cmd_start=""',
580
+ ' __nxm_last_cmd=""',
581
+ "}",
582
+ "",
583
+ "# Runs first in the chain (before any pre-existing PROMPT_COMMAND) so $? is still the real last-command status above.",
584
+ 'case ";$PROMPT_COMMAND;" in',
585
+ ' *";__nxm_precmd;"*) ;;',
586
+ ' *) PROMPT_COMMAND="__nxm_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;',
587
+ "esac",
588
+ "",
589
+ // Installed last, after every other statement in this block has already
590
+ // run once -- otherwise sourcing this file trips our own trap on our own
591
+ // remaining setup statements (confirmed live: the case/esac above got
592
+ // logged as if it were a real command, the one time this was ordered
593
+ // before the trap install).
594
+ "# Only takes over an unset DEBUG trap -- never overwrites one another tool",
595
+ "# (direnv, a debugger, ...) already owns. If something else already holds",
596
+ "# it, precmd above logs nothing rather than log with no command text.",
597
+ 'if [ -z "$(trap -p DEBUG)" ]; then',
598
+ " trap '__nxm_preexec' DEBUG",
599
+ " __nxm_debug_trap_owned=1",
600
+ "fi",
601
+ MARK_END,
602
+ ""
603
+ ].join("\n");
604
+ }
605
+ function isHookInstalled(profileContent) {
606
+ return profileContent.includes(MARK_START);
607
+ }
608
+ function stripHookSnippet(profileContent) {
609
+ const startIdx = profileContent.indexOf(MARK_START);
610
+ const endIdx = profileContent.indexOf(MARK_END);
611
+ if (startIdx === -1 || endIdx === -1) return profileContent;
612
+ const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
613
+ return profileContent.slice(0, startIdx) + afterBlock;
614
+ }
615
+ function upsertHookSnippet(profileContent, logPath) {
616
+ const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
617
+ const prefix = stripped.length > 0 ? `${stripped}
618
+
619
+ ` : "";
620
+ return `${prefix}${renderHookSnippet(logPath)}`;
621
+ }
622
+
623
+ // src/hooks/powershell.ts
624
+ var MARK_START2 = "# >>> nexusmem shell hook >>>";
625
+ var MARK_END2 = "# <<< nexusmem shell hook <<<";
626
+ function toPowerShellLiteral(s) {
627
+ return `'${s.replace(/'/g, "''")}'`;
628
+ }
629
+ function renderHookSnippet2(logPath) {
630
+ return [
631
+ MARK_START2,
520
632
  "if (Test-Path Function:\\prompt) { $function:__ssd_original_prompt = $function:prompt }",
521
633
  "$global:__ssd_last_history_id = -1",
522
634
  `$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
@@ -544,39 +656,153 @@ function renderHookSnippet(logPath) {
544
656
  " if (Test-Path Function:\\__ssd_original_prompt) { & $function:__ssd_original_prompt }",
545
657
  ` else { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }`,
546
658
  "}",
547
- MARK_END,
659
+ MARK_END2,
548
660
  ""
549
661
  ].join("\n");
550
662
  }
551
- function isHookInstalled(profileContent) {
552
- return profileContent.includes(MARK_START);
663
+ function isHookInstalled2(profileContent) {
664
+ return profileContent.includes(MARK_START2);
553
665
  }
554
- function stripHookSnippet(profileContent) {
555
- const startIdx = profileContent.indexOf(MARK_START);
556
- const endIdx = profileContent.indexOf(MARK_END);
666
+ function stripHookSnippet2(profileContent) {
667
+ const startIdx = profileContent.indexOf(MARK_START2);
668
+ const endIdx = profileContent.indexOf(MARK_END2);
557
669
  if (startIdx === -1 || endIdx === -1) return profileContent;
558
- const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
670
+ const afterBlock = profileContent.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
559
671
  return profileContent.slice(0, startIdx) + afterBlock;
560
672
  }
561
- function upsertHookSnippet(profileContent, logPath) {
562
- const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
673
+ function upsertHookSnippet2(profileContent, logPath) {
674
+ const stripped = stripHookSnippet2(profileContent).replace(/\s+$/, "");
563
675
  const prefix = stripped.length > 0 ? `${stripped}
564
676
 
565
677
  ` : "";
566
- return `${prefix}${renderHookSnippet(logPath)}`;
678
+ return `${prefix}${renderHookSnippet2(logPath)}`;
679
+ }
680
+
681
+ // src/hooks/zsh.ts
682
+ var MARK_START3 = "# >>> nexusmem shell hook >>>";
683
+ var MARK_END3 = "# <<< nexusmem shell hook <<<";
684
+ function toZshLiteral(s) {
685
+ return `'${s.replace(/'/g, `'\\''`)}'`;
686
+ }
687
+ function renderHookSnippet3(logPath) {
688
+ return [
689
+ MARK_START3,
690
+ `__nxm_log_path=${toZshLiteral(logPath)}`,
691
+ '__nxm_cmd_start=""',
692
+ '__nxm_last_cmd=""',
693
+ "zmodload zsh/datetime 2>/dev/null",
694
+ "",
695
+ "__nxm_json_escape() {",
696
+ " local s=$1",
697
+ " s=${s//\\\\/\\\\\\\\}",
698
+ ' s=${s//\\"/\\\\\\"}',
699
+ " s=${s//$'\\n'/\\\\n}",
700
+ " s=${s//$'\\t'/\\\\t}",
701
+ ` printf '%s' "$s"`,
702
+ "}",
703
+ "",
704
+ "__nxm_preexec() {",
705
+ " __nxm_last_cmd=$1",
706
+ ' if [ -n "${EPOCHREALTIME:-}" ]; then',
707
+ " __nxm_cmd_start=$(printf '%.0f' $(( EPOCHREALTIME * 1000 )))",
708
+ " else",
709
+ " __nxm_cmd_start=$(( $(date -u +%s) * 1000 ))",
710
+ " fi",
711
+ "}",
712
+ "",
713
+ "__nxm_precmd() {",
714
+ // Must be first: nothing above may run a command that would overwrite $?.
715
+ " local __nxm_exit=$?",
716
+ ' [ -z "$__nxm_last_cmd" ] && return',
717
+ "",
718
+ " local __nxm_dur=null",
719
+ ' if [ -n "$__nxm_cmd_start" ]; then',
720
+ " local __nxm_now",
721
+ ' if [ -n "${EPOCHREALTIME:-}" ]; then',
722
+ " __nxm_now=$(printf '%.0f' $(( EPOCHREALTIME * 1000 )))",
723
+ " else",
724
+ " __nxm_now=$(( $(date -u +%s) * 1000 ))",
725
+ " fi",
726
+ " __nxm_dur=$(( __nxm_now - __nxm_cmd_start ))",
727
+ " fi",
728
+ "",
729
+ " local __nxm_dir",
730
+ ' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
731
+ ' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
732
+ ` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"zsh-hook"}\\n' \\`,
733
+ ' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
734
+ ' "$(__nxm_json_escape "$__nxm_last_cmd")" >> "$__nxm_log_path" 2>/dev/null',
735
+ "",
736
+ ' __nxm_cmd_start=""',
737
+ ' __nxm_last_cmd=""',
738
+ "}",
739
+ "",
740
+ // Membership check (not just append) so re-sourcing this file doesn't register twice.
741
+ "typeset -ga preexec_functions precmd_functions",
742
+ '(( ${preexec_functions[(Ie)__nxm_preexec]} )) || preexec_functions=(__nxm_preexec "${preexec_functions[@]}")',
743
+ '(( ${precmd_functions[(Ie)__nxm_precmd]} )) || precmd_functions=(__nxm_precmd "${precmd_functions[@]}")',
744
+ MARK_END3,
745
+ ""
746
+ ].join("\n");
747
+ }
748
+ function isHookInstalled3(profileContent) {
749
+ return profileContent.includes(MARK_START3);
750
+ }
751
+ function stripHookSnippet3(profileContent) {
752
+ const startIdx = profileContent.indexOf(MARK_START3);
753
+ const endIdx = profileContent.indexOf(MARK_END3);
754
+ if (startIdx === -1 || endIdx === -1) return profileContent;
755
+ const afterBlock = profileContent.slice(endIdx + MARK_END3.length).replace(/^\r?\n/, "");
756
+ return profileContent.slice(0, startIdx) + afterBlock;
757
+ }
758
+ function upsertHookSnippet3(profileContent, logPath) {
759
+ const stripped = stripHookSnippet3(profileContent).replace(/\s+$/, "");
760
+ const prefix = stripped.length > 0 ? `${stripped}
761
+
762
+ ` : "";
763
+ return `${prefix}${renderHookSnippet3(logPath)}`;
567
764
  }
568
765
 
569
766
  // src/hooks/install.ts
767
+ var SHELL_MODULES = {
768
+ pwsh: {
769
+ isHookInstalled: isHookInstalled2,
770
+ stripHookSnippet: stripHookSnippet2,
771
+ upsertHookSnippet: upsertHookSnippet2,
772
+ resolveProfilePath: async (override) => override ?? await resolvePowerShellProfilePath()
773
+ },
774
+ bash: {
775
+ isHookInstalled,
776
+ stripHookSnippet,
777
+ upsertHookSnippet,
778
+ resolveProfilePath: async (override) => override ?? resolveBashProfilePath()
779
+ },
780
+ zsh: {
781
+ isHookInstalled: isHookInstalled3,
782
+ stripHookSnippet: stripHookSnippet3,
783
+ upsertHookSnippet: upsertHookSnippet3,
784
+ resolveProfilePath: async (override) => override ?? resolveZshProfilePath()
785
+ }
786
+ };
787
+ function detectShellKind() {
788
+ if (process.platform === "win32") return "pwsh";
789
+ return (process.env.SHELL ?? "").includes("zsh") ? "zsh" : "bash";
790
+ }
570
791
  var ProfileNotFoundError = class extends Error {
571
- constructor() {
572
- super("Could not resolve a PowerShell profile path (tried `powershell -Command $PROFILE`). Pass --profile explicitly.");
792
+ constructor(shell) {
793
+ super(
794
+ shell === "pwsh" ? "Could not resolve a PowerShell profile path (tried `powershell -Command $PROFILE`). Pass --profile explicitly." : `Could not resolve a ${shell} profile path. Pass --profile explicitly.`
795
+ );
796
+ this.shell = shell;
573
797
  this.name = "ProfileNotFoundError";
574
798
  }
799
+ shell;
575
800
  };
576
- async function resolveHookTarget(profileOverride, logPathOverride) {
577
- const profilePath = profileOverride ?? await resolvePowerShellProfilePath();
578
- if (!profilePath) throw new ProfileNotFoundError();
579
- return { profilePath, logPath: logPathOverride ?? hookLogPath() };
801
+ async function resolveHookTarget(shellOverride, profileOverride, logPathOverride) {
802
+ const shell = shellOverride ?? detectShellKind();
803
+ const profilePath = await SHELL_MODULES[shell].resolveProfilePath(profileOverride);
804
+ if (!profilePath) throw new ProfileNotFoundError(shell);
805
+ return { shell, profilePath, logPath: logPathOverride ?? hookLogPath() };
580
806
  }
581
807
  async function readProfile(path) {
582
808
  try {
@@ -586,66 +812,52 @@ async function readProfile(path) {
586
812
  }
587
813
  }
588
814
  async function installHook(target) {
815
+ const mod = SHELL_MODULES[target.shell];
589
816
  const current = await readProfile(target.profilePath);
590
- const alreadyInstalled = isHookInstalled(current);
591
- const next = upsertHookSnippet(current, target.logPath);
817
+ const alreadyInstalled = mod.isHookInstalled(current);
818
+ const next = mod.upsertHookSnippet(current, target.logPath);
592
819
  if (next === current) return { changed: false, alreadyInstalled };
593
820
  await mkdir2(dirname(target.profilePath), { recursive: true });
594
821
  await writeFile2(target.profilePath, next, "utf8");
595
822
  return { changed: true, alreadyInstalled };
596
823
  }
597
824
  async function removeHook(target) {
825
+ const mod = SHELL_MODULES[target.shell];
598
826
  const current = await readProfile(target.profilePath);
599
- if (!isHookInstalled(current)) return { changed: false };
600
- await writeFile2(target.profilePath, stripHookSnippet(current), "utf8");
827
+ if (!mod.isHookInstalled(current)) return { changed: false };
828
+ await writeFile2(target.profilePath, mod.stripHookSnippet(current), "utf8");
601
829
  return { changed: true };
602
830
  }
603
831
  async function hookStatus(target) {
832
+ const mod = SHELL_MODULES[target.shell];
604
833
  const current = await readProfile(target.profilePath);
605
- return { installed: isHookInstalled(current) };
834
+ return { installed: mod.isHookInstalled(current) };
606
835
  }
607
836
 
608
837
  // src/hooks/install-git-precommit.ts
609
- import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
610
- import { dirname as dirname2, join as join4 } from "path";
838
+ import { join as join5 } from "path";
611
839
 
612
- // src/hooks/git-pre-commit.ts
613
- var MARK_START2 = "# >>> nexusmem precommit hook >>>";
614
- var MARK_END2 = "# <<< nexusmem precommit hook <<<";
840
+ // src/hooks/git-hook-snippet.ts
615
841
  var SHEBANG = "#!/bin/sh";
616
- function renderHookSnippet2() {
617
- return [
618
- MARK_START2,
619
- "# Runs `nexusmem precheck` before each commit -- advisory only, never",
620
- "# blocks a commit on its own (this hook does not pass --strict).",
621
- "# Installed by: nexusmem hook git install",
622
- "# Remove with: nexusmem hook git remove",
623
- "if command -v nexusmem >/dev/null 2>&1; then",
624
- " nexusmem precheck",
625
- "fi",
626
- MARK_END2,
627
- ""
628
- ].join("\n");
629
- }
630
- function isHookInstalled2(content) {
631
- return content.includes(MARK_START2);
842
+ function isHookInstalled4(content, markers) {
843
+ return content.includes(markers.markStart);
632
844
  }
633
- function isForeignHook(content) {
634
- return content.trim().length > 0 && !isHookInstalled2(content);
845
+ function isForeignHook(content, markers) {
846
+ return content.trim().length > 0 && !isHookInstalled4(content, markers);
635
847
  }
636
- function stripHookSnippet2(content) {
637
- const startIdx = content.indexOf(MARK_START2);
638
- const endIdx = content.indexOf(MARK_END2);
848
+ function stripHookSnippet4(content, markers) {
849
+ const startIdx = content.indexOf(markers.markStart);
850
+ const endIdx = content.indexOf(markers.markEnd);
639
851
  if (startIdx === -1 || endIdx === -1) return content;
640
- const afterBlock = content.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
852
+ const afterBlock = content.slice(endIdx + markers.markEnd.length).replace(/^\r?\n/, "");
641
853
  return content.slice(0, startIdx) + afterBlock;
642
854
  }
643
- function upsertHookSnippet2(content) {
644
- const stripped = stripHookSnippet2(content).replace(/\s+$/, "");
855
+ function upsertHookSnippet4(content, markers, renderHookSnippet6) {
856
+ const stripped = stripHookSnippet4(content, markers).replace(/\s+$/, "");
645
857
  const prefix = stripped.length > 0 ? `${stripped}
646
858
 
647
859
  ` : "";
648
- return `${prefix}${renderHookSnippet2()}`;
860
+ return `${prefix}${renderHookSnippet6()}`;
649
861
  }
650
862
  function ensureShebang(content) {
651
863
  if (content.startsWith("#!")) return content;
@@ -654,20 +866,51 @@ ${content}` : `${SHEBANG}
654
866
  `;
655
867
  }
656
868
 
657
- // src/hooks/install-git-precommit.ts
658
- var ForeignGitHookError = class extends Error {
659
- constructor(hookPath) {
660
- super(
661
- `${hookPath} already has a pre-commit hook NexusMem did not install. Pass --force to append nexusmem's check to the end of it, or integrate manually.`
662
- );
663
- this.hookPath = hookPath;
664
- this.name = "ForeignGitHookError";
665
- }
666
- hookPath;
667
- };
668
- function resolveGitHookTarget(repoRoot) {
669
- return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
869
+ // src/hooks/git-pre-commit.ts
870
+ var MARK_START4 = "# >>> nexusmem precommit hook >>>";
871
+ var MARK_END4 = "# <<< nexusmem precommit hook <<<";
872
+ var MARKERS = { markStart: MARK_START4, markEnd: MARK_END4 };
873
+ function renderHookSnippet4() {
874
+ return [
875
+ MARK_START4,
876
+ "# Runs `nexusmem precheck` before each commit -- advisory only, never",
877
+ "# blocks a commit on its own (this hook does not pass --strict).",
878
+ "# Installed by: nexusmem hook git install",
879
+ "# Remove with: nexusmem hook git remove",
880
+ "if command -v nexusmem >/dev/null 2>&1; then",
881
+ " nexusmem precheck",
882
+ "fi",
883
+ MARK_END4,
884
+ ""
885
+ ].join("\n");
886
+ }
887
+ function isHookInstalled5(content) {
888
+ return isHookInstalled4(content, MARKERS);
889
+ }
890
+ function isForeignHook2(content) {
891
+ return isForeignHook(content, MARKERS);
892
+ }
893
+ function stripHookSnippet5(content) {
894
+ return stripHookSnippet4(content, MARKERS);
670
895
  }
896
+ function upsertHookSnippet5(content) {
897
+ return upsertHookSnippet4(content, MARKERS, renderHookSnippet4);
898
+ }
899
+ var ensureShebang2 = ensureShebang;
900
+
901
+ // src/hooks/git-hooks-dir.ts
902
+ import { isAbsolute, join as join4, resolve as resolve2 } from "path";
903
+ async function resolveHooksDir(repoRoot) {
904
+ const raw = await gitOrNull(repoRoot, ["config", "--path", "core.hooksPath"]);
905
+ const hooksPathConfig = raw?.trim() || null;
906
+ if (!hooksPathConfig) return { dir: join4(repoRoot, ".git", "hooks"), hooksPathConfig: null };
907
+ const dir = isAbsolute(hooksPathConfig) ? hooksPathConfig : resolve2(repoRoot, hooksPathConfig);
908
+ return { dir, hooksPathConfig };
909
+ }
910
+
911
+ // src/hooks/git-hook-install.ts
912
+ import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
913
+ import { dirname as dirname2 } from "path";
671
914
  async function readHook(path) {
672
915
  try {
673
916
  return await readFile3(path, "utf8");
@@ -675,14 +918,14 @@ async function readHook(path) {
675
918
  return "";
676
919
  }
677
920
  }
678
- async function installGitHook(target, opts = {}) {
921
+ async function installGitHookGeneric(target, kind, opts = {}) {
679
922
  const current = await readHook(target.hookPath);
680
- const alreadyInstalled = isHookInstalled2(current);
681
- const foreign = isForeignHook(current);
923
+ const alreadyInstalled = kind.isHookInstalled(current);
924
+ const foreign = kind.isForeignHook(current);
682
925
  if (foreign && !alreadyInstalled && !opts.force) {
683
- throw new ForeignGitHookError(target.hookPath);
926
+ throw kind.createForeignError(target.hookPath);
684
927
  }
685
- const next = upsertHookSnippet2(ensureShebang(current));
928
+ const next = kind.upsertHookSnippet(kind.ensureShebang(current));
686
929
  if (next === current) return { changed: false, alreadyInstalled, appendedToForeign: false };
687
930
  await mkdir3(dirname2(target.hookPath), { recursive: true });
688
931
  await writeFile3(target.hookPath, next, "utf8");
@@ -690,11 +933,11 @@ async function installGitHook(target, opts = {}) {
690
933
  });
691
934
  return { changed: true, alreadyInstalled, appendedToForeign: foreign && !alreadyInstalled };
692
935
  }
693
- async function removeGitHook(target) {
936
+ async function removeGitHookGeneric(target, kind) {
694
937
  const current = await readHook(target.hookPath);
695
- if (!isHookInstalled2(current)) return { changed: false };
696
- const stripped = stripHookSnippet2(current).trim();
697
- if (stripped === "" || stripped === SHEBANG) {
938
+ if (!kind.isHookInstalled(current)) return { changed: false };
939
+ const stripped = kind.stripHookSnippet(current).trim();
940
+ if (stripped === "" || stripped === kind.SHEBANG) {
698
941
  await unlink(target.hookPath).catch(() => {
699
942
  });
700
943
  } else {
@@ -703,9 +946,142 @@ async function removeGitHook(target) {
703
946
  }
704
947
  return { changed: true };
705
948
  }
706
- async function gitHookStatus(target) {
949
+ async function gitHookStatusGeneric(target, kind) {
707
950
  const current = await readHook(target.hookPath);
708
- return { installed: isHookInstalled2(current), foreign: isForeignHook(current) };
951
+ return { installed: kind.isHookInstalled(current), foreign: kind.isForeignHook(current) };
952
+ }
953
+
954
+ // src/hooks/install-git-precommit.ts
955
+ var ForeignGitHookError = class extends Error {
956
+ constructor(hookPath) {
957
+ super(
958
+ `${hookPath} already has a pre-commit hook NexusMem did not install. Pass --force to append nexusmem's check to the end of it, or integrate manually.`
959
+ );
960
+ this.hookPath = hookPath;
961
+ this.name = "ForeignGitHookError";
962
+ }
963
+ hookPath;
964
+ };
965
+ var KIND = {
966
+ isHookInstalled: isHookInstalled5,
967
+ isForeignHook: isForeignHook2,
968
+ stripHookSnippet: stripHookSnippet5,
969
+ upsertHookSnippet: upsertHookSnippet5,
970
+ ensureShebang: ensureShebang2,
971
+ SHEBANG,
972
+ createForeignError: (hookPath) => new ForeignGitHookError(hookPath)
973
+ };
974
+ async function resolveGitHookTarget(repoRoot) {
975
+ const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
976
+ return { hookPath: join5(dir, "pre-commit"), hooksPathConfig };
977
+ }
978
+ async function installGitHook(target, opts = {}) {
979
+ return installGitHookGeneric(target, KIND, opts);
980
+ }
981
+ async function removeGitHook(target) {
982
+ return removeGitHookGeneric(target, KIND);
983
+ }
984
+ async function gitHookStatus(target) {
985
+ return gitHookStatusGeneric(target, KIND);
986
+ }
987
+
988
+ // src/hooks/install-git-postcommit.ts
989
+ import { join as join6 } from "path";
990
+
991
+ // src/hooks/git-post-commit.ts
992
+ var MARK_START5 = "# >>> nexusmem postcommit hook >>>";
993
+ var MARK_END5 = "# <<< nexusmem postcommit hook <<<";
994
+ var MARKERS2 = { markStart: MARK_START5, markEnd: MARK_END5 };
995
+ var LOG_PATH = ".nexusmem/post-commit-sync.log";
996
+ var LOG_TRUNCATE_THRESHOLD = 2e3;
997
+ var STATE_PATH = ".nexusmem/post-commit-sync-state.json";
998
+ function renderHookSnippet5() {
999
+ return [
1000
+ MARK_START5,
1001
+ "# Runs a full `nexusmem sync` (including embedding) in the background after",
1002
+ "# each commit -- detached, so this never makes `git commit` itself wait.",
1003
+ `# Output goes to ${LOG_PATH} (reset once it passes ${LOG_TRUNCATE_THRESHOLD} lines), not the terminal.`,
1004
+ `# Last-run outcome is also written to ${STATE_PATH} (\`nexusmem status\` reads it).`,
1005
+ "# Installed by: nexusmem hook git-post install",
1006
+ "# Remove with: nexusmem hook git-post remove",
1007
+ "if command -v nexusmem >/dev/null 2>&1; then",
1008
+ " mkdir -p .nexusmem",
1009
+ " nohup sh -c '",
1010
+ ` [ "$(wc -l <${LOG_PATH} 2>/dev/null || echo 0)" -gt ${LOG_TRUNCATE_THRESHOLD} ] && : >${LOG_PATH}`,
1011
+ ` nexusmem sync --quiet --auto >>${LOG_PATH} 2>&1`,
1012
+ " ec=$?",
1013
+ ' ok=$([ "$ec" -eq 0 ] && echo true || echo false)',
1014
+ // Atomic rename, not truncate-in-place like the log above: this file is
1015
+ // wholesale-replaced each run (no concurrent-appender fd to protect), so
1016
+ // a rename avoids a reader ever seeing a half-written, unparseable JSON file.
1017
+ // Double-quoted, not single: this whole block is already inside the
1018
+ // outer nohup sh -c '...' single-quoted argument below, and single
1019
+ // quotes cannot nest -- one here would close that argument early.
1020
+ ` printf "{\\"ts\\":\\"%s\\",\\"ok\\":%s,\\"exitCode\\":%s}\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ok" "$ec" >${STATE_PATH}.tmp && mv ${STATE_PATH}.tmp ${STATE_PATH}`,
1021
+ " ' >/dev/null 2>&1 &",
1022
+ "fi",
1023
+ MARK_END5,
1024
+ ""
1025
+ ].join("\n");
1026
+ }
1027
+ function parsePostCommitSyncState(raw) {
1028
+ let obj;
1029
+ try {
1030
+ obj = JSON.parse(raw);
1031
+ } catch {
1032
+ return null;
1033
+ }
1034
+ if (typeof obj !== "object" || obj === null) return null;
1035
+ const o = obj;
1036
+ if (typeof o.ts !== "string" || typeof o.ok !== "boolean" || typeof o.exitCode !== "number") return null;
1037
+ return { ts: o.ts, ok: o.ok, exitCode: o.exitCode };
1038
+ }
1039
+ function isHookInstalled6(content) {
1040
+ return isHookInstalled4(content, MARKERS2);
1041
+ }
1042
+ function isForeignHook3(content) {
1043
+ return isForeignHook(content, MARKERS2);
1044
+ }
1045
+ function stripHookSnippet6(content) {
1046
+ return stripHookSnippet4(content, MARKERS2);
1047
+ }
1048
+ function upsertHookSnippet6(content) {
1049
+ return upsertHookSnippet4(content, MARKERS2, renderHookSnippet5);
1050
+ }
1051
+ var ensureShebang3 = ensureShebang;
1052
+
1053
+ // src/hooks/install-git-postcommit.ts
1054
+ var ForeignPostCommitHookError = class extends Error {
1055
+ constructor(hookPath) {
1056
+ super(
1057
+ `${hookPath} already has a post-commit hook NexusMem did not install. Pass --force to append nexusmem's sync to the end of it, or integrate manually.`
1058
+ );
1059
+ this.hookPath = hookPath;
1060
+ this.name = "ForeignPostCommitHookError";
1061
+ }
1062
+ hookPath;
1063
+ };
1064
+ var KIND2 = {
1065
+ isHookInstalled: isHookInstalled6,
1066
+ isForeignHook: isForeignHook3,
1067
+ stripHookSnippet: stripHookSnippet6,
1068
+ upsertHookSnippet: upsertHookSnippet6,
1069
+ ensureShebang: ensureShebang3,
1070
+ SHEBANG,
1071
+ createForeignError: (hookPath) => new ForeignPostCommitHookError(hookPath)
1072
+ };
1073
+ async function resolvePostCommitHookTarget(repoRoot) {
1074
+ const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
1075
+ return { hookPath: join6(dir, "post-commit"), hooksPathConfig };
1076
+ }
1077
+ async function installPostCommitGitHook(target, opts = {}) {
1078
+ return installGitHookGeneric(target, KIND2, opts);
1079
+ }
1080
+ async function removePostCommitGitHook(target) {
1081
+ return removeGitHookGeneric(target, KIND2);
1082
+ }
1083
+ async function postCommitGitHookStatus(target) {
1084
+ return gitHookStatusGeneric(target, KIND2);
709
1085
  }
710
1086
 
711
1087
  // src/store/deny-list.ts
@@ -1037,6 +1413,17 @@ var V12 = `
1037
1413
  ALTER TABLE nodes ADD COLUMN retrieved_count INTEGER NOT NULL DEFAULT 0;
1038
1414
  ALTER TABLE nodes ADD COLUMN last_retrieved_at INTEGER;
1039
1415
  `;
1416
+ var V13 = `
1417
+ ALTER TABLE nodes ADD COLUMN capture_mode TEXT NOT NULL DEFAULT 'unknown';
1418
+ ALTER TABLE nodes ADD COLUMN source_ts TEXT;
1419
+
1420
+ UPDATE nodes
1421
+ SET source_ts = CASE
1422
+ WHEN kind = 'doc_section' THEN NULL
1423
+ WHEN kind = 'shell_command' AND json_extract(meta, '$.tsApprox') = 1 THEN NULL
1424
+ ELSE ts
1425
+ END;
1426
+ `;
1040
1427
  var MIGRATIONS = [
1041
1428
  { version: 1, up: (db) => db.exec(V1) },
1042
1429
  { version: 2, up: (db) => db.exec(V2) },
@@ -1049,7 +1436,8 @@ var MIGRATIONS = [
1049
1436
  { version: 9, up: (db) => db.exec(V9) },
1050
1437
  { version: 10, up: (db) => db.exec(V10) },
1051
1438
  { version: 11, up: (db) => db.exec(V11) },
1052
- { version: 12, up: (db) => db.exec(V12) }
1439
+ { version: 12, up: (db) => db.exec(V12) },
1440
+ { version: 13, up: (db) => db.exec(V13) }
1053
1441
  ];
1054
1442
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
1055
1443
  function currentSchemaVersion(db) {
@@ -1159,15 +1547,16 @@ function epochOf(ts) {
1159
1547
  return Number.isNaN(parsed) ? Date.now() : parsed;
1160
1548
  }
1161
1549
  function upsertNodes(db, nodes) {
1162
- const exists = db.prepare("SELECT body, signal, title FROM nodes WHERE id = ?");
1550
+ const exists = db.prepare("SELECT body, signal, title, capture_mode AS captureMode, source_ts AS sourceTs FROM nodes WHERE id = ?");
1551
+ const projectCreatedAt = db.prepare("SELECT created_at AS createdAt FROM projects WHERE id = ?");
1163
1552
  const dropStaleEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
1164
1553
  const insertNode = db.prepare(
1165
- `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at)
1166
- VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now)
1554
+ `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at, capture_mode, source_ts)
1555
+ VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now, @captureMode, @sourceTs)
1167
1556
  ON CONFLICT(id) DO UPDATE SET
1168
1557
  ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
1169
1558
  title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta,
1170
- provenance = excluded.provenance`
1559
+ provenance = excluded.provenance, capture_mode = excluded.capture_mode, source_ts = excluded.source_ts`
1171
1560
  );
1172
1561
  const clearFiles = db.prepare("DELETE FROM node_files WHERE node_id = ?");
1173
1562
  const insertFile = db.prepare(
@@ -1179,6 +1568,7 @@ function upsertNodes(db, nodes) {
1179
1568
  );
1180
1569
  const stats2 = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
1181
1570
  const denyEntriesByProject = /* @__PURE__ */ new Map();
1571
+ const createdAtByProject = /* @__PURE__ */ new Map();
1182
1572
  const run = db.transaction((batch) => {
1183
1573
  const now = Date.now();
1184
1574
  for (const node of batch) {
@@ -1191,14 +1581,28 @@ function upsertNodes(db, nodes) {
1191
1581
  stats2.denied += 1;
1192
1582
  continue;
1193
1583
  }
1584
+ let initializedAt = createdAtByProject.get(node.projectId);
1585
+ if (initializedAt === void 0) {
1586
+ initializedAt = projectCreatedAt.get(node.projectId)?.createdAt ?? null;
1587
+ createdAtByProject.set(node.projectId, initializedAt);
1588
+ }
1589
+ const sourceTs = node.sourceTs === void 0 ? node.ts : node.sourceTs;
1590
+ const sourceEpoch = sourceTs === null ? Number.NaN : Date.parse(sourceTs);
1591
+ let captureMode = "unknown";
1592
+ if (sourceTs !== null && !Number.isNaN(sourceEpoch)) {
1593
+ if (node.captureMode === "backfilled") captureMode = "backfilled";
1594
+ else if (node.captureMode === "unknown") captureMode = "unknown";
1595
+ else if (initializedAt !== null && sourceEpoch < initializedAt) captureMode = "backfilled";
1596
+ else if (initializedAt !== null && sourceEpoch <= now) captureMode = "observed";
1597
+ }
1194
1598
  const prior = exists.get(node.id);
1195
1599
  if (prior) {
1196
- if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
1600
+ if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title && prior.captureMode === captureMode && prior.sourceTs === sourceTs) {
1197
1601
  stats2.unchanged += 1;
1198
1602
  continue;
1199
1603
  }
1200
1604
  stats2.updated += 1;
1201
- dropStaleEmbedding.run(node.id);
1605
+ if (prior.body !== node.body || prior.title !== node.title) dropStaleEmbedding.run(node.id);
1202
1606
  } else {
1203
1607
  stats2.inserted += 1;
1204
1608
  }
@@ -1214,6 +1618,8 @@ function upsertNodes(db, nodes) {
1214
1618
  signal: node.signal,
1215
1619
  meta: JSON.stringify(node.meta),
1216
1620
  provenance: node.provenance ?? defaultProvenanceForKind(node.kind),
1621
+ captureMode,
1622
+ sourceTs,
1217
1623
  supersedes: node.supersedes ?? null,
1218
1624
  now
1219
1625
  });
@@ -1251,13 +1657,14 @@ function clearProject(db, projectId) {
1251
1657
  function getNodesByIds(db, ids) {
1252
1658
  if (ids.length === 0) return [];
1253
1659
  return db.prepare(
1254
- `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance, trust_state AS trustState
1660
+ `SELECT id, kind, project_id AS projectId, ts, source_ts AS sourceTs, title, body, signal, provenance,
1661
+ capture_mode AS captureMode, trust_state AS trustState
1255
1662
  FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1256
1663
  ).all(JSON.stringify(ids));
1257
1664
  }
1258
1665
  function listRecentNodes(db, projectId, limit = 20) {
1259
1666
  return db.prepare(
1260
- `SELECT id, kind, ts, source, title, signal, provenance
1667
+ `SELECT id, kind, ts, source_ts AS sourceTs, source, title, signal, provenance, capture_mode AS captureMode
1261
1668
  FROM nodes
1262
1669
  WHERE project_id = ?
1263
1670
  ORDER BY ts_epoch DESC
@@ -1536,7 +1943,8 @@ function vectorSearch(db, projectId, embedding, limit = 20, opts = {}) {
1536
1943
  const asOfEpoch = opts.asOfEpoch ?? null;
1537
1944
  const k = asOfEpoch === null ? limit : Math.max(limit * 8, 50);
1538
1945
  return db.prepare(
1539
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState, v.distance AS distance
1946
+ `SELECT n.id, n.kind, n.ts, n.source_ts AS sourceTs, n.title, n.body, n.signal, n.provenance,
1947
+ n.capture_mode AS captureMode, n.trust_state AS trustState, v.distance AS distance
1540
1948
  FROM nodes_vec v
1541
1949
  JOIN nodes n ON n.rowid = v.rowid
1542
1950
  WHERE v.embedding MATCH ? AND k = ? AND v.project_id = ?
@@ -1567,7 +1975,8 @@ function search(db, projectId, query, limit = 20, opts = {}) {
1567
1975
  if (!match) return [];
1568
1976
  const asOfEpoch = opts.asOfEpoch ?? null;
1569
1977
  const rows = db.prepare(
1570
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState,
1978
+ `SELECT n.id, n.kind, n.ts, n.source_ts AS sourceTs, n.title, n.body, n.signal, n.provenance,
1979
+ n.capture_mode AS captureMode, n.trust_state AS trustState,
1571
1980
  bm25(nodes_fts, 10.0, 1.0) AS rank
1572
1981
  FROM nodes_fts
1573
1982
  JOIN nodes n ON n.rowid = nodes_fts.rowid
@@ -2071,14 +2480,18 @@ async function runForget(opts) {
2071
2480
 
2072
2481
  // src/cli/commands/hook-git.ts
2073
2482
  import pc2 from "picocolors";
2483
+ function hooksPathNote(hooksPathConfig) {
2484
+ return ` ${pc2.dim(`core.hooksPath=${hooksPathConfig}`)}`;
2485
+ }
2074
2486
  async function runHookGitInstall(opts) {
2075
2487
  const repo = await readRepoInfo(opts.cwd);
2076
- const target = resolveGitHookTarget(repo.root);
2488
+ const target = await resolveGitHookTarget(repo.root);
2077
2489
  const result = await installGitHook(target, { force: opts.force });
2078
2490
  const lines = [
2079
2491
  result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc2.dim("already up to date")}`,
2080
2492
  ` hook ${target.hookPath}`
2081
2493
  ];
2494
+ if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
2082
2495
  if (result.appendedToForeign) {
2083
2496
  lines.push(` ${pc2.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
2084
2497
  }
@@ -2093,7 +2506,7 @@ async function runHookGitInstall(opts) {
2093
2506
  }
2094
2507
  async function runHookGitRemove(opts) {
2095
2508
  const repo = await readRepoInfo(opts.cwd);
2096
- const target = resolveGitHookTarget(repo.root);
2509
+ const target = await resolveGitHookTarget(repo.root);
2097
2510
  const result = await removeGitHook(target);
2098
2511
  process.stdout.write(
2099
2512
  result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
@@ -2104,26 +2517,91 @@ async function runHookGitRemove(opts) {
2104
2517
  }
2105
2518
  async function runHookGitStatus(opts) {
2106
2519
  const repo = await readRepoInfo(opts.cwd);
2107
- const target = resolveGitHookTarget(repo.root);
2520
+ const target = await resolveGitHookTarget(repo.root);
2108
2521
  const result = await gitHookStatus(target);
2109
- const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- install --force to append") : pc2.yellow("not installed");
2110
- process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
2522
+ const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- nexusmem hook git install --force to append") : pc2.yellow("not installed");
2523
+ process.stdout.write(
2524
+ [
2525
+ `${pc2.dim("hook ")} ${target.hookPath}`,
2526
+ `${pc2.dim("status")} ${statusLabel}`,
2527
+ target.hooksPathConfig ? hooksPathNote(target.hooksPathConfig) : "",
2528
+ ""
2529
+ ].filter((line) => line !== "").concat("").join("\n")
2530
+ );
2531
+ return 0;
2532
+ }
2533
+ async function runHookGitPostInstall(opts) {
2534
+ const repo = await readRepoInfo(opts.cwd);
2535
+ const target = await resolvePostCommitHookTarget(repo.root);
2536
+ const result = await installPostCommitGitHook(target, { force: opts.force });
2537
+ const lines = [
2538
+ result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git post-commit hook` : `${pc2.dim("already up to date")}`,
2539
+ ` hook ${target.hookPath}`
2540
+ ];
2541
+ if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
2542
+ if (result.appendedToForeign) {
2543
+ lines.push(` ${pc2.yellow("appended after an existing post-commit hook -- review")} ${target.hookPath}`);
2544
+ }
2545
+ lines.push(
2546
+ "",
2547
+ `Runs a full ${pc2.bold("nexusmem sync")} (including embedding) in the background after each commit --`,
2548
+ `detached, so it never makes ${pc2.bold("git commit")} itself wait. Output goes to .nexusmem/post-commit-sync.log.`,
2549
+ `Run ${pc2.bold("nexusmem hook git-post remove")} to undo this.`,
2550
+ ""
2551
+ );
2552
+ process.stdout.write(lines.join("\n"));
2553
+ return 0;
2554
+ }
2555
+ async function runHookGitPostRemove(opts) {
2556
+ const repo = await readRepoInfo(opts.cwd);
2557
+ const target = await resolvePostCommitHookTarget(repo.root);
2558
+ const result = await removePostCommitGitHook(target);
2559
+ process.stdout.write(
2560
+ result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
2561
+ ` : `${pc2.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
2562
+ `
2563
+ );
2564
+ return 0;
2565
+ }
2566
+ async function runHookGitPostStatus(opts) {
2567
+ const repo = await readRepoInfo(opts.cwd);
2568
+ const target = await resolvePostCommitHookTarget(repo.root);
2569
+ const result = await postCommitGitHookStatus(target);
2570
+ const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- nexusmem hook git-post install --force to append") : pc2.yellow("not installed");
2571
+ process.stdout.write(
2572
+ [
2573
+ `${pc2.dim("hook ")} ${target.hookPath}`,
2574
+ `${pc2.dim("status")} ${statusLabel}`,
2575
+ target.hooksPathConfig ? hooksPathNote(target.hooksPathConfig) : "",
2576
+ ""
2577
+ ].filter((line) => line !== "").concat("").join("\n")
2578
+ );
2111
2579
  return 0;
2112
2580
  }
2113
2581
 
2114
2582
  // src/cli/commands/hook.ts
2115
2583
  import pc3 from "picocolors";
2584
+ function shellCaveats(shell) {
2585
+ if (shell !== "bash") return [];
2586
+ return [
2587
+ `Note: on macOS, Terminal.app opens login shells, which read ~/.bash_profile, not ~/.bashrc --`,
2588
+ `if commands aren't being logged there, source this file from your .bash_profile.`,
2589
+ `Note: if another tool already owns this shell's DEBUG trap, commands won't be logged at all`,
2590
+ `(rather than logged with the wrong exit code) -- check \`nexusmem hook status\` after installing.`
2591
+ ];
2592
+ }
2116
2593
  async function runHookInstall(opts) {
2117
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2594
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2118
2595
  const result = await installHook(target);
2119
2596
  process.stdout.write(
2120
2597
  [
2121
- result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc3.dim("already up to date")}`,
2598
+ result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook (${target.shell})` : `${pc3.dim("already up to date")}`,
2122
2599
  ` profile ${target.profilePath}`,
2123
2600
  ` log ${target.logPath}`,
2124
2601
  "",
2125
- `New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
2126
- `Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
2602
+ `New commands in any ${target.shell} session using this profile will now log their timestamp, cwd and exit code.`,
2603
+ target.shell === "pwsh" ? `Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.` : `Open a new shell (or run \`. ${target.profilePath}\`) for it to take effect.`,
2604
+ ...shellCaveats(target.shell),
2127
2605
  `Run ${pc3.bold("nexusmem hook remove")} to undo this.`,
2128
2606
  ""
2129
2607
  ].join("\n")
@@ -2131,7 +2609,7 @@ async function runHookInstall(opts) {
2131
2609
  return 0;
2132
2610
  }
2133
2611
  async function runHookRemove(opts) {
2134
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2612
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2135
2613
  const result = await removeHook(target);
2136
2614
  process.stdout.write(
2137
2615
  result.changed ? `${pc3.green("removed")} shell hook from ${target.profilePath}
@@ -2141,10 +2619,11 @@ async function runHookRemove(opts) {
2141
2619
  return 0;
2142
2620
  }
2143
2621
  async function runHookStatus(opts) {
2144
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2622
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2145
2623
  const result = await hookStatus(target);
2146
2624
  process.stdout.write(
2147
2625
  [
2626
+ `${pc3.dim("shell ")} ${target.shell}`,
2148
2627
  `${pc3.dim("profile")} ${target.profilePath}`,
2149
2628
  `${pc3.dim("log ")} ${target.logPath}`,
2150
2629
  `${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("not installed")}`,
@@ -2161,7 +2640,7 @@ import pc4 from "picocolors";
2161
2640
  // src/config/registry.ts
2162
2641
  import { existsSync as existsSync2 } from "fs";
2163
2642
  import { mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile5 } from "fs/promises";
2164
- import { join as join5 } from "path";
2643
+ import { join as join7 } from "path";
2165
2644
  import { z as z3 } from "zod";
2166
2645
  var ENTRY_SCHEMA = z3.object({
2167
2646
  projectId: z3.string().min(1),
@@ -2176,7 +2655,7 @@ var REGISTRY_SCHEMA = z3.object({
2176
2655
  projects: z3.array(ENTRY_SCHEMA).default([])
2177
2656
  });
2178
2657
  function registryPath() {
2179
- return join5(globalWorkspaceDir(), "projects.json");
2658
+ return join7(globalWorkspaceDir(), "projects.json");
2180
2659
  }
2181
2660
  async function readRegistry() {
2182
2661
  let raw;
@@ -2270,10 +2749,10 @@ async function runInit(opts) {
2270
2749
  const result = await installHook(target);
2271
2750
  lines.push(
2272
2751
  "",
2273
- `${pc4.green(result.changed ? "installed" : "already installed")} shell hook`,
2752
+ `${pc4.green(result.changed ? "installed" : "already installed")} shell hook (${target.shell})`,
2274
2753
  ` profile ${target.profilePath}`,
2275
2754
  ` log ${target.logPath}`,
2276
- ` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
2755
+ target.shell === "pwsh" ? ` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect` : ` open a new shell (or run \`. ${target.profilePath}\`) for it to take effect`
2277
2756
  );
2278
2757
  } catch (err) {
2279
2758
  if (err instanceof ProfileNotFoundError) {
@@ -2401,6 +2880,13 @@ function approxTokens(text) {
2401
2880
  // src/retrieval/pack.ts
2402
2881
  var DEFAULT_SUMMARY_CHARS = 320;
2403
2882
  var NODE_OVERHEAD_TOKENS = 8;
2883
+ function renderedLabels(provenance, captureMode, trustState, project) {
2884
+ const labels = [`[${provenance}]`];
2885
+ if (captureMode !== "unknown") labels.push(`[capture:${captureMode}]`);
2886
+ if (trustState !== "candidate") labels.push(`[${trustState}]`);
2887
+ if (project) labels.push(`[${project}]`);
2888
+ return labels.join(" ");
2889
+ }
2404
2890
  var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
2405
2891
  var MAX_PER_FAMILY = 2;
2406
2892
  var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section", "code_diff"]);
@@ -2531,7 +3017,8 @@ function packContext(ranked, tokensBudget, opts = {}) {
2531
3017
  continue;
2532
3018
  }
2533
3019
  const summary = summarize(hit, summaryChars, query);
2534
- const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
3020
+ const captureMode = hit.captureMode ?? "unknown";
3021
+ const tokens = approxTokens(hit.title) + approxTokens(summary) + approxTokens(renderedLabels(hit.provenance, captureMode, hit.trustState, hit.project)) + NODE_OVERHEAD_TOKENS;
2535
3022
  if (tokensUsed + tokens > tokensBudget) {
2536
3023
  droppedForBudget += 1;
2537
3024
  continue;
@@ -2540,12 +3027,14 @@ function packContext(ranked, tokensBudget, opts = {}) {
2540
3027
  id: hit.id,
2541
3028
  kind: hit.kind,
2542
3029
  ts: hit.ts,
3030
+ sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
2543
3031
  title: hit.title,
2544
3032
  signal: hit.signal,
2545
3033
  score: hit.score,
2546
3034
  summary,
2547
3035
  tokens,
2548
3036
  provenance: hit.provenance,
3037
+ captureMode,
2549
3038
  trustState: hit.trustState,
2550
3039
  ...hit.project ? { project: hit.project } : {}
2551
3040
  });
@@ -2558,10 +3047,9 @@ function renderContextBlock(query, result) {
2558
3047
  if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
2559
3048
  const lines = [`Relevant history for: ${query}`, ""];
2560
3049
  for (const node of result.nodes) {
2561
- const project = node.project ? `[${node.project}] ` : "";
2562
- const provenance = `[${node.provenance}] `;
2563
- const trust = node.trustState !== "candidate" ? `[${node.trustState}] ` : "";
2564
- lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${trust}${project}${node.title}`);
3050
+ const labels = renderedLabels(node.provenance, node.captureMode, node.trustState, node.project);
3051
+ const date = node.sourceTs === null ? "date unknown" : node.sourceTs.slice(0, 10);
3052
+ lines.push(`- ${date} ${labels} ${node.title}`);
2565
3053
  if (node.summary && node.summary !== node.title) {
2566
3054
  if (node.kind === "code_diff") {
2567
3055
  for (const line of node.summary.split("\n")) lines.push(` ${line}`);
@@ -2606,13 +3094,17 @@ function correlateFailures(store, projectId, opts = {}) {
2606
3094
  `SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
2607
3095
  FROM nodes
2608
3096
  WHERE project_id = ? AND kind = 'shell_command'
3097
+ AND source_ts IS NOT NULL
2609
3098
  AND json_extract(meta, '$.exitCode') IS NOT NULL
2610
- AND json_extract(meta, '$.exitCode') != 0`
3099
+ AND json_extract(meta, '$.exitCode') != 0
3100
+ AND json_extract(meta, '$.cwd') IS NOT NULL`
2611
3101
  ).all(projectId);
2612
3102
  const findRetry = db.prepare(
2613
3103
  `SELECT id FROM nodes
2614
3104
  WHERE project_id = ? AND kind = 'shell_command'
3105
+ AND source_ts IS NOT NULL
2615
3106
  AND json_extract(meta, '$.exitCode') = 0
3107
+ AND json_extract(meta, '$.cwd') IS NOT NULL
2616
3108
  AND ts_epoch > ? AND ts_epoch <= ?
2617
3109
  AND lower(trim(json_extract(meta, '$.command'))) = ?
2618
3110
  AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
@@ -2695,10 +3187,12 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
2695
3187
  id: hit.id,
2696
3188
  kind: hit.kind,
2697
3189
  ts: hit.ts,
3190
+ sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
2698
3191
  title: hit.title,
2699
3192
  body: hit.body,
2700
3193
  signal: hit.signal,
2701
3194
  provenance: hit.provenance,
3195
+ captureMode: hit.captureMode ?? "unknown",
2702
3196
  trustState: hit.trustState,
2703
3197
  rank: 0
2704
3198
  });
@@ -2794,6 +3288,8 @@ function pullLinkedResolutions(resolveStore, ranked) {
2794
3288
  body: resolution.body,
2795
3289
  signal: resolution.signal,
2796
3290
  provenance: resolution.provenance,
3291
+ captureMode: resolution.captureMode ?? "unknown",
3292
+ sourceTs: resolution.sourceTs,
2797
3293
  trustState: resolution.trustState,
2798
3294
  rank: 0,
2799
3295
  // no bm25/vector rank of its own -- never read again past this point
@@ -3676,6 +4172,7 @@ function toMemoryNodes3(file, projectId, opts = {}) {
3676
4172
  kind: "doc_section",
3677
4173
  projectId,
3678
4174
  ts: file.ts,
4175
+ sourceTs: null,
3679
4176
  source: "docs",
3680
4177
  title: sectionTitle(file.path, chunk2.heading, index, chunks.length),
3681
4178
  body: truncate(chunk2.text, maxBody),
@@ -4006,7 +4503,8 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4006
4503
  id: makeNodeId(projectId, "shell_command", entry.naturalKey),
4007
4504
  kind: "shell_command",
4008
4505
  projectId,
4009
- ts: entry.ts,
4506
+ ts: entry.ts ?? opts.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
4507
+ sourceTs: entry.ts,
4010
4508
  source: `shell:${entry.shell}`,
4011
4509
  title: truncate(titleLine, MAX_TITLE_CHARS7),
4012
4510
  body: renderBody2(entry, redactedCommand, maxBody),
@@ -4019,12 +4517,23 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4019
4517
  exitCode: entry.exitCode,
4020
4518
  durationMs: entry.durationMs,
4021
4519
  tsApprox: entry.tsApprox,
4520
+ sourceTimestamp: entry.ts,
4022
4521
  shell: entry.shell
4023
4522
  }
4024
4523
  };
4025
4524
  }
4026
4525
  function collectShellHistory(entries, projectId, opts = {}) {
4027
- return entries.map((entry) => toMemoryNode3(entry, projectId, opts));
4526
+ const recordedAt = opts.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString();
4527
+ const parsedBaseEpoch = Date.parse(recordedAt);
4528
+ const baseEpoch = Number.isNaN(parsedBaseEpoch) ? Date.now() : parsedBaseEpoch;
4529
+ return entries.map(
4530
+ (entry, index) => toMemoryNode3(entry, projectId, {
4531
+ ...opts,
4532
+ // This is record-time ordering only. The source timestamp stays null,
4533
+ // while increasing milliseconds preserve the history file's order.
4534
+ recordedAt: new Date(baseEpoch + index).toISOString()
4535
+ })
4536
+ );
4028
4537
  }
4029
4538
 
4030
4539
  // src/conversation/claude-code-reader.ts
@@ -4035,18 +4544,18 @@ import { basename as basename2 } from "path";
4035
4544
  import { existsSync as existsSync3 } from "fs";
4036
4545
  import { readdir } from "fs/promises";
4037
4546
  import { homedir as homedir3 } from "os";
4038
- import { join as join6 } from "path";
4547
+ import { join as join8 } from "path";
4039
4548
  function claudeProjectSlug(repoRoot) {
4040
4549
  return repoRoot.replace(/[\\/:]/g, "-");
4041
4550
  }
4042
4551
  function claudeProjectTranscriptDir(repoRoot) {
4043
- return join6(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4552
+ return join8(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4044
4553
  }
4045
4554
  async function listTranscriptFiles(repoRoot) {
4046
4555
  const dir = claudeProjectTranscriptDir(repoRoot);
4047
4556
  if (!existsSync3(dir)) return [];
4048
4557
  const entries = await readdir(dir, { withFileTypes: true });
4049
- return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join6(dir, e.name));
4558
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join8(dir, e.name));
4050
4559
  }
4051
4560
 
4052
4561
  // src/conversation/claude-code-reader.ts
@@ -4129,7 +4638,7 @@ async function collectClaudeCodeTranscripts(repoRoot) {
4129
4638
 
4130
4639
  // src/docs/read.ts
4131
4640
  import { readFile as readFile7, stat } from "fs/promises";
4132
- import { join as join7 } from "path";
4641
+ import { join as join9 } from "path";
4133
4642
  var DEFAULT_PATHSPECS = ["*.md"];
4134
4643
  async function listDocFiles(repoRoot, opts = {}) {
4135
4644
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -4142,7 +4651,7 @@ async function readDocFiles(repoRoot, opts = {}) {
4142
4651
  const unreadable = [];
4143
4652
  for (const relPath of paths) {
4144
4653
  const path = relPath.replace(/\\/g, "/");
4145
- const absPath = join7(repoRoot, relPath);
4654
+ const absPath = join9(repoRoot, relPath);
4146
4655
  let content;
4147
4656
  let mtime;
4148
4657
  try {
@@ -4360,6 +4869,7 @@ import { readFile as readFile9, stat as stat2 } from "fs/promises";
4360
4869
  // src/shell/hook-log.ts
4361
4870
  import { appendFile, mkdir as mkdir5, readFile as readFile8 } from "fs/promises";
4362
4871
  import { dirname as dirname4 } from "path";
4872
+ var HOOK_SHELL_KINDS = /* @__PURE__ */ new Set(["pwsh-hook", "bash-hook", "zsh-hook"]);
4363
4873
  function parseHookLogLine(line) {
4364
4874
  const trimmed = line.trim();
4365
4875
  if (!trimmed) return null;
@@ -4377,7 +4887,8 @@ function parseHookLogLine(line) {
4377
4887
  cwd: o.cwd,
4378
4888
  exitCode: typeof o.exitCode === "number" ? o.exitCode : null,
4379
4889
  durationMs: typeof o.durationMs === "number" ? o.durationMs : null,
4380
- command: o.command
4890
+ command: o.command,
4891
+ shell: typeof o.shell === "string" && HOOK_SHELL_KINDS.has(o.shell) ? o.shell : void 0
4381
4892
  };
4382
4893
  }
4383
4894
  async function readHookLog(path, fromLine) {
@@ -4385,17 +4896,20 @@ async function readHookLog(path, fromLine) {
4385
4896
  try {
4386
4897
  raw = await readFile8(path, "utf8");
4387
4898
  } catch {
4388
- return { entries: [], totalLines: fromLine };
4899
+ return { entries: [], totalLines: fromLine, shellsSeen: /* @__PURE__ */ new Set() };
4389
4900
  }
4390
4901
  const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
4391
- const slice = fromLine > 0 && fromLine <= lines.length ? lines.slice(fromLine) : lines;
4392
- const entries = slice.map(parseHookLogLine).filter((e) => e !== null);
4393
- return { entries, totalLines: lines.length };
4902
+ const allParsed = lines.map(parseHookLogLine);
4903
+ const shellsSeen = /* @__PURE__ */ new Set();
4904
+ for (const e of allParsed) if (e) shellsSeen.add(e.shell ?? "pwsh-hook");
4905
+ const sliceStart = fromLine > 0 && fromLine <= lines.length ? fromLine : 0;
4906
+ const entries = allParsed.slice(sliceStart).filter((e) => e !== null);
4907
+ return { entries, totalLines: lines.length, shellsSeen };
4394
4908
  }
4395
4909
 
4396
4910
  // src/shell/parse-bash.ts
4397
4911
  var EPOCH_COMMENT = /^#(\d{9,10})$/;
4398
- function parseBashHistory(raw, mtimeMs, opts = {}) {
4912
+ function parseBashHistory(raw, _mtimeMs, opts = {}) {
4399
4913
  const lines = raw.split(/\r?\n/);
4400
4914
  const prelim = [];
4401
4915
  let pendingEpoch = null;
@@ -4412,12 +4926,11 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
4412
4926
  const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
4413
4927
  const startIndex = prelim.length - tail.length;
4414
4928
  return tail.map((p, i) => {
4415
- const fromEnd = tail.length - 1 - i;
4416
4929
  const approx = p.ts === null;
4417
4930
  return {
4418
4931
  naturalKey: `bash:${startIndex + i}:${sha256Hex(p.command).slice(0, 12)}`,
4419
4932
  command: p.command,
4420
- ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
4933
+ ts: p.ts,
4421
4934
  tsApprox: approx,
4422
4935
  exitCode: null,
4423
4936
  cwd: null,
@@ -4428,16 +4941,15 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
4428
4941
  }
4429
4942
 
4430
4943
  // src/shell/parse-psreadline.ts
4431
- function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
4944
+ function parsePsReadLineHistory(raw, _mtimeMs, opts = {}) {
4432
4945
  const allLines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
4433
4946
  const tail = opts.tailLines ? allLines.slice(-opts.tailLines) : allLines;
4434
4947
  const startIndex = allLines.length - tail.length;
4435
4948
  return tail.map((command, i) => {
4436
- const fromEnd = tail.length - 1 - i;
4437
4949
  return {
4438
4950
  naturalKey: `pwsh:${startIndex + i}:${sha256Hex(command).slice(0, 12)}`,
4439
4951
  command,
4440
- ts: new Date(mtimeMs - fromEnd * 1e3).toISOString(),
4952
+ ts: null,
4441
4953
  tsApprox: true,
4442
4954
  exitCode: null,
4443
4955
  cwd: null,
@@ -4449,7 +4961,7 @@ function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
4449
4961
 
4450
4962
  // src/shell/parse-zsh.ts
4451
4963
  var EXTENDED_PREFIX = /^: (\d+):(\d+);(.*)$/;
4452
- function parseZshHistory(raw, mtimeMs, opts = {}) {
4964
+ function parseZshHistory(raw, _mtimeMs, opts = {}) {
4453
4965
  const rawLines = raw.split(/\r?\n/);
4454
4966
  const prelim = [];
4455
4967
  let i = 0;
@@ -4481,12 +4993,11 @@ ${rawLines[i]}`;
4481
4993
  const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
4482
4994
  const startIndex = prelim.length - tail.length;
4483
4995
  return tail.map((p, idx) => {
4484
- const fromEnd = tail.length - 1 - idx;
4485
4996
  const approx = p.ts === null;
4486
4997
  return {
4487
4998
  naturalKey: `zsh:${startIndex + idx}:${sha256Hex(p.command).slice(0, 12)}`,
4488
4999
  command: p.command,
4489
- ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
5000
+ ts: p.ts,
4490
5001
  tsApprox: approx,
4491
5002
  exitCode: null,
4492
5003
  cwd: null,
@@ -4504,15 +5015,16 @@ function isUnderRoot(cwd, root) {
4504
5015
  return c === r || c.startsWith(`${r}/`);
4505
5016
  }
4506
5017
  function hookEntryToRaw(e) {
5018
+ const shell = e.shell ?? "pwsh-hook";
4507
5019
  return {
4508
- naturalKey: `pwsh-hook:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
5020
+ naturalKey: `${shell}:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
4509
5021
  command: e.command,
4510
5022
  ts: e.ts,
4511
5023
  tsApprox: false,
4512
5024
  exitCode: e.exitCode,
4513
5025
  cwd: e.cwd,
4514
5026
  durationMs: e.durationMs,
4515
- shell: "pwsh-hook"
5027
+ shell
4516
5028
  };
4517
5029
  }
4518
5030
  async function tryReadScrapeSource(path, parse, tailLines) {
@@ -4526,21 +5038,29 @@ async function collectAvailableShellHistory(opts = {}) {
4526
5038
  const preferHook = opts.preferHook ?? true;
4527
5039
  const hookPath = hookLogPath();
4528
5040
  const hookExists = existsSync4(hookPath);
5041
+ let shellsSeen = /* @__PURE__ */ new Set();
4529
5042
  if (hookExists) {
4530
5043
  const fromLine = Number(opts.hookCursor ?? "0") || 0;
4531
- const { entries, totalLines } = await readHookLog(hookPath, fromLine);
5044
+ const { entries, totalLines, shellsSeen: seen } = await readHookLog(hookPath, fromLine);
5045
+ shellsSeen = seen;
4532
5046
  const scoped = opts.repoRoot ? entries.filter((e) => isUnderRoot(e.cwd, opts.repoRoot)) : entries;
4533
5047
  results.push({ name: "pwsh-hook", entries: scoped.map(hookEntryToRaw), cursorAfter: String(totalLines) });
4534
5048
  }
4535
- const skipPwshScrape = preferHook && hookExists;
5049
+ const skipPwshScrape = preferHook && shellsSeen.has("pwsh-hook");
4536
5050
  if (!skipPwshScrape && process.platform === "win32") {
4537
5051
  const entries = await tryReadScrapeSource(psReadLineHistoryPath(), parsePsReadLineHistory, tailLines);
4538
5052
  if (entries) results.push({ name: "pwsh", entries });
4539
5053
  }
4540
- const bashEntries = await tryReadScrapeSource(bashHistoryPath(), parseBashHistory, tailLines);
4541
- if (bashEntries) results.push({ name: "bash", entries: bashEntries });
4542
- const zshEntries = await tryReadScrapeSource(zshHistoryPath(), parseZshHistory, tailLines);
4543
- if (zshEntries) results.push({ name: "zsh", entries: zshEntries });
5054
+ const skipBashScrape = preferHook && shellsSeen.has("bash-hook");
5055
+ if (!skipBashScrape) {
5056
+ const bashEntries = await tryReadScrapeSource(bashHistoryPath(), parseBashHistory, tailLines);
5057
+ if (bashEntries) results.push({ name: "bash", entries: bashEntries });
5058
+ }
5059
+ const skipZshScrape = preferHook && shellsSeen.has("zsh-hook");
5060
+ if (!skipZshScrape) {
5061
+ const zshEntries = await tryReadScrapeSource(zshHistoryPath(), parseZshHistory, tailLines);
5062
+ if (zshEntries) results.push({ name: "zsh", entries: zshEntries });
5063
+ }
4544
5064
  return results;
4545
5065
  }
4546
5066
 
@@ -4549,8 +5069,8 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
4549
5069
  const rows = source ? db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ? AND source = ?").all(oldProjectId, kind, source) : db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ?").all(oldProjectId, kind);
4550
5070
  const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
4551
5071
  const insertNode = db.prepare(
4552
- `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
4553
- VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt)`
5072
+ `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at, capture_mode, source_ts)
5073
+ VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt, @captureMode, @sourceTs)`
4554
5074
  );
4555
5075
  const readFiles = db.prepare("SELECT path, previous_path, insertions, deletions, is_binary FROM node_files WHERE node_id = ?");
4556
5076
  const insertFile = db.prepare(
@@ -4597,7 +5117,9 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
4597
5117
  body: row.body,
4598
5118
  signal: row.signal,
4599
5119
  meta: row.meta,
4600
- createdAt: row.created_at
5120
+ createdAt: row.created_at,
5121
+ captureMode: row.capture_mode,
5122
+ sourceTs: row.source_ts
4601
5123
  });
4602
5124
  for (const file of readFiles.all(row.id)) {
4603
5125
  insertFile.run({
@@ -4628,15 +5150,27 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
4628
5150
  (_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
4629
5151
  denyEntries
4630
5152
  );
4631
- const hookShell = recomputeByNaturalKey(
4632
- db,
4633
- oldProjectId,
4634
- newProjectId,
4635
- "shell_command",
4636
- "shell:pwsh-hook",
4637
- (row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
4638
- denyEntries
4639
- );
5153
+ const HOOK_SHELL_SOURCES = [
5154
+ { source: "shell:pwsh-hook", prefix: "pwsh-hook" },
5155
+ { source: "shell:bash-hook", prefix: "bash-hook" },
5156
+ { source: "shell:zsh-hook", prefix: "zsh-hook" }
5157
+ ];
5158
+ const hookShell = { migrated: 0, deduped: 0, skipped: 0, denied: 0 };
5159
+ for (const { source, prefix } of HOOK_SHELL_SOURCES) {
5160
+ const r = recomputeByNaturalKey(
5161
+ db,
5162
+ oldProjectId,
5163
+ newProjectId,
5164
+ "shell_command",
5165
+ source,
5166
+ (row, meta) => typeof meta.command === "string" ? `${prefix}:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
5167
+ denyEntries
5168
+ );
5169
+ hookShell.migrated += r.migrated;
5170
+ hookShell.deduped += r.deduped;
5171
+ hookShell.skipped += r.skipped;
5172
+ hookShell.denied += r.denied;
5173
+ }
4640
5174
  let deniedConversationTurns = 0;
4641
5175
  if (denyEntries.length > 0) {
4642
5176
  const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
@@ -4672,7 +5206,7 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
4672
5206
 
4673
5207
  // src/structure/collect.ts
4674
5208
  import { readFile as readFile10 } from "fs/promises";
4675
- import { join as join8 } from "path";
5209
+ import { join as join10 } from "path";
4676
5210
 
4677
5211
  // src/structure/extract.ts
4678
5212
  var IMPORT_PATTERNS = [
@@ -5111,7 +5645,7 @@ async function collectFileEdges(repoRoot) {
5111
5645
  const trackedPaths = new Set(paths);
5112
5646
  let goModulePath = null;
5113
5647
  try {
5114
- goModulePath = parseGoModulePath(await readFile10(join8(repoRoot, "go.mod"), "utf8"));
5648
+ goModulePath = parseGoModulePath(await readFile10(join10(repoRoot, "go.mod"), "utf8"));
5115
5649
  } catch {
5116
5650
  goModulePath = null;
5117
5651
  }
@@ -5121,7 +5655,7 @@ async function collectFileEdges(repoRoot) {
5121
5655
  for (const path of paths) {
5122
5656
  let content;
5123
5657
  try {
5124
- content = await readFile10(join8(repoRoot, path), "utf8");
5658
+ content = await readFile10(join10(repoRoot, path), "utf8");
5125
5659
  } catch {
5126
5660
  unreadable.push(path);
5127
5661
  continue;
@@ -5210,6 +5744,52 @@ ${node.body}`)
5210
5744
  };
5211
5745
  }
5212
5746
 
5747
+ // src/cli/sync-lock.ts
5748
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
5749
+ import { join as join11 } from "path";
5750
+ function lockPath(wsDir) {
5751
+ return join11(wsDir, "sync.lock");
5752
+ }
5753
+ function readOwner(path) {
5754
+ try {
5755
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
5756
+ const pid = parsed?.pid;
5757
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? parsed : null;
5758
+ } catch {
5759
+ return null;
5760
+ }
5761
+ }
5762
+ function isPidAlive(pid) {
5763
+ try {
5764
+ process.kill(pid, 0);
5765
+ return true;
5766
+ } catch (err) {
5767
+ return err.code !== "ESRCH";
5768
+ }
5769
+ }
5770
+ function acquireSyncLock(wsDir) {
5771
+ mkdirSync2(wsDir, { recursive: true });
5772
+ const path = lockPath(wsDir);
5773
+ for (; ; ) {
5774
+ try {
5775
+ writeFileSync(path, `${JSON.stringify({ pid: process.pid })}
5776
+ `, { flag: "wx" });
5777
+ return { release: () => tryUnlink(path) };
5778
+ } catch (err) {
5779
+ if (err.code !== "EEXIST") throw err;
5780
+ }
5781
+ const owner = readOwner(path);
5782
+ if (owner && isPidAlive(owner.pid)) return null;
5783
+ tryUnlink(path);
5784
+ }
5785
+ }
5786
+ function tryUnlink(path) {
5787
+ try {
5788
+ unlinkSync(path);
5789
+ } catch {
5790
+ }
5791
+ }
5792
+
5213
5793
  // src/cli/commands/sync.ts
5214
5794
  var BATCH_SIZE = 500;
5215
5795
  var PROGRESS_THRESHOLD = 200;
@@ -5526,6 +6106,15 @@ async function runSync(opts) {
5526
6106
  `);
5527
6107
  };
5528
6108
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
6109
+ let lock = null;
6110
+ if (opts.auto) {
6111
+ lock = acquireSyncLock(ws.dir);
6112
+ if (!lock) {
6113
+ out(`${pc7.dim("auto-sync")} another sync is already running for this project -- skipping
6114
+ `);
6115
+ return 0;
6116
+ }
6117
+ }
5529
6118
  const store = MemoryStore.open(ws.dbPath);
5530
6119
  const started = Date.now();
5531
6120
  try {
@@ -5635,6 +6224,7 @@ async function runSync(opts) {
5635
6224
  return 0;
5636
6225
  } finally {
5637
6226
  store.close();
6227
+ lock?.release();
5638
6228
  }
5639
6229
  }
5640
6230
 
@@ -6529,10 +7119,11 @@ async function runScanShell(opts) {
6529
7119
  return 0;
6530
7120
  }
6531
7121
  function formatNode6(node) {
6532
- const approx = node.meta.tsApprox ? pc18.dim("~") : " ";
7122
+ const sourceTimestamp = typeof node.meta.sourceTimestamp === "string" ? node.meta.sourceTimestamp : null;
7123
+ const timestamp = sourceTimestamp ? sourceTimestamp.slice(0, 16).replace("T", " ") : pc18.dim("unknown");
6533
7124
  const exit = node.meta.exitCode;
6534
7125
  const exitLabel = typeof exit === "number" && exit !== 0 ? pc18.red(`exit ${exit}`) : "";
6535
- return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
7126
+ return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), timestamp, node.title, exitLabel].filter(Boolean).join(" ");
6536
7127
  }
6537
7128
 
6538
7129
  // src/cli/commands/scan-structure.ts
@@ -6622,8 +7213,9 @@ async function runStale(opts) {
6622
7213
  }
6623
7214
 
6624
7215
  // src/cli/commands/status.ts
6625
- import { basename as basename4 } from "path";
7216
+ import { basename as basename4, join as join12 } from "path";
6626
7217
  import { statSync } from "fs";
7218
+ import { readFile as readFile11 } from "fs/promises";
6627
7219
  import pc21 from "picocolors";
6628
7220
  function daySpan(oldest, newest) {
6629
7221
  const oldestDay = Date.parse(oldest.slice(0, 10));
@@ -6663,6 +7255,32 @@ function fileSize(path) {
6663
7255
  return 0;
6664
7256
  }
6665
7257
  }
7258
+ function relativeTime(fromMs, nowMs) {
7259
+ const diffSec = Math.max(0, Math.round((nowMs - fromMs) / 1e3));
7260
+ if (diffSec < 60) return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
7261
+ const diffMin = Math.round(diffSec / 60);
7262
+ if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`;
7263
+ const diffHour = Math.round(diffMin / 60);
7264
+ if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`;
7265
+ const diffDay = Math.round(diffHour / 24);
7266
+ return `${diffDay} day${diffDay === 1 ? "" : "s"} ago`;
7267
+ }
7268
+ async function getShellHookStatus(injected) {
7269
+ try {
7270
+ const target = injected ?? await resolveHookTarget();
7271
+ return { shell: target.shell, installed: (await hookStatus(target)).installed };
7272
+ } catch {
7273
+ return null;
7274
+ }
7275
+ }
7276
+ async function readLastAutoSync(repoRoot) {
7277
+ try {
7278
+ const raw = await readFile11(join12(repoRoot, STATE_PATH), "utf8");
7279
+ return parsePostCommitSyncState(raw);
7280
+ } catch {
7281
+ return null;
7282
+ }
7283
+ }
6666
7284
  async function runStatus(opts) {
6667
7285
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
6668
7286
  const { repo, ws, projectId } = await loadContext(opts.cwd);
@@ -6682,6 +7300,27 @@ async function runStatus(opts) {
6682
7300
  const structure = store.fileEdgeStats(projectId);
6683
7301
  const staleCount = store.countStaleCandidates(projectId);
6684
7302
  const flaggedCount = store.countContradictionSuggestions(projectId);
7303
+ const [shellHook, preCommitTarget, postCommitTarget, lastAutoSync] = await Promise.all([
7304
+ getShellHookStatus(opts.shellHookTarget),
7305
+ resolveGitHookTarget(repo.root),
7306
+ resolvePostCommitHookTarget(repo.root),
7307
+ readLastAutoSync(repo.root)
7308
+ ]);
7309
+ const [preCommitStatus, postCommitStatus] = await Promise.all([
7310
+ gitHookStatus(preCommitTarget),
7311
+ postCommitGitHookStatus(postCommitTarget)
7312
+ ]);
7313
+ const now = (opts.now ?? Date.now)();
7314
+ const hooksPathNote2 = (target) => target.hooksPathConfig ? pc21.dim(` (core.hooksPath=${target.hooksPathConfig})`) : "";
7315
+ const installedLabel = (installed) => installed ? pc21.green("installed") : pc21.yellow("not installed");
7316
+ const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ? pc21.green("ok") : pc21.red(`FAILED (exit ${lastAutoSync.exitCode})`)} ${pc21.dim(relativeTime(Date.parse(lastAutoSync.ts), now))}` : postCommitStatus.installed ? pc21.dim("never (or hook predates this field -- reinstall with `nexusmem hook git-post install`)") : pc21.dim("n/a -- hook not installed");
7317
+ const hooksLines = [
7318
+ pc21.dim("hooks"),
7319
+ ` ${pc21.dim(`shell (${shellHook?.shell ?? "?"})`.padEnd(23))} ${shellHook ? installedLabel(shellHook.installed) : pc21.dim("unknown -- could not detect this machine\u2019s shell profile")}`,
7320
+ ` ${pc21.dim("git pre-commit".padEnd(23))} ${installedLabel(preCommitStatus.installed)}${hooksPathNote2(preCommitTarget)}`,
7321
+ ` ${pc21.dim("git post-commit".padEnd(23))} ${installedLabel(postCommitStatus.installed)}${hooksPathNote2(postCommitTarget)}`,
7322
+ ` ${pc21.dim("last auto-sync".padEnd(23))} ${lastAutoSyncLabel}`
7323
+ ];
6685
7324
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
6686
7325
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
6687
7326
  const staleProjectWarning = otherProjectIds.length ? `${pc21.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc21.bold(
@@ -6711,7 +7350,9 @@ async function runStatus(opts) {
6711
7350
  chains.failuresTotal ? `${pc21.dim("chains ")} ${pc21.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc21.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc21.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
6712
7351
  structure.edges ? `${pc21.dim("structure")} ${pc21.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6713
7352
  staleCount ? `${pc21.dim("aging ")} ${pc21.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc21.bold("nexusmem stale")}` : "",
6714
- flaggedCount ? `${pc21.dim("flagged ")} ${pc21.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc21.bold("nexusmem stale")} for detail` : ""
7353
+ flaggedCount ? `${pc21.dim("flagged ")} ${pc21.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc21.bold("nexusmem stale")} for detail` : "",
7354
+ "",
7355
+ ...hooksLines
6715
7356
  ].filter((line) => line !== "").join("\n").concat("\n")
6716
7357
  );
6717
7358
  return 0;
@@ -6726,7 +7367,7 @@ function isExpected(err) {
6726
7367
  // the user fixes, not stack traces they debug.
6727
7368
  err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
6728
7369
  // (antivirus, a bad install). Actionable, and not our stack to print.
6729
- err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
7370
+ err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof ForeignPostCommitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
6730
7371
  }
6731
7372
  function guard(run) {
6732
7373
  return async () => {
@@ -6762,7 +7403,11 @@ program.command("sync").description("Ingest new history into the local database"
6762
7403
  "--link-failures",
6763
7404
  "opt-in (experimental): after ingest, link failed shell commands to whatever later resolved them",
6764
7405
  false
6765
- ).option("-q, --quiet", "only print the final summary", false).action(
7406
+ ).option("-q, --quiet", "only print the final summary", false).option(
7407
+ "--auto",
7408
+ "used by the post-commit hook: skip (instead of running) if another --auto sync already holds this project's lock -- a manually-run sync never checks it",
7409
+ false
7410
+ ).action(
6766
7411
  (options) => guard(
6767
7412
  () => runSync({
6768
7413
  cwd: options.cwd,
@@ -6778,16 +7423,21 @@ program.command("sync").description("Ingest new history into the local database"
6778
7423
  pruneStaleShell: options.pruneStaleShell,
6779
7424
  yes: options.yes,
6780
7425
  linkFailures: options.linkFailures,
6781
- quiet: options.quiet
7426
+ quiet: options.quiet,
7427
+ auto: options.auto
6782
7428
  })
6783
7429
  )()
6784
7430
  );
6785
- program.command("hook").description("Manage the opt-in PowerShell hook that logs cwd + exit code + timestamp").addCommand(
6786
- new Command("install").description("Install (or update) the hook in your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookInstall({ profile: options.profile }))())
7431
+ var SHELL_CHOICES = ["pwsh", "bash", "zsh"];
7432
+ function shellOption() {
7433
+ return new Option("--shell <kind>", "pwsh, bash, or zsh -- default: auto-detected from platform/$SHELL").choices(SHELL_CHOICES);
7434
+ }
7435
+ program.command("hook").description("Manage the opt-in shell hook that logs cwd + exit code + timestamp").addCommand(
7436
+ new Command("install").description("Install (or update) the hook in your shell profile").addOption(shellOption()).option("--profile <path>", "override the auto-detected profile path").action((options) => guard(() => runHookInstall({ shell: options.shell, profile: options.profile }))())
6787
7437
  ).addCommand(
6788
- new Command("remove").description("Remove the hook block from your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookRemove({ profile: options.profile }))())
7438
+ new Command("remove").description("Remove the hook block from your shell profile").addOption(shellOption()).option("--profile <path>", "override the auto-detected profile path").action((options) => guard(() => runHookRemove({ shell: options.shell, profile: options.profile }))())
6789
7439
  ).addCommand(
6790
- new Command("status").description("Show whether the hook is installed").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookStatus({ profile: options.profile }))())
7440
+ new Command("status").description("Show whether the hook is installed").addOption(shellOption()).option("--profile <path>", "override the auto-detected profile path").action((options) => guard(() => runHookStatus({ shell: options.shell, profile: options.profile }))())
6791
7441
  ).addCommand(
6792
7442
  new Command("git").description("Manage the opt-in git pre-commit hook that runs `nexusmem precheck` before each commit").addCommand(
6793
7443
  new Command("install").description("Install (or update) the hook in .git/hooks/pre-commit").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "append after an existing foreign pre-commit hook instead of refusing", false).action((options) => guard(() => runHookGitInstall({ cwd: options.cwd, force: options.force }))())
@@ -6796,6 +7446,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
6796
7446
  ).addCommand(
6797
7447
  new Command("status").description("Show whether the git pre-commit hook is installed").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitStatus({ cwd: options.cwd }))())
6798
7448
  )
7449
+ ).addCommand(
7450
+ new Command("git-post").description("Manage the opt-in git post-commit hook that runs a full `nexusmem sync` (with embedding) after each commit").addCommand(
7451
+ new Command("install").description("Install (or update) the hook in .git/hooks/post-commit").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "append after an existing foreign post-commit hook instead of refusing", false).action((options) => guard(() => runHookGitPostInstall({ cwd: options.cwd, force: options.force }))())
7452
+ ).addCommand(
7453
+ new Command("remove").description("Remove nexusmem's block from .git/hooks/post-commit").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitPostRemove({ cwd: options.cwd }))())
7454
+ ).addCommand(
7455
+ new Command("status").description("Show whether the git post-commit hook is installed").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitPostStatus({ cwd: options.cwd }))())
7456
+ )
6799
7457
  );
6800
7458
  program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--share", "print a plain-text summary formatted for sharing, e.g. on X or Reddit").action((options) => guard(() => runStatus({ cwd: options.cwd, share: options.share }))());
6801
7459
  program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--as-of <date>", 'bi-temporal read: only nodes recorded at or before this date -- "what did the store hold then", not "what happened then"').option("--json", "emit the packed result as JSON on stdout", false).action(