skillwiki 0.2.0-beta.2 → 0.2.0-beta.20

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
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ semverGt
4
+ } from "./chunk-XM5IYZX7.js";
2
5
 
3
6
  // src/cli.ts
7
+ import { readFileSync as readFileSync4 } from "fs";
4
8
  import { Command } from "commander";
5
9
 
6
10
  // src/utils/output.ts
@@ -9,9 +13,14 @@ function printJson(r) {
9
13
  }
10
14
  function printHuman(r) {
11
15
  if (r.ok) {
12
- process.stdout.write(`OK
16
+ if (typeof r.data === "object" && r.data !== null && "humanHint" in r.data) {
17
+ process.stdout.write(`${r.data.humanHint}
18
+ `);
19
+ } else {
20
+ process.stdout.write(`OK
13
21
  ${formatData(r.data)}
14
22
  `);
23
+ }
15
24
  } else {
16
25
  process.stdout.write(`ERR ${r.error}
17
26
  ${r.detail !== void 0 ? formatData(r.detail) + "\n" : ""}`);
@@ -53,7 +62,16 @@ var ExitCode = {
53
62
  LINT_HAS_WARNINGS: 22,
54
63
  LINT_HAS_ERRORS: 23,
55
64
  ENV_WRITE_CONFLICT: 24,
56
- NO_VAULT_CONFIGURED: 25
65
+ NO_VAULT_CONFIGURED: 25,
66
+ INVALID_CONFIG_KEY: 26,
67
+ CONFIG_WRITE_FAILED: 27,
68
+ DOCTOR_HAS_WARNINGS: 28,
69
+ DOCTOR_HAS_ERRORS: 29,
70
+ ARCHIVE_TARGET_NOT_FOUND: 30,
71
+ ARCHIVE_ALREADY_ARCHIVED: 31,
72
+ DRIFT_DETECTED: 32,
73
+ RAW_DEDUP_DETECTED: 33,
74
+ MIGRATION_APPLIED: 34
57
75
  };
58
76
 
59
77
  // ../shared/src/json-output.ts
@@ -93,10 +111,10 @@ var TypedKnowledgeSchema = z.object({
93
111
  });
94
112
  var sha256Hex = z.string().regex(/^[0-9a-f]{64}$/);
95
113
  var RawSourceSchema = z.object({
96
- title: z.string().min(1),
97
- source_url: z.string().url().nullable(),
114
+ title: z.string().min(1).optional(),
115
+ source_url: z.string().nullable(),
98
116
  ingested: isoDate,
99
- ingested_by: z.enum(["wiki-ingest", "proj-work", "manual"]),
117
+ ingested_by: z.enum(["wiki-ingest", "proj-work", "manual"]).optional(),
100
118
  sha256: sha256Hex,
101
119
  project: wikilink.optional(),
102
120
  work_item: wikilink.optional(),
@@ -231,7 +249,7 @@ async function runHash(input) {
231
249
  const sha256 = createHash("sha256").update(bodyBytes).digest("hex");
232
250
  return {
233
251
  exitCode: ExitCode.OK,
234
- result: ok({ path: input.file, sha256, byte_count: bodyBytes.byteLength })
252
+ result: ok({ path: input.file, sha256, byte_count: bodyBytes.byteLength, humanHint: sha256 })
235
253
  };
236
254
  }
237
255
 
@@ -261,7 +279,7 @@ function runFetchGuardSync(input) {
261
279
  result: err("HOST_BLOCKED", { sanitized_url: sanitized, host: u.hostname })
262
280
  };
263
281
  }
264
- return { exitCode: ExitCode.OK, result: ok({ allowed: true, sanitized_url: sanitized }) };
282
+ return { exitCode: ExitCode.OK, result: ok({ allowed: true, sanitized_url: sanitized, humanHint: `ALLOWED: ${sanitized}` }) };
265
283
  }
266
284
  function sanitizeUrl(u) {
267
285
  const clone = new URL(u.toString());
@@ -301,17 +319,18 @@ async function runValidate(input) {
301
319
  }
302
320
  const det = detectSchema(fm.data);
303
321
  if (!det.schema) {
304
- return { exitCode: ExitCode.SCHEMA_NOT_DETECTED, result: ok({ schema: null, valid: false, errors: [] }) };
322
+ return { exitCode: ExitCode.SCHEMA_NOT_DETECTED, result: ok({ schema: null, valid: false, errors: [], humanHint: "schema not detected" }) };
305
323
  }
306
324
  const parsed = SCHEMAS[det.schema].safeParse(fm.data);
307
325
  if (!parsed.success) {
308
326
  const errors = parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }));
309
327
  return {
310
328
  exitCode: ExitCode.INVALID_FRONTMATTER,
311
- result: ok({ schema: det.schema, valid: false, errors })
329
+ result: ok({ schema: det.schema, valid: false, errors, humanHint: `INVALID (${det.schema})
330
+ ${errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}` })
312
331
  };
313
332
  }
314
- return { exitCode: ExitCode.OK, result: ok({ schema: det.schema, valid: true, errors: [] }) };
333
+ return { exitCode: ExitCode.OK, result: ok({ schema: det.schema, valid: true, errors: [], humanHint: `VALID (${det.schema})` }) };
315
334
  }
316
335
 
317
336
  // src/commands/graph.ts
@@ -397,7 +416,8 @@ async function runGraphBuild(input) {
397
416
  }
398
417
  return {
399
418
  exitCode: ExitCode.OK,
400
- result: ok({ out_path: input.out, node_count: scan.data.typedKnowledge.length, edge_count })
419
+ result: ok({ out_path: input.out, node_count: scan.data.typedKnowledge.length, edge_count, humanHint: `nodes: ${scan.data.typedKnowledge.length}, edges: ${edge_count}
420
+ written: ${input.out}` })
401
421
  };
402
422
  }
403
423
  function computeAdamicAdar(adj) {
@@ -472,22 +492,19 @@ async function runOverlap(input) {
472
492
  }
473
493
  return { id, members, score };
474
494
  });
475
- return { exitCode: ExitCode.OK, result: ok({ clusters }) };
495
+ const humanHint = clusters.length === 0 ? "no overlap clusters found" : clusters.map((c) => `cluster (${c.members.length} pages, score ${c.score}): ${c.members.join(", ")}`).join("\n");
496
+ return { exitCode: ExitCode.OK, result: ok({ clusters, humanHint }) };
476
497
  }
477
498
 
478
499
  // src/utils/wiki-path.ts
479
500
  import { join as join2 } from "path";
480
501
 
481
502
  // src/utils/dotenv.ts
482
- import { readFile as readFile4 } from "fs/promises";
483
- var WHITELIST = /* @__PURE__ */ new Set(["WIKI_PATH", "WIKI_LANG"]);
484
- async function parseDotenvFile(path) {
485
- let text;
486
- try {
487
- text = await readFile4(path, "utf8");
488
- } catch {
489
- return {};
490
- }
503
+ import { readFile as readFile4, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
504
+ import { dirname as dirname2 } from "path";
505
+ var CONFIG_KEYS = ["WIKI_PATH", "WIKI_LANG"];
506
+ var _whitelist = new Set(CONFIG_KEYS);
507
+ function parseDotenvText(text) {
491
508
  const out = {};
492
509
  for (const rawLine of text.split(/\r?\n/)) {
493
510
  const line = rawLine.trim();
@@ -496,12 +513,65 @@ async function parseDotenvFile(path) {
496
513
  if (eq <= 0) continue;
497
514
  const key = line.slice(0, eq).trim();
498
515
  const value = line.slice(eq + 1).trim();
499
- if (!WHITELIST.has(key)) continue;
516
+ if (!_whitelist.has(key)) continue;
500
517
  if (value.length === 0) continue;
501
518
  out[key] = value;
502
519
  }
503
520
  return out;
504
521
  }
522
+ async function parseDotenvFile(path) {
523
+ let text;
524
+ try {
525
+ text = await readFile4(path, "utf8");
526
+ } catch {
527
+ return {};
528
+ }
529
+ return parseDotenvText(text);
530
+ }
531
+ async function writeDotenv(filePath, entries, originalContent) {
532
+ const lines = originalContent !== void 0 ? updateLines(originalContent, entries) : freshLines(entries);
533
+ await mkdir2(dirname2(filePath), { recursive: true });
534
+ await writeFile2(filePath, lines.join("\n") + "\n", "utf8");
535
+ }
536
+ function freshLines(entries) {
537
+ const out = [];
538
+ for (const [key, value] of Object.entries(entries)) {
539
+ if (value !== void 0) out.push(`${key}=${value}`);
540
+ }
541
+ return out;
542
+ }
543
+ function updateLines(originalContent, entries) {
544
+ let rawLines = originalContent.split(/\r?\n/);
545
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
546
+ rawLines = rawLines.slice(0, -1);
547
+ }
548
+ const keysToWrite = new Set(Object.keys(entries));
549
+ const out = [];
550
+ for (const line of rawLines) {
551
+ const trimmed = line.trim();
552
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
553
+ out.push(line);
554
+ continue;
555
+ }
556
+ const eq = trimmed.indexOf("=");
557
+ if (eq <= 0) {
558
+ out.push(line);
559
+ continue;
560
+ }
561
+ const key = trimmed.slice(0, eq).trim();
562
+ if (keysToWrite.has(key)) {
563
+ out.push(`${key}=${entries[key]}`);
564
+ keysToWrite.delete(key);
565
+ } else {
566
+ out.push(line);
567
+ }
568
+ }
569
+ for (const key of keysToWrite) {
570
+ const value = entries[key];
571
+ if (value !== void 0) out.push(`${key}=${value}`);
572
+ }
573
+ return out;
574
+ }
505
575
 
506
576
  // src/utils/wiki-path.ts
507
577
  async function resolveInitTimePath(input) {
@@ -569,7 +639,10 @@ async function runOrphans(input) {
569
639
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
570
640
  const slugToPath = {};
571
641
  for (const p of scan.data.typedKnowledge) {
572
- slugToPath[p.relPath.replace(/\.md$/, "").split("/").pop()] = p.relPath;
642
+ const rel = p.relPath.replace(/\.md$/, "");
643
+ slugToPath[rel] = p.relPath;
644
+ const filename = rel.split("/").pop();
645
+ if (!(filename in slugToPath)) slugToPath[filename] = p.relPath;
573
646
  }
574
647
  const adj = {};
575
648
  for (const p of scan.data.typedKnowledge) adj[p.relPath] = /* @__PURE__ */ new Set();
@@ -578,7 +651,7 @@ async function runOrphans(input) {
578
651
  const split = splitFrontmatter(text);
579
652
  const body = split.ok ? split.data.body : text;
580
653
  for (const slug of extractBodyWikilinks(body)) {
581
- const tgt = slugToPath[slug.split("/").pop()];
654
+ const tgt = slugToPath[slug] ?? slugToPath[slug.split("/").pop()];
582
655
  if (tgt) {
583
656
  adj[p.relPath].add(tgt);
584
657
  adj[tgt].add(p.relPath);
@@ -609,7 +682,11 @@ async function runOrphans(input) {
609
682
  }
610
683
  }
611
684
  }
612
- return { exitCode: ExitCode.OK, result: ok({ orphans, bridges }) };
685
+ const hintLines = [];
686
+ if (orphans.length > 0) hintLines.push(`orphans: ${orphans.length}`, ...orphans.map((o) => ` ${o}`));
687
+ if (bridges.length > 0) hintLines.push(`bridges: ${bridges.length}`, ...bridges.map((b) => ` ${b.path}`));
688
+ if (hintLines.length === 0) hintLines.push("no orphans or bridges");
689
+ return { exitCode: ExitCode.OK, result: ok({ orphans, bridges, humanHint: hintLines.join("\n") }) };
613
690
  }
614
691
  function simulateRemoval(adj, removed) {
615
692
  const seen = /* @__PURE__ */ new Set();
@@ -630,20 +707,92 @@ function simulateRemoval(adj, removed) {
630
707
 
631
708
  // src/commands/audit.ts
632
709
  import { readFile as readFile5, stat as stat2 } from "fs/promises";
633
- import { dirname as dirname2, resolve, join as join3 } from "path";
710
+ import { dirname as dirname3, resolve, join as join3 } from "path";
634
711
 
635
712
  // src/parsers/citations.ts
636
713
  var FENCE2 = /```[\s\S]*?```/g;
714
+ var MARKER_RE = /\^\[(raw\/[^\]]+)\]/g;
715
+ function stripFences(body) {
716
+ return body.replace(FENCE2, "");
717
+ }
637
718
  function extractCitationMarkers(body) {
638
- const stripped = body.replace(FENCE2, "");
719
+ const stripped = stripFences(body);
639
720
  const out = [];
640
- const re = /\^\[(raw\/[^\]]+)\]/g;
641
721
  let m;
642
- while ((m = re.exec(stripped)) !== null) {
722
+ while ((m = MARKER_RE.exec(stripped)) !== null) {
643
723
  out.push({ marker: m[0], target: m[1] });
644
724
  }
645
725
  return out;
646
726
  }
727
+ function hasSourcesFooter(body) {
728
+ return /^## Sources\s*$/m.test(stripFences(body));
729
+ }
730
+ function isLegacyCitationStyle(body) {
731
+ const markers = extractCitationMarkers(body);
732
+ if (markers.length === 0) return false;
733
+ if (!hasSourcesFooter(body)) return true;
734
+ const lines = stripFences(body).split("\n");
735
+ let inSources = false;
736
+ for (const line of lines) {
737
+ if (/^## Sources\b/.test(line.trim())) {
738
+ inSources = true;
739
+ continue;
740
+ }
741
+ if (inSources) continue;
742
+ const markerOnly = line.replace(MARKER_RE, "").trim();
743
+ if (markerOnly.length === 0 && /\^\[raw\//.test(line)) return true;
744
+ const lastMarkerIdx = line.lastIndexOf("^[raw/");
745
+ if (lastMarkerIdx >= 0) {
746
+ const afterLast = line.slice(lastMarkerIdx).replace(MARKER_RE, "").trim();
747
+ if (afterLast.length > 0) return true;
748
+ const beforeFirst = line.slice(0, line.indexOf("^[raw/")).trim();
749
+ if (beforeFirst.length > 0 && !/[.!?]\s*$/.test(beforeFirst)) return true;
750
+ }
751
+ }
752
+ return false;
753
+ }
754
+ function hasOrphanedCitations(body) {
755
+ const stripped = stripFences(body);
756
+ const lines = stripped.split("\n");
757
+ let inSources = false;
758
+ let sourcesEnded = false;
759
+ let sourcesStartLine = -1;
760
+ let lastNonBlankInSources = -1;
761
+ for (let i = 0; i < lines.length; i++) {
762
+ const line = lines[i];
763
+ const trimmed = line.trim();
764
+ if (/^## Sources\b/.test(trimmed)) {
765
+ inSources = true;
766
+ sourcesStartLine = i;
767
+ continue;
768
+ }
769
+ if (!inSources || sourcesEnded) continue;
770
+ if (trimmed.length === 0) {
771
+ if (lastNonBlankInSources >= 0) {
772
+ sourcesEnded = true;
773
+ }
774
+ continue;
775
+ }
776
+ const isListItem = /^\s*[-*]\s+/.test(line);
777
+ const hasMarker = /\^\[raw\//.test(line);
778
+ if (isListItem && hasMarker) {
779
+ lastNonBlankInSources = i;
780
+ } else if (hasMarker && !isListItem) {
781
+ return true;
782
+ } else {
783
+ sourcesEnded = true;
784
+ }
785
+ }
786
+ if (sourcesStartLine === -1) return false;
787
+ if (sourcesEnded) {
788
+ for (let i = lastNonBlankInSources + 1; i < lines.length; i++) {
789
+ if (/\^\[raw\//.test(lines[i])) {
790
+ return true;
791
+ }
792
+ }
793
+ }
794
+ return false;
795
+ }
647
796
 
648
797
  // src/commands/audit.ts
649
798
  async function runAudit(input) {
@@ -657,7 +806,7 @@ async function runAudit(input) {
657
806
  if (!fm.ok) return { exitCode: ExitCode.INVALID_FRONTMATTER, result: fm };
658
807
  const split = splitFrontmatter(text);
659
808
  const body = split.ok ? split.data.body : text;
660
- const vault = await findVaultRoot(dirname2(resolve(input.file)));
809
+ const vault = await findVaultRoot(dirname3(resolve(input.file)));
661
810
  if (!vault) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID") };
662
811
  const markers = extractCitationMarkers(body);
663
812
  const resolved = await Promise.all(markers.map(async (m) => {
@@ -672,13 +821,36 @@ async function runAudit(input) {
672
821
  const referenced = new Set(resolved.map((m) => m.target));
673
822
  const unused_sources = sources.filter((s) => !referenced.has(s));
674
823
  const missing_from_sources = [...referenced].filter((t) => !sources.includes(t));
824
+ const broken = resolved.filter((m) => !m.resolved);
825
+ const footerMatch = body.match(/\n## Sources\n([\s\S]*)$/);
826
+ let footer_consistency;
827
+ if (footerMatch) {
828
+ const footerTargets = /* @__PURE__ */ new Set();
829
+ const footerRe = /\^\[(raw\/[^\]]+)\]/g;
830
+ let mm;
831
+ while ((mm = footerRe.exec(footerMatch[1])) !== null) footerTargets.add(mm[1]);
832
+ const bodyTargets = new Set(resolved.map((m) => m.target));
833
+ const missing_from_footer = [...bodyTargets].filter((t) => !footerTargets.has(t));
834
+ const extra_in_footer = [...footerTargets].filter((t) => !bodyTargets.has(t));
835
+ footer_consistency = { missing_from_footer, extra_in_footer };
836
+ }
837
+ const hintLines = [];
838
+ hintLines.push(`markers: ${resolved.length}, broken: ${broken.length}`);
839
+ if (unused_sources.length > 0) hintLines.push(`unused_sources: ${unused_sources.length}`);
840
+ if (missing_from_sources.length > 0) hintLines.push(`missing_from_sources: ${missing_from_sources.length}`);
841
+ if (footer_consistency) {
842
+ if (footer_consistency.missing_from_footer.length > 0) hintLines.push(`missing_from_footer: ${footer_consistency.missing_from_footer.length}`);
843
+ if (footer_consistency.extra_in_footer.length > 0) hintLines.push(`extra_in_footer: ${footer_consistency.extra_in_footer.length}`);
844
+ }
845
+ if (broken.length === 0 && unused_sources.length === 0 && missing_from_sources.length === 0) hintLines.push("OK");
846
+ const humanHint = hintLines.join("\n");
675
847
  if (resolved.some((m) => !m.resolved)) {
676
- return { exitCode: ExitCode.UNRESOLVED_MARKERS, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources } }) };
848
+ return { exitCode: ExitCode.UNRESOLVED_MARKERS, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources }, footer_consistency, humanHint }) };
677
849
  }
678
850
  if (unused_sources.length > 0 || missing_from_sources.length > 0) {
679
- return { exitCode: ExitCode.SOURCES_INCONSISTENT, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources } }) };
851
+ return { exitCode: ExitCode.SOURCES_INCONSISTENT, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources }, footer_consistency, humanHint }) };
680
852
  }
681
- return { exitCode: ExitCode.OK, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources } }) };
853
+ return { exitCode: ExitCode.OK, result: ok({ markers: resolved, sources_consistency: { unused_sources, missing_from_sources }, footer_consistency, humanHint }) };
682
854
  }
683
855
  async function findVaultRoot(start) {
684
856
  let cur = start;
@@ -688,7 +860,7 @@ async function findVaultRoot(start) {
688
860
  return cur;
689
861
  } catch {
690
862
  }
691
- const parent = dirname2(cur);
863
+ const parent = dirname3(cur);
692
864
  if (parent === cur) return null;
693
865
  cur = parent;
694
866
  }
@@ -700,10 +872,10 @@ import { readdir as readdir2, stat as stat4 } from "fs/promises";
700
872
  import { join as join4 } from "path";
701
873
 
702
874
  // src/utils/install-fs.ts
703
- import { copyFile, mkdir as mkdir2, rename, writeFile as writeFile2, stat as stat3 } from "fs/promises";
704
- import { dirname as dirname3 } from "path";
875
+ import { copyFile, mkdir as mkdir3, rename, writeFile as writeFile3, stat as stat3 } from "fs/promises";
876
+ import { dirname as dirname4 } from "path";
705
877
  async function atomicCopyWithBackup(src, dst) {
706
- await mkdir2(dirname3(dst), { recursive: true });
878
+ await mkdir3(dirname4(dst), { recursive: true });
707
879
  let backupPath = null;
708
880
  try {
709
881
  await stat3(dst);
@@ -721,9 +893,9 @@ async function atomicCopyWithBackup(src, dst) {
721
893
  return ok({ copied: true, backupPath });
722
894
  }
723
895
  async function writeManifest(path, m) {
724
- await mkdir2(dirname3(path), { recursive: true });
896
+ await mkdir3(dirname4(path), { recursive: true });
725
897
  const enriched = { installed_at: (/* @__PURE__ */ new Date()).toISOString(), ...m };
726
- await writeFile2(path, JSON.stringify(enriched, null, 2));
898
+ await writeFile3(path, JSON.stringify(enriched, null, 2));
727
899
  }
728
900
 
729
901
  // src/commands/install.ts
@@ -758,7 +930,12 @@ async function runInstall(input) {
758
930
  }
759
931
  const manifest_path = join4(input.target, "wiki-manifest.json");
760
932
  if (!input.dryRun) await writeManifest(manifest_path, { installed, backed_up });
761
- return { exitCode: ExitCode.OK, result: ok({ installed, backed_up, manifest_path }) };
933
+ const hintLines = [
934
+ `installed: ${installed.length}`,
935
+ input.dryRun ? "(dry run)" : `backed up: ${backed_up.length}`,
936
+ `manifest: ${manifest_path}`
937
+ ];
938
+ return { exitCode: ExitCode.OK, result: ok({ installed, backed_up, manifest_path, humanHint: hintLines.join("\n") }) };
762
939
  }
763
940
 
764
941
  // src/commands/path.ts
@@ -770,7 +947,7 @@ async function runPath(input) {
770
947
  home: input.home,
771
948
  explain: input.explain
772
949
  });
773
- return { exitCode: ExitCode.OK, result: ok({ path: r2.path, source: r2.source, ...r2.chain ? { chain: r2.chain } : {} }) };
950
+ return { exitCode: ExitCode.OK, result: ok({ path: r2.path, source: r2.source, ...r2.chain ? { chain: r2.chain } : {}, humanHint: `${r2.path} (via ${r2.source})` }) };
774
951
  }
775
952
  const r = await resolveRuntimePath({
776
953
  flag: input.flag,
@@ -779,7 +956,7 @@ async function runPath(input) {
779
956
  explain: input.explain
780
957
  });
781
958
  if (!r.ok) return { exitCode: ExitCode.NO_VAULT_CONFIGURED, result: r };
782
- return { exitCode: ExitCode.OK, result: ok({ path: r.data.path, source: r.data.source, ...r.data.chain ? { chain: r.data.chain } : {} }) };
959
+ return { exitCode: ExitCode.OK, result: ok({ path: r.data.path, source: r.data.source, ...r.data.chain ? { chain: r.data.chain } : {}, humanHint: `${r.data.path} (via ${r.data.source})` }) };
783
960
  }
784
961
 
785
962
  // src/utils/lang.ts
@@ -833,14 +1010,42 @@ async function runLang(input) {
833
1010
  value: resolved.value,
834
1011
  source: resolved.source,
835
1012
  canonical: resolved.canonical,
836
- ...chain ? { chain } : {}
1013
+ ...chain ? { chain } : {},
1014
+ humanHint: `${resolved.value} (via ${resolved.source})`
837
1015
  })
838
1016
  };
839
1017
  }
840
1018
 
841
1019
  // src/commands/init.ts
842
- import { mkdir as mkdir3, readFile as readFile6, stat as stat5, writeFile as writeFile3 } from "fs/promises";
843
- import { join as join7, dirname as dirname4 } from "path";
1020
+ import { mkdir as mkdir4, readFile as readFile6, readdir as readdir3, writeFile as writeFile4 } from "fs/promises";
1021
+ import { join as join7 } from "path";
1022
+
1023
+ // src/parsers/taxonomy.ts
1024
+ import yaml2 from "js-yaml";
1025
+ var FENCE_RE = /^##\s+Tag Taxonomy\s*$[\s\S]*?```yaml\s*\n([\s\S]*?)\n```/m;
1026
+ function extractTaxonomy(schemaText) {
1027
+ const m = schemaText.match(FENCE_RE);
1028
+ if (!m) return err("NO_TAXONOMY_BLOCK", { message: "No fenced YAML taxonomy block found in SCHEMA.md" });
1029
+ let parsed;
1030
+ try {
1031
+ parsed = yaml2.load(m[1], { schema: yaml2.JSON_SCHEMA });
1032
+ } catch (e) {
1033
+ return err("INVALID_FRONTMATTER", { message: e.message });
1034
+ }
1035
+ if (parsed === null || typeof parsed !== "object") {
1036
+ return err("INVALID_FRONTMATTER", { message: "taxonomy block is not an object" });
1037
+ }
1038
+ const tax = parsed.taxonomy;
1039
+ if (!Array.isArray(tax)) {
1040
+ return err("INVALID_FRONTMATTER", { message: "taxonomy key missing or not an array" });
1041
+ }
1042
+ if (!tax.every((x) => typeof x === "string")) {
1043
+ return err("INVALID_FRONTMATTER", { message: "taxonomy must be a list of strings" });
1044
+ }
1045
+ return ok(tax);
1046
+ }
1047
+
1048
+ // src/commands/init.ts
844
1049
  var DEFAULT_TAXONOMY = [
845
1050
  "research",
846
1051
  "comparison",
@@ -865,25 +1070,59 @@ var VAULT_DIRS = [
865
1070
  "meta",
866
1071
  "projects"
867
1072
  ];
1073
+ function extractDomainFromSchema(text) {
1074
+ const m = text.match(/^##\s+Domain\s*\n([\s\S]*?)(?=\n\n|\n##|\s*$)/m);
1075
+ if (!m) return "";
1076
+ const d = m[1].trim();
1077
+ return d.startsWith("##") ? "" : d;
1078
+ }
1079
+ async function discoverTagsFromPages(target, knownSlugs) {
1080
+ const knownSet = new Set(knownSlugs);
1081
+ const discovered = /* @__PURE__ */ new Set();
1082
+ for (const dir of ["entities", "concepts", "comparisons", "queries"]) {
1083
+ let entries;
1084
+ try {
1085
+ entries = (await readdir3(join7(target, dir), { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name);
1086
+ } catch {
1087
+ continue;
1088
+ }
1089
+ for (const file of entries) {
1090
+ try {
1091
+ const text = await readFile6(join7(target, dir, file), "utf8");
1092
+ const fm = extractFrontmatter(text);
1093
+ if (!fm.ok || !fm.data.tags || !Array.isArray(fm.data.tags)) continue;
1094
+ for (const t of fm.data.tags) {
1095
+ if (typeof t === "string" && !knownSet.has(t)) discovered.add(t);
1096
+ }
1097
+ } catch {
1098
+ }
1099
+ }
1100
+ }
1101
+ return [...discovered].sort();
1102
+ }
868
1103
  async function runInit(input) {
869
1104
  const pathRes = await resolveInitTimePath({ flag: input.flag, envValue: input.envValue, home: input.home });
870
1105
  const target = pathRes.path;
871
1106
  const langRes = await resolveLang({ flag: input.lang, envValue: void 0, home: input.home });
872
1107
  const canonicalLang = langRes.canonical;
873
- let hasSchema = false;
1108
+ let oldSchemaText;
874
1109
  try {
875
- await stat5(join7(target, "SCHEMA.md"));
876
- hasSchema = true;
1110
+ oldSchemaText = await readFile6(join7(target, "SCHEMA.md"), "utf8");
877
1111
  } catch {
878
1112
  }
879
- if (hasSchema && !input.force) {
1113
+ if (oldSchemaText && !input.force) {
880
1114
  return {
881
1115
  exitCode: ExitCode.INIT_TARGET_NOT_EMPTY,
882
1116
  result: err("INIT_TARGET_NOT_EMPTY", { target })
883
1117
  };
884
1118
  }
885
1119
  const envPath = join7(input.home, ".skillwiki", ".env");
886
- const existingEnv = await parseDotenvFile(envPath);
1120
+ let existingEnvRaw = "";
1121
+ try {
1122
+ existingEnvRaw = await readFile6(envPath, "utf8");
1123
+ } catch {
1124
+ }
1125
+ const existingEnv = parseDotenvText(existingEnvRaw);
887
1126
  const swDotenvHadPath = existingEnv.WIKI_PATH !== void 0;
888
1127
  if (existingEnv.WIKI_PATH !== void 0 && existingEnv.WIKI_PATH !== target && !input.force) {
889
1128
  return {
@@ -899,73 +1138,125 @@ async function runInit(input) {
899
1138
  }
900
1139
  const created = [];
901
1140
  try {
902
- await mkdir3(target, { recursive: true });
1141
+ await mkdir4(target, { recursive: true });
903
1142
  for (const d of VAULT_DIRS) {
904
- await mkdir3(join7(target, d), { recursive: true });
1143
+ await mkdir4(join7(target, d), { recursive: true });
905
1144
  created.push(d + "/");
906
1145
  }
907
1146
  } catch (e) {
908
1147
  return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
909
1148
  }
910
1149
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
911
- const taxonomy = input.taxonomy && input.taxonomy.length > 0 ? input.taxonomy : DEFAULT_TAXONOMY;
912
- const taxonomyYaml = taxonomy.map((t) => ` - ${t}`).join("\n");
1150
+ let taxonomy = input.taxonomy && input.taxonomy.length > 0 ? input.taxonomy : DEFAULT_TAXONOMY;
1151
+ let domain = input.domain;
1152
+ let oldTaxonomy = [];
1153
+ if (oldSchemaText) {
1154
+ if (!domain) {
1155
+ const oldDomain = extractDomainFromSchema(oldSchemaText);
1156
+ if (oldDomain) domain = oldDomain;
1157
+ }
1158
+ const oldTax = extractTaxonomy(oldSchemaText);
1159
+ if (oldTax.ok) oldTaxonomy = oldTax.data;
1160
+ }
1161
+ const taxonomySet = new Set(taxonomy);
1162
+ for (const t of oldTaxonomy) {
1163
+ if (!taxonomySet.has(t)) {
1164
+ taxonomy.push(t);
1165
+ taxonomySet.add(t);
1166
+ }
1167
+ }
1168
+ const discovered = await discoverTagsFromPages(target, taxonomy);
1169
+ const discovered_tags = discovered.length;
1170
+ const fullTaxonomyYaml = discovered.length > 0 ? taxonomy.map((t) => ` - ${t}`).join("\n") + "\n # --- Discovered from existing pages ---\n" + discovered.map((t) => ` - ${t}`).join("\n") : taxonomy.map((t) => ` - ${t}`).join("\n");
913
1171
  try {
914
1172
  const schemaTpl = await readFile6(join7(input.templates, "SCHEMA.md"), "utf8");
915
- const schema = schemaTpl.replace("{{DOMAIN}}", input.domain).replace("{{WIKI_LANG}}", canonicalLang).replace("{{TAXONOMY_YAML}}", taxonomyYaml);
916
- await writeFile3(join7(target, "SCHEMA.md"), schema, "utf8");
1173
+ const schema = schemaTpl.replace("{{DOMAIN}}", domain).replace("{{WIKI_LANG}}", canonicalLang).replace("{{TAXONOMY_YAML}}", fullTaxonomyYaml);
1174
+ await writeFile4(join7(target, "SCHEMA.md"), schema, "utf8");
917
1175
  created.push("SCHEMA.md");
918
1176
  } catch (e) {
919
1177
  return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: "SCHEMA.md", message: String(e) }) };
920
1178
  }
921
- try {
922
- const idxTpl = await readFile6(join7(input.templates, "index.md"), "utf8");
923
- const idx = idxTpl.replace("{{INIT_DATE}}", today);
924
- await writeFile3(join7(target, "index.md"), idx, "utf8");
925
- created.push("index.md");
926
- } catch (e) {
927
- return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: "index.md", message: String(e) }) };
928
- }
929
- try {
930
- const logTpl = await readFile6(join7(input.templates, "log.md"), "utf8");
931
- const log = logTpl.replace(/\{\{INIT_DATE\}\}/g, today).replace("{{DOMAIN}}", input.domain).replace("{{WIKI_LANG}}", canonicalLang);
932
- await writeFile3(join7(target, "log.md"), log, "utf8");
933
- created.push("log.md");
934
- } catch (e) {
935
- return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: "log.md", message: String(e) }) };
1179
+ const preserved = [];
1180
+ async function writeOrPreserve(fileName, render) {
1181
+ try {
1182
+ const existing = await readFile6(join7(target, fileName), "utf8");
1183
+ if (existing.split("\n").length > 10) {
1184
+ preserved.push(fileName);
1185
+ return void 0;
1186
+ }
1187
+ } catch {
1188
+ }
1189
+ try {
1190
+ await writeFile4(join7(target, fileName), await render(), "utf8");
1191
+ created.push(fileName);
1192
+ return void 0;
1193
+ } catch (e) {
1194
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: fileName, message: String(e) }) };
1195
+ }
936
1196
  }
937
- try {
938
- await mkdir3(dirname4(envPath), { recursive: true });
939
- const envBody = `WIKI_PATH=${target}
940
- WIKI_LANG=${canonicalLang}
941
- `;
942
- await writeFile3(envPath, envBody, "utf8");
943
- } catch (e) {
944
- return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: envPath, message: String(e) }) };
1197
+ const err1 = await writeOrPreserve("index.md", async () => {
1198
+ const tpl = await readFile6(join7(input.templates, "index.md"), "utf8");
1199
+ return tpl.replace("{{INIT_DATE}}", today);
1200
+ });
1201
+ if (err1) return err1;
1202
+ const err22 = await writeOrPreserve("log.md", async () => {
1203
+ const tpl = await readFile6(join7(input.templates, "log.md"), "utf8");
1204
+ return tpl.replace(/\{\{INIT_DATE\}\}/g, today).replace("{{DOMAIN}}", domain).replace("{{WIKI_LANG}}", canonicalLang);
1205
+ });
1206
+ if (err22) return err22;
1207
+ const isTempPath = target.startsWith("/tmp/") || target === "/tmp" || target.startsWith("/var/tmp/") || target === "/var/tmp" || target.startsWith("/private/tmp/");
1208
+ const skipEnv = !!input.noEnv || isTempPath;
1209
+ let envWritten = "";
1210
+ if (!skipEnv) {
1211
+ try {
1212
+ await writeDotenv(envPath, { WIKI_PATH: target, WIKI_LANG: canonicalLang }, existingEnvRaw);
1213
+ envWritten = envPath;
1214
+ } catch (e) {
1215
+ return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: envPath, message: String(e) }) };
1216
+ }
945
1217
  }
946
1218
  const importedFromHermes = pathRes.source === "hermes-dotenv" && !swDotenvHadPath;
1219
+ const humanHint = [
1220
+ `vault: ${target}`,
1221
+ `domain: ${domain}`,
1222
+ `lang: ${canonicalLang}`,
1223
+ `created: ${created.length}, preserved: ${preserved.length}`,
1224
+ `discovered tags: ${discovered_tags}`,
1225
+ skipEnv ? "env: skipped" : `env: ${envWritten}`
1226
+ ].join("\n");
947
1227
  return {
948
1228
  exitCode: ExitCode.OK,
949
1229
  result: ok({
950
1230
  vault: target,
951
- domain: input.domain,
1231
+ domain,
952
1232
  taxonomy,
953
1233
  lang: canonicalLang,
954
1234
  created,
955
- env_written: envPath,
956
- imported_from_hermes: importedFromHermes
1235
+ preserved,
1236
+ env_written: envWritten,
1237
+ env_skipped: skipEnv,
1238
+ imported_from_hermes: importedFromHermes,
1239
+ discovered_tags,
1240
+ humanHint
957
1241
  })
958
1242
  };
959
1243
  }
960
1244
 
1245
+ // src/utils/slug.ts
1246
+ function buildSlugMap(pages) {
1247
+ const map = /* @__PURE__ */ new Map();
1248
+ for (const p of pages) {
1249
+ const slug = p.relPath.replace(/\.md$/, "").split("/").pop();
1250
+ map.set(slug.toLowerCase(), slug);
1251
+ }
1252
+ return map;
1253
+ }
1254
+
961
1255
  // src/commands/links.ts
962
1256
  async function runLinks(input) {
963
1257
  const scan = await scanVault(input.vault);
964
1258
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
965
- const slugs = /* @__PURE__ */ new Set();
966
- for (const p of scan.data.typedKnowledge) {
967
- slugs.add(p.relPath.replace(/\.md$/, "").split("/").pop());
968
- }
1259
+ const slugs = buildSlugMap(scan.data.typedKnowledge);
969
1260
  const broken = [];
970
1261
  for (const p of scan.data.typedKnowledge) {
971
1262
  const text = await readPage(p);
@@ -974,48 +1265,22 @@ async function runLinks(input) {
974
1265
  const lines = body.split("\n");
975
1266
  for (const slug of extractBodyWikilinks(body)) {
976
1267
  const tail = slug.split("/").pop();
977
- if (!slugs.has(tail)) {
1268
+ if (!slugs.has(tail.toLowerCase())) {
978
1269
  const line = lines.findIndex((l) => l.includes(`[[${slug}`));
979
1270
  broken.push({ page: p.relPath, slug, line: line >= 0 ? line + 1 : 0 });
980
1271
  }
981
1272
  }
982
1273
  }
983
1274
  if (broken.length > 0) {
984
- return { exitCode: ExitCode.BROKEN_WIKILINKS, result: ok({ broken }) };
1275
+ return { exitCode: ExitCode.BROKEN_WIKILINKS, result: ok({ broken, humanHint: `broken: ${broken.length}
1276
+ ${broken.map((b) => ` ${b.page}:[[${b.slug}]] (line ${b.line})`).join("\n")}` }) };
985
1277
  }
986
- return { exitCode: ExitCode.OK, result: ok({ broken }) };
1278
+ return { exitCode: ExitCode.OK, result: ok({ broken, humanHint: "no broken wikilinks" }) };
987
1279
  }
988
1280
 
989
1281
  // src/commands/tag-audit.ts
990
1282
  import { readFile as readFile7 } from "fs/promises";
991
1283
  import { join as join8 } from "path";
992
-
993
- // src/parsers/taxonomy.ts
994
- import yaml2 from "js-yaml";
995
- var FENCE_RE = /^##\s+Tag Taxonomy\s*$[\s\S]*?```yaml\s*\n([\s\S]*?)\n```/m;
996
- function extractTaxonomy(schemaText) {
997
- const m = schemaText.match(FENCE_RE);
998
- if (!m) return ok([]);
999
- let parsed;
1000
- try {
1001
- parsed = yaml2.load(m[1], { schema: yaml2.JSON_SCHEMA });
1002
- } catch (e) {
1003
- return err("INVALID_FRONTMATTER", { message: e.message });
1004
- }
1005
- if (parsed === null || typeof parsed !== "object") {
1006
- return err("INVALID_FRONTMATTER", { message: "taxonomy block is not an object" });
1007
- }
1008
- const tax = parsed.taxonomy;
1009
- if (!Array.isArray(tax)) {
1010
- return err("INVALID_FRONTMATTER", { message: "taxonomy key missing or not an array" });
1011
- }
1012
- if (!tax.every((x) => typeof x === "string")) {
1013
- return err("INVALID_FRONTMATTER", { message: "taxonomy must be a list of strings" });
1014
- }
1015
- return ok(tax);
1016
- }
1017
-
1018
- // src/commands/tag-audit.ts
1019
1284
  async function runTagAudit(input) {
1020
1285
  const scan = await scanVault(input.vault);
1021
1286
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -1037,9 +1302,9 @@ async function runTagAudit(input) {
1037
1302
  }
1038
1303
  }
1039
1304
  if (violations.length > 0) {
1040
- return { exitCode: ExitCode.TAG_NOT_IN_TAXONOMY, result: ok({ violations, taxonomy: tax.data }) };
1305
+ return { exitCode: ExitCode.TAG_NOT_IN_TAXONOMY, result: ok({ violations, taxonomy: tax.data, humanHint: violations.map((v) => `${v.page}: "${v.tag}" not in taxonomy`).join("\n") }) };
1041
1306
  }
1042
- return { exitCode: ExitCode.OK, result: ok({ violations, taxonomy: tax.data }) };
1307
+ return { exitCode: ExitCode.OK, result: ok({ violations, taxonomy: tax.data, humanHint: "all tags valid" }) };
1043
1308
  }
1044
1309
 
1045
1310
  // src/commands/index-check.ts
@@ -1053,7 +1318,11 @@ async function runIndexCheck(input) {
1053
1318
  indexText = await readFile8(join9(input.vault, "index.md"), "utf8");
1054
1319
  } catch {
1055
1320
  }
1056
- const indexSlugs = new Set(extractBodyWikilinks(indexText).map((s) => s.split("/").pop()));
1321
+ const indexSlugsLower = /* @__PURE__ */ new Map();
1322
+ for (const s of extractBodyWikilinks(indexText)) {
1323
+ const tail = s.split("/").pop();
1324
+ indexSlugsLower.set(tail.toLowerCase(), tail);
1325
+ }
1057
1326
  const fileSlugs = /* @__PURE__ */ new Map();
1058
1327
  for (const p of scan.data.typedKnowledge) {
1059
1328
  const slug = p.relPath.replace(/\.md$/, "").split("/").pop();
@@ -1061,16 +1330,21 @@ async function runIndexCheck(input) {
1061
1330
  }
1062
1331
  const missing_from_index = [];
1063
1332
  for (const [slug, relPath] of fileSlugs.entries()) {
1064
- if (!indexSlugs.has(slug)) missing_from_index.push(relPath);
1333
+ if (!indexSlugsLower.has(slug.toLowerCase())) missing_from_index.push(relPath);
1065
1334
  }
1335
+ const fileSlugsLower = new Set([...fileSlugs.keys()].map((s) => s.toLowerCase()));
1066
1336
  const ghost_entries = [];
1067
- for (const slug of indexSlugs) {
1068
- if (!fileSlugs.has(slug)) ghost_entries.push(slug);
1337
+ for (const [lower, orig] of indexSlugsLower) {
1338
+ if (!fileSlugsLower.has(lower)) ghost_entries.push(orig);
1069
1339
  }
1340
+ const hintLines = [];
1341
+ if (missing_from_index.length > 0) hintLines.push(`missing from index: ${missing_from_index.length}`, ...missing_from_index.map((p) => ` ${p}`));
1342
+ if (ghost_entries.length > 0) hintLines.push(`ghost entries: ${ghost_entries.length}`, ...ghost_entries.map((g) => ` ${g}`));
1343
+ if (hintLines.length === 0) hintLines.push("index OK");
1070
1344
  if (missing_from_index.length > 0 || ghost_entries.length > 0) {
1071
- return { exitCode: ExitCode.INDEX_INCOMPLETE, result: ok({ missing_from_index, ghost_entries }) };
1345
+ return { exitCode: ExitCode.INDEX_INCOMPLETE, result: ok({ missing_from_index, ghost_entries, humanHint: hintLines.join("\n") }) };
1072
1346
  }
1073
- return { exitCode: ExitCode.OK, result: ok({ missing_from_index, ghost_entries }) };
1347
+ return { exitCode: ExitCode.OK, result: ok({ missing_from_index, ghost_entries, humanHint: hintLines.join("\n") }) };
1074
1348
  }
1075
1349
 
1076
1350
  // src/commands/stale.ts
@@ -1110,8 +1384,8 @@ async function runStale(input) {
1110
1384
  stale.push({ page: p.relPath, page_updated: updated, newest_source_ingested: newest, gap_days: gap });
1111
1385
  }
1112
1386
  }
1113
- if (stale.length > 0) return { exitCode: ExitCode.STALE_PAGE, result: ok({ stale }) };
1114
- return { exitCode: ExitCode.OK, result: ok({ stale }) };
1387
+ if (stale.length > 0) return { exitCode: ExitCode.STALE_PAGE, result: ok({ stale, humanHint: stale.map((s) => `${s.page} (${s.gap_days}d stale)`).join("\n") }) };
1388
+ return { exitCode: ExitCode.OK, result: ok({ stale, humanHint: "no stale pages" }) };
1115
1389
  }
1116
1390
 
1117
1391
  // src/commands/pagesize.ts
@@ -1126,17 +1400,17 @@ async function runPagesize(input) {
1126
1400
  const count = body.split("\n").length;
1127
1401
  if (count > input.lines) oversized.push({ page: p.relPath, lines: count });
1128
1402
  }
1129
- if (oversized.length > 0) return { exitCode: ExitCode.PAGE_TOO_LARGE, result: ok({ oversized }) };
1130
- return { exitCode: ExitCode.OK, result: ok({ oversized }) };
1403
+ if (oversized.length > 0) return { exitCode: ExitCode.PAGE_TOO_LARGE, result: ok({ oversized, humanHint: oversized.map((p) => `${p.page}: ${p.lines} lines`).join("\n") }) };
1404
+ return { exitCode: ExitCode.OK, result: ok({ oversized, humanHint: "all pages within size limit" }) };
1131
1405
  }
1132
1406
 
1133
1407
  // src/commands/log-rotate.ts
1134
- import { readFile as readFile10, rename as rename2, writeFile as writeFile4, stat as stat6 } from "fs/promises";
1408
+ import { readFile as readFile10, rename as rename2, writeFile as writeFile5, stat as stat5 } from "fs/promises";
1135
1409
  import { join as join11 } from "path";
1136
1410
  var ENTRY_RE = /^## \[(\d{4})-\d{2}-\d{2}\]/gm;
1137
1411
  async function runLogRotate(input) {
1138
1412
  try {
1139
- await stat6(join11(input.vault, "SCHEMA.md"));
1413
+ await stat5(join11(input.vault, "SCHEMA.md"));
1140
1414
  } catch {
1141
1415
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
1142
1416
  }
@@ -1150,12 +1424,12 @@ async function runLogRotate(input) {
1150
1424
  const matches = [...logText.matchAll(ENTRY_RE)];
1151
1425
  const entries = matches.length;
1152
1426
  if (entries < input.threshold) {
1153
- return { exitCode: ExitCode.OK, result: ok({ entries, threshold: input.threshold, rotated: false }) };
1427
+ return { exitCode: ExitCode.OK, result: ok({ entries, threshold: input.threshold, rotated: false, humanHint: `${entries}/${input.threshold} entries \u2014 no rotation needed` }) };
1154
1428
  }
1155
1429
  if (!input.apply) {
1156
1430
  return {
1157
1431
  exitCode: ExitCode.LOG_ROTATE_NEEDED,
1158
- result: ok({ entries, threshold: input.threshold, rotated: false })
1432
+ result: ok({ entries, threshold: input.threshold, rotated: false, humanHint: `${entries}/${input.threshold} entries \u2014 rotation needed (use --apply)` })
1159
1433
  };
1160
1434
  }
1161
1435
  const newestYear = matches[matches.length - 1][1];
@@ -1172,17 +1446,88 @@ Chronological action log. Newest entries last. Skill writes append entries; lint
1172
1446
 
1173
1447
  - Previous log moved to ${rotatedName}
1174
1448
  `;
1175
- await writeFile4(logPath, fresh, "utf8");
1449
+ await writeFile5(logPath, fresh, "utf8");
1176
1450
  } catch (e) {
1177
1451
  return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
1178
1452
  }
1179
- return { exitCode: ExitCode.OK, result: ok({ entries, threshold: input.threshold, rotated: true, rotated_to: rotatedName }) };
1453
+ return { exitCode: ExitCode.OK, result: ok({ entries, threshold: input.threshold, rotated: true, rotated_to: rotatedName, humanHint: `rotated ${entries} entries to ${rotatedName}` }) };
1454
+ }
1455
+
1456
+ // src/commands/topic-map-check.ts
1457
+ var DEFAULT_THRESHOLD = 200;
1458
+ async function runTopicMapCheck(input) {
1459
+ const threshold = input.threshold ?? DEFAULT_THRESHOLD;
1460
+ const scan = await scanVault(input.vault);
1461
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1462
+ const page_count = scan.data.typedKnowledge.length;
1463
+ const recommended = page_count >= threshold;
1464
+ return {
1465
+ exitCode: ExitCode.OK,
1466
+ result: ok({
1467
+ recommended,
1468
+ page_count,
1469
+ threshold,
1470
+ humanHint: recommended ? `topic map recommended (${page_count} pages >= ${threshold} threshold)` : `topic map not needed (${page_count} pages < ${threshold} threshold)`
1471
+ })
1472
+ };
1473
+ }
1474
+
1475
+ // src/commands/index-link-format.ts
1476
+ import { readFile as readFile11 } from "fs/promises";
1477
+ import { join as join12 } from "path";
1478
+ var MD_LINK_RE = /\[[^\[\]]+\]\([^)]+\.md\)/;
1479
+ async function runIndexLinkFormat(input) {
1480
+ let text = "";
1481
+ try {
1482
+ text = await readFile11(join12(input.vault, "index.md"), "utf8");
1483
+ } catch {
1484
+ }
1485
+ const markdown_links = [];
1486
+ for (const [i, line] of text.split("\n").entries()) {
1487
+ if (MD_LINK_RE.test(line)) markdown_links.push({ line: i + 1, text: line.trim() });
1488
+ }
1489
+ const humanHint = markdown_links.length === 0 ? "all index links use wikilink format" : `markdown links found: ${markdown_links.length}
1490
+ ${markdown_links.map((l) => ` line ${l.line}: ${l.text}`).join("\n")}`;
1491
+ return { exitCode: ExitCode.OK, result: ok({ markdown_links, humanHint }) };
1492
+ }
1493
+
1494
+ // src/commands/dedup.ts
1495
+ async function runDedup(input) {
1496
+ const scan = await scanVault(input.vault);
1497
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1498
+ const hashMap = /* @__PURE__ */ new Map();
1499
+ let totalFiles = 0;
1500
+ for (const raw of scan.data.raw) {
1501
+ const fm = extractFrontmatter(await readPage(raw));
1502
+ if (!fm.ok) continue;
1503
+ const sha = typeof fm.data.sha256 === "string" ? fm.data.sha256 : null;
1504
+ if (!sha || sha.length !== 64) continue;
1505
+ totalFiles++;
1506
+ const existing = hashMap.get(sha);
1507
+ if (existing) existing.push(raw.relPath);
1508
+ else hashMap.set(sha, [raw.relPath]);
1509
+ }
1510
+ const duplicates = [...hashMap.entries()].filter(([, files]) => files.length > 1).map(([sha256, files]) => ({ sha256, files }));
1511
+ const exitCode = duplicates.length > 0 ? ExitCode.RAW_DEDUP_DETECTED : ExitCode.OK;
1512
+ const hintLines = [`scanned: ${totalFiles} raw files`];
1513
+ if (duplicates.length > 0) {
1514
+ hintLines.push(`duplicates: ${duplicates.length}`);
1515
+ for (const d of duplicates) hintLines.push(` ${d.sha256.slice(0, 12)}... \u2192 ${d.files.join(", ")}`);
1516
+ } else {
1517
+ hintLines.push("0 duplicates");
1518
+ }
1519
+ return {
1520
+ exitCode,
1521
+ result: ok({ scanned: totalFiles, duplicates, humanHint: hintLines.join("\n") })
1522
+ };
1180
1523
  }
1181
1524
 
1182
1525
  // src/commands/lint.ts
1183
- var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_drift", "tag_not_in_taxonomy"];
1184
- var WARNING_ORDER = ["index_incomplete", "stale_page", "page_too_large", "log_rotate_needed", "contested", "orphans"];
1185
- var INFO_ORDER = ["bridges", "low_confidence_single_source"];
1526
+ var STRUCT_MIN_BODY_LINES = 60;
1527
+ var STRUCT_MIN_SECTIONS = 3;
1528
+ var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_dedup", "tag_not_in_taxonomy"];
1529
+ var WARNING_ORDER = ["index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "contested", "orphans", "legacy_citation_style", "orphaned_citations"];
1530
+ var INFO_ORDER = ["bridges", "low_confidence_single_source", "page_structure", "topic_map_recommended"];
1186
1531
  async function runLint(input) {
1187
1532
  const buckets = {};
1188
1533
  const links = await runLinks({ vault: input.vault });
@@ -1202,6 +1547,10 @@ async function runLint(input) {
1202
1547
  ghost_entries: idx.result.data.ghost_entries
1203
1548
  }];
1204
1549
  }
1550
+ const linkFmt = await runIndexLinkFormat({ vault: input.vault });
1551
+ if (linkFmt.result.ok && linkFmt.result.data.markdown_links.length > 0) {
1552
+ buckets.index_link_format = linkFmt.result.data.markdown_links;
1553
+ }
1205
1554
  const stale = await runStale({ vault: input.vault, days: input.days });
1206
1555
  if (stale.result.ok && stale.result.data.stale.length > 0) buckets.stale_page = stale.result.data.stale;
1207
1556
  const pagesize = await runPagesize({ vault: input.vault, lines: input.lines });
@@ -1215,6 +1564,42 @@ async function runLint(input) {
1215
1564
  if (orphans.result.data.orphans.length > 0) buckets.orphans = orphans.result.data.orphans;
1216
1565
  if (orphans.result.data.bridges.length > 0) buckets.bridges = orphans.result.data.bridges;
1217
1566
  }
1567
+ const topicMap = await runTopicMapCheck({ vault: input.vault });
1568
+ if (topicMap.result.ok && topicMap.result.data.recommended) {
1569
+ buckets.topic_map_recommended = [{ page_count: topicMap.result.data.page_count, threshold: topicMap.result.data.threshold }];
1570
+ }
1571
+ const dedup = await runDedup({ vault: input.vault });
1572
+ if (dedup.result.ok && dedup.result.data.duplicates.length > 0) buckets.raw_dedup = dedup.result.data.duplicates;
1573
+ const scan = await scanVault(input.vault);
1574
+ if (scan.ok) {
1575
+ const legacyPages = [];
1576
+ const orphanedPages = [];
1577
+ const structFlags = [];
1578
+ for (const page of scan.data.typedKnowledge) {
1579
+ const text = await readPage(page);
1580
+ const split = splitFrontmatter(text);
1581
+ if (!split.ok) continue;
1582
+ const body = split.data.body;
1583
+ if (isLegacyCitationStyle(body)) legacyPages.push(page.relPath);
1584
+ if (hasOrphanedCitations(body)) orphanedPages.push(page.relPath);
1585
+ const bodyLines = body.split("\n").filter((l) => l.trim().length > 0).length;
1586
+ if (bodyLines < STRUCT_MIN_BODY_LINES) {
1587
+ const hasOverview = /^## Overview/m.test(body);
1588
+ const hasRelated = /^## (Related|Relationships)/m.test(body);
1589
+ const sectionCount = (body.match(/^## /gm) ?? []).length;
1590
+ if (!hasOverview || !hasRelated || sectionCount < STRUCT_MIN_SECTIONS) {
1591
+ const reasons = [];
1592
+ if (!hasOverview) reasons.push("no Overview");
1593
+ if (!hasRelated) reasons.push("no Related or Relationships");
1594
+ if (sectionCount < STRUCT_MIN_SECTIONS) reasons.push(`only ${sectionCount} sections`);
1595
+ structFlags.push(`${page.relPath}: ${bodyLines} lines, ${reasons.join(", ")}`);
1596
+ }
1597
+ }
1598
+ }
1599
+ if (legacyPages.length > 0) buckets.legacy_citation_style = legacyPages;
1600
+ if (orphanedPages.length > 0) buckets.orphaned_citations = orphanedPages;
1601
+ if (structFlags.length > 0) buckets.page_structure = structFlags;
1602
+ }
1218
1603
  const errorOut = ERROR_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
1219
1604
  const warningOut = WARNING_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
1220
1605
  const infoOut = INFO_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
@@ -1226,19 +1611,629 @@ async function runLint(input) {
1226
1611
  let exitCode = ExitCode.OK;
1227
1612
  if (summary.errors > 0) exitCode = ExitCode.LINT_HAS_ERRORS;
1228
1613
  else if (summary.warnings > 0 || summary.info > 0) exitCode = ExitCode.LINT_HAS_WARNINGS;
1614
+ const hintLines = [];
1615
+ if (summary.errors > 0) hintLines.push(`errors: ${summary.errors}`);
1616
+ if (summary.warnings > 0) hintLines.push(`warnings: ${summary.warnings}`);
1617
+ if (summary.info > 0) hintLines.push(`info: ${summary.info}`);
1618
+ const allBuckets = [...errorOut, ...warningOut, ...infoOut];
1619
+ for (const b of allBuckets) {
1620
+ hintLines.push(` ${b.kind}: ${b.items.length}`);
1621
+ }
1622
+ if (hintLines.length === 0) hintLines.push("0 errors, 0 warnings, 0 info");
1229
1623
  return {
1230
1624
  exitCode,
1231
1625
  result: ok({
1232
1626
  vault: { path: input.vault, source: input.source ?? "resolved" },
1233
1627
  summary,
1234
- by_severity: { error: errorOut, warning: warningOut, info: infoOut }
1628
+ by_severity: { error: errorOut, warning: warningOut, info: infoOut },
1629
+ humanHint: hintLines.join("\n")
1630
+ })
1631
+ };
1632
+ }
1633
+
1634
+ // src/commands/config.ts
1635
+ import { readFile as readFile12 } from "fs/promises";
1636
+ import { existsSync } from "fs";
1637
+ import { join as join13 } from "path";
1638
+ function validateKey(key) {
1639
+ return CONFIG_KEYS.includes(key);
1640
+ }
1641
+ function configPath(home) {
1642
+ return join13(home, ".skillwiki", ".env");
1643
+ }
1644
+ async function runConfigGet(input) {
1645
+ if (!validateKey(input.key)) {
1646
+ return { exitCode: ExitCode.INVALID_CONFIG_KEY, result: err("INVALID_CONFIG_KEY", { key: input.key }) };
1647
+ }
1648
+ const map = await parseDotenvFile(configPath(input.home));
1649
+ const value = map[input.key] ?? "";
1650
+ return { exitCode: ExitCode.OK, result: ok({ key: input.key, value, humanHint: value }) };
1651
+ }
1652
+ async function runConfigSet(input) {
1653
+ if (!validateKey(input.key)) {
1654
+ return { exitCode: ExitCode.INVALID_CONFIG_KEY, result: err("INVALID_CONFIG_KEY", { key: input.key }) };
1655
+ }
1656
+ const filePath = configPath(input.home);
1657
+ try {
1658
+ let originalContent;
1659
+ try {
1660
+ originalContent = await readFile12(filePath, "utf8");
1661
+ } catch {
1662
+ }
1663
+ const existing = originalContent !== void 0 ? parseDotenvText(originalContent) : {};
1664
+ const merged = { ...existing, [input.key]: input.value };
1665
+ await writeDotenv(filePath, merged, originalContent);
1666
+ return { exitCode: ExitCode.OK, result: ok({ key: input.key, value: input.value, written: true, humanHint: `${input.key}=${input.value}` }) };
1667
+ } catch (e) {
1668
+ return { exitCode: ExitCode.CONFIG_WRITE_FAILED, result: err("CONFIG_WRITE_FAILED", { key: input.key, error: String(e) }) };
1669
+ }
1670
+ }
1671
+ async function runConfigList(input) {
1672
+ const map = await parseDotenvFile(configPath(input.home));
1673
+ const entries = Object.entries(map).map(([key, value]) => ({ key, value: value ?? "" }));
1674
+ return { exitCode: ExitCode.OK, result: ok({ entries, humanHint: entries.map((e) => `${e.key}=${e.value}`).join("\n") }) };
1675
+ }
1676
+ async function runConfigPath(input) {
1677
+ const filePath = configPath(input.home);
1678
+ return { exitCode: ExitCode.OK, result: ok({ path: filePath, exists: existsSync(filePath), humanHint: filePath }) };
1679
+ }
1680
+
1681
+ // src/commands/doctor.ts
1682
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
1683
+ import { join as join15 } from "path";
1684
+ import { execSync } from "child_process";
1685
+
1686
+ // src/utils/auto-update.ts
1687
+ import { readFileSync, writeFileSync, existsSync as existsSync2, mkdirSync } from "fs";
1688
+ import { join as join14, dirname as dirname6 } from "path";
1689
+ import { spawn } from "child_process";
1690
+
1691
+ // src/utils/update-consts.ts
1692
+ var CACHE_FILENAME = ".update-cache.json";
1693
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1694
+ var ENV_DISABLE_KEY = "NO_UPDATE_NOTIFIER";
1695
+ var CLI_DISABLE_FLAG = "--no-update-notifier";
1696
+
1697
+ // src/utils/auto-update.ts
1698
+ function cachePath(home) {
1699
+ return join14(home, ".skillwiki", CACHE_FILENAME);
1700
+ }
1701
+ function readCacheRaw(home) {
1702
+ try {
1703
+ const raw = readFileSync(cachePath(home), "utf8");
1704
+ return JSON.parse(raw);
1705
+ } catch {
1706
+ return null;
1707
+ }
1708
+ }
1709
+ function readCache(home) {
1710
+ const cache = readCacheRaw(home);
1711
+ if (!cache) return { cache: null, hasUpdate: false, isStale: true };
1712
+ const isStale = Date.now() - cache.lastCheck >= CHECK_INTERVAL_MS;
1713
+ const hasUpdate = !!cache.latestVersion && semverGt(cache.latestVersion, cache.currentVersion);
1714
+ return { cache, hasUpdate, isStale };
1715
+ }
1716
+ function writeCache(home, cache) {
1717
+ const p = cachePath(home);
1718
+ mkdirSync(dirname6(p), { recursive: true });
1719
+ writeFileSync(p, JSON.stringify(cache, null, 2));
1720
+ }
1721
+ function latestFromCache(home, currentVersion) {
1722
+ const { cache } = readCache(home);
1723
+ if (!cache || !cache.latestVersion) return { hasUpdate: false, latest: null };
1724
+ return {
1725
+ hasUpdate: semverGt(cache.latestVersion, currentVersion),
1726
+ latest: cache.latestVersion
1727
+ };
1728
+ }
1729
+ function isDisabled() {
1730
+ return !!(process.env[ENV_DISABLE_KEY] || process.env.NODE_ENV === "test" || process.argv.includes(CLI_DISABLE_FLAG));
1731
+ }
1732
+ function triggerAutoUpdate(home, currentVersion) {
1733
+ if (isDisabled()) return;
1734
+ const { isStale } = readCache(home);
1735
+ if (!isStale) return;
1736
+ const bgScript = new URL("../auto-update-bg.js", import.meta.url).pathname;
1737
+ if (!existsSync2(bgScript)) return;
1738
+ const child = spawn(process.execPath, [bgScript, home, currentVersion], {
1739
+ detached: true,
1740
+ stdio: "ignore"
1741
+ });
1742
+ child.on("error", () => {
1743
+ });
1744
+ child.unref();
1745
+ }
1746
+
1747
+ // src/commands/doctor.ts
1748
+ function check(status, id, label, detail) {
1749
+ return { id, label, status, detail };
1750
+ }
1751
+ function checkNodeVersion() {
1752
+ const major = parseInt(process.version.slice(1).split(".")[0], 10);
1753
+ if (major >= 20) {
1754
+ return check("pass", "node_version", "Node.js version", `v${major} >= 20`);
1755
+ }
1756
+ return check("error", "node_version", "Node.js version", `Node.js v${major} is below minimum v20`);
1757
+ }
1758
+ function checkCliOnPath(argv) {
1759
+ if (argv.length >= 2 && argv[1].endsWith("cli.js")) {
1760
+ return check("warn", "cli_on_path", "skillwiki on PATH", "Running via node cli.js (dev mode) \u2014 PATH check skipped");
1761
+ }
1762
+ if (argv.length >= 2 && argv[1] === "skillwiki") {
1763
+ return check("pass", "cli_on_path", "skillwiki on PATH", "Running as skillwiki \u2014 already on PATH");
1764
+ }
1765
+ try {
1766
+ execSync("which skillwiki 2>/dev/null", { encoding: "utf8" }).trim();
1767
+ return check("pass", "cli_on_path", "skillwiki on PATH", "skillwiki found on PATH");
1768
+ } catch {
1769
+ return check("warn", "cli_on_path", "skillwiki on PATH", "skillwiki not found on PATH");
1770
+ }
1771
+ }
1772
+ async function checkConfigFile(home) {
1773
+ const cfgPath = configPath(home);
1774
+ if (!existsSync3(cfgPath)) {
1775
+ return check("warn", "config_file", "Config file exists", `${cfgPath} not found`);
1776
+ }
1777
+ try {
1778
+ const map = await parseDotenvFile(cfgPath);
1779
+ const keys = Object.keys(map);
1780
+ return check("pass", "config_file", "Config file exists", `Found with keys: ${keys.length > 0 ? keys.join(", ") : "(none set)"}`);
1781
+ } catch (e) {
1782
+ return check("warn", "config_file", "Config file exists", `Failed to parse ${cfgPath}: ${String(e)}`);
1783
+ }
1784
+ }
1785
+ function checkWikiPathExists(resolvedPath) {
1786
+ if (resolvedPath === void 0) {
1787
+ return check("error", "wiki_path_exists", "Vault directory exists", "Cannot check \u2014 WIKI_PATH not resolved");
1788
+ }
1789
+ if (existsSync3(resolvedPath) && statSync(resolvedPath).isDirectory()) {
1790
+ return check("pass", "wiki_path_exists", "Vault directory exists", resolvedPath);
1791
+ }
1792
+ return check("error", "wiki_path_exists", "Vault directory exists", `${resolvedPath} does not exist or is not a directory`);
1793
+ }
1794
+ function checkVaultStructure(resolvedPath) {
1795
+ if (resolvedPath === void 0) {
1796
+ return check("error", "vault_structure", "Vault structure valid", "Cannot check \u2014 WIKI_PATH not resolved");
1797
+ }
1798
+ if (!existsSync3(resolvedPath)) {
1799
+ return check("error", "vault_structure", "Vault structure valid", "Cannot check \u2014 vault directory does not exist");
1800
+ }
1801
+ const missing = [];
1802
+ if (!existsSync3(join15(resolvedPath, "SCHEMA.md"))) missing.push("SCHEMA.md");
1803
+ for (const dir of ["raw", "entities", "concepts", "meta"]) {
1804
+ if (!existsSync3(join15(resolvedPath, dir))) missing.push(dir + "/");
1805
+ }
1806
+ if (missing.length === 0) {
1807
+ return check("pass", "vault_structure", "Vault structure valid", "All required files and directories present");
1808
+ }
1809
+ return check("error", "vault_structure", "Vault structure valid", `Missing: ${missing.join(", ")}`);
1810
+ }
1811
+ function checkSkillsInstalled(home) {
1812
+ const skillsDir = join15(home, ".claude", "skills");
1813
+ if (!existsSync3(skillsDir)) {
1814
+ return check("warn", "skills_installed", "Skills installed", `${skillsDir} not found`);
1815
+ }
1816
+ const found = findSkillMd(skillsDir);
1817
+ if (found.length > 0) {
1818
+ return check("pass", "skills_installed", "Skills installed", `${found.length} SKILL.md file(s) found`);
1819
+ }
1820
+ return check("warn", "skills_installed", "Skills installed", "No SKILL.md files found in ~/.claude/skills/");
1821
+ }
1822
+ function checkNpmUpdate(home, currentVersion) {
1823
+ const { hasUpdate, latest } = latestFromCache(home, currentVersion);
1824
+ if (!latest) {
1825
+ return check("pass", "npm_update", "npm CLI version", `v${currentVersion} (no cache yet)`);
1826
+ }
1827
+ if (hasUpdate) {
1828
+ return check("warn", "npm_update", "npm CLI version", `v${currentVersion} \u2014 update available: v${latest}. Run \`skillwiki update\`.`);
1829
+ }
1830
+ return check("pass", "npm_update", "npm CLI version", `v${currentVersion} (latest: v${latest})`);
1831
+ }
1832
+ function checkPluginVersionDrift(home, currentVersion) {
1833
+ const pluginJsonPath = join15(home, ".claude", "plugins", "cache", "llm-wiki", "plugin.json");
1834
+ if (!existsSync3(pluginJsonPath)) {
1835
+ return check("pass", "plugin_version_drift", "Plugin/CLI version", "Plugin cache not found \u2014 plugin not installed");
1836
+ }
1837
+ try {
1838
+ const content = readFileSync2(pluginJsonPath, { encoding: "utf8" });
1839
+ const pluginData = JSON.parse(content);
1840
+ const pluginVersion = pluginData.version;
1841
+ if (!pluginVersion) {
1842
+ return check("pass", "plugin_version_drift", "Plugin/CLI version", "Plugin version not found in cache");
1843
+ }
1844
+ if (pluginVersion === currentVersion) {
1845
+ return check("pass", "plugin_version_drift", "Plugin/CLI version", `Both at v${currentVersion}`);
1846
+ }
1847
+ const updateCmd = semverGt(pluginVersion, currentVersion) ? "npm install -g skillwiki@beta" : "claude plugin update skillwiki@llm-wiki";
1848
+ return check(
1849
+ "warn",
1850
+ "plugin_version_drift",
1851
+ "Plugin/CLI version",
1852
+ `Plugin v${pluginVersion} \u2260 CLI v${currentVersion} \u2014 run \`${updateCmd}\``
1853
+ );
1854
+ } catch {
1855
+ return check("pass", "plugin_version_drift", "Plugin/CLI version", "Could not read plugin cache");
1856
+ }
1857
+ }
1858
+ function findSkillMd(dir) {
1859
+ const results = [];
1860
+ let entries;
1861
+ try {
1862
+ entries = readdirSync(dir, { withFileTypes: true });
1863
+ } catch {
1864
+ return results;
1865
+ }
1866
+ for (const entry of entries) {
1867
+ if (entry.isFile() && entry.name === "SKILL.md") {
1868
+ results.push(join15(dir, entry.name));
1869
+ } else if (entry.isDirectory()) {
1870
+ results.push(...findSkillMd(join15(dir, entry.name)));
1871
+ }
1872
+ }
1873
+ return results;
1874
+ }
1875
+ async function runDoctor(input) {
1876
+ const checks = [];
1877
+ checks.push(checkNodeVersion());
1878
+ checks.push(checkCliOnPath(input.argv));
1879
+ checks.push(await checkConfigFile(input.home));
1880
+ const resolved = await resolveRuntimePath({ flag: void 0, envValue: input.envValue, home: input.home });
1881
+ if (resolved.ok) {
1882
+ checks.push(check("pass", "wiki_path_set", "WIKI_PATH configured", `Resolved via ${resolved.data.source}: ${resolved.data.path}`));
1883
+ } else {
1884
+ checks.push(check("error", "wiki_path_set", "WIKI_PATH configured", "No vault configured. Run `skillwiki init` or pass --vault."));
1885
+ }
1886
+ const resolvedPath = resolved.ok ? resolved.data.path : void 0;
1887
+ checks.push(checkWikiPathExists(resolvedPath));
1888
+ checks.push(checkVaultStructure(resolvedPath));
1889
+ checks.push(checkSkillsInstalled(input.home));
1890
+ checks.push(checkNpmUpdate(input.home, input.currentVersion));
1891
+ checks.push(checkPluginVersionDrift(input.home, input.currentVersion));
1892
+ const summary = {
1893
+ pass: checks.filter((c) => c.status === "pass").length,
1894
+ warn: checks.filter((c) => c.status === "warn").length,
1895
+ error: checks.filter((c) => c.status === "error").length
1896
+ };
1897
+ const exitCode = summary.error > 0 ? ExitCode.DOCTOR_HAS_ERRORS : summary.warn > 0 ? ExitCode.DOCTOR_HAS_WARNINGS : ExitCode.OK;
1898
+ const statusIcon = { pass: "\u2713", warn: "\u26A0", error: "\u2717" };
1899
+ const lines = checks.map((c) => {
1900
+ const icon = statusIcon[c.status];
1901
+ const padded = c.label.padEnd(24);
1902
+ return ` ${icon} ${padded} ${c.detail}`;
1903
+ });
1904
+ lines.push("");
1905
+ lines.push(`${summary.pass} pass \xB7 ${summary.warn} warn \xB7 ${summary.error} error`);
1906
+ const humanHint = lines.join("\n");
1907
+ return { exitCode, result: ok({ checks, summary, humanHint }) };
1908
+ }
1909
+
1910
+ // src/commands/archive.ts
1911
+ import { rename as rename3, mkdir as mkdir5, readFile as readFile13, writeFile as writeFile6 } from "fs/promises";
1912
+ import { join as join16, dirname as dirname7 } from "path";
1913
+ async function runArchive(input) {
1914
+ const scan = await scanVault(input.vault);
1915
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1916
+ let relPath;
1917
+ if (input.page.includes("/")) {
1918
+ relPath = scan.data.typedKnowledge.find((p) => p.relPath === input.page)?.relPath;
1919
+ } else {
1920
+ relPath = scan.data.typedKnowledge.find((p) => p.relPath.replace(/\.md$/, "").split("/").pop() === input.page)?.relPath;
1921
+ }
1922
+ if (!relPath) return { exitCode: ExitCode.ARCHIVE_TARGET_NOT_FOUND, result: err("ARCHIVE_TARGET_NOT_FOUND", { page: input.page }) };
1923
+ if (relPath.startsWith("_archive/")) return { exitCode: ExitCode.ARCHIVE_ALREADY_ARCHIVED, result: err("ARCHIVE_ALREADY_ARCHIVED", { page: relPath }) };
1924
+ const archivePath = join16("_archive", relPath);
1925
+ await mkdir5(dirname7(join16(input.vault, archivePath)), { recursive: true });
1926
+ let indexUpdated = false;
1927
+ const indexPath = join16(input.vault, "index.md");
1928
+ try {
1929
+ const idx = await readFile13(indexPath, "utf8");
1930
+ const slug = relPath.replace(/\.md$/, "").split("/").pop();
1931
+ const originalLines = idx.split("\n");
1932
+ const filtered = originalLines.filter((l) => !l.includes(`[[${slug}]]`));
1933
+ if (filtered.length !== originalLines.length) {
1934
+ await writeFile6(indexPath, filtered.join("\n"), "utf8");
1935
+ indexUpdated = true;
1936
+ }
1937
+ } catch (e) {
1938
+ if (e?.code !== "ENOENT") throw e;
1939
+ }
1940
+ await rename3(join16(input.vault, relPath), join16(input.vault, archivePath));
1941
+ return { exitCode: ExitCode.OK, result: ok({ archived_from: relPath, archived_to: archivePath, index_updated: indexUpdated, humanHint: `${relPath} -> ${archivePath}${indexUpdated ? " (index updated)" : ""}` }) };
1942
+ }
1943
+
1944
+ // src/commands/drift.ts
1945
+ import { createHash as createHash2 } from "crypto";
1946
+
1947
+ // src/utils/fetch.ts
1948
+ async function controlledFetch(url, opts) {
1949
+ let current = url;
1950
+ for (let hop = 0; hop <= opts.maxRedirects; hop++) {
1951
+ const guard = runFetchGuardSync({ url: current });
1952
+ if (!guard.result.ok) return guard.result;
1953
+ const ctrl = new AbortController();
1954
+ const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs);
1955
+ let res;
1956
+ try {
1957
+ res = await fetch(current, { redirect: "manual", signal: ctrl.signal });
1958
+ } catch (e) {
1959
+ clearTimeout(timer);
1960
+ if (e?.name === "AbortError") return err("FETCH_TIMEOUT", { url: current });
1961
+ return err("FETCH_FAILED", { message: String(e) });
1962
+ }
1963
+ clearTimeout(timer);
1964
+ if (res.status >= 300 && res.status < 400) {
1965
+ const loc = res.headers.get("location");
1966
+ if (!loc) return err("FETCH_FAILED", { reason: "redirect without Location" });
1967
+ current = new URL(loc, current).toString();
1968
+ continue;
1969
+ }
1970
+ const declared = Number(res.headers.get("content-length") ?? "0");
1971
+ if (declared > opts.maxBytes) return err("FETCH_TOO_LARGE", { declared, limit: opts.maxBytes });
1972
+ const buf = new Uint8Array(await res.arrayBuffer());
1973
+ if (buf.byteLength > opts.maxBytes) return err("FETCH_TOO_LARGE", { actual: buf.byteLength, limit: opts.maxBytes });
1974
+ return ok({ url: current, status: res.status, body: new TextDecoder().decode(buf), bytes: buf.byteLength });
1975
+ }
1976
+ return err("FETCH_FAILED", { reason: "too many redirects", limit: opts.maxRedirects });
1977
+ }
1978
+
1979
+ // src/commands/drift.ts
1980
+ var FETCH_OPTS = { timeoutMs: 1e4, maxBytes: 5e6, maxRedirects: 5 };
1981
+ async function runDrift(input) {
1982
+ const doFetch = input.fetchFn ?? controlledFetch;
1983
+ const scan = await scanVault(input.vault);
1984
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1985
+ const results = [];
1986
+ for (const raw of scan.data.raw) {
1987
+ const fm = extractFrontmatter(await readPage(raw));
1988
+ if (!fm.ok) continue;
1989
+ const sourceUrl = typeof fm.data.source_url === "string" ? fm.data.source_url : null;
1990
+ const storedHash = typeof fm.data.sha256 === "string" ? fm.data.sha256 : null;
1991
+ if (!sourceUrl || !storedHash) continue;
1992
+ const resp = await doFetch(sourceUrl, FETCH_OPTS);
1993
+ if (!resp.ok) {
1994
+ results.push({
1995
+ raw_path: raw.relPath,
1996
+ source_url: sourceUrl,
1997
+ stored_sha256: storedHash,
1998
+ current_sha256: null,
1999
+ status: "fetch_failed",
2000
+ fetch_error: resp.error
2001
+ });
2002
+ continue;
2003
+ }
2004
+ const currentHash = createHash2("sha256").update(Buffer.from(resp.data.body, "utf8")).digest("hex");
2005
+ const drifted2 = currentHash !== storedHash;
2006
+ results.push({
2007
+ raw_path: raw.relPath,
2008
+ source_url: sourceUrl,
2009
+ stored_sha256: storedHash,
2010
+ current_sha256: currentHash,
2011
+ status: drifted2 ? "drifted" : "unchanged"
2012
+ });
2013
+ }
2014
+ const drifted = results.filter((r) => r.status === "drifted");
2015
+ const fetchFailed = results.filter((r) => r.status === "fetch_failed");
2016
+ const unchanged = results.filter((r) => r.status === "unchanged").length;
2017
+ const exitCode = drifted.length > 0 ? ExitCode.DRIFT_DETECTED : ExitCode.OK;
2018
+ const hintLines = [`scanned: ${results.length}, unchanged: ${unchanged}`];
2019
+ if (drifted.length > 0) hintLines.push(`drifted: ${drifted.length}`, ...drifted.map((d) => ` ${d.raw_path}`));
2020
+ if (fetchFailed.length > 0) hintLines.push(`fetch_failed: ${fetchFailed.length}`, ...fetchFailed.map((f) => ` ${f.raw_path}: ${f.fetch_error}`));
2021
+ return {
2022
+ exitCode,
2023
+ result: ok({ scanned: results.length, drifted, fetch_failed: fetchFailed, unchanged, humanHint: hintLines.join("\n") })
2024
+ };
2025
+ }
2026
+
2027
+ // src/commands/migrate-citations.ts
2028
+ import { writeFile as writeFile7 } from "fs/promises";
2029
+ var MARKER_RE2 = /\^\[(raw\/[^\]]+)\]/g;
2030
+ function moveMarkersToParagraphEnd(body) {
2031
+ const lines = body.split("\n");
2032
+ const result = [];
2033
+ for (let i = 0; i < lines.length; i++) {
2034
+ const line = lines[i];
2035
+ if (line.trimStart().startsWith("```")) {
2036
+ result.push(line);
2037
+ if (!line.trimEnd().endsWith("```") || line.trim() === "```") {
2038
+ for (let j = i + 1; j < lines.length; j++) {
2039
+ result.push(lines[j]);
2040
+ if (lines[j].trimStart().startsWith("```")) {
2041
+ i = j;
2042
+ break;
2043
+ }
2044
+ }
2045
+ }
2046
+ continue;
2047
+ }
2048
+ if (/^## Sources\b/.test(line.trim())) {
2049
+ result.push(line);
2050
+ continue;
2051
+ }
2052
+ const markers = [...line.matchAll(MARKER_RE2)];
2053
+ if (markers.length === 0) {
2054
+ result.push(line);
2055
+ continue;
2056
+ }
2057
+ const proseOnly = line.replace(MARKER_RE2, "").trim();
2058
+ if (proseOnly.length === 0) {
2059
+ const markerStr = " " + markers.map((m) => m[0]).join(" ");
2060
+ let merged = false;
2061
+ for (let k = result.length - 1; k >= 0; k--) {
2062
+ if (result[k].trim().length > 0) {
2063
+ result[k] = result[k].trimEnd() + markerStr;
2064
+ merged = true;
2065
+ break;
2066
+ }
2067
+ }
2068
+ if (!merged) result.push(line);
2069
+ continue;
2070
+ }
2071
+ const lastMarkerIdx = line.lastIndexOf("^[raw/");
2072
+ const afterLast = line.slice(lastMarkerIdx).replace(MARKER_RE2, "").trim();
2073
+ const firstMarkerIdx = line.indexOf("^[raw/");
2074
+ const beforeFirst = line.slice(0, firstMarkerIdx).trim();
2075
+ const alreadyAtEnd = afterLast.length === 0 && (beforeFirst.length === 0 || /[.!?]\s*$/.test(beforeFirst));
2076
+ if (alreadyAtEnd) {
2077
+ result.push(line);
2078
+ continue;
2079
+ }
2080
+ let cleaned = line.replace(/\s*\^\[raw\/[^\]]+\]\s*/g, " ").trimEnd();
2081
+ cleaned = cleaned.replace(/ +/g, " ").trimEnd();
2082
+ const markerStrings = markers.map((m) => m[0]);
2083
+ if (cleaned.length > 0 && /[.!?]$/.test(cleaned)) {
2084
+ cleaned += " " + markerStrings.join(" ");
2085
+ } else if (cleaned.length > 0) {
2086
+ cleaned += ". " + markerStrings.join(" ");
2087
+ } else {
2088
+ cleaned = markerStrings.join(" ");
2089
+ }
2090
+ result.push(cleaned);
2091
+ }
2092
+ return result.join("\n");
2093
+ }
2094
+ function buildSourcesFooter(targets) {
2095
+ return "\n## Sources\n" + targets.map((t) => `- ^[${t}]`).join("\n") + "\n";
2096
+ }
2097
+ function reorderSourcesFm(rawFm, targets) {
2098
+ const sourcesLineRe = /^sources:\s*\[([^\]]*)\]\s*$/m;
2099
+ const match = rawFm.match(sourcesLineRe);
2100
+ if (!match) return rawFm;
2101
+ const existing = match[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter((s) => s.length > 0);
2102
+ const targetSet = new Set(targets);
2103
+ const reordered = [
2104
+ ...targets,
2105
+ ...existing.filter((s) => !targetSet.has(s))
2106
+ ];
2107
+ const newLine = `sources: [${reordered.join(", ")}]`;
2108
+ return rawFm.replace(sourcesLineRe, newLine);
2109
+ }
2110
+ function removeExistingFooter(body) {
2111
+ const footerRe = /\n## Sources\n[\s\S]*$/;
2112
+ return body.replace(footerRe, "");
2113
+ }
2114
+ async function runMigrateCitations(input) {
2115
+ const scan = await scanVault(input.vault);
2116
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
2117
+ const migrated = [];
2118
+ const skipped = [];
2119
+ let unchanged = 0;
2120
+ for (const page of scan.data.typedKnowledge) {
2121
+ const text = await readPage(page);
2122
+ const split = splitFrontmatter(text);
2123
+ if (!split.ok) continue;
2124
+ const { rawFrontmatter, body } = split.data;
2125
+ const markers = extractCitationMarkers(body);
2126
+ if (markers.length === 0) {
2127
+ unchanged++;
2128
+ continue;
2129
+ }
2130
+ const seen = /* @__PURE__ */ new Set();
2131
+ const uniqueTargets = [];
2132
+ for (const m of markers) {
2133
+ if (!seen.has(m.target)) {
2134
+ seen.add(m.target);
2135
+ uniqueTargets.push(m.target);
2136
+ }
2137
+ }
2138
+ const bodyWithoutFooter = removeExistingFooter(body);
2139
+ const migratedBody = moveMarkersToParagraphEnd(bodyWithoutFooter);
2140
+ const newFooter = buildSourcesFooter(uniqueTargets);
2141
+ const newFm = reorderSourcesFm(rawFrontmatter, uniqueTargets);
2142
+ const newText = `---
2143
+ ${newFm}
2144
+ ---
2145
+ ${migratedBody}${newFooter}`;
2146
+ if (newText === text) {
2147
+ skipped.push(page.relPath);
2148
+ continue;
2149
+ }
2150
+ if (!input.dryRun) {
2151
+ await writeFile7(page.absPath, newText, "utf8");
2152
+ }
2153
+ migrated.push(page.relPath);
2154
+ }
2155
+ const exitCode = migrated.length > 0 ? ExitCode.MIGRATION_APPLIED : ExitCode.OK;
2156
+ const hintLines = [`scanned: ${migrated.length + skipped.length + unchanged}`];
2157
+ if (migrated.length > 0) hintLines.push(`migrated: ${migrated.length}`);
2158
+ if (skipped.length > 0) hintLines.push(`skipped (already clean): ${skipped.length}`);
2159
+ if (unchanged > 0) hintLines.push(`unchanged (no markers): ${unchanged}`);
2160
+ return {
2161
+ exitCode,
2162
+ result: ok({
2163
+ scanned: migrated.length + skipped.length + unchanged,
2164
+ migrated,
2165
+ skipped,
2166
+ unchanged,
2167
+ humanHint: hintLines.join("\n")
2168
+ })
2169
+ };
2170
+ }
2171
+
2172
+ // src/commands/update.ts
2173
+ import { execSync as execSync2 } from "child_process";
2174
+ import { readFileSync as readFileSync3 } from "fs";
2175
+ async function runUpdate(input) {
2176
+ const pkg2 = JSON.parse(
2177
+ readFileSync3(new URL("../../package.json", import.meta.url), "utf8")
2178
+ );
2179
+ const currentVersion = pkg2.version;
2180
+ const tag = input.distTag ?? "beta";
2181
+ let latest;
2182
+ try {
2183
+ latest = execSync2(`npm view skillwiki@${tag} version`, {
2184
+ encoding: "utf8",
2185
+ timeout: 15e3
2186
+ }).trim();
2187
+ } catch (e) {
2188
+ return {
2189
+ exitCode: ExitCode.PREFLIGHT_FAILED,
2190
+ result: err("PREFLIGHT_FAILED", { message: `Failed to query npm registry: ${String(e)}` })
2191
+ };
2192
+ }
2193
+ const cache = {
2194
+ lastCheck: Date.now(),
2195
+ latestVersion: latest,
2196
+ currentVersion
2197
+ };
2198
+ if (latest === currentVersion) {
2199
+ writeCache(input.home, cache);
2200
+ return {
2201
+ exitCode: ExitCode.OK,
2202
+ result: ok({
2203
+ previousVersion: currentVersion,
2204
+ newVersion: null,
2205
+ wasAlreadyLatest: true,
2206
+ humanHint: `Already on latest ${tag}: v${currentVersion}`
2207
+ })
2208
+ };
2209
+ }
2210
+ try {
2211
+ execSync2(`npm install -g skillwiki@${tag}`, {
2212
+ stdio: "pipe",
2213
+ timeout: 6e4
2214
+ });
2215
+ } catch (e) {
2216
+ return {
2217
+ exitCode: ExitCode.PREFLIGHT_FAILED,
2218
+ result: err("PREFLIGHT_FAILED", { message: `npm install failed: ${String(e)}` })
2219
+ };
2220
+ }
2221
+ writeCache(input.home, { ...cache, updateAppliedAt: Date.now() });
2222
+ return {
2223
+ exitCode: ExitCode.OK,
2224
+ result: ok({
2225
+ previousVersion: currentVersion,
2226
+ newVersion: latest,
2227
+ wasAlreadyLatest: false,
2228
+ humanHint: `Updated skillwiki ${currentVersion} \u2192 ${latest}`
1235
2229
  })
1236
2230
  };
1237
2231
  }
1238
2232
 
1239
2233
  // src/cli.ts
2234
+ var pkg = JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8"));
1240
2235
  var program = new Command();
1241
- program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version("0.2.0-beta.2");
2236
+ program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version(pkg.version);
1242
2237
  program.option("--human", "render terminal-readable output instead of JSON");
1243
2238
  function emit(r) {
1244
2239
  if (program.opts().human) printHuman(r.result);
@@ -1257,7 +2252,7 @@ program.command("orphans [vault]").action(async (vault) => emit(await runOrphans
1257
2252
  })));
1258
2253
  program.command("audit <file>").action(async (file) => emit(await runAudit({ file })));
1259
2254
  program.command("install").option("--target <dir>", "target install directory", `${process.env.HOME ?? ""}/.claude/skills/`).option("--dry-run", "preview only", false).option("--skills-root <dir>", "source skills directory (defaults to packaged)").action(async (opts) => {
1260
- const skillsRoot = opts.skillsRoot ?? new URL("../../skills/", import.meta.url).pathname;
2255
+ const skillsRoot = opts.skillsRoot ?? new URL("../skills/", import.meta.url).pathname;
1261
2256
  emit(await runInstall({ skillsRoot, target: opts.target, dryRun: !!opts.dryRun }));
1262
2257
  });
1263
2258
  program.command("path").option("--vault <dir>", "explicit vault override (runtime)").option("--target <dir>", "explicit target override (init-time)").option("--init-time", "use init-time chain instead of runtime", false).option("--explain", "include resolution chain in output", false).action(async (opts) => {
@@ -1279,7 +2274,7 @@ program.command("lang").option("--lang <code>", "explicit language override").op
1279
2274
  explain: !!opts.explain
1280
2275
  }));
1281
2276
  });
1282
- program.command("init").option("--target <dir>", "explicit target directory").requiredOption("--domain <text>", "knowledge domain seed").option("--taxonomy <csv>", "comma-separated tag list").option("--lang <code>", "output language (BCP 47 or alias)").option("--force", "override existing target / env conflict", false).action(async (opts) => {
2277
+ program.command("init").option("--target <dir>", "explicit target directory").requiredOption("--domain <text>", "knowledge domain seed").option("--taxonomy <csv>", "comma-separated tag list").option("--lang <code>", "output language (BCP 47 or alias)").option("--force", "override existing target / env conflict", false).option("--no-env", "skip writing ~/.skillwiki/.env").action(async (opts) => {
1283
2278
  const templates = new URL("../templates/", import.meta.url).pathname;
1284
2279
  const taxonomy = typeof opts.taxonomy === "string" ? opts.taxonomy.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : void 0;
1285
2280
  emit(await runInit({
@@ -1290,7 +2285,8 @@ program.command("init").option("--target <dir>", "explicit target directory").re
1290
2285
  domain: opts.domain,
1291
2286
  taxonomy,
1292
2287
  lang: opts.lang,
1293
- force: !!opts.force
2288
+ force: !!opts.force,
2289
+ noEnv: opts.env === false
1294
2290
  }));
1295
2291
  });
1296
2292
  async function resolveVaultArg(arg) {
@@ -1344,6 +2340,42 @@ program.command("lint [vault]").option("--days <n>", "stale threshold", (s) => p
1344
2340
  logThreshold: opts.logThreshold
1345
2341
  }));
1346
2342
  });
2343
+ var configCmd = program.command("config").description("manage skillwiki configuration");
2344
+ configCmd.command("get <key>").description("print the value of a config key").action(async (key) => emit(await runConfigGet({ key, home: process.env.HOME ?? "" })));
2345
+ configCmd.command("set <key> <value>").description("set a config key value").action(async (key, value) => emit(await runConfigSet({ key, value, home: process.env.HOME ?? "" })));
2346
+ configCmd.command("list").description("list all config key=value pairs").action(async () => emit(await runConfigList({ home: process.env.HOME ?? "" })));
2347
+ configCmd.command("path").description("print the config file path").action(async () => emit(await runConfigPath({ home: process.env.HOME ?? "" })));
2348
+ program.command("doctor").description("diagnose skillwiki setup issues").action(async () => emit(await runDoctor({
2349
+ home: process.env.HOME ?? "",
2350
+ envValue: process.env.WIKI_PATH,
2351
+ argv: process.argv,
2352
+ currentVersion: pkg.version
2353
+ })));
2354
+ program.command("archive <page> [vault]").description("archive a typed-knowledge page").action(async (page, vault) => {
2355
+ const v = await resolveVaultArg(vault);
2356
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2357
+ else emit(await runArchive({ vault: v.vault, page }));
2358
+ });
2359
+ program.command("drift [vault]").description("detect content drift in raw sources").action(async (vault) => {
2360
+ const v = await resolveVaultArg(vault);
2361
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2362
+ else emit(await runDrift({ vault: v.vault }));
2363
+ });
2364
+ program.command("dedup [vault]").description("detect duplicate raw sources by sha256").action(async (vault) => {
2365
+ const v = await resolveVaultArg(vault);
2366
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2367
+ else emit(await runDedup({ vault: v.vault }));
2368
+ });
2369
+ program.command("migrate-citations [vault]").description("migrate ^[raw/...] markers to paragraph-end citations").option("--dry-run", "preview changes without writing", false).action(async (vault, opts) => {
2370
+ const v = await resolveVaultArg(vault);
2371
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2372
+ else emit(await runMigrateCitations({ vault: v.vault, dryRun: !!opts.dryRun }));
2373
+ });
2374
+ program.command("update").description("update skillwiki CLI to the latest version").option("--tag <tag>", "npm dist-tag", "beta").action(async (opts) => emit(await runUpdate({
2375
+ home: process.env.HOME ?? "",
2376
+ distTag: opts.tag
2377
+ })));
2378
+ triggerAutoUpdate(process.env.HOME ?? "", pkg.version);
1347
2379
  program.parseAsync(process.argv).catch((e) => {
1348
2380
  process.stdout.write(JSON.stringify({ ok: false, error: "INTERNAL", detail: { message: String(e) } }) + "\n");
1349
2381
  process.exit(1);