skillwiki 0.9.61 → 0.9.63

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
@@ -3,15 +3,18 @@ import {
3
3
  ExitCode,
4
4
  FLEET_REL_PATH,
5
5
  MetaSchema,
6
+ RawSourceSchema,
6
7
  SATELLITE_STALE_MS,
7
- TypedKnowledgeSchema,
8
+ acquireLock,
9
+ acquireOwnedSyncLock,
8
10
  appendLastOp,
11
+ assertTargetInsideVault,
9
12
  assessSourceIdentity,
13
+ atomicWriteText,
10
14
  buildDegradedReasons,
11
15
  buildRemoteObjectPath,
12
16
  clearLastOp,
13
17
  configPath,
14
- detectSchema,
15
18
  err,
16
19
  evaluateSatelliteRunHealth,
17
20
  extractCitationMarkers,
@@ -19,6 +22,8 @@ import {
19
22
  extractTaxonomy,
20
23
  findPlugin,
21
24
  fixPathTooLong,
25
+ getCliSessionId,
26
+ getSessionId,
22
27
  isBlockedHost,
23
28
  isFailedRunStatus,
24
29
  isValidRemoteDeleteCap,
@@ -29,12 +34,19 @@ import {
29
34
  parseDotenvFile,
30
35
  parseDotenvText,
31
36
  planAndMaybePruneRemoteObjects,
37
+ prepareTypedPage,
32
38
  probeRemoteHealth,
33
39
  profileKey,
34
40
  readCliPackageJson,
35
41
  readLastOp,
42
+ readLock,
36
43
  readPage,
37
44
  readSatelliteLatestRunFromText,
45
+ reconcileTaxonomyDocument,
46
+ redactSensitiveContent,
47
+ releaseLock,
48
+ releaseOwnedSyncLock,
49
+ renderIndexUpsert,
38
50
  resolveFleetHostId,
39
51
  resolveInitTimePath,
40
52
  resolveRuntimePath,
@@ -53,6 +65,7 @@ import {
53
65
  runIndexLinkFormat,
54
66
  runLinks,
55
67
  runLint,
68
+ runLogAppend,
56
69
  runLogRotate,
57
70
  runMemoryImport,
58
71
  runMemoryIndex,
@@ -76,8 +89,10 @@ import {
76
89
  scanVault,
77
90
  snapshotterAliasForLocalHost,
78
91
  splitFrontmatter,
92
+ taxonomyCommentForPage,
93
+ upsertIndexEntry,
79
94
  writeDotenv
80
- } from "./chunk-FU462DVS.js";
95
+ } from "./chunk-TUFQZ5K4.js";
81
96
  import {
82
97
  normalizeDistTag,
83
98
  readCache,
@@ -87,7 +102,7 @@ import {
87
102
  } from "./chunk-7I2TPIV5.js";
88
103
 
89
104
  // src/cli.ts
90
- import { join as join26 } from "path";
105
+ import { join as join27 } from "path";
91
106
  import { Command } from "commander";
92
107
 
93
108
  // src/utils/output.ts
@@ -853,110 +868,9 @@ async function runClaim(input) {
853
868
  };
854
869
  }
855
870
 
856
- // src/commands/log-append.ts
857
- import { readFile as readFile5, rename as rename2, writeFile as writeFile4, stat as stat3 } from "fs/promises";
858
- import { join as join8 } from "path";
859
-
860
- // src/utils/log-lock.ts
861
- import { existsSync as existsSync2, mkdirSync, statSync as statSync2, unlinkSync, writeFileSync } from "fs";
862
- import { join as join7 } from "path";
863
- function logLockPath(vault) {
864
- return join7(vault, ".skillwiki", "log-append.lock");
865
- }
866
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
867
- async function acquireLogLock(vault, opts = {}) {
868
- const retryMs = opts.retryMs ?? 2e3;
869
- const pollMs = opts.pollMs ?? 50;
870
- const staleMs = opts.staleMs ?? 1e4;
871
- const path = logLockPath(vault);
872
- const dir = join7(vault, ".skillwiki");
873
- if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
874
- const deadline = Date.now() + retryMs;
875
- const content = JSON.stringify({ pid: process.pid, acquired: (/* @__PURE__ */ new Date()).toISOString() }) + "\n";
876
- for (; ; ) {
877
- try {
878
- writeFileSync(path, content, { flag: "wx" });
879
- return { ok: true };
880
- } catch (e) {
881
- const err2 = e;
882
- if (err2.code !== "EEXIST") throw err2;
883
- }
884
- try {
885
- const age = Date.now() - statSync2(path).mtimeMs;
886
- if (age > staleMs) {
887
- unlinkSync(path);
888
- continue;
889
- }
890
- } catch {
891
- continue;
892
- }
893
- if (Date.now() >= deadline) return { ok: false };
894
- await sleep(pollMs);
895
- }
896
- }
897
- function releaseLogLock(vault) {
898
- try {
899
- unlinkSync(logLockPath(vault));
900
- } catch {
901
- }
902
- }
903
-
904
- // src/commands/log-append.ts
905
- var ENTRY_RE = /^## \[(\d{4})-\d{2}-\d{2}\]/gm;
906
- async function runLogAppend(input) {
907
- try {
908
- await stat3(join8(input.vault, "SCHEMA.md"));
909
- } catch {
910
- return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
911
- }
912
- const content = (input.content ?? "").trim();
913
- if (content.length === 0) {
914
- return { exitCode: ExitCode.USAGE, result: err("USAGE", { message: "--content must be a non-empty log entry" }) };
915
- }
916
- const acquired = await acquireLogLock(input.vault);
917
- if (!acquired.ok) {
918
- return { exitCode: ExitCode.LOG_APPEND_LOCK_HELD, result: err("LOG_APPEND_LOCK_HELD", { vault: input.vault }) };
919
- }
920
- const logPath = join8(input.vault, "log.md");
921
- try {
922
- let logText;
923
- try {
924
- logText = await readFile5(logPath, "utf8");
925
- } catch {
926
- return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: logPath }) };
927
- }
928
- const entriesBefore = [...logText.matchAll(ENTRY_RE)].length;
929
- const body = logText.replace(/\s+$/, "");
930
- const next = `${body}
931
-
932
- ${content}
933
- `;
934
- try {
935
- const tmp = logPath + ".tmp";
936
- await writeFile4(tmp, next, "utf8");
937
- await rename2(tmp, logPath);
938
- } catch (e) {
939
- return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
940
- }
941
- appendLastOp(input.vault, {
942
- operation: "log-append",
943
- summary: `appended log entry (${entriesBefore}->${entriesBefore + 1})`,
944
- files: ["log.md"],
945
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
946
- });
947
- const entriesAfter = entriesBefore + 1;
948
- return {
949
- exitCode: ExitCode.OK,
950
- result: ok({ entries_before: entriesBefore, entries_after: entriesAfter, appended: true, humanHint: `appended log entry (${entriesBefore}->${entriesAfter})` })
951
- };
952
- } finally {
953
- releaseLogLock(input.vault);
954
- }
955
- }
956
-
957
871
  // src/commands/health.ts
958
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
959
- import { dirname as dirname4, join as join9, resolve as resolve2 } from "path";
872
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "fs";
873
+ import { dirname as dirname4, join as join7, resolve as resolve2 } from "path";
960
874
  import { platform } from "os";
961
875
  function statusFromCounts(counts) {
962
876
  if ((counts.error ?? 0) > 0) return "error";
@@ -1067,7 +981,7 @@ function summarizeChecks(checks) {
1067
981
  };
1068
982
  }
1069
983
  function classifyLog(path, id, label, okPattern) {
1070
- if (!existsSync3(path)) return { id, label, status: "warn", detail: `log file missing: ${path}` };
984
+ if (!existsSync2(path)) return { id, label, status: "warn", detail: `log file missing: ${path}` };
1071
985
  const lines = readFileSync2(path, "utf8").split(/\r?\n/).filter(Boolean);
1072
986
  if (lines.length === 0) return { id, label, status: "warn", detail: `log file empty: ${path}` };
1073
987
  const statusLine = [...lines].reverse().find(
@@ -1089,12 +1003,12 @@ function runVaultSyncHealth(home, syncMode) {
1089
1003
  };
1090
1004
  }
1091
1005
  const isMac = platform() === "darwin";
1092
- const shareDir = isMac ? join9(home, "Library", "Application Support", "vault-sync", "bin") : join9(home, ".local", "share", "vault-sync", "bin");
1093
- const logDir = isMac ? join9(home, "Library", "Logs") : join9(home, ".local", "state", "vault-sync", "log");
1094
- const filterPath = join9(home, ".config", "rclone", "wiki-push-filters.txt");
1006
+ const shareDir = isMac ? join7(home, "Library", "Application Support", "vault-sync", "bin") : join7(home, ".local", "share", "vault-sync", "bin");
1007
+ const logDir = isMac ? join7(home, "Library", "Logs") : join7(home, ".local", "state", "vault-sync", "log");
1008
+ const filterPath = join7(home, ".config", "rclone", "wiki-push-filters.txt");
1095
1009
  const checks = [];
1096
- const pushScript = join9(shareDir, "wiki-push.sh");
1097
- if (syncMode === "optional" && !existsSync3(pushScript)) {
1010
+ const pushScript = join7(shareDir, "wiki-push.sh");
1011
+ if (syncMode === "optional" && !existsSync2(pushScript)) {
1098
1012
  return {
1099
1013
  status: "pass",
1100
1014
  blocking: false,
@@ -1107,23 +1021,23 @@ function runVaultSyncHealth(home, syncMode) {
1107
1021
  }]
1108
1022
  };
1109
1023
  }
1110
- checks.push(existsSync3(pushScript) ? { id: "vault_sync_installed", label: "Vault sync installed", status: "pass", detail: `Found: ${pushScript}` } : { id: "vault_sync_installed", label: "Vault sync installed", status: "error", detail: `Script missing: ${pushScript}` });
1024
+ checks.push(existsSync2(pushScript) ? { id: "vault_sync_installed", label: "Vault sync installed", status: "pass", detail: `Found: ${pushScript}` } : { id: "vault_sync_installed", label: "Vault sync installed", status: "error", detail: `Script missing: ${pushScript}` });
1111
1025
  if (isMac) {
1112
- const pushPlist = join9(home, "Library", "LaunchAgents", "com.karlchow.wiki-push.plist");
1113
- const fetchPlist = join9(home, "Library", "LaunchAgents", "com.karlchow.wiki-fetch.plist");
1114
- checks.push(existsSync3(pushPlist) && existsSync3(fetchPlist) ? { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "pass", detail: "launchd unit files present (read-only mode)" } : { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "warn", detail: "launchd unit files missing (read-only mode)" });
1026
+ const pushPlist = join7(home, "Library", "LaunchAgents", "com.karlchow.wiki-push.plist");
1027
+ const fetchPlist = join7(home, "Library", "LaunchAgents", "com.karlchow.wiki-fetch.plist");
1028
+ checks.push(existsSync2(pushPlist) && existsSync2(fetchPlist) ? { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "pass", detail: "launchd unit files present (read-only mode)" } : { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "warn", detail: "launchd unit files missing (read-only mode)" });
1115
1029
  checks.push({ id: "vault_sync_fuse_refresh_job", label: "Vault sync fuse refresh job", status: "pass", detail: "macOS host \u2014 check skipped" });
1116
1030
  } else {
1117
- const pushTimer = join9(home, ".config", "systemd", "user", "wiki-push.timer");
1118
- const fetchTimer = join9(home, ".config", "systemd", "user", "wiki-fetch.timer");
1119
- const fuseTimer = join9(home, ".config", "systemd", "user", "wiki-fuse-refresh.timer");
1120
- const fuseService = join9(home, ".config", "systemd", "user", "wiki-fuse-refresh.service");
1121
- checks.push(existsSync3(pushTimer) && existsSync3(fetchTimer) ? { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "pass", detail: "systemd timer unit files present (read-only mode)" } : { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "warn", detail: "systemd timer unit files missing (read-only mode)" });
1122
- checks.push(existsSync3(fuseTimer) && existsSync3(fuseService) ? { id: "vault_sync_fuse_refresh_job", label: "Vault sync fuse refresh job", status: "pass", detail: "wiki-fuse-refresh unit files present (read-only mode)" } : { id: "vault_sync_fuse_refresh_job", label: "Vault sync fuse refresh job", status: "warn", detail: "wiki-fuse-refresh unit files missing (read-only mode)" });
1123
- }
1124
- checks.push(classifyLog(join9(logDir, "wiki-push.log"), "vault_sync_last_push_age", "Vault sync last push recency", /OK push/));
1125
- checks.push(classifyLog(join9(logDir, "wiki-fetch.log"), "vault_sync_last_fetch_status", "Vault sync last fetch status", /NOTIFY|OK behind|OK/));
1126
- if (!existsSync3(filterPath)) {
1031
+ const pushTimer = join7(home, ".config", "systemd", "user", "wiki-push.timer");
1032
+ const fetchTimer = join7(home, ".config", "systemd", "user", "wiki-fetch.timer");
1033
+ const fuseTimer = join7(home, ".config", "systemd", "user", "wiki-fuse-refresh.timer");
1034
+ const fuseService = join7(home, ".config", "systemd", "user", "wiki-fuse-refresh.service");
1035
+ checks.push(existsSync2(pushTimer) && existsSync2(fetchTimer) ? { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "pass", detail: "systemd timer unit files present (read-only mode)" } : { id: "vault_sync_jobs_enabled", label: "Vault sync jobs enabled", status: "warn", detail: "systemd timer unit files missing (read-only mode)" });
1036
+ checks.push(existsSync2(fuseTimer) && existsSync2(fuseService) ? { id: "vault_sync_fuse_refresh_job", label: "Vault sync fuse refresh job", status: "pass", detail: "wiki-fuse-refresh unit files present (read-only mode)" } : { id: "vault_sync_fuse_refresh_job", label: "Vault sync fuse refresh job", status: "warn", detail: "wiki-fuse-refresh unit files missing (read-only mode)" });
1037
+ }
1038
+ checks.push(classifyLog(join7(logDir, "wiki-push.log"), "vault_sync_last_push_age", "Vault sync last push recency", /OK push/));
1039
+ checks.push(classifyLog(join7(logDir, "wiki-fetch.log"), "vault_sync_last_fetch_status", "Vault sync last fetch status", /NOTIFY|OK behind|OK/));
1040
+ if (!existsSync2(filterPath)) {
1127
1041
  checks.push({ id: "vault_sync_filter_present", label: "Vault sync filter file present", status: "error", detail: `Filter missing: ${filterPath}` });
1128
1042
  } else {
1129
1043
  const content = readFileSync2(filterPath, "utf8");
@@ -1219,9 +1133,9 @@ function buildHumanHint(report) {
1219
1133
  return lines.join("\n");
1220
1134
  }
1221
1135
  function writeReport(out, report) {
1222
- mkdirSync2(dirname4(out), { recursive: true });
1136
+ mkdirSync(dirname4(out), { recursive: true });
1223
1137
  const tmp = `${out}.tmp-${process.pid}-${Date.now()}`;
1224
- writeFileSync2(tmp, JSON.stringify(ok(report), null, 2) + "\n", "utf8");
1138
+ writeFileSync(tmp, JSON.stringify(ok(report), null, 2) + "\n", "utf8");
1225
1139
  renameSync(tmp, out);
1226
1140
  }
1227
1141
  function buildIncompleteReport(input, syncMode) {
@@ -1445,8 +1359,50 @@ async function runHealth(input) {
1445
1359
  }
1446
1360
 
1447
1361
  // src/commands/archive.ts
1448
- import { rename as rename3, mkdir as mkdir5, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
1449
- import { join as join10, dirname as dirname5 } from "path";
1362
+ import { rename as rename2, mkdir as mkdir6, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
1363
+ import { join as join9, dirname as dirname5 } from "path";
1364
+
1365
+ // src/utils/delete-intent.ts
1366
+ import { mkdir as mkdir5, writeFile as writeFile4, readdir as readdir3, readFile as readFile5 } from "fs/promises";
1367
+ import { join as join8 } from "path";
1368
+ var DELETE_INTENT_SCHEMA = "vault-delete-intent/v1";
1369
+ var DELETE_INTENT_DIR = "meta/delete-intents";
1370
+ function normalizeVaultRelPath(path) {
1371
+ const p = path.replace(/\\/g, "/").replace(/^\/+/, "");
1372
+ if (!p || p.includes("..") || p.startsWith(".git/")) {
1373
+ throw new Error(`invalid vault-relative path: ${path}`);
1374
+ }
1375
+ return p;
1376
+ }
1377
+ function pathToIntentFilename(path) {
1378
+ const p = normalizeVaultRelPath(path);
1379
+ return `${p.replace(/\//g, "__")}.json`;
1380
+ }
1381
+ function intentHostId() {
1382
+ return process.env.SKILLWIKI_HOST_ID ?? process.env.AGENT_HOST_ID ?? "unknown";
1383
+ }
1384
+ function buildDeleteIntent(input) {
1385
+ return {
1386
+ schema: DELETE_INTENT_SCHEMA,
1387
+ path: normalizeVaultRelPath(input.path),
1388
+ action: input.action,
1389
+ created: input.created ?? (/* @__PURE__ */ new Date()).toISOString(),
1390
+ host: input.host ?? intentHostId(),
1391
+ actor: input.actor,
1392
+ reason: input.reason,
1393
+ source: input.source,
1394
+ expires: input.expires ?? null
1395
+ };
1396
+ }
1397
+ async function writeDeleteIntent(vault, intent) {
1398
+ const dir = join8(vault, DELETE_INTENT_DIR);
1399
+ await mkdir5(dir, { recursive: true });
1400
+ const rel = `${DELETE_INTENT_DIR}/${pathToIntentFilename(intent.path)}`;
1401
+ await writeFile4(join8(vault, rel), JSON.stringify(intent, null, 2) + "\n", "utf8");
1402
+ return rel;
1403
+ }
1404
+
1405
+ // src/commands/archive.ts
1450
1406
  function countWikilinks(body, slug) {
1451
1407
  const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1452
1408
  const re = new RegExp(`\\[\\[${escaped}(?:[|#][^\\]]*)?\\]\\]`, "g");
@@ -1480,7 +1436,7 @@ async function runArchive(input) {
1480
1436
  if (!relPath) return { exitCode: ExitCode.ARCHIVE_TARGET_NOT_FOUND, result: err("ARCHIVE_TARGET_NOT_FOUND", { page: input.page }) };
1481
1437
  if (relPath.startsWith("_archive/")) return { exitCode: ExitCode.ARCHIVE_ALREADY_ARCHIVED, result: err("ARCHIVE_ALREADY_ARCHIVED", { page: relPath }) };
1482
1438
  const slug = relPath.replace(/\.md$/, "").split("/").pop();
1483
- const archivePath = join10("_archive", relPath).replace(/\\/g, "/");
1439
+ const archivePath = join9("_archive", relPath).replace(/\\/g, "/");
1484
1440
  const remoteRoot = normalizeRemoteRoot(input.remote);
1485
1441
  const remoteObjectPath = buildRemoteObjectPath(remoteRoot, relPath);
1486
1442
  let cascade;
@@ -1506,7 +1462,7 @@ async function runArchive(input) {
1506
1462
  const indexRefs = [];
1507
1463
  if (!isRaw) {
1508
1464
  try {
1509
- const idx = await readFile6(join10(input.vault, "index.md"), "utf8");
1465
+ const idx = await readFile6(join9(input.vault, "index.md"), "utf8");
1510
1466
  idx.split("\n").forEach((line, i) => {
1511
1467
  if (line.includes(`[[${slug}]]`)) indexRefs.push({ line: i + 1, text: line });
1512
1468
  });
@@ -1533,7 +1489,7 @@ async function runArchive(input) {
1533
1489
  }
1534
1490
  if (input.cascade && input.apply && cascade) {
1535
1491
  for (const ref of cascade.source_array_refs) {
1536
- const absPath = join10(input.vault, ref.page);
1492
+ const absPath = join9(input.vault, ref.page);
1537
1493
  const text = await readFile6(absPath, "utf8");
1538
1494
  const split = splitFrontmatter(text);
1539
1495
  if (!split.ok) continue;
@@ -1551,10 +1507,10 @@ ${fmRewritten}
1551
1507
  }
1552
1508
  }
1553
1509
  }
1554
- await mkdir5(dirname5(join10(input.vault, archivePath)), { recursive: true });
1510
+ await mkdir6(dirname5(join9(input.vault, archivePath)), { recursive: true });
1555
1511
  let indexUpdated = false;
1556
1512
  if (!isRaw) {
1557
- const indexPath = join10(input.vault, "index.md");
1513
+ const indexPath = join9(input.vault, "index.md");
1558
1514
  try {
1559
1515
  const idx = await readFile6(indexPath, "utf8");
1560
1516
  const originalLines = idx.split("\n");
@@ -1567,11 +1523,18 @@ ${fmRewritten}
1567
1523
  if (e instanceof Error && "code" in e && e.code !== "ENOENT") throw e;
1568
1524
  }
1569
1525
  }
1570
- await rename3(join10(input.vault, relPath), join10(input.vault, archivePath));
1526
+ await rename2(join9(input.vault, relPath), join9(input.vault, archivePath));
1527
+ const archiveIntent = buildDeleteIntent({
1528
+ path: relPath,
1529
+ action: "archive",
1530
+ actor: "skillwiki-cli",
1531
+ source: "cli"
1532
+ });
1533
+ const tombstonePath = await writeDeleteIntent(input.vault, archiveIntent);
1571
1534
  appendLastOp(input.vault, {
1572
1535
  operation: input.cascade ? "archive-cascade" : "archive",
1573
- summary: `moved ${relPath} to ${archivePath}${input.cascade ? ` (cascade: ${cascade?.source_array_refs.length ?? 0} source arrays updated)` : ""}`,
1574
- files: [relPath],
1536
+ summary: `moved ${relPath} to ${archivePath}${input.cascade ? ` (cascade: ${cascade?.source_array_refs.length ?? 0} source arrays updated)` : ""}; tombstone ${tombstonePath}`,
1537
+ files: [relPath, archivePath, tombstonePath],
1575
1538
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1576
1539
  });
1577
1540
  let remote;
@@ -1600,6 +1563,106 @@ ${fmRewritten}
1600
1563
  };
1601
1564
  }
1602
1565
 
1566
+ // src/commands/remove.ts
1567
+ import { unlink as unlink2, readFile as readFile7, writeFile as writeFile6, access } from "fs/promises";
1568
+ import { join as join10 } from "path";
1569
+ async function pathExists(abs) {
1570
+ try {
1571
+ await access(abs);
1572
+ return true;
1573
+ } catch {
1574
+ return false;
1575
+ }
1576
+ }
1577
+ async function runRemove(input) {
1578
+ if (input.remoteDelete && !input.remote) {
1579
+ return { exitCode: ExitCode.USAGE, result: err("USAGE", { message: "--remote-delete requires --remote" }) };
1580
+ }
1581
+ if (input.remoteDelete && !isValidRemoteDeleteCap(input.maxRemoteDeletes)) {
1582
+ return { exitCode: ExitCode.USAGE, result: err("USAGE", { message: "--max-remote-deletes must be a positive integer" }) };
1583
+ }
1584
+ const scan = await scanVault(input.vault);
1585
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1586
+ const lookup = (pages) => {
1587
+ if (input.page.includes("/")) return pages.find((p) => p.relPath === input.page)?.relPath;
1588
+ return pages.find((p) => p.relPath.replace(/\.md$/, "").split("/").pop() === input.page)?.relPath;
1589
+ };
1590
+ let relPath = lookup(scan.data.typedKnowledge) ?? lookup(scan.data.raw) ?? null;
1591
+ if (!relPath) {
1592
+ try {
1593
+ const candidate = normalizeVaultRelPath(input.page);
1594
+ if (await pathExists(join10(input.vault, candidate))) {
1595
+ relPath = candidate;
1596
+ }
1597
+ } catch {
1598
+ }
1599
+ }
1600
+ if (!relPath) {
1601
+ return {
1602
+ exitCode: ExitCode.FILE_NOT_FOUND,
1603
+ result: err("FILE_NOT_FOUND", { page: input.page })
1604
+ };
1605
+ }
1606
+ if (relPath.startsWith("_archive/")) {
1607
+ return {
1608
+ exitCode: ExitCode.USAGE,
1609
+ result: err("USAGE", { message: "refusing to remove path already under _archive/; use restore or leave archived" })
1610
+ };
1611
+ }
1612
+ const remoteRoot = normalizeRemoteRoot(input.remote);
1613
+ const remoteObjectPath = buildRemoteObjectPath(remoteRoot, relPath);
1614
+ const slug = relPath.replace(/\.md$/, "").split("/").pop() ?? relPath;
1615
+ let indexUpdated = false;
1616
+ if (relPath.endsWith(".md") && !relPath.startsWith("raw/")) {
1617
+ const indexPath = join10(input.vault, "index.md");
1618
+ try {
1619
+ const idx = await readFile7(indexPath, "utf8");
1620
+ const originalLines = idx.split("\n");
1621
+ const filtered = originalLines.filter((l) => !l.includes(`[[${slug}]]`));
1622
+ if (filtered.length !== originalLines.length) {
1623
+ await writeFile6(indexPath, filtered.join("\n"), "utf8");
1624
+ indexUpdated = true;
1625
+ }
1626
+ } catch (e) {
1627
+ if (e instanceof Error && "code" in e && e.code !== "ENOENT") throw e;
1628
+ }
1629
+ }
1630
+ const intent = buildDeleteIntent({
1631
+ path: relPath,
1632
+ action: "remove",
1633
+ actor: "skillwiki-cli",
1634
+ source: "cli",
1635
+ reason: input.reason
1636
+ });
1637
+ const tombstonePath = await writeDeleteIntent(input.vault, intent);
1638
+ await unlink2(join10(input.vault, relPath));
1639
+ appendLastOp(input.vault, {
1640
+ operation: "remove",
1641
+ summary: `removed ${relPath} (tombstone ${tombstonePath})`,
1642
+ files: [relPath, tombstonePath],
1643
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1644
+ });
1645
+ let remote;
1646
+ if (remoteObjectPath) {
1647
+ const pruned = await planAndMaybePruneRemoteObjects([remoteObjectPath], input);
1648
+ if (!pruned.ok) {
1649
+ return { exitCode: ExitCode.SYNC_PUSH_FAILED, result: pruned };
1650
+ }
1651
+ remote = pruned.data;
1652
+ }
1653
+ const remoteNote = remote ? ` (remote ${input.remoteDelete ? `deleted ${remote.deleted.length}` : `planned ${remote.plannedDeletes.length}`})` : "";
1654
+ return {
1655
+ exitCode: ExitCode.OK,
1656
+ result: ok({
1657
+ removed: relPath,
1658
+ tombstone_path: tombstonePath,
1659
+ index_updated: indexUpdated,
1660
+ ...remote ? { remote } : {},
1661
+ humanHint: `removed ${relPath}; tombstone ${tombstonePath}${indexUpdated ? " (index updated)" : ""}${remoteNote}`
1662
+ })
1663
+ };
1664
+ }
1665
+
1603
1666
  // src/commands/drift.ts
1604
1667
  import { createHash as createHash2 } from "crypto";
1605
1668
 
@@ -2023,7 +2086,7 @@ async function runUpdate(input) {
2023
2086
 
2024
2087
  // src/commands/self-update.ts
2025
2088
  import { execSync as execSync2 } from "child_process";
2026
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
2089
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
2027
2090
  import { join as join12 } from "path";
2028
2091
  var DEFAULT_SOURCE_ROOT_SUFFIX = "/Desktop/code/llm-wiki";
2029
2092
  async function runSelfUpdate(input) {
@@ -2031,7 +2094,7 @@ async function runSelfUpdate(input) {
2031
2094
  const sourceRoot = input.sourceRoot ?? `${input.home}${DEFAULT_SOURCE_ROOT_SUFFIX}`;
2032
2095
  const distTag = normalizeDistTag(input.distTag);
2033
2096
  const localPkgPath = join12(sourceRoot, "packages", "cli", "package.json");
2034
- const hasLocalSource = existsSync4(localPkgPath);
2097
+ const hasLocalSource = existsSync3(localPkgPath);
2035
2098
  if (input.check) {
2036
2099
  let availableVersion = null;
2037
2100
  let source;
@@ -2162,13 +2225,13 @@ async function runSelfUpdate(input) {
2162
2225
  }
2163
2226
 
2164
2227
  // src/commands/transcripts.ts
2165
- import { readdir as readdir3, stat as stat4, readFile as readFile7 } from "fs/promises";
2228
+ import { readdir as readdir4, stat as stat3, readFile as readFile8 } from "fs/promises";
2166
2229
  import { join as join13 } from "path";
2167
2230
  async function runTranscripts(input) {
2168
2231
  const dir = join13(input.vault, "raw", "transcripts");
2169
2232
  let entries;
2170
2233
  try {
2171
- entries = await readdir3(dir, { withFileTypes: true });
2234
+ entries = await readdir4(dir, { withFileTypes: true });
2172
2235
  } catch {
2173
2236
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: { ok: false, error: "VAULT_PATH_INVALID", detail: `raw/transcripts/ not found: ${dir}` } };
2174
2237
  }
@@ -2176,12 +2239,12 @@ async function runTranscripts(input) {
2176
2239
  for (const entry of entries) {
2177
2240
  if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
2178
2241
  const filePath = join13(dir, entry.name);
2179
- const content = await readFile7(filePath, "utf8");
2242
+ const content = await readFile8(filePath, "utf8");
2180
2243
  const fm = extractFrontmatter(content);
2181
2244
  if (!fm.ok) continue;
2182
2245
  const ingested = typeof fm.data.ingested === "string" ? fm.data.ingested : "";
2183
2246
  if (input.since && ingested && ingested < input.since) continue;
2184
- const s = await stat4(filePath);
2247
+ const s = await stat3(filePath);
2185
2248
  transcripts.push({
2186
2249
  file: `raw/transcripts/${entry.name}`,
2187
2250
  ingested,
@@ -2193,10 +2256,10 @@ async function runTranscripts(input) {
2193
2256
  }
2194
2257
 
2195
2258
  // src/commands/compound.ts
2196
- import { writeFile as writeFile6, mkdir as mkdir6, readdir as readdir4, unlink as unlink2 } from "fs/promises";
2259
+ import { writeFile as writeFile7, mkdir as mkdir7, readdir as readdir5, unlink as unlink3 } from "fs/promises";
2197
2260
  import { join as join14 } from "path";
2198
- import { existsSync as existsSync5 } from "fs";
2199
- import { readFile as readFile8 } from "fs/promises";
2261
+ import { existsSync as existsSync4 } from "fs";
2262
+ import { readFile as readFile9 } from "fs/promises";
2200
2263
  var RETRO_HEADING_RE = /^## \[(\d{4}-\d{2}-\d{2})(?:\s+[^\]]+)?\] retro \| loop cycle(?: (\d+))?: (.+)$/;
2201
2264
  var FIELD_RE = {
2202
2265
  improve: /^-\s+\*?\*?Improve:?\*?\*?\s*(.+)$/m,
@@ -2297,7 +2360,7 @@ async function runCompound(input) {
2297
2360
  const logPath = join14(input.vault, "log.md");
2298
2361
  let logText;
2299
2362
  try {
2300
- logText = await readFile8(logPath, "utf8");
2363
+ logText = await readFile9(logPath, "utf8");
2301
2364
  } catch {
2302
2365
  return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: logPath }) };
2303
2366
  }
@@ -2313,7 +2376,7 @@ async function runCompound(input) {
2313
2376
  }
2314
2377
  const slug = slugify(entry.cycleName);
2315
2378
  const compoundPath = join14(compoundDir, `${slug}.md`);
2316
- if (existsSync5(compoundPath)) {
2379
+ if (existsSync4(compoundPath)) {
2317
2380
  skipped.push(entry.date);
2318
2381
  continue;
2319
2382
  }
@@ -2351,10 +2414,10 @@ async function runCompound(input) {
2351
2414
  ].join("\n");
2352
2415
  const content = frontmatter + "\n" + body;
2353
2416
  if (!input.dryRun) {
2354
- if (!existsSync5(compoundDir)) {
2355
- await mkdir6(compoundDir, { recursive: true });
2417
+ if (!existsSync4(compoundDir)) {
2418
+ await mkdir7(compoundDir, { recursive: true });
2356
2419
  }
2357
- await writeFile6(compoundPath, content, "utf8");
2420
+ await writeFile7(compoundPath, content, "utf8");
2358
2421
  }
2359
2422
  promoted.push(`${slug}.md`);
2360
2423
  }
@@ -2374,7 +2437,7 @@ async function runCompound(input) {
2374
2437
  }
2375
2438
  async function runCompoundDelete(input) {
2376
2439
  const projectDir = join14(input.vault, "projects", input.project);
2377
- if (!existsSync5(projectDir)) {
2440
+ if (!existsSync4(projectDir)) {
2378
2441
  return {
2379
2442
  exitCode: ExitCode.PROJECT_NOT_FOUND,
2380
2443
  result: err("PROJECT_NOT_FOUND", { slug: input.project, path: projectDir })
@@ -2382,14 +2445,14 @@ async function runCompoundDelete(input) {
2382
2445
  }
2383
2446
  const entryName = input.entry.replace(/\.md$/, "");
2384
2447
  const compoundPath = join14(projectDir, "compound", `${entryName}.md`);
2385
- if (!existsSync5(compoundPath)) {
2448
+ if (!existsSync4(compoundPath)) {
2386
2449
  return {
2387
2450
  exitCode: ExitCode.FILE_NOT_FOUND,
2388
2451
  result: err("FILE_NOT_FOUND", { path: compoundPath })
2389
2452
  };
2390
2453
  }
2391
2454
  try {
2392
- await unlink2(compoundPath);
2455
+ await unlink3(compoundPath);
2393
2456
  } catch (e) {
2394
2457
  return {
2395
2458
  exitCode: ExitCode.WRITE_FAILED,
@@ -2416,7 +2479,7 @@ knowledge.md regenerated`
2416
2479
  }
2417
2480
  async function runCompoundList(input) {
2418
2481
  const compoundDir = join14(input.vault, "projects", input.project, "compound");
2419
- if (!existsSync5(compoundDir)) {
2482
+ if (!existsSync4(compoundDir)) {
2420
2483
  return {
2421
2484
  exitCode: ExitCode.OK,
2422
2485
  result: ok({
@@ -2430,7 +2493,7 @@ no compound directory found`
2430
2493
  }
2431
2494
  let dirents;
2432
2495
  try {
2433
- dirents = await readdir4(compoundDir, { withFileTypes: true });
2496
+ dirents = await readdir5(compoundDir, { withFileTypes: true });
2434
2497
  } catch {
2435
2498
  return {
2436
2499
  exitCode: ExitCode.OK,
@@ -2449,7 +2512,7 @@ could not read compound directory`
2449
2512
  const filePath = join14(compoundDir, dirent.name);
2450
2513
  let text;
2451
2514
  try {
2452
- text = await readFile8(filePath, "utf8");
2515
+ text = await readFile9(filePath, "utf8");
2453
2516
  } catch {
2454
2517
  continue;
2455
2518
  }
@@ -2478,7 +2541,7 @@ no compound entries found`;
2478
2541
  }
2479
2542
 
2480
2543
  // src/commands/session-brief.ts
2481
- import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile7 } from "fs/promises";
2544
+ import { mkdir as mkdir8, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
2482
2545
  import { join as join15, relative, sep } from "path";
2483
2546
  var MAX_WORDS = 900;
2484
2547
  async function runSessionBrief(input) {
@@ -2588,7 +2651,7 @@ async function resolveProject(input) {
2588
2651
  async function readProjectSlug(file) {
2589
2652
  let text;
2590
2653
  try {
2591
- text = await readFile9(file, "utf8");
2654
+ text = await readFile10(file, "utf8");
2592
2655
  } catch {
2593
2656
  return void 0;
2594
2657
  }
@@ -2815,15 +2878,15 @@ async function writeBriefArtifacts(vault, input) {
2815
2878
  const metaPath = join15(vault, "meta", "latest-session-brief.md");
2816
2879
  const cacheMdPath = join15(vault, ".skillwiki", "session-brief.md");
2817
2880
  const cacheJsonPath = join15(vault, ".skillwiki", "session-brief.json");
2818
- await mkdir7(join15(vault, "meta"), { recursive: true });
2819
- await mkdir7(join15(vault, ".skillwiki"), { recursive: true });
2881
+ await mkdir8(join15(vault, "meta"), { recursive: true });
2882
+ await mkdir8(join15(vault, ".skillwiki"), { recursive: true });
2820
2883
  const committed = renderCommittedBrief(input);
2821
2884
  const previousComparable = comparableBrief(await readIfExists(metaPath));
2822
2885
  const nextComparable = comparableBrief(committed);
2823
2886
  const materialChange = previousComparable !== nextComparable;
2824
- await writeFile7(metaPath, committed, "utf8");
2825
- await writeFile7(cacheMdPath, input.brief, "utf8");
2826
- await writeFile7(cacheJsonPath, `${JSON.stringify({
2887
+ await writeFile8(metaPath, committed, "utf8");
2888
+ await writeFile8(cacheMdPath, input.brief, "utf8");
2889
+ await writeFile8(cacheJsonPath, `${JSON.stringify({
2827
2890
  project: input.project,
2828
2891
  brief: input.brief,
2829
2892
  word_count: input.wordCount,
@@ -2892,7 +2955,7 @@ async function ensureIndexEntry(vault) {
2892
2955
  while (insertAt < lines.length && !lines[insertAt].startsWith("## ")) insertAt++;
2893
2956
  lines.splice(insertAt, 0, entry);
2894
2957
  }
2895
- await writeFile7(indexPath, lines.join("\n"), "utf8");
2958
+ await writeFile8(indexPath, lines.join("\n"), "utf8");
2896
2959
  return true;
2897
2960
  }
2898
2961
  async function appendMaterialLog(vault, today) {
@@ -2901,12 +2964,12 @@ async function appendMaterialLog(vault, today) {
2901
2964
  if (!text) return false;
2902
2965
  const entry = `
2903
2966
  ## [${today}] session-brief | refreshed: meta/latest-session-brief.md`;
2904
- await writeFile7(logPath, text.trimEnd() + entry + "\n", "utf8");
2967
+ await writeFile8(logPath, text.trimEnd() + entry + "\n", "utf8");
2905
2968
  return true;
2906
2969
  }
2907
2970
  async function readIfExists(path) {
2908
2971
  try {
2909
- return await readFile9(path, "utf8");
2972
+ return await readFile10(path, "utf8");
2910
2973
  } catch {
2911
2974
  return "";
2912
2975
  }
@@ -2948,9 +3011,419 @@ function dateFromPath(path) {
2948
3011
  }
2949
3012
 
2950
3013
  // src/commands/ingest.ts
2951
- import { readFile as readFile10, writeFile as writeFile8, mkdir as mkdir8 } from "fs/promises";
2952
- import { join as join16 } from "path";
3014
+ import { readFile as readFile12, open, unlink as unlink4, mkdir as mkdir9 } from "fs/promises";
3015
+ import { join as join17 } from "path";
3016
+ import { createHash as createHash4 } from "crypto";
3017
+
3018
+ // src/commands/page-publish.ts
2953
3019
  import { createHash as createHash3 } from "crypto";
3020
+ import { realpathSync } from "fs";
3021
+ import { readFile as readFile11 } from "fs/promises";
3022
+ import { join as join16, resolve as resolve3 } from "path";
3023
+ var DEFAULT_DEPS = { afterStage: async () => void 0 };
3024
+ function errorExitCode(error) {
3025
+ switch (error) {
3026
+ case "FILE_NOT_FOUND":
3027
+ return ExitCode.FILE_NOT_FOUND;
3028
+ case "MISSING_CLOSING_DELIMITER":
3029
+ return ExitCode.MISSING_CLOSING_DELIMITER;
3030
+ case "SCHEME_REJECTED":
3031
+ case "NO_TAXONOMY_BLOCK":
3032
+ return ExitCode.SCHEME_REJECTED;
3033
+ case "VAULT_PATH_INVALID":
3034
+ return ExitCode.VAULT_PATH_INVALID;
3035
+ case "SENSITIVE_CONTENT_DETECTED":
3036
+ return ExitCode.SENSITIVE_CONTENT_DETECTED;
3037
+ case "SYNC_LOCK_HELD":
3038
+ return ExitCode.SYNC_LOCK_HELD;
3039
+ case "WRITE_FAILED":
3040
+ return ExitCode.WRITE_FAILED;
3041
+ default:
3042
+ return ExitCode.INVALID_FRONTMATTER;
3043
+ }
3044
+ }
3045
+ function publicationId(target, content, logNote = "") {
3046
+ return createHash3("sha256").update("skillwiki-page-publish-v1\0").update(target).update("\0").update(content).update("\0").update(logNote).digest("hex");
3047
+ }
3048
+ function prepareFrozenPublication(input, source) {
3049
+ const target = assertTargetInsideVault(input.vault, input.target);
3050
+ if (!target.ok) return target;
3051
+ const page = prepareTypedPage(input.content, input.target);
3052
+ if (!page.ok) return page;
3053
+ if (input.logNote !== void 0 && /[\r\n]/.test(input.logNote)) {
3054
+ return err("SCHEME_REJECTED", { message: "log note must be one line" });
3055
+ }
3056
+ const logNote = input.logNote?.trim() || void 0;
3057
+ if (logNote && Buffer.byteLength(logNote, "utf8") > 500) {
3058
+ return err("SCHEME_REJECTED", { message: "log note must be one line and at most 500 UTF-8 bytes" });
3059
+ }
3060
+ if (logNote && scanSensitiveContent(logNote, { file: "page-publish log note" }).length > 0) {
3061
+ return err("SENSITIVE_CONTENT_DETECTED", { message: "log note contains sensitive authentication material" });
3062
+ }
3063
+ const date = (input.now ?? /* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3064
+ const taxonomyComment = taxonomyCommentForPage(input.target, date);
3065
+ if (!taxonomyComment.ok) return taxonomyComment;
3066
+ return ok({
3067
+ page: page.data,
3068
+ source,
3069
+ targetPath: target.data.absolutePath,
3070
+ logNote,
3071
+ operationId: publicationId(input.target, input.content, logNote),
3072
+ date,
3073
+ taxonomyComment: taxonomyComment.data
3074
+ });
3075
+ }
3076
+ function preparePagePublicationFromContent(input) {
3077
+ return prepareFrozenPublication(input, { kind: "content" });
3078
+ }
3079
+ async function preparePagePublication(input) {
3080
+ let content;
3081
+ try {
3082
+ content = await readFile11(input.draftPath, "utf8");
3083
+ } catch (error) {
3084
+ return err("FILE_NOT_FOUND", { path: input.draftPath, message: String(error) });
3085
+ }
3086
+ let draftRealPath;
3087
+ try {
3088
+ draftRealPath = realpathSync(input.draftPath);
3089
+ } catch (error) {
3090
+ return err("VAULT_PATH_INVALID", {
3091
+ path: input.draftPath,
3092
+ message: `draft realpath failed: ${String(error)}`
3093
+ });
3094
+ }
3095
+ const target = assertTargetInsideVault(input.vault, input.target);
3096
+ if (!target.ok) return target;
3097
+ if (resolve3(input.draftPath) === target.data.absolutePath || target.data.existingRealPath !== void 0 && draftRealPath === target.data.existingRealPath) {
3098
+ return err("VAULT_PATH_INVALID", { message: "draft must not alias the final target" });
3099
+ }
3100
+ return prepareFrozenPublication(
3101
+ {
3102
+ vault: input.vault,
3103
+ content,
3104
+ target: input.target,
3105
+ logNote: input.logNote,
3106
+ now: input.now
3107
+ },
3108
+ { kind: "file", realPath: draftRealPath }
3109
+ );
3110
+ }
3111
+ function emptyLockedState() {
3112
+ return {
3113
+ taxonomyAdded: [],
3114
+ pageChanged: false,
3115
+ indexUpdated: false,
3116
+ published: false,
3117
+ changed: /* @__PURE__ */ new Set()
3118
+ };
3119
+ }
3120
+ function lockedFailure(stage, state, cause, exitCode = ExitCode.WRITE_FAILED) {
3121
+ return { ok: false, exitCode, stage, state, cause };
3122
+ }
3123
+ async function observeStage(deps, stage) {
3124
+ try {
3125
+ await deps.afterStage(stage);
3126
+ return void 0;
3127
+ } catch (error) {
3128
+ return err("WRITE_FAILED", { message: `stage hook failed at ${stage}: ${String(error)}` });
3129
+ }
3130
+ }
3131
+ async function runLockedPrimaryStages(input, vault, deps) {
3132
+ const state = emptyLockedState();
3133
+ const freshTarget = assertTargetInsideVault(vault, input.page.target);
3134
+ if (!freshTarget.ok) {
3135
+ return lockedFailure("target", state, freshTarget, ExitCode.VAULT_PATH_INVALID);
3136
+ }
3137
+ if (freshTarget.data.absolutePath !== input.targetPath) {
3138
+ return lockedFailure(
3139
+ "target",
3140
+ state,
3141
+ err("VAULT_PATH_INVALID", { message: "target canonical path changed after preparation" }),
3142
+ ExitCode.VAULT_PATH_INVALID
3143
+ );
3144
+ }
3145
+ if (input.source.kind === "file" && freshTarget.data.existingRealPath !== void 0 && freshTarget.data.existingRealPath === input.source.realPath) {
3146
+ return lockedFailure(
3147
+ "target",
3148
+ state,
3149
+ err("VAULT_PATH_INVALID", { message: "draft now aliases the final target" }),
3150
+ ExitCode.VAULT_PATH_INVALID
3151
+ );
3152
+ }
3153
+ const schemaPath = join16(vault, "SCHEMA.md");
3154
+ let schemaText;
3155
+ try {
3156
+ schemaText = await readFile11(schemaPath, "utf8");
3157
+ } catch (error) {
3158
+ return lockedFailure("schema", state, err("WRITE_FAILED", { message: String(error) }));
3159
+ }
3160
+ const reconciled = reconcileTaxonomyDocument(schemaText, {
3161
+ tags: input.page.tags,
3162
+ comment: input.taxonomyComment
3163
+ });
3164
+ if (!reconciled.ok) {
3165
+ return lockedFailure("schema", state, reconciled, errorExitCode(reconciled.error));
3166
+ }
3167
+ state.taxonomyAdded = reconciled.data.added;
3168
+ if (reconciled.data.changed) {
3169
+ const schemaWrite = await atomicWriteText(schemaPath, reconciled.data.text);
3170
+ if (!schemaWrite.ok) return lockedFailure("schema", state, schemaWrite);
3171
+ if (schemaWrite.data.changed) state.changed.add("SCHEMA.md");
3172
+ }
3173
+ const schemaHook = await observeStage(deps, "schema");
3174
+ if (schemaHook) return lockedFailure("schema", state, schemaHook);
3175
+ const pageWrite = await safeWritePage(input.targetPath, input.page.content);
3176
+ if (!pageWrite.ok) return lockedFailure("page", state, pageWrite);
3177
+ state.pageChanged = pageWrite.data.changed;
3178
+ if (state.pageChanged) state.changed.add(input.page.target);
3179
+ state.published = true;
3180
+ const pageHook = await observeStage(deps, "page");
3181
+ if (pageHook) return lockedFailure("page", state, pageHook);
3182
+ let visible;
3183
+ let visibleSchema;
3184
+ try {
3185
+ [visible, visibleSchema] = await Promise.all([
3186
+ readFile11(input.targetPath, "utf8"),
3187
+ readFile11(schemaPath, "utf8")
3188
+ ]);
3189
+ } catch (error) {
3190
+ return lockedFailure("verify", state, err("WRITE_FAILED", { message: String(error) }));
3191
+ }
3192
+ const visiblePage = prepareTypedPage(visible, input.page.target);
3193
+ const visibleTaxonomy = extractTaxonomy(visibleSchema);
3194
+ if (!visiblePage.ok || visible !== input.page.content || !visibleTaxonomy.ok || input.page.tags.some((tag) => !visibleTaxonomy.data.includes(tag))) {
3195
+ return lockedFailure(
3196
+ "verify",
3197
+ state,
3198
+ err("WRITE_FAILED", { message: "published bytes or taxonomy verification failed" })
3199
+ );
3200
+ }
3201
+ const verifyHook = await observeStage(deps, "verify");
3202
+ if (verifyHook) return lockedFailure("verify", state, verifyHook);
3203
+ const index = await upsertIndexEntry({
3204
+ vault,
3205
+ target: input.page.target,
3206
+ title: input.page.title,
3207
+ type: input.page.type
3208
+ });
3209
+ if (!index.ok) return lockedFailure("index", state, index);
3210
+ state.indexUpdated = index.data.changed;
3211
+ if (state.indexUpdated) state.changed.add("index.md");
3212
+ const indexHook = await observeStage(deps, "index");
3213
+ if (indexHook) return lockedFailure("index", state, indexHook);
3214
+ return { ok: true, data: state };
3215
+ }
3216
+ function redactDetail(detail) {
3217
+ if (detail === void 0) return void 0;
3218
+ try {
3219
+ const encoded = JSON.stringify(detail);
3220
+ return JSON.parse(redactSensitiveContent(encoded).text);
3221
+ } catch {
3222
+ return { message: "unserializable error detail omitted" };
3223
+ }
3224
+ }
3225
+ function phaseFailure(stage, input, published, cause, context = {}, exitCode = ExitCode.WRITE_FAILED) {
3226
+ return {
3227
+ exitCode,
3228
+ result: err("WRITE_FAILED", {
3229
+ ...context,
3230
+ stage,
3231
+ published,
3232
+ target: input.page.target,
3233
+ operation_id: input.operationId,
3234
+ retry_safe: stage !== "target",
3235
+ cause_error: cause.error,
3236
+ cause_detail: redactDetail(cause.detail)
3237
+ })
3238
+ };
3239
+ }
3240
+ function successReceipt(input, taxonomyAdded, pageChanged, indexUpdated, logAppended, filesChanged, dryRun = false) {
3241
+ return {
3242
+ exitCode: ExitCode.OK,
3243
+ result: ok({
3244
+ target: input.page.target,
3245
+ page_type: input.page.type,
3246
+ tags: [...input.page.tags],
3247
+ taxonomy_added: [...taxonomyAdded],
3248
+ page_changed: pageChanged,
3249
+ index_updated: indexUpdated,
3250
+ log_appended: logAppended,
3251
+ operation_id: input.operationId,
3252
+ dry_run: dryRun,
3253
+ files_changed: filesChanged,
3254
+ humanHint: dryRun ? `dry run: would publish ${input.page.target} (${input.operationId.slice(0, 12)})` : `published ${input.page.target} (${input.operationId.slice(0, 12)})`
3255
+ })
3256
+ };
3257
+ }
3258
+ function renderPublicationLog(input, added) {
3259
+ return [
3260
+ `## [${input.date}] page-publish | ${input.page.target}`,
3261
+ "",
3262
+ `- Published: [[${input.page.target.replace(/\.md$/, "")}]]`,
3263
+ `- Taxonomy: ${added.length > 0 ? `added ${added.join(", ")}` : "no additions"}`,
3264
+ ...input.logNote ? [`- Note: ${input.logNote}`] : []
3265
+ ].join("\n");
3266
+ }
3267
+ async function readPageChanged(targetPath, content) {
3268
+ try {
3269
+ return ok(await readFile11(targetPath, "utf8") !== content);
3270
+ } catch (error) {
3271
+ if (error.code === "ENOENT") return ok(true);
3272
+ return err("WRITE_FAILED", { path: targetPath, message: String(error) });
3273
+ }
3274
+ }
3275
+ async function previewPreparedPagePublication(input, vault) {
3276
+ const schemaPath = join16(vault, "SCHEMA.md");
3277
+ let schemaText;
3278
+ try {
3279
+ schemaText = await readFile11(schemaPath, "utf8");
3280
+ } catch (error) {
3281
+ const result = err("FILE_NOT_FOUND", { path: schemaPath, message: String(error) });
3282
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result };
3283
+ }
3284
+ const reconciled = reconcileTaxonomyDocument(schemaText, {
3285
+ tags: input.page.tags,
3286
+ comment: input.taxonomyComment
3287
+ });
3288
+ if (!reconciled.ok) return { exitCode: errorExitCode(reconciled.error), result: reconciled };
3289
+ const pageChanged = await readPageChanged(input.targetPath, input.page.content);
3290
+ if (!pageChanged.ok) return { exitCode: errorExitCode(pageChanged.error), result: pageChanged };
3291
+ const indexPath = join16(vault, "index.md");
3292
+ let indexText;
3293
+ try {
3294
+ indexText = await readFile11(indexPath, "utf8");
3295
+ } catch (error) {
3296
+ const result = err("FILE_NOT_FOUND", { path: indexPath, message: String(error) });
3297
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result };
3298
+ }
3299
+ const index = renderIndexUpsert(indexText, {
3300
+ target: input.page.target,
3301
+ title: input.page.title,
3302
+ type: input.page.type
3303
+ });
3304
+ if (!index.ok) return { exitCode: errorExitCode(index.error), result: index };
3305
+ const logPath = join16(vault, "log.md");
3306
+ let logText;
3307
+ try {
3308
+ logText = await readFile11(logPath, "utf8");
3309
+ } catch (error) {
3310
+ const result = err("FILE_NOT_FOUND", { path: logPath, message: String(error) });
3311
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result };
3312
+ }
3313
+ const logAppended = !logText.includes(`<!-- skillwiki-page-publish:${input.operationId} -->`);
3314
+ const filesChanged = [
3315
+ ...reconciled.data.changed ? ["SCHEMA.md"] : [],
3316
+ ...pageChanged.data ? [input.page.target] : [],
3317
+ ...index.data.changed ? ["index.md"] : [],
3318
+ ...logAppended ? ["log.md"] : []
3319
+ ];
3320
+ return successReceipt(
3321
+ input,
3322
+ reconciled.data.added,
3323
+ pageChanged.data,
3324
+ index.data.changed,
3325
+ logAppended,
3326
+ filesChanged,
3327
+ true
3328
+ );
3329
+ }
3330
+ async function publishPreparedPage(input, vault, deps = DEFAULT_DEPS) {
3331
+ let lock;
3332
+ try {
3333
+ lock = acquireOwnedSyncLock(vault, {
3334
+ summary: `page publish ${input.page.target}`,
3335
+ ttlMinutes: 1
3336
+ });
3337
+ } catch (error) {
3338
+ return {
3339
+ exitCode: ExitCode.WRITE_FAILED,
3340
+ result: err("WRITE_FAILED", { stage: "lock", message: String(error) })
3341
+ };
3342
+ }
3343
+ if (!lock.ok) return { exitCode: errorExitCode(lock.error), result: lock };
3344
+ let primary;
3345
+ let released;
3346
+ try {
3347
+ primary = await runLockedPrimaryStages(input, vault, deps);
3348
+ } catch (error) {
3349
+ primary = lockedFailure(
3350
+ "schema",
3351
+ emptyLockedState(),
3352
+ err("WRITE_FAILED", { message: `unexpected primary-stage failure: ${String(error)}` })
3353
+ );
3354
+ } finally {
3355
+ released = releaseOwnedSyncLock(lock.data);
3356
+ }
3357
+ const primaryState = primary?.ok ? primary.data : primary?.state;
3358
+ if (released === void 0 || !released.ok || !released.data.released) {
3359
+ return phaseFailure(
3360
+ "unlock",
3361
+ input,
3362
+ primaryState?.published ?? false,
3363
+ released && !released.ok ? released : err("WRITE_FAILED", { message: "lock release did not run" }),
3364
+ {
3365
+ primary_stage: primary && !primary.ok ? primary.stage : "complete",
3366
+ primary_error: primary && !primary.ok ? primary.cause.error : void 0
3367
+ }
3368
+ );
3369
+ }
3370
+ const unlockHook = await observeStage(deps, "unlock");
3371
+ if (unlockHook) return phaseFailure("unlock", input, primaryState?.published ?? false, unlockHook);
3372
+ if (primary === void 0) {
3373
+ return phaseFailure(
3374
+ "schema",
3375
+ input,
3376
+ false,
3377
+ err("WRITE_FAILED", { message: "locked publication produced no result" })
3378
+ );
3379
+ }
3380
+ if (!primary.ok) {
3381
+ return phaseFailure(
3382
+ primary.stage,
3383
+ input,
3384
+ primary.state.published,
3385
+ primary.cause,
3386
+ void 0,
3387
+ primary.exitCode
3388
+ );
3389
+ }
3390
+ const state = primary.data;
3391
+ const log = await runLogAppend({
3392
+ vault,
3393
+ content: renderPublicationLog(input, state.taxonomyAdded),
3394
+ operationId: input.operationId,
3395
+ strictLock: true,
3396
+ recordLastOp: false
3397
+ });
3398
+ if (!log.result.ok) return phaseFailure("log", input, true, log.result);
3399
+ if (log.exitCode !== ExitCode.OK) {
3400
+ return phaseFailure(
3401
+ "log",
3402
+ input,
3403
+ true,
3404
+ err("WRITE_FAILED", { message: "log append returned inconsistent success state" })
3405
+ );
3406
+ }
3407
+ if (log.result.data.appended) state.changed.add("log.md");
3408
+ const logHook = await observeStage(deps, "log");
3409
+ if (logHook) return phaseFailure("log", input, true, logHook);
3410
+ return successReceipt(
3411
+ input,
3412
+ state.taxonomyAdded,
3413
+ state.pageChanged,
3414
+ state.indexUpdated,
3415
+ log.result.data.appended,
3416
+ [...state.changed]
3417
+ );
3418
+ }
3419
+ async function runPagePublish(input, deps = DEFAULT_DEPS) {
3420
+ const prepared = await preparePagePublication(input);
3421
+ if (!prepared.ok) return { exitCode: errorExitCode(prepared.error), result: prepared };
3422
+ if (!input.write) return previewPreparedPagePublication(prepared.data, input.vault);
3423
+ return publishPreparedPage(prepared.data, input.vault, deps);
3424
+ }
3425
+
3426
+ // src/commands/ingest.ts
2954
3427
  var ALLOWED_TYPES = /* @__PURE__ */ new Set(["entity", "concept", "comparison", "query"]);
2955
3428
  var TYPE_DIR = {
2956
3429
  entity: "entities",
@@ -2991,19 +3464,6 @@ function buildTypedContent(title, ingested, type, tags, rawRelPath, provenance)
2991
3464
  const aliases = [];
2992
3465
  const sourcesYaml = ` - ${rawRelPath}`;
2993
3466
  const tagsYaml = tags.length > 0 ? tags.map((t) => ` - ${t}`).join("\n") : " []";
2994
- const fm = {
2995
- title,
2996
- aliases,
2997
- created: ingested,
2998
- updated: ingested,
2999
- type,
3000
- tags,
3001
- sources: [rawRelPath],
3002
- confidence: "medium"
3003
- };
3004
- if (provenance) {
3005
- fm.provenance = provenance;
3006
- }
3007
3467
  const fmLines = ["---"];
3008
3468
  fmLines.push(`title: "${title}"`);
3009
3469
  if (aliases.length > 0) {
@@ -3039,6 +3499,78 @@ function buildTypedContent(title, ingested, type, tags, rawRelPath, provenance)
3039
3499
  ].join("\n");
3040
3500
  return fmLines.join("\n") + body;
3041
3501
  }
3502
+ async function resolveRawCapture(input) {
3503
+ try {
3504
+ const existing = await readFile12(input.path, "utf8");
3505
+ const frontmatter = extractFrontmatter(existing);
3506
+ if (!frontmatter.ok) {
3507
+ return err("INGEST_VALIDATION_FAILED", {
3508
+ path: input.path,
3509
+ message: "existing immutable raw source has invalid frontmatter",
3510
+ source_error: frontmatter.error
3511
+ });
3512
+ }
3513
+ const parsed = RawSourceSchema.safeParse(frontmatter.data);
3514
+ if (!parsed.success || parsed.data.sha256 !== input.sha256 || (parsed.data.source_url ?? null) !== input.sourceUrl || existing !== buildRawContent(
3515
+ input.sourceUrl,
3516
+ String(parsed.data.ingested),
3517
+ input.sha256,
3518
+ input.sourceContent
3519
+ )) {
3520
+ return err("INGEST_VALIDATION_FAILED", {
3521
+ path: input.path,
3522
+ message: "existing immutable raw source differs from the fetched source"
3523
+ });
3524
+ }
3525
+ return ok({
3526
+ content: existing,
3527
+ ingested: String(parsed.data.ingested),
3528
+ shouldWrite: false
3529
+ });
3530
+ } catch (error) {
3531
+ if (error.code !== "ENOENT") {
3532
+ return err("WRITE_FAILED", { path: input.path, message: String(error) });
3533
+ }
3534
+ }
3535
+ return ok({
3536
+ content: buildRawContent(input.sourceUrl, input.today, input.sha256, input.sourceContent),
3537
+ ingested: input.today,
3538
+ shouldWrite: true
3539
+ });
3540
+ }
3541
+ async function writeResolvedRaw(input) {
3542
+ if (!input.capture.shouldWrite) return ok({ changed: false, capture: input.capture });
3543
+ const lock = await acquireRawCaptureLock(input.path);
3544
+ if (!lock.ok) return lock;
3545
+ try {
3546
+ const resolved = await resolveRawCapture(input);
3547
+ if (!resolved.ok) return resolved;
3548
+ if (!resolved.data.shouldWrite) return ok({ changed: false, capture: resolved.data });
3549
+ const written = await atomicWriteText(input.path, resolved.data.content);
3550
+ return written.ok ? ok({ changed: written.data.changed, capture: resolved.data }) : written;
3551
+ } finally {
3552
+ try {
3553
+ await unlink4(lock.data);
3554
+ } catch {
3555
+ }
3556
+ }
3557
+ }
3558
+ async function acquireRawCaptureLock(path) {
3559
+ const lockPath = `${path}.ingest.lock`;
3560
+ for (let attempt = 0; attempt < 200; attempt++) {
3561
+ try {
3562
+ const handle = await open(lockPath, "wx");
3563
+ await handle.close();
3564
+ return ok(lockPath);
3565
+ } catch (error) {
3566
+ if (error.code !== "EEXIST") {
3567
+ return err("WRITE_FAILED", { path: lockPath, phase: "raw-lock", message: String(error) });
3568
+ }
3569
+ await new Promise((resolve4) => setTimeout(resolve4, 10));
3570
+ }
3571
+ }
3572
+ return err("WRITE_FAILED", { path: lockPath, phase: "raw-lock", message: "raw capture lock held" });
3573
+ }
3042
3574
  async function runIngest(input) {
3043
3575
  if (!input.source || input.source.trim().length === 0) {
3044
3576
  return {
@@ -3108,7 +3640,7 @@ async function runIngest(input) {
3108
3640
  sourceContent = fetchResult.data.body;
3109
3641
  } else {
3110
3642
  try {
3111
- sourceContent = await readFile10(input.source, "utf8");
3643
+ sourceContent = await readFile12(input.source, "utf8");
3112
3644
  } catch {
3113
3645
  return {
3114
3646
  exitCode: ExitCode.FILE_NOT_FOUND,
@@ -3126,15 +3658,14 @@ async function runIngest(input) {
3126
3658
  })
3127
3659
  };
3128
3660
  }
3129
- const sha256 = createHash3("sha256").update(Buffer.from(sourceContent, "utf8")).digest("hex");
3661
+ const sha256 = createHash4("sha256").update(Buffer.from(sourceContent, "utf8")).digest("hex");
3130
3662
  const today = todayIso();
3131
3663
  const slug = slugify2(input.title);
3132
3664
  const tags = input.tags && input.tags.length > 0 ? input.tags : [];
3133
3665
  const rawRelPath = `raw/articles/${slug}.md`;
3134
3666
  const typedDir = TYPE_DIR[input.type] ?? `${input.type}s`;
3135
3667
  const typedRelPath = `${typedDir}/${slug}.md`;
3136
- const rawAbsPath = join16(input.vault, rawRelPath);
3137
- const typedAbsPath = join16(input.vault, typedRelPath);
3668
+ const rawAbsPath = join17(input.vault, rawRelPath);
3138
3669
  const identity = assessSourceIdentity({
3139
3670
  rawPath: rawRelPath,
3140
3671
  sourceUrl: sourceUrl ?? void 0,
@@ -3154,16 +3685,62 @@ async function runIngest(input) {
3154
3685
  })
3155
3686
  };
3156
3687
  }
3157
- const rawContent = buildRawContent(sourceUrl, today, sha256, sourceContent);
3158
- const typedContent = buildTypedContent(
3688
+ const resolvedRaw = await resolveRawCapture({
3689
+ path: rawAbsPath,
3690
+ sourceUrl,
3691
+ sourceContent,
3692
+ sha256,
3693
+ today
3694
+ });
3695
+ if (!resolvedRaw.ok) {
3696
+ return {
3697
+ exitCode: resolvedRaw.error === "INGEST_VALIDATION_FAILED" ? ExitCode.INGEST_VALIDATION_FAILED : ExitCode.WRITE_FAILED,
3698
+ result: resolvedRaw
3699
+ };
3700
+ }
3701
+ let publicationDate = resolvedRaw.data.ingested;
3702
+ let typedContent = buildTypedContent(
3159
3703
  input.title,
3160
- today,
3704
+ publicationDate,
3161
3705
  input.type,
3162
3706
  tags,
3163
3707
  rawRelPath,
3164
3708
  input.provenance
3165
3709
  );
3710
+ if (!input.dryRun) {
3711
+ try {
3712
+ await mkdir9(join17(input.vault, typedDir), { recursive: true });
3713
+ } catch (error) {
3714
+ return {
3715
+ exitCode: ExitCode.WRITE_FAILED,
3716
+ result: err("WRITE_FAILED", { path: join17(input.vault, typedDir), message: String(error) })
3717
+ };
3718
+ }
3719
+ }
3720
+ let publication = preparePagePublicationFromContent({
3721
+ vault: input.vault,
3722
+ content: typedContent,
3723
+ target: typedRelPath,
3724
+ logNote: `ingested from ${rawRelPath}`,
3725
+ now: /* @__PURE__ */ new Date(`${publicationDate}T00:00:00Z`)
3726
+ });
3727
+ if (!publication.ok) {
3728
+ return {
3729
+ exitCode: ExitCode.INGEST_VALIDATION_FAILED,
3730
+ result: publication
3731
+ };
3732
+ }
3166
3733
  if (input.dryRun) {
3734
+ const preview = await previewPreparedPagePublication(publication.data, input.vault);
3735
+ if (!preview.result.ok) {
3736
+ return { exitCode: preview.exitCode, result: preview.result };
3737
+ }
3738
+ if (preview.exitCode !== ExitCode.OK) {
3739
+ return {
3740
+ exitCode: preview.exitCode,
3741
+ result: err("WRITE_FAILED", { message: "publication preview returned inconsistent success state" })
3742
+ };
3743
+ }
3167
3744
  return {
3168
3745
  exitCode: ExitCode.OK,
3169
3746
  result: ok({
@@ -3172,78 +3749,84 @@ async function runIngest(input) {
3172
3749
  sha256,
3173
3750
  dry_run: true,
3174
3751
  humanHint: [
3175
- `DRY RUN \u2014 would create:`,
3752
+ "DRY RUN \u2014 would create:",
3176
3753
  ` ${rawRelPath} (sha256: ${sha256.slice(0, 12)}...)`,
3177
3754
  ` ${typedRelPath}`,
3178
3755
  ` type: ${input.type}, tags: [${tags.join(", ")}]`,
3179
3756
  input.provenance ? ` provenance: ${input.provenance}` : ""
3180
- ].filter(Boolean).join("\n")
3757
+ ].filter(Boolean).join("\n"),
3758
+ publication: preview.result.data
3181
3759
  })
3182
3760
  };
3183
3761
  }
3184
- const typedFm = {
3185
- title: input.title,
3186
- aliases: [],
3187
- created: today,
3188
- updated: today,
3189
- type: input.type,
3190
- tags,
3191
- sources: [rawRelPath],
3192
- confidence: "medium",
3193
- ...input.provenance ? { provenance: input.provenance } : {}
3194
- };
3195
- const det = detectSchema(typedFm);
3196
- if (!det.schema) {
3762
+ try {
3763
+ await mkdir9(join17(input.vault, "raw", "articles"), { recursive: true });
3764
+ } catch (error) {
3197
3765
  return {
3198
- exitCode: ExitCode.INGEST_VALIDATION_FAILED,
3199
- result: err("INGEST_VALIDATION_FAILED", {
3200
- message: "generated typed-knowledge page could not be detected as a valid schema"
3201
- })
3766
+ exitCode: ExitCode.WRITE_FAILED,
3767
+ result: err("WRITE_FAILED", { path: join17(input.vault, "raw", "articles"), message: String(error) })
3202
3768
  };
3203
3769
  }
3204
- const parsed = TypedKnowledgeSchema.safeParse(typedFm);
3205
- if (!parsed.success) {
3206
- const errors = parsed.error.issues.map((i) => ({
3207
- path: i.path.join("."),
3208
- message: i.message
3209
- }));
3770
+ const rawWrite = await writeResolvedRaw({
3771
+ path: rawAbsPath,
3772
+ sourceUrl,
3773
+ sourceContent,
3774
+ sha256,
3775
+ today,
3776
+ capture: resolvedRaw.data
3777
+ });
3778
+ if (!rawWrite.ok) {
3210
3779
  return {
3211
- exitCode: ExitCode.INGEST_VALIDATION_FAILED,
3212
- result: err("INGEST_VALIDATION_FAILED", {
3213
- message: "generated typed-knowledge page failed schema validation",
3214
- errors
3215
- })
3780
+ exitCode: rawWrite.error === "INGEST_VALIDATION_FAILED" ? ExitCode.INGEST_VALIDATION_FAILED : ExitCode.WRITE_FAILED,
3781
+ result: rawWrite
3216
3782
  };
3217
3783
  }
3218
- try {
3219
- await mkdir8(join16(input.vault, "raw", "articles"), { recursive: true });
3220
- await writeFile8(rawAbsPath, rawContent, "utf8");
3221
- } catch (e) {
3222
- return {
3223
- exitCode: ExitCode.WRITE_FAILED,
3224
- result: err("WRITE_FAILED", { path: rawAbsPath, message: String(e) })
3225
- };
3784
+ if (rawWrite.data.capture.ingested !== publicationDate) {
3785
+ publicationDate = rawWrite.data.capture.ingested;
3786
+ typedContent = buildTypedContent(
3787
+ input.title,
3788
+ publicationDate,
3789
+ input.type,
3790
+ tags,
3791
+ rawRelPath,
3792
+ input.provenance
3793
+ );
3794
+ publication = preparePagePublicationFromContent({
3795
+ vault: input.vault,
3796
+ content: typedContent,
3797
+ target: typedRelPath,
3798
+ logNote: `ingested from ${rawRelPath}`,
3799
+ now: /* @__PURE__ */ new Date(`${publicationDate}T00:00:00Z`)
3800
+ });
3801
+ if (!publication.ok) {
3802
+ return {
3803
+ exitCode: ExitCode.INGEST_VALIDATION_FAILED,
3804
+ result: publication
3805
+ };
3806
+ }
3226
3807
  }
3227
- try {
3228
- await mkdir8(join16(input.vault, typedDir), { recursive: true });
3229
- await writeFile8(typedAbsPath, typedContent, "utf8");
3230
- } catch (e) {
3808
+ const published = await publishPreparedPage(publication.data, input.vault);
3809
+ if (!published.result.ok) {
3810
+ return { exitCode: published.exitCode, result: published.result };
3811
+ }
3812
+ if (published.exitCode !== ExitCode.OK) {
3231
3813
  return {
3232
- exitCode: ExitCode.WRITE_FAILED,
3233
- result: err("WRITE_FAILED", { path: typedAbsPath, message: String(e) })
3814
+ exitCode: published.exitCode,
3815
+ result: err("WRITE_FAILED", { message: "publisher returned inconsistent success state" })
3234
3816
  };
3235
3817
  }
3236
- const humanHint = [
3237
- `created:`,
3238
- ` ${rawRelPath} (sha256: ${sha256.slice(0, 12)}...)`,
3239
- ` ${typedRelPath}`
3240
- ].join("\n");
3241
- appendLastOp(input.vault, {
3242
- operation: "ingest",
3243
- summary: `added ${slug}`,
3244
- files: [rawRelPath, typedRelPath],
3245
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3246
- });
3818
+ const changedFiles = [
3819
+ ...rawWrite.data.changed ? [rawRelPath] : [],
3820
+ ...published.result.data.files_changed
3821
+ ];
3822
+ if (changedFiles.length > 0) {
3823
+ appendLastOp(input.vault, {
3824
+ operation: "ingest",
3825
+ summary: `added ${slug}`,
3826
+ files: [...new Set(changedFiles)],
3827
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3828
+ });
3829
+ }
3247
3830
  return {
3248
3831
  exitCode: ExitCode.OK,
3249
3832
  result: ok({
@@ -3251,7 +3834,12 @@ async function runIngest(input) {
3251
3834
  typed_path: typedRelPath,
3252
3835
  sha256,
3253
3836
  dry_run: false,
3254
- humanHint
3837
+ humanHint: [
3838
+ "created:",
3839
+ ` ${rawRelPath} (sha256: ${sha256.slice(0, 12)}...)`,
3840
+ ` ${typedRelPath}`
3841
+ ].join("\n"),
3842
+ publication: published.result.data
3255
3843
  })
3256
3844
  };
3257
3845
  }
@@ -3403,138 +3991,216 @@ ${body}`;
3403
3991
  };
3404
3992
  }
3405
3993
 
3406
- // src/commands/sync.ts
3407
- import { existsSync as existsSync7 } from "fs";
3408
- import { join as join18 } from "path";
3409
- import { execFileSync as execFileSync2 } from "child_process";
3410
-
3411
- // src/utils/git.ts
3412
- import { execFileSync } from "child_process";
3413
- function git(cwd, args) {
3414
- try {
3415
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3416
- } catch {
3417
- return "";
3994
+ // src/commands/tag-reconcile.ts
3995
+ import { readFile as readFile13 } from "fs/promises";
3996
+ import { join as join18, posix } from "path";
3997
+ var TYPED_TARGET_RE = /^(entities|concepts|comparisons|queries|meta)\/[a-z0-9][a-z0-9./_-]*\.md$/;
3998
+ function errorExitCode2(error) {
3999
+ switch (error) {
4000
+ case "FILE_NOT_FOUND":
4001
+ return ExitCode.FILE_NOT_FOUND;
4002
+ case "MISSING_CLOSING_DELIMITER":
4003
+ return ExitCode.MISSING_CLOSING_DELIMITER;
4004
+ case "SCHEME_REJECTED":
4005
+ return ExitCode.SCHEME_REJECTED;
4006
+ case "VAULT_PATH_INVALID":
4007
+ return ExitCode.VAULT_PATH_INVALID;
4008
+ case "WRITE_FAILED":
4009
+ return ExitCode.WRITE_FAILED;
4010
+ case "SYNC_LOCK_HELD":
4011
+ return ExitCode.SYNC_LOCK_HELD;
4012
+ default:
4013
+ return ExitCode.INVALID_FRONTMATTER;
4014
+ }
4015
+ }
4016
+ function validatePageIdentity(page) {
4017
+ const segments = page.split("/");
4018
+ if (page.length === 0 || posix.isAbsolute(page) || page.includes("\\") || posix.normalize(page) !== page || segments.some((segment) => segment === "" || segment === "." || segment === "..") || !TYPED_TARGET_RE.test(page)) {
4019
+ return err("VAULT_PATH_INVALID", {
4020
+ page,
4021
+ message: "page must be a normalized vault-relative typed Markdown path"
4022
+ });
3418
4023
  }
4024
+ return ok(page);
3419
4025
  }
3420
- function gitStrict(cwd, args) {
3421
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3422
- }
3423
-
3424
- // src/utils/sync-lock.ts
3425
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
3426
- import { join as join17 } from "path";
3427
- import { createHash as createHash4 } from "crypto";
3428
- function getEnvSessionId() {
3429
- if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
3430
- if (process.env.SKILLWIKI_SESSION_ID) return process.env.SKILLWIKI_SESSION_ID;
3431
- return void 0;
3432
- }
3433
- function getSessionId() {
3434
- const envSessionId = getEnvSessionId();
3435
- if (envSessionId) return envSessionId;
3436
- return process.pid.toString();
3437
- }
3438
- function getCwdHash(cwd) {
3439
- const path = cwd || process.cwd();
3440
- const hash = createHash4("sha256").update(path).digest("hex");
3441
- return hash.slice(0, 8);
4026
+ function asTagArray(frontmatter, path) {
4027
+ const tags = frontmatter.tags;
4028
+ if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === "string")) {
4029
+ return err("INVALID_FRONTMATTER", {
4030
+ path,
4031
+ message: "frontmatter tags must be an array of strings"
4032
+ });
4033
+ }
4034
+ return ok(tags);
3442
4035
  }
3443
- function getCliSessionId(cwd) {
3444
- const envSessionId = getEnvSessionId();
3445
- if (envSessionId) return envSessionId;
3446
- return `cli-${getCwdHash(cwd)}`;
4036
+ async function readTagsFromFile(path) {
4037
+ let text;
4038
+ try {
4039
+ text = await readFile13(path, "utf8");
4040
+ } catch (error) {
4041
+ if (error.code === "ENOENT") {
4042
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path }) };
4043
+ }
4044
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { path, message: String(error) }) };
4045
+ }
4046
+ const frontmatter = extractFrontmatter(text);
4047
+ if (!frontmatter.ok) return { exitCode: errorExitCode2(frontmatter.error), result: frontmatter };
4048
+ const tags = asTagArray(frontmatter.data, path);
4049
+ if (!tags.ok) return { exitCode: ExitCode.INVALID_FRONTMATTER, result: tags };
4050
+ return { exitCode: ExitCode.OK, result: tags };
3447
4051
  }
3448
- function lockPath(vault) {
3449
- return join17(vault, ".skillwiki", "sync.lock");
4052
+ async function resolveRequestedTags(input, page) {
4053
+ const explicit = input.tags ?? [];
4054
+ if (!Array.isArray(explicit) || !explicit.every((tag) => typeof tag === "string")) {
4055
+ return {
4056
+ exitCode: ExitCode.INVALID_FRONTMATTER,
4057
+ result: err("INVALID_FRONTMATTER", { message: "explicit tags must be an array of strings" })
4058
+ };
4059
+ }
4060
+ const source = input.from ?? (explicit.length === 0 ? join18(input.vault, page) : void 0);
4061
+ if (!source) return { exitCode: ExitCode.OK, result: ok({ tags: [...new Set(explicit)].sort() }) };
4062
+ const sourced = await readTagsFromFile(source);
4063
+ if (!sourced.result.ok) return { exitCode: sourced.exitCode, result: sourced.result };
4064
+ return {
4065
+ exitCode: ExitCode.OK,
4066
+ result: ok({ tags: [.../* @__PURE__ */ new Set([...explicit, ...sourced.result.data])].sort() })
4067
+ };
3450
4068
  }
3451
- function readLock(vault) {
3452
- const path = lockPath(vault);
3453
- if (!existsSync6(path)) return null;
4069
+ async function readSchema(schemaPath) {
3454
4070
  try {
3455
- const raw = readFileSync4(path, "utf8");
3456
- return JSON.parse(raw);
3457
- } catch {
3458
- return null;
4071
+ return { exitCode: ExitCode.OK, result: ok(await readFile13(schemaPath, "utf8")) };
4072
+ } catch (error) {
4073
+ if (error.code === "ENOENT") {
4074
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: schemaPath }) };
4075
+ }
4076
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { path: schemaPath, message: String(error) }) };
3459
4077
  }
3460
4078
  }
3461
- function isStale(lock, now) {
3462
- const nowTime = (now ?? /* @__PURE__ */ new Date()).getTime();
3463
- const expiresTime = new Date(lock.expires).getTime();
3464
- return expiresTime < nowTime;
4079
+ function previewResult(page, tags, reconciled, dryRun, filesChanged) {
4080
+ if (!reconciled.ok) return { exitCode: errorExitCode2(reconciled.error), result: reconciled };
4081
+ const missingTags = reconciled.data.missing;
4082
+ const addedTags = dryRun ? [] : reconciled.data.added;
4083
+ const humanHint = dryRun ? missingTags.length > 0 ? `dry run: would add ${missingTags.join(", ")} to taxonomy for ${page}` : `dry run: taxonomy already includes requested tags for ${page}` : addedTags.length > 0 ? `added ${addedTags.join(", ")} to taxonomy for ${page}` : `taxonomy already includes requested tags for ${page}`;
4084
+ return {
4085
+ exitCode: ExitCode.OK,
4086
+ result: ok({
4087
+ page,
4088
+ requested_tags: tags,
4089
+ missing_tags: missingTags,
4090
+ added_tags: addedTags,
4091
+ changed: reconciled.data.changed,
4092
+ dry_run: dryRun,
4093
+ files_changed: filesChanged,
4094
+ humanHint
4095
+ })
4096
+ };
3465
4097
  }
3466
- function acquireLock(vault, opts = {}) {
3467
- const path = lockPath(vault);
3468
- const dir = join17(vault, ".skillwiki");
3469
- if (!existsSync6(dir)) {
3470
- mkdirSync3(dir, { recursive: true });
4098
+ async function reconcileTagsWhileLocked(input, page, tags, comment) {
4099
+ const schemaPath = join18(input.vault, "SCHEMA.md");
4100
+ const current = await readSchema(schemaPath);
4101
+ if (!current.result.ok) return { exitCode: current.exitCode, result: current.result };
4102
+ const next = reconcileTaxonomyDocument(current.result.data, { tags, comment });
4103
+ if (!next.ok) return { exitCode: errorExitCode2(next.error), result: next };
4104
+ if (next.data.changed) {
4105
+ const written = await atomicWriteText(schemaPath, next.data.text);
4106
+ if (!written.ok) return { exitCode: ExitCode.WRITE_FAILED, result: written };
4107
+ }
4108
+ let verifiedText;
4109
+ try {
4110
+ verifiedText = await readFile13(schemaPath, "utf8");
4111
+ } catch (error) {
4112
+ return {
4113
+ exitCode: ExitCode.WRITE_FAILED,
4114
+ result: err("WRITE_FAILED", { stage: "verify-taxonomy", page, message: String(error) })
4115
+ };
3471
4116
  }
3472
- const sessionId = opts.sessionId ?? getSessionId();
3473
- const summary = opts.summary ?? "skillwiki sync";
3474
- const ttlMinutes = opts.ttlMinutes ?? 30;
3475
- const force = opts.force ?? false;
3476
- const now = /* @__PURE__ */ new Date();
3477
- const acquired = now.toISOString();
3478
- const expires = new Date(now.getTime() + ttlMinutes * 60 * 1e3).toISOString();
3479
- const lock = {
3480
- session_id: sessionId,
3481
- pid: process.pid,
3482
- cwd: process.cwd(),
3483
- summary,
3484
- acquired,
3485
- expires
3486
- };
4117
+ const verified = extractTaxonomy(verifiedText);
4118
+ if (!verified.ok || tags.some((tag) => !verified.data.includes(tag))) {
4119
+ return {
4120
+ exitCode: ExitCode.WRITE_FAILED,
4121
+ result: err("WRITE_FAILED", { stage: "verify-taxonomy", page })
4122
+ };
4123
+ }
4124
+ return previewResult(page, tags, next, false, next.data.changed ? ["SCHEMA.md"] : []);
4125
+ }
4126
+ async function runTagReconcile(input) {
4127
+ const page = validatePageIdentity(input.page);
4128
+ if (!page.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: page };
4129
+ const resolvedTags = await resolveRequestedTags(input, page.data);
4130
+ if (!resolvedTags.result.ok) return { exitCode: resolvedTags.exitCode, result: resolvedTags.result };
4131
+ const tags = resolvedTags.result.data.tags;
4132
+ const date = (input.now ?? /* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4133
+ const comment = taxonomyCommentForPage(page.data, date, input.reason);
4134
+ if (!comment.ok) return { exitCode: ExitCode.SCHEME_REJECTED, result: comment };
4135
+ if (!input.write) {
4136
+ const schema = await readSchema(join18(input.vault, "SCHEMA.md"));
4137
+ if (!schema.result.ok) return { exitCode: schema.exitCode, result: schema.result };
4138
+ const preview = reconcileTaxonomyDocument(schema.result.data, { tags, comment: comment.data });
4139
+ return previewResult(page.data, tags, preview, true, []);
4140
+ }
4141
+ let lock;
3487
4142
  try {
3488
- const content = JSON.stringify(lock, null, 2) + "\n";
3489
- writeFileSync3(path, content, { flag: "wx" });
3490
- return { ok: true, lock };
3491
- } catch (e) {
3492
- const err2 = e;
3493
- if (err2.code !== "EEXIST") throw err2;
3494
- }
3495
- const existing = readLock(vault);
3496
- if (!existing) {
3497
- writeLockedFile(path, lock);
3498
- return { ok: true, lock };
3499
- }
3500
- if (force || isStale(existing)) {
3501
- writeLockedFile(path, lock);
3502
- return { ok: true, lock };
3503
- }
3504
- return { ok: false, held: existing };
3505
- }
3506
- function writeLockedFile(path, lock) {
3507
- const tmp = path + ".tmp";
3508
- const content = JSON.stringify(lock, null, 2) + "\n";
3509
- writeFileSync3(tmp, content);
3510
- renameSync2(tmp, path);
3511
- }
3512
- function releaseLock(vault, opts = {}) {
3513
- const path = lockPath(vault);
3514
- if (!existsSync6(path)) {
3515
- return { released: false };
3516
- }
3517
- const sessionId = opts.sessionId ?? getSessionId();
3518
- const existing = readLock(vault);
3519
- if (opts.force) {
3520
- try {
3521
- unlinkSync2(path);
3522
- const prior = existing && existing.session_id !== sessionId ? existing : void 0;
3523
- return { released: true, prior };
3524
- } catch {
3525
- return { released: false };
3526
- }
4143
+ lock = acquireOwnedSyncLock(input.vault, {
4144
+ summary: `tag reconcile ${page.data}`,
4145
+ ttlMinutes: 1
4146
+ });
4147
+ } catch (error) {
4148
+ return {
4149
+ exitCode: ExitCode.WRITE_FAILED,
4150
+ result: err("WRITE_FAILED", { stage: "lock", page: page.data, message: String(error) })
4151
+ };
4152
+ }
4153
+ if (!lock.ok) return { exitCode: errorExitCode2(lock.error), result: lock };
4154
+ let outcome;
4155
+ let released;
4156
+ try {
4157
+ outcome = await reconcileTagsWhileLocked(input, page.data, tags, comment.data);
4158
+ } catch (error) {
4159
+ outcome = {
4160
+ exitCode: ExitCode.WRITE_FAILED,
4161
+ result: err("WRITE_FAILED", { stage: "reconcile", page: page.data, message: String(error) })
4162
+ };
4163
+ } finally {
4164
+ released = releaseOwnedSyncLock(lock.data);
3527
4165
  }
3528
- if (!existing || existing.session_id !== sessionId) {
3529
- return { released: false };
4166
+ if (released === void 0 || !released.ok) {
4167
+ return {
4168
+ exitCode: ExitCode.WRITE_FAILED,
4169
+ result: err("WRITE_FAILED", {
4170
+ stage: "unlock",
4171
+ page: page.data,
4172
+ primary_error: outcome && !outcome.result.ok ? outcome.result.error : void 0,
4173
+ release_error: released && !released.ok ? released.detail : "release did not run"
4174
+ })
4175
+ };
3530
4176
  }
4177
+ return outcome ?? {
4178
+ exitCode: ExitCode.WRITE_FAILED,
4179
+ result: err("WRITE_FAILED", {
4180
+ stage: "reconcile",
4181
+ page: page.data,
4182
+ message: "locked reconciliation produced no result"
4183
+ })
4184
+ };
4185
+ }
4186
+
4187
+ // src/commands/sync.ts
4188
+ import { existsSync as existsSync5 } from "fs";
4189
+ import { join as join19 } from "path";
4190
+ import { execFileSync as execFileSync2 } from "child_process";
4191
+
4192
+ // src/utils/git.ts
4193
+ import { execFileSync } from "child_process";
4194
+ function git(cwd, args) {
3531
4195
  try {
3532
- unlinkSync2(path);
3533
- return { released: true };
4196
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3534
4197
  } catch {
3535
- return { released: false };
4198
+ return "";
3536
4199
  }
3537
4200
  }
4201
+ function gitStrict(cwd, args) {
4202
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
4203
+ }
3538
4204
 
3539
4205
  // src/utils/vault-git-pathspec.ts
3540
4206
  var VAULT_GENERATED_COMMIT_PATHS = [
@@ -3587,7 +4253,7 @@ function refHasPath(vault, ref, path) {
3587
4253
  function runSyncStatus(input) {
3588
4254
  const vault = input.vault;
3589
4255
  const includeStashes = input.includeStashes ?? false;
3590
- if (!existsSync7(join18(vault, ".git"))) {
4256
+ if (!existsSync5(join19(vault, ".git"))) {
3591
4257
  return {
3592
4258
  exitCode: ExitCode.VAULT_PATH_INVALID,
3593
4259
  result: ok({
@@ -3694,7 +4360,7 @@ function runSyncStatus(input) {
3694
4360
  }
3695
4361
  async function runSyncPush(input) {
3696
4362
  const vault = input.vault;
3697
- if (!existsSync7(join18(vault, ".git"))) {
4363
+ if (!existsSync5(join19(vault, ".git"))) {
3698
4364
  return {
3699
4365
  exitCode: ExitCode.VAULT_PATH_INVALID,
3700
4366
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -3854,7 +4520,7 @@ function enableGitLongPathsOnWindows(vault) {
3854
4520
  }
3855
4521
  async function runSyncPull(input) {
3856
4522
  const vault = input.vault;
3857
- if (!existsSync7(join18(vault, ".git"))) {
4523
+ if (!existsSync5(join19(vault, ".git"))) {
3858
4524
  return {
3859
4525
  exitCode: ExitCode.VAULT_PATH_INVALID,
3860
4526
  result: err("NOT_A_GIT_REPO", { path: vault })
@@ -4026,7 +4692,7 @@ function runSyncPeers(input) {
4026
4692
  }
4027
4693
  function runSyncLock(input) {
4028
4694
  const vault = input.vault;
4029
- if (!existsSync7(vault)) {
4695
+ if (!existsSync5(vault)) {
4030
4696
  return {
4031
4697
  exitCode: ExitCode.VAULT_PATH_INVALID,
4032
4698
  result: err("VAULT_PATH_INVALID", { path: vault })
@@ -4061,7 +4727,7 @@ function runSyncLock(input) {
4061
4727
  }
4062
4728
  function runSyncUnlock(input) {
4063
4729
  const vault = input.vault;
4064
- if (!existsSync7(vault)) {
4730
+ if (!existsSync5(vault)) {
4065
4731
  return {
4066
4732
  exitCode: ExitCode.VAULT_PATH_INVALID,
4067
4733
  result: err("VAULT_PATH_INVALID", { path: vault })
@@ -4094,8 +4760,8 @@ function runSyncUnlock(input) {
4094
4760
  }
4095
4761
 
4096
4762
  // src/commands/backup.ts
4097
- import { statSync as statSync3, readdirSync, readFileSync as readFileSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
4098
- import { join as join19, relative as relative2, dirname as dirname6 } from "path";
4763
+ import { statSync as statSync2, readdirSync, readFileSync as readFileSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
4764
+ import { join as join20, relative as relative2, dirname as dirname6 } from "path";
4099
4765
  import { PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
4100
4766
 
4101
4767
  // src/utils/s3-client.ts
@@ -4119,7 +4785,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".obsidian", "_archive", "node_
4119
4785
  function* walkMarkdown(dir, base) {
4120
4786
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
4121
4787
  if (SKIP_DIRS.has(entry.name)) continue;
4122
- const full = join19(dir, entry.name);
4788
+ const full = join20(dir, entry.name);
4123
4789
  if (entry.isDirectory()) {
4124
4790
  yield* walkMarkdown(full, base);
4125
4791
  } else if (entry.name.endsWith(".md")) {
@@ -4142,8 +4808,8 @@ async function runBackupSync(input) {
4142
4808
  let failed = 0;
4143
4809
  const files = [...walkMarkdown(input.vault, input.vault)];
4144
4810
  for (const relPath of files) {
4145
- const absPath = join19(input.vault, relPath);
4146
- const localStat = statSync3(absPath);
4811
+ const absPath = join20(input.vault, relPath);
4812
+ const localStat = statSync2(absPath);
4147
4813
  let needsUpload = true;
4148
4814
  try {
4149
4815
  const head = await client.send(new HeadObjectCommand({ Bucket: input.bucket, Key: relPath }));
@@ -4161,7 +4827,7 @@ async function runBackupSync(input) {
4161
4827
  continue;
4162
4828
  }
4163
4829
  try {
4164
- const body = readFileSync5(absPath);
4830
+ const body = readFileSync4(absPath);
4165
4831
  await client.send(new PutObjectCommand({ Bucket: input.bucket, Key: relPath, Body: body }));
4166
4832
  uploaded++;
4167
4833
  } catch {
@@ -4218,9 +4884,9 @@ async function runBackupRestore(input) {
4218
4884
  const objects = list.Contents ?? [];
4219
4885
  for (const obj of objects) {
4220
4886
  if (!obj.Key) continue;
4221
- const localPath = join19(target, obj.Key);
4887
+ const localPath = join20(target, obj.Key);
4222
4888
  try {
4223
- const localStat = statSync3(localPath);
4889
+ const localStat = statSync2(localPath);
4224
4890
  if (obj.LastModified && localStat.mtime > obj.LastModified) {
4225
4891
  conflicts++;
4226
4892
  continue;
@@ -4231,8 +4897,8 @@ async function runBackupRestore(input) {
4231
4897
  const resp = await client.send(new GetObjectCommand({ Bucket: input.bucket, Key: obj.Key }));
4232
4898
  const body = await resp.Body?.transformToByteArray();
4233
4899
  if (body) {
4234
- mkdirSync4(dirname6(localPath), { recursive: true });
4235
- writeFileSync4(localPath, Buffer.from(body));
4900
+ mkdirSync2(dirname6(localPath), { recursive: true });
4901
+ writeFileSync2(localPath, Buffer.from(body));
4236
4902
  downloaded++;
4237
4903
  }
4238
4904
  } catch {
@@ -4264,11 +4930,11 @@ async function runBackupRestore(input) {
4264
4930
  }
4265
4931
 
4266
4932
  // src/commands/status.ts
4267
- import { existsSync as existsSync8, statSync as statSync4 } from "fs";
4268
- import { readFile as readFile11 } from "fs/promises";
4269
- import { join as join20 } from "path";
4933
+ import { existsSync as existsSync6, statSync as statSync3 } from "fs";
4934
+ import { readFile as readFile14 } from "fs/promises";
4935
+ import { join as join21 } from "path";
4270
4936
  async function runStatus(input) {
4271
- if (!existsSync8(input.vault)) {
4937
+ if (!existsSync6(input.vault)) {
4272
4938
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
4273
4939
  }
4274
4940
  const scan = await scanVault(input.vault);
@@ -4293,7 +4959,7 @@ async function runStatus(input) {
4293
4959
  const compound = scan.data.compound.length;
4294
4960
  let schemaVersion = "v1";
4295
4961
  try {
4296
- const schemaContent = await readFile11(join20(input.vault, "SCHEMA.md"), "utf8");
4962
+ const schemaContent = await readFile14(join21(input.vault, "SCHEMA.md"), "utf8");
4297
4963
  const versionMatch = schemaContent.match(/version:\s*["']?([^"'\s\n]+)/i);
4298
4964
  if (versionMatch) schemaVersion = versionMatch[1];
4299
4965
  } catch {
@@ -4309,7 +4975,7 @@ async function runStatus(input) {
4309
4975
  let maxTime = 0;
4310
4976
  for (const page of allPages) {
4311
4977
  try {
4312
- const st = statSync4(page.absPath);
4978
+ const st = statSync3(page.absPath);
4313
4979
  if (st.mtimeMs > maxTime) {
4314
4980
  maxTime = st.mtimeMs;
4315
4981
  lastModified = st.mtime.toISOString();
@@ -4353,8 +5019,8 @@ async function runStatus(input) {
4353
5019
  }
4354
5020
 
4355
5021
  // src/commands/seed.ts
4356
- import { mkdir as mkdir9, writeFile as writeFile9, stat as stat5 } from "fs/promises";
4357
- import { join as join21 } from "path";
5022
+ import { mkdir as mkdir10, writeFile as writeFile9, stat as stat4 } from "fs/promises";
5023
+ import { join as join22 } from "path";
4358
5024
  var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4359
5025
  var EXAMPLE_PAGES = {
4360
5026
  "entities/example-project.md": `---
@@ -4423,29 +5089,29 @@ Real sources are immutable after ingestion \u2014 never edit them.
4423
5089
  `;
4424
5090
  async function runSeed(input) {
4425
5091
  try {
4426
- await stat5(join21(input.vault, "SCHEMA.md"));
5092
+ await stat4(join22(input.vault, "SCHEMA.md"));
4427
5093
  } catch {
4428
5094
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { root: input.vault, reason: "SCHEMA.md missing \u2014 run `skillwiki init` first" }) };
4429
5095
  }
4430
5096
  const created = [];
4431
5097
  const skipped = [];
4432
5098
  for (const [relPath, content] of Object.entries(EXAMPLE_PAGES)) {
4433
- const absPath = join21(input.vault, relPath);
5099
+ const absPath = join22(input.vault, relPath);
4434
5100
  try {
4435
- await stat5(absPath);
5101
+ await stat4(absPath);
4436
5102
  skipped.push(relPath);
4437
5103
  } catch {
4438
- await mkdir9(join21(absPath, ".."), { recursive: true });
5104
+ await mkdir10(join22(absPath, ".."), { recursive: true });
4439
5105
  await writeFile9(absPath, content, "utf8");
4440
5106
  created.push(relPath);
4441
5107
  }
4442
5108
  }
4443
- const rawPath = join21(input.vault, "raw", "articles", "example-source.md");
5109
+ const rawPath = join22(input.vault, "raw", "articles", "example-source.md");
4444
5110
  try {
4445
- await stat5(rawPath);
5111
+ await stat4(rawPath);
4446
5112
  skipped.push("raw/articles/example-source.md");
4447
5113
  } catch {
4448
- await mkdir9(join21(rawPath, ".."), { recursive: true });
5114
+ await mkdir10(join22(rawPath, ".."), { recursive: true });
4449
5115
  await writeFile9(rawPath, EXAMPLE_RAW, "utf8");
4450
5116
  created.push("raw/articles/example-source.md");
4451
5117
  }
@@ -4468,9 +5134,9 @@ async function runSeed(input) {
4468
5134
  }
4469
5135
 
4470
5136
  // src/commands/canvas.ts
4471
- import { readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
4472
- import { existsSync as existsSync9 } from "fs";
4473
- import { join as join22 } from "path";
5137
+ import { readFile as readFile15, writeFile as writeFile10 } from "fs/promises";
5138
+ import { existsSync as existsSync7 } from "fs";
5139
+ import { join as join23 } from "path";
4474
5140
  var NODE_WIDTH = 240;
4475
5141
  var NODE_HEIGHT = 60;
4476
5142
  var COLUMN_SPACING = 400;
@@ -4548,8 +5214,8 @@ function buildCanvasEdges(adjacency) {
4548
5214
  return edges;
4549
5215
  }
4550
5216
  async function runCanvasGenerate(input) {
4551
- const graphPath = input.graphPath ?? join22(input.vault, ".skillwiki", "graph.json");
4552
- if (!existsSync9(graphPath)) {
5217
+ const graphPath = input.graphPath ?? join23(input.vault, ".skillwiki", "graph.json");
5218
+ if (!existsSync7(graphPath)) {
4553
5219
  return {
4554
5220
  exitCode: ExitCode.FILE_NOT_FOUND,
4555
5221
  result: err("FILE_NOT_FOUND", {
@@ -4560,7 +5226,7 @@ async function runCanvasGenerate(input) {
4560
5226
  }
4561
5227
  let raw;
4562
5228
  try {
4563
- raw = await readFile12(graphPath, "utf8");
5229
+ raw = await readFile15(graphPath, "utf8");
4564
5230
  } catch (e) {
4565
5231
  return {
4566
5232
  exitCode: ExitCode.FILE_NOT_FOUND,
@@ -4586,7 +5252,7 @@ async function runCanvasGenerate(input) {
4586
5252
  const nodes = buildCanvasNodes(paths);
4587
5253
  const edges = buildCanvasEdges(graph.adjacency);
4588
5254
  const canvas = { nodes, edges };
4589
- const outPath = join22(input.vault, "vault-graph.canvas");
5255
+ const outPath = join23(input.vault, "vault-graph.canvas");
4590
5256
  try {
4591
5257
  await writeFile10(outPath, JSON.stringify(canvas, null, 2));
4592
5258
  } catch (e) {
@@ -4608,10 +5274,10 @@ written: ${outPath}`
4608
5274
  }
4609
5275
 
4610
5276
  // src/commands/fleet-health.ts
4611
- import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
5277
+ import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
4612
5278
  import { execSync as nodeExecSync } from "child_process";
4613
5279
  import { hostname as nodeHostname, platform as nodePlatform } from "os";
4614
- import { join as join23 } from "path";
5280
+ import { join as join24 } from "path";
4615
5281
  var SSH_TIMEOUT_MS = 15e3;
4616
5282
  var TIMER_UNIT = "agent-memory-trends.timer";
4617
5283
  var SERVICE_UNIT = "agent-memory-trends.service";
@@ -4708,9 +5374,9 @@ function applyServiceFailedOverlay(run, serviceFailed) {
4708
5374
  function probeLocal(vaultPath, deps) {
4709
5375
  const latestPath = satelliteLatestRunPath(vaultPath);
4710
5376
  let parsed = null;
4711
- if (existsSync10(latestPath)) {
5377
+ if (existsSync8(latestPath)) {
4712
5378
  try {
4713
- const wire = readSatelliteLatestRunFromText(readFileSync6(latestPath, "utf8"));
5379
+ const wire = readSatelliteLatestRunFromText(readFileSync5(latestPath, "utf8"));
4714
5380
  if (wire) {
4715
5381
  parsed = {
4716
5382
  status: wire.status,
@@ -4841,7 +5507,7 @@ async function runFleetHealth(input) {
4841
5507
  const home = input.home ?? env.HOME ?? "";
4842
5508
  const osHostname = input.osHostname ?? env.HOSTNAME ?? nodeHostname();
4843
5509
  const vault = input.vault ?? env.WIKI_PATH;
4844
- const file = input.file ?? (vault ? join23(vault, FLEET_REL_PATH) : void 0);
5510
+ const file = input.file ?? (vault ? join24(vault, FLEET_REL_PATH) : void 0);
4845
5511
  if (!file) {
4846
5512
  return {
4847
5513
  exitCode: ExitCode.NO_VAULT_CONFIGURED,
@@ -4925,15 +5591,15 @@ async function runFleetHealth(input) {
4925
5591
  }
4926
5592
 
4927
5593
  // src/utils/auto-commit.ts
4928
- import { existsSync as existsSync11 } from "fs";
4929
- import { join as join24 } from "path";
5594
+ import { existsSync as existsSync9 } from "fs";
5595
+ import { join as join25 } from "path";
4930
5596
  async function postCommit(vault, exitCode) {
4931
5597
  if (exitCode !== 0) return;
4932
5598
  const home = process.env.HOME ?? "";
4933
5599
  const dotenv = await parseDotenvFile(configPath(home));
4934
5600
  const autoCommit = process.env.AUTO_COMMIT ?? dotenv["AUTO_COMMIT"];
4935
5601
  if (autoCommit === "false") return;
4936
- if (!existsSync11(join24(vault, ".git"))) return;
5602
+ if (!existsSync9(join25(vault, ".git"))) return;
4937
5603
  const lastOps = readLastOp(vault);
4938
5604
  if (lastOps.length === 0) return;
4939
5605
  const porcelain = git(vault, ["status", "--porcelain", "--", ...VAULT_COMMIT_PATHSPEC]);
@@ -4957,8 +5623,8 @@ async function postCommit(vault, exitCode) {
4957
5623
  }
4958
5624
 
4959
5625
  // src/utils/protected-vault-write-guard.ts
4960
- import { readFileSync as readFileSync7 } from "fs";
4961
- import { join as join25, resolve as resolvePath } from "path";
5626
+ import { readFileSync as readFileSync6 } from "fs";
5627
+ import { join as join26, resolve as resolvePath } from "path";
4962
5628
  async function guardProtectedVaultWrite(input) {
4963
5629
  const env = input.env ?? process.env;
4964
5630
  const home = input.home ?? process.env.HOME ?? "";
@@ -5024,7 +5690,7 @@ async function resolveLiveVaultPath(input) {
5024
5690
  return resolved.ok ? resolved.data.path : void 0;
5025
5691
  }
5026
5692
  function resolveSnapshotWorktree(home) {
5027
- const skillwikiEnv = join25(home, ".skillwiki", ".env");
5693
+ const skillwikiEnv = join26(home, ".skillwiki", ".env");
5028
5694
  const explicitWorktree = readEnvKey(skillwikiEnv, ["vault_sync.snapshot_worktree"]);
5029
5695
  if (explicitWorktree) return explicitWorktree;
5030
5696
  const snapshotProfile = readEnvKey(skillwikiEnv, ["vault_sync.snapshot_profile"]);
@@ -5036,7 +5702,7 @@ function resolveSnapshotWorktree(home) {
5036
5702
  }
5037
5703
  function readEnvKey(path, keys) {
5038
5704
  try {
5039
- const content = readFileSync7(path, "utf8");
5705
+ const content = readFileSync6(path, "utf8");
5040
5706
  for (const line of content.split(/\r?\n/)) {
5041
5707
  const trimmed = line.trim();
5042
5708
  if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
@@ -5094,7 +5760,7 @@ program.command("validate <file>").description("validate vault page frontmatter
5094
5760
  emit(await runValidate({ file, apply: !!opts.apply, vault }), vault);
5095
5761
  });
5096
5762
  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) => {
5097
- const out = opts.out ?? join26(vault, ".skillwiki", "graph.json");
5763
+ const out = opts.out ?? join27(vault, ".skillwiki", "graph.json");
5098
5764
  return emitGuardedVaultWrite(vault, "graph build", () => runGraphBuild({ vault, out }));
5099
5765
  });
5100
5766
  var canvasCmd = program.command("canvas").description("manage Obsidian canvas files");
@@ -5188,6 +5854,45 @@ program.command("tag-audit [vault]").description("audit tag taxonomy consistency
5188
5854
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5189
5855
  else emit(await runTagAudit({ vault: v.vault }), v.vault);
5190
5856
  });
5857
+ var tagCmd = program.command("tag").description("manage the vault tag taxonomy");
5858
+ tagCmd.command("reconcile [vault]").description("preview or add prospective page tags to SCHEMA taxonomy").requiredOption("--page <path>", "vault-relative typed page target").option("--from <path>", "unpublished draft or existing page to read tags from").option("--tags <csv>", "comma-separated prospective tags").option("--reason <text>", "reconciliation comment reason").option("--write", "write SCHEMA.md after successful preview", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5859
+ const resolved = await resolveVaultArg(vault, opts.wiki);
5860
+ if (!resolved.ok) return emit({ exitCode: resolved.exitCode, result: resolved.payload });
5861
+ const input = {
5862
+ vault: resolved.vault,
5863
+ page: opts.page,
5864
+ from: opts.from,
5865
+ tags: opts.tags?.split(",").map((tag) => tag.trim()).filter(Boolean),
5866
+ reason: opts.reason,
5867
+ write: !!opts.write
5868
+ };
5869
+ if (!opts.write) return emit(await runTagReconcile(input), resolved.vault, { postCommit: false });
5870
+ return emitGuardedVaultWrite(
5871
+ resolved.vault,
5872
+ "tag reconcile",
5873
+ () => runTagReconcile(input),
5874
+ { postCommit: false }
5875
+ );
5876
+ });
5877
+ var pageCmd = program.command("page").description("validate and publish typed vault pages");
5878
+ pageCmd.command("publish <draft> [vault]").description("preview or publish an unpublished typed-page draft").requiredOption("--target <path>", "vault-relative typed page target").option("--log-note <text>", "single-line publication log note").option("--write", "publish SCHEMA, page, index, and log", false).option("--wiki <name>", "wiki profile name").action(async (draft, vault, opts) => {
5879
+ const resolved = await resolveVaultArg(vault, opts.wiki);
5880
+ if (!resolved.ok) return emit({ exitCode: resolved.exitCode, result: resolved.payload });
5881
+ const input = {
5882
+ vault: resolved.vault,
5883
+ draftPath: draft,
5884
+ target: opts.target,
5885
+ logNote: opts.logNote,
5886
+ write: !!opts.write
5887
+ };
5888
+ if (!opts.write) return emit(await runPagePublish(input), resolved.vault, { postCommit: false });
5889
+ return emitGuardedVaultWrite(
5890
+ resolved.vault,
5891
+ "page publish",
5892
+ () => runPagePublish(input),
5893
+ { postCommit: false }
5894
+ );
5895
+ });
5191
5896
  program.command("index-check [vault]").description("verify index.md entries match actual vault pages").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5192
5897
  const v = await resolveVaultArg(vault, opts.wiki);
5193
5898
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
@@ -5354,6 +6059,22 @@ program.command("archive <page> [vault]").description("archive a typed-knowledge
5354
6059
  })
5355
6060
  );
5356
6061
  });
6062
+ program.command("remove <page> [vault]").description("remove a vault path and write a delete-intent tombstone").option("--wiki <name>", "wiki profile name").option("--remote <remote>", "rclone remote root to prune the live path, for example seaweed-wiki:cloud/wiki").option("--remote-delete", "delete the live path from the remote after local remove", false).option("--max-remote-deletes <n>", "maximum remote object deletes allowed", "1").option("--reason <text>", "stored on the delete-intent tombstone").action(async (page, vault, opts) => {
6063
+ const v = await resolveVaultArg(vault, opts.wiki);
6064
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6065
+ else return emitGuardedVaultWrite(
6066
+ v.vault,
6067
+ "remove",
6068
+ () => runRemove({
6069
+ vault: v.vault,
6070
+ page,
6071
+ remote: opts.remote,
6072
+ remoteDelete: !!opts.remoteDelete,
6073
+ maxRemoteDeletes: Number.parseInt(opts.maxRemoteDeletes, 10),
6074
+ reason: opts.reason
6075
+ })
6076
+ );
6077
+ });
5357
6078
  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) => {
5358
6079
  const v = await resolveVaultArg(vault, opts.wiki);
5359
6080
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });