skillwiki 0.2.0-beta.27 → 0.2.0-beta.28

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
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-XM5IYZX7.js";
5
5
 
6
6
  // src/cli.ts
7
- import { readFileSync as readFileSync4 } from "fs";
7
+ import { readFileSync as readFileSync5 } from "fs";
8
8
  import { Command } from "commander";
9
9
 
10
10
  // src/utils/output.ts
@@ -72,7 +72,8 @@ var ExitCode = {
72
72
  DRIFT_DETECTED: 32,
73
73
  RAW_DEDUP_DETECTED: 33,
74
74
  MIGRATION_APPLIED: 34,
75
- UNKNOWN_WIKI_PROFILE: 35
75
+ UNKNOWN_WIKI_PROFILE: 35,
76
+ DEDUP_APPLIED: 36
76
77
  };
77
78
 
78
79
  // ../shared/src/json-output.ts
@@ -901,7 +902,7 @@ async function runAudit(input) {
901
902
  return { ...m, resolved: false };
902
903
  }
903
904
  }));
904
- const sources = fm.data.sources ?? [];
905
+ const sources = (fm.data.sources ?? []).map((s) => s.replace(/^\^\[/, "").replace(/\]$/, ""));
905
906
  const referenced = new Set(resolved.map((m) => m.target));
906
907
  const unused_sources = sources.filter((s) => !referenced.has(s));
907
908
  const missing_from_sources = [...referenced].filter((t) => !sources.includes(t));
@@ -1306,8 +1307,7 @@ async function runInit(input) {
1306
1307
  return tpl.replace(/\{\{INIT_DATE\}\}/g, today).replace("{{DOMAIN}}", domain).replace("{{WIKI_LANG}}", canonicalLang);
1307
1308
  });
1308
1309
  if (err22) return err22;
1309
- const isTempPath = target.startsWith("/tmp/") || target === "/tmp" || target.startsWith("/var/tmp/") || target === "/var/tmp" || target.startsWith("/private/tmp/");
1310
- const skipEnv = !!input.noEnv || isTempPath;
1310
+ const skipEnv = !!input.noEnv;
1311
1311
  let envWritten = "";
1312
1312
  if (!skipEnv) {
1313
1313
  try {
@@ -1603,6 +1603,8 @@ ${markdown_links.map((l) => ` line ${l.line}: ${l.text}`).join("\n")}`;
1603
1603
  }
1604
1604
 
1605
1605
  // src/commands/dedup.ts
1606
+ import { readFileSync, writeFileSync, unlinkSync } from "fs";
1607
+ import { join as join13 } from "path";
1606
1608
  async function runDedup(input) {
1607
1609
  const scan = await scanVault(input.vault);
1608
1610
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -1619,17 +1621,63 @@ async function runDedup(input) {
1619
1621
  else hashMap.set(sha, [raw.relPath]);
1620
1622
  }
1621
1623
  const duplicates = [...hashMap.entries()].filter(([, files]) => files.length > 1).map(([sha256, files]) => ({ sha256, files }));
1622
- const exitCode = duplicates.length > 0 ? ExitCode.RAW_DEDUP_DETECTED : ExitCode.OK;
1624
+ const rewired = [];
1625
+ const removed = [];
1626
+ if (input.apply && duplicates.length > 0) {
1627
+ const replacements = /* @__PURE__ */ new Map();
1628
+ for (const group of duplicates) {
1629
+ const canonical = group.files[0];
1630
+ for (let i = 1; i < group.files.length; i++) {
1631
+ replacements.set(group.files[i], canonical);
1632
+ }
1633
+ }
1634
+ for (const page of scan.data.typedKnowledge) {
1635
+ const text = readFileSync(join13(input.vault, page.relPath), "utf-8");
1636
+ let updated = text;
1637
+ let changed = false;
1638
+ for (const [oldPath, newPath] of replacements) {
1639
+ const oldMarker = `^[${oldPath}]`;
1640
+ const newMarker = `^[${newPath}]`;
1641
+ if (updated.includes(oldMarker)) {
1642
+ updated = updated.replaceAll(oldMarker, newMarker);
1643
+ changed = true;
1644
+ }
1645
+ const oldFm = `- "^[${oldPath}]"`;
1646
+ const newFm = `- "^[${newPath}]"`;
1647
+ if (updated.includes(oldFm)) {
1648
+ updated = updated.replaceAll(oldFm, newFm);
1649
+ changed = true;
1650
+ }
1651
+ }
1652
+ if (changed) {
1653
+ writeFileSync(join13(input.vault, page.relPath), updated);
1654
+ rewired.push(page.relPath);
1655
+ }
1656
+ }
1657
+ for (const [oldPath] of replacements) {
1658
+ const fullPath = join13(input.vault, oldPath);
1659
+ try {
1660
+ unlinkSync(fullPath);
1661
+ removed.push(oldPath);
1662
+ } catch {
1663
+ }
1664
+ }
1665
+ }
1666
+ const exitCode = duplicates.length > 0 ? input.apply ? ExitCode.DEDUP_APPLIED : ExitCode.RAW_DEDUP_DETECTED : ExitCode.OK;
1623
1667
  const hintLines = [`scanned: ${totalFiles} raw files`];
1624
1668
  if (duplicates.length > 0) {
1625
1669
  hintLines.push(`duplicates: ${duplicates.length}`);
1626
1670
  for (const d of duplicates) hintLines.push(` ${d.sha256.slice(0, 12)}... \u2192 ${d.files.join(", ")}`);
1671
+ if (input.apply) {
1672
+ hintLines.push(`rewired: ${rewired.length} pages`);
1673
+ hintLines.push(`removed: ${removed.length} raw files`);
1674
+ }
1627
1675
  } else {
1628
1676
  hintLines.push("0 duplicates");
1629
1677
  }
1630
1678
  return {
1631
1679
  exitCode,
1632
- result: ok({ scanned: totalFiles, duplicates, humanHint: hintLines.join("\n") })
1680
+ result: ok({ scanned: totalFiles, duplicates, rewired, removed, humanHint: hintLines.join("\n") })
1633
1681
  };
1634
1682
  }
1635
1683
 
@@ -1648,8 +1696,8 @@ function hasDuplicateFrontmatter(body) {
1648
1696
  return false;
1649
1697
  }
1650
1698
  var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_dedup", "tag_not_in_taxonomy"];
1651
- var WARNING_ORDER = ["index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "contested", "orphans", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "missing_overview"];
1652
- var INFO_ORDER = ["bridges", "low_confidence_single_source", "page_structure", "topic_map_recommended"];
1699
+ var WARNING_ORDER = ["index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "orphans", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "missing_overview"];
1700
+ var INFO_ORDER = ["bridges", "page_structure", "topic_map_recommended", "frontmatter_wikilink"];
1653
1701
  async function runLint(input) {
1654
1702
  const buckets = {};
1655
1703
  const links = await runLinks({ vault: input.vault });
@@ -1693,20 +1741,31 @@ async function runLint(input) {
1693
1741
  const dedup = await runDedup({ vault: input.vault });
1694
1742
  if (dedup.result.ok && dedup.result.data.duplicates.length > 0) buckets.raw_dedup = dedup.result.data.duplicates;
1695
1743
  const scan = await scanVault(input.vault);
1744
+ const slugs = scan.ok ? buildSlugMap(scan.data.typedKnowledge) : /* @__PURE__ */ new Map();
1696
1745
  if (scan.ok) {
1697
1746
  const legacyPages = [];
1698
1747
  const orphanedPages = [];
1699
1748
  const structFlags = [];
1700
1749
  const dupFrontmatter = [];
1701
1750
  const noOverview = [];
1751
+ const fmWikilinkFlags = [];
1702
1752
  for (const page of scan.data.typedKnowledge) {
1703
1753
  const text = await readPage(page);
1704
1754
  const split = splitFrontmatter(text);
1705
1755
  if (!split.ok) continue;
1706
1756
  const body = split.data.body;
1757
+ const rawFm = split.data.rawFrontmatter;
1707
1758
  if (hasDuplicateFrontmatter(body)) dupFrontmatter.push(page.relPath);
1708
1759
  if (isLegacyCitationStyle(body)) legacyPages.push(page.relPath);
1709
1760
  if (hasOrphanedCitations(body)) orphanedPages.push(page.relPath);
1761
+ const fmLinks = rawFm.match(/\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]/g) ?? [];
1762
+ for (const link of fmLinks) {
1763
+ const target = link.replace(/^\[\[/, "").replace(/(?:\|[^\[\]]*)?\]\]$/, "").trim();
1764
+ const tail = target.split("/").pop();
1765
+ if (!slugs.has(tail.toLowerCase())) {
1766
+ fmWikilinkFlags.push(`${page.relPath}: [[${target}]] does not resolve`);
1767
+ }
1768
+ }
1710
1769
  const bodyLines = body.split("\n").filter((l) => l.trim().length > 0).length;
1711
1770
  const hasOverview = /^## Overview/m.test(body);
1712
1771
  if (!hasOverview) noOverview.push(page.relPath);
@@ -1726,6 +1785,7 @@ async function runLint(input) {
1726
1785
  if (structFlags.length > 0) buckets.page_structure = structFlags;
1727
1786
  if (dupFrontmatter.length > 0) buckets.duplicate_frontmatter = dupFrontmatter;
1728
1787
  if (noOverview.length > 0) buckets.missing_overview = noOverview;
1788
+ if (fmWikilinkFlags.length > 0) buckets.frontmatter_wikilink = fmWikilinkFlags;
1729
1789
  }
1730
1790
  const errorOut = ERROR_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
1731
1791
  const warningOut = WARNING_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
@@ -1761,12 +1821,12 @@ async function runLint(input) {
1761
1821
  // src/commands/config.ts
1762
1822
  import { readFile as readFile12 } from "fs/promises";
1763
1823
  import { existsSync } from "fs";
1764
- import { join as join13 } from "path";
1824
+ import { join as join14 } from "path";
1765
1825
  function validateKey(key) {
1766
1826
  return CONFIG_KEYS.includes(key) || isValidWikiProfileKey(key);
1767
1827
  }
1768
1828
  function configPath(home) {
1769
- return join13(home, ".skillwiki", ".env");
1829
+ return join14(home, ".skillwiki", ".env");
1770
1830
  }
1771
1831
  async function runConfigGet(input) {
1772
1832
  if (!validateKey(input.key)) {
@@ -1820,13 +1880,13 @@ async function runConfigPath(input) {
1820
1880
  }
1821
1881
 
1822
1882
  // src/commands/doctor.ts
1823
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
1824
- import { join as join15 } from "path";
1883
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
1884
+ import { join as join16 } from "path";
1825
1885
  import { execSync } from "child_process";
1826
1886
 
1827
1887
  // src/utils/auto-update.ts
1828
- import { readFileSync, writeFileSync, existsSync as existsSync2, mkdirSync } from "fs";
1829
- import { join as join14, dirname as dirname6 } from "path";
1888
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync } from "fs";
1889
+ import { join as join15, dirname as dirname6 } from "path";
1830
1890
  import { spawn } from "child_process";
1831
1891
 
1832
1892
  // src/utils/update-consts.ts
@@ -1837,11 +1897,11 @@ var CLI_DISABLE_FLAG = "--no-update-notifier";
1837
1897
 
1838
1898
  // src/utils/auto-update.ts
1839
1899
  function cachePath(home) {
1840
- return join14(home, ".skillwiki", CACHE_FILENAME);
1900
+ return join15(home, ".skillwiki", CACHE_FILENAME);
1841
1901
  }
1842
1902
  function readCacheRaw(home) {
1843
1903
  try {
1844
- const raw = readFileSync(cachePath(home), "utf8");
1904
+ const raw = readFileSync2(cachePath(home), "utf8");
1845
1905
  return JSON.parse(raw);
1846
1906
  } catch {
1847
1907
  return null;
@@ -1857,7 +1917,7 @@ function readCache(home) {
1857
1917
  function writeCache(home, cache) {
1858
1918
  const p = cachePath(home);
1859
1919
  mkdirSync(dirname6(p), { recursive: true });
1860
- writeFileSync(p, JSON.stringify(cache, null, 2));
1920
+ writeFileSync2(p, JSON.stringify(cache, null, 2));
1861
1921
  }
1862
1922
  function latestFromCache(home, currentVersion) {
1863
1923
  const { cache } = readCache(home);
@@ -1940,9 +2000,9 @@ function checkVaultStructure(resolvedPath) {
1940
2000
  return check("error", "vault_structure", "Vault structure valid", "Cannot check \u2014 vault directory does not exist");
1941
2001
  }
1942
2002
  const missing = [];
1943
- if (!existsSync3(join15(resolvedPath, "SCHEMA.md"))) missing.push("SCHEMA.md");
2003
+ if (!existsSync3(join16(resolvedPath, "SCHEMA.md"))) missing.push("SCHEMA.md");
1944
2004
  for (const dir of ["raw", "entities", "concepts", "meta"]) {
1945
- if (!existsSync3(join15(resolvedPath, dir))) missing.push(dir + "/");
2005
+ if (!existsSync3(join16(resolvedPath, dir))) missing.push(dir + "/");
1946
2006
  }
1947
2007
  if (missing.length === 0) {
1948
2008
  return check("pass", "vault_structure", "Vault structure valid", "All required files and directories present");
@@ -1950,7 +2010,7 @@ function checkVaultStructure(resolvedPath) {
1950
2010
  return check("error", "vault_structure", "Vault structure valid", `Missing: ${missing.join(", ")}`);
1951
2011
  }
1952
2012
  function checkSkillsInstalled(home) {
1953
- const skillsDir = join15(home, ".claude", "skills");
2013
+ const skillsDir = join16(home, ".claude", "skills");
1954
2014
  if (!existsSync3(skillsDir)) {
1955
2015
  return check("warn", "skills_installed", "Skills installed", `${skillsDir} not found`);
1956
2016
  }
@@ -1971,12 +2031,12 @@ function checkNpmUpdate(home, currentVersion) {
1971
2031
  return check("pass", "npm_update", "npm CLI version", `v${currentVersion} (latest: v${latest})`);
1972
2032
  }
1973
2033
  function checkPluginVersionDrift(home, currentVersion) {
1974
- const pluginJsonPath = join15(home, ".claude", "plugins", "cache", "llm-wiki", "plugin.json");
2034
+ const pluginJsonPath = join16(home, ".claude", "plugins", "cache", "llm-wiki", "plugin.json");
1975
2035
  if (!existsSync3(pluginJsonPath)) {
1976
2036
  return check("pass", "plugin_version_drift", "Plugin/CLI version", "Plugin cache not found \u2014 plugin not installed");
1977
2037
  }
1978
2038
  try {
1979
- const content = readFileSync2(pluginJsonPath, { encoding: "utf8" });
2039
+ const content = readFileSync3(pluginJsonPath, { encoding: "utf8" });
1980
2040
  const pluginData = JSON.parse(content);
1981
2041
  const pluginVersion = pluginData.version;
1982
2042
  if (!pluginVersion) {
@@ -2018,7 +2078,7 @@ async function checkProfiles(home) {
2018
2078
  }
2019
2079
  async function checkProjectLocalOverride(cwd) {
2020
2080
  const dir = cwd ?? process.cwd();
2021
- const envPath = join15(dir, ".skillwiki", ".env");
2081
+ const envPath = join16(dir, ".skillwiki", ".env");
2022
2082
  if (existsSync3(envPath)) {
2023
2083
  return check("pass", "project_local", "Project-local config", `Found: ${envPath}`);
2024
2084
  }
@@ -2034,9 +2094,9 @@ function findSkillMd(dir) {
2034
2094
  }
2035
2095
  for (const entry of entries) {
2036
2096
  if (entry.isFile() && entry.name === "SKILL.md") {
2037
- results.push(join15(dir, entry.name));
2097
+ results.push(join16(dir, entry.name));
2038
2098
  } else if (entry.isDirectory()) {
2039
- results.push(...findSkillMd(join15(dir, entry.name)));
2099
+ results.push(...findSkillMd(join16(dir, entry.name)));
2040
2100
  }
2041
2101
  }
2042
2102
  return results;
@@ -2080,7 +2140,7 @@ async function runDoctor(input) {
2080
2140
 
2081
2141
  // src/commands/archive.ts
2082
2142
  import { rename as rename3, mkdir as mkdir5, readFile as readFile13, writeFile as writeFile6 } from "fs/promises";
2083
- import { join as join16, dirname as dirname7 } from "path";
2143
+ import { join as join17, dirname as dirname7 } from "path";
2084
2144
  async function runArchive(input) {
2085
2145
  const scan = await scanVault(input.vault);
2086
2146
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -2092,10 +2152,10 @@ async function runArchive(input) {
2092
2152
  }
2093
2153
  if (!relPath) return { exitCode: ExitCode.ARCHIVE_TARGET_NOT_FOUND, result: err("ARCHIVE_TARGET_NOT_FOUND", { page: input.page }) };
2094
2154
  if (relPath.startsWith("_archive/")) return { exitCode: ExitCode.ARCHIVE_ALREADY_ARCHIVED, result: err("ARCHIVE_ALREADY_ARCHIVED", { page: relPath }) };
2095
- const archivePath = join16("_archive", relPath);
2096
- await mkdir5(dirname7(join16(input.vault, archivePath)), { recursive: true });
2155
+ const archivePath = join17("_archive", relPath);
2156
+ await mkdir5(dirname7(join17(input.vault, archivePath)), { recursive: true });
2097
2157
  let indexUpdated = false;
2098
- const indexPath = join16(input.vault, "index.md");
2158
+ const indexPath = join17(input.vault, "index.md");
2099
2159
  try {
2100
2160
  const idx = await readFile13(indexPath, "utf8");
2101
2161
  const slug = relPath.replace(/\.md$/, "").split("/").pop();
@@ -2108,7 +2168,7 @@ async function runArchive(input) {
2108
2168
  } catch (e) {
2109
2169
  if (e?.code !== "ENOENT") throw e;
2110
2170
  }
2111
- await rename3(join16(input.vault, relPath), join16(input.vault, archivePath));
2171
+ await rename3(join17(input.vault, relPath), join17(input.vault, archivePath));
2112
2172
  return { exitCode: ExitCode.OK, result: ok({ archived_from: relPath, archived_to: archivePath, index_updated: indexUpdated, humanHint: `${relPath} -> ${archivePath}${indexUpdated ? " (index updated)" : ""}` }) };
2113
2173
  }
2114
2174
 
@@ -2407,10 +2467,10 @@ ${newBody}`;
2407
2467
 
2408
2468
  // src/commands/update.ts
2409
2469
  import { execSync as execSync2 } from "child_process";
2410
- import { readFileSync as readFileSync3 } from "fs";
2470
+ import { readFileSync as readFileSync4 } from "fs";
2411
2471
  async function runUpdate(input) {
2412
2472
  const pkg2 = JSON.parse(
2413
- readFileSync3(new URL("../../package.json", import.meta.url), "utf8")
2473
+ readFileSync4(new URL("../../package.json", import.meta.url), "utf8")
2414
2474
  );
2415
2475
  const currentVersion = pkg2.version;
2416
2476
  const tag = input.distTag ?? "beta";
@@ -2467,7 +2527,7 @@ async function runUpdate(input) {
2467
2527
  }
2468
2528
 
2469
2529
  // src/cli.ts
2470
- var pkg = JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8"));
2530
+ var pkg = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
2471
2531
  var program = new Command();
2472
2532
  program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version(pkg.version);
2473
2533
  program.option("--human", "render terminal-readable output instead of JSON");
@@ -2606,10 +2666,10 @@ program.command("drift [vault]").description("detect content drift in raw source
2606
2666
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2607
2667
  else emit(await runDrift({ vault: v.vault }));
2608
2668
  });
2609
- program.command("dedup [vault]").description("detect duplicate raw sources by sha256").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
2669
+ program.command("dedup [vault]").description("detect duplicate raw sources by sha256").option("--apply", "rewire citations and remove duplicate raw files", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
2610
2670
  const v = await resolveVaultArg(vault, opts.wiki);
2611
2671
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2612
- else emit(await runDedup({ vault: v.vault }));
2672
+ else emit(await runDedup({ vault: v.vault, apply: opts.apply }));
2613
2673
  });
2614
2674
  program.command("migrate-citations [vault]").description("migrate ^[raw/...] markers to paragraph-end citations").option("--dry-run", "preview changes without writing", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
2615
2675
  const v = await resolveVaultArg(vault, opts.wiki);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.2.0-beta.27",
3
+ "version": "0.2.0-beta.28",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillwiki": "dist/cli.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.2.0-beta.27",
3
+ "version": "0.2.0-beta.28",
4
4
  "skills": "./",
5
5
  "description": "Project-aware Karpathy-style knowledge base for Claude Code: 11 prompt-only skills (wiki-*, proj-*, using-skillwiki) backed by the deterministic `skillwiki` CLI (8 subcommands, JSON-by-default).",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillwiki/skills",
3
- "version": "0.2.0-beta.27",
3
+ "version": "0.2.0-beta.28",
4
4
  "private": true,
5
5
  "files": [
6
6
  "wiki-*",
@@ -24,7 +24,8 @@ Standard four reads (SCHEMA, index, log, project context if applicable).
24
24
  1. Identify the target page. Confirm with the user which page to archive (show full relPath).
25
25
  2. Run `skillwiki archive <page> [vault]`. Read the JSON output.
26
26
  3. Verify with `skillwiki index-check [vault]` — confirm no ghost entries remain.
27
- 4. Append a `log.md` entry: `## [{date}] archive | {relPath} _archive/{subdir}/`.
27
+ 4. Run `skillwiki lint [vault]` — check for broken wikilinks from other pages that still reference the archived page. If found, update those pages to point to the replacement or remove the stale link.
28
+ 5. Append a `log.md` entry: `## [{date}] archive | {relPath} → _archive/{subdir}/`.
28
29
 
29
30
  ## Reversibility
30
31
 
@@ -18,7 +18,7 @@ Standard four reads.
18
18
 
19
19
  0. Resolve vault: `skillwiki path` (record source for context).
20
20
  1. Run `skillwiki lint <vault>`. Read the JSON.
21
- 2. Reason over findings; present grouped by severity with concrete suggested actions per kind.
21
+ 2. Reason over findings; present grouped by severity with concrete suggested actions per kind. If the CLI was recently updated with new lint checks, re-running lint on the full vault may flag pre-existing pages that predate the new rule — treat these as legitimate findings, not false positives.
22
22
  3. If `log_rotate_needed` is present and the user consents, run `skillwiki log-rotate <vault> --apply`. Otherwise leave alone.
23
23
  4. Append one `log.md` entry summarizing the lint counts (errors/warnings/info).
24
24