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 +435 -60
- package/package.json +1 -1
- package/skills/.claude-plugin/plugin.json +1 -1
- package/skills/.codex-plugin/plugin.json +1 -1
- package/skills/package.json +1 -1
- package/skills/wiki-sync/SKILL.md +140 -16
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
|
|
12
|
-
import { join as
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
|
6220
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
6357
|
-
|
|
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
|
|
6746
|
+
import { existsSync as existsSync13, statSync as statSync5 } from "fs";
|
|
6390
6747
|
import { readFile as readFile23 } from "fs/promises";
|
|
6391
|
-
import { join as
|
|
6748
|
+
import { join as join36 } from "path";
|
|
6392
6749
|
async function runStatus(input) {
|
|
6393
|
-
if (!
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
6595
|
-
import { join as
|
|
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 ??
|
|
6674
|
-
if (!
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
6878
|
-
import { join as
|
|
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 (!
|
|
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(
|
|
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 ??
|
|
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.
|
|
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": {
|
package/skills/package.json
CHANGED
|
@@ -1,47 +1,124 @@
|
|
|
1
1
|
---
|
|
2
|
-
version: 0.
|
|
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.
|
|
31
|
-
10.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|