skillwiki 0.5.5 → 0.6.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.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
@@ -2779,7 +2780,7 @@ function extractSourceEntries(rawFm) {
2779
2780
  return entries;
2780
2781
  }
2781
2782
  var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_dedup", "broken_sources", "tag_not_in_taxonomy"];
2782
- var WARNING_ORDER = ["raw_body_duplicate", "raw_subdirectory_duplicate", "index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "orphans", "compound_refs", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "work_item_health", "orphaned_project_pages", "missing_overview", "missing_diagram"];
2783
+ var WARNING_ORDER = ["raw_body_duplicate", "raw_subdirectory_duplicate", "file_source_url", "index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "orphans", "compound_refs", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "work_item_health", "orphaned_project_pages", "missing_overview", "missing_diagram"];
2783
2784
  var INFO_ORDER = ["bridges", "page_structure", "topic_map_recommended", "frontmatter_wikilink", "wikilink_citation", "missing_tldr", "stale_sections", "cli_refs"];
2784
2785
  async function runLint(input) {
2785
2786
  const buckets = {};
@@ -2864,6 +2865,16 @@ async function runLint(input) {
2864
2865
  if (subDirDupes.length > 0) {
2865
2866
  buckets.raw_subdirectory_duplicate = subDirDupes;
2866
2867
  }
2868
+ const fileSourceUrlFlags = [];
2869
+ for (const raw of scan.data.raw) {
2870
+ const text = await readPage(raw);
2871
+ const split = splitFrontmatter(text);
2872
+ if (!split.ok) continue;
2873
+ if (/^source_url:\s*file:\/\//m.test(split.data.rawFrontmatter)) {
2874
+ fileSourceUrlFlags.push(raw.relPath);
2875
+ }
2876
+ }
2877
+ if (fileSourceUrlFlags.length > 0) buckets.file_source_url = fileSourceUrlFlags;
2867
2878
  const legacyPages = [];
2868
2879
  const orphanedPages = [];
2869
2880
  const structFlags = [];
@@ -3303,6 +3314,45 @@ ${newBody}`;
3303
3314
  else delete buckets.wikilink_citation;
3304
3315
  }
3305
3316
  }
3317
+ if (input.fix && fileSourceUrlFlags.length > 0) {
3318
+ const FILE_FIXED = [];
3319
+ for (const relPath of fileSourceUrlFlags) {
3320
+ try {
3321
+ const absPath = `${input.vault}/${relPath}`;
3322
+ const raw = await readFile15(absPath, "utf8");
3323
+ const parts = raw.split("---", 3);
3324
+ if (parts.length < 3) {
3325
+ unresolved.push(relPath);
3326
+ continue;
3327
+ }
3328
+ const rawFm = parts[1];
3329
+ const rest = parts[2];
3330
+ const sourceMatch = rest.match(/^source:\s*"?(https?:\/\/[^\s\n"]+)"?\s*$/m);
3331
+ if (!sourceMatch) {
3332
+ unresolved.push(relPath);
3333
+ continue;
3334
+ }
3335
+ const realUrl = sourceMatch[1];
3336
+ const newRawFm = rawFm.replace(/^source_url:\s*file:\/\/[^\n]+/m, `source_url: ${realUrl}`);
3337
+ const newContent = `---${newRawFm}---${rest}`;
3338
+ const w = await safeWritePage(absPath, newContent);
3339
+ if (!w.ok) {
3340
+ unresolved.push(relPath);
3341
+ continue;
3342
+ }
3343
+ FILE_FIXED.push(relPath);
3344
+ } catch {
3345
+ unresolved.push(relPath);
3346
+ }
3347
+ }
3348
+ fixed.push(...FILE_FIXED);
3349
+ if (FILE_FIXED.length > 0) {
3350
+ const fixedSet = new Set(FILE_FIXED);
3351
+ const remaining = fileSourceUrlFlags.filter((p) => !fixedSet.has(p));
3352
+ if (remaining.length > 0) buckets.file_source_url = remaining;
3353
+ else delete buckets.file_source_url;
3354
+ }
3355
+ }
3306
3356
  }
3307
3357
  const errorOut = ERROR_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
3308
3358
  const warningOut = WARNING_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
@@ -3469,9 +3519,9 @@ function readCacheRaw(home) {
3469
3519
  function readCache(home) {
3470
3520
  const cache = readCacheRaw(home);
3471
3521
  if (!cache) return { cache: null, hasUpdate: false, isStale: true };
3472
- const isStale = Date.now() - cache.lastCheck >= CHECK_INTERVAL_MS;
3522
+ const isStale2 = Date.now() - cache.lastCheck >= CHECK_INTERVAL_MS;
3473
3523
  const hasUpdate = !!cache.latestVersion && semverGt(cache.latestVersion, cache.currentVersion);
3474
- return { cache, hasUpdate, isStale };
3524
+ return { cache, hasUpdate, isStale: isStale2 };
3475
3525
  }
3476
3526
  function writeCache(home, cache) {
3477
3527
  const p = cachePath(home);
@@ -3491,8 +3541,8 @@ function isDisabled() {
3491
3541
  }
3492
3542
  function triggerAutoUpdate(home, currentVersion) {
3493
3543
  if (isDisabled()) return;
3494
- const { isStale } = readCache(home);
3495
- if (!isStale) return;
3544
+ const { isStale: isStale2 } = readCache(home);
3545
+ if (!isStale2) return;
3496
3546
  const bgScript = new URL("../auto-update-bg.js", import.meta.url).pathname;
3497
3547
  if (!existsSync5(bgScript)) return;
3498
3548
  const child = spawn(process.execPath, [bgScript, home, currentVersion], {
@@ -3680,7 +3730,7 @@ function detectFuseMount(vaultPath) {
3680
3730
  best = { point, fsType: `fuse.${match[1].split(":")[0] || "unknown"}` };
3681
3731
  }
3682
3732
  }
3683
- if (best) return best;
3733
+ if (best) return { mountPoint: best.point, fsType: best.fsType };
3684
3734
  }
3685
3735
  } catch {
3686
3736
  }
@@ -4362,6 +4412,17 @@ async function runDoctor(input) {
4362
4412
  // src/commands/archive.ts
4363
4413
  import { rename as rename5, mkdir as mkdir8, readFile as readFile18, writeFile as writeFile9 } from "fs/promises";
4364
4414
  import { join as join25, dirname as dirname9 } from "path";
4415
+ function countWikilinks(body, slug) {
4416
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4417
+ const re = new RegExp(`\\[\\[${escaped}(?:[|#][^\\]]*)?\\]\\]`, "g");
4418
+ const m = body.match(re);
4419
+ return m ? m.length : 0;
4420
+ }
4421
+ function arraysEqual(a, b) {
4422
+ if (a.length !== b.length) return false;
4423
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
4424
+ return true;
4425
+ }
4365
4426
  async function runArchive(input) {
4366
4427
  const scan = await scanVault(input.vault);
4367
4428
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -4377,14 +4438,81 @@ async function runArchive(input) {
4377
4438
  }
4378
4439
  if (!relPath) return { exitCode: ExitCode.ARCHIVE_TARGET_NOT_FOUND, result: err("ARCHIVE_TARGET_NOT_FOUND", { page: input.page }) };
4379
4440
  if (relPath.startsWith("_archive/")) return { exitCode: ExitCode.ARCHIVE_ALREADY_ARCHIVED, result: err("ARCHIVE_ALREADY_ARCHIVED", { page: relPath }) };
4441
+ const slug = relPath.replace(/\.md$/, "").split("/").pop();
4380
4442
  const archivePath = join25("_archive", relPath).replace(/\\/g, "/");
4443
+ let cascade;
4444
+ if (input.cascade) {
4445
+ const wikilinkRefs = [];
4446
+ const sourceArrayRefs = [];
4447
+ for (const page of scan.data.typedKnowledge) {
4448
+ if (page.relPath === relPath) continue;
4449
+ const text = await readPage(page);
4450
+ const split = splitFrontmatter(text);
4451
+ if (!split.ok) continue;
4452
+ const wl = countWikilinks(split.data.body, slug);
4453
+ if (wl > 0) wikilinkRefs.push({ page: page.relPath, count: wl });
4454
+ const fm = extractFrontmatter(text);
4455
+ if (!fm.ok) continue;
4456
+ const sources = fm.data.sources;
4457
+ if (Array.isArray(sources) && sources.includes(relPath)) {
4458
+ const before = sources.filter((s) => typeof s === "string");
4459
+ const after = before.filter((s) => s !== relPath);
4460
+ sourceArrayRefs.push({ page: page.relPath, sources_before: before, sources_after: after });
4461
+ }
4462
+ }
4463
+ const indexRefs = [];
4464
+ if (!isRaw) {
4465
+ try {
4466
+ const idx = await readFile18(join25(input.vault, "index.md"), "utf8");
4467
+ idx.split("\n").forEach((line, i) => {
4468
+ if (line.includes(`[[${slug}]]`)) indexRefs.push({ line: i + 1, text: line });
4469
+ });
4470
+ } catch (e) {
4471
+ if (e instanceof Error && "code" in e && e.code !== "ENOENT") throw e;
4472
+ }
4473
+ }
4474
+ cascade = { wikilink_refs: wikilinkRefs, index_refs: indexRefs, source_array_refs: sourceArrayRefs };
4475
+ }
4476
+ if (input.cascade && !input.apply) {
4477
+ const summary = `DRY-RUN \u2014 would archive ${relPath}; ${cascade.wikilink_refs.length} wikilink ref(s), ${cascade.index_refs.length} index ref(s), ${cascade.source_array_refs.length} source array ref(s).`;
4478
+ return {
4479
+ exitCode: ExitCode.OK,
4480
+ result: ok({
4481
+ archived_from: relPath,
4482
+ archived_to: archivePath,
4483
+ index_updated: false,
4484
+ applied: false,
4485
+ cascade,
4486
+ humanHint: summary
4487
+ })
4488
+ };
4489
+ }
4490
+ if (input.cascade && input.apply && cascade) {
4491
+ for (const ref of cascade.source_array_refs) {
4492
+ const absPath = join25(input.vault, ref.page);
4493
+ const text = await readFile18(absPath, "utf8");
4494
+ const split = splitFrontmatter(text);
4495
+ if (!split.ok) continue;
4496
+ const before = split.data.rawFrontmatter;
4497
+ const newSourcesYaml = ref.sources_after.length === 0 ? "sources: []" : "sources:\n" + ref.sources_after.map((s) => ` - ${s}`).join("\n");
4498
+ const fmRewritten = before.replace(
4499
+ /^sources:\s*(?:\[[^\]]*\]|(?:\r?\n(?:\s*-\s.*))+)/m,
4500
+ newSourcesYaml
4501
+ );
4502
+ if (fmRewritten === before) continue;
4503
+ if (!arraysEqual(ref.sources_after, ref.sources_before)) {
4504
+ await writeFile9(absPath, `---
4505
+ ${fmRewritten}
4506
+ ---${split.data.body}`, "utf8");
4507
+ }
4508
+ }
4509
+ }
4381
4510
  await mkdir8(dirname9(join25(input.vault, archivePath)), { recursive: true });
4382
4511
  let indexUpdated = false;
4383
4512
  if (!isRaw) {
4384
4513
  const indexPath = join25(input.vault, "index.md");
4385
4514
  try {
4386
4515
  const idx = await readFile18(indexPath, "utf8");
4387
- const slug = relPath.replace(/\.md$/, "").split("/").pop();
4388
4516
  const originalLines = idx.split("\n");
4389
4517
  const filtered = originalLines.filter((l) => !l.includes(`[[${slug}]]`));
4390
4518
  if (filtered.length !== originalLines.length) {
@@ -4397,12 +4525,24 @@ async function runArchive(input) {
4397
4525
  }
4398
4526
  await rename5(join25(input.vault, relPath), join25(input.vault, archivePath));
4399
4527
  appendLastOp(input.vault, {
4400
- operation: "archive",
4401
- summary: `moved ${relPath} to ${archivePath}`,
4528
+ operation: input.cascade ? "archive-cascade" : "archive",
4529
+ summary: `moved ${relPath} to ${archivePath}${input.cascade ? ` (cascade: ${cascade?.source_array_refs.length ?? 0} source arrays updated)` : ""}`,
4402
4530
  files: [relPath],
4403
4531
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
4404
4532
  });
4405
- return { exitCode: ExitCode.OK, result: ok({ archived_from: relPath, archived_to: archivePath, index_updated: indexUpdated, humanHint: `${relPath} -> ${archivePath}${indexUpdated ? " (index updated)" : ""}` }) };
4533
+ const applied = input.cascade ? true : void 0;
4534
+ const cascadeNote = input.cascade ? ` (cascade: ${cascade.source_array_refs.length} src arrays updated, ${cascade.wikilink_refs.length} wikilinks reported)` : "";
4535
+ return {
4536
+ exitCode: ExitCode.OK,
4537
+ result: ok({
4538
+ archived_from: relPath,
4539
+ archived_to: archivePath,
4540
+ index_updated: indexUpdated,
4541
+ ...applied !== void 0 ? { applied } : {},
4542
+ ...cascade ? { cascade } : {},
4543
+ humanHint: `${relPath} -> ${archivePath}${indexUpdated ? " (index updated)" : ""}${cascadeNote}`
4544
+ })
4545
+ };
4406
4546
  }
4407
4547
 
4408
4548
  // src/commands/drift.ts
@@ -5983,11 +6123,105 @@ ${body}`;
5983
6123
  }
5984
6124
 
5985
6125
  // src/commands/sync.ts
5986
- import { existsSync as existsSync11 } from "fs";
6126
+ import { existsSync as existsSync12 } from "fs";
6127
+ import { join as join34 } from "path";
6128
+
6129
+ // src/utils/sync-lock.ts
6130
+ import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync9, renameSync, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5 } from "fs";
5987
6131
  import { join as join33 } from "path";
6132
+ import { createHash as createHash6 } from "crypto";
6133
+ function getSessionId() {
6134
+ if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
6135
+ if (process.env.SKILLWIKI_SESSION_ID) return process.env.SKILLWIKI_SESSION_ID;
6136
+ return process.pid.toString();
6137
+ }
6138
+ function lockPath(vault) {
6139
+ return join33(vault, ".skillwiki", "sync.lock");
6140
+ }
6141
+ function readLock(vault) {
6142
+ const path = lockPath(vault);
6143
+ if (!existsSync11(path)) return null;
6144
+ try {
6145
+ const raw = readFileSync9(path, "utf8");
6146
+ return JSON.parse(raw);
6147
+ } catch {
6148
+ return null;
6149
+ }
6150
+ }
6151
+ function isStale(lock, now) {
6152
+ const nowTime = (now ?? /* @__PURE__ */ new Date()).getTime();
6153
+ const expiresTime = new Date(lock.expires).getTime();
6154
+ return expiresTime < nowTime;
6155
+ }
6156
+ function acquireLock(vault, opts = {}) {
6157
+ const path = lockPath(vault);
6158
+ const dir = join33(vault, ".skillwiki");
6159
+ if (!existsSync11(dir)) {
6160
+ mkdirSync3(dir, { recursive: true });
6161
+ }
6162
+ const sessionId = opts.sessionId ?? getSessionId();
6163
+ const summary = opts.summary ?? "skillwiki sync";
6164
+ const ttlMinutes = opts.ttlMinutes ?? 30;
6165
+ const force = opts.force ?? false;
6166
+ const now = /* @__PURE__ */ new Date();
6167
+ const acquired = now.toISOString();
6168
+ const expires = new Date(now.getTime() + ttlMinutes * 60 * 1e3).toISOString();
6169
+ const lock = {
6170
+ session_id: sessionId,
6171
+ pid: process.pid,
6172
+ cwd: process.cwd(),
6173
+ summary,
6174
+ acquired,
6175
+ expires
6176
+ };
6177
+ try {
6178
+ const content = JSON.stringify(lock, null, 2) + "\n";
6179
+ writeFileSync5(path, content, { flag: "wx" });
6180
+ return { ok: true, lock };
6181
+ } catch (e) {
6182
+ const err3 = e;
6183
+ if (err3.code !== "EEXIST") throw err3;
6184
+ }
6185
+ const existing = readLock(vault);
6186
+ if (!existing) {
6187
+ writeLockedFile(path, lock);
6188
+ return { ok: true, lock };
6189
+ }
6190
+ if (force || isStale(existing)) {
6191
+ writeLockedFile(path, lock);
6192
+ return { ok: true, lock };
6193
+ }
6194
+ return { ok: false, held: existing };
6195
+ }
6196
+ function writeLockedFile(path, lock) {
6197
+ const tmp = path + ".tmp";
6198
+ const content = JSON.stringify(lock, null, 2) + "\n";
6199
+ writeFileSync5(tmp, content);
6200
+ renameSync(tmp, path);
6201
+ }
6202
+ function releaseLock(vault, opts = {}) {
6203
+ const path = lockPath(vault);
6204
+ if (!existsSync11(path)) {
6205
+ return { released: false };
6206
+ }
6207
+ const sessionId = opts.sessionId ?? getSessionId();
6208
+ const existing = readLock(vault);
6209
+ if (!existing || existing.session_id !== sessionId) {
6210
+ return { released: false };
6211
+ }
6212
+ try {
6213
+ unlinkSync4(path);
6214
+ return { released: true };
6215
+ } catch {
6216
+ return { released: false };
6217
+ }
6218
+ }
6219
+
6220
+ // src/commands/sync.ts
5988
6221
  function runSyncStatus(input) {
5989
6222
  const vault = input.vault;
5990
- if (!existsSync11(join33(vault, ".git"))) {
6223
+ const includeStashes = input.includeStashes ?? false;
6224
+ if (!existsSync12(join34(vault, ".git"))) {
5991
6225
  return {
5992
6226
  exitCode: ExitCode.VAULT_PATH_INVALID,
5993
6227
  result: ok({
@@ -6041,22 +6275,30 @@ function runSyncStatus(input) {
6041
6275
  `last_commit: ${last_commit}`
6042
6276
  ];
6043
6277
  const exitCode = status === "clean" ? ExitCode.OK : ExitCode.LINT_HAS_WARNINGS;
6278
+ let stashes;
6279
+ if (includeStashes) {
6280
+ stashes = enumerateStashes(vault);
6281
+ }
6282
+ const output = {
6283
+ is_git_repo: true,
6284
+ dirty,
6285
+ ahead,
6286
+ behind,
6287
+ last_commit,
6288
+ status,
6289
+ humanHint: hintLines.join("\n")
6290
+ };
6291
+ if (stashes !== void 0) {
6292
+ output.stashes = stashes;
6293
+ }
6044
6294
  return {
6045
6295
  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
- })
6296
+ result: ok(output)
6055
6297
  };
6056
6298
  }
6057
6299
  async function runSyncPush(input) {
6058
6300
  const vault = input.vault;
6059
- if (!existsSync11(join33(vault, ".git"))) {
6301
+ if (!existsSync12(join34(vault, ".git"))) {
6060
6302
  return {
6061
6303
  exitCode: ExitCode.VAULT_PATH_INVALID,
6062
6304
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -6139,9 +6381,28 @@ async function runSyncPush(input) {
6139
6381
  })
6140
6382
  };
6141
6383
  }
6384
+ function enumerateStashes(vault) {
6385
+ const output = git(vault, ["log", "--format=%gd%x09%s%x09%ct", "-g", "stash"]);
6386
+ if (!output) return [];
6387
+ const now = Date.now();
6388
+ const stashes = [];
6389
+ const lines = output.split("\n").filter((l) => l.trim().length > 0);
6390
+ for (const line of lines) {
6391
+ const parts = line.split(" ");
6392
+ if (parts.length < 3) continue;
6393
+ const ref = parts[0];
6394
+ const message = parts[1];
6395
+ const ctStr = parts[2];
6396
+ const ct = parseInt(ctStr, 10);
6397
+ if (isNaN(ct)) continue;
6398
+ const age_minutes = Math.floor((now - ct * 1e3) / (60 * 1e3));
6399
+ stashes.push({ ref, message, age_minutes });
6400
+ }
6401
+ return stashes;
6402
+ }
6142
6403
  async function runSyncPull(input) {
6143
6404
  const vault = input.vault;
6144
- if (!existsSync11(join33(vault, ".git"))) {
6405
+ if (!existsSync12(join34(vault, ".git"))) {
6145
6406
  return {
6146
6407
  exitCode: ExitCode.VAULT_PATH_INVALID,
6147
6408
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -6214,10 +6475,106 @@ async function runSyncPull(input) {
6214
6475
  })
6215
6476
  };
6216
6477
  }
6478
+ function runSyncPeers(input) {
6479
+ const vault = input.vault;
6480
+ const locks = [];
6481
+ const existingLock = readLock(vault);
6482
+ if (existingLock) {
6483
+ const self = existingLock.session_id === getSessionId();
6484
+ locks.push({ ...existingLock, is_self: self });
6485
+ }
6486
+ const allStashes = enumerateStashes(vault);
6487
+ const stashes = [];
6488
+ for (const stash of allStashes) {
6489
+ let actualMessage = stash.message;
6490
+ const prefixMatch = stash.message.match(/^On [^:]+:\s*(.*)/);
6491
+ if (prefixMatch) {
6492
+ actualMessage = prefixMatch[1];
6493
+ }
6494
+ const match = actualMessage.match(/^wiki-sync:([^:]+):([^:]+):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z):(.*)$/);
6495
+ if (!match) continue;
6496
+ const session_id = match[1];
6497
+ const cwd_hash = match[2];
6498
+ const timestamp = match[3];
6499
+ const summary = match[4];
6500
+ stashes.push({
6501
+ ref: stash.ref,
6502
+ session_id,
6503
+ cwd_hash,
6504
+ timestamp,
6505
+ summary,
6506
+ age_minutes: stash.age_minutes
6507
+ });
6508
+ }
6509
+ const hintParts = [];
6510
+ if (locks.length > 0) hintParts.push(`${locks.length} lock(s)`);
6511
+ if (stashes.length > 0) hintParts.push(`${stashes.length} wiki-sync stash(es)`);
6512
+ const humanHint = hintParts.length > 0 ? hintParts.join(", ") : "no peers detected";
6513
+ return {
6514
+ exitCode: ExitCode.OK,
6515
+ result: ok({
6516
+ locks,
6517
+ stashes,
6518
+ humanHint
6519
+ })
6520
+ };
6521
+ }
6522
+ function runSyncLock(input) {
6523
+ const vault = input.vault;
6524
+ if (!existsSync12(vault)) {
6525
+ return {
6526
+ exitCode: ExitCode.VAULT_PATH_INVALID,
6527
+ result: err("VAULT_PATH_INVALID", { path: vault })
6528
+ };
6529
+ }
6530
+ const result = acquireLock(vault, {
6531
+ sessionId: input.sessionId,
6532
+ summary: input.summary,
6533
+ ttlMinutes: input.ttlMinutes,
6534
+ force: input.force
6535
+ });
6536
+ if (result.ok) {
6537
+ return {
6538
+ exitCode: ExitCode.OK,
6539
+ result: ok({
6540
+ acquired: true,
6541
+ lock: result.lock,
6542
+ humanHint: `lock acquired for ${result.lock.summary} (expires ${result.lock.expires})`
6543
+ })
6544
+ };
6545
+ } else {
6546
+ return {
6547
+ exitCode: ExitCode.SYNC_LOCK_HELD,
6548
+ result: ok({
6549
+ acquired: false,
6550
+ lock: result.held,
6551
+ held_by: result.held,
6552
+ humanHint: `lock held by session ${result.held.session_id} (PID ${result.held.pid}) for ${result.held.summary}`
6553
+ })
6554
+ };
6555
+ }
6556
+ }
6557
+ function runSyncUnlock(input) {
6558
+ const vault = input.vault;
6559
+ if (!existsSync12(vault)) {
6560
+ return {
6561
+ exitCode: ExitCode.VAULT_PATH_INVALID,
6562
+ result: err("VAULT_PATH_INVALID", { path: vault })
6563
+ };
6564
+ }
6565
+ const result = releaseLock(vault, { sessionId: input.sessionId });
6566
+ return {
6567
+ exitCode: ExitCode.OK,
6568
+ result: ok({
6569
+ released: result.released,
6570
+ humanHint: result.released ? "lock released" : "lock not held by this session (no-op)"
6571
+ })
6572
+ };
6573
+ }
6217
6574
 
6218
6575
  // 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";
6576
+ import { statSync as statSync4, readdirSync as readdirSync2, readFileSync as readFileSync10, mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "fs";
6577
+ import { join as join35, relative as relative3, dirname as dirname11 } from "path";
6221
6578
  import { PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
6222
6579
 
6223
6580
  // src/utils/s3-client.ts
@@ -6241,7 +6598,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".obsidian", "_archive", "node_
6241
6598
  function* walkMarkdown(dir, base) {
6242
6599
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
6243
6600
  if (SKIP_DIRS.has(entry.name)) continue;
6244
- const full = join34(dir, entry.name);
6601
+ const full = join35(dir, entry.name);
6245
6602
  if (entry.isDirectory()) {
6246
6603
  yield* walkMarkdown(full, base);
6247
6604
  } else if (entry.name.endsWith(".md")) {
@@ -6264,7 +6621,7 @@ async function runBackupSync(input) {
6264
6621
  let failed = 0;
6265
6622
  const files = [...walkMarkdown(input.vault, input.vault)];
6266
6623
  for (const relPath of files) {
6267
- const absPath = join34(input.vault, relPath);
6624
+ const absPath = join35(input.vault, relPath);
6268
6625
  const localStat = statSync4(absPath);
6269
6626
  let needsUpload = true;
6270
6627
  try {
@@ -6283,7 +6640,7 @@ async function runBackupSync(input) {
6283
6640
  continue;
6284
6641
  }
6285
6642
  try {
6286
- const body = readFileSync9(absPath);
6643
+ const body = readFileSync10(absPath);
6287
6644
  await client.send(new PutObjectCommand({ Bucket: input.bucket, Key: relPath, Body: body }));
6288
6645
  uploaded++;
6289
6646
  } catch {
@@ -6340,7 +6697,7 @@ async function runBackupRestore(input) {
6340
6697
  const objects = list.Contents ?? [];
6341
6698
  for (const obj of objects) {
6342
6699
  if (!obj.Key) continue;
6343
- const localPath = join34(target, obj.Key);
6700
+ const localPath = join35(target, obj.Key);
6344
6701
  try {
6345
6702
  const localStat = statSync4(localPath);
6346
6703
  if (obj.LastModified && localStat.mtime > obj.LastModified) {
@@ -6353,8 +6710,8 @@ async function runBackupRestore(input) {
6353
6710
  const resp = await client.send(new GetObjectCommand({ Bucket: input.bucket, Key: obj.Key }));
6354
6711
  const body = await resp.Body?.transformToByteArray();
6355
6712
  if (body) {
6356
- mkdirSync3(dirname11(localPath), { recursive: true });
6357
- writeFileSync5(localPath, Buffer.from(body));
6713
+ mkdirSync4(dirname11(localPath), { recursive: true });
6714
+ writeFileSync6(localPath, Buffer.from(body));
6358
6715
  downloaded++;
6359
6716
  }
6360
6717
  } catch {
@@ -6386,11 +6743,11 @@ async function runBackupRestore(input) {
6386
6743
  }
6387
6744
 
6388
6745
  // src/commands/status.ts
6389
- import { existsSync as existsSync12, statSync as statSync5 } from "fs";
6746
+ import { existsSync as existsSync13, statSync as statSync5 } from "fs";
6390
6747
  import { readFile as readFile23 } from "fs/promises";
6391
- import { join as join35 } from "path";
6748
+ import { join as join36 } from "path";
6392
6749
  async function runStatus(input) {
6393
- if (!existsSync12(input.vault)) {
6750
+ if (!existsSync13(input.vault)) {
6394
6751
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
6395
6752
  }
6396
6753
  const scan = await scanVault(input.vault);
@@ -6415,7 +6772,7 @@ async function runStatus(input) {
6415
6772
  const compound = scan.data.compound.length;
6416
6773
  let schemaVersion = "v1";
6417
6774
  try {
6418
- const schemaContent = await readFile23(join35(input.vault, "SCHEMA.md"), "utf8");
6775
+ const schemaContent = await readFile23(join36(input.vault, "SCHEMA.md"), "utf8");
6419
6776
  const versionMatch = schemaContent.match(/version:\s*["']?([^"'\s\n]+)/i);
6420
6777
  if (versionMatch) schemaVersion = versionMatch[1];
6421
6778
  } catch {
@@ -6476,7 +6833,7 @@ async function runStatus(input) {
6476
6833
 
6477
6834
  // src/commands/seed.ts
6478
6835
  import { mkdir as mkdir13, writeFile as writeFile14, stat as stat7 } from "fs/promises";
6479
- import { join as join36 } from "path";
6836
+ import { join as join37 } from "path";
6480
6837
  var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6481
6838
  var EXAMPLE_PAGES = {
6482
6839
  "entities/example-project.md": `---
@@ -6545,29 +6902,29 @@ Real sources are immutable after ingestion \u2014 never edit them.
6545
6902
  `;
6546
6903
  async function runSeed(input) {
6547
6904
  try {
6548
- await stat7(join36(input.vault, "SCHEMA.md"));
6905
+ await stat7(join37(input.vault, "SCHEMA.md"));
6549
6906
  } catch {
6550
6907
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { root: input.vault, reason: "SCHEMA.md missing \u2014 run `skillwiki init` first" }) };
6551
6908
  }
6552
6909
  const created = [];
6553
6910
  const skipped = [];
6554
6911
  for (const [relPath, content] of Object.entries(EXAMPLE_PAGES)) {
6555
- const absPath = join36(input.vault, relPath);
6912
+ const absPath = join37(input.vault, relPath);
6556
6913
  try {
6557
6914
  await stat7(absPath);
6558
6915
  skipped.push(relPath);
6559
6916
  } catch {
6560
- await mkdir13(join36(absPath, ".."), { recursive: true });
6917
+ await mkdir13(join37(absPath, ".."), { recursive: true });
6561
6918
  await writeFile14(absPath, content, "utf8");
6562
6919
  created.push(relPath);
6563
6920
  }
6564
6921
  }
6565
- const rawPath = join36(input.vault, "raw", "articles", "example-source.md");
6922
+ const rawPath = join37(input.vault, "raw", "articles", "example-source.md");
6566
6923
  try {
6567
6924
  await stat7(rawPath);
6568
6925
  skipped.push("raw/articles/example-source.md");
6569
6926
  } catch {
6570
- await mkdir13(join36(rawPath, ".."), { recursive: true });
6927
+ await mkdir13(join37(rawPath, ".."), { recursive: true });
6571
6928
  await writeFile14(rawPath, EXAMPLE_RAW, "utf8");
6572
6929
  created.push("raw/articles/example-source.md");
6573
6930
  }
@@ -6591,8 +6948,8 @@ async function runSeed(input) {
6591
6948
 
6592
6949
  // src/commands/canvas.ts
6593
6950
  import { readFile as readFile24, writeFile as writeFile15 } from "fs/promises";
6594
- import { existsSync as existsSync13 } from "fs";
6595
- import { join as join37 } from "path";
6951
+ import { existsSync as existsSync14 } from "fs";
6952
+ import { join as join38 } from "path";
6596
6953
  var NODE_WIDTH = 240;
6597
6954
  var NODE_HEIGHT = 60;
6598
6955
  var COLUMN_SPACING = 400;
@@ -6670,8 +7027,8 @@ function buildCanvasEdges(adjacency) {
6670
7027
  return edges;
6671
7028
  }
6672
7029
  async function runCanvasGenerate(input) {
6673
- const graphPath = input.graphPath ?? join37(input.vault, ".skillwiki", "graph.json");
6674
- if (!existsSync13(graphPath)) {
7030
+ const graphPath = input.graphPath ?? join38(input.vault, ".skillwiki", "graph.json");
7031
+ if (!existsSync14(graphPath)) {
6675
7032
  return {
6676
7033
  exitCode: ExitCode.FILE_NOT_FOUND,
6677
7034
  result: err("FILE_NOT_FOUND", {
@@ -6708,7 +7065,7 @@ async function runCanvasGenerate(input) {
6708
7065
  const nodes = buildCanvasNodes(paths);
6709
7066
  const edges = buildCanvasEdges(graph.adjacency);
6710
7067
  const canvas = { nodes, edges };
6711
- const outPath = join37(input.vault, "vault-graph.canvas");
7068
+ const outPath = join38(input.vault, "vault-graph.canvas");
6712
7069
  try {
6713
7070
  await writeFile15(outPath, JSON.stringify(canvas, null, 2));
6714
7071
  } catch (e) {
@@ -6731,7 +7088,7 @@ written: ${outPath}`
6731
7088
 
6732
7089
  // src/commands/query.ts
6733
7090
  import { readFile as readFile25, stat as stat8 } from "fs/promises";
6734
- import { join as join38 } from "path";
7091
+ import { join as join39 } from "path";
6735
7092
  var W_KEYWORD = 2;
6736
7093
  var W_SOURCE_OVERLAP = 4;
6737
7094
  var W_WIKILINK = 3;
@@ -6852,7 +7209,7 @@ function computeKeywordScore(terms, title, tags, body) {
6852
7209
  return score;
6853
7210
  }
6854
7211
  async function loadOrBuildGraph(vault) {
6855
- const graphPath = join38(vault, ".skillwiki", "graph.json");
7212
+ const graphPath = join39(vault, ".skillwiki", "graph.json");
6856
7213
  let needsBuild = false;
6857
7214
  try {
6858
7215
  const fileStat = await stat8(graphPath);
@@ -6874,14 +7231,14 @@ async function loadOrBuildGraph(vault) {
6874
7231
  }
6875
7232
 
6876
7233
  // src/utils/auto-commit.ts
6877
- import { existsSync as existsSync14 } from "fs";
6878
- import { join as join39 } from "path";
7234
+ import { existsSync as existsSync15 } from "fs";
7235
+ import { join as join40 } from "path";
6879
7236
  async function postCommit(vault, exitCode) {
6880
7237
  if (exitCode !== 0) return;
6881
7238
  const home = process.env.HOME ?? "";
6882
7239
  const dotenv = await parseDotenvFile(configPath(home));
6883
7240
  if (dotenv["AUTO_COMMIT"] === "false") return;
6884
- if (!existsSync14(join39(vault, ".git"))) return;
7241
+ if (!existsSync15(join40(vault, ".git"))) return;
6885
7242
  const lastOps = readLastOp(vault);
6886
7243
  if (lastOps.length === 0) return;
6887
7244
  const porcelain = git(vault, ["status", "--porcelain"]);
@@ -6910,7 +7267,7 @@ async function postCommit(vault, exitCode) {
6910
7267
  }
6911
7268
 
6912
7269
  // src/cli.ts
6913
- var pkg = JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8"));
7270
+ var pkg = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
6914
7271
  var program = new Command2();
6915
7272
  program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version(pkg.version);
6916
7273
  program.option("--human", "render terminal-readable output instead of JSON");
@@ -6932,7 +7289,7 @@ program.command("validate <file>").description("validate vault page frontmatter
6932
7289
  emit(await runValidate({ file, apply: !!opts.apply, vault }), vault);
6933
7290
  });
6934
7291
  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");
7292
+ const out = opts.out ?? join41(vault, ".skillwiki", "graph.json");
6936
7293
  emit(await runGraphBuild({ vault, out }), vault);
6937
7294
  });
6938
7295
  var canvasCmd = program.command("canvas").description("manage Obsidian canvas files");
@@ -7092,10 +7449,10 @@ program.command("status [vault]").description("output vault diagnostics").option
7092
7449
  langEnvValue: process.env.WIKI_LANG
7093
7450
  }), v.vault);
7094
7451
  });
7095
- program.command("archive <page> [vault]").description("archive a typed-knowledge or raw page").option("--wiki <name>", "wiki profile name").action(async (page, vault, opts) => {
7452
+ program.command("archive <page> [vault]").description("archive a typed-knowledge or raw page").option("--wiki <name>", "wiki profile name").option("--cascade", "scan vault for references (wikilinks + sources arrays); preview by default", false).option("--apply", "with --cascade: mutate sources arrays and archive (without --apply, --cascade is preview-only)", false).action(async (page, vault, opts) => {
7096
7453
  const v = await resolveVaultArg(vault, opts.wiki);
7097
7454
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7098
- else emit(await runArchive({ vault: v.vault, page }), v.vault);
7455
+ else emit(await runArchive({ vault: v.vault, page, cascade: !!opts.cascade, apply: !!opts.apply }), v.vault);
7099
7456
  });
7100
7457
  program.command("drift [vault]").description("detect content drift in raw sources").option("--apply", "update sha256 in drifted sources").option("--new <date>", "list raw files ingested on/after this date (YYYY-MM-DD)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7101
7458
  const v = await resolveVaultArg(vault, opts.wiki);
@@ -7157,10 +7514,10 @@ program.command("tag-sync [vault]").description("mirror frontmatter enum values
7157
7514
  else emit(await runTagSync({ vault: v.vault, dryRun: !!opts.dryRun }), v.vault);
7158
7515
  });
7159
7516
  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) => {
7517
+ 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
7518
  const v = await resolveVaultArg(vault, opts.wiki);
7162
7519
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7163
- else emit(runSyncStatus({ vault: v.vault }));
7520
+ else emit(runSyncStatus({ vault: v.vault, includeStashes: !!opts.includeStashes }));
7164
7521
  });
7165
7522
  syncCmd.command("push [vault]").description("lint, commit, and push vault changes to remote").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7166
7523
  const v = await resolveVaultArg(vault, opts.wiki);
@@ -7172,6 +7529,24 @@ syncCmd.command("pull [vault]").description("pull remote vault changes and lint"
7172
7529
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7173
7530
  else emit(await runSyncPull({ vault: v.vault }), v.vault);
7174
7531
  });
7532
+ 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) => {
7533
+ const v = await resolveVaultArg(vault, opts.wiki);
7534
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7535
+ else {
7536
+ const ttl = parseInt(opts.ttlMinutes, 10) || 30;
7537
+ emit(runSyncLock({ vault: v.vault, summary: opts.summary, ttlMinutes: ttl, force: !!opts.force }));
7538
+ }
7539
+ });
7540
+ syncCmd.command("unlock [vault]").description("release advisory lock on vault").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7541
+ const v = await resolveVaultArg(vault, opts.wiki);
7542
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7543
+ else emit(runSyncUnlock({ vault: v.vault }));
7544
+ });
7545
+ syncCmd.command("peers [vault]").description("list active locks and recent wiki-sync stashes").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7546
+ const v = await resolveVaultArg(vault, opts.wiki);
7547
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
7548
+ else emit(runSyncPeers({ vault: v.vault }));
7549
+ });
7175
7550
  var backupCmd = program.command("backup").description("manage S3-compatible remote backup");
7176
7551
  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
7552
  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.1-beta.1",
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.1-beta.1",
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.1-beta.1",
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.1-beta.1",
4
4
  "private": true,
5
5
  "files": [
6
6
  "wiki-*",
@@ -1,47 +1,124 @@
1
1
  ---
2
- version: 0.2.1
2
+ version: 0.6.1
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. Handles rebase conflict storms from archive-commit × snapshot-stream patterns. 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,53 @@ 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
+
146
+ ### Rebase conflict storm (archive commits × snapshot stream)
147
+
148
+ When many local archive-only commits (e.g., `archive: moved X to _archive/`) are rebased over an origin/main that receives frequent snapshot commits (e.g., sg01 `Snapshot YYYYMMDD_HHMMSS`), every archive commit re-triggers the same content conflicts on shared files (`log.md`, `knowledge.md`, `spec.md`). This is predictable and can be resolved systematically.
149
+
150
+ **Detection**: 3+ consecutive rebase stops on commits whose message matches `^archive: moved`.
151
+
152
+ **Resolution**: For each archive commit during the storm:
153
+
154
+ ```bash
155
+ # Apply --ours to all conflicting files (keep HEAD = origin/main + snapshots)
156
+ for f in $(git -C "$VAULT" diff --name-only --diff-filter=U); do
157
+ git -C "$VAULT" checkout --ours "$f" && git -C "$VAULT" add "$f"
158
+ done
159
+ git -C "$VAULT" rebase --continue
160
+ ```
161
+
162
+ **After the storm passes** (non-archive commits or clean rebase), pop the stash and handle any remaining conflicts per the normal Conflict Resolution sections above.
163
+
164
+ **Prevention**:
165
+ - Sync more frequently — don't let local fall >5 commits behind origin/main
166
+ - Bundle archive commits — `skillwiki archive --batch` groups 5-10 transcript archives into one commit, reducing rebase surface
167
+ - For vaults with snapshot cron, prefer smaller, more frequent syncs over large batch rebases
168
+
169
+ See `concepts/wiki-sync-rebase-conflict-storm-pattern.md` for detailed analysis.
170
+
54
171
  ## Multi-device coordination
55
172
  When the user mentions editing from Obsidian desktop and Claude Code on a server (or any two-device setup):
56
173
  - Recommend pulling before every editing session on each device.
57
174
  - Recommend pushing after every editing session on each device.
58
175
  - If both devices edit the same page between syncs, conflicts are inevitable — the Conflict Resolution section handles this.
59
176
  - Suggest enabling auto-commit in Obsidian (Community Plugins: `obsidian-git`) to reduce dirty-state drift.
177
+
60
178
  ## Rclone-backed vault with git snapshotting (cron pattern)
61
179
  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
180
  ### Architecture
@@ -105,12 +223,18 @@ bash ~/.hermes/scripts/wiki-snapshot.sh # Re-sync fresh
105
223
  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
224
  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
225
  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.
226
+
108
227
  ## Stop conditions
109
228
  - `skillwiki sync status` reports `not_a_repo` — the vault is not a git repository. Advise the user to initialize one.
110
229
  - Lint errors are found before a push — do not push until resolved.
111
230
  - `git push` or `git pull` fails with a network error — report and stop.
231
+ - Peer lock is held or peer stashes exist — abort and ask the user to wait or pass `--force`.
232
+ - Untracked file collision detected on pull — surface to user for manual resolution.
233
+
112
234
  ## Forbidden
113
235
  - Pushing when lint errors exist.
114
236
  - Auto-resolving body conflicts without user review.
115
237
  - Force-pushing (`git push --force`).
116
238
  - Modifying files in `raw/` to resolve conflicts (N9 — archive and re-ingest instead).
239
+ - Stashing without the `wiki-sync:...` name format (breaks peer detection).
240
+ - Force-deleting a peer's lockfile (use `--force` only if peer is confirmed dead).