skillwiki 0.5.4 → 0.6.0

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.js CHANGED
@@ -8,8 +8,8 @@ import {
8
8
  } from "./chunk-TPS5XD2J.js";
9
9
 
10
10
  // src/cli.ts
11
- import { readFileSync as readFileSync10 } from "fs";
12
- import { join as join39 } from "path";
11
+ import { readFileSync as readFileSync11 } from "fs";
12
+ import { join as join41 } from "path";
13
13
  import { Command as Command2 } from "commander";
14
14
 
15
15
  // ../shared/src/exit-codes.ts
@@ -61,7 +61,8 @@ var ExitCode = {
61
61
  BACKUP_SYNC_FAILED: 44,
62
62
  BACKUP_RESTORE_CONFLICTS: 45,
63
63
  USAGE: 46,
64
- BODY_TRUNCATION_GUARD: 47
64
+ BODY_TRUNCATION_GUARD: 47,
65
+ SYNC_LOCK_HELD: 48
65
66
  };
66
67
 
67
68
  // ../shared/src/json-output.ts
@@ -3439,10 +3440,9 @@ async function runConfigPath(input) {
3439
3440
  }
3440
3441
 
3441
3442
  // src/commands/doctor.ts
3442
- import { existsSync as existsSync6, lstatSync, readlinkSync, readdirSync, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
3443
- import { join as join23, resolve as resolve4 } from "path";
3444
- import { execSync } from "child_process";
3445
- import { platform } from "os";
3443
+ import { existsSync as existsSync7, lstatSync, readlinkSync, readdirSync, statSync as statSync2 } from "fs";
3444
+ import { join as join24, resolve as resolve4 } from "path";
3445
+ import { execSync as execSync2 } from "child_process";
3446
3446
 
3447
3447
  // src/utils/auto-update.ts
3448
3448
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
@@ -3470,9 +3470,9 @@ function readCacheRaw(home) {
3470
3470
  function readCache(home) {
3471
3471
  const cache = readCacheRaw(home);
3472
3472
  if (!cache) return { cache: null, hasUpdate: false, isStale: true };
3473
- const isStale = Date.now() - cache.lastCheck >= CHECK_INTERVAL_MS;
3473
+ const isStale2 = Date.now() - cache.lastCheck >= CHECK_INTERVAL_MS;
3474
3474
  const hasUpdate = !!cache.latestVersion && semverGt(cache.latestVersion, cache.currentVersion);
3475
- return { cache, hasUpdate, isStale };
3475
+ return { cache, hasUpdate, isStale: isStale2 };
3476
3476
  }
3477
3477
  function writeCache(home, cache) {
3478
3478
  const p = cachePath(home);
@@ -3492,8 +3492,8 @@ function isDisabled() {
3492
3492
  }
3493
3493
  function triggerAutoUpdate(home, currentVersion) {
3494
3494
  if (isDisabled()) return;
3495
- const { isStale } = readCache(home);
3496
- if (!isStale) return;
3495
+ const { isStale: isStale2 } = readCache(home);
3496
+ if (!isStale2) return;
3497
3497
  const bgScript = new URL("../auto-update-bg.js", import.meta.url).pathname;
3498
3498
  if (!existsSync5(bgScript)) return;
3499
3499
  const child = spawn(process.execPath, [bgScript, home, currentVersion], {
@@ -3526,6 +3526,208 @@ function findPlugin(home, key = PLUGIN_KEY) {
3526
3526
  return entries[0];
3527
3527
  }
3528
3528
 
3529
+ // src/utils/s3-mount-health.ts
3530
+ import { execSync } from "child_process";
3531
+ import { platform } from "os";
3532
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, readFileSync as readFile17 } from "fs";
3533
+ import { join as join23 } from "path";
3534
+ var OS = platform();
3535
+ function findRcloneMountPid() {
3536
+ try {
3537
+ const out = execSync("pgrep -f 'rclone.*mount'", {
3538
+ encoding: "utf8",
3539
+ timeout: 2e3,
3540
+ stdio: ["pipe", "pipe", "pipe"]
3541
+ }).trim();
3542
+ const pids = out.split("\n").filter(Boolean);
3543
+ if (pids.length === 0) return null;
3544
+ return parseInt(pids[0], 10);
3545
+ } catch {
3546
+ try {
3547
+ const out = execSync("ps aux", { encoding: "utf8", timeout: 2e3, stdio: ["pipe", "pipe", "pipe"] });
3548
+ for (const line of out.split("\n")) {
3549
+ if (line.includes("rclone") && line.includes("mount") && !line.includes("grep")) {
3550
+ const parts = line.trim().split(/\s+/);
3551
+ if (parts.length >= 2) return parseInt(parts[1], 10);
3552
+ }
3553
+ }
3554
+ } catch {
3555
+ }
3556
+ return null;
3557
+ }
3558
+ }
3559
+ function parseRcloneFlags(pid) {
3560
+ const flags = /* @__PURE__ */ new Map();
3561
+ try {
3562
+ const args = getRcloneArgs(pid);
3563
+ for (let i = 0; i < args.length; i++) {
3564
+ const arg = args[i];
3565
+ if (arg.startsWith("--") && arg.includes("=")) {
3566
+ const eq = arg.indexOf("=");
3567
+ flags.set(arg.slice(0, eq), arg.slice(eq + 1));
3568
+ } else if (arg.startsWith("--")) {
3569
+ const next = args[i + 1];
3570
+ if (next && !next.startsWith("-")) {
3571
+ flags.set(arg, next);
3572
+ i++;
3573
+ } else {
3574
+ flags.set(arg, "");
3575
+ }
3576
+ }
3577
+ }
3578
+ } catch {
3579
+ }
3580
+ return flags;
3581
+ }
3582
+ function getRcloneVersion() {
3583
+ try {
3584
+ const out = execSync("rclone version", {
3585
+ encoding: "utf8",
3586
+ timeout: 3e3,
3587
+ stdio: ["pipe", "pipe", "pipe"]
3588
+ });
3589
+ const match = out.match(/rclone\s+v(\d+)\.(\d+)\.(\d+)/i);
3590
+ if (!match) return null;
3591
+ return {
3592
+ major: parseInt(match[1], 10),
3593
+ minor: parseInt(match[2], 10),
3594
+ patch: parseInt(match[3], 10),
3595
+ raw: out.split("\n")[0].trim()
3596
+ };
3597
+ } catch {
3598
+ return null;
3599
+ }
3600
+ }
3601
+ function extractRcloneFs(args) {
3602
+ let foundMount = false;
3603
+ for (const arg of args) {
3604
+ if (arg === "mount") {
3605
+ foundMount = true;
3606
+ continue;
3607
+ }
3608
+ if (foundMount && arg.includes(":") && !arg.startsWith("-") && !arg.startsWith("/")) {
3609
+ return arg;
3610
+ }
3611
+ }
3612
+ return null;
3613
+ }
3614
+ function getRcloneArgs(pid) {
3615
+ try {
3616
+ if (OS === "linux") {
3617
+ const raw = readFileSync6(`/proc/${pid}/cmdline`);
3618
+ return new TextDecoder().decode(raw).split("\0").filter(Boolean);
3619
+ } else {
3620
+ const out = execSync(`ps -o args= -p ${pid}`, {
3621
+ encoding: "utf8",
3622
+ timeout: 2e3,
3623
+ stdio: ["pipe", "pipe", "pipe"]
3624
+ }).trim();
3625
+ return out.split(/\s+/);
3626
+ }
3627
+ } catch {
3628
+ return [];
3629
+ }
3630
+ }
3631
+ function queryRcloneRC(rcAddr, fs) {
3632
+ try {
3633
+ const payload = JSON.stringify({ fs });
3634
+ const out = execSync(
3635
+ `curl -s --max-time 3 -X POST "http://${rcAddr}/vfs/stats" -H "Content-Type: application/json" -d '${payload}' 2>/dev/null`,
3636
+ { encoding: "utf8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }
3637
+ );
3638
+ if (!out.trim()) return null;
3639
+ const data = JSON.parse(out);
3640
+ if (data.status && data.status >= 400) {
3641
+ return { error: data.error || `RC error (status ${data.status})`, erroredFiles: 0, uploadsInProgress: 0, uploadsQueued: 0, outOfSpace: false, bytesUsed: 0, files: 0, totalSize: "unknown" };
3642
+ }
3643
+ const dc = data.diskCache || {};
3644
+ return {
3645
+ erroredFiles: dc.erroredFiles ?? 0,
3646
+ uploadsInProgress: dc.uploadsInProgress ?? 0,
3647
+ uploadsQueued: dc.uploadsQueued ?? 0,
3648
+ outOfSpace: dc.outOfSpace ?? false,
3649
+ bytesUsed: dc.bytesUsed ?? 0,
3650
+ files: dc.files ?? 0,
3651
+ totalSize: data.totalSize || "unknown"
3652
+ };
3653
+ } catch {
3654
+ return { error: "RC endpoint unreachable", erroredFiles: 0, uploadsInProgress: 0, uploadsQueued: 0, outOfSpace: false, bytesUsed: 0, files: 0, totalSize: "unknown" };
3655
+ }
3656
+ }
3657
+ function detectFuseMount(vaultPath) {
3658
+ try {
3659
+ if (OS === "linux") {
3660
+ const mounts = readFileSync6("/proc/mounts", "utf8");
3661
+ let best = null;
3662
+ for (const line of mounts.split("\n")) {
3663
+ const parts = line.split(" ");
3664
+ if (parts.length < 3) continue;
3665
+ const point = parts[1];
3666
+ const fs = parts[2];
3667
+ if (vaultPath.startsWith(point) && (!best || point.length > best.point.length)) {
3668
+ best = { point, fs };
3669
+ }
3670
+ }
3671
+ if (best && best.fs.includes("fuse")) return { mountPoint: best.point, fsType: best.fs };
3672
+ } else if (OS === "darwin") {
3673
+ const out = execSync("mount", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
3674
+ let best = null;
3675
+ for (const line of out.split("\n")) {
3676
+ const match = line.match(/^(\S+) on (\S+) \((.*?)\)/);
3677
+ if (!match) continue;
3678
+ const point = match[2];
3679
+ const opts = match[3];
3680
+ if (opts.includes("fuse") && vaultPath.startsWith(point) && (!best || point.length > best.point.length)) {
3681
+ best = { point, fsType: `fuse.${match[1].split(":")[0] || "unknown"}` };
3682
+ }
3683
+ }
3684
+ if (best) return best;
3685
+ }
3686
+ } catch {
3687
+ }
3688
+ return null;
3689
+ }
3690
+ function writeTest(dir) {
3691
+ const testFile = join23(dir, `.doctor-write-test-${process.pid}.tmp`);
3692
+ const payload = `skillwiki doctor write test \u2014 ${Date.now()} \u2014 ${Math.random().toString(36).slice(2)}`;
3693
+ const start = Date.now();
3694
+ try {
3695
+ writeFileSync4(testFile, payload, "utf8");
3696
+ } catch (e) {
3697
+ return { success: false, writeMs: Date.now() - start, readMs: 0, size: 0, error: `write failed: ${e.message}` };
3698
+ }
3699
+ const writeMs = Date.now() - start;
3700
+ const readStart = Date.now();
3701
+ try {
3702
+ const back = readFile17(testFile, "utf8");
3703
+ const readMs = Date.now() - readStart;
3704
+ if (back !== payload) {
3705
+ try {
3706
+ unlinkSync3(testFile);
3707
+ } catch {
3708
+ }
3709
+ return { success: false, writeMs, readMs, size: Buffer.byteLength(payload, "utf8"), error: "content mismatch \u2014 wrote and read-back differ" };
3710
+ }
3711
+ } catch (e) {
3712
+ try {
3713
+ unlinkSync3(testFile);
3714
+ } catch {
3715
+ }
3716
+ return { success: false, writeMs, readMs: Date.now() - readStart, size: 0, error: `read failed: ${e.message}` };
3717
+ }
3718
+ try {
3719
+ unlinkSync3(testFile);
3720
+ } catch {
3721
+ }
3722
+ return { success: true, writeMs, readMs: Date.now() - readStart, size: Buffer.byteLength(payload, "utf8") };
3723
+ }
3724
+ var FLAG_THRESHOLDS = {
3725
+ "--vfs-write-back": { min: 15, unit: "s", label: "VFS write-back window" },
3726
+ "--vfs-write-wait": { min: 10, unit: "s", label: "VFS write-wait" },
3727
+ "--vfs-cache-max-age": { min: 24, unit: "h", label: "VFS cache max age" }
3728
+ };
3729
+ var MIN_RCLONE_VERSION = { major: 1, minor: 65, patch: 0 };
3730
+
3529
3731
  // src/commands/doctor.ts
3530
3732
  function check(status, id, label, detail) {
3531
3733
  return { id, label, status, detail };
@@ -3544,7 +3746,7 @@ function detectCliChannels(argv, home) {
3544
3746
  channels.push({ name: "dev", path: devPath, isDevLink: true });
3545
3747
  }
3546
3748
  try {
3547
- const whichOut = execSync("which skillwiki 2>/dev/null", { encoding: "utf8" }).trim();
3749
+ const whichOut = execSync2("which skillwiki 2>/dev/null", { encoding: "utf8" }).trim();
3548
3750
  if (whichOut) {
3549
3751
  const isDev = isDevSymlink(whichOut);
3550
3752
  if (!channels.some((c) => c.path === resolve4(whichOut))) {
@@ -3555,13 +3757,13 @@ function detectCliChannels(argv, home) {
3555
3757
  }
3556
3758
  const plugin = findPlugin(home);
3557
3759
  if (plugin) {
3558
- const pluginBin = join23(plugin.installPath, "bin", "skillwiki");
3559
- if (existsSync6(pluginBin)) {
3760
+ const pluginBin = join24(plugin.installPath, "bin", "skillwiki");
3761
+ if (existsSync7(pluginBin)) {
3560
3762
  channels.push({ name: "plugin", path: pluginBin, isDevLink: false });
3561
3763
  }
3562
3764
  }
3563
- const installBin = join23(home, ".claude", "skills", "bin", "skillwiki");
3564
- if (existsSync6(installBin)) {
3765
+ const installBin = join24(home, ".claude", "skills", "bin", "skillwiki");
3766
+ if (existsSync7(installBin)) {
3565
3767
  channels.push({ name: "install", path: installBin, isDevLink: false });
3566
3768
  }
3567
3769
  return channels;
@@ -3613,7 +3815,7 @@ function checkCliChannels(argv, home) {
3613
3815
  }
3614
3816
  async function checkConfigFile(home) {
3615
3817
  const cfgPath = configPath(home);
3616
- if (!existsSync6(cfgPath)) {
3818
+ if (!existsSync7(cfgPath)) {
3617
3819
  return check("warn", "config_file", "Config file exists", `${cfgPath} not found`);
3618
3820
  }
3619
3821
  try {
@@ -3628,7 +3830,7 @@ function checkWikiPathExists(resolvedPath) {
3628
3830
  if (resolvedPath === void 0) {
3629
3831
  return check("error", "wiki_path_exists", "Vault directory exists", "Cannot check \u2014 WIKI_PATH not resolved");
3630
3832
  }
3631
- if (existsSync6(resolvedPath) && statSync2(resolvedPath).isDirectory()) {
3833
+ if (existsSync7(resolvedPath) && statSync2(resolvedPath).isDirectory()) {
3632
3834
  return check("pass", "wiki_path_exists", "Vault directory exists", resolvedPath);
3633
3835
  }
3634
3836
  return check("error", "wiki_path_exists", "Vault directory exists", `${resolvedPath} does not exist or is not a directory`);
@@ -3637,13 +3839,13 @@ function checkVaultStructure(resolvedPath) {
3637
3839
  if (resolvedPath === void 0) {
3638
3840
  return check("error", "vault_structure", "Vault structure valid", "Cannot check \u2014 WIKI_PATH not resolved");
3639
3841
  }
3640
- if (!existsSync6(resolvedPath)) {
3842
+ if (!existsSync7(resolvedPath)) {
3641
3843
  return check("error", "vault_structure", "Vault structure valid", "Cannot check \u2014 vault directory does not exist");
3642
3844
  }
3643
3845
  const missing = [];
3644
- if (!existsSync6(join23(resolvedPath, "SCHEMA.md"))) missing.push("SCHEMA.md");
3846
+ if (!existsSync7(join24(resolvedPath, "SCHEMA.md"))) missing.push("SCHEMA.md");
3645
3847
  for (const dir of ["raw", "entities", "concepts", "meta"]) {
3646
- if (!existsSync6(join23(resolvedPath, dir))) missing.push(dir + "/");
3848
+ if (!existsSync7(join24(resolvedPath, dir))) missing.push(dir + "/");
3647
3849
  }
3648
3850
  if (missing.length === 0) {
3649
3851
  return check("pass", "vault_structure", "Vault structure valid", "All required files and directories present");
@@ -3651,8 +3853,8 @@ function checkVaultStructure(resolvedPath) {
3651
3853
  return check("warn", "vault_structure", "Vault structure valid", `Missing: ${missing.join(", ")} \u2014 run \`skillwiki init\` to add CodeWiki structure`);
3652
3854
  }
3653
3855
  function checkSkillsInstalled(home, cwd) {
3654
- const srcDir = cwd ? join23(cwd, "packages", "skills") : void 0;
3655
- if (srcDir && existsSync6(srcDir)) {
3856
+ const srcDir = cwd ? join24(cwd, "packages", "skills") : void 0;
3857
+ if (srcDir && existsSync7(srcDir)) {
3656
3858
  const found = findSkillMd(srcDir);
3657
3859
  if (found.length > 0) {
3658
3860
  return check("pass", "skills_installed", "Skills installed", `${found.length} SKILL.md file(s) found (source)`);
@@ -3665,8 +3867,8 @@ function checkSkillsInstalled(home, cwd) {
3665
3867
  return check("pass", "skills_installed", "Skills installed", `${found.length} SKILL.md file(s) found (plugin v${plugin.version})`);
3666
3868
  }
3667
3869
  }
3668
- const skillsDir = join23(home, ".claude", "skills");
3669
- if (existsSync6(skillsDir)) {
3870
+ const skillsDir = join24(home, ".claude", "skills");
3871
+ if (existsSync7(skillsDir)) {
3670
3872
  const found = findSkillMd(skillsDir);
3671
3873
  if (found.length > 0) {
3672
3874
  return check("pass", "skills_installed", "Skills installed", `${found.length} SKILL.md file(s) found (CLI install)`);
@@ -3676,10 +3878,10 @@ function checkSkillsInstalled(home, cwd) {
3676
3878
  }
3677
3879
  function checkDuplicateSkills(home) {
3678
3880
  const plugin = findPlugin(home);
3679
- const skillsDir = join23(home, ".claude", "skills");
3881
+ const skillsDir = join24(home, ".claude", "skills");
3680
3882
  const agentSkillDirs = [
3681
- { label: "~/.codex/skills/", path: join23(home, ".codex", "skills") },
3682
- { label: "~/.agents/skills/", path: join23(home, ".agents", "skills") }
3883
+ { label: "~/.codex/skills/", path: join24(home, ".codex", "skills") },
3884
+ { label: "~/.agents/skills/", path: join24(home, ".agents", "skills") }
3683
3885
  ];
3684
3886
  if (!plugin) {
3685
3887
  return check("pass", "skills_duplicate", "Skills not duplicated", "Single install channel");
@@ -3756,8 +3958,8 @@ async function checkProfiles(home) {
3756
3958
  }
3757
3959
  async function checkProjectLocalOverride(cwd) {
3758
3960
  const dir = cwd ?? process.cwd();
3759
- const envPath = join23(dir, ".skillwiki", ".env");
3760
- if (existsSync6(envPath)) {
3961
+ const envPath = join24(dir, ".skillwiki", ".env");
3962
+ if (existsSync7(envPath)) {
3761
3963
  return check("pass", "project_local", "Project-local config", `Found: ${envPath}`);
3762
3964
  }
3763
3965
  return check("pass", "project_local", "Project-local config", "None");
@@ -3766,17 +3968,17 @@ function checkVaultGitRemote(resolvedPath) {
3766
3968
  if (resolvedPath === void 0) {
3767
3969
  return check("error", "vault_git_remote", "Vault git remote", "Cannot check \u2014 WIKI_PATH not resolved");
3768
3970
  }
3769
- if (!existsSync6(join23(resolvedPath, ".git"))) {
3971
+ if (!existsSync7(join24(resolvedPath, ".git"))) {
3770
3972
  return check("warn", "vault_git_remote", "Vault git remote", "Vault is not a git repository \u2014 sync features unavailable");
3771
3973
  }
3772
3974
  try {
3773
- const remote = execSync("git remote", { cwd: resolvedPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3975
+ const remote = execSync2("git remote", { cwd: resolvedPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3774
3976
  if (!remote) {
3775
3977
  return check("warn", "vault_git_remote", "Vault git remote", "No remote configured \u2014 push/pull unavailable");
3776
3978
  }
3777
3979
  let branch = "(no commits yet)";
3778
3980
  try {
3779
- branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: resolvedPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3981
+ branch = execSync2("git rev-parse --abbrev-ref HEAD", { cwd: resolvedPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3780
3982
  } catch {
3781
3983
  }
3782
3984
  return check("pass", "vault_git_remote", "Vault git remote", `Remote: ${remote.split("\n")[0]}, branch: ${branch}`);
@@ -3789,9 +3991,9 @@ function checkObsidianTemplates(resolvedPath) {
3789
3991
  return check("error", "obsidian_templates", "Obsidian templates", "Cannot check \u2014 WIKI_PATH not resolved");
3790
3992
  }
3791
3993
  const missing = [];
3792
- if (!existsSync6(join23(resolvedPath, "_Templates"))) missing.push("_Templates/");
3793
- if (!existsSync6(join23(resolvedPath, ".obsidian", "templates.json"))) missing.push(".obsidian/templates.json");
3794
- if (!existsSync6(join23(resolvedPath, ".obsidian", "app.json"))) missing.push(".obsidian/app.json");
3994
+ if (!existsSync7(join24(resolvedPath, "_Templates"))) missing.push("_Templates/");
3995
+ if (!existsSync7(join24(resolvedPath, ".obsidian", "templates.json"))) missing.push(".obsidian/templates.json");
3996
+ if (!existsSync7(join24(resolvedPath, ".obsidian", "app.json"))) missing.push(".obsidian/app.json");
3795
3997
  if (missing.length === 0) {
3796
3998
  return check("pass", "obsidian_templates", "Obsidian templates", "Template folder and config present");
3797
3999
  }
@@ -3801,8 +4003,8 @@ function checkDotStoreClean(resolvedPath) {
3801
4003
  if (resolvedPath === void 0) {
3802
4004
  return check("error", "dsstore_clean", "No .DS_Store in raw/", "Cannot check \u2014 WIKI_PATH not resolved");
3803
4005
  }
3804
- const rawDir = join23(resolvedPath, "raw");
3805
- if (!existsSync6(rawDir)) {
4006
+ const rawDir = join24(resolvedPath, "raw");
4007
+ if (!existsSync7(rawDir)) {
3806
4008
  return check("pass", "dsstore_clean", "No .DS_Store in raw/", "raw/ directory not found \u2014 check skipped");
3807
4009
  }
3808
4010
  const found = [];
@@ -3817,7 +4019,7 @@ function checkDotStoreClean(resolvedPath) {
3817
4019
  if (entry.name === ".DS_Store") {
3818
4020
  found.push(rel ? `${rel}/.DS_Store` : ".DS_Store");
3819
4021
  } else if (entry.isDirectory()) {
3820
- walk2(join23(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name);
4022
+ walk2(join24(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name);
3821
4023
  }
3822
4024
  }
3823
4025
  })(rawDir, "");
@@ -3830,12 +4032,12 @@ function checkSyncLastPush(resolvedPath) {
3830
4032
  if (resolvedPath === void 0) {
3831
4033
  return check("error", "sync_last_push", "Vault sync recency", "Cannot check \u2014 WIKI_PATH not resolved");
3832
4034
  }
3833
- if (!existsSync6(join23(resolvedPath, ".git"))) {
4035
+ if (!existsSync7(join24(resolvedPath, ".git"))) {
3834
4036
  return check("pass", "sync_last_push", "Vault sync recency", "No git repo \u2014 sync check skipped");
3835
4037
  }
3836
4038
  let timestamp;
3837
4039
  try {
3838
- const out = execSync("git log -1 --format=%ct origin/HEAD", {
4040
+ const out = execSync2("git log -1 --format=%ct origin/HEAD", {
3839
4041
  cwd: resolvedPath,
3840
4042
  encoding: "utf8",
3841
4043
  stdio: ["pipe", "pipe", "pipe"]
@@ -3843,7 +4045,7 @@ function checkSyncLastPush(resolvedPath) {
3843
4045
  timestamp = parseInt(out, 10);
3844
4046
  } catch {
3845
4047
  try {
3846
- const out = execSync("git log -1 --format=%ct HEAD", {
4048
+ const out = execSync2("git log -1 --format=%ct HEAD", {
3847
4049
  cwd: resolvedPath,
3848
4050
  encoding: "utf8",
3849
4051
  stdio: ["pipe", "pipe", "pipe"]
@@ -3862,56 +4064,23 @@ function checkSyncLastPush(resolvedPath) {
3862
4064
  }
3863
4065
  return check("pass", "sync_last_push", "Vault sync recency", `Last push: ${dateStr} (${daysSince2} day(s) ago)`);
3864
4066
  }
3865
- function detectFuseMount(vaultPath) {
3866
- const os = platform();
3867
- try {
3868
- if (os === "linux") {
3869
- const mounts = readFileSync6("/proc/mounts", "utf8");
3870
- let best = null;
3871
- for (const line of mounts.split("\n")) {
3872
- const parts = line.split(" ");
3873
- if (parts.length < 3) continue;
3874
- const point = parts[1];
3875
- const fs = parts[2];
3876
- if (vaultPath.startsWith(point) && (!best || point.length > best.point.length)) {
3877
- best = { point, fs };
3878
- }
3879
- }
3880
- if (best && best.fs.includes("fuse")) return best.point;
3881
- } else if (os === "darwin") {
3882
- const out = execSync("mount", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
3883
- let best = null;
3884
- for (const line of out.split("\n")) {
3885
- const match = line.match(/^(\S+) on (\S+) \((.*?)\)/);
3886
- if (!match) continue;
3887
- const point = match[2];
3888
- const opts = match[3];
3889
- if (opts.includes("fuse") && vaultPath.startsWith(point) && (!best || point.length > best.point.length)) {
3890
- best = { point };
3891
- }
3892
- }
3893
- if (best) return best.point;
3894
- }
3895
- } catch {
3896
- }
3897
- return null;
3898
- }
3899
4067
  function checkS3MountPerf(resolvedPath) {
3900
4068
  if (resolvedPath === void 0) {
3901
4069
  return check("pass", "s3_mount_perf", "S3 mount performance", "No vault path \u2014 check skipped");
3902
4070
  }
3903
- const mountPoint = detectFuseMount(resolvedPath);
3904
- if (!mountPoint) {
4071
+ const fuse = detectFuseMount(resolvedPath);
4072
+ if (!fuse) {
3905
4073
  return check("pass", "s3_mount_perf", "S3 mount performance", "local disk");
3906
4074
  }
3907
- const conceptsDir = join23(resolvedPath, "concepts");
3908
- if (!existsSync6(conceptsDir)) {
4075
+ const mountPoint = fuse.mountPoint;
4076
+ const conceptsDir = join24(resolvedPath, "concepts");
4077
+ if (!existsSync7(conceptsDir)) {
3909
4078
  return check("pass", "s3_mount_perf", "S3 mount performance", `S3 FUSE mount (${mountPoint}), no concepts/ to benchmark`);
3910
4079
  }
3911
4080
  const start = Date.now();
3912
4081
  let timedOut = false;
3913
4082
  try {
3914
- execSync(`rg -l "." "${conceptsDir}"`, {
4083
+ execSync2(`rg -l "." "${conceptsDir}"`, {
3915
4084
  timeout: 5e3,
3916
4085
  encoding: "utf8",
3917
4086
  stdio: ["pipe", "pipe", "pipe"]
@@ -3944,6 +4113,170 @@ function checkS3MountPerf(resolvedPath) {
3944
4113
  `S3 FUSE mount, cache warm (rg scan: ${elapsed.toFixed(3)}s)`
3945
4114
  );
3946
4115
  }
4116
+ function checkRcloneFlagAudit(resolvedPath) {
4117
+ if (!resolvedPath) {
4118
+ return check("pass", "rclone_flags", "rclone VFS flags", "No vault path \u2014 check skipped");
4119
+ }
4120
+ const fuse = detectFuseMount(resolvedPath);
4121
+ if (!fuse) {
4122
+ return check("pass", "rclone_flags", "rclone VFS flags", "local disk \u2014 check skipped");
4123
+ }
4124
+ const pid = findRcloneMountPid();
4125
+ if (pid === null) {
4126
+ return check("warn", "rclone_flags", "rclone VFS flags", `S3 FUSE mount (${fuse.mountPoint}) but no rclone process found \u2014 cannot audit flags`);
4127
+ }
4128
+ const flags = parseRcloneFlags(pid);
4129
+ if (flags.size === 0) {
4130
+ return check("warn", "rclone_flags", "rclone VFS flags", `rclone PID ${pid} found but could not parse flags`);
4131
+ }
4132
+ const warnings = [];
4133
+ for (const [flag, threshold] of Object.entries(FLAG_THRESHOLDS)) {
4134
+ const raw = flags.get(flag);
4135
+ if (raw === void 0) {
4136
+ warnings.push(`${flag} not set (default may be unsafe)`);
4137
+ continue;
4138
+ }
4139
+ const value = parseFloat(raw);
4140
+ if (isNaN(value)) continue;
4141
+ let inSeconds = value;
4142
+ if (raw.endsWith("h")) inSeconds = value * 3600;
4143
+ else if (raw.endsWith("m")) inSeconds = value * 60;
4144
+ const thresholdSec = threshold.unit === "h" ? threshold.min * 3600 : threshold.unit === "m" ? threshold.min * 60 : threshold.min;
4145
+ if (inSeconds < thresholdSec) {
4146
+ warnings.push(`${flag}=${raw} (recommended \u2265${threshold.min}${threshold.unit})`);
4147
+ }
4148
+ }
4149
+ const cacheMode = flags.get("--vfs-cache-mode");
4150
+ if (!cacheMode) {
4151
+ warnings.push("--vfs-cache-mode not set (recommended: full)");
4152
+ } else if (cacheMode !== "full") {
4153
+ warnings.push(`--vfs-cache-mode=${cacheMode} (recommended: full)`);
4154
+ }
4155
+ if (!flags.has("--log-file")) {
4156
+ warnings.push("--log-file not set \u2014 no rclone error log configured");
4157
+ }
4158
+ if (warnings.length > 0) {
4159
+ return check("warn", "rclone_flags", "rclone VFS flags", warnings.join("; "));
4160
+ }
4161
+ return check("pass", "rclone_flags", "rclone VFS flags", `PID ${pid}: all critical flags at safe values`);
4162
+ }
4163
+ function checkRcloneVersion(resolvedPath) {
4164
+ if (!resolvedPath) {
4165
+ return check("pass", "rclone_version", "rclone version", "No vault path \u2014 check skipped");
4166
+ }
4167
+ const fuse = detectFuseMount(resolvedPath);
4168
+ if (!fuse) {
4169
+ return check("pass", "rclone_version", "rclone version", "local disk \u2014 check skipped");
4170
+ }
4171
+ const ver = getRcloneVersion();
4172
+ if (!ver) {
4173
+ return check("warn", "rclone_version", "rclone version", "rclone not found on PATH \u2014 cannot verify version");
4174
+ }
4175
+ const min = MIN_RCLONE_VERSION;
4176
+ const tooOld = ver.major < min.major || ver.major === min.major && ver.minor < min.minor || ver.major === min.major && ver.minor === min.minor && ver.patch < min.patch;
4177
+ if (tooOld) {
4178
+ return check(
4179
+ "warn",
4180
+ "rclone_version",
4181
+ "rclone version",
4182
+ `${ver.raw} \u2014 upgrade to \u2265v${min.major}.${min.minor}.${min.patch} for --vfs-write-wait support (current version may silently ignore this flag)`
4183
+ );
4184
+ }
4185
+ return check("pass", "rclone_version", "rclone version", ver.raw);
4186
+ }
4187
+ function checkWriteTest(resolvedPath) {
4188
+ if (!resolvedPath) {
4189
+ return check("pass", "s3_write_test", "S3 write test", "No vault path \u2014 check skipped");
4190
+ }
4191
+ const fuse = detectFuseMount(resolvedPath);
4192
+ if (!fuse) {
4193
+ return check("pass", "s3_write_test", "S3 write test", "local disk \u2014 check skipped");
4194
+ }
4195
+ const conceptsDir = join24(resolvedPath, "concepts");
4196
+ if (!existsSync7(conceptsDir)) {
4197
+ return check("pass", "s3_write_test", "S3 write test", "no concepts/ dir to test \u2014 check skipped");
4198
+ }
4199
+ const result = writeTest(conceptsDir);
4200
+ if (result.success) {
4201
+ const totalMs = result.writeMs + result.readMs;
4202
+ if (totalMs > 3e3) {
4203
+ return check(
4204
+ "warn",
4205
+ "s3_write_test",
4206
+ "S3 write test",
4207
+ `write+read ${totalMs}ms (write ${result.writeMs}ms, read ${result.readMs}ms, ${result.size}B) \u2014 S3 mount is slow`
4208
+ );
4209
+ }
4210
+ return check(
4211
+ "pass",
4212
+ "s3_write_test",
4213
+ "S3 write test",
4214
+ `write+read ${totalMs}ms (write ${result.writeMs}ms, read ${result.readMs}ms)`
4215
+ );
4216
+ }
4217
+ return check(
4218
+ "warn",
4219
+ "s3_write_test",
4220
+ "S3 write test",
4221
+ `${result.error} \u2014 S3 mount may have a stale FUSE handle or write-back failure`
4222
+ );
4223
+ }
4224
+ function checkVfsCacheHealth(resolvedPath) {
4225
+ if (!resolvedPath) {
4226
+ return check("pass", "vfs_cache_health", "VFS cache health", "No vault path \u2014 check skipped");
4227
+ }
4228
+ const fuse = detectFuseMount(resolvedPath);
4229
+ if (!fuse) {
4230
+ return check("pass", "vfs_cache_health", "VFS cache health", "local disk \u2014 check skipped");
4231
+ }
4232
+ const pid = findRcloneMountPid();
4233
+ if (pid === null) {
4234
+ return check("warn", "vfs_cache_health", "VFS cache health", "no rclone process found \u2014 cannot query VFS stats");
4235
+ }
4236
+ const flags = parseRcloneFlags(pid);
4237
+ const rcAddr = flags.get("--rc-addr") || "127.0.0.1:5572";
4238
+ if (!flags.has("--rc")) {
4239
+ return check(
4240
+ "info",
4241
+ "vfs_cache_health",
4242
+ "VFS cache health",
4243
+ `rclone RC not enabled \u2014 add --rc --rc-addr ${rcAddr} to enable cache health monitoring`
4244
+ );
4245
+ }
4246
+ const args = getRcloneArgs(pid);
4247
+ const fs = extractRcloneFs(args) || "unknown:";
4248
+ const stats = queryRcloneRC(rcAddr, fs || "unknown:");
4249
+ if (!stats) {
4250
+ return check(
4251
+ "warn",
4252
+ "vfs_cache_health",
4253
+ "VFS cache health",
4254
+ `RC endpoint ${rcAddr} unreachable \u2014 is rclone --rc enabled?`
4255
+ );
4256
+ }
4257
+ if (stats.error) {
4258
+ return check("warn", "vfs_cache_health", "VFS cache health", stats.error);
4259
+ }
4260
+ const issues = [];
4261
+ if (stats.uploadsInProgress > 0) issues.push(`${stats.uploadsInProgress} upload(s) in progress`);
4262
+ if (stats.uploadsQueued > 10) issues.push(`${stats.uploadsQueued} upload(s) queued (backlog)`);
4263
+ if (stats.erroredFiles > 0) issues.push(`${stats.erroredFiles} errored file(s)`);
4264
+ if (stats.outOfSpace) issues.push("cache disk full");
4265
+ if (issues.length > 0) {
4266
+ return check(
4267
+ "warn",
4268
+ "vfs_cache_health",
4269
+ "VFS cache health",
4270
+ `${stats.files} files, ${stats.bytesUsed} bytes \u2014 ${issues.join("; ")}`
4271
+ );
4272
+ }
4273
+ return check(
4274
+ "pass",
4275
+ "vfs_cache_health",
4276
+ "VFS cache health",
4277
+ `${stats.files} files, ${(stats.bytesUsed / 1024 / 1024).toFixed(1)}MB \u2014 clean (0 errored, 0 pending)`
4278
+ );
4279
+ }
3947
4280
  function findSkillMd(dir) {
3948
4281
  const results = [];
3949
4282
  let entries;
@@ -3954,9 +4287,9 @@ function findSkillMd(dir) {
3954
4287
  }
3955
4288
  for (const entry of entries) {
3956
4289
  if (entry.isFile() && entry.name === "SKILL.md") {
3957
- results.push(join23(dir, entry.name));
4290
+ results.push(join24(dir, entry.name));
3958
4291
  } else if (entry.isDirectory()) {
3959
- results.push(...findSkillMd(join23(dir, entry.name)));
4292
+ results.push(...findSkillMd(join24(dir, entry.name)));
3960
4293
  }
3961
4294
  }
3962
4295
  return results;
@@ -3970,7 +4303,7 @@ function findSkillNames(dir) {
3970
4303
  return results;
3971
4304
  }
3972
4305
  for (const entry of entries) {
3973
- if (entry.isDirectory() && existsSync6(join23(dir, entry.name, "SKILL.md"))) {
4306
+ if (entry.isDirectory() && existsSync7(join24(dir, entry.name, "SKILL.md"))) {
3974
4307
  results.push(entry.name);
3975
4308
  }
3976
4309
  }
@@ -3997,6 +4330,10 @@ async function runDoctor(input) {
3997
4330
  checks.push(checkSyncLastPush(resolvedPath));
3998
4331
  checks.push(checkDotStoreClean(resolvedPath));
3999
4332
  checks.push(checkS3MountPerf(resolvedPath));
4333
+ checks.push(checkRcloneFlagAudit(resolvedPath));
4334
+ checks.push(checkRcloneVersion(resolvedPath));
4335
+ checks.push(checkWriteTest(resolvedPath));
4336
+ checks.push(checkVfsCacheHealth(resolvedPath));
4000
4337
  checks.push(checkSkillsInstalled(input.home, input.cwd));
4001
4338
  checks.push(checkDuplicateSkills(input.home));
4002
4339
  checks.push(checkNpmUpdate(input.home, input.currentVersion));
@@ -4024,8 +4361,8 @@ async function runDoctor(input) {
4024
4361
  }
4025
4362
 
4026
4363
  // src/commands/archive.ts
4027
- import { rename as rename5, mkdir as mkdir8, readFile as readFile17, writeFile as writeFile9 } from "fs/promises";
4028
- import { join as join24, dirname as dirname9 } from "path";
4364
+ import { rename as rename5, mkdir as mkdir8, readFile as readFile18, writeFile as writeFile9 } from "fs/promises";
4365
+ import { join as join25, dirname as dirname9 } from "path";
4029
4366
  async function runArchive(input) {
4030
4367
  const scan = await scanVault(input.vault);
4031
4368
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -4041,13 +4378,13 @@ async function runArchive(input) {
4041
4378
  }
4042
4379
  if (!relPath) return { exitCode: ExitCode.ARCHIVE_TARGET_NOT_FOUND, result: err("ARCHIVE_TARGET_NOT_FOUND", { page: input.page }) };
4043
4380
  if (relPath.startsWith("_archive/")) return { exitCode: ExitCode.ARCHIVE_ALREADY_ARCHIVED, result: err("ARCHIVE_ALREADY_ARCHIVED", { page: relPath }) };
4044
- const archivePath = join24("_archive", relPath).replace(/\\/g, "/");
4045
- await mkdir8(dirname9(join24(input.vault, archivePath)), { recursive: true });
4381
+ const archivePath = join25("_archive", relPath).replace(/\\/g, "/");
4382
+ await mkdir8(dirname9(join25(input.vault, archivePath)), { recursive: true });
4046
4383
  let indexUpdated = false;
4047
4384
  if (!isRaw) {
4048
- const indexPath = join24(input.vault, "index.md");
4385
+ const indexPath = join25(input.vault, "index.md");
4049
4386
  try {
4050
- const idx = await readFile17(indexPath, "utf8");
4387
+ const idx = await readFile18(indexPath, "utf8");
4051
4388
  const slug = relPath.replace(/\.md$/, "").split("/").pop();
4052
4389
  const originalLines = idx.split("\n");
4053
4390
  const filtered = originalLines.filter((l) => !l.includes(`[[${slug}]]`));
@@ -4059,7 +4396,7 @@ async function runArchive(input) {
4059
4396
  if (e instanceof Error && "code" in e && e.code !== "ENOENT") throw e;
4060
4397
  }
4061
4398
  }
4062
- await rename5(join24(input.vault, relPath), join24(input.vault, archivePath));
4399
+ await rename5(join25(input.vault, relPath), join25(input.vault, archivePath));
4063
4400
  appendLastOp(input.vault, {
4064
4401
  operation: "archive",
4065
4402
  summary: `moved ${relPath} to ${archivePath}`,
@@ -4432,16 +4769,16 @@ ${newBody}`;
4432
4769
  }
4433
4770
 
4434
4771
  // src/commands/update.ts
4435
- import { execSync as execSync2 } from "child_process";
4772
+ import { execSync as execSync3 } from "child_process";
4436
4773
  import { readFileSync as readFileSync7 } from "fs";
4437
- import { join as join25 } from "path";
4774
+ import { join as join26 } from "path";
4438
4775
  function resolveGlobalSkillsRoot() {
4439
4776
  try {
4440
- const globalRoot = execSync2("npm root -g", {
4777
+ const globalRoot = execSync3("npm root -g", {
4441
4778
  encoding: "utf8",
4442
4779
  timeout: 5e3
4443
4780
  }).trim();
4444
- return join25(globalRoot, "skillwiki", "skills");
4781
+ return join26(globalRoot, "skillwiki", "skills");
4445
4782
  } catch {
4446
4783
  return null;
4447
4784
  }
@@ -4467,10 +4804,10 @@ async function runUpdate(input) {
4467
4804
  );
4468
4805
  const currentVersion = pkg2.version;
4469
4806
  const tag = input.distTag ?? "latest";
4470
- const target = join25(input.home, ".claude", "skills");
4807
+ const target = join26(input.home, ".claude", "skills");
4471
4808
  let latest;
4472
4809
  try {
4473
- latest = execSync2(`npm view skillwiki@${tag} version`, {
4810
+ latest = execSync3(`npm view skillwiki@${tag} version`, {
4474
4811
  encoding: "utf8",
4475
4812
  timeout: 15e3
4476
4813
  }).trim();
@@ -4500,7 +4837,7 @@ async function runUpdate(input) {
4500
4837
  };
4501
4838
  }
4502
4839
  try {
4503
- execSync2(`npm install -g skillwiki@${tag}`, {
4840
+ execSync3(`npm install -g skillwiki@${tag}`, {
4504
4841
  stdio: "pipe",
4505
4842
  timeout: 6e4
4506
4843
  });
@@ -4536,17 +4873,17 @@ async function runUpdate(input) {
4536
4873
  }
4537
4874
 
4538
4875
  // src/commands/self-update.ts
4539
- import { execSync as execSync3 } from "child_process";
4540
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
4541
- import { join as join26 } from "path";
4876
+ import { execSync as execSync4 } from "child_process";
4877
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
4878
+ import { join as join27 } from "path";
4542
4879
  var DEFAULT_SOURCE_ROOT_SUFFIX = "/Desktop/code/llm-wiki";
4543
4880
  async function runSelfUpdate(input) {
4544
4881
  const currentVersion = JSON.parse(
4545
4882
  readFileSync8(new URL("../../package.json", import.meta.url), "utf8")
4546
4883
  ).version;
4547
4884
  const sourceRoot = input.sourceRoot ?? `${input.home}${DEFAULT_SOURCE_ROOT_SUFFIX}`;
4548
- const localPkgPath = join26(sourceRoot, "packages", "cli", "package.json");
4549
- const hasLocalSource = existsSync7(localPkgPath);
4885
+ const localPkgPath = join27(sourceRoot, "packages", "cli", "package.json");
4886
+ const hasLocalSource = existsSync8(localPkgPath);
4550
4887
  if (input.check) {
4551
4888
  let availableVersion = null;
4552
4889
  let source;
@@ -4560,7 +4897,7 @@ async function runSelfUpdate(input) {
4560
4897
  } else {
4561
4898
  source = "npm";
4562
4899
  try {
4563
- availableVersion = execSync3("npm view skillwiki@latest version", {
4900
+ availableVersion = execSync4("npm view skillwiki@latest version", {
4564
4901
  encoding: "utf8",
4565
4902
  timeout: 15e3
4566
4903
  }).trim();
@@ -4586,7 +4923,7 @@ async function runSelfUpdate(input) {
4586
4923
  }
4587
4924
  if (hasLocalSource) {
4588
4925
  try {
4589
- execSync3("npm run build -w packages/cli", {
4926
+ execSync4("npm run build -w packages/cli", {
4590
4927
  cwd: sourceRoot,
4591
4928
  stdio: "pipe",
4592
4929
  timeout: 6e4
@@ -4598,7 +4935,7 @@ async function runSelfUpdate(input) {
4598
4935
  };
4599
4936
  }
4600
4937
  try {
4601
- execSync3("npm link ./packages/cli", {
4938
+ execSync4("npm link ./packages/cli", {
4602
4939
  cwd: sourceRoot,
4603
4940
  stdio: "pipe",
4604
4941
  timeout: 3e4
@@ -4630,7 +4967,7 @@ async function runSelfUpdate(input) {
4630
4967
  }
4631
4968
  let latestVersion;
4632
4969
  try {
4633
- latestVersion = execSync3("npm view skillwiki@latest version", {
4970
+ latestVersion = execSync4("npm view skillwiki@latest version", {
4634
4971
  encoding: "utf8",
4635
4972
  timeout: 15e3
4636
4973
  }).trim();
@@ -4653,7 +4990,7 @@ async function runSelfUpdate(input) {
4653
4990
  };
4654
4991
  }
4655
4992
  try {
4656
- execSync3("npm install -g skillwiki@latest", {
4993
+ execSync4("npm install -g skillwiki@latest", {
4657
4994
  stdio: "pipe",
4658
4995
  timeout: 6e4
4659
4996
  });
@@ -4677,10 +5014,10 @@ async function runSelfUpdate(input) {
4677
5014
  }
4678
5015
 
4679
5016
  // src/commands/transcripts.ts
4680
- import { readdir as readdir5, stat as stat6, readFile as readFile18 } from "fs/promises";
4681
- import { join as join27 } from "path";
5017
+ import { readdir as readdir5, stat as stat6, readFile as readFile19 } from "fs/promises";
5018
+ import { join as join28 } from "path";
4682
5019
  async function runTranscripts(input) {
4683
- const dir = join27(input.vault, "raw", "transcripts");
5020
+ const dir = join28(input.vault, "raw", "transcripts");
4684
5021
  let entries;
4685
5022
  try {
4686
5023
  entries = await readdir5(dir, { withFileTypes: true });
@@ -4690,8 +5027,8 @@ async function runTranscripts(input) {
4690
5027
  const transcripts = [];
4691
5028
  for (const entry of entries) {
4692
5029
  if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
4693
- const filePath = join27(dir, entry.name);
4694
- const content = await readFile18(filePath, "utf8");
5030
+ const filePath = join28(dir, entry.name);
5031
+ const content = await readFile19(filePath, "utf8");
4695
5032
  const fm = extractFrontmatter(content);
4696
5033
  if (!fm.ok) continue;
4697
5034
  const ingested = typeof fm.data.ingested === "string" ? fm.data.ingested : "";
@@ -4708,12 +5045,12 @@ async function runTranscripts(input) {
4708
5045
  }
4709
5046
 
4710
5047
  // src/commands/project-index.ts
4711
- import { readdir as readdir6, readFile as readFile19, writeFile as writeFile10, mkdir as mkdir9 } from "fs/promises";
4712
- import { join as join28, dirname as dirname10 } from "path";
5048
+ import { readdir as readdir6, readFile as readFile20, writeFile as writeFile10, mkdir as mkdir9 } from "fs/promises";
5049
+ import { join as join29, dirname as dirname10 } from "path";
4713
5050
  var LAYER2_DIRS = ["entities", "concepts", "comparisons", "queries", "meta"];
4714
5051
  async function runProjectIndex(input) {
4715
5052
  const slug = input.slug;
4716
- const projectDir = join28(input.vault, "projects", slug);
5053
+ const projectDir = join29(input.vault, "projects", slug);
4717
5054
  try {
4718
5055
  await readdir6(projectDir);
4719
5056
  } catch {
@@ -4724,15 +5061,15 @@ async function runProjectIndex(input) {
4724
5061
  }
4725
5062
  const wikilinkPattern = `[[${slug}]]`;
4726
5063
  const entries = [];
4727
- const compoundDir = join28(input.vault, "projects", slug, "compound");
5064
+ const compoundDir = join29(input.vault, "projects", slug, "compound");
4728
5065
  try {
4729
5066
  const compoundFiles = await readdir6(compoundDir, { withFileTypes: true });
4730
5067
  for (const entry of compoundFiles) {
4731
5068
  if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
4732
- const filePath = join28(compoundDir, entry.name);
5069
+ const filePath = join29(compoundDir, entry.name);
4733
5070
  let text;
4734
5071
  try {
4735
- text = await readFile19(filePath, "utf8");
5072
+ text = await readFile20(filePath, "utf8");
4736
5073
  } catch {
4737
5074
  continue;
4738
5075
  }
@@ -4749,16 +5086,16 @@ async function runProjectIndex(input) {
4749
5086
  for (const dir of LAYER2_DIRS) {
4750
5087
  let files;
4751
5088
  try {
4752
- files = await readdir6(join28(input.vault, dir), { withFileTypes: true });
5089
+ files = await readdir6(join29(input.vault, dir), { withFileTypes: true });
4753
5090
  } catch {
4754
5091
  continue;
4755
5092
  }
4756
5093
  for (const entry of files) {
4757
5094
  if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
4758
- const filePath = join28(input.vault, dir, entry.name);
5095
+ const filePath = join29(input.vault, dir, entry.name);
4759
5096
  let text;
4760
5097
  try {
4761
- text = await readFile19(filePath, "utf8");
5098
+ text = await readFile20(filePath, "utf8");
4762
5099
  } catch {
4763
5100
  continue;
4764
5101
  }
@@ -4779,11 +5116,11 @@ async function runProjectIndex(input) {
4779
5116
  const tb = typeOrder[b.type] ?? 99;
4780
5117
  return ta !== tb ? ta - tb : a.title.localeCompare(b.title);
4781
5118
  });
4782
- const indexPath = join28(projectDir, "knowledge.md");
5119
+ const indexPath = join29(projectDir, "knowledge.md");
4783
5120
  let existing = false;
4784
5121
  let stale = false;
4785
5122
  try {
4786
- const existingText = await readFile19(indexPath, "utf8");
5123
+ const existingText = await readFile20(indexPath, "utf8");
4787
5124
  existing = true;
4788
5125
  const existingEntries = existingText.split("\n").filter((l) => l.startsWith("- [["));
4789
5126
  const existingPages = new Set(existingEntries.map((l) => {
@@ -4853,9 +5190,9 @@ ${entries.map((e) => ` ${e.type}: [[${e.page.replace(/\.md$/, "")}]] \u2014 ${e
4853
5190
 
4854
5191
  // src/commands/compound.ts
4855
5192
  import { writeFile as writeFile11, mkdir as mkdir10, readdir as readdir7, unlink as unlink3 } from "fs/promises";
4856
- import { join as join29 } from "path";
4857
- import { existsSync as existsSync8 } from "fs";
4858
- import { readFile as readFile20 } from "fs/promises";
5193
+ import { join as join30 } from "path";
5194
+ import { existsSync as existsSync9 } from "fs";
5195
+ import { readFile as readFile21 } from "fs/promises";
4859
5196
  var RETRO_HEADING_RE = /^## \[(\d{4}-\d{2}-\d{2})(?:\s+[^\]]+)?\] retro \| loop cycle(?: (\d+))?: (.+)$/;
4860
5197
  var FIELD_RE = {
4861
5198
  improve: /^-\s+\*?\*?Improve:?\*?\*?\s*(.+)$/m,
@@ -4953,17 +5290,17 @@ function extractRetroFields(date, cycleName, block) {
4953
5290
  };
4954
5291
  }
4955
5292
  async function runCompound(input) {
4956
- const logPath = join29(input.vault, "log.md");
5293
+ const logPath = join30(input.vault, "log.md");
4957
5294
  let logText;
4958
5295
  try {
4959
- logText = await readFile20(logPath, "utf8");
5296
+ logText = await readFile21(logPath, "utf8");
4960
5297
  } catch {
4961
5298
  return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: logPath }) };
4962
5299
  }
4963
5300
  const entries = parseRetroEntries(logText);
4964
5301
  const promoted = [];
4965
5302
  const skipped = [];
4966
- const compoundDir = join29(input.vault, "projects", input.project, "compound");
5303
+ const compoundDir = join30(input.vault, "projects", input.project, "compound");
4967
5304
  for (const entry of entries) {
4968
5305
  const generalizeValue = entry.generalize.trim();
4969
5306
  if (!/^yes/i.test(generalizeValue)) {
@@ -4971,8 +5308,8 @@ async function runCompound(input) {
4971
5308
  continue;
4972
5309
  }
4973
5310
  const slug = slugify(entry.cycleName);
4974
- const compoundPath = join29(compoundDir, `${slug}.md`);
4975
- if (existsSync8(compoundPath)) {
5311
+ const compoundPath = join30(compoundDir, `${slug}.md`);
5312
+ if (existsSync9(compoundPath)) {
4976
5313
  skipped.push(entry.date);
4977
5314
  continue;
4978
5315
  }
@@ -5010,7 +5347,7 @@ async function runCompound(input) {
5010
5347
  ].join("\n");
5011
5348
  const content = frontmatter + "\n" + body;
5012
5349
  if (!input.dryRun) {
5013
- if (!existsSync8(compoundDir)) {
5350
+ if (!existsSync9(compoundDir)) {
5014
5351
  await mkdir10(compoundDir, { recursive: true });
5015
5352
  }
5016
5353
  await writeFile11(compoundPath, content, "utf8");
@@ -5032,16 +5369,16 @@ async function runCompound(input) {
5032
5369
  };
5033
5370
  }
5034
5371
  async function runCompoundDelete(input) {
5035
- const projectDir = join29(input.vault, "projects", input.project);
5036
- if (!existsSync8(projectDir)) {
5372
+ const projectDir = join30(input.vault, "projects", input.project);
5373
+ if (!existsSync9(projectDir)) {
5037
5374
  return {
5038
5375
  exitCode: ExitCode.PROJECT_NOT_FOUND,
5039
5376
  result: err("PROJECT_NOT_FOUND", { slug: input.project, path: projectDir })
5040
5377
  };
5041
5378
  }
5042
5379
  const entryName = input.entry.replace(/\.md$/, "");
5043
- const compoundPath = join29(projectDir, "compound", `${entryName}.md`);
5044
- if (!existsSync8(compoundPath)) {
5380
+ const compoundPath = join30(projectDir, "compound", `${entryName}.md`);
5381
+ if (!existsSync9(compoundPath)) {
5045
5382
  return {
5046
5383
  exitCode: ExitCode.FILE_NOT_FOUND,
5047
5384
  result: err("FILE_NOT_FOUND", { path: compoundPath })
@@ -5074,8 +5411,8 @@ knowledge.md regenerated`
5074
5411
  };
5075
5412
  }
5076
5413
  async function runCompoundList(input) {
5077
- const compoundDir = join29(input.vault, "projects", input.project, "compound");
5078
- if (!existsSync8(compoundDir)) {
5414
+ const compoundDir = join30(input.vault, "projects", input.project, "compound");
5415
+ if (!existsSync9(compoundDir)) {
5079
5416
  return {
5080
5417
  exitCode: ExitCode.OK,
5081
5418
  result: ok({
@@ -5105,10 +5442,10 @@ could not read compound directory`
5105
5442
  const entries = [];
5106
5443
  for (const dirent of dirents) {
5107
5444
  if (!dirent.isFile() || !dirent.name.endsWith(".md")) continue;
5108
- const filePath = join29(compoundDir, dirent.name);
5445
+ const filePath = join30(compoundDir, dirent.name);
5109
5446
  let text;
5110
5447
  try {
5111
- text = await readFile20(filePath, "utf8");
5448
+ text = await readFile21(filePath, "utf8");
5112
5449
  } catch {
5113
5450
  continue;
5114
5451
  }
@@ -5138,8 +5475,8 @@ no compound entries found`;
5138
5475
 
5139
5476
  // src/commands/observe.ts
5140
5477
  import { mkdir as mkdir11, writeFile as writeFile12 } from "fs/promises";
5141
- import { existsSync as existsSync9, statSync as statSync3 } from "fs";
5142
- import { join as join30 } from "path";
5478
+ import { existsSync as existsSync10, statSync as statSync3 } from "fs";
5479
+ import { join as join31 } from "path";
5143
5480
  import { createHash as createHash4 } from "crypto";
5144
5481
  var ALLOWED_KINDS = /* @__PURE__ */ new Set(["note", "bug", "task", "idea", "session-log"]);
5145
5482
  function slugify2(text) {
@@ -5162,13 +5499,13 @@ async function runObserve(input) {
5162
5499
  result: err("SCHEME_REJECTED", { message: "Text must not be empty" })
5163
5500
  };
5164
5501
  }
5165
- if (!existsSync9(input.vault) || !statSync3(input.vault).isDirectory()) {
5502
+ if (!existsSync10(input.vault) || !statSync3(input.vault).isDirectory()) {
5166
5503
  return {
5167
5504
  exitCode: ExitCode.VAULT_PATH_INVALID,
5168
5505
  result: err("VAULT_PATH_INVALID", { path: input.vault })
5169
5506
  };
5170
5507
  }
5171
- const transcriptsDir = join30(input.vault, "raw", "transcripts");
5508
+ const transcriptsDir = join31(input.vault, "raw", "transcripts");
5172
5509
  try {
5173
5510
  await mkdir11(transcriptsDir, { recursive: true });
5174
5511
  } catch {
@@ -5180,7 +5517,7 @@ async function runObserve(input) {
5180
5517
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5181
5518
  const slug = slugify2(input.text);
5182
5519
  const fileName = `${today}-observation-${slug}.md`;
5183
- const filePath = join30(transcriptsDir, fileName);
5520
+ const filePath = join31(transcriptsDir, fileName);
5184
5521
  const body = `
5185
5522
  ${input.text.trim()}
5186
5523
  `;
@@ -5220,8 +5557,8 @@ ${input.text.trim()}
5220
5557
  }
5221
5558
 
5222
5559
  // src/commands/ingest.ts
5223
- import { readFile as readFile21, writeFile as writeFile13, mkdir as mkdir12 } from "fs/promises";
5224
- import { join as join31 } from "path";
5560
+ import { readFile as readFile22, writeFile as writeFile13, mkdir as mkdir12 } from "fs/promises";
5561
+ import { join as join32 } from "path";
5225
5562
  import { createHash as createHash5 } from "crypto";
5226
5563
  var ALLOWED_TYPES = /* @__PURE__ */ new Set(["entity", "concept", "comparison", "query"]);
5227
5564
  var TYPE_DIR = {
@@ -5380,7 +5717,7 @@ async function runIngest(input) {
5380
5717
  sourceContent = fetchResult.data.body;
5381
5718
  } else {
5382
5719
  try {
5383
- sourceContent = await readFile21(input.source, "utf8");
5720
+ sourceContent = await readFile22(input.source, "utf8");
5384
5721
  } catch {
5385
5722
  return {
5386
5723
  exitCode: ExitCode.FILE_NOT_FOUND,
@@ -5395,8 +5732,8 @@ async function runIngest(input) {
5395
5732
  const rawRelPath = `raw/articles/${slug}.md`;
5396
5733
  const typedDir = TYPE_DIR[input.type] ?? `${input.type}s`;
5397
5734
  const typedRelPath = `${typedDir}/${slug}.md`;
5398
- const rawAbsPath = join31(input.vault, rawRelPath);
5399
- const typedAbsPath = join31(input.vault, typedRelPath);
5735
+ const rawAbsPath = join32(input.vault, rawRelPath);
5736
+ const typedAbsPath = join32(input.vault, typedRelPath);
5400
5737
  const rawContent = buildRawContent(sourceUrl, today, sha256, sourceContent);
5401
5738
  const typedContent = buildTypedContent(
5402
5739
  input.title,
@@ -5459,7 +5796,7 @@ async function runIngest(input) {
5459
5796
  };
5460
5797
  }
5461
5798
  try {
5462
- await mkdir12(join31(input.vault, "raw", "articles"), { recursive: true });
5799
+ await mkdir12(join32(input.vault, "raw", "articles"), { recursive: true });
5463
5800
  await writeFile13(rawAbsPath, rawContent, "utf8");
5464
5801
  } catch (e) {
5465
5802
  return {
@@ -5468,7 +5805,7 @@ async function runIngest(input) {
5468
5805
  };
5469
5806
  }
5470
5807
  try {
5471
- await mkdir12(join31(input.vault, typedDir), { recursive: true });
5808
+ await mkdir12(join32(input.vault, typedDir), { recursive: true });
5472
5809
  await writeFile13(typedAbsPath, typedContent, "utf8");
5473
5810
  } catch (e) {
5474
5811
  return {
@@ -5647,11 +5984,105 @@ ${body}`;
5647
5984
  }
5648
5985
 
5649
5986
  // src/commands/sync.ts
5650
- import { existsSync as existsSync10 } from "fs";
5651
- import { join as join32 } from "path";
5987
+ import { existsSync as existsSync12 } from "fs";
5988
+ import { join as join34 } from "path";
5989
+
5990
+ // src/utils/sync-lock.ts
5991
+ import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync9, renameSync, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5 } from "fs";
5992
+ import { join as join33 } from "path";
5993
+ import { createHash as createHash6 } from "crypto";
5994
+ function getSessionId() {
5995
+ if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
5996
+ if (process.env.SKILLWIKI_SESSION_ID) return process.env.SKILLWIKI_SESSION_ID;
5997
+ return process.pid.toString();
5998
+ }
5999
+ function lockPath(vault) {
6000
+ return join33(vault, ".skillwiki", "sync.lock");
6001
+ }
6002
+ function readLock(vault) {
6003
+ const path = lockPath(vault);
6004
+ if (!existsSync11(path)) return null;
6005
+ try {
6006
+ const raw = readFileSync9(path, "utf8");
6007
+ return JSON.parse(raw);
6008
+ } catch {
6009
+ return null;
6010
+ }
6011
+ }
6012
+ function isStale(lock, now) {
6013
+ const nowTime = (now ?? /* @__PURE__ */ new Date()).getTime();
6014
+ const expiresTime = new Date(lock.expires).getTime();
6015
+ return expiresTime < nowTime;
6016
+ }
6017
+ function acquireLock(vault, opts = {}) {
6018
+ const path = lockPath(vault);
6019
+ const dir = join33(vault, ".skillwiki");
6020
+ if (!existsSync11(dir)) {
6021
+ mkdirSync3(dir, { recursive: true });
6022
+ }
6023
+ const sessionId = opts.sessionId ?? getSessionId();
6024
+ const summary = opts.summary ?? "skillwiki sync";
6025
+ const ttlMinutes = opts.ttlMinutes ?? 30;
6026
+ const force = opts.force ?? false;
6027
+ const now = /* @__PURE__ */ new Date();
6028
+ const acquired = now.toISOString();
6029
+ const expires = new Date(now.getTime() + ttlMinutes * 60 * 1e3).toISOString();
6030
+ const lock = {
6031
+ session_id: sessionId,
6032
+ pid: process.pid,
6033
+ cwd: process.cwd(),
6034
+ summary,
6035
+ acquired,
6036
+ expires
6037
+ };
6038
+ try {
6039
+ const content = JSON.stringify(lock, null, 2) + "\n";
6040
+ writeFileSync5(path, content, { flag: "wx" });
6041
+ return { ok: true, lock };
6042
+ } catch (e) {
6043
+ const err3 = e;
6044
+ if (err3.code !== "EEXIST") throw err3;
6045
+ }
6046
+ const existing = readLock(vault);
6047
+ if (!existing) {
6048
+ writeLockedFile(path, lock);
6049
+ return { ok: true, lock };
6050
+ }
6051
+ if (force || isStale(existing)) {
6052
+ writeLockedFile(path, lock);
6053
+ return { ok: true, lock };
6054
+ }
6055
+ return { ok: false, held: existing };
6056
+ }
6057
+ function writeLockedFile(path, lock) {
6058
+ const tmp = path + ".tmp";
6059
+ const content = JSON.stringify(lock, null, 2) + "\n";
6060
+ writeFileSync5(tmp, content);
6061
+ renameSync(tmp, path);
6062
+ }
6063
+ function releaseLock(vault, opts = {}) {
6064
+ const path = lockPath(vault);
6065
+ if (!existsSync11(path)) {
6066
+ return { released: false };
6067
+ }
6068
+ const sessionId = opts.sessionId ?? getSessionId();
6069
+ const existing = readLock(vault);
6070
+ if (!existing || existing.session_id !== sessionId) {
6071
+ return { released: false };
6072
+ }
6073
+ try {
6074
+ unlinkSync4(path);
6075
+ return { released: true };
6076
+ } catch {
6077
+ return { released: false };
6078
+ }
6079
+ }
6080
+
6081
+ // src/commands/sync.ts
5652
6082
  function runSyncStatus(input) {
5653
6083
  const vault = input.vault;
5654
- if (!existsSync10(join32(vault, ".git"))) {
6084
+ const includeStashes = input.includeStashes ?? false;
6085
+ if (!existsSync12(join34(vault, ".git"))) {
5655
6086
  return {
5656
6087
  exitCode: ExitCode.VAULT_PATH_INVALID,
5657
6088
  result: ok({
@@ -5705,22 +6136,30 @@ function runSyncStatus(input) {
5705
6136
  `last_commit: ${last_commit}`
5706
6137
  ];
5707
6138
  const exitCode = status === "clean" ? ExitCode.OK : ExitCode.LINT_HAS_WARNINGS;
6139
+ let stashes;
6140
+ if (includeStashes) {
6141
+ stashes = enumerateStashes(vault);
6142
+ }
6143
+ const output = {
6144
+ is_git_repo: true,
6145
+ dirty,
6146
+ ahead,
6147
+ behind,
6148
+ last_commit,
6149
+ status,
6150
+ humanHint: hintLines.join("\n")
6151
+ };
6152
+ if (stashes !== void 0) {
6153
+ output.stashes = stashes;
6154
+ }
5708
6155
  return {
5709
6156
  exitCode,
5710
- result: ok({
5711
- is_git_repo: true,
5712
- dirty,
5713
- ahead,
5714
- behind,
5715
- last_commit,
5716
- status,
5717
- humanHint: hintLines.join("\n")
5718
- })
6157
+ result: ok(output)
5719
6158
  };
5720
6159
  }
5721
6160
  async function runSyncPush(input) {
5722
6161
  const vault = input.vault;
5723
- if (!existsSync10(join32(vault, ".git"))) {
6162
+ if (!existsSync12(join34(vault, ".git"))) {
5724
6163
  return {
5725
6164
  exitCode: ExitCode.VAULT_PATH_INVALID,
5726
6165
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -5803,9 +6242,28 @@ async function runSyncPush(input) {
5803
6242
  })
5804
6243
  };
5805
6244
  }
6245
+ function enumerateStashes(vault) {
6246
+ const output = git(vault, ["log", "--format=%gd%x09%s%x09%ct", "-g", "stash"]);
6247
+ if (!output) return [];
6248
+ const now = Date.now();
6249
+ const stashes = [];
6250
+ const lines = output.split("\n").filter((l) => l.trim().length > 0);
6251
+ for (const line of lines) {
6252
+ const parts = line.split(" ");
6253
+ if (parts.length < 3) continue;
6254
+ const ref = parts[0];
6255
+ const message = parts[1];
6256
+ const ctStr = parts[2];
6257
+ const ct = parseInt(ctStr, 10);
6258
+ if (isNaN(ct)) continue;
6259
+ const age_minutes = Math.floor((now - ct * 1e3) / (60 * 1e3));
6260
+ stashes.push({ ref, message, age_minutes });
6261
+ }
6262
+ return stashes;
6263
+ }
5806
6264
  async function runSyncPull(input) {
5807
6265
  const vault = input.vault;
5808
- if (!existsSync10(join32(vault, ".git"))) {
6266
+ if (!existsSync12(join34(vault, ".git"))) {
5809
6267
  return {
5810
6268
  exitCode: ExitCode.VAULT_PATH_INVALID,
5811
6269
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -5878,10 +6336,106 @@ async function runSyncPull(input) {
5878
6336
  })
5879
6337
  };
5880
6338
  }
6339
+ function runSyncPeers(input) {
6340
+ const vault = input.vault;
6341
+ const locks = [];
6342
+ const existingLock = readLock(vault);
6343
+ if (existingLock) {
6344
+ const self = existingLock.session_id === getSessionId();
6345
+ locks.push({ ...existingLock, is_self: self });
6346
+ }
6347
+ const allStashes = enumerateStashes(vault);
6348
+ const stashes = [];
6349
+ for (const stash of allStashes) {
6350
+ let actualMessage = stash.message;
6351
+ const prefixMatch = stash.message.match(/^On [^:]+:\s*(.*)/);
6352
+ if (prefixMatch) {
6353
+ actualMessage = prefixMatch[1];
6354
+ }
6355
+ const match = actualMessage.match(/^wiki-sync:([^:]+):([^:]+):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z):(.*)$/);
6356
+ if (!match) continue;
6357
+ const session_id = match[1];
6358
+ const cwd_hash = match[2];
6359
+ const timestamp = match[3];
6360
+ const summary = match[4];
6361
+ stashes.push({
6362
+ ref: stash.ref,
6363
+ session_id,
6364
+ cwd_hash,
6365
+ timestamp,
6366
+ summary,
6367
+ age_minutes: stash.age_minutes
6368
+ });
6369
+ }
6370
+ const hintParts = [];
6371
+ if (locks.length > 0) hintParts.push(`${locks.length} lock(s)`);
6372
+ if (stashes.length > 0) hintParts.push(`${stashes.length} wiki-sync stash(es)`);
6373
+ const humanHint = hintParts.length > 0 ? hintParts.join(", ") : "no peers detected";
6374
+ return {
6375
+ exitCode: ExitCode.OK,
6376
+ result: ok({
6377
+ locks,
6378
+ stashes,
6379
+ humanHint
6380
+ })
6381
+ };
6382
+ }
6383
+ function runSyncLock(input) {
6384
+ const vault = input.vault;
6385
+ if (!existsSync12(vault)) {
6386
+ return {
6387
+ exitCode: ExitCode.VAULT_PATH_INVALID,
6388
+ result: err("VAULT_PATH_INVALID", { path: vault })
6389
+ };
6390
+ }
6391
+ const result = acquireLock(vault, {
6392
+ sessionId: input.sessionId,
6393
+ summary: input.summary,
6394
+ ttlMinutes: input.ttlMinutes,
6395
+ force: input.force
6396
+ });
6397
+ if (result.ok) {
6398
+ return {
6399
+ exitCode: ExitCode.OK,
6400
+ result: ok({
6401
+ acquired: true,
6402
+ lock: result.lock,
6403
+ humanHint: `lock acquired for ${result.lock.summary} (expires ${result.lock.expires})`
6404
+ })
6405
+ };
6406
+ } else {
6407
+ return {
6408
+ exitCode: ExitCode.SYNC_LOCK_HELD,
6409
+ result: ok({
6410
+ acquired: false,
6411
+ lock: result.held,
6412
+ held_by: result.held,
6413
+ humanHint: `lock held by session ${result.held.session_id} (PID ${result.held.pid}) for ${result.held.summary}`
6414
+ })
6415
+ };
6416
+ }
6417
+ }
6418
+ function runSyncUnlock(input) {
6419
+ const vault = input.vault;
6420
+ if (!existsSync12(vault)) {
6421
+ return {
6422
+ exitCode: ExitCode.VAULT_PATH_INVALID,
6423
+ result: err("VAULT_PATH_INVALID", { path: vault })
6424
+ };
6425
+ }
6426
+ const result = releaseLock(vault, { sessionId: input.sessionId });
6427
+ return {
6428
+ exitCode: ExitCode.OK,
6429
+ result: ok({
6430
+ released: result.released,
6431
+ humanHint: result.released ? "lock released" : "lock not held by this session (no-op)"
6432
+ })
6433
+ };
6434
+ }
5881
6435
 
5882
6436
  // src/commands/backup.ts
5883
- import { statSync as statSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
5884
- import { join as join33, relative as relative3, dirname as dirname11 } from "path";
6437
+ import { statSync as statSync4, readdirSync as readdirSync2, readFileSync as readFileSync10, mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "fs";
6438
+ import { join as join35, relative as relative3, dirname as dirname11 } from "path";
5885
6439
  import { PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
5886
6440
 
5887
6441
  // src/utils/s3-client.ts
@@ -5905,7 +6459,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".obsidian", "_archive", "node_
5905
6459
  function* walkMarkdown(dir, base) {
5906
6460
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
5907
6461
  if (SKIP_DIRS.has(entry.name)) continue;
5908
- const full = join33(dir, entry.name);
6462
+ const full = join35(dir, entry.name);
5909
6463
  if (entry.isDirectory()) {
5910
6464
  yield* walkMarkdown(full, base);
5911
6465
  } else if (entry.name.endsWith(".md")) {
@@ -5928,7 +6482,7 @@ async function runBackupSync(input) {
5928
6482
  let failed = 0;
5929
6483
  const files = [...walkMarkdown(input.vault, input.vault)];
5930
6484
  for (const relPath of files) {
5931
- const absPath = join33(input.vault, relPath);
6485
+ const absPath = join35(input.vault, relPath);
5932
6486
  const localStat = statSync4(absPath);
5933
6487
  let needsUpload = true;
5934
6488
  try {
@@ -5947,7 +6501,7 @@ async function runBackupSync(input) {
5947
6501
  continue;
5948
6502
  }
5949
6503
  try {
5950
- const body = readFileSync9(absPath);
6504
+ const body = readFileSync10(absPath);
5951
6505
  await client.send(new PutObjectCommand({ Bucket: input.bucket, Key: relPath, Body: body }));
5952
6506
  uploaded++;
5953
6507
  } catch {
@@ -6004,7 +6558,7 @@ async function runBackupRestore(input) {
6004
6558
  const objects = list.Contents ?? [];
6005
6559
  for (const obj of objects) {
6006
6560
  if (!obj.Key) continue;
6007
- const localPath = join33(target, obj.Key);
6561
+ const localPath = join35(target, obj.Key);
6008
6562
  try {
6009
6563
  const localStat = statSync4(localPath);
6010
6564
  if (obj.LastModified && localStat.mtime > obj.LastModified) {
@@ -6017,8 +6571,8 @@ async function runBackupRestore(input) {
6017
6571
  const resp = await client.send(new GetObjectCommand({ Bucket: input.bucket, Key: obj.Key }));
6018
6572
  const body = await resp.Body?.transformToByteArray();
6019
6573
  if (body) {
6020
- mkdirSync3(dirname11(localPath), { recursive: true });
6021
- writeFileSync4(localPath, Buffer.from(body));
6574
+ mkdirSync4(dirname11(localPath), { recursive: true });
6575
+ writeFileSync6(localPath, Buffer.from(body));
6022
6576
  downloaded++;
6023
6577
  }
6024
6578
  } catch {
@@ -6050,11 +6604,11 @@ async function runBackupRestore(input) {
6050
6604
  }
6051
6605
 
6052
6606
  // src/commands/status.ts
6053
- import { existsSync as existsSync11, statSync as statSync5 } from "fs";
6054
- import { readFile as readFile22 } from "fs/promises";
6055
- import { join as join34 } from "path";
6607
+ import { existsSync as existsSync13, statSync as statSync5 } from "fs";
6608
+ import { readFile as readFile23 } from "fs/promises";
6609
+ import { join as join36 } from "path";
6056
6610
  async function runStatus(input) {
6057
- if (!existsSync11(input.vault)) {
6611
+ if (!existsSync13(input.vault)) {
6058
6612
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
6059
6613
  }
6060
6614
  const scan = await scanVault(input.vault);
@@ -6079,7 +6633,7 @@ async function runStatus(input) {
6079
6633
  const compound = scan.data.compound.length;
6080
6634
  let schemaVersion = "v1";
6081
6635
  try {
6082
- const schemaContent = await readFile22(join34(input.vault, "SCHEMA.md"), "utf8");
6636
+ const schemaContent = await readFile23(join36(input.vault, "SCHEMA.md"), "utf8");
6083
6637
  const versionMatch = schemaContent.match(/version:\s*["']?([^"'\s\n]+)/i);
6084
6638
  if (versionMatch) schemaVersion = versionMatch[1];
6085
6639
  } catch {
@@ -6140,7 +6694,7 @@ async function runStatus(input) {
6140
6694
 
6141
6695
  // src/commands/seed.ts
6142
6696
  import { mkdir as mkdir13, writeFile as writeFile14, stat as stat7 } from "fs/promises";
6143
- import { join as join35 } from "path";
6697
+ import { join as join37 } from "path";
6144
6698
  var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6145
6699
  var EXAMPLE_PAGES = {
6146
6700
  "entities/example-project.md": `---
@@ -6209,29 +6763,29 @@ Real sources are immutable after ingestion \u2014 never edit them.
6209
6763
  `;
6210
6764
  async function runSeed(input) {
6211
6765
  try {
6212
- await stat7(join35(input.vault, "SCHEMA.md"));
6766
+ await stat7(join37(input.vault, "SCHEMA.md"));
6213
6767
  } catch {
6214
6768
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { root: input.vault, reason: "SCHEMA.md missing \u2014 run `skillwiki init` first" }) };
6215
6769
  }
6216
6770
  const created = [];
6217
6771
  const skipped = [];
6218
6772
  for (const [relPath, content] of Object.entries(EXAMPLE_PAGES)) {
6219
- const absPath = join35(input.vault, relPath);
6773
+ const absPath = join37(input.vault, relPath);
6220
6774
  try {
6221
6775
  await stat7(absPath);
6222
6776
  skipped.push(relPath);
6223
6777
  } catch {
6224
- await mkdir13(join35(absPath, ".."), { recursive: true });
6778
+ await mkdir13(join37(absPath, ".."), { recursive: true });
6225
6779
  await writeFile14(absPath, content, "utf8");
6226
6780
  created.push(relPath);
6227
6781
  }
6228
6782
  }
6229
- const rawPath = join35(input.vault, "raw", "articles", "example-source.md");
6783
+ const rawPath = join37(input.vault, "raw", "articles", "example-source.md");
6230
6784
  try {
6231
6785
  await stat7(rawPath);
6232
6786
  skipped.push("raw/articles/example-source.md");
6233
6787
  } catch {
6234
- await mkdir13(join35(rawPath, ".."), { recursive: true });
6788
+ await mkdir13(join37(rawPath, ".."), { recursive: true });
6235
6789
  await writeFile14(rawPath, EXAMPLE_RAW, "utf8");
6236
6790
  created.push("raw/articles/example-source.md");
6237
6791
  }
@@ -6254,9 +6808,9 @@ async function runSeed(input) {
6254
6808
  }
6255
6809
 
6256
6810
  // src/commands/canvas.ts
6257
- import { readFile as readFile23, writeFile as writeFile15 } from "fs/promises";
6258
- import { existsSync as existsSync12 } from "fs";
6259
- import { join as join36 } from "path";
6811
+ import { readFile as readFile24, writeFile as writeFile15 } from "fs/promises";
6812
+ import { existsSync as existsSync14 } from "fs";
6813
+ import { join as join38 } from "path";
6260
6814
  var NODE_WIDTH = 240;
6261
6815
  var NODE_HEIGHT = 60;
6262
6816
  var COLUMN_SPACING = 400;
@@ -6334,8 +6888,8 @@ function buildCanvasEdges(adjacency) {
6334
6888
  return edges;
6335
6889
  }
6336
6890
  async function runCanvasGenerate(input) {
6337
- const graphPath = input.graphPath ?? join36(input.vault, ".skillwiki", "graph.json");
6338
- if (!existsSync12(graphPath)) {
6891
+ const graphPath = input.graphPath ?? join38(input.vault, ".skillwiki", "graph.json");
6892
+ if (!existsSync14(graphPath)) {
6339
6893
  return {
6340
6894
  exitCode: ExitCode.FILE_NOT_FOUND,
6341
6895
  result: err("FILE_NOT_FOUND", {
@@ -6346,7 +6900,7 @@ async function runCanvasGenerate(input) {
6346
6900
  }
6347
6901
  let raw;
6348
6902
  try {
6349
- raw = await readFile23(graphPath, "utf8");
6903
+ raw = await readFile24(graphPath, "utf8");
6350
6904
  } catch (e) {
6351
6905
  return {
6352
6906
  exitCode: ExitCode.FILE_NOT_FOUND,
@@ -6372,7 +6926,7 @@ async function runCanvasGenerate(input) {
6372
6926
  const nodes = buildCanvasNodes(paths);
6373
6927
  const edges = buildCanvasEdges(graph.adjacency);
6374
6928
  const canvas = { nodes, edges };
6375
- const outPath = join36(input.vault, "vault-graph.canvas");
6929
+ const outPath = join38(input.vault, "vault-graph.canvas");
6376
6930
  try {
6377
6931
  await writeFile15(outPath, JSON.stringify(canvas, null, 2));
6378
6932
  } catch (e) {
@@ -6394,8 +6948,8 @@ written: ${outPath}`
6394
6948
  }
6395
6949
 
6396
6950
  // src/commands/query.ts
6397
- import { readFile as readFile24, stat as stat8 } from "fs/promises";
6398
- import { join as join37 } from "path";
6951
+ import { readFile as readFile25, stat as stat8 } from "fs/promises";
6952
+ import { join as join39 } from "path";
6399
6953
  var W_KEYWORD = 2;
6400
6954
  var W_SOURCE_OVERLAP = 4;
6401
6955
  var W_WIKILINK = 3;
@@ -6516,7 +7070,7 @@ function computeKeywordScore(terms, title, tags, body) {
6516
7070
  return score;
6517
7071
  }
6518
7072
  async function loadOrBuildGraph(vault) {
6519
- const graphPath = join37(vault, ".skillwiki", "graph.json");
7073
+ const graphPath = join39(vault, ".skillwiki", "graph.json");
6520
7074
  let needsBuild = false;
6521
7075
  try {
6522
7076
  const fileStat = await stat8(graphPath);
@@ -6530,7 +7084,7 @@ async function loadOrBuildGraph(vault) {
6530
7084
  if (buildResult.exitCode !== 0) return null;
6531
7085
  }
6532
7086
  try {
6533
- const raw = await readFile24(graphPath, "utf8");
7087
+ const raw = await readFile25(graphPath, "utf8");
6534
7088
  return JSON.parse(raw);
6535
7089
  } catch {
6536
7090
  return null;
@@ -6538,14 +7092,14 @@ async function loadOrBuildGraph(vault) {
6538
7092
  }
6539
7093
 
6540
7094
  // src/utils/auto-commit.ts
6541
- import { existsSync as existsSync13 } from "fs";
6542
- import { join as join38 } from "path";
7095
+ import { existsSync as existsSync15 } from "fs";
7096
+ import { join as join40 } from "path";
6543
7097
  async function postCommit(vault, exitCode) {
6544
7098
  if (exitCode !== 0) return;
6545
7099
  const home = process.env.HOME ?? "";
6546
7100
  const dotenv = await parseDotenvFile(configPath(home));
6547
7101
  if (dotenv["AUTO_COMMIT"] === "false") return;
6548
- if (!existsSync13(join38(vault, ".git"))) return;
7102
+ if (!existsSync15(join40(vault, ".git"))) return;
6549
7103
  const lastOps = readLastOp(vault);
6550
7104
  if (lastOps.length === 0) return;
6551
7105
  const porcelain = git(vault, ["status", "--porcelain"]);
@@ -6574,7 +7128,7 @@ async function postCommit(vault, exitCode) {
6574
7128
  }
6575
7129
 
6576
7130
  // src/cli.ts
6577
- var pkg = JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8"));
7131
+ var pkg = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
6578
7132
  var program = new Command2();
6579
7133
  program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version(pkg.version);
6580
7134
  program.option("--human", "render terminal-readable output instead of JSON");
@@ -6596,7 +7150,7 @@ program.command("validate <file>").description("validate vault page frontmatter
6596
7150
  emit(await runValidate({ file, apply: !!opts.apply, vault }), vault);
6597
7151
  });
6598
7152
  program.command("graph").description("graph subcommands").command("build <vault>").option("--out <path>", "graph output path (default: <vault>/.skillwiki/graph.json)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6599
- const out = opts.out ?? join39(vault, ".skillwiki", "graph.json");
7153
+ const out = opts.out ?? join41(vault, ".skillwiki", "graph.json");
6600
7154
  emit(await runGraphBuild({ vault, out }), vault);
6601
7155
  });
6602
7156
  var canvasCmd = program.command("canvas").description("manage Obsidian canvas files");
@@ -6821,10 +7375,10 @@ program.command("tag-sync [vault]").description("mirror frontmatter enum values
6821
7375
  else emit(await runTagSync({ vault: v.vault, dryRun: !!opts.dryRun }), v.vault);
6822
7376
  });
6823
7377
  var syncCmd = program.command("sync").description("manage vault sync");
6824
- syncCmd.command("status [vault]").description("check vault git sync status").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7378
+ syncCmd.command("status [vault]").description("check vault git sync status").option("--wiki <name>", "wiki profile name").option("--include-stashes", "enumerate all stashes in output", false).action(async (vault, opts) => {
6825
7379
  const v = await resolveVaultArg(vault, opts.wiki);
6826
7380
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6827
- else emit(runSyncStatus({ vault: v.vault }));
7381
+ else emit(runSyncStatus({ vault: v.vault, includeStashes: !!opts.includeStashes }));
6828
7382
  });
6829
7383
  syncCmd.command("push [vault]").description("lint, commit, and push vault changes to remote").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6830
7384
  const v = await resolveVaultArg(vault, opts.wiki);
@@ -6836,6 +7390,24 @@ syncCmd.command("pull [vault]").description("pull remote vault changes and lint"
6836
7390
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6837
7391
  else emit(await runSyncPull({ vault: v.vault }), v.vault);
6838
7392
  });
7393
+ syncCmd.command("lock [vault]").description("acquire advisory lock on vault").option("--summary <text>", "lock description", "skillwiki sync").option("--ttl-minutes <n>", "lock time-to-live in minutes", "30").option("--force", "overwrite existing lock", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7394
+ const v = await resolveVaultArg(vault, opts.wiki);
7395
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7396
+ else {
7397
+ const ttl = parseInt(opts.ttlMinutes, 10) || 30;
7398
+ emit(runSyncLock({ vault: v.vault, summary: opts.summary, ttlMinutes: ttl, force: !!opts.force }));
7399
+ }
7400
+ });
7401
+ syncCmd.command("unlock [vault]").description("release advisory lock on vault").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7402
+ const v = await resolveVaultArg(vault, opts.wiki);
7403
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7404
+ else emit(runSyncUnlock({ vault: v.vault }));
7405
+ });
7406
+ syncCmd.command("peers [vault]").description("list active locks and recent wiki-sync stashes").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7407
+ const v = await resolveVaultArg(vault, opts.wiki);
7408
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7409
+ else emit(runSyncPeers({ vault: v.vault }));
7410
+ });
6839
7411
  var backupCmd = program.command("backup").description("manage S3-compatible remote backup");
6840
7412
  backupCmd.command("sync [vault]").description("sync vault to S3-compatible remote backup").option("--dry-run", "list actions without executing").option("--bucket <name>", "S3 bucket name").option("--endpoint <url>", "S3 endpoint URL").option("--region <region>", "S3 region", "us-east-1").option("--prune", "delete orphaned S3 objects not in vault", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6841
7413
  const v = await resolveVaultArg(vault, opts.wiki);