skillwiki 0.5.0 → 0.5.3

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
@@ -59,7 +59,8 @@ var ExitCode = {
59
59
  SYNC_PUSH_FAILED: 42,
60
60
  SYNC_PULL_FAILED: 43,
61
61
  BACKUP_SYNC_FAILED: 44,
62
- BACKUP_RESTORE_CONFLICTS: 45
62
+ BACKUP_RESTORE_CONFLICTS: 45,
63
+ USAGE: 46
63
64
  };
64
65
 
65
66
  // ../shared/src/json-output.ts
@@ -91,7 +92,8 @@ var TypedKnowledgeSchema = z.object({
91
92
  contradictions: z.array(z.string()).optional(),
92
93
  provenance: z.enum(["research", "project", "mixed"]).optional(),
93
94
  provenance_projects: z.array(wikilink).optional(),
94
- work_items: z.array(wikilink).optional()
95
+ work_items: z.array(wikilink).optional(),
96
+ stale_ttl: z.number().int().positive().optional()
95
97
  }).superRefine((v, ctx) => {
96
98
  if (v.provenance && v.provenance !== "research" && (!v.provenance_projects || v.provenance_projects.length === 0)) {
97
99
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance_projects"], message: "required when provenance != research" });
@@ -1004,8 +1006,10 @@ function isLegacyCitationStyle(body) {
1004
1006
  return false;
1005
1007
  }
1006
1008
  function hasOrphanedCitations(body) {
1007
- const stripped = stripFences(body.replace(FRONTMATTER, ""));
1009
+ const noFm = body.replace(FRONTMATTER, "");
1010
+ const stripped = stripFences(noFm);
1008
1011
  const lines = stripped.split("\n");
1012
+ const rawLines = noFm.split("\n");
1009
1013
  let inSources = false;
1010
1014
  let sourcesEnded = false;
1011
1015
  let sourcesStartLine = -1;
@@ -1025,10 +1029,13 @@ function hasOrphanedCitations(body) {
1025
1029
  }
1026
1030
  continue;
1027
1031
  }
1028
- const isListItem = /^\s*[-*]\s+/.test(line);
1032
+ const isListItem = /^\s*(?:[-*]|\d+\.)\s+/.test(line);
1029
1033
  const hasMarker = /\^\[raw\//.test(line);
1034
+ const hasBacktickRawPath = /`raw\/[^`]+`/.test(rawLines[i]);
1030
1035
  if (isListItem && hasMarker) {
1031
1036
  lastNonBlankInSources = i;
1037
+ } else if (isListItem && hasBacktickRawPath) {
1038
+ lastNonBlankInSources = i;
1032
1039
  } else if (hasMarker && !isListItem) {
1033
1040
  return true;
1034
1041
  } else {
@@ -1835,6 +1842,42 @@ async function runIndexCheck(input) {
1835
1842
  // src/commands/stale.ts
1836
1843
  import { readdir as readdir4, rename as rename2, mkdir as mkdir6, readFile as readFile10 } from "fs/promises";
1837
1844
  import { join as join13 } from "path";
1845
+
1846
+ // src/parsers/expiry-annotations.ts
1847
+ var HEADING_RE = /^#{1,6}\s+(.+)$/;
1848
+ var ANNOTATION_RE = /^<!--\s*expires:\s*(\d{4}-\d{2}-\d{2})(?:\s+refresh:\s*(weekly|monthly|quarterly))?(?:\s+source:\s*(\S+))?\s*-->$/;
1849
+ var VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1850
+ function isValidDate(s) {
1851
+ if (!VALID_DATE_RE.test(s)) return false;
1852
+ const d = /* @__PURE__ */ new Date(s + "T00:00:00Z");
1853
+ return !Number.isNaN(d.getTime()) && s === d.toISOString().slice(0, 10);
1854
+ }
1855
+ function parseExpiryAnnotations(content, pagePath) {
1856
+ const lines = content.split("\n");
1857
+ const annotations = [];
1858
+ for (let i = 0; i < lines.length; i++) {
1859
+ const match = lines[i].match(ANNOTATION_RE);
1860
+ if (!match) continue;
1861
+ const expires = match[1];
1862
+ if (!isValidDate(expires)) continue;
1863
+ if (i === 0) continue;
1864
+ const prevLine = lines[i - 1];
1865
+ const headingMatch = prevLine.match(HEADING_RE);
1866
+ if (!headingMatch) continue;
1867
+ annotations.push({
1868
+ page: pagePath,
1869
+ heading: headingMatch[1].trim(),
1870
+ line: i + 1,
1871
+ // 1-indexed
1872
+ expires,
1873
+ refresh: match[2],
1874
+ source: match[3]
1875
+ });
1876
+ }
1877
+ return annotations;
1878
+ }
1879
+
1880
+ // src/commands/stale.ts
1838
1881
  function daysSince(isoDate2) {
1839
1882
  return Math.floor((Date.now() - Date.parse(isoDate2)) / 864e5);
1840
1883
  }
@@ -1852,6 +1895,12 @@ async function runStale(input) {
1852
1895
  projectSlugs = (await readdir4(projectsDir, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
1853
1896
  } catch {
1854
1897
  }
1898
+ if (input.project) {
1899
+ if (!projectSlugs.includes(input.project)) {
1900
+ return { exitCode: ExitCode.USAGE, result: { ok: false, error: "UNKNOWN_PROJECT", detail: `Project "${input.project}" not found. Available: ${projectSlugs.join(", ") || "(none)"}` } };
1901
+ }
1902
+ projectSlugs = [input.project];
1903
+ }
1855
1904
  for (const slug of projectSlugs) {
1856
1905
  const workPath = join13(projectsDir, slug, "work");
1857
1906
  let entries;
@@ -1893,9 +1942,10 @@ async function runStale(input) {
1893
1942
  function extractSlug(projectField) {
1894
1943
  return projectField.replace(/^\[\[/, "").replace(/\]\]$/, "").replace(/^"|"$/g, "");
1895
1944
  }
1945
+ const TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "abandoned", "done", "invalid"]);
1896
1946
  const KIND_FROM_FILENAME = /^(?:\d{4}-\d{2}-\d{2})-(task|bug|idea|note|observation)-.+\.md$/;
1897
1947
  const LOOP_CYCLE_PATTERN = /loop-cycle-/;
1898
- const transcripts = scan.data.raw.filter((p) => p.relPath.startsWith("raw/transcripts/") && p.relPath.endsWith(".md"));
1948
+ let transcripts = scan.data.raw.filter((p) => p.relPath.startsWith("raw/transcripts/") && p.relPath.endsWith(".md"));
1899
1949
  const claimedPaths = /* @__PURE__ */ new Set();
1900
1950
  const transcriptMeta = /* @__PURE__ */ new Map();
1901
1951
  for (const t of transcripts) {
@@ -1904,6 +1954,7 @@ async function runStale(input) {
1904
1954
  const fm = extractFrontmatter(content);
1905
1955
  let kind = fm.ok && typeof fm.data.kind === "string" ? fm.data.kind : "";
1906
1956
  let project = fm.ok && typeof fm.data.project === "string" ? fm.data.project : "";
1957
+ if (input.project && !project.includes(input.project)) continue;
1907
1958
  let inferred = false;
1908
1959
  if (input.forceScan && !kind) {
1909
1960
  const basename = t.relPath.split("/").pop();
@@ -1948,7 +1999,7 @@ async function runStale(input) {
1948
1999
  const overlap = wWords.filter((w) => tWords.has(w)).length;
1949
2000
  if (dirName.includes(tSlug) || tSlug.includes(wSlug) || overlap >= 1) {
1950
2001
  claimedPaths.add(t.relPath);
1951
- if (status === "done" || status === "invalid") {
2002
+ if (TERMINAL_STATUSES.has(status)) {
1952
2003
  staleTranscripts.push({ path: t.relPath, reason: `work item projects/${slug}/work/${dirName} is ${status}` });
1953
2004
  }
1954
2005
  break;
@@ -1958,7 +2009,7 @@ async function runStale(input) {
1958
2009
  for (const [dir, status] of workDirs) {
1959
2010
  if (dir.split("/").pop().startsWith(datePrefix)) {
1960
2011
  claimedPaths.add(t.relPath);
1961
- if (status === "done" || status === "invalid") {
2012
+ if (TERMINAL_STATUSES.has(status)) {
1962
2013
  staleTranscripts.push({ path: t.relPath, reason: `work item ${dir} is ${status}` });
1963
2014
  }
1964
2015
  break;
@@ -2003,10 +2054,8 @@ async function runStale(input) {
2003
2054
  continue;
2004
2055
  }
2005
2056
  const hasSpec = files.includes("spec.md"), hasPlan = files.includes("plan.md"), hasWI = files.includes("work-item.md");
2006
- if (status === "done") {
2007
- doneWorkItems.push({ path: relDir, reason: "completed \u2014 should be archived" });
2008
- } else if (status === "invalid") {
2009
- doneWorkItems.push({ path: relDir, reason: "invalid \u2014 should be archived" });
2057
+ if (TERMINAL_STATUSES.has(status)) {
2058
+ doneWorkItems.push({ path: relDir, reason: `${status || "completed"} \u2014 should be archived` });
2010
2059
  } else if (hasSpec && !hasPlan) {
2011
2060
  incompleteWorkItems.push({ path: relDir, reason: "has spec but no plan" });
2012
2061
  } else if (hasWI && !hasSpec && !hasPlan) {
@@ -2019,16 +2068,53 @@ async function runStale(input) {
2019
2068
  const text = await readFile10(join13(input.vault, page.relPath), "utf8");
2020
2069
  const fm = extractFrontmatter(text);
2021
2070
  if (fm.ok && typeof fm.data.updated === "string") {
2071
+ if (input.project) {
2072
+ const pp = fm.data.provenance_projects;
2073
+ const linked = Array.isArray(pp) && pp.some((p) => String(p).includes(input.project));
2074
+ if (!linked) continue;
2075
+ }
2022
2076
  const age = daysSince(fm.data.updated);
2023
- if (age >= input.days) {
2024
- stale.push({ page: page.relPath, reason: `updated ${age} days ago (threshold: ${input.days})` });
2077
+ const threshold = typeof fm.data.stale_ttl === "number" && fm.data.stale_ttl > 0 ? fm.data.stale_ttl : input.days;
2078
+ if (age >= threshold) {
2079
+ stale.push({ page: page.relPath, reason: `updated ${age} days ago (threshold: ${threshold})` });
2025
2080
  }
2026
2081
  }
2027
2082
  } catch {
2028
2083
  }
2029
2084
  }
2085
+ const staleSections = [];
2086
+ for (const page of scan.data.typedKnowledge) {
2087
+ try {
2088
+ const text = await readFile10(join13(input.vault, page.relPath), "utf8");
2089
+ const projectFilter = input.project;
2090
+ if (projectFilter) {
2091
+ const fm = extractFrontmatter(text);
2092
+ if (fm.ok) {
2093
+ const pp = fm.data.provenance_projects;
2094
+ const linked = Array.isArray(pp) && pp.some((p) => String(p).includes(projectFilter));
2095
+ if (!linked) continue;
2096
+ }
2097
+ }
2098
+ const annotations = parseExpiryAnnotations(text, page.relPath);
2099
+ for (const ann of annotations) {
2100
+ if (daysSince(ann.expires) >= 0) {
2101
+ staleSections.push({
2102
+ page: ann.page,
2103
+ heading: ann.heading,
2104
+ line: ann.line,
2105
+ expires: ann.expires,
2106
+ refresh: ann.refresh,
2107
+ source: ann.source,
2108
+ reason: `section "${ann.heading}" expired on ${ann.expires}`
2109
+ });
2110
+ }
2111
+ }
2112
+ } catch {
2113
+ }
2114
+ }
2115
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2030
2116
  if (input.archive) {
2031
- const archiveDir = join13(input.vault, "_archive", (/* @__PURE__ */ new Date()).toISOString().slice(0, 10));
2117
+ const archiveDir = join13(input.vault, "_archive", today);
2032
2118
  await mkdir6(archiveDir, { recursive: true });
2033
2119
  const citedRawPaths = /* @__PURE__ */ new Set();
2034
2120
  for (const page of scan.data.typedKnowledge) {
@@ -2082,7 +2168,7 @@ async function runStale(input) {
2082
2168
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2083
2169
  });
2084
2170
  }
2085
- const total = stale.length + staleTranscripts.length + unclaimedTranscripts.length + incompleteWorkItems.length + doneWorkItems.length;
2171
+ const total = stale.length + staleTranscripts.length + unclaimedTranscripts.length + incompleteWorkItems.length + doneWorkItems.length + staleSections.length;
2086
2172
  const hintLines = [];
2087
2173
  if (stale.length > 0) hintLines.push(`stale_pages: ${stale.length}`, ...stale.map((p) => ` ${p.page}: ${p.reason}`));
2088
2174
  if (staleTranscripts.length > 0) hintLines.push(`stale_transcripts: ${staleTranscripts.length}`, ...staleTranscripts.map((t) => ` ${t.path}: ${t.reason}`));
@@ -2090,6 +2176,7 @@ async function runStale(input) {
2090
2176
  hint: ${t.hint}` : ""}`));
2091
2177
  if (incompleteWorkItems.length > 0) hintLines.push(`incomplete_work_items: ${incompleteWorkItems.length}`, ...incompleteWorkItems.map((w) => ` ${w.path}: ${w.reason}`));
2092
2178
  if (doneWorkItems.length > 0) hintLines.push(`done_work_items: ${doneWorkItems.length}`, ...doneWorkItems.map((w) => ` ${w.path}: ${w.reason}`));
2179
+ if (staleSections.length > 0) hintLines.push(`stale_sections: ${staleSections.length}`, ...staleSections.map((s) => ` ${s.page}#${s.heading}: ${s.reason}`));
2093
2180
  if (archived.length > 0) hintLines.push(`archived: ${archived.length}`, ...archived.map((a) => ` ${a}`));
2094
2181
  if (hintLines.length === 0) hintLines.push("no stale transcripts or incomplete work items");
2095
2182
  return { exitCode: total > 0 ? ExitCode.STALE_PAGE : ExitCode.OK, result: ok({
@@ -2098,6 +2185,7 @@ async function runStale(input) {
2098
2185
  unclaimed_transcripts: unclaimedTranscripts,
2099
2186
  incomplete_work_items: incompleteWorkItems,
2100
2187
  done_work_items: doneWorkItems,
2188
+ stale_sections: staleSections,
2101
2189
  archived,
2102
2190
  humanHint: hintLines.join("\n")
2103
2191
  }) };
@@ -2466,11 +2554,11 @@ function buildCliSurface() {
2466
2554
  program2.command("index-check").option("--wiki <name>");
2467
2555
  program2.command("index-link-format").option("--wiki <name>");
2468
2556
  program2.command("topic-map-check").option("--threshold <n>").option("--wiki <name>");
2469
- program2.command("stale").option("--archive").option("--days <n>").option("--force-scan").option("--wiki <name>");
2557
+ program2.command("stale").option("--archive").option("--days <n>").option("--force-scan").option("--project <slug>").option("--refresh").option("--wiki <name>");
2470
2558
  program2.command("claim").option("--project <slug>").option("--slug <slug>").option("--wiki <name>");
2471
2559
  program2.command("pagesize").option("--lines <n>").option("--wiki <name>");
2472
2560
  program2.command("log-rotate").option("--threshold <n>").option("--apply").option("--wiki <name>");
2473
- program2.command("lint").option("--days <n>").option("--lines <n>").option("--log-threshold <n>").option("--fix").option("--wiki <name>");
2561
+ program2.command("lint").option("--days <n>").option("--lines <n>").option("--log-threshold <n>").option("--fix").option("--only <bucket>").option("--wiki <name>");
2474
2562
  program2.command("config");
2475
2563
  program2.command("doctor");
2476
2564
  program2.command("status").option("--wiki <name>");
@@ -2615,8 +2703,8 @@ function extractSourceEntries(rawFm) {
2615
2703
  return entries;
2616
2704
  }
2617
2705
  var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_dedup", "broken_sources", "tag_not_in_taxonomy"];
2618
- var WARNING_ORDER = ["raw_body_duplicate", "raw_subdirectory_duplicate", "index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "orphans", "compound_refs", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "work_item_health", "orphaned_project_pages", "missing_overview"];
2619
- var INFO_ORDER = ["bridges", "page_structure", "topic_map_recommended", "frontmatter_wikilink", "wikilink_citation", "missing_tldr", "missing_diagram", "cli_refs"];
2706
+ var WARNING_ORDER = ["raw_body_duplicate", "raw_subdirectory_duplicate", "index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "orphans", "compound_refs", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "work_item_health", "orphaned_project_pages", "missing_overview", "missing_diagram"];
2707
+ var INFO_ORDER = ["bridges", "page_structure", "topic_map_recommended", "frontmatter_wikilink", "wikilink_citation", "missing_tldr", "stale_sections", "cli_refs"];
2620
2708
  async function runLint(input) {
2621
2709
  const buckets = {};
2622
2710
  const fixed = [];
@@ -2741,7 +2829,7 @@ async function runLint(input) {
2741
2829
  const hasOverview = /^## Overview/m.test(body);
2742
2830
  if (!hasOverview) noOverview.push(page.relPath);
2743
2831
  const bodyFirst15 = body.split("\n").slice(0, 15).join("\n");
2744
- if (!/^##\s+TL;\s*DR/m.test(bodyFirst15)) missingTldrFlags.push(page.relPath);
2832
+ if (!/^>\s*\*\*TL;DR:?\*\*/m.test(bodyFirst15) && !/^##\s+TL;\s*DR/m.test(bodyFirst15)) missingTldrFlags.push(page.relPath);
2745
2833
  const fmData = extractFrontmatter(text);
2746
2834
  const pageTags = fmData.ok && Array.isArray(fmData.data.tags) ? fmData.data.tags : [];
2747
2835
  if (pageTags.includes("architecture") && !body.includes("```mermaid")) {
@@ -2785,7 +2873,7 @@ async function runLint(input) {
2785
2873
  const text = await readPage(specPage);
2786
2874
  const fm = extractFrontmatter(text);
2787
2875
  if (fm.ok) {
2788
- specStatus = fm.data.status;
2876
+ specStatus = typeof fm.data.status === "string" ? fm.data.status : void 0;
2789
2877
  specStarted = fm.data.started;
2790
2878
  }
2791
2879
  }
@@ -2840,6 +2928,27 @@ async function runLint(input) {
2840
2928
  }
2841
2929
  }
2842
2930
  if (cliRefFlags.length > 0) buckets.cli_refs = cliRefFlags;
2931
+ const staleSectionFlags = [];
2932
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2933
+ const approachingThreshold = 7;
2934
+ for (const page of scan.data.typedKnowledge) {
2935
+ try {
2936
+ const text = await readPage(page);
2937
+ const annotations = parseExpiryAnnotations(text, page.relPath);
2938
+ for (const ann of annotations) {
2939
+ if (ann.expires < today) {
2940
+ staleSectionFlags.push(`${page.relPath}: section "${ann.heading}" expired on ${ann.expires}`);
2941
+ } else {
2942
+ const daysUntilExpiry = Math.floor((Date.parse(ann.expires) - Date.now()) / 864e5);
2943
+ if (daysUntilExpiry <= approachingThreshold) {
2944
+ staleSectionFlags.push(`${page.relPath}: section "${ann.heading}" expires in ${daysUntilExpiry} day(s) (${ann.expires})`);
2945
+ }
2946
+ }
2947
+ }
2948
+ } catch {
2949
+ }
2950
+ }
2951
+ if (staleSectionFlags.length > 0) buckets.stale_sections = staleSectionFlags;
2843
2952
  if (input.fix && legacyPages.length > 0) {
2844
2953
  const FENCE_RE2 = /```[\s\S]*?```/g;
2845
2954
  const INLINE_MARKER = /\^\[raw\/[^\]]+\]/g;
@@ -2986,16 +3095,26 @@ ${trimmedBody}`;
2986
3095
  }
2987
3096
  const body = split.data.body;
2988
3097
  const rawFm = split.data.rawFrontmatter;
2989
- const trimmedBody = body.replace(/^\n+/, "");
3098
+ const lines = body.split("\n");
3099
+ let insertIndex = 0;
3100
+ for (let i = 0; i < lines.length; i++) {
3101
+ if (/^# /.test(lines[i])) {
3102
+ insertIndex = i + 1;
3103
+ while (insertIndex < lines.length && lines[insertIndex].trim() === "") {
3104
+ insertIndex++;
3105
+ }
3106
+ break;
3107
+ }
3108
+ }
3109
+ if (insertIndex === 0) {
3110
+ lines.splice(0, 0, "", "> **TL;DR:** ");
3111
+ } else {
3112
+ lines.splice(insertIndex, 0, "> **TL;DR:** ");
3113
+ }
3114
+ const trimmedFm = rawFm.endsWith("\n") ? rawFm : rawFm + "\n";
2990
3115
  const newContent = `---
2991
- ${rawFm}
2992
- ---
2993
-
2994
- ## TL;DR
2995
-
2996
- - Pending summary.
2997
-
2998
- ${trimmedBody}`;
3116
+ ${trimmedFm}---
3117
+ ${lines.join("\n")}`;
2999
3118
  await writeFile8(absPath, newContent, "utf8");
3000
3119
  fixed.push(relPath);
3001
3120
  } catch {
@@ -3096,6 +3215,38 @@ ${newBody}`;
3096
3215
  const errorOut = ERROR_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
3097
3216
  const warningOut = WARNING_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
3098
3217
  const infoOut = INFO_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
3218
+ if (input.only) {
3219
+ const allKnown = [...ERROR_ORDER, ...WARNING_ORDER, ...INFO_ORDER];
3220
+ if (!allKnown.includes(input.only)) {
3221
+ return {
3222
+ exitCode: ExitCode.USAGE,
3223
+ result: { ok: false, error: "UNKNOWN_BUCKET", detail: `Unknown bucket "${input.only}". Valid: ${allKnown.join(", ")}` }
3224
+ };
3225
+ }
3226
+ const match = [...errorOut, ...warningOut, ...infoOut].filter((b) => b.kind === input.only);
3227
+ const severity = ERROR_ORDER.includes(input.only) ? "error" : WARNING_ORDER.includes(input.only) ? "warning" : "info";
3228
+ const filtered = severity === "error" ? { error: match, warning: [], info: [] } : severity === "warning" ? { error: [], warning: match, info: [] } : { error: [], warning: [], info: match };
3229
+ const fSummary = {
3230
+ errors: filtered.error.reduce((n, b) => n + b.items.length, 0),
3231
+ warnings: filtered.warning.reduce((n, b) => n + b.items.length, 0),
3232
+ info: filtered.info.reduce((n, b) => n + b.items.length, 0)
3233
+ };
3234
+ let fExit = ExitCode.OK;
3235
+ if (fSummary.errors > 0) fExit = ExitCode.LINT_HAS_ERRORS;
3236
+ else if (fSummary.warnings > 0 || fSummary.info > 0) fExit = ExitCode.LINT_HAS_WARNINGS;
3237
+ return {
3238
+ exitCode: fExit,
3239
+ result: ok({
3240
+ vault: { path: input.vault, source: input.source ?? "resolved" },
3241
+ summary: fSummary,
3242
+ by_severity: filtered,
3243
+ fixed,
3244
+ unresolved,
3245
+ humanHint: `--only ${input.only}
3246
+ ${match.length === 0 ? "0 violations" : match.map((b) => ` ${b.kind}: ${b.items.length}`).join("\n")}`
3247
+ })
3248
+ };
3249
+ }
3099
3250
  const summary = {
3100
3251
  errors: errorOut.reduce((n, b) => n + b.items.length, 0),
3101
3252
  warnings: warningOut.reduce((n, b) => n + b.items.length, 0),
@@ -3203,7 +3354,7 @@ import { platform } from "os";
3203
3354
 
3204
3355
  // src/utils/auto-update.ts
3205
3356
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
3206
- import { join as join20, dirname as dirname8 } from "path";
3357
+ import { join as join20, dirname as dirname7 } from "path";
3207
3358
  import { spawn } from "child_process";
3208
3359
 
3209
3360
  // src/utils/update-consts.ts
@@ -3233,7 +3384,7 @@ function readCache(home) {
3233
3384
  }
3234
3385
  function writeCache(home, cache) {
3235
3386
  const p = cachePath(home);
3236
- mkdirSync2(dirname8(p), { recursive: true });
3387
+ mkdirSync2(dirname7(p), { recursive: true });
3237
3388
  writeFileSync3(p, JSON.stringify(cache, null, 2));
3238
3389
  }
3239
3390
  function latestFromCache(home, currentVersion) {
@@ -3782,7 +3933,7 @@ async function runDoctor(input) {
3782
3933
 
3783
3934
  // src/commands/archive.ts
3784
3935
  import { rename as rename4, mkdir as mkdir8, readFile as readFile16, writeFile as writeFile9 } from "fs/promises";
3785
- import { join as join23, dirname as dirname9 } from "path";
3936
+ import { join as join23, dirname as dirname8 } from "path";
3786
3937
  async function runArchive(input) {
3787
3938
  const scan = await scanVault(input.vault);
3788
3939
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -3799,7 +3950,7 @@ async function runArchive(input) {
3799
3950
  if (!relPath) return { exitCode: ExitCode.ARCHIVE_TARGET_NOT_FOUND, result: err("ARCHIVE_TARGET_NOT_FOUND", { page: input.page }) };
3800
3951
  if (relPath.startsWith("_archive/")) return { exitCode: ExitCode.ARCHIVE_ALREADY_ARCHIVED, result: err("ARCHIVE_ALREADY_ARCHIVED", { page: relPath }) };
3801
3952
  const archivePath = join23("_archive", relPath).replace(/\\/g, "/");
3802
- await mkdir8(dirname9(join23(input.vault, archivePath)), { recursive: true });
3953
+ await mkdir8(dirname8(join23(input.vault, archivePath)), { recursive: true });
3803
3954
  let indexUpdated = false;
3804
3955
  if (!isRaw) {
3805
3956
  const indexPath = join23(input.vault, "index.md");
@@ -4461,7 +4612,7 @@ async function runTranscripts(input) {
4461
4612
 
4462
4613
  // src/commands/project-index.ts
4463
4614
  import { readdir as readdir6, readFile as readFile18, writeFile as writeFile13, mkdir as mkdir9 } from "fs/promises";
4464
- import { join as join27, dirname as dirname10 } from "path";
4615
+ import { join as join27, dirname as dirname9 } from "path";
4465
4616
  var LAYER2_DIRS = ["entities", "concepts", "comparisons", "queries", "meta"];
4466
4617
  async function runProjectIndex(input) {
4467
4618
  const slug = input.slug;
@@ -4575,7 +4726,7 @@ Autogenerated by \`skillwiki project-index\` on ${today}.
4575
4726
  }
4576
4727
  if (input.apply) {
4577
4728
  try {
4578
- await mkdir9(dirname10(indexPath), { recursive: true });
4729
+ await mkdir9(dirname9(indexPath), { recursive: true });
4579
4730
  await writeFile13(indexPath, body, "utf8");
4580
4731
  } catch (e) {
4581
4732
  return {
@@ -5630,7 +5781,7 @@ async function runSyncPull(input) {
5630
5781
 
5631
5782
  // src/commands/backup.ts
5632
5783
  import { statSync as statSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
5633
- import { join as join32, relative as relative3, dirname as dirname11 } from "path";
5784
+ import { join as join32, relative as relative3, dirname as dirname10 } from "path";
5634
5785
  import { PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
5635
5786
 
5636
5787
  // src/utils/s3-client.ts
@@ -5766,7 +5917,7 @@ async function runBackupRestore(input) {
5766
5917
  const resp = await client.send(new GetObjectCommand({ Bucket: input.bucket, Key: obj.Key }));
5767
5918
  const body = await resp.Body?.transformToByteArray();
5768
5919
  if (body) {
5769
- mkdirSync3(dirname11(localPath), { recursive: true });
5920
+ mkdirSync3(dirname10(localPath), { recursive: true });
5770
5921
  writeFileSync4(localPath, Buffer.from(body));
5771
5922
  downloaded++;
5772
5923
  }
@@ -6451,10 +6602,10 @@ program.command("topic-map-check [vault]").description("check whether a topic ma
6451
6602
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6452
6603
  else emit(await runTopicMapCheck({ vault: v.vault, threshold: opts.threshold }), v.vault);
6453
6604
  });
6454
- program.command("stale [vault]").description("identify stale transcripts and incomplete work items").option("--archive", "move stale items to _archive/", false).option("--days <n>", "staleness threshold in days", (s) => parseInt(s, 10), 3).option("--force-scan", "infer kind/project from filename and content when frontmatter is missing", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6605
+ program.command("stale [vault]").description("identify stale transcripts and incomplete work items").option("--archive", "move stale items to _archive/", false).option("--days <n>", "staleness threshold in days", (s) => parseInt(s, 10), 3).option("--force-scan", "infer kind/project from filename and content when frontmatter is missing", false).option("--project <slug>", "scope to a single project").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6455
6606
  const v = await resolveVaultArg(vault, opts.wiki);
6456
6607
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6457
- else emit(await runStale({ vault: v.vault, days: opts.days, archive: !!opts.archive, forceScan: !!opts.forceScan }), v.vault);
6608
+ else emit(await runStale({ vault: v.vault, days: opts.days, archive: !!opts.archive, forceScan: !!opts.forceScan, project: opts.project }), v.vault);
6458
6609
  });
6459
6610
  program.command("claim <transcript> [vault]").description("claim an unclaimed transcript by creating a work item with source: link").option("--project <slug>", "project slug (overrides transcript frontmatter)").option("--slug <slug>", "work-item slug (defaults to transcript filename without date/kind prefix)").option("--wiki <name>", "wiki profile name").action(async (transcript, vault, opts) => {
6460
6611
  const v = await resolveVaultArg(vault, opts.wiki);
@@ -6471,7 +6622,7 @@ program.command("log-rotate [vault]").description("rotate or trim the vault log
6471
6622
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6472
6623
  else emit(await runLogRotate({ vault: v.vault, threshold: opts.threshold, apply: !!opts.apply }), v.vault);
6473
6624
  });
6474
- program.command("lint [vault]").description("run all vault health checks").option("--days <n>", "stale threshold", (s) => parseInt(s, 10), 90).option("--lines <n>", "pagesize threshold", (s) => parseInt(s, 10), 200).option("--log-threshold <n>", "log rotation threshold", (s) => parseInt(s, 10), 500).option("--fix", "auto-fix legacy_citation_style violations").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6625
+ program.command("lint [vault]").description("run all vault health checks").option("--days <n>", "stale threshold", (s) => parseInt(s, 10), 90).option("--lines <n>", "pagesize threshold", (s) => parseInt(s, 10), 200).option("--log-threshold <n>", "log rotation threshold", (s) => parseInt(s, 10), 500).option("--fix", "auto-fix legacy_citation_style violations").option("--only <bucket>", "run only the specified lint bucket").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6475
6626
  const v = await resolveVaultArg(vault, opts.wiki);
6476
6627
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6477
6628
  else emit(await runLint({
@@ -6480,7 +6631,8 @@ program.command("lint [vault]").description("run all vault health checks").optio
6480
6631
  days: opts.days,
6481
6632
  lines: opts.lines,
6482
6633
  logThreshold: opts.logThreshold,
6483
- fix: opts.fix ?? false
6634
+ fix: opts.fix ?? false,
6635
+ only: opts.only
6484
6636
  }), v.vault);
6485
6637
  });
6486
6638
  var configCmd = program.command("config").description("manage skillwiki configuration");
@@ -6587,7 +6739,10 @@ syncCmd.command("pull [vault]").description("pull remote vault changes and lint"
6587
6739
  var backupCmd = program.command("backup").description("manage S3-compatible remote backup");
6588
6740
  backupCmd.command("sync [vault]").description("sync vault to S3-compatible remote backup").option("--dry-run", "list actions without executing").option("--bucket <name>", "S3 bucket name").option("--endpoint <url>", "S3 endpoint URL").option("--region <region>", "S3 region", "us-east-1").option("--prune", "delete orphaned S3 objects not in vault", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6589
6741
  const v = await resolveVaultArg(vault, opts.wiki);
6590
- if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6742
+ if (!v.ok) {
6743
+ emit({ exitCode: v.exitCode, result: v.payload });
6744
+ return;
6745
+ }
6591
6746
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
6592
6747
  const dotenv = await parseDotenvFile(configPath(home));
6593
6748
  emit(await runBackupSync({
@@ -6603,7 +6758,10 @@ backupCmd.command("sync [vault]").description("sync vault to S3-compatible remot
6603
6758
  });
6604
6759
  backupCmd.command("restore [vault]").description("restore vault from S3-compatible remote backup").option("--bucket <name>", "S3 bucket name").option("--endpoint <url>", "S3 endpoint URL").option("--region <region>", "S3 region", "us-east-1").option("--target <dir>", "restore target directory (defaults to vault)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6605
6760
  const v = await resolveVaultArg(vault, opts.wiki);
6606
- if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6761
+ if (!v.ok) {
6762
+ emit({ exitCode: v.exitCode, result: v.payload });
6763
+ return;
6764
+ }
6607
6765
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
6608
6766
  const dotenv = await parseDotenvFile(configPath(home));
6609
6767
  emit(await runBackupRestore({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillwiki": "dist/cli.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "skills": "./",
5
5
  "description": "Project-aware Karpathy-style knowledge base for Claude Code: 18 prompt-only skills (wiki-*, proj-*, using-skillwiki) backed by the deterministic `skillwiki` CLI.",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Project-aware Karpathy-style knowledge base for Codex with 18 prompt-only skills backed by the deterministic skillwiki CLI.",
5
5
  "author": {
6
6
  "name": "karlorz",
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@skillwiki/skills",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "private": true,
5
5
  "files": [
6
6
  "wiki-*",
7
7
  "proj-*",
8
- "dev-loop-research",
9
8
  "using-skillwiki",
10
9
  ".claude-plugin",
11
10
  ".codex-plugin",
@@ -71,7 +71,7 @@ sha256: # computed by skillwiki hash over body bytes after closing ---
71
71
  | `wiki-canvas` | Generate Obsidian Canvas visualization from vault graph data |
72
72
  | `proj-decide` | Write an Architectural Decision Record (ADR) |
73
73
  | `wiki-gate-plan-mode` | Toggle EnterPlanMode gating — force superpowers planning instead of built-in plan mode |
74
- | `dev-loop-research` | Standalone research agent — scans repo + vault health, outputs prioritized work-item recommendations |
74
+ | `dev-loop:research` | Research agent for dev-loop IDLE — scans repo + vault health, outputs prioritized work-item recommendations (formerly `/dev-loop-research`) |
75
75
  ## CLI Backbone
76
76
  All skills are backed by the `skillwiki` CLI — a deterministic tool with no LLM calls. It handles path resolution, config management, validation, and linting. Skills invoke it via Bash for the mechanical parts and use Claude for the creative parts.
77
77
  Key CLI subcommands: `init`, `lint`, `config`, `doctor`, `path`, `lang`, `install`, `graph build`, `archive`, `drift`, `compound`, `tag-sync`, `sync status`, `seed`, `stale`, `observe`, `canvas generate`.
@@ -16,7 +16,7 @@ Standard four reads. If cwd is inside `projects/{slug}/`, also read project READ
16
16
  1. Identify type: entity / concept / comparison / query / summary.
17
17
  2. Set `provenance:`. Default `research`. If in project context: `project` with `provenance_projects: ["[[slug]]"]`.
18
18
  3. Compose the page with citations pre-attached. Reuse existing `raw/` sources where possible. Every page MUST include:
19
- - `## TL;DR` as the first section after frontmatter — a 1–3 bullet summary of the page's key takeaway.
19
+ - `> **TL;DR:**` blockquote as the first content after the title heading — a one-sentence summary of the page's key takeaway (under 200 chars). See SCHEMA.md `## TL;DR Convention`.
20
20
  - For pages tagged `architecture` or explaining workflows/systems: include a Mermaid diagram (`graph TB` or `sequenceDiagram`) in the body. Follow Obsidian-compatible Mermaid rules (see SCHEMA.md `## Mermaid Diagrams`).
21
21
  4. `skillwiki validate <page>`. If non-zero, STOP.
22
22
  5. Apply writes: page → `index.md` → `log.md`.
@@ -21,7 +21,7 @@ Run `skillwiki lang` at the start. Generate page-body prose, narrative sections,
21
21
  2. **Fetch.** Use `web_fetch` (or read local file) under Layer 2 controls (the CLI Layer 2 fetcher applies in tests; in skill runtime use `web_fetch` directly and treat any error as STOP).
22
22
  3. **Hash.** Write the raw file (frontmatter + body). Run `skillwiki hash <raw-file>` and embed the result in raw frontmatter `sha256:`.
23
23
  4. **Generate page(s).** Compose typed-knowledge page(s) with citations pre-attached (`^[raw/...]` markers). Every page MUST include:
24
- - `## TL;DR` as the first section after frontmatter — a 1–3 bullet summary of the page's key takeaway.
24
+ - `> **TL;DR:**` blockquote as the first content after the title heading — a one-sentence summary of the page's key takeaway (under 200 chars). See SCHEMA.md `## TL;DR Convention`.
25
25
  - For pages tagged `architecture` or explaining workflows/systems: include a Mermaid diagram (`graph TB` or `sequenceDiagram`) in the body. Follow Obsidian-compatible Mermaid rules (see SCHEMA.md `## Mermaid Diagrams`).
26
26
  5. **Validate.** For each generated page: run `skillwiki validate <page>`. If exit ≠ 0, STOP — do not write index/log.
27
27
  6. **Apply writes in order.** raw → page(s) → `index.md` → `log.md`.