skillwiki 0.5.5 → 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 join40 } 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
@@ -3469,9 +3470,9 @@ function readCacheRaw(home) {
3469
3470
  function readCache(home) {
3470
3471
  const cache = readCacheRaw(home);
3471
3472
  if (!cache) return { cache: null, hasUpdate: false, isStale: true };
3472
- const isStale = Date.now() - cache.lastCheck >= CHECK_INTERVAL_MS;
3473
+ const isStale2 = Date.now() - cache.lastCheck >= CHECK_INTERVAL_MS;
3473
3474
  const hasUpdate = !!cache.latestVersion && semverGt(cache.latestVersion, cache.currentVersion);
3474
- return { cache, hasUpdate, isStale };
3475
+ return { cache, hasUpdate, isStale: isStale2 };
3475
3476
  }
3476
3477
  function writeCache(home, cache) {
3477
3478
  const p = cachePath(home);
@@ -3491,8 +3492,8 @@ function isDisabled() {
3491
3492
  }
3492
3493
  function triggerAutoUpdate(home, currentVersion) {
3493
3494
  if (isDisabled()) return;
3494
- const { isStale } = readCache(home);
3495
- if (!isStale) return;
3495
+ const { isStale: isStale2 } = readCache(home);
3496
+ if (!isStale2) return;
3496
3497
  const bgScript = new URL("../auto-update-bg.js", import.meta.url).pathname;
3497
3498
  if (!existsSync5(bgScript)) return;
3498
3499
  const child = spawn(process.execPath, [bgScript, home, currentVersion], {
@@ -5983,11 +5984,105 @@ ${body}`;
5983
5984
  }
5984
5985
 
5985
5986
  // src/commands/sync.ts
5986
- import { existsSync as existsSync11 } from "fs";
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";
5987
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
5988
6082
  function runSyncStatus(input) {
5989
6083
  const vault = input.vault;
5990
- if (!existsSync11(join33(vault, ".git"))) {
6084
+ const includeStashes = input.includeStashes ?? false;
6085
+ if (!existsSync12(join34(vault, ".git"))) {
5991
6086
  return {
5992
6087
  exitCode: ExitCode.VAULT_PATH_INVALID,
5993
6088
  result: ok({
@@ -6041,22 +6136,30 @@ function runSyncStatus(input) {
6041
6136
  `last_commit: ${last_commit}`
6042
6137
  ];
6043
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
+ }
6044
6155
  return {
6045
6156
  exitCode,
6046
- result: ok({
6047
- is_git_repo: true,
6048
- dirty,
6049
- ahead,
6050
- behind,
6051
- last_commit,
6052
- status,
6053
- humanHint: hintLines.join("\n")
6054
- })
6157
+ result: ok(output)
6055
6158
  };
6056
6159
  }
6057
6160
  async function runSyncPush(input) {
6058
6161
  const vault = input.vault;
6059
- if (!existsSync11(join33(vault, ".git"))) {
6162
+ if (!existsSync12(join34(vault, ".git"))) {
6060
6163
  return {
6061
6164
  exitCode: ExitCode.VAULT_PATH_INVALID,
6062
6165
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -6139,9 +6242,28 @@ async function runSyncPush(input) {
6139
6242
  })
6140
6243
  };
6141
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
+ }
6142
6264
  async function runSyncPull(input) {
6143
6265
  const vault = input.vault;
6144
- if (!existsSync11(join33(vault, ".git"))) {
6266
+ if (!existsSync12(join34(vault, ".git"))) {
6145
6267
  return {
6146
6268
  exitCode: ExitCode.VAULT_PATH_INVALID,
6147
6269
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -6214,10 +6336,106 @@ async function runSyncPull(input) {
6214
6336
  })
6215
6337
  };
6216
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
+ }
6217
6435
 
6218
6436
  // src/commands/backup.ts
6219
- import { statSync as statSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, mkdirSync as mkdirSync3, writeFileSync as writeFileSync5 } from "fs";
6220
- import { join as join34, 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";
6221
6439
  import { PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
6222
6440
 
6223
6441
  // src/utils/s3-client.ts
@@ -6241,7 +6459,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".obsidian", "_archive", "node_
6241
6459
  function* walkMarkdown(dir, base) {
6242
6460
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
6243
6461
  if (SKIP_DIRS.has(entry.name)) continue;
6244
- const full = join34(dir, entry.name);
6462
+ const full = join35(dir, entry.name);
6245
6463
  if (entry.isDirectory()) {
6246
6464
  yield* walkMarkdown(full, base);
6247
6465
  } else if (entry.name.endsWith(".md")) {
@@ -6264,7 +6482,7 @@ async function runBackupSync(input) {
6264
6482
  let failed = 0;
6265
6483
  const files = [...walkMarkdown(input.vault, input.vault)];
6266
6484
  for (const relPath of files) {
6267
- const absPath = join34(input.vault, relPath);
6485
+ const absPath = join35(input.vault, relPath);
6268
6486
  const localStat = statSync4(absPath);
6269
6487
  let needsUpload = true;
6270
6488
  try {
@@ -6283,7 +6501,7 @@ async function runBackupSync(input) {
6283
6501
  continue;
6284
6502
  }
6285
6503
  try {
6286
- const body = readFileSync9(absPath);
6504
+ const body = readFileSync10(absPath);
6287
6505
  await client.send(new PutObjectCommand({ Bucket: input.bucket, Key: relPath, Body: body }));
6288
6506
  uploaded++;
6289
6507
  } catch {
@@ -6340,7 +6558,7 @@ async function runBackupRestore(input) {
6340
6558
  const objects = list.Contents ?? [];
6341
6559
  for (const obj of objects) {
6342
6560
  if (!obj.Key) continue;
6343
- const localPath = join34(target, obj.Key);
6561
+ const localPath = join35(target, obj.Key);
6344
6562
  try {
6345
6563
  const localStat = statSync4(localPath);
6346
6564
  if (obj.LastModified && localStat.mtime > obj.LastModified) {
@@ -6353,8 +6571,8 @@ async function runBackupRestore(input) {
6353
6571
  const resp = await client.send(new GetObjectCommand({ Bucket: input.bucket, Key: obj.Key }));
6354
6572
  const body = await resp.Body?.transformToByteArray();
6355
6573
  if (body) {
6356
- mkdirSync3(dirname11(localPath), { recursive: true });
6357
- writeFileSync5(localPath, Buffer.from(body));
6574
+ mkdirSync4(dirname11(localPath), { recursive: true });
6575
+ writeFileSync6(localPath, Buffer.from(body));
6358
6576
  downloaded++;
6359
6577
  }
6360
6578
  } catch {
@@ -6386,11 +6604,11 @@ async function runBackupRestore(input) {
6386
6604
  }
6387
6605
 
6388
6606
  // src/commands/status.ts
6389
- import { existsSync as existsSync12, statSync as statSync5 } from "fs";
6607
+ import { existsSync as existsSync13, statSync as statSync5 } from "fs";
6390
6608
  import { readFile as readFile23 } from "fs/promises";
6391
- import { join as join35 } from "path";
6609
+ import { join as join36 } from "path";
6392
6610
  async function runStatus(input) {
6393
- if (!existsSync12(input.vault)) {
6611
+ if (!existsSync13(input.vault)) {
6394
6612
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
6395
6613
  }
6396
6614
  const scan = await scanVault(input.vault);
@@ -6415,7 +6633,7 @@ async function runStatus(input) {
6415
6633
  const compound = scan.data.compound.length;
6416
6634
  let schemaVersion = "v1";
6417
6635
  try {
6418
- const schemaContent = await readFile23(join35(input.vault, "SCHEMA.md"), "utf8");
6636
+ const schemaContent = await readFile23(join36(input.vault, "SCHEMA.md"), "utf8");
6419
6637
  const versionMatch = schemaContent.match(/version:\s*["']?([^"'\s\n]+)/i);
6420
6638
  if (versionMatch) schemaVersion = versionMatch[1];
6421
6639
  } catch {
@@ -6476,7 +6694,7 @@ async function runStatus(input) {
6476
6694
 
6477
6695
  // src/commands/seed.ts
6478
6696
  import { mkdir as mkdir13, writeFile as writeFile14, stat as stat7 } from "fs/promises";
6479
- import { join as join36 } from "path";
6697
+ import { join as join37 } from "path";
6480
6698
  var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6481
6699
  var EXAMPLE_PAGES = {
6482
6700
  "entities/example-project.md": `---
@@ -6545,29 +6763,29 @@ Real sources are immutable after ingestion \u2014 never edit them.
6545
6763
  `;
6546
6764
  async function runSeed(input) {
6547
6765
  try {
6548
- await stat7(join36(input.vault, "SCHEMA.md"));
6766
+ await stat7(join37(input.vault, "SCHEMA.md"));
6549
6767
  } catch {
6550
6768
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { root: input.vault, reason: "SCHEMA.md missing \u2014 run `skillwiki init` first" }) };
6551
6769
  }
6552
6770
  const created = [];
6553
6771
  const skipped = [];
6554
6772
  for (const [relPath, content] of Object.entries(EXAMPLE_PAGES)) {
6555
- const absPath = join36(input.vault, relPath);
6773
+ const absPath = join37(input.vault, relPath);
6556
6774
  try {
6557
6775
  await stat7(absPath);
6558
6776
  skipped.push(relPath);
6559
6777
  } catch {
6560
- await mkdir13(join36(absPath, ".."), { recursive: true });
6778
+ await mkdir13(join37(absPath, ".."), { recursive: true });
6561
6779
  await writeFile14(absPath, content, "utf8");
6562
6780
  created.push(relPath);
6563
6781
  }
6564
6782
  }
6565
- const rawPath = join36(input.vault, "raw", "articles", "example-source.md");
6783
+ const rawPath = join37(input.vault, "raw", "articles", "example-source.md");
6566
6784
  try {
6567
6785
  await stat7(rawPath);
6568
6786
  skipped.push("raw/articles/example-source.md");
6569
6787
  } catch {
6570
- await mkdir13(join36(rawPath, ".."), { recursive: true });
6788
+ await mkdir13(join37(rawPath, ".."), { recursive: true });
6571
6789
  await writeFile14(rawPath, EXAMPLE_RAW, "utf8");
6572
6790
  created.push("raw/articles/example-source.md");
6573
6791
  }
@@ -6591,8 +6809,8 @@ async function runSeed(input) {
6591
6809
 
6592
6810
  // src/commands/canvas.ts
6593
6811
  import { readFile as readFile24, writeFile as writeFile15 } from "fs/promises";
6594
- import { existsSync as existsSync13 } from "fs";
6595
- import { join as join37 } from "path";
6812
+ import { existsSync as existsSync14 } from "fs";
6813
+ import { join as join38 } from "path";
6596
6814
  var NODE_WIDTH = 240;
6597
6815
  var NODE_HEIGHT = 60;
6598
6816
  var COLUMN_SPACING = 400;
@@ -6670,8 +6888,8 @@ function buildCanvasEdges(adjacency) {
6670
6888
  return edges;
6671
6889
  }
6672
6890
  async function runCanvasGenerate(input) {
6673
- const graphPath = input.graphPath ?? join37(input.vault, ".skillwiki", "graph.json");
6674
- if (!existsSync13(graphPath)) {
6891
+ const graphPath = input.graphPath ?? join38(input.vault, ".skillwiki", "graph.json");
6892
+ if (!existsSync14(graphPath)) {
6675
6893
  return {
6676
6894
  exitCode: ExitCode.FILE_NOT_FOUND,
6677
6895
  result: err("FILE_NOT_FOUND", {
@@ -6708,7 +6926,7 @@ async function runCanvasGenerate(input) {
6708
6926
  const nodes = buildCanvasNodes(paths);
6709
6927
  const edges = buildCanvasEdges(graph.adjacency);
6710
6928
  const canvas = { nodes, edges };
6711
- const outPath = join37(input.vault, "vault-graph.canvas");
6929
+ const outPath = join38(input.vault, "vault-graph.canvas");
6712
6930
  try {
6713
6931
  await writeFile15(outPath, JSON.stringify(canvas, null, 2));
6714
6932
  } catch (e) {
@@ -6731,7 +6949,7 @@ written: ${outPath}`
6731
6949
 
6732
6950
  // src/commands/query.ts
6733
6951
  import { readFile as readFile25, stat as stat8 } from "fs/promises";
6734
- import { join as join38 } from "path";
6952
+ import { join as join39 } from "path";
6735
6953
  var W_KEYWORD = 2;
6736
6954
  var W_SOURCE_OVERLAP = 4;
6737
6955
  var W_WIKILINK = 3;
@@ -6852,7 +7070,7 @@ function computeKeywordScore(terms, title, tags, body) {
6852
7070
  return score;
6853
7071
  }
6854
7072
  async function loadOrBuildGraph(vault) {
6855
- const graphPath = join38(vault, ".skillwiki", "graph.json");
7073
+ const graphPath = join39(vault, ".skillwiki", "graph.json");
6856
7074
  let needsBuild = false;
6857
7075
  try {
6858
7076
  const fileStat = await stat8(graphPath);
@@ -6874,14 +7092,14 @@ async function loadOrBuildGraph(vault) {
6874
7092
  }
6875
7093
 
6876
7094
  // src/utils/auto-commit.ts
6877
- import { existsSync as existsSync14 } from "fs";
6878
- import { join as join39 } from "path";
7095
+ import { existsSync as existsSync15 } from "fs";
7096
+ import { join as join40 } from "path";
6879
7097
  async function postCommit(vault, exitCode) {
6880
7098
  if (exitCode !== 0) return;
6881
7099
  const home = process.env.HOME ?? "";
6882
7100
  const dotenv = await parseDotenvFile(configPath(home));
6883
7101
  if (dotenv["AUTO_COMMIT"] === "false") return;
6884
- if (!existsSync14(join39(vault, ".git"))) return;
7102
+ if (!existsSync15(join40(vault, ".git"))) return;
6885
7103
  const lastOps = readLastOp(vault);
6886
7104
  if (lastOps.length === 0) return;
6887
7105
  const porcelain = git(vault, ["status", "--porcelain"]);
@@ -6910,7 +7128,7 @@ async function postCommit(vault, exitCode) {
6910
7128
  }
6911
7129
 
6912
7130
  // src/cli.ts
6913
- 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"));
6914
7132
  var program = new Command2();
6915
7133
  program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version(pkg.version);
6916
7134
  program.option("--human", "render terminal-readable output instead of JSON");
@@ -6932,7 +7150,7 @@ program.command("validate <file>").description("validate vault page frontmatter
6932
7150
  emit(await runValidate({ file, apply: !!opts.apply, vault }), vault);
6933
7151
  });
6934
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) => {
6935
- const out = opts.out ?? join40(vault, ".skillwiki", "graph.json");
7153
+ const out = opts.out ?? join41(vault, ".skillwiki", "graph.json");
6936
7154
  emit(await runGraphBuild({ vault, out }), vault);
6937
7155
  });
6938
7156
  var canvasCmd = program.command("canvas").description("manage Obsidian canvas files");
@@ -7157,10 +7375,10 @@ program.command("tag-sync [vault]").description("mirror frontmatter enum values
7157
7375
  else emit(await runTagSync({ vault: v.vault, dryRun: !!opts.dryRun }), v.vault);
7158
7376
  });
7159
7377
  var syncCmd = program.command("sync").description("manage vault sync");
7160
- 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) => {
7161
7379
  const v = await resolveVaultArg(vault, opts.wiki);
7162
7380
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7163
- else emit(runSyncStatus({ vault: v.vault }));
7381
+ else emit(runSyncStatus({ vault: v.vault, includeStashes: !!opts.includeStashes }));
7164
7382
  });
7165
7383
  syncCmd.command("push [vault]").description("lint, commit, and push vault changes to remote").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7166
7384
  const v = await resolveVaultArg(vault, opts.wiki);
@@ -7172,6 +7390,24 @@ syncCmd.command("pull [vault]").description("pull remote vault changes and lint"
7172
7390
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7173
7391
  else emit(await runSyncPull({ vault: v.vault }), v.vault);
7174
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
+ });
7175
7411
  var backupCmd = program.command("backup").description("manage S3-compatible remote backup");
7176
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) => {
7177
7413
  const v = await resolveVaultArg(vault, opts.wiki);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillwiki": "dist/cli.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "skills": "./",
5
5
  "description": "Project-aware Karpathy-style knowledge base for Claude Code: 18 prompt-only skills (wiki-*, proj-*, using-skillwiki) backed by the deterministic `skillwiki` CLI.",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Project-aware Karpathy-style knowledge base for Codex with 18 prompt-only skills backed by the deterministic skillwiki CLI.",
5
5
  "author": {
6
6
  "name": "karlorz",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillwiki/skills",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "files": [
6
6
  "wiki-*",
@@ -1,47 +1,124 @@
1
1
  ---
2
- version: 0.2.1
2
+ version: 0.3.0
3
3
  name: wiki-sync
4
- description: Safely sync the vault git repository. Runs skillwiki sync status, then guides push or pull with lint guards and conflict resolution.
4
+ description: Safely sync the vault git repository — multi-session safe via advisory lockfile. Runs skillwiki sync status, then guides push or pull with lint guards and conflict resolution.
5
5
  ---
6
6
  # wiki-sync
7
7
  ## When This Skill Activates
8
8
  - User wants to push local vault changes to the remote.
9
9
  - User wants to pull remote changes into their local vault.
10
10
  - User asks about vault sync status, git state, or multi-device coordination.
11
+ - Multiple Claude Code sessions targeting the same vault.
11
12
  - Periodic maintenance before or after editing sessions.
12
13
  ## Pre-orientation reads
13
14
  Standard four reads.
14
15
  ## Steps
15
16
  0. Resolve vault: `skillwiki path` (record source for context).
17
+
18
+ ## Pre-flight peer check (multi-session safe)
19
+
20
+ **Before any git stash or pull/push operation**, check for peer sessions:
21
+
22
+ 1. Run `skillwiki sync peers <vault>` to detect other sessions with active locks or recent `wiki-sync:*` stashes.
23
+ 2. If any non-self peer is present (locked or has stashes newer than 5 minutes):
24
+ - Surface the peer's session_id, PID, and summary to the user
25
+ - Ask the user to wait for the peer to finish, or pass `--force` to proceed anyway
26
+ - If `--force` is not given and peer is detected, **abort and exit**
27
+ 3. Acquire an advisory lock: `skillwiki sync lock <vault> --summary "wiki-sync <op>"` (where `<op>` is "pull" or "push")
28
+ - If lock is held (exit code 48), surface the holder (session_id, PID, summary) and abort
29
+ 4. **Always pair with unlock on exit** (success or error):
30
+ - `skillwiki sync unlock <vault>` in a finally block or error handler
31
+
32
+ ### Stash backlog warning
33
+
34
+ On every invocation, count `wiki-sync:*` stashes older than 24 hours via `skillwiki sync peers`:
35
+ - If any old stashes exist, warn the user: "Found N wiki-sync stash(es) older than 24h — audit and clean before proceeding"
36
+ - **Do not auto-drop old stashes** — the user audits each one
37
+
38
+ ## Sync workflow
39
+
16
40
  1. Run `skillwiki sync status <vault>`. Read the JSON output.
17
- - Exit code 0: vault is clean (nothing to sync).
18
- - Exit code 22: warnings — dirty/ahead/behind (needs action).
41
+ - Exit code 0: vault is clean (nothing to sync).
42
+ - Exit code 22: warnings — dirty/ahead/behind (needs action).
19
43
  2. Present the current state: `status`, `dirty`, `ahead`, `behind`, `last_commit`.
20
44
  3. Ask the user which operation they want: **push**, **pull**, or **both** (pull then push).
45
+
21
46
  ### Push workflow
22
47
  4. If vault is dirty, ask the user to review uncommitted changes before proceeding.
23
48
  5. Run `skillwiki lint <vault>`. If errors exist, stop and report — do not push lint errors to remote.
24
49
  6. If lint passes (errors = 0), stage and commit:
25
- - `git -C <vault> add -A`
26
- - `git -C <vault> commit -m "sync: vault update $(date -u +%Y-%m-%dT%H:%MZ)"`
50
+ - `git -C <vault> add -A`
51
+ - `git -C <vault> commit -m "sync: vault update $(date -u +%Y-%m-%dT%H:%MZ)"`
27
52
  7. Run `git -C <vault> push origin HEAD`. Report result.
28
53
  8. Append one `log.md` entry summarizing: files pushed, lint result, commit hash.
54
+
29
55
  ### Pull workflow
30
- 9. If vault is dirty, stash first: `git -C <vault> stash push -m "auto-stash before pull $(date -u +%Y-%m-%dT%H:%MZ)"`.
31
- 10. Run `git -C <vault> pull --rebase origin HEAD`. Report result.
32
- 11. If a stash was created, pop it: `git -C <vault> stash pop`.
33
- 12. If conflicts occur during stash pop, identify them and present to the user for resolution (see Conflict Resolution below).
34
- 13. Run `skillwiki lint <vault>` after pull to verify vault integrity.
35
- 14. Append one `log.md` entry summarizing: commits pulled, lint result, any conflicts.
56
+ 9. Run `skillwiki sync status <vault> --include-stashes` to check for untracked file collisions (see Untracked file fingerprint below).
57
+ 10. If vault is dirty, stash first with the identifiable name format:
58
+ ```bash
59
+ VAULT="<vault>"
60
+ SESSION_ID="$(echo $CLAUDE_SESSION_ID)" # or fallback to PID/hostname
61
+ CWD_HASH="$(echo -n "$VAULT" | sha256sum | cut -c1-8)"
62
+ ISO_TS="$(date -u +%Y-%m-%dT%H:%MZ)"
63
+ MSG="wiki-sync:${SESSION_ID}:${CWD_HASH}:${ISO_TS}:pre-pull"
64
+ git -C "$VAULT" stash push -m "$MSG"
65
+ ```
66
+ 11. Run `git -C <vault> pull --rebase origin HEAD`. Report result.
67
+ 12. If a stash was created, pop it: `git -C <vault> stash pop`.
68
+ 13. If conflicts occur during stash pop, identify them and present to the user for resolution (see Conflict Resolution below).
69
+ 14. Run `skillwiki lint <vault>` after pull to verify vault integrity.
70
+ 15. Append one `log.md` entry summarizing: commits pulled, lint result, any conflicts.
71
+
36
72
  ### Pull-then-push workflow
37
- 15. Execute the pull workflow (steps 9-13) first.
38
- 16. Then execute the push workflow (steps 4-8).
73
+ 16. Execute the pull workflow (steps 9-14) first.
74
+ 17. Then execute the push workflow (steps 4-8).
75
+
76
+ ## Stash naming convention
77
+
78
+ When `wiki-sync` creates a stash, use the identifiable message format:
79
+
80
+ ```
81
+ wiki-sync:{session_id}:{cwd_hash}:{iso8601_timestamp}:{summary}
82
+ ```
83
+
84
+ - **session_id**: prefer `$CLAUDE_SESSION_ID` env var if set, else `$$` (shell PID), else `unknown`
85
+ - **cwd_hash**: first 8 chars of sha256(`$VAULT` path)
86
+ - **iso8601_timestamp**: e.g., `2026-05-23T03:25:00Z` (UTC)
87
+ - **summary**: short label like `pre-pull`, `pre-push`, or custom reason
88
+
89
+ This allows any session to list `git stash list` and identify which stash came from which session/working directory.
90
+
91
+ ## Untracked file fingerprint (pre-pull)
92
+
93
+ Before `git pull --rebase`, check for untracked files that exist on the remote and may collide:
94
+
95
+ ```bash
96
+ for f in $(git -C "$VAULT" ls-files --others --exclude-standard); do
97
+ if git -C "$VAULT" cat-file -e "origin/main:$f" 2>/dev/null; then
98
+ # File exists on remote; check if identical
99
+ if diff -q <(git -C "$VAULT" show "origin/main:$f") "$VAULT/$f" >/dev/null 2>&1; then
100
+ # Byte-identical — safe to remove (presync artifact)
101
+ rm "$VAULT/$f"
102
+ else
103
+ # DIFFERENT — surface to user, DO NOT silently --include-untracked
104
+ echo "UNTRACKED COLLISION: $f differs from origin/main — surface to user for resolution"
105
+ fi
106
+ fi
107
+ # If file does not exist on remote, leave it alone (pull won't touch it)
108
+ done
109
+ ```
110
+
111
+ If collisions are found (different content), ask the user to resolve manually before pulling.
112
+
39
113
  ## Conflict Resolution
114
+
40
115
  When merge conflicts are detected:
41
- - **Frontmatter conflicts:**
116
+
117
+ ### Frontmatter conflicts
42
118
  - For `updated:` fields: always take the newer timestamp (compare both sides, keep the later one).
43
119
  - For all other frontmatter fields: present both versions to the user and ask which to keep.
44
- - **Body conflicts:**
120
+
121
+ ### Body conflicts
45
122
  - Do not auto-resolve body conflicts.
46
123
  - Mark unresolved regions with `???` on a line by itself between the conflicting versions, so the user can see both sides and decide.
47
124
  - Example:
@@ -51,12 +128,28 @@ Content from local version
51
128
  Content from remote version
52
129
  ```
53
130
  - After resolving conflicts, run `skillwiki lint <vault>` to verify before committing.
131
+
132
+ ### Modify/delete conflicts
133
+
134
+ When `git pull --rebase` reports `CONFLICT (modify/delete)`:
135
+
136
+ 1. Identify the commit that deleted the file:
137
+ ```bash
138
+ git -C "$VAULT" log --diff-filter=D --pretty=oneline -- <path>
139
+ ```
140
+ 2. Read the commit message and any retro / log entry referencing it to determine if the deletion was intentional or accidental.
141
+ 3. Decide:
142
+ - `git -C "$VAULT" rm <path>` — accept the deletion (rebase continues)
143
+ - `git -C "$VAULT" add <path>` — keep the local restoration (rebase continues)
144
+ 4. `git -C "$VAULT" rebase --continue`.
145
+
54
146
  ## Multi-device coordination
55
147
  When the user mentions editing from Obsidian desktop and Claude Code on a server (or any two-device setup):
56
148
  - Recommend pulling before every editing session on each device.
57
149
  - Recommend pushing after every editing session on each device.
58
150
  - If both devices edit the same page between syncs, conflicts are inevitable — the Conflict Resolution section handles this.
59
151
  - Suggest enabling auto-commit in Obsidian (Community Plugins: `obsidian-git`) to reduce dirty-state drift.
152
+
60
153
  ## Rclone-backed vault with git snapshotting (cron pattern)
61
154
  Some deployments use a cloud-backed vault (`rclone mount`) with a separate git repository for versioned snapshots. This pattern separates "live working vault" from "versioned backup".
62
155
  ### Architecture
@@ -105,12 +198,18 @@ bash ~/.hermes/scripts/wiki-snapshot.sh # Re-sync fresh
105
198
  2. **Slow rsync on rclone mounts**: The rclone FUSE mount can be slow for large directory listings. Use `rsync -q` (quiet) to reduce output overhead, and consider `--delete-delay` instead of `--delete` if file churn is high. The rclone mount latency can cause `du` and `find` operations to timeout — this is normal, not an error.
106
199
  3. **Golden Rule violation**: Never mix sync methods on the same vault. If using rclone mount + git snapshotting, do NOT also enable Obsidian Sync, Syncthing, or iCloud on `~/wiki`. The rclone mount IS the sync mechanism.
107
200
  4. **Credential exposure**: The rclone mount and git remote use different credentials. Ensure git credentials are cached or use HTTPS with token, but never commit rclone config to git.
201
+
108
202
  ## Stop conditions
109
203
  - `skillwiki sync status` reports `not_a_repo` — the vault is not a git repository. Advise the user to initialize one.
110
204
  - Lint errors are found before a push — do not push until resolved.
111
205
  - `git push` or `git pull` fails with a network error — report and stop.
206
+ - Peer lock is held or peer stashes exist — abort and ask the user to wait or pass `--force`.
207
+ - Untracked file collision detected on pull — surface to user for manual resolution.
208
+
112
209
  ## Forbidden
113
210
  - Pushing when lint errors exist.
114
211
  - Auto-resolving body conflicts without user review.
115
212
  - Force-pushing (`git push --force`).
116
213
  - Modifying files in `raw/` to resolve conflicts (N9 — archive and re-ingest instead).
214
+ - Stashing without the `wiki-sync:...` name format (breaks peer detection).
215
+ - Force-deleting a peer's lockfile (use `--force` only if peer is confirmed dead).