nexusmem 0.10.3 → 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,49 +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 { join as join4 } from "path";
838
+ import { join as join5 } from "path";
610
839
 
611
840
  // src/hooks/git-hook-snippet.ts
612
841
  var SHEBANG = "#!/bin/sh";
613
- function isHookInstalled2(content, markers) {
842
+ function isHookInstalled4(content, markers) {
614
843
  return content.includes(markers.markStart);
615
844
  }
616
845
  function isForeignHook(content, markers) {
617
- return content.trim().length > 0 && !isHookInstalled2(content, markers);
846
+ return content.trim().length > 0 && !isHookInstalled4(content, markers);
618
847
  }
619
- function stripHookSnippet2(content, markers) {
848
+ function stripHookSnippet4(content, markers) {
620
849
  const startIdx = content.indexOf(markers.markStart);
621
850
  const endIdx = content.indexOf(markers.markEnd);
622
851
  if (startIdx === -1 || endIdx === -1) return content;
623
852
  const afterBlock = content.slice(endIdx + markers.markEnd.length).replace(/^\r?\n/, "");
624
853
  return content.slice(0, startIdx) + afterBlock;
625
854
  }
626
- function upsertHookSnippet2(content, markers, renderHookSnippet4) {
627
- const stripped = stripHookSnippet2(content, markers).replace(/\s+$/, "");
855
+ function upsertHookSnippet4(content, markers, renderHookSnippet6) {
856
+ const stripped = stripHookSnippet4(content, markers).replace(/\s+$/, "");
628
857
  const prefix = stripped.length > 0 ? `${stripped}
629
858
 
630
859
  ` : "";
631
- return `${prefix}${renderHookSnippet4()}`;
860
+ return `${prefix}${renderHookSnippet6()}`;
632
861
  }
633
862
  function ensureShebang(content) {
634
863
  if (content.startsWith("#!")) return content;
@@ -638,12 +867,12 @@ ${content}` : `${SHEBANG}
638
867
  }
639
868
 
640
869
  // src/hooks/git-pre-commit.ts
641
- var MARK_START2 = "# >>> nexusmem precommit hook >>>";
642
- var MARK_END2 = "# <<< nexusmem precommit hook <<<";
643
- var MARKERS = { markStart: MARK_START2, markEnd: MARK_END2 };
644
- function renderHookSnippet2() {
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() {
645
874
  return [
646
- MARK_START2,
875
+ MARK_START4,
647
876
  "# Runs `nexusmem precheck` before each commit -- advisory only, never",
648
877
  "# blocks a commit on its own (this hook does not pass --strict).",
649
878
  "# Installed by: nexusmem hook git install",
@@ -651,24 +880,34 @@ function renderHookSnippet2() {
651
880
  "if command -v nexusmem >/dev/null 2>&1; then",
652
881
  " nexusmem precheck",
653
882
  "fi",
654
- MARK_END2,
883
+ MARK_END4,
655
884
  ""
656
885
  ].join("\n");
657
886
  }
658
- function isHookInstalled3(content) {
659
- return isHookInstalled2(content, MARKERS);
887
+ function isHookInstalled5(content) {
888
+ return isHookInstalled4(content, MARKERS);
660
889
  }
661
890
  function isForeignHook2(content) {
662
891
  return isForeignHook(content, MARKERS);
663
892
  }
664
- function stripHookSnippet3(content) {
665
- return stripHookSnippet2(content, MARKERS);
893
+ function stripHookSnippet5(content) {
894
+ return stripHookSnippet4(content, MARKERS);
666
895
  }
667
- function upsertHookSnippet3(content) {
668
- return upsertHookSnippet2(content, MARKERS, renderHookSnippet2);
896
+ function upsertHookSnippet5(content) {
897
+ return upsertHookSnippet4(content, MARKERS, renderHookSnippet4);
669
898
  }
670
899
  var ensureShebang2 = ensureShebang;
671
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
+
672
911
  // src/hooks/git-hook-install.ts
673
912
  import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
674
913
  import { dirname as dirname2 } from "path";
@@ -724,16 +963,17 @@ var ForeignGitHookError = class extends Error {
724
963
  hookPath;
725
964
  };
726
965
  var KIND = {
727
- isHookInstalled: isHookInstalled3,
966
+ isHookInstalled: isHookInstalled5,
728
967
  isForeignHook: isForeignHook2,
729
- stripHookSnippet: stripHookSnippet3,
730
- upsertHookSnippet: upsertHookSnippet3,
968
+ stripHookSnippet: stripHookSnippet5,
969
+ upsertHookSnippet: upsertHookSnippet5,
731
970
  ensureShebang: ensureShebang2,
732
971
  SHEBANG,
733
972
  createForeignError: (hookPath) => new ForeignGitHookError(hookPath)
734
973
  };
735
- function resolveGitHookTarget(repoRoot) {
736
- return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
974
+ async function resolveGitHookTarget(repoRoot) {
975
+ const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
976
+ return { hookPath: join5(dir, "pre-commit"), hooksPathConfig };
737
977
  }
738
978
  async function installGitHook(target, opts = {}) {
739
979
  return installGitHookGeneric(target, KIND, opts);
@@ -746,41 +986,67 @@ async function gitHookStatus(target) {
746
986
  }
747
987
 
748
988
  // src/hooks/install-git-postcommit.ts
749
- import { join as join5 } from "path";
989
+ import { join as join6 } from "path";
750
990
 
751
991
  // src/hooks/git-post-commit.ts
752
- var MARK_START3 = "# >>> nexusmem postcommit hook >>>";
753
- var MARK_END3 = "# <<< nexusmem postcommit hook <<<";
754
- var MARKERS2 = { markStart: MARK_START3, markEnd: MARK_END3 };
992
+ var MARK_START5 = "# >>> nexusmem postcommit hook >>>";
993
+ var MARK_END5 = "# <<< nexusmem postcommit hook <<<";
994
+ var MARKERS2 = { markStart: MARK_START5, markEnd: MARK_END5 };
755
995
  var LOG_PATH = ".nexusmem/post-commit-sync.log";
756
996
  var LOG_TRUNCATE_THRESHOLD = 2e3;
757
- function renderHookSnippet3() {
997
+ var STATE_PATH = ".nexusmem/post-commit-sync-state.json";
998
+ function renderHookSnippet5() {
758
999
  return [
759
- MARK_START3,
1000
+ MARK_START5,
760
1001
  "# Runs a full `nexusmem sync` (including embedding) in the background after",
761
1002
  "# each commit -- detached, so this never makes `git commit` itself wait.",
762
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).`,
763
1005
  "# Installed by: nexusmem hook git-post install",
764
1006
  "# Remove with: nexusmem hook git-post remove",
765
1007
  "if command -v nexusmem >/dev/null 2>&1; then",
766
1008
  " mkdir -p .nexusmem",
767
- ` nohup sh -c '[ "$(wc -l <${LOG_PATH} 2>/dev/null || echo 0)" -gt ${LOG_TRUNCATE_THRESHOLD} ] && : >${LOG_PATH}; nexusmem sync --quiet --auto >>${LOG_PATH} 2>&1' >/dev/null 2>&1 &`,
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 &",
768
1022
  "fi",
769
- MARK_END3,
1023
+ MARK_END5,
770
1024
  ""
771
1025
  ].join("\n");
772
1026
  }
773
- function isHookInstalled4(content) {
774
- return isHookInstalled2(content, MARKERS2);
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);
775
1041
  }
776
1042
  function isForeignHook3(content) {
777
1043
  return isForeignHook(content, MARKERS2);
778
1044
  }
779
- function stripHookSnippet4(content) {
780
- return stripHookSnippet2(content, MARKERS2);
1045
+ function stripHookSnippet6(content) {
1046
+ return stripHookSnippet4(content, MARKERS2);
781
1047
  }
782
- function upsertHookSnippet4(content) {
783
- return upsertHookSnippet2(content, MARKERS2, renderHookSnippet3);
1048
+ function upsertHookSnippet6(content) {
1049
+ return upsertHookSnippet4(content, MARKERS2, renderHookSnippet5);
784
1050
  }
785
1051
  var ensureShebang3 = ensureShebang;
786
1052
 
@@ -796,16 +1062,17 @@ var ForeignPostCommitHookError = class extends Error {
796
1062
  hookPath;
797
1063
  };
798
1064
  var KIND2 = {
799
- isHookInstalled: isHookInstalled4,
1065
+ isHookInstalled: isHookInstalled6,
800
1066
  isForeignHook: isForeignHook3,
801
- stripHookSnippet: stripHookSnippet4,
802
- upsertHookSnippet: upsertHookSnippet4,
1067
+ stripHookSnippet: stripHookSnippet6,
1068
+ upsertHookSnippet: upsertHookSnippet6,
803
1069
  ensureShebang: ensureShebang3,
804
1070
  SHEBANG,
805
1071
  createForeignError: (hookPath) => new ForeignPostCommitHookError(hookPath)
806
1072
  };
807
- function resolvePostCommitHookTarget(repoRoot) {
808
- return { hookPath: join5(repoRoot, ".git", "hooks", "post-commit") };
1073
+ async function resolvePostCommitHookTarget(repoRoot) {
1074
+ const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
1075
+ return { hookPath: join6(dir, "post-commit"), hooksPathConfig };
809
1076
  }
810
1077
  async function installPostCommitGitHook(target, opts = {}) {
811
1078
  return installGitHookGeneric(target, KIND2, opts);
@@ -1146,6 +1413,17 @@ var V12 = `
1146
1413
  ALTER TABLE nodes ADD COLUMN retrieved_count INTEGER NOT NULL DEFAULT 0;
1147
1414
  ALTER TABLE nodes ADD COLUMN last_retrieved_at INTEGER;
1148
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
+ `;
1149
1427
  var MIGRATIONS = [
1150
1428
  { version: 1, up: (db) => db.exec(V1) },
1151
1429
  { version: 2, up: (db) => db.exec(V2) },
@@ -1158,7 +1436,8 @@ var MIGRATIONS = [
1158
1436
  { version: 9, up: (db) => db.exec(V9) },
1159
1437
  { version: 10, up: (db) => db.exec(V10) },
1160
1438
  { version: 11, up: (db) => db.exec(V11) },
1161
- { version: 12, up: (db) => db.exec(V12) }
1439
+ { version: 12, up: (db) => db.exec(V12) },
1440
+ { version: 13, up: (db) => db.exec(V13) }
1162
1441
  ];
1163
1442
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
1164
1443
  function currentSchemaVersion(db) {
@@ -1268,15 +1547,16 @@ function epochOf(ts) {
1268
1547
  return Number.isNaN(parsed) ? Date.now() : parsed;
1269
1548
  }
1270
1549
  function upsertNodes(db, nodes) {
1271
- 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 = ?");
1272
1552
  const dropStaleEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
1273
1553
  const insertNode = db.prepare(
1274
- `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at)
1275
- 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)
1276
1556
  ON CONFLICT(id) DO UPDATE SET
1277
1557
  ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
1278
1558
  title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta,
1279
- provenance = excluded.provenance`
1559
+ provenance = excluded.provenance, capture_mode = excluded.capture_mode, source_ts = excluded.source_ts`
1280
1560
  );
1281
1561
  const clearFiles = db.prepare("DELETE FROM node_files WHERE node_id = ?");
1282
1562
  const insertFile = db.prepare(
@@ -1288,6 +1568,7 @@ function upsertNodes(db, nodes) {
1288
1568
  );
1289
1569
  const stats2 = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
1290
1570
  const denyEntriesByProject = /* @__PURE__ */ new Map();
1571
+ const createdAtByProject = /* @__PURE__ */ new Map();
1291
1572
  const run = db.transaction((batch) => {
1292
1573
  const now = Date.now();
1293
1574
  for (const node of batch) {
@@ -1300,14 +1581,28 @@ function upsertNodes(db, nodes) {
1300
1581
  stats2.denied += 1;
1301
1582
  continue;
1302
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
+ }
1303
1598
  const prior = exists.get(node.id);
1304
1599
  if (prior) {
1305
- 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) {
1306
1601
  stats2.unchanged += 1;
1307
1602
  continue;
1308
1603
  }
1309
1604
  stats2.updated += 1;
1310
- dropStaleEmbedding.run(node.id);
1605
+ if (prior.body !== node.body || prior.title !== node.title) dropStaleEmbedding.run(node.id);
1311
1606
  } else {
1312
1607
  stats2.inserted += 1;
1313
1608
  }
@@ -1323,6 +1618,8 @@ function upsertNodes(db, nodes) {
1323
1618
  signal: node.signal,
1324
1619
  meta: JSON.stringify(node.meta),
1325
1620
  provenance: node.provenance ?? defaultProvenanceForKind(node.kind),
1621
+ captureMode,
1622
+ sourceTs,
1326
1623
  supersedes: node.supersedes ?? null,
1327
1624
  now
1328
1625
  });
@@ -1360,13 +1657,14 @@ function clearProject(db, projectId) {
1360
1657
  function getNodesByIds(db, ids) {
1361
1658
  if (ids.length === 0) return [];
1362
1659
  return db.prepare(
1363
- `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
1364
1662
  FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1365
1663
  ).all(JSON.stringify(ids));
1366
1664
  }
1367
1665
  function listRecentNodes(db, projectId, limit = 20) {
1368
1666
  return db.prepare(
1369
- `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
1370
1668
  FROM nodes
1371
1669
  WHERE project_id = ?
1372
1670
  ORDER BY ts_epoch DESC
@@ -1645,7 +1943,8 @@ function vectorSearch(db, projectId, embedding, limit = 20, opts = {}) {
1645
1943
  const asOfEpoch = opts.asOfEpoch ?? null;
1646
1944
  const k = asOfEpoch === null ? limit : Math.max(limit * 8, 50);
1647
1945
  return db.prepare(
1648
- `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
1649
1948
  FROM nodes_vec v
1650
1949
  JOIN nodes n ON n.rowid = v.rowid
1651
1950
  WHERE v.embedding MATCH ? AND k = ? AND v.project_id = ?
@@ -1676,7 +1975,8 @@ function search(db, projectId, query, limit = 20, opts = {}) {
1676
1975
  if (!match) return [];
1677
1976
  const asOfEpoch = opts.asOfEpoch ?? null;
1678
1977
  const rows = db.prepare(
1679
- `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,
1680
1980
  bm25(nodes_fts, 10.0, 1.0) AS rank
1681
1981
  FROM nodes_fts
1682
1982
  JOIN nodes n ON n.rowid = nodes_fts.rowid
@@ -2180,14 +2480,18 @@ async function runForget(opts) {
2180
2480
 
2181
2481
  // src/cli/commands/hook-git.ts
2182
2482
  import pc2 from "picocolors";
2483
+ function hooksPathNote(hooksPathConfig) {
2484
+ return ` ${pc2.dim(`core.hooksPath=${hooksPathConfig}`)}`;
2485
+ }
2183
2486
  async function runHookGitInstall(opts) {
2184
2487
  const repo = await readRepoInfo(opts.cwd);
2185
- const target = resolveGitHookTarget(repo.root);
2488
+ const target = await resolveGitHookTarget(repo.root);
2186
2489
  const result = await installGitHook(target, { force: opts.force });
2187
2490
  const lines = [
2188
2491
  result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc2.dim("already up to date")}`,
2189
2492
  ` hook ${target.hookPath}`
2190
2493
  ];
2494
+ if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
2191
2495
  if (result.appendedToForeign) {
2192
2496
  lines.push(` ${pc2.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
2193
2497
  }
@@ -2202,7 +2506,7 @@ async function runHookGitInstall(opts) {
2202
2506
  }
2203
2507
  async function runHookGitRemove(opts) {
2204
2508
  const repo = await readRepoInfo(opts.cwd);
2205
- const target = resolveGitHookTarget(repo.root);
2509
+ const target = await resolveGitHookTarget(repo.root);
2206
2510
  const result = await removeGitHook(target);
2207
2511
  process.stdout.write(
2208
2512
  result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
@@ -2213,20 +2517,28 @@ async function runHookGitRemove(opts) {
2213
2517
  }
2214
2518
  async function runHookGitStatus(opts) {
2215
2519
  const repo = await readRepoInfo(opts.cwd);
2216
- const target = resolveGitHookTarget(repo.root);
2520
+ const target = await resolveGitHookTarget(repo.root);
2217
2521
  const result = await gitHookStatus(target);
2218
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");
2219
- process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
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
+ );
2220
2531
  return 0;
2221
2532
  }
2222
2533
  async function runHookGitPostInstall(opts) {
2223
2534
  const repo = await readRepoInfo(opts.cwd);
2224
- const target = resolvePostCommitHookTarget(repo.root);
2535
+ const target = await resolvePostCommitHookTarget(repo.root);
2225
2536
  const result = await installPostCommitGitHook(target, { force: opts.force });
2226
2537
  const lines = [
2227
2538
  result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git post-commit hook` : `${pc2.dim("already up to date")}`,
2228
2539
  ` hook ${target.hookPath}`
2229
2540
  ];
2541
+ if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
2230
2542
  if (result.appendedToForeign) {
2231
2543
  lines.push(` ${pc2.yellow("appended after an existing post-commit hook -- review")} ${target.hookPath}`);
2232
2544
  }
@@ -2242,7 +2554,7 @@ async function runHookGitPostInstall(opts) {
2242
2554
  }
2243
2555
  async function runHookGitPostRemove(opts) {
2244
2556
  const repo = await readRepoInfo(opts.cwd);
2245
- const target = resolvePostCommitHookTarget(repo.root);
2557
+ const target = await resolvePostCommitHookTarget(repo.root);
2246
2558
  const result = await removePostCommitGitHook(target);
2247
2559
  process.stdout.write(
2248
2560
  result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
@@ -2253,26 +2565,43 @@ async function runHookGitPostRemove(opts) {
2253
2565
  }
2254
2566
  async function runHookGitPostStatus(opts) {
2255
2567
  const repo = await readRepoInfo(opts.cwd);
2256
- const target = resolvePostCommitHookTarget(repo.root);
2568
+ const target = await resolvePostCommitHookTarget(repo.root);
2257
2569
  const result = await postCommitGitHookStatus(target);
2258
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");
2259
- process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
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
+ );
2260
2579
  return 0;
2261
2580
  }
2262
2581
 
2263
2582
  // src/cli/commands/hook.ts
2264
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
+ }
2265
2593
  async function runHookInstall(opts) {
2266
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2594
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2267
2595
  const result = await installHook(target);
2268
2596
  process.stdout.write(
2269
2597
  [
2270
- 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")}`,
2271
2599
  ` profile ${target.profilePath}`,
2272
2600
  ` log ${target.logPath}`,
2273
2601
  "",
2274
- `New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
2275
- `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),
2276
2605
  `Run ${pc3.bold("nexusmem hook remove")} to undo this.`,
2277
2606
  ""
2278
2607
  ].join("\n")
@@ -2280,7 +2609,7 @@ async function runHookInstall(opts) {
2280
2609
  return 0;
2281
2610
  }
2282
2611
  async function runHookRemove(opts) {
2283
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2612
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2284
2613
  const result = await removeHook(target);
2285
2614
  process.stdout.write(
2286
2615
  result.changed ? `${pc3.green("removed")} shell hook from ${target.profilePath}
@@ -2290,10 +2619,11 @@ async function runHookRemove(opts) {
2290
2619
  return 0;
2291
2620
  }
2292
2621
  async function runHookStatus(opts) {
2293
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2622
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2294
2623
  const result = await hookStatus(target);
2295
2624
  process.stdout.write(
2296
2625
  [
2626
+ `${pc3.dim("shell ")} ${target.shell}`,
2297
2627
  `${pc3.dim("profile")} ${target.profilePath}`,
2298
2628
  `${pc3.dim("log ")} ${target.logPath}`,
2299
2629
  `${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("not installed")}`,
@@ -2310,7 +2640,7 @@ import pc4 from "picocolors";
2310
2640
  // src/config/registry.ts
2311
2641
  import { existsSync as existsSync2 } from "fs";
2312
2642
  import { mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile5 } from "fs/promises";
2313
- import { join as join6 } from "path";
2643
+ import { join as join7 } from "path";
2314
2644
  import { z as z3 } from "zod";
2315
2645
  var ENTRY_SCHEMA = z3.object({
2316
2646
  projectId: z3.string().min(1),
@@ -2325,7 +2655,7 @@ var REGISTRY_SCHEMA = z3.object({
2325
2655
  projects: z3.array(ENTRY_SCHEMA).default([])
2326
2656
  });
2327
2657
  function registryPath() {
2328
- return join6(globalWorkspaceDir(), "projects.json");
2658
+ return join7(globalWorkspaceDir(), "projects.json");
2329
2659
  }
2330
2660
  async function readRegistry() {
2331
2661
  let raw;
@@ -2419,10 +2749,10 @@ async function runInit(opts) {
2419
2749
  const result = await installHook(target);
2420
2750
  lines.push(
2421
2751
  "",
2422
- `${pc4.green(result.changed ? "installed" : "already installed")} shell hook`,
2752
+ `${pc4.green(result.changed ? "installed" : "already installed")} shell hook (${target.shell})`,
2423
2753
  ` profile ${target.profilePath}`,
2424
2754
  ` log ${target.logPath}`,
2425
- ` 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`
2426
2756
  );
2427
2757
  } catch (err) {
2428
2758
  if (err instanceof ProfileNotFoundError) {
@@ -2550,6 +2880,13 @@ function approxTokens(text) {
2550
2880
  // src/retrieval/pack.ts
2551
2881
  var DEFAULT_SUMMARY_CHARS = 320;
2552
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
+ }
2553
2890
  var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
2554
2891
  var MAX_PER_FAMILY = 2;
2555
2892
  var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section", "code_diff"]);
@@ -2680,7 +3017,8 @@ function packContext(ranked, tokensBudget, opts = {}) {
2680
3017
  continue;
2681
3018
  }
2682
3019
  const summary = summarize(hit, summaryChars, query);
2683
- 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;
2684
3022
  if (tokensUsed + tokens > tokensBudget) {
2685
3023
  droppedForBudget += 1;
2686
3024
  continue;
@@ -2689,12 +3027,14 @@ function packContext(ranked, tokensBudget, opts = {}) {
2689
3027
  id: hit.id,
2690
3028
  kind: hit.kind,
2691
3029
  ts: hit.ts,
3030
+ sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
2692
3031
  title: hit.title,
2693
3032
  signal: hit.signal,
2694
3033
  score: hit.score,
2695
3034
  summary,
2696
3035
  tokens,
2697
3036
  provenance: hit.provenance,
3037
+ captureMode,
2698
3038
  trustState: hit.trustState,
2699
3039
  ...hit.project ? { project: hit.project } : {}
2700
3040
  });
@@ -2707,10 +3047,9 @@ function renderContextBlock(query, result) {
2707
3047
  if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
2708
3048
  const lines = [`Relevant history for: ${query}`, ""];
2709
3049
  for (const node of result.nodes) {
2710
- const project = node.project ? `[${node.project}] ` : "";
2711
- const provenance = `[${node.provenance}] `;
2712
- const trust = node.trustState !== "candidate" ? `[${node.trustState}] ` : "";
2713
- 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}`);
2714
3053
  if (node.summary && node.summary !== node.title) {
2715
3054
  if (node.kind === "code_diff") {
2716
3055
  for (const line of node.summary.split("\n")) lines.push(` ${line}`);
@@ -2755,13 +3094,17 @@ function correlateFailures(store, projectId, opts = {}) {
2755
3094
  `SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
2756
3095
  FROM nodes
2757
3096
  WHERE project_id = ? AND kind = 'shell_command'
3097
+ AND source_ts IS NOT NULL
2758
3098
  AND json_extract(meta, '$.exitCode') IS NOT NULL
2759
- AND json_extract(meta, '$.exitCode') != 0`
3099
+ AND json_extract(meta, '$.exitCode') != 0
3100
+ AND json_extract(meta, '$.cwd') IS NOT NULL`
2760
3101
  ).all(projectId);
2761
3102
  const findRetry = db.prepare(
2762
3103
  `SELECT id FROM nodes
2763
3104
  WHERE project_id = ? AND kind = 'shell_command'
3105
+ AND source_ts IS NOT NULL
2764
3106
  AND json_extract(meta, '$.exitCode') = 0
3107
+ AND json_extract(meta, '$.cwd') IS NOT NULL
2765
3108
  AND ts_epoch > ? AND ts_epoch <= ?
2766
3109
  AND lower(trim(json_extract(meta, '$.command'))) = ?
2767
3110
  AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
@@ -2844,10 +3187,12 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
2844
3187
  id: hit.id,
2845
3188
  kind: hit.kind,
2846
3189
  ts: hit.ts,
3190
+ sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
2847
3191
  title: hit.title,
2848
3192
  body: hit.body,
2849
3193
  signal: hit.signal,
2850
3194
  provenance: hit.provenance,
3195
+ captureMode: hit.captureMode ?? "unknown",
2851
3196
  trustState: hit.trustState,
2852
3197
  rank: 0
2853
3198
  });
@@ -2943,6 +3288,8 @@ function pullLinkedResolutions(resolveStore, ranked) {
2943
3288
  body: resolution.body,
2944
3289
  signal: resolution.signal,
2945
3290
  provenance: resolution.provenance,
3291
+ captureMode: resolution.captureMode ?? "unknown",
3292
+ sourceTs: resolution.sourceTs,
2946
3293
  trustState: resolution.trustState,
2947
3294
  rank: 0,
2948
3295
  // no bm25/vector rank of its own -- never read again past this point
@@ -3825,6 +4172,7 @@ function toMemoryNodes3(file, projectId, opts = {}) {
3825
4172
  kind: "doc_section",
3826
4173
  projectId,
3827
4174
  ts: file.ts,
4175
+ sourceTs: null,
3828
4176
  source: "docs",
3829
4177
  title: sectionTitle(file.path, chunk2.heading, index, chunks.length),
3830
4178
  body: truncate(chunk2.text, maxBody),
@@ -4155,7 +4503,8 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4155
4503
  id: makeNodeId(projectId, "shell_command", entry.naturalKey),
4156
4504
  kind: "shell_command",
4157
4505
  projectId,
4158
- ts: entry.ts,
4506
+ ts: entry.ts ?? opts.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
4507
+ sourceTs: entry.ts,
4159
4508
  source: `shell:${entry.shell}`,
4160
4509
  title: truncate(titleLine, MAX_TITLE_CHARS7),
4161
4510
  body: renderBody2(entry, redactedCommand, maxBody),
@@ -4168,12 +4517,23 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4168
4517
  exitCode: entry.exitCode,
4169
4518
  durationMs: entry.durationMs,
4170
4519
  tsApprox: entry.tsApprox,
4520
+ sourceTimestamp: entry.ts,
4171
4521
  shell: entry.shell
4172
4522
  }
4173
4523
  };
4174
4524
  }
4175
4525
  function collectShellHistory(entries, projectId, opts = {}) {
4176
- 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
+ );
4177
4537
  }
4178
4538
 
4179
4539
  // src/conversation/claude-code-reader.ts
@@ -4184,18 +4544,18 @@ import { basename as basename2 } from "path";
4184
4544
  import { existsSync as existsSync3 } from "fs";
4185
4545
  import { readdir } from "fs/promises";
4186
4546
  import { homedir as homedir3 } from "os";
4187
- import { join as join7 } from "path";
4547
+ import { join as join8 } from "path";
4188
4548
  function claudeProjectSlug(repoRoot) {
4189
4549
  return repoRoot.replace(/[\\/:]/g, "-");
4190
4550
  }
4191
4551
  function claudeProjectTranscriptDir(repoRoot) {
4192
- return join7(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4552
+ return join8(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4193
4553
  }
4194
4554
  async function listTranscriptFiles(repoRoot) {
4195
4555
  const dir = claudeProjectTranscriptDir(repoRoot);
4196
4556
  if (!existsSync3(dir)) return [];
4197
4557
  const entries = await readdir(dir, { withFileTypes: true });
4198
- return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join7(dir, e.name));
4558
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join8(dir, e.name));
4199
4559
  }
4200
4560
 
4201
4561
  // src/conversation/claude-code-reader.ts
@@ -4278,7 +4638,7 @@ async function collectClaudeCodeTranscripts(repoRoot) {
4278
4638
 
4279
4639
  // src/docs/read.ts
4280
4640
  import { readFile as readFile7, stat } from "fs/promises";
4281
- import { join as join8 } from "path";
4641
+ import { join as join9 } from "path";
4282
4642
  var DEFAULT_PATHSPECS = ["*.md"];
4283
4643
  async function listDocFiles(repoRoot, opts = {}) {
4284
4644
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -4291,7 +4651,7 @@ async function readDocFiles(repoRoot, opts = {}) {
4291
4651
  const unreadable = [];
4292
4652
  for (const relPath of paths) {
4293
4653
  const path = relPath.replace(/\\/g, "/");
4294
- const absPath = join8(repoRoot, relPath);
4654
+ const absPath = join9(repoRoot, relPath);
4295
4655
  let content;
4296
4656
  let mtime;
4297
4657
  try {
@@ -4509,6 +4869,7 @@ import { readFile as readFile9, stat as stat2 } from "fs/promises";
4509
4869
  // src/shell/hook-log.ts
4510
4870
  import { appendFile, mkdir as mkdir5, readFile as readFile8 } from "fs/promises";
4511
4871
  import { dirname as dirname4 } from "path";
4872
+ var HOOK_SHELL_KINDS = /* @__PURE__ */ new Set(["pwsh-hook", "bash-hook", "zsh-hook"]);
4512
4873
  function parseHookLogLine(line) {
4513
4874
  const trimmed = line.trim();
4514
4875
  if (!trimmed) return null;
@@ -4526,7 +4887,8 @@ function parseHookLogLine(line) {
4526
4887
  cwd: o.cwd,
4527
4888
  exitCode: typeof o.exitCode === "number" ? o.exitCode : null,
4528
4889
  durationMs: typeof o.durationMs === "number" ? o.durationMs : null,
4529
- command: o.command
4890
+ command: o.command,
4891
+ shell: typeof o.shell === "string" && HOOK_SHELL_KINDS.has(o.shell) ? o.shell : void 0
4530
4892
  };
4531
4893
  }
4532
4894
  async function readHookLog(path, fromLine) {
@@ -4534,17 +4896,20 @@ async function readHookLog(path, fromLine) {
4534
4896
  try {
4535
4897
  raw = await readFile8(path, "utf8");
4536
4898
  } catch {
4537
- return { entries: [], totalLines: fromLine };
4899
+ return { entries: [], totalLines: fromLine, shellsSeen: /* @__PURE__ */ new Set() };
4538
4900
  }
4539
4901
  const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
4540
- const slice = fromLine > 0 && fromLine <= lines.length ? lines.slice(fromLine) : lines;
4541
- const entries = slice.map(parseHookLogLine).filter((e) => e !== null);
4542
- 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 };
4543
4908
  }
4544
4909
 
4545
4910
  // src/shell/parse-bash.ts
4546
4911
  var EPOCH_COMMENT = /^#(\d{9,10})$/;
4547
- function parseBashHistory(raw, mtimeMs, opts = {}) {
4912
+ function parseBashHistory(raw, _mtimeMs, opts = {}) {
4548
4913
  const lines = raw.split(/\r?\n/);
4549
4914
  const prelim = [];
4550
4915
  let pendingEpoch = null;
@@ -4561,12 +4926,11 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
4561
4926
  const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
4562
4927
  const startIndex = prelim.length - tail.length;
4563
4928
  return tail.map((p, i) => {
4564
- const fromEnd = tail.length - 1 - i;
4565
4929
  const approx = p.ts === null;
4566
4930
  return {
4567
4931
  naturalKey: `bash:${startIndex + i}:${sha256Hex(p.command).slice(0, 12)}`,
4568
4932
  command: p.command,
4569
- ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
4933
+ ts: p.ts,
4570
4934
  tsApprox: approx,
4571
4935
  exitCode: null,
4572
4936
  cwd: null,
@@ -4577,16 +4941,15 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
4577
4941
  }
4578
4942
 
4579
4943
  // src/shell/parse-psreadline.ts
4580
- function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
4944
+ function parsePsReadLineHistory(raw, _mtimeMs, opts = {}) {
4581
4945
  const allLines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
4582
4946
  const tail = opts.tailLines ? allLines.slice(-opts.tailLines) : allLines;
4583
4947
  const startIndex = allLines.length - tail.length;
4584
4948
  return tail.map((command, i) => {
4585
- const fromEnd = tail.length - 1 - i;
4586
4949
  return {
4587
4950
  naturalKey: `pwsh:${startIndex + i}:${sha256Hex(command).slice(0, 12)}`,
4588
4951
  command,
4589
- ts: new Date(mtimeMs - fromEnd * 1e3).toISOString(),
4952
+ ts: null,
4590
4953
  tsApprox: true,
4591
4954
  exitCode: null,
4592
4955
  cwd: null,
@@ -4598,7 +4961,7 @@ function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
4598
4961
 
4599
4962
  // src/shell/parse-zsh.ts
4600
4963
  var EXTENDED_PREFIX = /^: (\d+):(\d+);(.*)$/;
4601
- function parseZshHistory(raw, mtimeMs, opts = {}) {
4964
+ function parseZshHistory(raw, _mtimeMs, opts = {}) {
4602
4965
  const rawLines = raw.split(/\r?\n/);
4603
4966
  const prelim = [];
4604
4967
  let i = 0;
@@ -4630,12 +4993,11 @@ ${rawLines[i]}`;
4630
4993
  const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
4631
4994
  const startIndex = prelim.length - tail.length;
4632
4995
  return tail.map((p, idx) => {
4633
- const fromEnd = tail.length - 1 - idx;
4634
4996
  const approx = p.ts === null;
4635
4997
  return {
4636
4998
  naturalKey: `zsh:${startIndex + idx}:${sha256Hex(p.command).slice(0, 12)}`,
4637
4999
  command: p.command,
4638
- ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
5000
+ ts: p.ts,
4639
5001
  tsApprox: approx,
4640
5002
  exitCode: null,
4641
5003
  cwd: null,
@@ -4653,15 +5015,16 @@ function isUnderRoot(cwd, root) {
4653
5015
  return c === r || c.startsWith(`${r}/`);
4654
5016
  }
4655
5017
  function hookEntryToRaw(e) {
5018
+ const shell = e.shell ?? "pwsh-hook";
4656
5019
  return {
4657
- naturalKey: `pwsh-hook:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
5020
+ naturalKey: `${shell}:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
4658
5021
  command: e.command,
4659
5022
  ts: e.ts,
4660
5023
  tsApprox: false,
4661
5024
  exitCode: e.exitCode,
4662
5025
  cwd: e.cwd,
4663
5026
  durationMs: e.durationMs,
4664
- shell: "pwsh-hook"
5027
+ shell
4665
5028
  };
4666
5029
  }
4667
5030
  async function tryReadScrapeSource(path, parse, tailLines) {
@@ -4675,21 +5038,29 @@ async function collectAvailableShellHistory(opts = {}) {
4675
5038
  const preferHook = opts.preferHook ?? true;
4676
5039
  const hookPath = hookLogPath();
4677
5040
  const hookExists = existsSync4(hookPath);
5041
+ let shellsSeen = /* @__PURE__ */ new Set();
4678
5042
  if (hookExists) {
4679
5043
  const fromLine = Number(opts.hookCursor ?? "0") || 0;
4680
- const { entries, totalLines } = await readHookLog(hookPath, fromLine);
5044
+ const { entries, totalLines, shellsSeen: seen } = await readHookLog(hookPath, fromLine);
5045
+ shellsSeen = seen;
4681
5046
  const scoped = opts.repoRoot ? entries.filter((e) => isUnderRoot(e.cwd, opts.repoRoot)) : entries;
4682
5047
  results.push({ name: "pwsh-hook", entries: scoped.map(hookEntryToRaw), cursorAfter: String(totalLines) });
4683
5048
  }
4684
- const skipPwshScrape = preferHook && hookExists;
5049
+ const skipPwshScrape = preferHook && shellsSeen.has("pwsh-hook");
4685
5050
  if (!skipPwshScrape && process.platform === "win32") {
4686
5051
  const entries = await tryReadScrapeSource(psReadLineHistoryPath(), parsePsReadLineHistory, tailLines);
4687
5052
  if (entries) results.push({ name: "pwsh", entries });
4688
5053
  }
4689
- const bashEntries = await tryReadScrapeSource(bashHistoryPath(), parseBashHistory, tailLines);
4690
- if (bashEntries) results.push({ name: "bash", entries: bashEntries });
4691
- const zshEntries = await tryReadScrapeSource(zshHistoryPath(), parseZshHistory, tailLines);
4692
- 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
+ }
4693
5064
  return results;
4694
5065
  }
4695
5066
 
@@ -4698,8 +5069,8 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
4698
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);
4699
5070
  const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
4700
5071
  const insertNode = db.prepare(
4701
- `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
4702
- 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)`
4703
5074
  );
4704
5075
  const readFiles = db.prepare("SELECT path, previous_path, insertions, deletions, is_binary FROM node_files WHERE node_id = ?");
4705
5076
  const insertFile = db.prepare(
@@ -4746,7 +5117,9 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
4746
5117
  body: row.body,
4747
5118
  signal: row.signal,
4748
5119
  meta: row.meta,
4749
- createdAt: row.created_at
5120
+ createdAt: row.created_at,
5121
+ captureMode: row.capture_mode,
5122
+ sourceTs: row.source_ts
4750
5123
  });
4751
5124
  for (const file of readFiles.all(row.id)) {
4752
5125
  insertFile.run({
@@ -4777,15 +5150,27 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
4777
5150
  (_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
4778
5151
  denyEntries
4779
5152
  );
4780
- const hookShell = recomputeByNaturalKey(
4781
- db,
4782
- oldProjectId,
4783
- newProjectId,
4784
- "shell_command",
4785
- "shell:pwsh-hook",
4786
- (row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
4787
- denyEntries
4788
- );
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
+ }
4789
5174
  let deniedConversationTurns = 0;
4790
5175
  if (denyEntries.length > 0) {
4791
5176
  const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
@@ -4821,7 +5206,7 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
4821
5206
 
4822
5207
  // src/structure/collect.ts
4823
5208
  import { readFile as readFile10 } from "fs/promises";
4824
- import { join as join9 } from "path";
5209
+ import { join as join10 } from "path";
4825
5210
 
4826
5211
  // src/structure/extract.ts
4827
5212
  var IMPORT_PATTERNS = [
@@ -5260,7 +5645,7 @@ async function collectFileEdges(repoRoot) {
5260
5645
  const trackedPaths = new Set(paths);
5261
5646
  let goModulePath = null;
5262
5647
  try {
5263
- goModulePath = parseGoModulePath(await readFile10(join9(repoRoot, "go.mod"), "utf8"));
5648
+ goModulePath = parseGoModulePath(await readFile10(join10(repoRoot, "go.mod"), "utf8"));
5264
5649
  } catch {
5265
5650
  goModulePath = null;
5266
5651
  }
@@ -5270,7 +5655,7 @@ async function collectFileEdges(repoRoot) {
5270
5655
  for (const path of paths) {
5271
5656
  let content;
5272
5657
  try {
5273
- content = await readFile10(join9(repoRoot, path), "utf8");
5658
+ content = await readFile10(join10(repoRoot, path), "utf8");
5274
5659
  } catch {
5275
5660
  unreadable.push(path);
5276
5661
  continue;
@@ -5361,9 +5746,9 @@ ${node.body}`)
5361
5746
 
5362
5747
  // src/cli/sync-lock.ts
5363
5748
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
5364
- import { join as join10 } from "path";
5749
+ import { join as join11 } from "path";
5365
5750
  function lockPath(wsDir) {
5366
- return join10(wsDir, "sync.lock");
5751
+ return join11(wsDir, "sync.lock");
5367
5752
  }
5368
5753
  function readOwner(path) {
5369
5754
  try {
@@ -6734,10 +7119,11 @@ async function runScanShell(opts) {
6734
7119
  return 0;
6735
7120
  }
6736
7121
  function formatNode6(node) {
6737
- 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");
6738
7124
  const exit = node.meta.exitCode;
6739
7125
  const exitLabel = typeof exit === "number" && exit !== 0 ? pc18.red(`exit ${exit}`) : "";
6740
- 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(" ");
6741
7127
  }
6742
7128
 
6743
7129
  // src/cli/commands/scan-structure.ts
@@ -6827,8 +7213,9 @@ async function runStale(opts) {
6827
7213
  }
6828
7214
 
6829
7215
  // src/cli/commands/status.ts
6830
- import { basename as basename4 } from "path";
7216
+ import { basename as basename4, join as join12 } from "path";
6831
7217
  import { statSync } from "fs";
7218
+ import { readFile as readFile11 } from "fs/promises";
6832
7219
  import pc21 from "picocolors";
6833
7220
  function daySpan(oldest, newest) {
6834
7221
  const oldestDay = Date.parse(oldest.slice(0, 10));
@@ -6868,6 +7255,32 @@ function fileSize(path) {
6868
7255
  return 0;
6869
7256
  }
6870
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
+ }
6871
7284
  async function runStatus(opts) {
6872
7285
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
6873
7286
  const { repo, ws, projectId } = await loadContext(opts.cwd);
@@ -6887,6 +7300,27 @@ async function runStatus(opts) {
6887
7300
  const structure = store.fileEdgeStats(projectId);
6888
7301
  const staleCount = store.countStaleCandidates(projectId);
6889
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
+ ];
6890
7324
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
6891
7325
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
6892
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(
@@ -6916,7 +7350,9 @@ async function runStatus(opts) {
6916
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` : ""}` : "",
6917
7351
  structure.edges ? `${pc21.dim("structure")} ${pc21.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6918
7352
  staleCount ? `${pc21.dim("aging ")} ${pc21.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc21.bold("nexusmem stale")}` : "",
6919
- 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
6920
7356
  ].filter((line) => line !== "").join("\n").concat("\n")
6921
7357
  );
6922
7358
  return 0;
@@ -6992,12 +7428,16 @@ program.command("sync").description("Ingest new history into the local database"
6992
7428
  })
6993
7429
  )()
6994
7430
  );
6995
- program.command("hook").description("Manage the opt-in PowerShell hook that logs cwd + exit code + timestamp").addCommand(
6996
- 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 }))())
6997
7437
  ).addCommand(
6998
- 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 }))())
6999
7439
  ).addCommand(
7000
- 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 }))())
7001
7441
  ).addCommand(
7002
7442
  new Command("git").description("Manage the opt-in git pre-commit hook that runs `nexusmem precheck` before each commit").addCommand(
7003
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 }))())