skillwiki 0.2.1-beta.16 → 0.2.1-beta.21

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
@@ -2,43 +2,20 @@
2
2
  import {
3
3
  semverGt
4
4
  } from "./chunk-XM5IYZX7.js";
5
+ import {
6
+ git,
7
+ gitStrict
8
+ } from "./chunk-TPS5XD2J.js";
5
9
 
6
10
  // src/cli.ts
7
- import { readFileSync as readFileSync5 } from "fs";
11
+ import { readFileSync as readFileSync9 } from "fs";
12
+ import { join as join37 } from "path";
8
13
  import { Command } from "commander";
9
14
 
10
- // src/utils/output.ts
11
- function printJson(r) {
12
- process.stdout.write(JSON.stringify(r) + "\n");
13
- }
14
- function printHuman(r) {
15
- if (r.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
21
- ${formatData(r.data)}
22
- `);
23
- }
24
- } else {
25
- process.stdout.write(`ERR ${r.error}
26
- ${r.detail !== void 0 ? formatData(r.detail) + "\n" : ""}`);
27
- }
28
- }
29
- function formatData(d) {
30
- if (d == null) return "";
31
- if (typeof d === "string") return d;
32
- return JSON.stringify(d, null, 2);
33
- }
34
-
35
- // src/commands/hash.ts
36
- import { readFile } from "fs/promises";
37
- import { createHash } from "crypto";
38
-
39
15
  // ../shared/src/exit-codes.ts
40
16
  var ExitCode = {
41
17
  OK: 0,
18
+ INTERNAL_ERROR: 1,
42
19
  FILE_NOT_FOUND: 2,
43
20
  MISSING_CLOSING_DELIMITER: 3,
44
21
  SCHEME_REJECTED: 4,
@@ -74,7 +51,15 @@ var ExitCode = {
74
51
  MIGRATION_APPLIED: 34,
75
52
  UNKNOWN_WIKI_PROFILE: 35,
76
53
  DEDUP_APPLIED: 36,
77
- PROJECT_NOT_FOUND: 37
54
+ PROJECT_NOT_FOUND: 37,
55
+ SYMLINK_FAILED: 38,
56
+ COMPOUND_PROMOTED: 39,
57
+ SKILL_VERSION_MISMATCH: 40,
58
+ INGEST_VALIDATION_FAILED: 41,
59
+ SYNC_PUSH_FAILED: 42,
60
+ SYNC_PULL_FAILED: 43,
61
+ BACKUP_SYNC_FAILED: 44,
62
+ BACKUP_RESTORE_CONFLICTS: 45
78
63
  };
79
64
 
80
65
  // ../shared/src/json-output.ts
@@ -98,7 +83,7 @@ var TypedKnowledgeSchema = z.object({
98
83
  aliases: z.array(z.string()).optional(),
99
84
  created: isoDate,
100
85
  updated: isoDate,
101
- type: z.enum(["entity", "concept", "comparison", "query", "summary"]),
86
+ type: z.enum(["entity", "concept", "comparison", "query"]),
102
87
  tags: z.array(z.string()),
103
88
  sources: z.array(z.string()).min(1),
104
89
  confidence: z.enum(["high", "medium", "low"]).optional(),
@@ -118,7 +103,7 @@ var RawSourceSchema = z.object({
118
103
  source_url: z.string().nullable(),
119
104
  ingested: isoDate,
120
105
  ingested_by: z.enum(["wiki-ingest", "proj-work", "manual"]).optional(),
121
- sha256: sha256Hex,
106
+ sha256: sha256Hex.optional(),
122
107
  project: wikilink.optional(),
123
108
  work_item: wikilink.optional(),
124
109
  kind: z.enum(["postmortem", "session-log", "meeting-notes", "other", "idea", "bug", "task", "note"]).optional()
@@ -181,7 +166,9 @@ function detectSchema(fm) {
181
166
  if (typeof fm.type === "string" && COMPOUND_TYPES.has(fm.type) && "project" in fm) return { schema: "compound" };
182
167
  if (fm.type === "meta") return { schema: "meta" };
183
168
  if ("type" in fm && "sources" in fm) return { schema: "typed-knowledge" };
184
- if (typeof fm.sha256 === "string" && "ingested" in fm) return { schema: "raw" };
169
+ if ("ingested" in fm && ("source_url" in fm || "sha256" in fm)) return { schema: "raw" };
170
+ const RAW_KINDS = /* @__PURE__ */ new Set(["postmortem", "session-log", "meeting-notes", "other", "idea", "bug", "task", "note"]);
171
+ if ("ingested" in fm && typeof fm.kind === "string" && RAW_KINDS.has(fm.kind)) return { schema: "raw" };
185
172
  if ("kind" in fm && "status" in fm) return { schema: "work-item" };
186
173
  return { schema: null };
187
174
  }
@@ -225,6 +212,56 @@ function isBlockedHost(host) {
225
212
  return false;
226
213
  }
227
214
 
215
+ // src/utils/output.ts
216
+ function printJson(r) {
217
+ process.stdout.write(JSON.stringify(r) + "\n");
218
+ }
219
+ function printHuman(r) {
220
+ if (r.ok) {
221
+ if (typeof r.data === "object" && r.data !== null && "humanHint" in r.data) {
222
+ process.stdout.write(`${r.data.humanHint}
223
+ `);
224
+ } else {
225
+ process.stdout.write(`OK
226
+ ${formatData(r.data)}
227
+ `);
228
+ }
229
+ } else {
230
+ process.stdout.write(`ERR ${r.error}
231
+ ${r.detail !== void 0 ? formatData(r.detail) + "\n" : ""}`);
232
+ }
233
+ }
234
+ function formatData(d) {
235
+ if (d == null) return "";
236
+ if (typeof d === "string") return d;
237
+ return JSON.stringify(d, null, 2);
238
+ }
239
+
240
+ // src/utils/deprecation.ts
241
+ import { readFileSync } from "fs";
242
+ import { join } from "path";
243
+ function getDeprecatedWarnings(home) {
244
+ const manifestPath = join(home, ".claude", "skills", "wiki-manifest.json");
245
+ try {
246
+ const raw = readFileSync(manifestPath, "utf8");
247
+ const manifest = JSON.parse(raw);
248
+ if (!manifest.skills) return [];
249
+ const warnings = [];
250
+ for (const [dirName, meta] of Object.entries(manifest.skills)) {
251
+ if (meta.deprecated) {
252
+ warnings.push(`\u26A0 Skill "${meta.name || dirName}" is deprecated. See SKILL.md for migration notes.`);
253
+ }
254
+ }
255
+ return warnings;
256
+ } catch {
257
+ return [];
258
+ }
259
+ }
260
+
261
+ // src/commands/hash.ts
262
+ import { readFile } from "fs/promises";
263
+ import { createHash } from "crypto";
264
+
228
265
  // src/parsers/frontmatter.ts
229
266
  import yaml from "js-yaml";
230
267
  var FM_OPEN = /^---\r?\n/;
@@ -313,7 +350,16 @@ function sanitizeUrl(u) {
313
350
  }
314
351
 
315
352
  // src/commands/validate.ts
316
- import { readFile as readFile2 } from "fs/promises";
353
+ import { readFile as readFile2, writeFile } from "fs/promises";
354
+ import { join as join2, resolve, relative, sep } from "path";
355
+ var TYPE_TO_SECTION = {
356
+ entity: "Entities",
357
+ concept: "Concepts",
358
+ comparison: "Comparisons",
359
+ query: "Queries",
360
+ summary: "Summaries",
361
+ meta: "Meta"
362
+ };
317
363
  var SCHEMAS = {
318
364
  "typed-knowledge": TypedKnowledgeSchema,
319
365
  "raw": RawSourceSchema,
@@ -337,36 +383,120 @@ async function runValidate(input) {
337
383
  }
338
384
  const det = detectSchema(fm.data);
339
385
  if (!det.schema) {
340
- return { exitCode: ExitCode.SCHEMA_NOT_DETECTED, result: ok({ schema: null, valid: false, errors: [], humanHint: "schema not detected" }) };
386
+ return { exitCode: ExitCode.SCHEMA_NOT_DETECTED, result: ok({ schema: null, valid: false, errors: [], index_updated: false, log_updated: false, humanHint: "schema not detected" }) };
341
387
  }
342
388
  const parsed = SCHEMAS[det.schema].safeParse(fm.data);
343
389
  if (!parsed.success) {
344
390
  const errors = parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }));
345
391
  return {
346
392
  exitCode: ExitCode.INVALID_FRONTMATTER,
347
- result: ok({ schema: det.schema, valid: false, errors, humanHint: `INVALID (${det.schema})
393
+ result: ok({ schema: det.schema, valid: false, errors, index_updated: false, log_updated: false, humanHint: `INVALID (${det.schema})
348
394
  ${errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}` })
349
395
  };
350
396
  }
351
- return { exitCode: ExitCode.OK, result: ok({ schema: det.schema, valid: true, errors: [], humanHint: `VALID (${det.schema})` }) };
397
+ if (input.apply && !input.vault) {
398
+ return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { reason: "--vault is required when --apply is set" }) };
399
+ }
400
+ let indexUpdated = false;
401
+ let logUpdated = false;
402
+ let applyHint = "";
403
+ if (input.apply && input.vault) {
404
+ const absFile = resolve(input.file);
405
+ const absVault = resolve(input.vault);
406
+ const relPath = relative(absVault, absFile).split(sep).join("/");
407
+ if (relPath.startsWith("..")) {
408
+ return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { reason: `file ${input.file} is not inside vault ${input.vault}` }) };
409
+ }
410
+ const pageType = "type" in parsed.data && typeof parsed.data.type === "string" ? parsed.data.type : "";
411
+ const title = typeof parsed.data.title === "string" ? parsed.data.title : relPath.replace(/\.md$/, "");
412
+ if (det.schema === "typed-knowledge" || det.schema === "meta") {
413
+ indexUpdated = await addToIndex(input.vault, relPath, title, pageType);
414
+ }
415
+ logUpdated = await appendToLog(input.vault, relPath);
416
+ if (indexUpdated) applyHint += `
417
+ index: added [[${relPath.replace(/\.md$/, "")}]]`;
418
+ if (logUpdated) applyHint += "\n log: appended entry";
419
+ }
420
+ return { exitCode: ExitCode.OK, result: ok({
421
+ schema: det.schema,
422
+ valid: true,
423
+ errors: [],
424
+ index_updated: indexUpdated,
425
+ log_updated: logUpdated,
426
+ humanHint: `VALID (${det.schema})${applyHint}`
427
+ }) };
428
+ }
429
+ async function addToIndex(vault, relPath, title, pageType) {
430
+ const section = TYPE_TO_SECTION[pageType];
431
+ if (!section) return false;
432
+ const indexPath = join2(vault, "index.md");
433
+ let text;
434
+ try {
435
+ text = await readFile2(indexPath, "utf8");
436
+ } catch {
437
+ return false;
438
+ }
439
+ const ref = relPath.replace(/\.md$/, "");
440
+ if (text.includes(`[[${ref}]]`)) return false;
441
+ const entry = `- [[${ref}]] \u2014 ${title}`;
442
+ const lines = text.split("\n");
443
+ const sectionLine = `## ${section}`;
444
+ const sectionIdx = lines.findIndex((l) => l.trim() === sectionLine);
445
+ if (sectionIdx === -1) {
446
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
447
+ lines.push("", sectionLine, entry);
448
+ } else {
449
+ let endIdx = sectionIdx + 1;
450
+ while (endIdx < lines.length) {
451
+ if (lines[endIdx].startsWith("## ")) break;
452
+ endIdx++;
453
+ }
454
+ let insertAt = endIdx;
455
+ while (insertAt > sectionIdx + 1 && lines[insertAt - 1].trim() === "") insertAt--;
456
+ lines.splice(insertAt, 0, entry);
457
+ }
458
+ try {
459
+ await writeFile(indexPath, lines.join("\n"), "utf8");
460
+ } catch {
461
+ return false;
462
+ }
463
+ return true;
464
+ }
465
+ async function appendToLog(vault, relPath) {
466
+ const logPath = join2(vault, "log.md");
467
+ let text;
468
+ try {
469
+ text = await readFile2(logPath, "utf8");
470
+ } catch {
471
+ return false;
472
+ }
473
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
474
+ const entry = `
475
+ ## [${today}] validate | added: ${relPath}`;
476
+ try {
477
+ await writeFile(logPath, text.trimEnd() + entry, "utf8");
478
+ } catch {
479
+ return false;
480
+ }
481
+ return true;
352
482
  }
353
483
 
354
484
  // src/commands/graph.ts
355
- import { writeFile, mkdir } from "fs/promises";
485
+ import { writeFile as writeFile2, mkdir } from "fs/promises";
356
486
  import { dirname } from "path";
357
487
 
358
488
  // src/utils/vault.ts
359
489
  import { readFile as readFile3, readdir, stat } from "fs/promises";
360
- import { join, relative, sep } from "path";
490
+ import { join as join3, relative as relative2, sep as sep2 } from "path";
361
491
  var TYPED_DIRS = ["entities", "concepts", "comparisons", "queries", "meta"];
362
492
  async function scanVault(root) {
363
493
  try {
364
- await stat(join(root, "SCHEMA.md"));
494
+ await stat(join3(root, "SCHEMA.md"));
365
495
  } catch {
366
496
  return err("VAULT_PATH_INVALID", { root, reason: "SCHEMA.md missing" });
367
497
  }
368
498
  const all = await walk(root);
369
- const rels = all.map((p) => ({ absPath: p, relPath: relative(root, p).split(sep).join("/") }));
499
+ const rels = all.map((p) => ({ absPath: p, relPath: relative2(root, p).split(sep2).join("/") }));
370
500
  return ok({
371
501
  root,
372
502
  typedKnowledge: rels.filter((p) => TYPED_DIRS.some((d) => p.relPath.startsWith(d + "/"))),
@@ -379,7 +509,7 @@ async function walk(dir) {
379
509
  const entries = await readdir(dir, { withFileTypes: true });
380
510
  const out = [];
381
511
  for (const e of entries) {
382
- const p = join(dir, e.name);
512
+ const p = join3(dir, e.name);
383
513
  if (e.isDirectory()) out.push(...await walk(p));
384
514
  else if (e.isFile() && e.name.endsWith(".md")) out.push(p);
385
515
  }
@@ -390,7 +520,7 @@ async function readPage(p) {
390
520
  }
391
521
 
392
522
  // src/parsers/wikilinks.ts
393
- var FENCE = /`[^`]*`|```[\s\S]*?```/g;
523
+ var FENCE = /```[\s\S]*?```|`[^`\n]*`/g;
394
524
  function extractBodyWikilinks(body) {
395
525
  const stripped = body.replace(FENCE, "");
396
526
  const seen = /* @__PURE__ */ new Set();
@@ -428,7 +558,7 @@ async function runGraphBuild(input) {
428
558
  const edge_count = Object.values(adjacency).reduce((acc, arr) => acc + arr.length, 0);
429
559
  try {
430
560
  await mkdir(dirname(input.out), { recursive: true });
431
- await writeFile(input.out, JSON.stringify({ adjacency, adamicAdar }, null, 2));
561
+ await writeFile2(input.out, JSON.stringify({ adjacency, adamicAdar }, null, 2));
432
562
  } catch (e) {
433
563
  return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
434
564
  }
@@ -515,12 +645,21 @@ async function runOverlap(input) {
515
645
  }
516
646
 
517
647
  // src/utils/wiki-path.ts
518
- import { join as join2 } from "path";
648
+ import { join as join4 } from "path";
519
649
 
520
650
  // src/utils/dotenv.ts
521
- import { readFile as readFile4, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
651
+ import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
522
652
  import { dirname as dirname2 } from "path";
523
- var CONFIG_KEYS = ["WIKI_PATH", "WIKI_LANG"];
653
+ var CONFIG_KEYS = [
654
+ "WIKI_PATH",
655
+ "WIKI_LANG",
656
+ "AUTO_COMMIT",
657
+ "BACKUP_ENDPOINT",
658
+ "BACKUP_BUCKET",
659
+ "BACKUP_REGION",
660
+ "BACKUP_ACCESS_KEY_ID",
661
+ "BACKUP_SECRET_ACCESS_KEY"
662
+ ];
524
663
  var _whitelist = new Set(CONFIG_KEYS);
525
664
  var PROFILE_PATH_RE = /^WIKI_([A-Z][A-Z0-9_]{0,31})_PATH$/;
526
665
  var PROFILE_LANG_RE = /^WIKI_([A-Z][A-Z0-9_]{0,31})_LANG$/;
@@ -559,7 +698,7 @@ async function parseDotenvFile(path) {
559
698
  async function writeDotenv(filePath, entries, originalContent) {
560
699
  const lines = originalContent !== void 0 ? updateLines(originalContent, entries) : freshLines(entries);
561
700
  await mkdir2(dirname2(filePath), { recursive: true });
562
- await writeFile2(filePath, lines.join("\n") + "\n", "utf8");
701
+ await writeFile3(filePath, lines.join("\n") + "\n", "utf8");
563
702
  }
564
703
  function freshLines(entries) {
565
704
  const out = [];
@@ -614,27 +753,27 @@ async function resolveInitTimePath(input) {
614
753
  return { path: input.envValue, source: "env", ...input.explain ? { chain } : {} };
615
754
  }
616
755
  if (input.explain) chain.push({ source: "env", matched: false });
617
- const sw = await parseDotenvFile(join2(input.home, ".skillwiki", ".env"));
756
+ const sw = await parseDotenvFile(join4(input.home, ".skillwiki", ".env"));
618
757
  if (sw.WIKI_PATH !== void 0) {
619
758
  if (input.explain) chain.push({ source: "skillwiki-dotenv", matched: true, value: sw.WIKI_PATH });
620
759
  return { path: sw.WIKI_PATH, source: "skillwiki-dotenv", ...input.explain ? { chain } : {} };
621
760
  }
622
761
  if (input.explain) chain.push({ source: "skillwiki-dotenv", matched: false });
623
- const hermes = await parseDotenvFile(join2(input.home, ".hermes", ".env"));
762
+ const hermes = await parseDotenvFile(join4(input.home, ".hermes", ".env"));
624
763
  if (hermes.WIKI_PATH !== void 0) {
625
764
  if (input.explain) chain.push({ source: "hermes-dotenv", matched: true, value: hermes.WIKI_PATH });
626
765
  return { path: hermes.WIKI_PATH, source: "hermes-dotenv", ...input.explain ? { chain } : {} };
627
766
  }
628
767
  if (input.explain) chain.push({ source: "hermes-dotenv", matched: false });
629
768
  if (input.cwd) {
630
- const projCfg = await parseDotenvFile(join2(input.cwd, ".skillwiki", ".env"));
769
+ const projCfg = await parseDotenvFile(join4(input.cwd, ".skillwiki", ".env"));
631
770
  if (projCfg.WIKI_PATH !== void 0) {
632
771
  if (input.explain) chain.push({ source: "project-dotenv", matched: true, value: projCfg.WIKI_PATH });
633
772
  return { path: projCfg.WIKI_PATH, source: "project-dotenv", ...input.explain ? { chain } : {} };
634
773
  }
635
774
  }
636
775
  if (input.explain) chain.push({ source: "project-dotenv", matched: false });
637
- const fallback = join2(input.home, "wiki");
776
+ const fallback = join4(input.home, "wiki");
638
777
  if (input.explain) chain.push({ source: "default", matched: true, value: fallback });
639
778
  return { path: fallback, source: "default", ...input.explain ? { chain } : {} };
640
779
  }
@@ -645,7 +784,7 @@ async function resolveRuntimePath(input) {
645
784
  return ok({ path: input.flag, source: "flag", ...input.explain ? { chain } : {} });
646
785
  }
647
786
  if (input.explain) chain.push({ source: "flag", matched: false });
648
- const swGlobal = await parseDotenvFile(join2(input.home, ".skillwiki", ".env"));
787
+ const swGlobal = await parseDotenvFile(join4(input.home, ".skillwiki", ".env"));
649
788
  const wikiName = input.wiki;
650
789
  if (wikiName !== void 0 && wikiName.length > 0) {
651
790
  if (wikiName.toLowerCase() === "default") {
@@ -689,7 +828,7 @@ async function resolveRuntimePath(input) {
689
828
  }
690
829
  if (input.explain) chain.push({ source: "env", matched: false });
691
830
  if (input.cwd) {
692
- const projCfg = await parseDotenvFile(join2(input.cwd, ".skillwiki", ".env"));
831
+ const projCfg = await parseDotenvFile(join4(input.cwd, ".skillwiki", ".env"));
693
832
  if (projCfg.WIKI_PATH !== void 0) {
694
833
  if (input.explain) chain.push({ source: "project-dotenv", matched: true, value: projCfg.WIKI_PATH });
695
834
  return ok({ path: projCfg.WIKI_PATH, source: "project-dotenv", ...input.explain ? { chain } : {} });
@@ -804,7 +943,7 @@ function simulateRemoval(adj, removed) {
804
943
 
805
944
  // src/commands/audit.ts
806
945
  import { readFile as readFile5, stat as stat2 } from "fs/promises";
807
- import { dirname as dirname3, resolve, join as join3 } from "path";
946
+ import { dirname as dirname3, resolve as resolve2, join as join5 } from "path";
808
947
 
809
948
  // src/parsers/citations.ts
810
949
  var FENCE2 = /```[\s\S]*?```/g;
@@ -911,12 +1050,12 @@ async function runAudit(input) {
911
1050
  if (!fm.ok) return { exitCode: ExitCode.INVALID_FRONTMATTER, result: fm };
912
1051
  const split = splitFrontmatter(text);
913
1052
  const body = split.ok ? split.data.body : text;
914
- const vault = await findVaultRoot(dirname3(resolve(input.file)));
1053
+ const vault = await findVaultRoot(dirname3(resolve2(input.file)));
915
1054
  if (!vault) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID") };
916
1055
  const markers = extractCitationMarkers(body);
917
1056
  const resolved = await Promise.all(markers.map(async (m) => {
918
1057
  try {
919
- await stat2(join3(vault, m.target));
1058
+ await stat2(join5(vault, m.target));
920
1059
  return { ...m, resolved: true };
921
1060
  } catch {
922
1061
  return { ...m, resolved: false };
@@ -927,7 +1066,7 @@ async function runAudit(input) {
927
1066
  const unused_sources = sources.filter((s) => !referenced.has(s));
928
1067
  const missing_from_sources = [...referenced].filter((t) => !sources.includes(t));
929
1068
  const broken = resolved.filter((m) => !m.resolved);
930
- const footerMatch = body.match(/\n## Sources\n([\s\S]*)$/);
1069
+ const footerMatch = body.match(/\r?\n## Sources\r?\n([\s\S]*)$/);
931
1070
  let footer_consistency;
932
1071
  if (footerMatch) {
933
1072
  const footerTargets = /* @__PURE__ */ new Set();
@@ -961,7 +1100,7 @@ async function findVaultRoot(start) {
961
1100
  let cur = start;
962
1101
  for (let i = 0; i < 20; i++) {
963
1102
  try {
964
- await stat2(join3(cur, "SCHEMA.md"));
1103
+ await stat2(join5(cur, "SCHEMA.md"));
965
1104
  return cur;
966
1105
  } catch {
967
1106
  }
@@ -971,13 +1110,53 @@ async function findVaultRoot(start) {
971
1110
  }
972
1111
  return null;
973
1112
  }
1113
+ function stripWikilink(s) {
1114
+ return s.replace(/^\[\[/, "").replace(/(?:\|[^\[\]]*)?\]\]$/, "").trim();
1115
+ }
1116
+ async function validateCompoundReferences(vault) {
1117
+ const scan = await scanVault(vault);
1118
+ if (!scan.ok) return scan;
1119
+ const slugToPage = /* @__PURE__ */ new Map();
1120
+ const pathToPage = /* @__PURE__ */ new Map();
1121
+ for (const p of scan.data.workItems) {
1122
+ slugToPage.set(p.relPath.replace(/\.md$/, "").split("/").pop().toLowerCase(), p);
1123
+ pathToPage.set(p.relPath, p);
1124
+ }
1125
+ const findings = [];
1126
+ for (const cp of scan.data.compound) {
1127
+ const text = await readPage(cp);
1128
+ const fm = extractFrontmatter(text);
1129
+ if (!fm.ok) continue;
1130
+ const projectRaw = fm.data.project;
1131
+ const workItems = fm.data.work_items;
1132
+ if (!projectRaw || !workItems?.length) continue;
1133
+ const projSlug = stripWikilink(String(projectRaw));
1134
+ for (const wi of workItems) {
1135
+ const target = stripWikilink(wi);
1136
+ const withExt = target.endsWith(".md") ? target : target + ".md";
1137
+ const resolved = pathToPage.get(withExt) ?? slugToPage.get(target.split("/").pop().replace(/\.md$/, "").toLowerCase());
1138
+ if (!resolved) {
1139
+ findings.push({ compound: cp.relPath, work_item: wi, kind: "missing", detail: `no work item found for [[${target}]]` });
1140
+ continue;
1141
+ }
1142
+ const wiFm = extractFrontmatter(await readPage(resolved));
1143
+ if (wiFm.ok && wiFm.data.project) {
1144
+ const wiProj = stripWikilink(String(wiFm.data.project));
1145
+ if (wiProj !== projSlug) {
1146
+ findings.push({ compound: cp.relPath, work_item: wi, kind: "cross_project", detail: `compound project [[${projSlug}]] != work_item project [[${wiProj}]]` });
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+ return ok(findings);
1152
+ }
974
1153
 
975
1154
  // src/commands/install.ts
976
- import { readdir as readdir2, stat as stat4 } from "fs/promises";
977
- import { join as join4 } from "path";
1155
+ import { readdir as readdir2, stat as stat4, symlink, unlink, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
1156
+ import { join as join6, resolve as resolve3, dirname as dirname5 } from "path";
978
1157
 
979
1158
  // src/utils/install-fs.ts
980
- import { copyFile, mkdir as mkdir3, rename, writeFile as writeFile3, stat as stat3 } from "fs/promises";
1159
+ import { copyFile, mkdir as mkdir3, rename, writeFile as writeFile4, stat as stat3 } from "fs/promises";
981
1160
  import { dirname as dirname4 } from "path";
982
1161
  async function atomicCopyWithBackup(src, dst) {
983
1162
  await mkdir3(dirname4(dst), { recursive: true });
@@ -1000,10 +1179,36 @@ async function atomicCopyWithBackup(src, dst) {
1000
1179
  async function writeManifest(path, m) {
1001
1180
  await mkdir3(dirname4(path), { recursive: true });
1002
1181
  const enriched = { installed_at: (/* @__PURE__ */ new Date()).toISOString(), ...m };
1003
- await writeFile3(path, JSON.stringify(enriched, null, 2));
1182
+ await writeFile4(path, JSON.stringify(enriched, null, 2));
1004
1183
  }
1005
1184
 
1006
1185
  // src/commands/install.ts
1186
+ function parseSkillMeta(content) {
1187
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
1188
+ const meta = { name: "" };
1189
+ if (!fmMatch) return meta;
1190
+ const fm = fmMatch[1];
1191
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
1192
+ if (nameMatch) meta.name = nameMatch[1].trim();
1193
+ const versionMatch = fm.match(/^version:\s*(.+)$/m);
1194
+ if (versionMatch) meta.version = versionMatch[1].trim();
1195
+ const depMatch = fm.match(/^deprecated:\s*(.+)$/m);
1196
+ if (depMatch && /^(true|yes)$/i.test(depMatch[1].trim())) meta.deprecated = true;
1197
+ return meta;
1198
+ }
1199
+ async function createSymlink(src, dst) {
1200
+ await mkdir4(dirname5(dst), { recursive: true });
1201
+ try {
1202
+ await unlink(dst);
1203
+ } catch {
1204
+ }
1205
+ try {
1206
+ await symlink(resolve3(src), dst);
1207
+ } catch (e) {
1208
+ return err("SYMLINK_FAILED", { message: String(e) });
1209
+ }
1210
+ return ok({ linked: true });
1211
+ }
1007
1212
  async function runInstall(input) {
1008
1213
  let entries;
1009
1214
  try {
@@ -1016,45 +1221,85 @@ async function runInstall(input) {
1016
1221
  }
1017
1222
  const installed = [];
1018
1223
  const backed_up = [];
1224
+ const version_warnings = [];
1225
+ const skillMetas = {};
1019
1226
  for (const name of entries) {
1020
- const src = join4(input.skillsRoot, name, "SKILL.md");
1021
- const dst = join4(input.target, name, "SKILL.md");
1227
+ const src = join6(input.skillsRoot, name, "SKILL.md");
1228
+ const dst = join6(input.target, name, "SKILL.md");
1022
1229
  try {
1023
1230
  await stat4(src);
1024
1231
  } catch {
1025
1232
  return { exitCode: ExitCode.PREFLIGHT_FAILED, result: err("PREFLIGHT_FAILED", { missing: src }) };
1026
1233
  }
1234
+ try {
1235
+ const content = await readFile6(src, "utf8");
1236
+ const meta = parseSkillMeta(content);
1237
+ meta.name = meta.name || name;
1238
+ skillMetas[name] = meta;
1239
+ if (meta.deprecated) {
1240
+ version_warnings.push(`${name}: DEPRECATED \u2014 will be removed in a future release`);
1241
+ }
1242
+ if (!input.dryRun) {
1243
+ try {
1244
+ const existingContent = await readFile6(dst, "utf8");
1245
+ const existingMeta = parseSkillMeta(existingContent);
1246
+ if (existingMeta.version && meta.version && existingMeta.version !== meta.version) {
1247
+ version_warnings.push(`${name}: version changed ${existingMeta.version} \u2192 ${meta.version}`);
1248
+ }
1249
+ } catch {
1250
+ }
1251
+ }
1252
+ } catch {
1253
+ }
1027
1254
  if (input.dryRun) {
1028
1255
  installed.push(dst);
1029
1256
  continue;
1030
1257
  }
1031
- const r = await atomicCopyWithBackup(src, dst);
1032
- if (!r.ok) return { exitCode: ExitCode.ATOMIC_COPY_FAILED, result: r };
1033
- installed.push(dst);
1034
- if (r.data.backupPath) backed_up.push(r.data.backupPath);
1258
+ if (input.symlink) {
1259
+ const r = await createSymlink(src, dst);
1260
+ if (!r.ok) return { exitCode: ExitCode.SYMLINK_FAILED, result: r };
1261
+ installed.push(dst);
1262
+ } else {
1263
+ const r = await atomicCopyWithBackup(src, dst);
1264
+ if (!r.ok) return { exitCode: ExitCode.ATOMIC_COPY_FAILED, result: r };
1265
+ installed.push(dst);
1266
+ if (r.data.backupPath) backed_up.push(r.data.backupPath);
1267
+ }
1035
1268
  }
1036
- const binSrc = join4(input.skillsRoot, "bin", "skillwiki");
1269
+ const binSrc = join6(input.skillsRoot, "bin", "skillwiki");
1037
1270
  try {
1038
1271
  await stat4(binSrc);
1039
- const binDst = join4(input.target, "bin", "skillwiki");
1272
+ const binDst = join6(input.target, "bin", "skillwiki");
1040
1273
  if (!input.dryRun) {
1041
- const r = await atomicCopyWithBackup(binSrc, binDst);
1042
- if (!r.ok) return { exitCode: ExitCode.ATOMIC_COPY_FAILED, result: r };
1043
- installed.push(binDst);
1044
- if (r.data.backupPath) backed_up.push(r.data.backupPath);
1274
+ if (input.symlink) {
1275
+ const r = await createSymlink(binSrc, binDst);
1276
+ if (!r.ok) return { exitCode: ExitCode.SYMLINK_FAILED, result: r };
1277
+ installed.push(binDst);
1278
+ } else {
1279
+ const r = await atomicCopyWithBackup(binSrc, binDst);
1280
+ if (!r.ok) return { exitCode: ExitCode.ATOMIC_COPY_FAILED, result: r };
1281
+ installed.push(binDst);
1282
+ if (r.data.backupPath) backed_up.push(r.data.backupPath);
1283
+ }
1045
1284
  } else {
1046
1285
  installed.push(binDst);
1047
1286
  }
1048
1287
  } catch {
1049
1288
  }
1050
- const manifest_path = join4(input.target, "wiki-manifest.json");
1051
- if (!input.dryRun) await writeManifest(manifest_path, { installed, backed_up });
1289
+ const manifest_path = join6(input.target, "wiki-manifest.json");
1290
+ if (!input.dryRun) await writeManifest(manifest_path, { installed, backed_up, symlink: input.symlink || void 0, skills: skillMetas });
1291
+ const mode = input.symlink ? "symlink (dev mode)" : "copy";
1052
1292
  const hintLines = [
1053
- `installed: ${installed.length}`,
1293
+ `installed: ${installed.length} (${mode})`,
1054
1294
  input.dryRun ? "(dry run)" : `backed up: ${backed_up.length}`,
1055
1295
  `manifest: ${manifest_path}`
1056
1296
  ];
1057
- return { exitCode: ExitCode.OK, result: ok({ installed, backed_up, manifest_path, humanHint: hintLines.join("\n") }) };
1297
+ if (version_warnings.length > 0) {
1298
+ hintLines.push(`version warnings: ${version_warnings.length}`);
1299
+ for (const w of version_warnings) hintLines.push(` ${w}`);
1300
+ }
1301
+ const exitCode = version_warnings.length > 0 ? ExitCode.SKILL_VERSION_MISMATCH : ExitCode.OK;
1302
+ return { exitCode, result: ok({ installed, backed_up, manifest_path, version_warnings, humanHint: hintLines.join("\n") }) };
1058
1303
  }
1059
1304
 
1060
1305
  // src/commands/path.ts
@@ -1083,7 +1328,7 @@ async function runPath(input) {
1083
1328
  }
1084
1329
 
1085
1330
  // src/utils/lang.ts
1086
- import { join as join5 } from "path";
1331
+ import { join as join7 } from "path";
1087
1332
  var ALIASES = {
1088
1333
  english: "en",
1089
1334
  en: "en",
@@ -1106,7 +1351,7 @@ async function resolveLang(input) {
1106
1351
  if (input.envValue !== void 0 && input.envValue.length > 0) {
1107
1352
  return { value: input.envValue, source: "env", canonical: normalizeLang(input.envValue) };
1108
1353
  }
1109
- const dotenv = await parseDotenvFile(join5(input.home, ".skillwiki", ".env"));
1354
+ const dotenv = await parseDotenvFile(join7(input.home, ".skillwiki", ".env"));
1110
1355
  if (dotenv.WIKI_LANG !== void 0) {
1111
1356
  return { value: dotenv.WIKI_LANG, source: "skillwiki-dotenv", canonical: normalizeLang(dotenv.WIKI_LANG) };
1112
1357
  }
@@ -1114,7 +1359,7 @@ async function resolveLang(input) {
1114
1359
  }
1115
1360
 
1116
1361
  // src/commands/lang.ts
1117
- import { join as join6 } from "path";
1362
+ import { join as join8 } from "path";
1118
1363
  async function runLang(input) {
1119
1364
  const resolved = await resolveLang({ flag: input.flag, envValue: input.envValue, home: input.home });
1120
1365
  let chain;
@@ -1123,7 +1368,7 @@ async function runLang(input) {
1123
1368
  { source: "flag", matched: input.flag !== void 0 && input.flag.length > 0, value: input.flag },
1124
1369
  { source: "env", matched: input.envValue !== void 0 && input.envValue.length > 0, value: input.envValue }
1125
1370
  ];
1126
- const sw = await parseDotenvFile(join6(input.home, ".skillwiki", ".env"));
1371
+ const sw = await parseDotenvFile(join8(input.home, ".skillwiki", ".env"));
1127
1372
  chain.push({ source: "skillwiki-dotenv", matched: sw.WIKI_LANG !== void 0, value: sw.WIKI_LANG });
1128
1373
  chain.push({ source: "default", matched: resolved.source === "default", value: "en" });
1129
1374
  }
@@ -1140,8 +1385,8 @@ async function runLang(input) {
1140
1385
  }
1141
1386
 
1142
1387
  // src/commands/init.ts
1143
- import { mkdir as mkdir4, readFile as readFile6, readdir as readdir3, writeFile as writeFile4 } from "fs/promises";
1144
- import { join as join7 } from "path";
1388
+ import { mkdir as mkdir5, readFile as readFile7, readdir as readdir3, writeFile as writeFile5 } from "fs/promises";
1389
+ import { join as join10 } from "path";
1145
1390
 
1146
1391
  // src/parsers/taxonomy.ts
1147
1392
  import yaml2 from "js-yaml";
@@ -1168,6 +1413,48 @@ function extractTaxonomy(schemaText) {
1168
1413
  return ok(tax);
1169
1414
  }
1170
1415
 
1416
+ // src/utils/last-op.ts
1417
+ import { readFileSync as readFileSync2, writeFileSync, mkdirSync, unlinkSync, existsSync } from "fs";
1418
+ import { join as join9 } from "path";
1419
+ var LAST_OP_DIR = ".skillwiki";
1420
+ var LAST_OP_FILE = "last-op.json";
1421
+ function lastOpPath(vault) {
1422
+ return join9(vault, LAST_OP_DIR, LAST_OP_FILE);
1423
+ }
1424
+ function readLastOp(vault) {
1425
+ const p = lastOpPath(vault);
1426
+ if (!existsSync(p)) return [];
1427
+ try {
1428
+ const raw = readFileSync2(p, "utf8");
1429
+ const parsed = JSON.parse(raw);
1430
+ if (!Array.isArray(parsed)) {
1431
+ unlinkSync(p);
1432
+ return [];
1433
+ }
1434
+ return parsed;
1435
+ } catch {
1436
+ try {
1437
+ unlinkSync(p);
1438
+ } catch {
1439
+ }
1440
+ return [];
1441
+ }
1442
+ }
1443
+ function appendLastOp(vault, entry) {
1444
+ const existing = readLastOp(vault);
1445
+ existing.push(entry);
1446
+ const dir = join9(vault, LAST_OP_DIR);
1447
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1448
+ writeFileSync(lastOpPath(vault), JSON.stringify(existing, null, 2), "utf8");
1449
+ }
1450
+ function clearLastOp(vault) {
1451
+ const p = lastOpPath(vault);
1452
+ try {
1453
+ unlinkSync(p);
1454
+ } catch {
1455
+ }
1456
+ }
1457
+
1171
1458
  // src/commands/init.ts
1172
1459
  var DEFAULT_TAXONOMY = [
1173
1460
  "research",
@@ -1210,13 +1497,13 @@ async function discoverTagsFromPages(target, knownSlugs) {
1210
1497
  for (const dir of ["entities", "concepts", "comparisons", "queries"]) {
1211
1498
  let entries;
1212
1499
  try {
1213
- entries = (await readdir3(join7(target, dir), { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name);
1500
+ entries = (await readdir3(join10(target, dir), { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name);
1214
1501
  } catch {
1215
1502
  continue;
1216
1503
  }
1217
1504
  for (const file of entries) {
1218
1505
  try {
1219
- const text = await readFile6(join7(target, dir, file), "utf8");
1506
+ const text = await readFile7(join10(target, dir, file), "utf8");
1220
1507
  const fm = extractFrontmatter(text);
1221
1508
  if (!fm.ok || !fm.data.tags || !Array.isArray(fm.data.tags)) continue;
1222
1509
  for (const t of fm.data.tags) {
@@ -1235,7 +1522,7 @@ async function runInit(input) {
1235
1522
  const canonicalLang = langRes.canonical;
1236
1523
  let oldSchemaText;
1237
1524
  try {
1238
- oldSchemaText = await readFile6(join7(target, "SCHEMA.md"), "utf8");
1525
+ oldSchemaText = await readFile7(join10(target, "SCHEMA.md"), "utf8");
1239
1526
  } catch {
1240
1527
  }
1241
1528
  if (oldSchemaText && !input.force) {
@@ -1244,10 +1531,10 @@ async function runInit(input) {
1244
1531
  result: err("INIT_TARGET_NOT_EMPTY", { target })
1245
1532
  };
1246
1533
  }
1247
- const envPath = join7(input.home, ".skillwiki", ".env");
1534
+ const envPath = join10(input.home, ".skillwiki", ".env");
1248
1535
  let existingEnvRaw = "";
1249
1536
  try {
1250
- existingEnvRaw = await readFile6(envPath, "utf8");
1537
+ existingEnvRaw = await readFile7(envPath, "utf8");
1251
1538
  } catch {
1252
1539
  }
1253
1540
  const existingEnv = parseDotenvText(existingEnvRaw);
@@ -1268,9 +1555,9 @@ async function runInit(input) {
1268
1555
  }
1269
1556
  const created = [];
1270
1557
  try {
1271
- await mkdir4(target, { recursive: true });
1558
+ await mkdir5(target, { recursive: true });
1272
1559
  for (const d of VAULT_DIRS) {
1273
- await mkdir4(join7(target, d), { recursive: true });
1560
+ await mkdir5(join10(target, d), { recursive: true });
1274
1561
  created.push(d + "/");
1275
1562
  }
1276
1563
  } catch (e) {
@@ -1299,9 +1586,9 @@ async function runInit(input) {
1299
1586
  const discovered_tags = discovered.length;
1300
1587
  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");
1301
1588
  try {
1302
- const schemaTpl = await readFile6(join7(input.templates, "SCHEMA.md"), "utf8");
1589
+ const schemaTpl = await readFile7(join10(input.templates, "SCHEMA.md"), "utf8");
1303
1590
  const schema = schemaTpl.replace("{{DOMAIN}}", domain).replace("{{WIKI_LANG}}", canonicalLang).replace("{{TAXONOMY_YAML}}", fullTaxonomyYaml);
1304
- await writeFile4(join7(target, "SCHEMA.md"), schema, "utf8");
1591
+ await writeFile5(join10(target, "SCHEMA.md"), schema, "utf8");
1305
1592
  created.push("SCHEMA.md");
1306
1593
  } catch (e) {
1307
1594
  return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { file: "SCHEMA.md", message: String(e) }) };
@@ -1309,7 +1596,7 @@ async function runInit(input) {
1309
1596
  const preserved = [];
1310
1597
  async function writeOrPreserve(fileName, render) {
1311
1598
  try {
1312
- const existing = await readFile6(join7(target, fileName), "utf8");
1599
+ const existing = await readFile7(join10(target, fileName), "utf8");
1313
1600
  if (existing.split("\n").length > 10) {
1314
1601
  preserved.push(fileName);
1315
1602
  return void 0;
@@ -1317,7 +1604,7 @@ async function runInit(input) {
1317
1604
  } catch {
1318
1605
  }
1319
1606
  try {
1320
- await writeFile4(join7(target, fileName), await render(), "utf8");
1607
+ await writeFile5(join10(target, fileName), await render(), "utf8");
1321
1608
  created.push(fileName);
1322
1609
  return void 0;
1323
1610
  } catch (e) {
@@ -1325,7 +1612,7 @@ async function runInit(input) {
1325
1612
  }
1326
1613
  }
1327
1614
  const err1 = await writeOrPreserve("index.md", async () => {
1328
- const tpl = await readFile6(join7(input.templates, "index.md"), "utf8");
1615
+ const tpl = await readFile7(join10(input.templates, "index.md"), "utf8");
1329
1616
  return tpl.replace("{{INIT_DATE}}", today);
1330
1617
  });
1331
1618
  if (err1) return err1;
@@ -1342,7 +1629,6 @@ async function runInit(input) {
1342
1629
  "---",
1343
1630
  "source_url:",
1344
1631
  "ingested: {{date:YYYY-MM-DD}}",
1345
- "sha256: # run: skillwiki hash <this-file>",
1346
1632
  "kind: # idea | bug | task | note | other",
1347
1633
  'project: # optional: "[[slug]]"',
1348
1634
  "---",
@@ -1352,7 +1638,7 @@ async function runInit(input) {
1352
1638
  });
1353
1639
  if (errTemplate) return errTemplate;
1354
1640
  const err22 = await writeOrPreserve("log.md", async () => {
1355
- const tpl = await readFile6(join7(input.templates, "log.md"), "utf8");
1641
+ const tpl = await readFile7(join10(input.templates, "log.md"), "utf8");
1356
1642
  return tpl.replace(/\{\{INIT_DATE\}\}/g, today).replace("{{DOMAIN}}", domain).replace("{{WIKI_LANG}}", canonicalLang);
1357
1643
  });
1358
1644
  if (err22) return err22;
@@ -1384,6 +1670,14 @@ async function runInit(input) {
1384
1670
  `discovered tags: ${discovered_tags}`,
1385
1671
  skipEnv ? "env: skipped" : `env: ${envWritten}`
1386
1672
  ].join("\n");
1673
+ if (created.length > 0) {
1674
+ appendLastOp(target, {
1675
+ operation: "init",
1676
+ summary: `initialized vault: ${domain}`,
1677
+ files: created,
1678
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1679
+ });
1680
+ }
1387
1681
  return {
1388
1682
  exitCode: ExitCode.OK,
1389
1683
  result: ok({
@@ -1441,12 +1735,12 @@ ${broken.map((b) => ` ${b.page}:[[${b.slug}]] (line ${b.line})`).join("\n")}` }
1441
1735
  }
1442
1736
 
1443
1737
  // src/commands/tag-audit.ts
1444
- import { readFile as readFile7 } from "fs/promises";
1445
- import { join as join8 } from "path";
1738
+ import { readFile as readFile8 } from "fs/promises";
1739
+ import { join as join11 } from "path";
1446
1740
  async function runTagAudit(input) {
1447
1741
  const scan = await scanVault(input.vault);
1448
1742
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1449
- const schemaText = await readFile7(join8(input.vault, "SCHEMA.md"), "utf8");
1743
+ const schemaText = await readFile8(join11(input.vault, "SCHEMA.md"), "utf8");
1450
1744
  const tax = extractTaxonomy(schemaText);
1451
1745
  if (!tax.ok) return { exitCode: ExitCode.INVALID_FRONTMATTER, result: tax };
1452
1746
  const allowed = new Set(tax.data);
@@ -1470,14 +1764,14 @@ async function runTagAudit(input) {
1470
1764
  }
1471
1765
 
1472
1766
  // src/commands/index-check.ts
1473
- import { readFile as readFile8 } from "fs/promises";
1474
- import { join as join9 } from "path";
1767
+ import { readFile as readFile9 } from "fs/promises";
1768
+ import { join as join12 } from "path";
1475
1769
  async function runIndexCheck(input) {
1476
1770
  const scan = await scanVault(input.vault);
1477
1771
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1478
1772
  let indexText = "";
1479
1773
  try {
1480
- indexText = await readFile8(join9(input.vault, "index.md"), "utf8");
1774
+ indexText = await readFile9(join12(input.vault, "index.md"), "utf8");
1481
1775
  } catch {
1482
1776
  }
1483
1777
  const indexSlugsLower = /* @__PURE__ */ new Map();
@@ -1486,12 +1780,18 @@ async function runIndexCheck(input) {
1486
1780
  indexSlugsLower.set(tail.toLowerCase(), tail);
1487
1781
  }
1488
1782
  const fileSlugs = /* @__PURE__ */ new Map();
1783
+ const requiredSlugs = /* @__PURE__ */ new Map();
1489
1784
  for (const p of scan.data.typedKnowledge) {
1490
1785
  const slug = p.relPath.replace(/\.md$/, "").split("/").pop();
1491
1786
  fileSlugs.set(slug, p.relPath);
1787
+ requiredSlugs.set(slug, p.relPath);
1788
+ }
1789
+ for (const p of scan.data.compound) {
1790
+ const slug = p.relPath.replace(/\.md$/, "").split("/").pop();
1791
+ fileSlugs.set(slug, p.relPath);
1492
1792
  }
1493
1793
  const missing_from_index = [];
1494
- for (const [slug, relPath] of fileSlugs.entries()) {
1794
+ for (const [slug, relPath] of requiredSlugs.entries()) {
1495
1795
  if (!indexSlugsLower.has(slug.toLowerCase())) missing_from_index.push(relPath);
1496
1796
  }
1497
1797
  const fileSlugsLower = new Set([...fileSlugs.keys()].map((s) => s.toLowerCase()));
@@ -1510,44 +1810,176 @@ async function runIndexCheck(input) {
1510
1810
  }
1511
1811
 
1512
1812
  // src/commands/stale.ts
1513
- import { readFile as readFile9 } from "fs/promises";
1514
- import { join as join10 } from "path";
1515
- function dayDiff(a, b) {
1516
- const da = Date.parse(a);
1517
- const db = Date.parse(b);
1518
- return Math.round((db - da) / 864e5);
1813
+ import { readdir as readdir4, rename as rename2, mkdir as mkdir6, readFile as readFile10 } from "fs/promises";
1814
+ import { join as join13 } from "path";
1815
+ function daysSince(isoDate2) {
1816
+ return Math.floor((Date.now() - Date.parse(isoDate2)) / 864e5);
1519
1817
  }
1520
1818
  async function runStale(input) {
1521
1819
  const scan = await scanVault(input.vault);
1522
1820
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
1523
- const stale = [];
1524
- for (const p of scan.data.typedKnowledge) {
1525
- const fm = extractFrontmatter(await readPage(p));
1526
- if (!fm.ok) continue;
1527
- const updated = typeof fm.data.updated === "string" ? fm.data.updated : void 0;
1528
- const sources = Array.isArray(fm.data.sources) ? fm.data.sources.filter((s) => typeof s === "string") : [];
1529
- if (!updated || sources.length === 0) continue;
1530
- let newest;
1531
- for (const rel of sources) {
1532
- let raw;
1821
+ const staleTranscripts = [];
1822
+ const incompleteWorkItems = [];
1823
+ const archived = [];
1824
+ const workDirs = /* @__PURE__ */ new Map();
1825
+ const projectsDir = join13(input.vault, "projects");
1826
+ let projectSlugs = [];
1827
+ try {
1828
+ projectSlugs = (await readdir4(projectsDir, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
1829
+ } catch {
1830
+ }
1831
+ for (const slug of projectSlugs) {
1832
+ const workPath = join13(projectsDir, slug, "work");
1833
+ let entries;
1834
+ try {
1835
+ entries = await readdir4(workPath, { withFileTypes: true });
1836
+ } catch {
1837
+ continue;
1838
+ }
1839
+ for (const e of entries) {
1840
+ if (!e.isDirectory()) continue;
1841
+ const relDir = `projects/${slug}/work/${e.name}`;
1842
+ const absDir = join13(workPath, e.name);
1843
+ let status = "";
1844
+ let files;
1533
1845
  try {
1534
- raw = await readFile9(join10(input.vault, rel), "utf8");
1846
+ files = await readdir4(absDir);
1535
1847
  } catch {
1848
+ workDirs.set(relDir, "");
1536
1849
  continue;
1537
1850
  }
1538
- const rfm = extractFrontmatter(raw);
1539
- if (!rfm.ok) continue;
1540
- const ing = typeof rfm.data.ingested === "string" ? rfm.data.ingested : void 0;
1541
- if (ing && (!newest || Date.parse(ing) > Date.parse(newest))) newest = ing;
1851
+ for (const f of files) {
1852
+ if (!f.endsWith(".md")) continue;
1853
+ try {
1854
+ const fm = extractFrontmatter(await readFile10(join13(absDir, f), "utf8"));
1855
+ if (fm.ok && typeof fm.data.status === "string") {
1856
+ status = fm.data.status;
1857
+ break;
1858
+ }
1859
+ } catch {
1860
+ }
1861
+ }
1862
+ workDirs.set(relDir, status);
1863
+ }
1864
+ }
1865
+ const transcripts = scan.data.raw.filter((p) => p.relPath.startsWith("raw/transcripts/") && p.relPath.endsWith(".md"));
1866
+ for (const t of transcripts) {
1867
+ const datePrefix = t.relPath.split("/").pop().slice(0, 10);
1868
+ for (const [dir, status] of workDirs) {
1869
+ if (dir.split("/").pop().startsWith(datePrefix) && (status === "done" || status === "invalid")) {
1870
+ staleTranscripts.push({ path: t.relPath, reason: `work item ${dir} is ${status}` });
1871
+ break;
1872
+ }
1873
+ }
1874
+ }
1875
+ const doneWorkItems = [];
1876
+ for (const [relDir, status] of workDirs) {
1877
+ const dirName = relDir.split("/").pop();
1878
+ const dateStr = dirName.slice(0, 10);
1879
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
1880
+ if (daysSince(dateStr) < input.days) continue;
1881
+ let files;
1882
+ try {
1883
+ files = await readdir4(join13(input.vault, relDir));
1884
+ } catch {
1885
+ continue;
1886
+ }
1887
+ const hasSpec = files.includes("spec.md"), hasPlan = files.includes("plan.md"), hasWI = files.includes("work-item.md");
1888
+ if (status === "done") {
1889
+ doneWorkItems.push({ path: relDir, reason: "completed \u2014 should be archived" });
1890
+ } else if (status === "invalid") {
1891
+ doneWorkItems.push({ path: relDir, reason: "invalid \u2014 should be archived" });
1892
+ } else if (hasSpec && !hasPlan) {
1893
+ incompleteWorkItems.push({ path: relDir, reason: "has spec but no plan" });
1894
+ } else if (hasWI && !hasSpec && !hasPlan) {
1895
+ incompleteWorkItems.push({ path: relDir, reason: "only work-item.md, no spec or plan" });
1896
+ }
1897
+ }
1898
+ const stale = [];
1899
+ for (const page of scan.data.typedKnowledge) {
1900
+ try {
1901
+ const text = await readFile10(join13(input.vault, page.relPath), "utf8");
1902
+ const fm = extractFrontmatter(text);
1903
+ if (fm.ok && typeof fm.data.updated === "string") {
1904
+ const age = daysSince(fm.data.updated);
1905
+ if (age >= input.days) {
1906
+ stale.push({ page: page.relPath, reason: `updated ${age} days ago (threshold: ${input.days})` });
1907
+ }
1908
+ }
1909
+ } catch {
1910
+ }
1911
+ }
1912
+ if (input.archive) {
1913
+ const archiveDir = join13(input.vault, "_archive", (/* @__PURE__ */ new Date()).toISOString().slice(0, 10));
1914
+ await mkdir6(archiveDir, { recursive: true });
1915
+ const citedRawPaths = /* @__PURE__ */ new Set();
1916
+ for (const page of scan.data.typedKnowledge) {
1917
+ const text = await readFile10(join13(input.vault, page.relPath), "utf8").catch(() => "");
1918
+ for (const line of text.split("\n")) {
1919
+ for (const m of line.matchAll(/\^\[(raw\/[^\]]+)\]/g)) {
1920
+ citedRawPaths.add(m[1]);
1921
+ }
1922
+ for (const m of line.matchAll(/raw\/[^\s,\]"]+\.md/g)) {
1923
+ citedRawPaths.add(m[0]);
1924
+ }
1925
+ }
1926
+ }
1927
+ for (const t of staleTranscripts) {
1928
+ if (citedRawPaths.has(t.path) || citedRawPaths.has(t.path.replace(/\.md$/, ""))) continue;
1929
+ const dest = join13(archiveDir, t.path.split("/").pop());
1930
+ try {
1931
+ await rename2(join13(input.vault, t.path), dest);
1932
+ archived.push(t.path);
1933
+ } catch {
1934
+ }
1542
1935
  }
1543
- if (!newest) continue;
1544
- const gap = dayDiff(updated, newest);
1545
- if (gap > input.days) {
1546
- stale.push({ page: p.relPath, page_updated: updated, newest_source_ingested: newest, gap_days: gap });
1936
+ for (const w of [...incompleteWorkItems, ...doneWorkItems]) {
1937
+ const parts = w.path.split("/");
1938
+ if (parts.length >= 4 && parts[0] === "projects") {
1939
+ const slug = parts[1];
1940
+ const itemName = parts[3];
1941
+ const histDir = join13(input.vault, "projects", slug, "history", "archived-work");
1942
+ await mkdir6(histDir, { recursive: true });
1943
+ const dest = join13(histDir, itemName);
1944
+ try {
1945
+ await rename2(join13(input.vault, w.path), dest);
1946
+ archived.push(w.path);
1947
+ } catch {
1948
+ }
1949
+ } else {
1950
+ const dest = join13(archiveDir, w.path.replace(/\//g, "_"));
1951
+ try {
1952
+ await rename2(join13(input.vault, w.path), dest);
1953
+ archived.push(w.path);
1954
+ } catch {
1955
+ }
1956
+ }
1547
1957
  }
1548
1958
  }
1549
- 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") }) };
1550
- return { exitCode: ExitCode.OK, result: ok({ stale, humanHint: "no stale pages" }) };
1959
+ if (input.archive && archived.length > 0) {
1960
+ appendLastOp(input.vault, {
1961
+ operation: "stale-archive",
1962
+ summary: `archived ${archived.length} stale items`,
1963
+ files: archived,
1964
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1965
+ });
1966
+ }
1967
+ const total = stale.length + staleTranscripts.length + incompleteWorkItems.length + doneWorkItems.length;
1968
+ const hintLines = [];
1969
+ if (stale.length > 0) hintLines.push(`stale_pages: ${stale.length}`, ...stale.map((p) => ` ${p.page}: ${p.reason}`));
1970
+ if (staleTranscripts.length > 0) hintLines.push(`stale_transcripts: ${staleTranscripts.length}`, ...staleTranscripts.map((t) => ` ${t.path}: ${t.reason}`));
1971
+ if (incompleteWorkItems.length > 0) hintLines.push(`incomplete_work_items: ${incompleteWorkItems.length}`, ...incompleteWorkItems.map((w) => ` ${w.path}: ${w.reason}`));
1972
+ if (doneWorkItems.length > 0) hintLines.push(`done_work_items: ${doneWorkItems.length}`, ...doneWorkItems.map((w) => ` ${w.path}: ${w.reason}`));
1973
+ if (archived.length > 0) hintLines.push(`archived: ${archived.length}`, ...archived.map((a) => ` ${a}`));
1974
+ if (hintLines.length === 0) hintLines.push("no stale transcripts or incomplete work items");
1975
+ return { exitCode: total > 0 ? ExitCode.STALE_PAGE : ExitCode.OK, result: ok({
1976
+ stale: [...stale, ...staleTranscripts.map((t) => ({ page: t.path, reason: t.reason })), ...incompleteWorkItems.map((w) => ({ page: w.path, reason: w.reason })), ...doneWorkItems.map((w) => ({ page: w.path, reason: w.reason }))],
1977
+ stale_transcripts: staleTranscripts,
1978
+ incomplete_work_items: incompleteWorkItems,
1979
+ done_work_items: doneWorkItems,
1980
+ archived,
1981
+ humanHint: hintLines.join("\n")
1982
+ }) };
1551
1983
  }
1552
1984
 
1553
1985
  // src/commands/pagesize.ts
@@ -1567,19 +1999,19 @@ async function runPagesize(input) {
1567
1999
  }
1568
2000
 
1569
2001
  // src/commands/log-rotate.ts
1570
- import { readFile as readFile10, rename as rename2, writeFile as writeFile5, stat as stat5 } from "fs/promises";
1571
- import { join as join11 } from "path";
2002
+ import { readFile as readFile11, rename as rename3, writeFile as writeFile6, stat as stat5 } from "fs/promises";
2003
+ import { join as join14 } from "path";
1572
2004
  var ENTRY_RE = /^## \[(\d{4})-\d{2}-\d{2}\]/gm;
1573
2005
  async function runLogRotate(input) {
1574
2006
  try {
1575
- await stat5(join11(input.vault, "SCHEMA.md"));
2007
+ await stat5(join14(input.vault, "SCHEMA.md"));
1576
2008
  } catch {
1577
2009
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
1578
2010
  }
1579
- const logPath = join11(input.vault, "log.md");
2011
+ const logPath = join14(input.vault, "log.md");
1580
2012
  let logText;
1581
2013
  try {
1582
- logText = await readFile10(logPath, "utf8");
2014
+ logText = await readFile11(logPath, "utf8");
1583
2015
  } catch {
1584
2016
  return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: logPath }) };
1585
2017
  }
@@ -1596,9 +2028,9 @@ async function runLogRotate(input) {
1596
2028
  }
1597
2029
  const newestYear = matches[matches.length - 1][1];
1598
2030
  const rotatedName = `log-${newestYear}.md`;
1599
- const rotatedPath = join11(input.vault, rotatedName);
2031
+ const rotatedPath = join14(input.vault, rotatedName);
1600
2032
  try {
1601
- await rename2(logPath, rotatedPath);
2033
+ await rename3(logPath, rotatedPath);
1602
2034
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1603
2035
  const fresh = `# Vault Log
1604
2036
 
@@ -1608,15 +2040,23 @@ Chronological action log. Newest entries last. Skill writes append entries; lint
1608
2040
 
1609
2041
  - Previous log moved to ${rotatedName}
1610
2042
  `;
1611
- await writeFile5(logPath, fresh, "utf8");
2043
+ await writeFile6(logPath, fresh, "utf8");
1612
2044
  } catch (e) {
1613
2045
  return { exitCode: ExitCode.WRITE_FAILED, result: err("WRITE_FAILED", { message: String(e) }) };
1614
2046
  }
2047
+ appendLastOp(input.vault, {
2048
+ operation: "log-rotate",
2049
+ summary: `rotated ${entries} entries to ${rotatedName}`,
2050
+ files: ["log.md", rotatedName],
2051
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2052
+ });
1615
2053
  return { exitCode: ExitCode.OK, result: ok({ entries, threshold: input.threshold, rotated: true, rotated_to: rotatedName, humanHint: `rotated ${entries} entries to ${rotatedName}` }) };
1616
2054
  }
1617
2055
 
1618
2056
  // src/commands/lint.ts
1619
- import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
2057
+ import { existsSync as existsSync2 } from "fs";
2058
+ import { readFile as readFile13, writeFile as writeFile7 } from "fs/promises";
2059
+ import { join as join17 } from "path";
1620
2060
 
1621
2061
  // src/commands/topic-map-check.ts
1622
2062
  var DEFAULT_THRESHOLD = 200;
@@ -1638,13 +2078,13 @@ async function runTopicMapCheck(input) {
1638
2078
  }
1639
2079
 
1640
2080
  // src/commands/index-link-format.ts
1641
- import { readFile as readFile11 } from "fs/promises";
1642
- import { join as join12 } from "path";
2081
+ import { readFile as readFile12 } from "fs/promises";
2082
+ import { join as join15 } from "path";
1643
2083
  var MD_LINK_RE = /\[[^\[\]]+\]\([^)]+\.md\)/;
1644
2084
  async function runIndexLinkFormat(input) {
1645
2085
  let text = "";
1646
2086
  try {
1647
- text = await readFile11(join12(input.vault, "index.md"), "utf8");
2087
+ text = await readFile12(join15(input.vault, "index.md"), "utf8");
1648
2088
  } catch {
1649
2089
  }
1650
2090
  const markdown_links = [];
@@ -1657,8 +2097,8 @@ ${markdown_links.map((l) => ` line ${l.line}: ${l.text}`).join("\n")}`;
1657
2097
  }
1658
2098
 
1659
2099
  // src/commands/dedup.ts
1660
- import { readFileSync, writeFileSync, unlinkSync } from "fs";
1661
- import { join as join13 } from "path";
2100
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
2101
+ import { join as join16 } from "path";
1662
2102
  async function runDedup(input) {
1663
2103
  const scan = await scanVault(input.vault);
1664
2104
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -1686,7 +2126,7 @@ async function runDedup(input) {
1686
2126
  }
1687
2127
  }
1688
2128
  for (const page of scan.data.typedKnowledge) {
1689
- const text = readFileSync(join13(input.vault, page.relPath), "utf-8");
2129
+ const text = readFileSync3(join16(input.vault, page.relPath), "utf-8");
1690
2130
  let updated = text;
1691
2131
  let changed = false;
1692
2132
  for (const [oldPath, newPath] of replacements) {
@@ -1704,19 +2144,27 @@ async function runDedup(input) {
1704
2144
  }
1705
2145
  }
1706
2146
  if (changed) {
1707
- writeFileSync(join13(input.vault, page.relPath), updated);
2147
+ writeFileSync2(join16(input.vault, page.relPath), updated);
1708
2148
  rewired.push(page.relPath);
1709
2149
  }
1710
2150
  }
1711
2151
  for (const [oldPath] of replacements) {
1712
- const fullPath = join13(input.vault, oldPath);
2152
+ const fullPath = join16(input.vault, oldPath);
1713
2153
  try {
1714
- unlinkSync(fullPath);
2154
+ unlinkSync2(fullPath);
1715
2155
  removed.push(oldPath);
1716
2156
  } catch {
1717
2157
  }
1718
2158
  }
1719
2159
  }
2160
+ if (input.apply && (rewired.length > 0 || removed.length > 0)) {
2161
+ appendLastOp(input.vault, {
2162
+ operation: "dedup",
2163
+ summary: `rewired ${rewired.length} pages, removed ${removed.length} duplicates`,
2164
+ files: [...rewired, ...removed],
2165
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2166
+ });
2167
+ }
1720
2168
  const exitCode = duplicates.length > 0 ? input.apply ? ExitCode.DEDUP_APPLIED : ExitCode.RAW_DEDUP_DETECTED : ExitCode.OK;
1721
2169
  const hintLines = [`scanned: ${totalFiles} raw files`];
1722
2170
  if (duplicates.length > 0) {
@@ -1749,8 +2197,25 @@ function hasDuplicateFrontmatter(body) {
1749
2197
  }
1750
2198
  return false;
1751
2199
  }
1752
- var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_dedup", "tag_not_in_taxonomy"];
1753
- var WARNING_ORDER = ["index_incomplete", "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "orphans", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "missing_overview"];
2200
+ function extractSourceEntries(rawFm) {
2201
+ const lines = rawFm.split(/\r?\n/);
2202
+ const sourcesLineIdx = lines.findIndex((l) => /^sources:/.test(l));
2203
+ if (sourcesLineIdx === -1) return [];
2204
+ const sourcesLine = lines[sourcesLineIdx].trim();
2205
+ const inlineMatch = sourcesLine.match(/^sources:\s*\[(.+)]\s*$/);
2206
+ if (inlineMatch) {
2207
+ return [...inlineMatch[1].matchAll(/"[^"]*"|'[^']*'|[^,\s]\S*/g)].map((m) => m[0].replace(/,\s*$/, ""));
2208
+ }
2209
+ const entries = [];
2210
+ for (let i = sourcesLineIdx + 1; i < lines.length; i++) {
2211
+ const line = lines[i];
2212
+ if (!/^\s+- /.test(line)) break;
2213
+ entries.push(line.replace(/^\s+- /, "").trim());
2214
+ }
2215
+ return entries;
2216
+ }
2217
+ var ERROR_ORDER = ["broken_wikilinks", "invalid_frontmatter", "raw_dedup", "broken_sources", "tag_not_in_taxonomy"];
2218
+ var WARNING_ORDER = ["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"];
1754
2219
  var INFO_ORDER = ["bridges", "page_structure", "topic_map_recommended", "frontmatter_wikilink", "wikilink_citation"];
1755
2220
  async function runLint(input) {
1756
2221
  const buckets = {};
@@ -1777,8 +2242,12 @@ async function runLint(input) {
1777
2242
  if (linkFmt.result.ok && linkFmt.result.data.markdown_links.length > 0) {
1778
2243
  buckets.index_link_format = linkFmt.result.data.markdown_links;
1779
2244
  }
1780
- const stale = await runStale({ vault: input.vault, days: input.days });
1781
- if (stale.result.ok && stale.result.data.stale.length > 0) buckets.stale_page = stale.result.data.stale;
2245
+ const staleResult = await runStale({ vault: input.vault, days: input.days });
2246
+ if (staleResult.result.ok) {
2247
+ const st = staleResult.result.data;
2248
+ const staleList = [...st.stale_transcripts.map((t) => t.path), ...st.incomplete_work_items.map((w) => w.path), ...(st.done_work_items ?? []).map((w) => w.path)];
2249
+ if (staleList.length > 0) buckets.stale_page = staleList;
2250
+ }
1782
2251
  const pagesize = await runPagesize({ vault: input.vault, lines: input.lines });
1783
2252
  if (pagesize.result.ok && pagesize.result.data.oversized.length > 0) buckets.page_too_large = pagesize.result.data.oversized;
1784
2253
  const rotate = await runLogRotate({ vault: input.vault, threshold: input.logThreshold, apply: false });
@@ -1796,6 +2265,8 @@ async function runLint(input) {
1796
2265
  }
1797
2266
  const dedup = await runDedup({ vault: input.vault });
1798
2267
  if (dedup.result.ok && dedup.result.data.duplicates.length > 0) buckets.raw_dedup = dedup.result.data.duplicates;
2268
+ const compoundRefs = await validateCompoundReferences(input.vault);
2269
+ if (compoundRefs.ok && compoundRefs.data.length > 0) buckets.compound_refs = compoundRefs.data;
1799
2270
  const scan = await scanVault(input.vault);
1800
2271
  const allPages = scan.ok ? [...scan.data.typedKnowledge, ...scan.data.raw, ...scan.data.workItems, ...scan.data.compound] : [];
1801
2272
  const slugs = scan.ok ? buildSlugMap(allPages) : /* @__PURE__ */ new Map();
@@ -1807,6 +2278,7 @@ async function runLint(input) {
1807
2278
  const noOverview = [];
1808
2279
  const fmWikilinkFlags = [];
1809
2280
  const wikilinkCitationFlags = [];
2281
+ const brokenSourceFlags = [];
1810
2282
  for (const page of scan.data.typedKnowledge) {
1811
2283
  const text = await readPage(page);
1812
2284
  const split = splitFrontmatter(text);
@@ -1817,6 +2289,15 @@ async function runLint(input) {
1817
2289
  if (isLegacyCitationStyle(body)) legacyPages.push(page.relPath);
1818
2290
  if (hasOrphanedCitations(body)) orphanedPages.push(page.relPath);
1819
2291
  if (hasWikilinkCitations(body)) wikilinkCitationFlags.push(page.relPath);
2292
+ const sourcesEntries = extractSourceEntries(rawFm);
2293
+ for (const entry of sourcesEntries) {
2294
+ let rawPath = entry.replace(/^"/, "").replace(/"$/, "").replace(/^'/, "").replace(/'$/, "");
2295
+ rawPath = rawPath.replace(/^\^\[/, "").replace(/\]$/, "");
2296
+ if (!rawPath.startsWith("raw/") && !rawPath.startsWith("_archive/raw/")) continue;
2297
+ if (!existsSync2(join17(input.vault, rawPath)) && !existsSync2(join17(input.vault, rawPath + ".md")) && !rawPath.startsWith("_archive/") && !existsSync2(join17(input.vault, "_archive", rawPath)) && !existsSync2(join17(input.vault, "_archive", rawPath + ".md"))) {
2298
+ brokenSourceFlags.push(`${page.relPath}: ${rawPath}`);
2299
+ }
2300
+ }
1820
2301
  const fmLinks = rawFm.match(/\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]/g) ?? [];
1821
2302
  for (const link of fmLinks) {
1822
2303
  const target = link.replace(/^\[\[/, "").replace(/(?:\|[^\[\]]*)?\]\]$/, "").trim();
@@ -1846,19 +2327,75 @@ async function runLint(input) {
1846
2327
  if (noOverview.length > 0) buckets.missing_overview = noOverview;
1847
2328
  if (fmWikilinkFlags.length > 0) buckets.frontmatter_wikilink = fmWikilinkFlags;
1848
2329
  if (wikilinkCitationFlags.length > 0) buckets.wikilink_citation = wikilinkCitationFlags;
1849
- if (input.fix && legacyPages.length > 0) {
1850
- const FENCE_RE2 = /```[\s\S]*?```/g;
1851
- const INLINE_MARKER = /\^\[raw\/[^\]]+\]/g;
1852
- for (const relPath of legacyPages) {
1853
- try {
1854
- const absPath = `${input.vault}/${relPath}`;
1855
- const raw = await readFile12(absPath, "utf8");
1856
- const split = splitFrontmatter(raw);
1857
- if (!split.ok) {
1858
- unresolved.push(relPath);
1859
- continue;
2330
+ if (brokenSourceFlags.length > 0) buckets.broken_sources = brokenSourceFlags;
2331
+ const workItemHealth = [];
2332
+ const workItemDirs = /* @__PURE__ */ new Map();
2333
+ for (const page of scan.data.workItems) {
2334
+ const dir = page.relPath.replace(/\/(spec|plan|log)\.md$/, "");
2335
+ const pages = workItemDirs.get(dir) ?? [];
2336
+ pages.push(page);
2337
+ workItemDirs.set(dir, pages);
2338
+ }
2339
+ for (const [dir, pages] of workItemDirs) {
2340
+ const hasSpec = pages.some((p) => p.relPath.endsWith("/spec.md"));
2341
+ const hasPlan = pages.some((p) => p.relPath.endsWith("/plan.md"));
2342
+ if (hasSpec && !hasPlan) {
2343
+ const lastSegment = dir.split("/").pop();
2344
+ const dateMatch = lastSegment.match(/^(\d{4}-\d{2}-\d{2})/);
2345
+ if (dateMatch) {
2346
+ const dirDate = Date.parse(dateMatch[1]);
2347
+ if (!isNaN(dirDate) && Date.now() - dirDate > 24 * 60 * 60 * 1e3) {
2348
+ workItemHealth.push(`${dir}/spec.md: has spec but no plan after 24h`);
1860
2349
  }
1861
- const body = split.data.body;
2350
+ }
2351
+ }
2352
+ for (const page of pages) {
2353
+ if (!page.relPath.endsWith("/spec.md")) continue;
2354
+ const text = await readPage(page);
2355
+ const fm = extractFrontmatter(text);
2356
+ if (fm.ok && fm.data.status === "in-progress" && !fm.data.started) {
2357
+ workItemHealth.push(`${page.relPath}: in-progress without started date`);
2358
+ }
2359
+ }
2360
+ }
2361
+ if (workItemHealth.length > 0) buckets.work_item_health = workItemHealth;
2362
+ const orphanedProjectPages = [];
2363
+ for (const page of scan.data.typedKnowledge) {
2364
+ const text = await readPage(page);
2365
+ const fm = extractFrontmatter(text);
2366
+ if (!fm.ok) continue;
2367
+ const pp = fm.data.provenance_projects;
2368
+ if (!Array.isArray(pp)) continue;
2369
+ for (const entry of pp) {
2370
+ const slugMatch = String(entry).match(/\[\[([^\]]+)\]\]/);
2371
+ if (!slugMatch) continue;
2372
+ const slug = slugMatch[1];
2373
+ const knowledgePath = join17(input.vault, "projects", slug, "knowledge.md");
2374
+ if (!existsSync2(knowledgePath)) continue;
2375
+ const pageRef = page.relPath.replace(/\.md$/, "");
2376
+ try {
2377
+ const knowledgeContent = await readFile13(knowledgePath, "utf8");
2378
+ if (!knowledgeContent.includes(`[[${pageRef}]]`)) {
2379
+ orphanedProjectPages.push(`${page.relPath}: not in projects/${slug}/knowledge.md`);
2380
+ }
2381
+ } catch {
2382
+ }
2383
+ }
2384
+ }
2385
+ if (orphanedProjectPages.length > 0) buckets.orphaned_project_pages = orphanedProjectPages;
2386
+ if (input.fix && legacyPages.length > 0) {
2387
+ const FENCE_RE2 = /```[\s\S]*?```/g;
2388
+ const INLINE_MARKER = /\^\[raw\/[^\]]+\]/g;
2389
+ for (const relPath of legacyPages) {
2390
+ try {
2391
+ const absPath = `${input.vault}/${relPath}`;
2392
+ const raw = await readFile13(absPath, "utf8");
2393
+ const split = splitFrontmatter(raw);
2394
+ if (!split.ok) {
2395
+ unresolved.push(relPath);
2396
+ continue;
2397
+ }
2398
+ const body = split.data.body;
1862
2399
  const rawFm = split.data.rawFrontmatter;
1863
2400
  const stripped = body.replace(FENCE_RE2, "");
1864
2401
  const lines = stripped.split("\n");
@@ -1932,7 +2469,7 @@ async function runLint(input) {
1932
2469
  ${rawFm}
1933
2470
  ---
1934
2471
  ${newBody}`;
1935
- await writeFile6(absPath, newContent, "utf8");
2472
+ await writeFile7(absPath, newContent, "utf8");
1936
2473
  fixed.push(relPath);
1937
2474
  } catch {
1938
2475
  unresolved.push(relPath);
@@ -1945,6 +2482,128 @@ ${newBody}`;
1945
2482
  else delete buckets.legacy_citation_style;
1946
2483
  }
1947
2484
  }
2485
+ if (input.fix && noOverview.length > 0) {
2486
+ for (const relPath of noOverview) {
2487
+ try {
2488
+ const absPath = `${input.vault}/${relPath}`;
2489
+ const raw = await readFile13(absPath, "utf8");
2490
+ const split = splitFrontmatter(raw);
2491
+ if (!split.ok) {
2492
+ unresolved.push(relPath);
2493
+ continue;
2494
+ }
2495
+ const body = split.data.body;
2496
+ const rawFm = split.data.rawFrontmatter;
2497
+ const fm = extractFrontmatter(raw);
2498
+ const title = fm.ok && typeof fm.data.title === "string" ? fm.data.title : "";
2499
+ const overviewSection = `## Overview
2500
+
2501
+ ${title}`;
2502
+ const trimmedBody = body.replace(/^\n+/, "");
2503
+ const newContent = `---
2504
+ ${rawFm}
2505
+ ---
2506
+
2507
+ ${overviewSection}
2508
+
2509
+ ${trimmedBody}`;
2510
+ await writeFile7(absPath, newContent, "utf8");
2511
+ fixed.push(relPath);
2512
+ } catch {
2513
+ unresolved.push(relPath);
2514
+ }
2515
+ }
2516
+ const fixedBeforeOverview = fixed.length;
2517
+ const fixedSet = new Set(fixed);
2518
+ const remaining = noOverview.filter((p) => !fixedSet.has(p));
2519
+ if (remaining.length > 0) buckets.missing_overview = remaining;
2520
+ else delete buckets.missing_overview;
2521
+ }
2522
+ if (input.fix && wikilinkCitationFlags.length > 0) {
2523
+ const WIKILINK_RE = /\[\[raw\/([^\]|]+)(?:\|[^\]]*)?\]\]/g;
2524
+ const FENCE_RE2 = /```[\s\S]*?```/g;
2525
+ const wikilinkFixed = [];
2526
+ for (const relPath of wikilinkCitationFlags) {
2527
+ try {
2528
+ const absPath = `${input.vault}/${relPath}`;
2529
+ const raw = await readFile13(absPath, "utf8");
2530
+ const split = splitFrontmatter(raw);
2531
+ if (!split.ok) {
2532
+ unresolved.push(relPath);
2533
+ continue;
2534
+ }
2535
+ const body = split.data.body;
2536
+ const rawFm = split.data.rawFrontmatter;
2537
+ const stripped = body.replace(FENCE_RE2, "");
2538
+ const wikilinkMatches = [...stripped.matchAll(WIKILINK_RE)];
2539
+ if (wikilinkMatches.length === 0) {
2540
+ unresolved.push(relPath);
2541
+ continue;
2542
+ }
2543
+ const wikilinkPaths = [...new Set(wikilinkMatches.map((m) => m[1]))];
2544
+ const bodyLines = body.split("\n");
2545
+ let inSrc = false;
2546
+ const newBodyLines = [];
2547
+ for (const line of bodyLines) {
2548
+ if (/^## Sources\b/.test(line.trim())) {
2549
+ inSrc = true;
2550
+ newBodyLines.push(line);
2551
+ continue;
2552
+ }
2553
+ if (inSrc) {
2554
+ newBodyLines.push(line);
2555
+ continue;
2556
+ }
2557
+ let cleaned = line.replace(/\[\[raw\/[^\]|]+(?:\|[^\]]*)?\]\]/g, "");
2558
+ cleaned = cleaned.replace(/\s+\./g, ".").replace(/\s{2,}/g, " ").replace(/\s+$/, "");
2559
+ if (cleaned.length > 0 || line.trim().length === 0) {
2560
+ newBodyLines.push(cleaned);
2561
+ }
2562
+ }
2563
+ let newBody = newBodyLines.join("\n");
2564
+ const citationMarkers = wikilinkPaths.map((p) => `^[raw/${p}]`);
2565
+ const sourceEntries = extractSourceEntries(rawFm);
2566
+ const fmMarkers = [];
2567
+ for (const entry of sourceEntries) {
2568
+ let rawPath = entry.replace(/^"/, "").replace(/"$/, "").replace(/^'/, "").replace(/'$/, "");
2569
+ rawPath = rawPath.replace(/^\^\[/, "").replace(/\]$/, "");
2570
+ if (rawPath.startsWith("raw/")) {
2571
+ fmMarkers.push(`^[${rawPath}]`);
2572
+ }
2573
+ }
2574
+ const allMarkers = [.../* @__PURE__ */ new Set([...citationMarkers, ...fmMarkers])];
2575
+ const hasSourcesSection = /^## Sources\b/m.test(newBody);
2576
+ if (hasSourcesSection) {
2577
+ const existingSources = new Set(
2578
+ newBody.split("\n").filter((l) => /^- \^\[raw\//.test(l.trim())).map((l) => l.trim().replace(/^- /, ""))
2579
+ );
2580
+ const newMarkers = allMarkers.filter((m) => !existingSources.has(m));
2581
+ const sourceLines = newMarkers.map((m) => `- ${m}`);
2582
+ if (sourceLines.length > 0) {
2583
+ newBody = newBody.trimEnd() + "\n" + sourceLines.join("\n") + "\n";
2584
+ }
2585
+ } else {
2586
+ const sourceLines = allMarkers.map((m) => `- ${m}`);
2587
+ newBody = newBody.trimEnd() + "\n\n## Sources\n\n" + sourceLines.join("\n") + "\n";
2588
+ }
2589
+ const newContent = `---
2590
+ ${rawFm}
2591
+ ---
2592
+ ${newBody}`;
2593
+ await writeFile7(absPath, newContent, "utf8");
2594
+ wikilinkFixed.push(relPath);
2595
+ } catch {
2596
+ unresolved.push(relPath);
2597
+ }
2598
+ }
2599
+ fixed.push(...wikilinkFixed);
2600
+ if (wikilinkFixed.length > 0) {
2601
+ const fixedSet = new Set(wikilinkFixed);
2602
+ const remaining = wikilinkCitationFlags.filter((p) => !fixedSet.has(p));
2603
+ if (remaining.length > 0) buckets.wikilink_citation = remaining;
2604
+ else delete buckets.wikilink_citation;
2605
+ }
2606
+ }
1948
2607
  }
1949
2608
  const errorOut = ERROR_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
1950
2609
  const warningOut = WARNING_ORDER.flatMap((k) => buckets[k] ? [{ kind: k, items: buckets[k] }] : []);
@@ -1966,6 +2625,14 @@ ${newBody}`;
1966
2625
  hintLines.push(` ${b.kind}: ${b.items.length}`);
1967
2626
  }
1968
2627
  if (hintLines.length === 0) hintLines.push("0 errors, 0 warnings, 0 info");
2628
+ if (input.fix && fixed.length > 0) {
2629
+ appendLastOp(input.vault, {
2630
+ operation: "lint-fix",
2631
+ summary: `fixed ${fixed.length} page(s)`,
2632
+ files: fixed,
2633
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2634
+ });
2635
+ }
1969
2636
  return {
1970
2637
  exitCode,
1971
2638
  result: ok({
@@ -1980,14 +2647,14 @@ ${newBody}`;
1980
2647
  }
1981
2648
 
1982
2649
  // src/commands/config.ts
1983
- import { readFile as readFile13 } from "fs/promises";
1984
- import { existsSync } from "fs";
1985
- import { join as join14 } from "path";
2650
+ import { readFile as readFile14 } from "fs/promises";
2651
+ import { existsSync as existsSync3 } from "fs";
2652
+ import { join as join18 } from "path";
1986
2653
  function validateKey(key) {
1987
2654
  return CONFIG_KEYS.includes(key) || isValidWikiProfileKey(key);
1988
2655
  }
1989
2656
  function configPath(home) {
1990
- return join14(home, ".skillwiki", ".env");
2657
+ return join18(home, ".skillwiki", ".env");
1991
2658
  }
1992
2659
  async function runConfigGet(input) {
1993
2660
  if (!validateKey(input.key)) {
@@ -2005,7 +2672,7 @@ async function runConfigSet(input) {
2005
2672
  try {
2006
2673
  let originalContent;
2007
2674
  try {
2008
- originalContent = await readFile13(filePath, "utf8");
2675
+ originalContent = await readFile14(filePath, "utf8");
2009
2676
  } catch {
2010
2677
  }
2011
2678
  const existing = originalContent !== void 0 ? parseDotenvText(originalContent) : {};
@@ -2037,17 +2704,17 @@ async function runConfigList(input) {
2037
2704
  }
2038
2705
  async function runConfigPath(input) {
2039
2706
  const filePath = configPath(input.home);
2040
- return { exitCode: ExitCode.OK, result: ok({ path: filePath, exists: existsSync(filePath), humanHint: filePath }) };
2707
+ return { exitCode: ExitCode.OK, result: ok({ path: filePath, exists: existsSync3(filePath), humanHint: filePath }) };
2041
2708
  }
2042
2709
 
2043
2710
  // src/commands/doctor.ts
2044
- import { existsSync as existsSync3, readdirSync, statSync } from "fs";
2045
- import { join as join17 } from "path";
2711
+ import { existsSync as existsSync5, lstatSync, readlinkSync, readdirSync, statSync } from "fs";
2712
+ import { join as join21, resolve as resolve4 } from "path";
2046
2713
  import { execSync } from "child_process";
2047
2714
 
2048
2715
  // src/utils/auto-update.ts
2049
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync } from "fs";
2050
- import { join as join15, dirname as dirname6 } from "path";
2716
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
2717
+ import { join as join19, dirname as dirname8 } from "path";
2051
2718
  import { spawn } from "child_process";
2052
2719
 
2053
2720
  // src/utils/update-consts.ts
@@ -2058,11 +2725,11 @@ var CLI_DISABLE_FLAG = "--no-update-notifier";
2058
2725
 
2059
2726
  // src/utils/auto-update.ts
2060
2727
  function cachePath(home) {
2061
- return join15(home, ".skillwiki", CACHE_FILENAME);
2728
+ return join19(home, ".skillwiki", CACHE_FILENAME);
2062
2729
  }
2063
2730
  function readCacheRaw(home) {
2064
2731
  try {
2065
- const raw = readFileSync2(cachePath(home), "utf8");
2732
+ const raw = readFileSync4(cachePath(home), "utf8");
2066
2733
  return JSON.parse(raw);
2067
2734
  } catch {
2068
2735
  return null;
@@ -2077,8 +2744,8 @@ function readCache(home) {
2077
2744
  }
2078
2745
  function writeCache(home, cache) {
2079
2746
  const p = cachePath(home);
2080
- mkdirSync(dirname6(p), { recursive: true });
2081
- writeFileSync2(p, JSON.stringify(cache, null, 2));
2747
+ mkdirSync2(dirname8(p), { recursive: true });
2748
+ writeFileSync3(p, JSON.stringify(cache, null, 2));
2082
2749
  }
2083
2750
  function latestFromCache(home, currentVersion) {
2084
2751
  const { cache } = readCache(home);
@@ -2096,7 +2763,7 @@ function triggerAutoUpdate(home, currentVersion) {
2096
2763
  const { isStale } = readCache(home);
2097
2764
  if (!isStale) return;
2098
2765
  const bgScript = new URL("../auto-update-bg.js", import.meta.url).pathname;
2099
- if (!existsSync2(bgScript)) return;
2766
+ if (!existsSync4(bgScript)) return;
2100
2767
  const child = spawn(process.execPath, [bgScript, home, currentVersion], {
2101
2768
  detached: true,
2102
2769
  stdio: "ignore"
@@ -2107,13 +2774,13 @@ function triggerAutoUpdate(home, currentVersion) {
2107
2774
  }
2108
2775
 
2109
2776
  // src/utils/plugin-registry.ts
2110
- import { readFileSync as readFileSync3 } from "fs";
2111
- import { join as join16 } from "path";
2112
- var REGISTRY_PATH = join16(".claude", "plugins", "installed_plugins.json");
2777
+ import { readFileSync as readFileSync5 } from "fs";
2778
+ import { join as join20 } from "path";
2779
+ var REGISTRY_PATH = join20(".claude", "plugins", "installed_plugins.json");
2113
2780
  var PLUGIN_KEY = "skillwiki@llm-wiki";
2114
2781
  function readInstalledPlugins(home) {
2115
2782
  try {
2116
- const raw = readFileSync3(join16(home, REGISTRY_PATH), "utf8");
2783
+ const raw = readFileSync5(join20(home, REGISTRY_PATH), "utf8");
2117
2784
  return JSON.parse(raw);
2118
2785
  } catch {
2119
2786
  return null;
@@ -2138,23 +2805,79 @@ function checkNodeVersion() {
2138
2805
  }
2139
2806
  return check("error", "node_version", "Node.js version", `Node.js v${major} is below minimum v20`);
2140
2807
  }
2141
- function checkCliOnPath(argv) {
2808
+ function detectCliChannels(argv, home) {
2809
+ const channels = [];
2142
2810
  if (argv.length >= 2 && argv[1].endsWith("cli.js")) {
2143
- return check("warn", "cli_on_path", "skillwiki on PATH", "Running via node cli.js (dev mode) \u2014 PATH check skipped");
2811
+ const devPath = resolve4(argv[1]);
2812
+ channels.push({ name: "dev", path: devPath, isDevLink: true });
2813
+ }
2814
+ try {
2815
+ const whichOut = execSync("which skillwiki 2>/dev/null", { encoding: "utf8" }).trim();
2816
+ if (whichOut) {
2817
+ const isDev = isDevSymlink(whichOut);
2818
+ if (!channels.some((c) => c.path === resolve4(whichOut))) {
2819
+ channels.push({ name: "npm", path: whichOut, isDevLink: isDev });
2820
+ }
2821
+ }
2822
+ } catch {
2144
2823
  }
2145
- if (argv.length >= 2 && argv[1] === "skillwiki") {
2146
- return check("pass", "cli_on_path", "skillwiki on PATH", "Running as skillwiki \u2014 already on PATH");
2824
+ const plugin = findPlugin(home);
2825
+ if (plugin) {
2826
+ const pluginBin = join21(plugin.installPath, "bin", "skillwiki");
2827
+ if (existsSync5(pluginBin)) {
2828
+ channels.push({ name: "plugin", path: pluginBin, isDevLink: false });
2829
+ }
2830
+ }
2831
+ const installBin = join21(home, ".claude", "skills", "bin", "skillwiki");
2832
+ if (existsSync5(installBin)) {
2833
+ channels.push({ name: "install", path: installBin, isDevLink: false });
2147
2834
  }
2835
+ return channels;
2836
+ }
2837
+ function isDevSymlink(binPath) {
2148
2838
  try {
2149
- execSync("which skillwiki 2>/dev/null", { encoding: "utf8" }).trim();
2150
- return check("pass", "cli_on_path", "skillwiki on PATH", "skillwiki found on PATH");
2839
+ const st = lstatSync(binPath);
2840
+ if (st.isSymbolicLink()) {
2841
+ const target = resolve4(binPath, "..", readlinkSync(binPath));
2842
+ return target.includes("packages/cli") || target.includes("packages\\cli");
2843
+ }
2151
2844
  } catch {
2152
- return check("warn", "cli_on_path", "skillwiki on PATH", "skillwiki not found on PATH");
2153
2845
  }
2846
+ return false;
2847
+ }
2848
+ function checkCliChannels(argv, home) {
2849
+ const channels = detectCliChannels(argv, home);
2850
+ if (channels.length === 0) {
2851
+ return check("warn", "cli_channels", "CLI channels", "skillwiki not found on any channel");
2852
+ }
2853
+ if (channels.length === 1) {
2854
+ const ch = channels[0];
2855
+ const label = ch.isDevLink ? `${ch.name} (dev source)` : ch.name;
2856
+ return check("pass", "cli_channels", "CLI channels", `Single channel: ${label}`);
2857
+ }
2858
+ const devChannels = channels.filter((c) => c.isDevLink);
2859
+ const prodChannels = channels.filter((c) => !c.isDevLink);
2860
+ if (devChannels.length > 0 && prodChannels.length > 0) {
2861
+ const devNames = devChannels.map((c) => `${c.name}(dev)`);
2862
+ const prodNames = prodChannels.map((c) => c.name);
2863
+ return check(
2864
+ "warn",
2865
+ "cli_channels",
2866
+ "CLI channels",
2867
+ `${channels.length} channels: ${[...devNames, ...prodNames].join(", ")} \u2014 dev and prod binaries overlap; dev repo should use project-local settings only`
2868
+ );
2869
+ }
2870
+ const names = channels.map((c) => c.name);
2871
+ return check(
2872
+ "warn",
2873
+ "cli_channels",
2874
+ "CLI channels",
2875
+ `${channels.length} channels: ${names.join(", ")} \u2014 remove unused install with: rm ~/.claude/skills/bin/skillwiki`
2876
+ );
2154
2877
  }
2155
2878
  async function checkConfigFile(home) {
2156
2879
  const cfgPath = configPath(home);
2157
- if (!existsSync3(cfgPath)) {
2880
+ if (!existsSync5(cfgPath)) {
2158
2881
  return check("warn", "config_file", "Config file exists", `${cfgPath} not found`);
2159
2882
  }
2160
2883
  try {
@@ -2169,7 +2892,7 @@ function checkWikiPathExists(resolvedPath) {
2169
2892
  if (resolvedPath === void 0) {
2170
2893
  return check("error", "wiki_path_exists", "Vault directory exists", "Cannot check \u2014 WIKI_PATH not resolved");
2171
2894
  }
2172
- if (existsSync3(resolvedPath) && statSync(resolvedPath).isDirectory()) {
2895
+ if (existsSync5(resolvedPath) && statSync(resolvedPath).isDirectory()) {
2173
2896
  return check("pass", "wiki_path_exists", "Vault directory exists", resolvedPath);
2174
2897
  }
2175
2898
  return check("error", "wiki_path_exists", "Vault directory exists", `${resolvedPath} does not exist or is not a directory`);
@@ -2178,20 +2901,27 @@ function checkVaultStructure(resolvedPath) {
2178
2901
  if (resolvedPath === void 0) {
2179
2902
  return check("error", "vault_structure", "Vault structure valid", "Cannot check \u2014 WIKI_PATH not resolved");
2180
2903
  }
2181
- if (!existsSync3(resolvedPath)) {
2904
+ if (!existsSync5(resolvedPath)) {
2182
2905
  return check("error", "vault_structure", "Vault structure valid", "Cannot check \u2014 vault directory does not exist");
2183
2906
  }
2184
2907
  const missing = [];
2185
- if (!existsSync3(join17(resolvedPath, "SCHEMA.md"))) missing.push("SCHEMA.md");
2908
+ if (!existsSync5(join21(resolvedPath, "SCHEMA.md"))) missing.push("SCHEMA.md");
2186
2909
  for (const dir of ["raw", "entities", "concepts", "meta"]) {
2187
- if (!existsSync3(join17(resolvedPath, dir))) missing.push(dir + "/");
2910
+ if (!existsSync5(join21(resolvedPath, dir))) missing.push(dir + "/");
2188
2911
  }
2189
2912
  if (missing.length === 0) {
2190
2913
  return check("pass", "vault_structure", "Vault structure valid", "All required files and directories present");
2191
2914
  }
2192
2915
  return check("warn", "vault_structure", "Vault structure valid", `Missing: ${missing.join(", ")} \u2014 run \`skillwiki init\` to add CodeWiki structure`);
2193
2916
  }
2194
- function checkSkillsInstalled(home) {
2917
+ function checkSkillsInstalled(home, cwd) {
2918
+ const srcDir = cwd ? join21(cwd, "packages", "skills") : void 0;
2919
+ if (srcDir && existsSync5(srcDir)) {
2920
+ const found = findSkillMd(srcDir);
2921
+ if (found.length > 0) {
2922
+ return check("pass", "skills_installed", "Skills installed", `${found.length} SKILL.md file(s) found (source)`);
2923
+ }
2924
+ }
2195
2925
  const plugin = findPlugin(home);
2196
2926
  if (plugin) {
2197
2927
  const found = findSkillMd(plugin.installPath);
@@ -2199,8 +2929,8 @@ function checkSkillsInstalled(home) {
2199
2929
  return check("pass", "skills_installed", "Skills installed", `${found.length} SKILL.md file(s) found (plugin v${plugin.version})`);
2200
2930
  }
2201
2931
  }
2202
- const skillsDir = join17(home, ".claude", "skills");
2203
- if (existsSync3(skillsDir)) {
2932
+ const skillsDir = join21(home, ".claude", "skills");
2933
+ if (existsSync5(skillsDir)) {
2204
2934
  const found = findSkillMd(skillsDir);
2205
2935
  if (found.length > 0) {
2206
2936
  return check("pass", "skills_installed", "Skills installed", `${found.length} SKILL.md file(s) found (CLI install)`);
@@ -2208,6 +2938,25 @@ function checkSkillsInstalled(home) {
2208
2938
  }
2209
2939
  return check("warn", "skills_installed", "Skills installed", "No SKILL.md files found");
2210
2940
  }
2941
+ function checkDuplicateSkills(home) {
2942
+ const plugin = findPlugin(home);
2943
+ const skillsDir = join21(home, ".claude", "skills");
2944
+ if (!plugin || !existsSync5(skillsDir)) {
2945
+ return check("pass", "skills_duplicate", "Skills not duplicated", "Single install channel");
2946
+ }
2947
+ const pluginSkills = findSkillNames(plugin.installPath);
2948
+ const cliSkills = findSkillNames(skillsDir);
2949
+ const duplicates = pluginSkills.filter((name) => cliSkills.includes(name));
2950
+ if (duplicates.length === 0) {
2951
+ return check("pass", "skills_duplicate", "Skills not duplicated", "No overlap between plugin and CLI install");
2952
+ }
2953
+ return check(
2954
+ "warn",
2955
+ "skills_duplicate",
2956
+ "Skills not duplicated",
2957
+ `${duplicates.length} skill(s) in both plugin and ~/.claude/skills/ \u2014 remove CLI copies: rm -r ~/.claude/skills/{${duplicates.slice(0, 3).join(",")}${duplicates.length > 3 ? ",\u2026" : ""}}`
2958
+ );
2959
+ }
2211
2960
  function checkNpmUpdate(home, currentVersion) {
2212
2961
  const { hasUpdate, latest } = latestFromCache(home, currentVersion);
2213
2962
  if (!latest) {
@@ -2257,12 +3006,112 @@ async function checkProfiles(home) {
2257
3006
  }
2258
3007
  async function checkProjectLocalOverride(cwd) {
2259
3008
  const dir = cwd ?? process.cwd();
2260
- const envPath = join17(dir, ".skillwiki", ".env");
2261
- if (existsSync3(envPath)) {
3009
+ const envPath = join21(dir, ".skillwiki", ".env");
3010
+ if (existsSync5(envPath)) {
2262
3011
  return check("pass", "project_local", "Project-local config", `Found: ${envPath}`);
2263
3012
  }
2264
3013
  return check("pass", "project_local", "Project-local config", "None");
2265
3014
  }
3015
+ function checkVaultGitRemote(resolvedPath) {
3016
+ if (resolvedPath === void 0) {
3017
+ return check("error", "vault_git_remote", "Vault git remote", "Cannot check \u2014 WIKI_PATH not resolved");
3018
+ }
3019
+ if (!existsSync5(join21(resolvedPath, ".git"))) {
3020
+ return check("warn", "vault_git_remote", "Vault git remote", "Vault is not a git repository \u2014 sync features unavailable");
3021
+ }
3022
+ try {
3023
+ const remote = execSync("git remote", { cwd: resolvedPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3024
+ if (!remote) {
3025
+ return check("warn", "vault_git_remote", "Vault git remote", "No remote configured \u2014 push/pull unavailable");
3026
+ }
3027
+ let branch = "(no commits yet)";
3028
+ try {
3029
+ branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: resolvedPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
3030
+ } catch {
3031
+ }
3032
+ return check("pass", "vault_git_remote", "Vault git remote", `Remote: ${remote.split("\n")[0]}, branch: ${branch}`);
3033
+ } catch {
3034
+ return check("warn", "vault_git_remote", "Vault git remote", "Could not read git remote info");
3035
+ }
3036
+ }
3037
+ function checkObsidianTemplates(resolvedPath) {
3038
+ if (resolvedPath === void 0) {
3039
+ return check("error", "obsidian_templates", "Obsidian templates", "Cannot check \u2014 WIKI_PATH not resolved");
3040
+ }
3041
+ const missing = [];
3042
+ if (!existsSync5(join21(resolvedPath, "_Templates"))) missing.push("_Templates/");
3043
+ if (!existsSync5(join21(resolvedPath, ".obsidian", "templates.json"))) missing.push(".obsidian/templates.json");
3044
+ if (!existsSync5(join21(resolvedPath, ".obsidian", "app.json"))) missing.push(".obsidian/app.json");
3045
+ if (missing.length === 0) {
3046
+ return check("pass", "obsidian_templates", "Obsidian templates", "Template folder and config present");
3047
+ }
3048
+ return check("warn", "obsidian_templates", "Obsidian templates", `Missing: ${missing.join(", ")} \u2014 run \`skillwiki init\` to create`);
3049
+ }
3050
+ function checkDotStoreClean(resolvedPath) {
3051
+ if (resolvedPath === void 0) {
3052
+ return check("error", "dsstore_clean", "No .DS_Store in raw/", "Cannot check \u2014 WIKI_PATH not resolved");
3053
+ }
3054
+ const rawDir = join21(resolvedPath, "raw");
3055
+ if (!existsSync5(rawDir)) {
3056
+ return check("pass", "dsstore_clean", "No .DS_Store in raw/", "raw/ directory not found \u2014 check skipped");
3057
+ }
3058
+ const found = [];
3059
+ (function walk2(dir, rel) {
3060
+ let entries;
3061
+ try {
3062
+ entries = readdirSync(dir, { withFileTypes: true });
3063
+ } catch {
3064
+ return;
3065
+ }
3066
+ for (const entry of entries) {
3067
+ if (entry.name === ".DS_Store") {
3068
+ found.push(rel ? `${rel}/.DS_Store` : ".DS_Store");
3069
+ } else if (entry.isDirectory()) {
3070
+ walk2(join21(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name);
3071
+ }
3072
+ }
3073
+ })(rawDir, "");
3074
+ if (found.length === 0) {
3075
+ return check("pass", "dsstore_clean", "No .DS_Store in raw/", "No .DS_Store files found");
3076
+ }
3077
+ return check("warn", "dsstore_clean", "No .DS_Store in raw/", `${found.length} .DS_Store file(s) found \u2014 remove with: find ${rawDir} -name .DS_Store -delete`);
3078
+ }
3079
+ function checkSyncLastPush(resolvedPath) {
3080
+ if (resolvedPath === void 0) {
3081
+ return check("error", "sync_last_push", "Vault sync recency", "Cannot check \u2014 WIKI_PATH not resolved");
3082
+ }
3083
+ if (!existsSync5(join21(resolvedPath, ".git"))) {
3084
+ return check("pass", "sync_last_push", "Vault sync recency", "No git repo \u2014 sync check skipped");
3085
+ }
3086
+ let timestamp;
3087
+ try {
3088
+ const out = execSync("git log -1 --format=%ct origin/HEAD", {
3089
+ cwd: resolvedPath,
3090
+ encoding: "utf8",
3091
+ stdio: ["pipe", "pipe", "pipe"]
3092
+ }).trim();
3093
+ timestamp = parseInt(out, 10);
3094
+ } catch {
3095
+ try {
3096
+ const out = execSync("git log -1 --format=%ct HEAD", {
3097
+ cwd: resolvedPath,
3098
+ encoding: "utf8",
3099
+ stdio: ["pipe", "pipe", "pipe"]
3100
+ }).trim();
3101
+ timestamp = parseInt(out, 10);
3102
+ } catch {
3103
+ }
3104
+ }
3105
+ if (timestamp === void 0 || isNaN(timestamp)) {
3106
+ return check("warn", "sync_last_push", "Vault sync recency", "No commits found \u2014 consider running `skillwiki sync status`");
3107
+ }
3108
+ const daysSince2 = Math.floor((Date.now() / 1e3 - timestamp) / 86400);
3109
+ const dateStr = new Date(timestamp * 1e3).toISOString().slice(0, 10);
3110
+ if (daysSince2 > 7) {
3111
+ return check("warn", "sync_last_push", "Vault sync recency", `Last push was ${daysSince2} days ago \u2014 consider running \`skillwiki sync status\``);
3112
+ }
3113
+ return check("pass", "sync_last_push", "Vault sync recency", `Last push: ${dateStr} (${daysSince2} day(s) ago)`);
3114
+ }
2266
3115
  function findSkillMd(dir) {
2267
3116
  const results = [];
2268
3117
  let entries;
@@ -2273,9 +3122,24 @@ function findSkillMd(dir) {
2273
3122
  }
2274
3123
  for (const entry of entries) {
2275
3124
  if (entry.isFile() && entry.name === "SKILL.md") {
2276
- results.push(join17(dir, entry.name));
3125
+ results.push(join21(dir, entry.name));
2277
3126
  } else if (entry.isDirectory()) {
2278
- results.push(...findSkillMd(join17(dir, entry.name)));
3127
+ results.push(...findSkillMd(join21(dir, entry.name)));
3128
+ }
3129
+ }
3130
+ return results;
3131
+ }
3132
+ function findSkillNames(dir) {
3133
+ const results = [];
3134
+ let entries;
3135
+ try {
3136
+ entries = readdirSync(dir, { withFileTypes: true });
3137
+ } catch {
3138
+ return results;
3139
+ }
3140
+ for (const entry of entries) {
3141
+ if (entry.isDirectory() && existsSync5(join21(dir, entry.name, "SKILL.md"))) {
3142
+ results.push(entry.name);
2279
3143
  }
2280
3144
  }
2281
3145
  return results;
@@ -2283,7 +3147,7 @@ function findSkillMd(dir) {
2283
3147
  async function runDoctor(input) {
2284
3148
  const checks = [];
2285
3149
  checks.push(checkNodeVersion());
2286
- checks.push(checkCliOnPath(input.argv));
3150
+ checks.push(checkCliChannels(input.argv, input.home));
2287
3151
  checks.push(await checkConfigFile(input.home));
2288
3152
  checks.push(await checkProfiles(input.home));
2289
3153
  checks.push(await checkProjectLocalOverride(input.cwd));
@@ -2296,7 +3160,12 @@ async function runDoctor(input) {
2296
3160
  const resolvedPath = resolved.ok ? resolved.data.path : void 0;
2297
3161
  checks.push(checkWikiPathExists(resolvedPath));
2298
3162
  checks.push(checkVaultStructure(resolvedPath));
2299
- checks.push(checkSkillsInstalled(input.home));
3163
+ checks.push(checkObsidianTemplates(resolvedPath));
3164
+ checks.push(checkVaultGitRemote(resolvedPath));
3165
+ checks.push(checkSyncLastPush(resolvedPath));
3166
+ checks.push(checkDotStoreClean(resolvedPath));
3167
+ checks.push(checkSkillsInstalled(input.home, input.cwd));
3168
+ checks.push(checkDuplicateSkills(input.home));
2300
3169
  checks.push(checkNpmUpdate(input.home, input.currentVersion));
2301
3170
  checks.push(checkPluginVersionDrift(input.home, input.currentVersion));
2302
3171
  const summary = {
@@ -2318,8 +3187,8 @@ async function runDoctor(input) {
2318
3187
  }
2319
3188
 
2320
3189
  // src/commands/archive.ts
2321
- import { rename as rename3, mkdir as mkdir5, readFile as readFile14, writeFile as writeFile7 } from "fs/promises";
2322
- import { join as join18, dirname as dirname7 } from "path";
3190
+ import { rename as rename4, mkdir as mkdir7, readFile as readFile15, writeFile as writeFile8 } from "fs/promises";
3191
+ import { join as join22, dirname as dirname9 } from "path";
2323
3192
  async function runArchive(input) {
2324
3193
  const scan = await scanVault(input.vault);
2325
3194
  if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
@@ -2335,31 +3204,37 @@ async function runArchive(input) {
2335
3204
  }
2336
3205
  if (!relPath) return { exitCode: ExitCode.ARCHIVE_TARGET_NOT_FOUND, result: err("ARCHIVE_TARGET_NOT_FOUND", { page: input.page }) };
2337
3206
  if (relPath.startsWith("_archive/")) return { exitCode: ExitCode.ARCHIVE_ALREADY_ARCHIVED, result: err("ARCHIVE_ALREADY_ARCHIVED", { page: relPath }) };
2338
- const archivePath = join18("_archive", relPath);
2339
- await mkdir5(dirname7(join18(input.vault, archivePath)), { recursive: true });
3207
+ const archivePath = join22("_archive", relPath).replace(/\\/g, "/");
3208
+ await mkdir7(dirname9(join22(input.vault, archivePath)), { recursive: true });
2340
3209
  let indexUpdated = false;
2341
3210
  if (!isRaw) {
2342
- const indexPath = join18(input.vault, "index.md");
3211
+ const indexPath = join22(input.vault, "index.md");
2343
3212
  try {
2344
- const idx = await readFile14(indexPath, "utf8");
3213
+ const idx = await readFile15(indexPath, "utf8");
2345
3214
  const slug = relPath.replace(/\.md$/, "").split("/").pop();
2346
3215
  const originalLines = idx.split("\n");
2347
3216
  const filtered = originalLines.filter((l) => !l.includes(`[[${slug}]]`));
2348
3217
  if (filtered.length !== originalLines.length) {
2349
- await writeFile7(indexPath, filtered.join("\n"), "utf8");
3218
+ await writeFile8(indexPath, filtered.join("\n"), "utf8");
2350
3219
  indexUpdated = true;
2351
3220
  }
2352
3221
  } catch (e) {
2353
3222
  if (e?.code !== "ENOENT") throw e;
2354
3223
  }
2355
3224
  }
2356
- await rename3(join18(input.vault, relPath), join18(input.vault, archivePath));
3225
+ await rename4(join22(input.vault, relPath), join22(input.vault, archivePath));
3226
+ appendLastOp(input.vault, {
3227
+ operation: "archive",
3228
+ summary: `moved ${relPath} to ${archivePath}`,
3229
+ files: [relPath],
3230
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3231
+ });
2357
3232
  return { exitCode: ExitCode.OK, result: ok({ archived_from: relPath, archived_to: archivePath, index_updated: indexUpdated, humanHint: `${relPath} -> ${archivePath}${indexUpdated ? " (index updated)" : ""}` }) };
2358
3233
  }
2359
3234
 
2360
3235
  // src/commands/drift.ts
2361
3236
  import { createHash as createHash2 } from "crypto";
2362
- import { writeFile as writeFile8 } from "fs/promises";
3237
+ import { writeFile as writeFile9 } from "fs/promises";
2363
3238
 
2364
3239
  // src/utils/fetch.ts
2365
3240
  async function controlledFetch(url, opts) {
@@ -2425,6 +3300,7 @@ async function runDrift(input) {
2425
3300
  const sourceUrl = sourceUrlMatch[1].trim();
2426
3301
  const storedHash = storedHashMatch[1];
2427
3302
  if (!sourceUrl.startsWith("http://") && !sourceUrl.startsWith("https://")) continue;
3303
+ if (/^refreshable:\s*false\b/m.test(rawFrontmatter)) continue;
2428
3304
  const resp = await doFetch(sourceUrl, FETCH_OPTS);
2429
3305
  if (!resp.ok) {
2430
3306
  results.push({
@@ -2445,7 +3321,7 @@ async function runDrift(input) {
2445
3321
  ${newFm}
2446
3322
  ---
2447
3323
  ${body}`;
2448
- await writeFile8(raw.absPath, newText, "utf8");
3324
+ await writeFile9(raw.absPath, newText, "utf8");
2449
3325
  results.push({
2450
3326
  raw_path: raw.relPath,
2451
3327
  source_url: sourceUrl,
@@ -2473,6 +3349,14 @@ ${body}`;
2473
3349
  if (drifted.length > 0) hintLines.push(`drifted: ${drifted.length}`, ...drifted.map((d) => ` ${d.raw_path}`));
2474
3350
  if (fetchFailed.length > 0) hintLines.push(`fetch_failed: ${fetchFailed.length}`, ...fetchFailed.map((f) => ` ${f.raw_path}: ${f.fetch_error}`));
2475
3351
  if (updated.length > 0) hintLines.push(`updated: ${updated.length}`, ...updated.map((u) => ` ${u.raw_path}`));
3352
+ if (input.apply && updated.length > 0) {
3353
+ appendLastOp(input.vault, {
3354
+ operation: "drift-apply",
3355
+ summary: `updated ${updated.length} raw sources`,
3356
+ files: updated.map((u) => u.raw_path),
3357
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3358
+ });
3359
+ }
2476
3360
  return {
2477
3361
  exitCode,
2478
3362
  result: ok({ scanned: results.length, drifted, fetch_failed: fetchFailed, updated, newFiles: newResults, unchanged, humanHint: hintLines.join("\n") })
@@ -2480,7 +3364,7 @@ ${body}`;
2480
3364
  }
2481
3365
 
2482
3366
  // src/commands/migrate-citations.ts
2483
- import { writeFile as writeFile9 } from "fs/promises";
3367
+ import { writeFile as writeFile10 } from "fs/promises";
2484
3368
  var MARKER_RE2 = /\^\[(raw\/[^\]]+)\]/g;
2485
3369
  function moveMarkersToParagraphEnd(body) {
2486
3370
  const lines = body.split("\n");
@@ -2603,7 +3487,7 @@ ${migratedBody}${newFooter}`;
2603
3487
  continue;
2604
3488
  }
2605
3489
  if (!input.dryRun) {
2606
- await writeFile9(page.absPath, newText, "utf8");
3490
+ await writeFile10(page.absPath, newText, "utf8");
2607
3491
  }
2608
3492
  migrated.push(page.relPath);
2609
3493
  }
@@ -2612,6 +3496,14 @@ ${migratedBody}${newFooter}`;
2612
3496
  if (migrated.length > 0) hintLines.push(`migrated: ${migrated.length}`);
2613
3497
  if (skipped.length > 0) hintLines.push(`skipped (already clean): ${skipped.length}`);
2614
3498
  if (unchanged > 0) hintLines.push(`unchanged (no markers): ${unchanged}`);
3499
+ if (!input.dryRun && migrated.length > 0) {
3500
+ appendLastOp(input.vault, {
3501
+ operation: "migrate-citations",
3502
+ summary: `converted ${migrated.length} citation(s)`,
3503
+ files: migrated,
3504
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3505
+ });
3506
+ }
2615
3507
  return {
2616
3508
  exitCode,
2617
3509
  result: ok({
@@ -2625,7 +3517,7 @@ ${migratedBody}${newFooter}`;
2625
3517
  }
2626
3518
 
2627
3519
  // src/commands/frontmatter-fix.ts
2628
- import { writeFile as writeFile10 } from "fs/promises";
3520
+ import { writeFile as writeFile11 } from "fs/promises";
2629
3521
  function isoToday() {
2630
3522
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2631
3523
  }
@@ -2667,7 +3559,7 @@ ${newBody}`;
2667
3559
  continue;
2668
3560
  }
2669
3561
  if (!input.dryRun) {
2670
- await writeFile10(page.absPath, newText, "utf8");
3562
+ await writeFile11(page.absPath, newText, "utf8");
2671
3563
  }
2672
3564
  fixed.push(page.relPath);
2673
3565
  }
@@ -2677,6 +3569,14 @@ ${newBody}`;
2677
3569
  if (skipped.length > 0) hintLines.push(`skipped (parse error): ${skipped.length}`);
2678
3570
  if (unchanged > 0) hintLines.push(`unchanged: ${unchanged}`);
2679
3571
  if (input.dryRun && fixed.length > 0) hintLines.push("(dry run \u2014 no files written)");
3572
+ if (!input.dryRun && fixed.length > 0) {
3573
+ appendLastOp(input.vault, {
3574
+ operation: "frontmatter-fix",
3575
+ summary: `normalized frontmatter on ${fixed.length} page(s)`,
3576
+ files: fixed,
3577
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3578
+ });
3579
+ }
2680
3580
  return {
2681
3581
  exitCode,
2682
3582
  result: ok({
@@ -2691,13 +3591,41 @@ ${newBody}`;
2691
3591
 
2692
3592
  // src/commands/update.ts
2693
3593
  import { execSync as execSync2 } from "child_process";
2694
- import { readFileSync as readFileSync4 } from "fs";
3594
+ import { readFileSync as readFileSync6 } from "fs";
3595
+ import { join as join23 } from "path";
3596
+ function resolveGlobalSkillsRoot() {
3597
+ try {
3598
+ const globalRoot = execSync2("npm root -g", {
3599
+ encoding: "utf8",
3600
+ timeout: 5e3
3601
+ }).trim();
3602
+ return join23(globalRoot, "skillwiki", "skills");
3603
+ } catch {
3604
+ return null;
3605
+ }
3606
+ }
3607
+ async function refreshInstalledSkills(target) {
3608
+ const skillsRoot = resolveGlobalSkillsRoot();
3609
+ if (!skillsRoot) {
3610
+ return { warnings: ["could not locate global skillwiki installation for skill refresh"], refreshed: false };
3611
+ }
3612
+ try {
3613
+ const result = await runInstall({ skillsRoot, target, dryRun: false, symlink: false });
3614
+ if (result.result.ok) {
3615
+ return { warnings: result.result.data.version_warnings, refreshed: true };
3616
+ }
3617
+ return { warnings: [`skill refresh failed: ${result.result.error}`], refreshed: false };
3618
+ } catch (e) {
3619
+ return { warnings: [`skill refresh error: ${String(e)}`], refreshed: false };
3620
+ }
3621
+ }
2695
3622
  async function runUpdate(input) {
2696
3623
  const pkg2 = JSON.parse(
2697
- readFileSync4(new URL("../../package.json", import.meta.url), "utf8")
3624
+ readFileSync6(new URL("../../package.json", import.meta.url), "utf8")
2698
3625
  );
2699
3626
  const currentVersion = pkg2.version;
2700
3627
  const tag = input.distTag ?? "beta";
3628
+ const target = join23(input.home, ".claude", "skills");
2701
3629
  let latest;
2702
3630
  try {
2703
3631
  latest = execSync2(`npm view skillwiki@${tag} version`, {
@@ -2723,6 +3651,8 @@ async function runUpdate(input) {
2723
3651
  previousVersion: currentVersion,
2724
3652
  newVersion: null,
2725
3653
  wasAlreadyLatest: true,
3654
+ version_warnings: [],
3655
+ skills_refreshed: false,
2726
3656
  humanHint: `Already on latest ${tag}: v${currentVersion}`
2727
3657
  })
2728
3658
  };
@@ -2739,78 +3669,254 @@ async function runUpdate(input) {
2739
3669
  };
2740
3670
  }
2741
3671
  writeCache(input.home, { ...cache, updateAppliedAt: Date.now() });
3672
+ const installResult = await refreshInstalledSkills(target);
3673
+ const version_warnings = installResult.warnings;
3674
+ const skills_refreshed = installResult.refreshed;
3675
+ const hintLines = [
3676
+ `Updated skillwiki ${currentVersion} \u2192 ${latest}`,
3677
+ `skills refreshed: ${skills_refreshed}`
3678
+ ];
3679
+ if (version_warnings.length > 0) {
3680
+ hintLines.push(`version warnings: ${version_warnings.length}`);
3681
+ for (const w of version_warnings) hintLines.push(` ${w}`);
3682
+ }
2742
3683
  return {
2743
3684
  exitCode: ExitCode.OK,
2744
3685
  result: ok({
2745
3686
  previousVersion: currentVersion,
2746
3687
  newVersion: latest,
2747
3688
  wasAlreadyLatest: false,
2748
- humanHint: `Updated skillwiki ${currentVersion} \u2192 ${latest}`
3689
+ version_warnings,
3690
+ skills_refreshed,
3691
+ humanHint: hintLines.join("\n")
2749
3692
  })
2750
3693
  };
2751
3694
  }
2752
3695
 
2753
- // src/commands/transcripts.ts
2754
- import { readdir as readdir4, stat as stat6, readFile as readFile15 } from "fs/promises";
2755
- import { join as join19 } from "path";
2756
- async function runTranscripts(input) {
2757
- const dir = join19(input.vault, "raw", "transcripts");
2758
- let entries;
2759
- try {
2760
- entries = await readdir4(dir, { withFileTypes: true });
2761
- } catch {
2762
- return { exitCode: ExitCode.VAULT_PATH_INVALID, result: { ok: false, error: "VAULT_PATH_INVALID", detail: `raw/transcripts/ not found: ${dir}` } };
2763
- }
2764
- const transcripts = [];
2765
- for (const entry of entries) {
2766
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
2767
- const filePath = join19(dir, entry.name);
2768
- const content = await readFile15(filePath, "utf8");
2769
- const fm = extractFrontmatter(content);
2770
- if (!fm.ok) continue;
2771
- const ingested = typeof fm.data.ingested === "string" ? fm.data.ingested : "";
2772
- if (input.since && ingested && ingested < input.since) continue;
2773
- const s = await stat6(filePath);
2774
- transcripts.push({
2775
- file: `raw/transcripts/${entry.name}`,
2776
- ingested,
2777
- size: s.size
2778
- });
2779
- }
2780
- const hint = transcripts.length > 0 ? transcripts.map((t) => `${t.file} (ingested: ${t.ingested || "unknown"}, ${t.size}B)`).join("\n") : "no transcript files found";
2781
- return { exitCode: ExitCode.OK, result: ok({ transcripts, humanHint: hint }) };
2782
- }
2783
-
2784
- // src/commands/project-index.ts
2785
- import { readdir as readdir5, readFile as readFile16, writeFile as writeFile11, mkdir as mkdir6 } from "fs/promises";
2786
- import { join as join20, dirname as dirname8 } from "path";
2787
- var LAYER2_DIRS = ["entities", "concepts", "comparisons", "queries", "meta"];
2788
- async function runProjectIndex(input) {
2789
- const slug = input.slug;
2790
- const projectDir = join20(input.vault, "projects", slug);
2791
- try {
2792
- await readdir5(projectDir);
2793
- } catch {
3696
+ // src/commands/self-update.ts
3697
+ import { execSync as execSync3 } from "child_process";
3698
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
3699
+ import { join as join24 } from "path";
3700
+ var DEFAULT_SOURCE_ROOT_SUFFIX = "/Desktop/code/llm-wiki";
3701
+ async function runSelfUpdate(input) {
3702
+ const currentVersion = JSON.parse(
3703
+ readFileSync7(new URL("../../package.json", import.meta.url), "utf8")
3704
+ ).version;
3705
+ const sourceRoot = input.sourceRoot ?? `${input.home}${DEFAULT_SOURCE_ROOT_SUFFIX}`;
3706
+ const localPkgPath = join24(sourceRoot, "packages", "cli", "package.json");
3707
+ const hasLocalSource = existsSync6(localPkgPath);
3708
+ if (input.check) {
3709
+ let availableVersion = null;
3710
+ let source;
3711
+ if (hasLocalSource) {
3712
+ source = "local";
3713
+ try {
3714
+ availableVersion = JSON.parse(readFileSync7(localPkgPath, "utf8")).version ?? null;
3715
+ } catch {
3716
+ availableVersion = null;
3717
+ }
3718
+ } else {
3719
+ source = "npm";
3720
+ try {
3721
+ availableVersion = execSync3("npm view skillwiki@beta version", {
3722
+ encoding: "utf8",
3723
+ timeout: 15e3
3724
+ }).trim();
3725
+ } catch (e) {
3726
+ return {
3727
+ exitCode: ExitCode.INTERNAL_ERROR,
3728
+ result: err("PREFLIGHT_FAILED", { message: `Failed to query npm registry: ${String(e)}` })
3729
+ };
3730
+ }
3731
+ }
3732
+ const updateAvailable = availableVersion !== null && availableVersion !== currentVersion;
3733
+ const hint = updateAvailable ? `Update available: ${currentVersion} \u2192 ${availableVersion} (${source})` : `Already up to date: v${currentVersion} (${source})`;
2794
3734
  return {
2795
- exitCode: ExitCode.PROJECT_NOT_FOUND,
3735
+ exitCode: ExitCode.OK,
3736
+ result: ok({
3737
+ source,
3738
+ currentVersion,
3739
+ availableVersion,
3740
+ updateAvailable,
3741
+ humanHint: hint
3742
+ })
3743
+ };
3744
+ }
3745
+ if (hasLocalSource) {
3746
+ try {
3747
+ execSync3("npm run build -w packages/cli", {
3748
+ cwd: sourceRoot,
3749
+ stdio: "pipe",
3750
+ timeout: 6e4
3751
+ });
3752
+ } catch (e) {
3753
+ return {
3754
+ exitCode: ExitCode.INTERNAL_ERROR,
3755
+ result: err("BUILD_FAILED", { message: `Build failed: ${String(e)}` })
3756
+ };
3757
+ }
3758
+ try {
3759
+ execSync3("npm link ./packages/cli", {
3760
+ cwd: sourceRoot,
3761
+ stdio: "pipe",
3762
+ timeout: 3e4
3763
+ });
3764
+ } catch (e) {
3765
+ return {
3766
+ exitCode: ExitCode.INTERNAL_ERROR,
3767
+ result: err("LINK_FAILED", { message: `npm link failed: ${String(e)}` })
3768
+ };
3769
+ }
3770
+ const newVersion = (() => {
3771
+ try {
3772
+ return JSON.parse(readFileSync7(localPkgPath, "utf8")).version ?? "unknown";
3773
+ } catch {
3774
+ return "unknown";
3775
+ }
3776
+ })();
3777
+ return {
3778
+ exitCode: ExitCode.OK,
3779
+ result: ok({
3780
+ source: "local",
3781
+ currentVersion,
3782
+ availableVersion: newVersion,
3783
+ updateAvailable: newVersion !== currentVersion,
3784
+ newVersion,
3785
+ humanHint: `Built and linked from local source: v${newVersion}`
3786
+ })
3787
+ };
3788
+ }
3789
+ let latestVersion;
3790
+ try {
3791
+ latestVersion = execSync3("npm view skillwiki@beta version", {
3792
+ encoding: "utf8",
3793
+ timeout: 15e3
3794
+ }).trim();
3795
+ } catch (e) {
3796
+ return {
3797
+ exitCode: ExitCode.INTERNAL_ERROR,
3798
+ result: err("PREFLIGHT_FAILED", { message: `Failed to query npm registry: ${String(e)}` })
3799
+ };
3800
+ }
3801
+ if (latestVersion === currentVersion) {
3802
+ return {
3803
+ exitCode: ExitCode.OK,
3804
+ result: ok({
3805
+ source: "npm",
3806
+ currentVersion,
3807
+ availableVersion: latestVersion,
3808
+ updateAvailable: false,
3809
+ humanHint: `Already on latest beta: v${currentVersion}`
3810
+ })
3811
+ };
3812
+ }
3813
+ try {
3814
+ execSync3("npm install -g skillwiki@beta", {
3815
+ stdio: "pipe",
3816
+ timeout: 6e4
3817
+ });
3818
+ } catch (e) {
3819
+ return {
3820
+ exitCode: ExitCode.INTERNAL_ERROR,
3821
+ result: err("INSTALL_FAILED", { message: `npm install failed: ${String(e)}` })
3822
+ };
3823
+ }
3824
+ return {
3825
+ exitCode: ExitCode.OK,
3826
+ result: ok({
3827
+ source: "npm",
3828
+ currentVersion,
3829
+ availableVersion: latestVersion,
3830
+ updateAvailable: true,
3831
+ newVersion: latestVersion,
3832
+ humanHint: `Updated skillwiki ${currentVersion} \u2192 ${latestVersion} via npm@beta`
3833
+ })
3834
+ };
3835
+ }
3836
+
3837
+ // src/commands/transcripts.ts
3838
+ import { readdir as readdir5, stat as stat6, readFile as readFile16 } from "fs/promises";
3839
+ import { join as join25 } from "path";
3840
+ async function runTranscripts(input) {
3841
+ const dir = join25(input.vault, "raw", "transcripts");
3842
+ let entries;
3843
+ try {
3844
+ entries = await readdir5(dir, { withFileTypes: true });
3845
+ } catch {
3846
+ return { exitCode: ExitCode.VAULT_PATH_INVALID, result: { ok: false, error: "VAULT_PATH_INVALID", detail: `raw/transcripts/ not found: ${dir}` } };
3847
+ }
3848
+ const transcripts = [];
3849
+ for (const entry of entries) {
3850
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
3851
+ const filePath = join25(dir, entry.name);
3852
+ const content = await readFile16(filePath, "utf8");
3853
+ const fm = extractFrontmatter(content);
3854
+ if (!fm.ok) continue;
3855
+ const ingested = typeof fm.data.ingested === "string" ? fm.data.ingested : "";
3856
+ if (input.since && ingested && ingested < input.since) continue;
3857
+ const s = await stat6(filePath);
3858
+ transcripts.push({
3859
+ file: `raw/transcripts/${entry.name}`,
3860
+ ingested,
3861
+ size: s.size
3862
+ });
3863
+ }
3864
+ const hint = transcripts.length > 0 ? transcripts.map((t) => `${t.file} (ingested: ${t.ingested || "unknown"}, ${t.size}B)`).join("\n") : "no transcript files found";
3865
+ return { exitCode: ExitCode.OK, result: ok({ transcripts, humanHint: hint }) };
3866
+ }
3867
+
3868
+ // src/commands/project-index.ts
3869
+ import { readdir as readdir6, readFile as readFile17, writeFile as writeFile12, mkdir as mkdir8 } from "fs/promises";
3870
+ import { join as join26, dirname as dirname10 } from "path";
3871
+ var LAYER2_DIRS = ["entities", "concepts", "comparisons", "queries", "meta"];
3872
+ async function runProjectIndex(input) {
3873
+ const slug = input.slug;
3874
+ const projectDir = join26(input.vault, "projects", slug);
3875
+ try {
3876
+ await readdir6(projectDir);
3877
+ } catch {
3878
+ return {
3879
+ exitCode: ExitCode.PROJECT_NOT_FOUND,
2796
3880
  result: err("PROJECT_NOT_FOUND", { slug, path: projectDir })
2797
3881
  };
2798
3882
  }
2799
3883
  const wikilinkPattern = `[[${slug}]]`;
2800
3884
  const entries = [];
3885
+ const compoundDir = join26(input.vault, "projects", slug, "compound");
3886
+ try {
3887
+ const compoundFiles = await readdir6(compoundDir, { withFileTypes: true });
3888
+ for (const entry of compoundFiles) {
3889
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
3890
+ const filePath = join26(compoundDir, entry.name);
3891
+ let text;
3892
+ try {
3893
+ text = await readFile17(filePath, "utf8");
3894
+ } catch {
3895
+ continue;
3896
+ }
3897
+ const fm = extractFrontmatter(text);
3898
+ if (!fm.ok) continue;
3899
+ entries.push({
3900
+ page: `projects/${slug}/compound/${entry.name}`,
3901
+ type: typeof fm.data.type === "string" ? fm.data.type : "compound",
3902
+ title: typeof fm.data.title === "string" ? fm.data.title : entry.name.replace(/\.md$/, "")
3903
+ });
3904
+ }
3905
+ } catch {
3906
+ }
2801
3907
  for (const dir of LAYER2_DIRS) {
2802
3908
  let files;
2803
3909
  try {
2804
- files = await readdir5(join20(input.vault, dir), { withFileTypes: true });
3910
+ files = await readdir6(join26(input.vault, dir), { withFileTypes: true });
2805
3911
  } catch {
2806
3912
  continue;
2807
3913
  }
2808
3914
  for (const entry of files) {
2809
3915
  if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
2810
- const filePath = join20(input.vault, dir, entry.name);
3916
+ const filePath = join26(input.vault, dir, entry.name);
2811
3917
  let text;
2812
3918
  try {
2813
- text = await readFile16(filePath, "utf8");
3919
+ text = await readFile17(filePath, "utf8");
2814
3920
  } catch {
2815
3921
  continue;
2816
3922
  }
@@ -2820,117 +3926,1860 @@ async function runProjectIndex(input) {
2820
3926
  if (!Array.isArray(pp) || !pp.some((p) => String(p) === wikilinkPattern)) continue;
2821
3927
  entries.push({
2822
3928
  page: `${dir}/${entry.name}`,
2823
- type: fm.data.type ?? dir.slice(0, -1),
2824
- // singularize dir name
2825
- title: fm.data.title ?? entry.name.replace(/\.md$/, "")
3929
+ type: typeof fm.data.type === "string" ? fm.data.type : dir.slice(0, -1),
3930
+ title: typeof fm.data.title === "string" ? fm.data.title : entry.name.replace(/\.md$/, "")
2826
3931
  });
2827
3932
  }
2828
3933
  }
2829
- const typeOrder = { entity: 0, concept: 1, comparison: 2, query: 3, summary: 4, meta: 5 };
2830
- entries.sort((a, b) => {
2831
- const ta = typeOrder[a.type] ?? 99;
2832
- const tb = typeOrder[b.type] ?? 99;
2833
- return ta !== tb ? ta - tb : a.title.localeCompare(b.title);
2834
- });
2835
- const indexPath = join20(projectDir, "knowledge.md");
2836
- let existing = false;
2837
- let stale = false;
3934
+ const typeOrder = { entity: 0, concept: 1, comparison: 2, query: 3, summary: 4, meta: 5, pattern: 6, gotcha: 7, lesson: 8, antipattern: 9, compound: 10 };
3935
+ entries.sort((a, b) => {
3936
+ const ta = typeOrder[a.type] ?? 99;
3937
+ const tb = typeOrder[b.type] ?? 99;
3938
+ return ta !== tb ? ta - tb : a.title.localeCompare(b.title);
3939
+ });
3940
+ const indexPath = join26(projectDir, "knowledge.md");
3941
+ let existing = false;
3942
+ let stale = false;
3943
+ try {
3944
+ const existingText = await readFile17(indexPath, "utf8");
3945
+ existing = true;
3946
+ const existingEntries = existingText.split("\n").filter((l) => l.startsWith("- [["));
3947
+ const existingPages = new Set(existingEntries.map((l) => {
3948
+ const m = l.match(/\[\[([^\]]+)\]\]/);
3949
+ return m ? m[1] : "";
3950
+ }));
3951
+ const currentPages = new Set(entries.map((e) => e.page.replace(/\.md$/, "")));
3952
+ stale = existingPages.size !== currentPages.size || [...currentPages].some((p) => !existingPages.has(p));
3953
+ } catch {
3954
+ }
3955
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3956
+ const grouped = /* @__PURE__ */ new Map();
3957
+ for (const e of entries) {
3958
+ const group = e.type;
3959
+ if (!grouped.has(group)) grouped.set(group, []);
3960
+ grouped.get(group).push(e);
3961
+ }
3962
+ let body = `# Knowledge Index: ${slug}
3963
+
3964
+ Autogenerated by \`skillwiki project-index\` on ${today}.
3965
+
3966
+ `;
3967
+ for (const [type, items] of grouped) {
3968
+ body += `## ${type}
3969
+
3970
+ `;
3971
+ for (const item of items) {
3972
+ const pageRef = item.page.replace(/\.md$/, "");
3973
+ body += `- [[${pageRef}]] \u2014 ${item.title}
3974
+ `;
3975
+ }
3976
+ body += "\n";
3977
+ }
3978
+ if (entries.length === 0) {
3979
+ body += `No Layer 2 pages reference \`[[${slug}]]\` in provenance_projects.
3980
+ `;
3981
+ }
3982
+ if (input.apply) {
3983
+ try {
3984
+ await mkdir8(dirname10(indexPath), { recursive: true });
3985
+ await writeFile12(indexPath, body, "utf8");
3986
+ } catch (e) {
3987
+ return {
3988
+ exitCode: ExitCode.WRITE_FAILED,
3989
+ result: err("WRITE_FAILED", { file: indexPath, message: String(e) })
3990
+ };
3991
+ }
3992
+ }
3993
+ const action = input.apply ? `written ${entries.length} entries to ${indexPath}` : `${entries.length} entries found (use --apply to write)`;
3994
+ const staleHint = stale ? " (STALE \u2014 existing index outdated)" : existing ? " (up to date)" : "";
3995
+ return {
3996
+ exitCode: ExitCode.OK,
3997
+ result: ok({
3998
+ slug,
3999
+ entries,
4000
+ existing,
4001
+ stale,
4002
+ index_path: `projects/${slug}/knowledge.md`,
4003
+ humanHint: `project: ${slug}
4004
+ entries: ${entries.length}${staleHint}
4005
+ ${action}
4006
+
4007
+ ${entries.map((e) => ` ${e.type}: [[${e.page.replace(/\.md$/, "")}]] \u2014 ${e.title}`).join("\n")}`
4008
+ })
4009
+ };
4010
+ }
4011
+
4012
+ // src/commands/compound.ts
4013
+ import { writeFile as writeFile13, mkdir as mkdir9, readdir as readdir7, unlink as unlink2 } from "fs/promises";
4014
+ import { join as join27 } from "path";
4015
+ import { existsSync as existsSync7 } from "fs";
4016
+ import { readFile as readFile18 } from "fs/promises";
4017
+ var RETRO_HEADING_RE = /^## \[(\d{4}-\d{2}-\d{2})(?:\s+[^\]]+)?\] retro \| loop cycle(?: (\d+))?: (.+)$/;
4018
+ var FIELD_RE = {
4019
+ improve: /^-\s+\*?\*?Improve:?\*?\*?\s*(.+)$/m,
4020
+ friction: /^-\s+\*?\*?Friction:?\*?\*?\s*(.+)$/m,
4021
+ generalize: /^-\s+\*?\*?Generalize\?:?\*?\*?\s*(.+)$/m
4022
+ };
4023
+ function slugify(name) {
4024
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+$/g, "");
4025
+ }
4026
+ function inferType(improve, friction) {
4027
+ if (/\bshould\b/i.test(improve)) return "pattern";
4028
+ if (/\bbug\b|\berror\b/i.test(friction)) return "gotcha";
4029
+ return "lesson";
4030
+ }
4031
+ function extractTags(generalize) {
4032
+ const tags = [];
4033
+ const parenRe = /\(([^)]+)\)/g;
4034
+ let match;
4035
+ while ((match = parenRe.exec(generalize)) !== null) {
4036
+ const words = match[1].trim().split(/\s+/);
4037
+ for (const w of words) {
4038
+ const cleaned = w.toLowerCase().replace(/[^a-z0-9-]/g, "").trim();
4039
+ if (cleaned.length > 0) tags.push(cleaned);
4040
+ }
4041
+ }
4042
+ const appliesRe = /applies to any\s+(.+?)(?:\.|,|$)/i;
4043
+ const appliesMatch = generalize.match(appliesRe);
4044
+ if (appliesMatch) {
4045
+ const words = appliesMatch[1].trim().split(/\s+/);
4046
+ for (const w of words) {
4047
+ const cleaned = w.toLowerCase().replace(/[^a-z0-9-]/g, "").trim();
4048
+ if (cleaned.length > 0) tags.push(cleaned);
4049
+ }
4050
+ }
4051
+ if (tags.length === 0) {
4052
+ tags.push("dev-loop");
4053
+ }
4054
+ return [...new Set(tags)];
4055
+ }
4056
+ function parseRationale(generalize) {
4057
+ const yesMatch = generalize.match(/^yes[,:]\s*(.+)$/i);
4058
+ if (yesMatch) return yesMatch[1].trim();
4059
+ if (/^yes$/i.test(generalize.trim())) return "";
4060
+ return generalize.trim();
4061
+ }
4062
+ function parseRetroEntries(logText) {
4063
+ const entries = [];
4064
+ const lines = logText.split("\n");
4065
+ let currentDate = "";
4066
+ let currentCycleName = "";
4067
+ let currentBlock = [];
4068
+ let foundHeading = false;
4069
+ for (const line of lines) {
4070
+ const headingMatch = line.match(RETRO_HEADING_RE);
4071
+ if (headingMatch) {
4072
+ if (foundHeading && currentBlock.length > 0) {
4073
+ const entry = extractRetroFields(currentDate, currentCycleName, currentBlock);
4074
+ if (entry) entries.push(entry);
4075
+ }
4076
+ currentDate = headingMatch[1];
4077
+ currentCycleName = headingMatch[3];
4078
+ currentBlock = [];
4079
+ foundHeading = true;
4080
+ continue;
4081
+ }
4082
+ if (foundHeading && /^## /.test(line)) {
4083
+ const entry = extractRetroFields(currentDate, currentCycleName, currentBlock);
4084
+ if (entry) entries.push(entry);
4085
+ foundHeading = false;
4086
+ currentBlock = [];
4087
+ continue;
4088
+ }
4089
+ if (foundHeading) {
4090
+ currentBlock.push(line);
4091
+ }
4092
+ }
4093
+ if (foundHeading && currentBlock.length > 0) {
4094
+ const entry = extractRetroFields(currentDate, currentCycleName, currentBlock);
4095
+ if (entry) entries.push(entry);
4096
+ }
4097
+ return entries;
4098
+ }
4099
+ function extractRetroFields(date, cycleName, block) {
4100
+ const text = block.join("\n");
4101
+ const improveMatch = text.match(FIELD_RE.improve);
4102
+ const frictionMatch = text.match(FIELD_RE.friction);
4103
+ const generalizeMatch = text.match(FIELD_RE.generalize);
4104
+ if (!generalizeMatch) return null;
4105
+ return {
4106
+ date,
4107
+ cycleName,
4108
+ improve: improveMatch?.[1]?.trim() ?? "",
4109
+ friction: frictionMatch?.[1]?.trim() ?? "",
4110
+ generalize: generalizeMatch[1].trim()
4111
+ };
4112
+ }
4113
+ async function runCompound(input) {
4114
+ const logPath = join27(input.vault, "log.md");
4115
+ let logText;
4116
+ try {
4117
+ logText = await readFile18(logPath, "utf8");
4118
+ } catch {
4119
+ return { exitCode: ExitCode.FILE_NOT_FOUND, result: err("FILE_NOT_FOUND", { path: logPath }) };
4120
+ }
4121
+ const entries = parseRetroEntries(logText);
4122
+ const promoted = [];
4123
+ const skipped = [];
4124
+ const compoundDir = join27(input.vault, "projects", input.project, "compound");
4125
+ for (const entry of entries) {
4126
+ const generalizeValue = entry.generalize.trim();
4127
+ if (!/^yes/i.test(generalizeValue)) {
4128
+ skipped.push(entry.date);
4129
+ continue;
4130
+ }
4131
+ const slug = slugify(entry.cycleName);
4132
+ const compoundPath = join27(compoundDir, `${slug}.md`);
4133
+ if (existsSync7(compoundPath)) {
4134
+ skipped.push(entry.date);
4135
+ continue;
4136
+ }
4137
+ const type = inferType(entry.improve, entry.friction);
4138
+ const rationale = parseRationale(generalizeValue);
4139
+ const tags = extractTags(generalizeValue);
4140
+ const tagsYaml = tags.map((t) => t).join(", ");
4141
+ const title = entry.cycleName;
4142
+ const typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
4143
+ const frontmatter = [
4144
+ "---",
4145
+ `title: ${title}`,
4146
+ `created: ${entry.date}`,
4147
+ `updated: ${entry.date}`,
4148
+ `type: ${type}`,
4149
+ `tags: [${tagsYaml}]`,
4150
+ `confidence: medium`,
4151
+ `project: "[[${input.project}]]"`,
4152
+ `work_items: []`,
4153
+ "---"
4154
+ ].join("\n");
4155
+ const body = [
4156
+ `## ${typeLabel}`,
4157
+ "",
4158
+ entry.improve,
4159
+ "",
4160
+ "## Evidence",
4161
+ "",
4162
+ entry.friction,
4163
+ "",
4164
+ "## Source",
4165
+ "",
4166
+ `Retro from ${entry.date} | ${entry.cycleName}. Generalize rationale: ${rationale}`,
4167
+ ""
4168
+ ].join("\n");
4169
+ const content = frontmatter + "\n" + body;
4170
+ if (!input.dryRun) {
4171
+ if (!existsSync7(compoundDir)) {
4172
+ await mkdir9(compoundDir, { recursive: true });
4173
+ }
4174
+ await writeFile13(compoundPath, content, "utf8");
4175
+ }
4176
+ promoted.push(`${slug}.md`);
4177
+ }
4178
+ const exitCode = promoted.length > 0 ? ExitCode.COMPOUND_PROMOTED : ExitCode.OK;
4179
+ const hintLines = [`scanned: ${entries.length}`];
4180
+ if (promoted.length > 0) hintLines.push(`promoted: ${promoted.length}`);
4181
+ if (skipped.length > 0) hintLines.push(`skipped (Generalize?: no): ${skipped.length}`);
4182
+ return {
4183
+ exitCode,
4184
+ result: ok({
4185
+ scanned: entries.length,
4186
+ promoted,
4187
+ skipped,
4188
+ humanHint: hintLines.join("\n")
4189
+ })
4190
+ };
4191
+ }
4192
+ async function runCompoundDelete(input) {
4193
+ const projectDir = join27(input.vault, "projects", input.project);
4194
+ if (!existsSync7(projectDir)) {
4195
+ return {
4196
+ exitCode: ExitCode.PROJECT_NOT_FOUND,
4197
+ result: err("PROJECT_NOT_FOUND", { slug: input.project, path: projectDir })
4198
+ };
4199
+ }
4200
+ const entryName = input.entry.replace(/\.md$/, "");
4201
+ const compoundPath = join27(projectDir, "compound", `${entryName}.md`);
4202
+ if (!existsSync7(compoundPath)) {
4203
+ return {
4204
+ exitCode: ExitCode.FILE_NOT_FOUND,
4205
+ result: err("FILE_NOT_FOUND", { path: compoundPath })
4206
+ };
4207
+ }
4208
+ try {
4209
+ await unlink2(compoundPath);
4210
+ } catch (e) {
4211
+ return {
4212
+ exitCode: ExitCode.WRITE_FAILED,
4213
+ result: err("WRITE_FAILED", { file: compoundPath, message: String(e) })
4214
+ };
4215
+ }
4216
+ const indexResult = await runProjectIndex({ vault: input.vault, slug: input.project, apply: true });
4217
+ if (!indexResult.result.ok) {
4218
+ return {
4219
+ exitCode: indexResult.exitCode,
4220
+ result: err("INDEX_REGEN_FAILED", { detail: indexResult.result })
4221
+ };
4222
+ }
4223
+ return {
4224
+ exitCode: ExitCode.OK,
4225
+ result: ok({
4226
+ deleted: compoundPath,
4227
+ project: input.project,
4228
+ humanHint: `deleted: ${entryName}.md
4229
+ project: ${input.project}
4230
+ knowledge.md regenerated`
4231
+ })
4232
+ };
4233
+ }
4234
+ async function runCompoundList(input) {
4235
+ const compoundDir = join27(input.vault, "projects", input.project, "compound");
4236
+ if (!existsSync7(compoundDir)) {
4237
+ return {
4238
+ exitCode: ExitCode.OK,
4239
+ result: ok({
4240
+ project: input.project,
4241
+ entries: [],
4242
+ humanHint: `project: ${input.project}
4243
+ entries: 0
4244
+ no compound directory found`
4245
+ })
4246
+ };
4247
+ }
4248
+ let dirents;
4249
+ try {
4250
+ dirents = await readdir7(compoundDir, { withFileTypes: true });
4251
+ } catch {
4252
+ return {
4253
+ exitCode: ExitCode.OK,
4254
+ result: ok({
4255
+ project: input.project,
4256
+ entries: [],
4257
+ humanHint: `project: ${input.project}
4258
+ entries: 0
4259
+ could not read compound directory`
4260
+ })
4261
+ };
4262
+ }
4263
+ const entries = [];
4264
+ for (const dirent of dirents) {
4265
+ if (!dirent.isFile() || !dirent.name.endsWith(".md")) continue;
4266
+ const filePath = join27(compoundDir, dirent.name);
4267
+ let text;
4268
+ try {
4269
+ text = await readFile18(filePath, "utf8");
4270
+ } catch {
4271
+ continue;
4272
+ }
4273
+ const fm = extractFrontmatter(text);
4274
+ if (!fm.ok) continue;
4275
+ const tags = Array.isArray(fm.data.tags) ? fm.data.tags.map((t) => String(t)) : typeof fm.data.tags === "string" ? fm.data.tags.split(",").map((s) => s.trim()) : [];
4276
+ entries.push({
4277
+ file: dirent.name,
4278
+ title: typeof fm.data.title === "string" ? fm.data.title : dirent.name.replace(/\.md$/, ""),
4279
+ type: typeof fm.data.type === "string" ? fm.data.type : "lesson",
4280
+ created: typeof fm.data.created === "string" ? fm.data.created : "",
4281
+ tags
4282
+ });
4283
+ }
4284
+ const hint = entries.length > 0 ? [`project: ${input.project}`, `entries: ${entries.length}`, "", ...entries.map((e) => ` ${e.file}: ${e.title} (${e.type}, created: ${e.created || "unknown"}, tags: ${e.tags.join(", ") || "none"})`)].join("\n") : `project: ${input.project}
4285
+ entries: 0
4286
+ no compound entries found`;
4287
+ return {
4288
+ exitCode: ExitCode.OK,
4289
+ result: ok({
4290
+ project: input.project,
4291
+ entries,
4292
+ humanHint: hint
4293
+ })
4294
+ };
4295
+ }
4296
+
4297
+ // src/commands/observe.ts
4298
+ import { mkdir as mkdir10, writeFile as writeFile14 } from "fs/promises";
4299
+ import { existsSync as existsSync8, statSync as statSync2 } from "fs";
4300
+ import { join as join28 } from "path";
4301
+ import { createHash as createHash3 } from "crypto";
4302
+ var ALLOWED_KINDS = /* @__PURE__ */ new Set(["note", "bug", "task", "idea", "session-log"]);
4303
+ function slugify2(text) {
4304
+ const words = text.trim().split(/\s+/).slice(0, 6).join("-").toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
4305
+ return words || "untitled";
4306
+ }
4307
+ async function runObserve(input) {
4308
+ const kind = input.kind || "note";
4309
+ if (!ALLOWED_KINDS.has(kind)) {
4310
+ return {
4311
+ exitCode: ExitCode.SCHEME_REJECTED,
4312
+ result: err("SCHEME_REJECTED", {
4313
+ message: `Invalid kind "${kind}". Allowed: ${[...ALLOWED_KINDS].join(", ")}`
4314
+ })
4315
+ };
4316
+ }
4317
+ if (!input.text || input.text.trim().length === 0) {
4318
+ return {
4319
+ exitCode: ExitCode.SCHEME_REJECTED,
4320
+ result: err("SCHEME_REJECTED", { message: "Text must not be empty" })
4321
+ };
4322
+ }
4323
+ if (!existsSync8(input.vault) || !statSync2(input.vault).isDirectory()) {
4324
+ return {
4325
+ exitCode: ExitCode.VAULT_PATH_INVALID,
4326
+ result: err("VAULT_PATH_INVALID", { path: input.vault })
4327
+ };
4328
+ }
4329
+ const transcriptsDir = join28(input.vault, "raw", "transcripts");
4330
+ try {
4331
+ await mkdir10(transcriptsDir, { recursive: true });
4332
+ } catch {
4333
+ return {
4334
+ exitCode: ExitCode.VAULT_PATH_INVALID,
4335
+ result: err("VAULT_PATH_INVALID", { path: transcriptsDir })
4336
+ };
4337
+ }
4338
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4339
+ const slug = slugify2(input.text);
4340
+ const fileName = `${today}-observation-${slug}.md`;
4341
+ const filePath = join28(transcriptsDir, fileName);
4342
+ const body = `
4343
+ ${input.text.trim()}
4344
+ `;
4345
+ const sha256 = createHash3("sha256").update(Buffer.from(body, "utf8")).digest("hex");
4346
+ const frontmatterLines = [
4347
+ "---",
4348
+ "source_url:",
4349
+ `ingested: ${today}`,
4350
+ `sha256: ${sha256}`,
4351
+ `kind: ${kind}`
4352
+ ];
4353
+ if (input.project) {
4354
+ frontmatterLines.push(`project: "[[${input.project}]]"`);
4355
+ }
4356
+ frontmatterLines.push("---");
4357
+ const content = frontmatterLines.join("\n") + body;
4358
+ try {
4359
+ await writeFile14(filePath, content, "utf8");
4360
+ } catch (e) {
4361
+ return {
4362
+ exitCode: ExitCode.WRITE_FAILED,
4363
+ result: err("WRITE_FAILED", { path: filePath, message: String(e) })
4364
+ };
4365
+ }
4366
+ appendLastOp(input.vault, {
4367
+ operation: "observe",
4368
+ summary: `created observation: ${slug}`,
4369
+ files: [`raw/transcripts/${fileName}`],
4370
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4371
+ });
4372
+ const relPath = `raw/transcripts/${fileName}`;
4373
+ const humanHint = `created ${relPath} (${sha256.slice(0, 12)}...)`;
4374
+ return {
4375
+ exitCode: ExitCode.OK,
4376
+ result: ok({ path: relPath, sha256, humanHint })
4377
+ };
4378
+ }
4379
+
4380
+ // src/commands/ingest.ts
4381
+ import { readFile as readFile19, writeFile as writeFile15, mkdir as mkdir11 } from "fs/promises";
4382
+ import { join as join29 } from "path";
4383
+ import { createHash as createHash4 } from "crypto";
4384
+ var ALLOWED_TYPES = /* @__PURE__ */ new Set(["entity", "concept", "comparison", "query"]);
4385
+ var TYPE_DIR = {
4386
+ entity: "entities",
4387
+ concept: "concepts",
4388
+ comparison: "comparisons",
4389
+ query: "queries"
4390
+ };
4391
+ var ALLOWED_PROVENANCE = /* @__PURE__ */ new Set(["research", "project"]);
4392
+ function slugify3(text) {
4393
+ return text.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || "untitled";
4394
+ }
4395
+ function isUrl(source) {
4396
+ try {
4397
+ const u = new URL(source);
4398
+ return u.protocol === "https:" || u.protocol === "http:";
4399
+ } catch {
4400
+ return false;
4401
+ }
4402
+ }
4403
+ function todayIso() {
4404
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4405
+ }
4406
+ function buildRawContent(sourceUrl, ingested, sha256, body) {
4407
+ const lines = [
4408
+ "---",
4409
+ sourceUrl !== null ? `source_url: "${sourceUrl}"` : "source_url:",
4410
+ `ingested: ${ingested}`,
4411
+ `sha256: ${sha256}`,
4412
+ `ingested_by: wiki-ingest`,
4413
+ "---",
4414
+ "",
4415
+ body
4416
+ ];
4417
+ return lines.join("\n");
4418
+ }
4419
+ function buildTypedContent(title, ingested, type, tags, rawRelPath, provenance) {
4420
+ const aliases = [];
4421
+ const sourcesYaml = ` - ${rawRelPath}`;
4422
+ const tagsYaml = tags.length > 0 ? tags.map((t) => ` - ${t}`).join("\n") : " []";
4423
+ const fm = {
4424
+ title,
4425
+ aliases,
4426
+ created: ingested,
4427
+ updated: ingested,
4428
+ type,
4429
+ tags,
4430
+ sources: [rawRelPath],
4431
+ confidence: "medium"
4432
+ };
4433
+ if (provenance) {
4434
+ fm.provenance = provenance;
4435
+ }
4436
+ const fmLines = ["---"];
4437
+ fmLines.push(`title: "${title}"`);
4438
+ if (aliases.length > 0) {
4439
+ fmLines.push("aliases:");
4440
+ for (const a of aliases) fmLines.push(` - ${a}`);
4441
+ } else {
4442
+ fmLines.push("aliases: []");
4443
+ }
4444
+ fmLines.push(`created: ${ingested}`);
4445
+ fmLines.push(`updated: ${ingested}`);
4446
+ fmLines.push(`type: ${type}`);
4447
+ fmLines.push("tags:");
4448
+ fmLines.push(tagsYaml);
4449
+ fmLines.push("sources:");
4450
+ fmLines.push(sourcesYaml);
4451
+ fmLines.push("confidence: medium");
4452
+ if (provenance) {
4453
+ fmLines.push(`provenance: ${provenance}`);
4454
+ }
4455
+ fmLines.push("---");
4456
+ fmLines.push("");
4457
+ const body = [
4458
+ `# ${title}`,
4459
+ "",
4460
+ "## Overview",
4461
+ "",
4462
+ "## See also",
4463
+ "",
4464
+ "## Sources",
4465
+ "",
4466
+ `^[${rawRelPath}]`,
4467
+ ""
4468
+ ].join("\n");
4469
+ return fmLines.join("\n") + body;
4470
+ }
4471
+ async function runIngest(input) {
4472
+ if (!input.source || input.source.trim().length === 0) {
4473
+ return {
4474
+ exitCode: ExitCode.SCHEME_REJECTED,
4475
+ result: err("SCHEME_REJECTED", { message: "source is required" })
4476
+ };
4477
+ }
4478
+ if (!input.vault || input.vault.trim().length === 0) {
4479
+ return {
4480
+ exitCode: ExitCode.VAULT_PATH_INVALID,
4481
+ result: err("VAULT_PATH_INVALID", { message: "vault path is required" })
4482
+ };
4483
+ }
4484
+ if (!input.type || !ALLOWED_TYPES.has(input.type)) {
4485
+ return {
4486
+ exitCode: ExitCode.SCHEME_REJECTED,
4487
+ result: err("SCHEME_REJECTED", {
4488
+ message: `Invalid type "${input.type}". Allowed: ${[...ALLOWED_TYPES].join(", ")}`
4489
+ })
4490
+ };
4491
+ }
4492
+ if (!input.title || input.title.trim().length === 0) {
4493
+ return {
4494
+ exitCode: ExitCode.SCHEME_REJECTED,
4495
+ result: err("SCHEME_REJECTED", { message: "title is required" })
4496
+ };
4497
+ }
4498
+ if (input.provenance && !ALLOWED_PROVENANCE.has(input.provenance)) {
4499
+ return {
4500
+ exitCode: ExitCode.SCHEME_REJECTED,
4501
+ result: err("SCHEME_REJECTED", {
4502
+ message: `Invalid provenance "${input.provenance}". Allowed: ${[...ALLOWED_PROVENANCE].join(", ")}`
4503
+ })
4504
+ };
4505
+ }
4506
+ let sourceContent;
4507
+ let sourceUrl = null;
4508
+ if (isUrl(input.source)) {
4509
+ sourceUrl = input.source;
4510
+ const guardResult = runFetchGuardSync({ url: input.source });
4511
+ if (!guardResult.result.ok) {
4512
+ return {
4513
+ exitCode: ExitCode.INGEST_VALIDATION_FAILED,
4514
+ result: err("INGEST_VALIDATION_FAILED", {
4515
+ message: "source URL blocked by fetch-guard",
4516
+ guardError: guardResult.result.error,
4517
+ guardDetail: guardResult.result.detail
4518
+ })
4519
+ };
4520
+ }
4521
+ const fetchResult = await controlledFetch(input.source, {
4522
+ timeoutMs: 15e3,
4523
+ maxBytes: 1024 * 1024,
4524
+ // 1 MB
4525
+ maxRedirects: 5
4526
+ });
4527
+ if (!fetchResult.ok) {
4528
+ return {
4529
+ exitCode: ExitCode.INGEST_VALIDATION_FAILED,
4530
+ result: err("INGEST_VALIDATION_FAILED", {
4531
+ message: "failed to fetch source URL",
4532
+ fetchError: fetchResult.error,
4533
+ fetchDetail: fetchResult.detail
4534
+ })
4535
+ };
4536
+ }
4537
+ sourceContent = fetchResult.data.body;
4538
+ } else {
4539
+ try {
4540
+ sourceContent = await readFile19(input.source, "utf8");
4541
+ } catch {
4542
+ return {
4543
+ exitCode: ExitCode.FILE_NOT_FOUND,
4544
+ result: err("FILE_NOT_FOUND", { path: input.source })
4545
+ };
4546
+ }
4547
+ }
4548
+ const sha256 = createHash4("sha256").update(Buffer.from(sourceContent, "utf8")).digest("hex");
4549
+ const today = todayIso();
4550
+ const slug = slugify3(input.title);
4551
+ const tags = input.tags && input.tags.length > 0 ? input.tags : [];
4552
+ const rawRelPath = `raw/articles/${slug}.md`;
4553
+ const typedDir = TYPE_DIR[input.type] ?? `${input.type}s`;
4554
+ const typedRelPath = `${typedDir}/${slug}.md`;
4555
+ const rawAbsPath = join29(input.vault, rawRelPath);
4556
+ const typedAbsPath = join29(input.vault, typedRelPath);
4557
+ const rawContent = buildRawContent(sourceUrl, today, sha256, sourceContent);
4558
+ const typedContent = buildTypedContent(
4559
+ input.title,
4560
+ today,
4561
+ input.type,
4562
+ tags,
4563
+ rawRelPath,
4564
+ input.provenance
4565
+ );
4566
+ if (input.dryRun) {
4567
+ return {
4568
+ exitCode: ExitCode.OK,
4569
+ result: ok({
4570
+ raw_path: rawRelPath,
4571
+ typed_path: typedRelPath,
4572
+ sha256,
4573
+ dry_run: true,
4574
+ humanHint: [
4575
+ `DRY RUN \u2014 would create:`,
4576
+ ` ${rawRelPath} (sha256: ${sha256.slice(0, 12)}...)`,
4577
+ ` ${typedRelPath}`,
4578
+ ` type: ${input.type}, tags: [${tags.join(", ")}]`,
4579
+ input.provenance ? ` provenance: ${input.provenance}` : ""
4580
+ ].filter(Boolean).join("\n")
4581
+ })
4582
+ };
4583
+ }
4584
+ const typedFm = {
4585
+ title: input.title,
4586
+ aliases: [],
4587
+ created: today,
4588
+ updated: today,
4589
+ type: input.type,
4590
+ tags,
4591
+ sources: [rawRelPath],
4592
+ confidence: "medium",
4593
+ ...input.provenance ? { provenance: input.provenance } : {}
4594
+ };
4595
+ const det = detectSchema(typedFm);
4596
+ if (!det.schema) {
4597
+ return {
4598
+ exitCode: ExitCode.INGEST_VALIDATION_FAILED,
4599
+ result: err("INGEST_VALIDATION_FAILED", {
4600
+ message: "generated typed-knowledge page could not be detected as a valid schema"
4601
+ })
4602
+ };
4603
+ }
4604
+ const parsed = TypedKnowledgeSchema.safeParse(typedFm);
4605
+ if (!parsed.success) {
4606
+ const errors = parsed.error.issues.map((i) => ({
4607
+ path: i.path.join("."),
4608
+ message: i.message
4609
+ }));
4610
+ return {
4611
+ exitCode: ExitCode.INGEST_VALIDATION_FAILED,
4612
+ result: err("INGEST_VALIDATION_FAILED", {
4613
+ message: "generated typed-knowledge page failed schema validation",
4614
+ errors
4615
+ })
4616
+ };
4617
+ }
4618
+ try {
4619
+ await mkdir11(join29(input.vault, "raw", "articles"), { recursive: true });
4620
+ await writeFile15(rawAbsPath, rawContent, "utf8");
4621
+ } catch (e) {
4622
+ return {
4623
+ exitCode: ExitCode.WRITE_FAILED,
4624
+ result: err("WRITE_FAILED", { path: rawAbsPath, message: String(e) })
4625
+ };
4626
+ }
4627
+ try {
4628
+ await mkdir11(join29(input.vault, typedDir), { recursive: true });
4629
+ await writeFile15(typedAbsPath, typedContent, "utf8");
4630
+ } catch (e) {
4631
+ return {
4632
+ exitCode: ExitCode.WRITE_FAILED,
4633
+ result: err("WRITE_FAILED", { path: typedAbsPath, message: String(e) })
4634
+ };
4635
+ }
4636
+ const humanHint = [
4637
+ `created:`,
4638
+ ` ${rawRelPath} (sha256: ${sha256.slice(0, 12)}...)`,
4639
+ ` ${typedRelPath}`
4640
+ ].join("\n");
4641
+ appendLastOp(input.vault, {
4642
+ operation: "ingest",
4643
+ summary: `added ${slug}`,
4644
+ files: [rawRelPath, typedRelPath],
4645
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4646
+ });
4647
+ return {
4648
+ exitCode: ExitCode.OK,
4649
+ result: ok({
4650
+ raw_path: rawRelPath,
4651
+ typed_path: typedRelPath,
4652
+ sha256,
4653
+ dry_run: false,
4654
+ humanHint
4655
+ })
4656
+ };
4657
+ }
4658
+
4659
+ // src/commands/tag-sync.ts
4660
+ import { writeFile as writeFile16 } from "fs/promises";
4661
+ var ENUM_MIRRORS = {
4662
+ provenance: ["research", "project", "mixed"],
4663
+ confidence: ["high", "medium", "low"]
4664
+ };
4665
+ function toNestedTag(field, value) {
4666
+ return `${field}/${value}`;
4667
+ }
4668
+ function expectedNestedTags(fm) {
4669
+ const expected = /* @__PURE__ */ new Set();
4670
+ for (const [field, allowedValues] of Object.entries(ENUM_MIRRORS)) {
4671
+ const value = fm[field];
4672
+ if (typeof value === "string" && allowedValues.includes(value)) {
4673
+ expected.add(toNestedTag(field, value));
4674
+ }
4675
+ }
4676
+ return expected;
4677
+ }
4678
+ function parseTagsFromYaml(rawFm) {
4679
+ const inlineMatch = rawFm.match(/^tags:\s*\[([^\]]*)\]/m);
4680
+ if (inlineMatch) {
4681
+ return inlineMatch[1].split(",").map((t) => t.trim().replace(/^['"]|['"]$/g, "")).filter((t) => t.length > 0);
4682
+ }
4683
+ const lines = rawFm.split("\n");
4684
+ const tagItems = [];
4685
+ let inTags = false;
4686
+ for (const line of lines) {
4687
+ if (/^tags:\s*$/.test(line)) {
4688
+ inTags = true;
4689
+ continue;
4690
+ }
4691
+ if (inTags) {
4692
+ if (/^\s+-\s+/.test(line) && !/^\s+-\s+\[\[/.test(line)) {
4693
+ const value = line.replace(/^\s+-\s+/, "").trim().replace(/^['"]|['"]$/g, "");
4694
+ if (value.length > 0) tagItems.push(value);
4695
+ } else {
4696
+ break;
4697
+ }
4698
+ }
4699
+ }
4700
+ return tagItems;
4701
+ }
4702
+ function rebuildTagsSection(rawFm, existingTags, toAdd) {
4703
+ const allTags = [...existingTags, ...toAdd];
4704
+ const tagsLine = `tags: [${allTags.join(", ")}]`;
4705
+ if (/^tags:\s*\[/m.test(rawFm)) {
4706
+ return rawFm.replace(/^tags:\s*\[[^\]]*\]/m, tagsLine);
4707
+ }
4708
+ const lines = rawFm.split("\n");
4709
+ const out = [];
4710
+ let inTags = false;
4711
+ let tagsReplaced = false;
4712
+ for (const line of lines) {
4713
+ if (/^tags:\s*$/.test(line)) {
4714
+ inTags = true;
4715
+ if (!tagsReplaced) {
4716
+ out.push(tagsLine);
4717
+ tagsReplaced = true;
4718
+ }
4719
+ continue;
4720
+ }
4721
+ if (inTags) {
4722
+ if (/^\s+-\s+/.test(line) && !/^\s+-\s+\[\[/.test(line)) {
4723
+ continue;
4724
+ } else {
4725
+ inTags = false;
4726
+ }
4727
+ }
4728
+ out.push(line);
4729
+ }
4730
+ if (!tagsReplaced) {
4731
+ out.push(tagsLine);
4732
+ }
4733
+ return out.join("\n");
4734
+ }
4735
+ async function runTagSync(input) {
4736
+ const scan = await scanVault(input.vault);
4737
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
4738
+ const synced = [];
4739
+ let unchanged = 0;
4740
+ for (const page of scan.data.typedKnowledge) {
4741
+ const text = await readPage(page);
4742
+ const split = splitFrontmatter(text);
4743
+ if (!split.ok) {
4744
+ unchanged++;
4745
+ continue;
4746
+ }
4747
+ const { rawFrontmatter, body } = split.data;
4748
+ const fm = {};
4749
+ for (const [field, allowedValues] of Object.entries(ENUM_MIRRORS)) {
4750
+ for (const v of allowedValues) {
4751
+ if (rawFrontmatter.includes(`${field}: ${v}`)) {
4752
+ fm[field] = v;
4753
+ break;
4754
+ }
4755
+ }
4756
+ }
4757
+ const expected = expectedNestedTags(fm);
4758
+ if (expected.size === 0) {
4759
+ unchanged++;
4760
+ continue;
4761
+ }
4762
+ const existingTags = parseTagsFromYaml(rawFrontmatter);
4763
+ const existingSet = new Set(existingTags);
4764
+ const toAdd = [...expected].filter((t) => !existingSet.has(t));
4765
+ if (toAdd.length === 0) {
4766
+ unchanged++;
4767
+ continue;
4768
+ }
4769
+ const newFm = rebuildTagsSection(rawFrontmatter, existingTags, toAdd);
4770
+ const newText = `---
4771
+ ${newFm}
4772
+ ---
4773
+ ${body}`;
4774
+ if (!input.dryRun) {
4775
+ await writeFile16(page.absPath, newText, "utf8");
4776
+ }
4777
+ synced.push(page.relPath);
4778
+ }
4779
+ if (!input.dryRun && synced.length > 0) {
4780
+ appendLastOp(input.vault, {
4781
+ operation: "tag-sync",
4782
+ summary: `synced tags on ${synced.length} pages`,
4783
+ files: synced,
4784
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4785
+ });
4786
+ }
4787
+ const exitCode = synced.length > 0 ? ExitCode.MIGRATION_APPLIED : ExitCode.OK;
4788
+ const hintLines = [`scanned: ${synced.length + unchanged}`];
4789
+ if (synced.length > 0) hintLines.push(`synced: ${synced.length}`);
4790
+ if (unchanged > 0) hintLines.push(`unchanged: ${unchanged}`);
4791
+ if (input.dryRun && synced.length > 0) hintLines.push("(dry run \u2014 no files written)");
4792
+ return {
4793
+ exitCode,
4794
+ result: ok({
4795
+ scanned: synced.length + unchanged,
4796
+ synced,
4797
+ unchanged,
4798
+ humanHint: hintLines.join("\n")
4799
+ })
4800
+ };
4801
+ }
4802
+
4803
+ // src/commands/sync.ts
4804
+ import { existsSync as existsSync9 } from "fs";
4805
+ import { join as join30 } from "path";
4806
+ function runSyncStatus(input) {
4807
+ const vault = input.vault;
4808
+ if (!existsSync9(join30(vault, ".git"))) {
4809
+ return {
4810
+ exitCode: ExitCode.VAULT_PATH_INVALID,
4811
+ result: ok({
4812
+ is_git_repo: false,
4813
+ dirty: 0,
4814
+ ahead: 0,
4815
+ behind: 0,
4816
+ last_commit: "never",
4817
+ status: "not_a_repo",
4818
+ humanHint: "not a git repository"
4819
+ })
4820
+ };
4821
+ }
4822
+ const porcelain = git(vault, ["status", "--porcelain"]);
4823
+ const dirty = porcelain ? porcelain.split("\n").filter((l) => l.trim().length > 0).length : 0;
4824
+ const revOutput = git(vault, ["rev-list", "--left-right", "--count", "origin/HEAD...HEAD"]);
4825
+ let ahead = 0;
4826
+ let behind = 0;
4827
+ if (revOutput) {
4828
+ const parts = revOutput.split(/\s+/);
4829
+ behind = parseInt(parts[0], 10) || 0;
4830
+ ahead = parseInt(parts[1], 10) || 0;
4831
+ }
4832
+ const tsRaw = git(vault, ["log", "-1", "--format=%ct"]);
4833
+ let last_commit;
4834
+ if (tsRaw) {
4835
+ const ts = parseInt(tsRaw, 10);
4836
+ if (!isNaN(ts) && ts > 0) {
4837
+ last_commit = new Date(ts * 1e3).toISOString();
4838
+ } else {
4839
+ last_commit = "never";
4840
+ }
4841
+ } else {
4842
+ last_commit = "never";
4843
+ }
4844
+ let status;
4845
+ if (dirty > 0) {
4846
+ status = "dirty";
4847
+ } else if (ahead > 0) {
4848
+ status = "ahead";
4849
+ } else if (behind > 0) {
4850
+ status = "behind";
4851
+ } else {
4852
+ status = "clean";
4853
+ }
4854
+ const hintLines = [
4855
+ `status: ${status}`,
4856
+ `dirty: ${dirty}`,
4857
+ `ahead: ${ahead}`,
4858
+ `behind: ${behind}`,
4859
+ `last_commit: ${last_commit}`
4860
+ ];
4861
+ const exitCode = status === "clean" ? ExitCode.OK : ExitCode.LINT_HAS_WARNINGS;
4862
+ return {
4863
+ exitCode,
4864
+ result: ok({
4865
+ is_git_repo: true,
4866
+ dirty,
4867
+ ahead,
4868
+ behind,
4869
+ last_commit,
4870
+ status,
4871
+ humanHint: hintLines.join("\n")
4872
+ })
4873
+ };
4874
+ }
4875
+ async function runSyncPush(input) {
4876
+ const vault = input.vault;
4877
+ if (!existsSync9(join30(vault, ".git"))) {
4878
+ return {
4879
+ exitCode: ExitCode.VAULT_PATH_INVALID,
4880
+ result: err("NOT_A_GIT_REPO", { path: vault })
4881
+ };
4882
+ }
4883
+ const porcelain = git(vault, ["status", "--porcelain"]);
4884
+ const dirtyFiles = porcelain ? porcelain.split("\n").filter((l) => l.trim().length > 0) : [];
4885
+ if (dirtyFiles.length === 0) {
4886
+ return {
4887
+ exitCode: ExitCode.OK,
4888
+ result: ok({
4889
+ files_committed: 0,
4890
+ commit_message: "",
4891
+ pushed: false,
4892
+ humanHint: "nothing to commit, working tree clean"
4893
+ })
4894
+ };
4895
+ }
4896
+ const lintResult = await runLint({ vault, days: 90, lines: 200, logThreshold: 500 });
4897
+ if (lintResult.result.ok && lintResult.result.data.summary.errors > 0) {
4898
+ return {
4899
+ exitCode: ExitCode.LINT_HAS_ERRORS,
4900
+ result: err("LINT_ERRORS_BLOCK_PUSH", {
4901
+ errors: lintResult.result.data.summary.errors,
4902
+ buckets: lintResult.result.data.by_severity.error
4903
+ })
4904
+ };
4905
+ }
4906
+ try {
4907
+ gitStrict(vault, ["add", "-A"]);
4908
+ try {
4909
+ gitStrict(vault, ["reset", "HEAD", "--", ".skillwiki/last-op.json"]);
4910
+ } catch {
4911
+ }
4912
+ } catch (e) {
4913
+ return {
4914
+ exitCode: ExitCode.SYNC_PUSH_FAILED,
4915
+ result: err("GIT_ADD_FAILED", { message: String(e) })
4916
+ };
4917
+ }
4918
+ const lastOps = readLastOp(vault);
4919
+ let commitMessage;
4920
+ if (lastOps.length > 0) {
4921
+ commitMessage = lastOps.map((op) => `${op.operation}: ${op.summary} (${op.files.length} files)`).join("; ");
4922
+ } else {
4923
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
4924
+ commitMessage = `sync: vault update ${timestamp}`;
4925
+ }
4926
+ try {
4927
+ gitStrict(vault, ["commit", "-m", commitMessage]);
4928
+ } catch (e) {
4929
+ return {
4930
+ exitCode: ExitCode.SYNC_PUSH_FAILED,
4931
+ result: err("GIT_COMMIT_FAILED", { message: String(e) })
4932
+ };
4933
+ }
4934
+ clearLastOp(vault);
4935
+ let pushed = false;
4936
+ try {
4937
+ gitStrict(vault, ["push", "origin", "HEAD"]);
4938
+ pushed = true;
4939
+ } catch (e) {
4940
+ return {
4941
+ exitCode: ExitCode.SYNC_PUSH_FAILED,
4942
+ result: ok({
4943
+ files_committed: dirtyFiles.length,
4944
+ commit_message: commitMessage,
4945
+ pushed: false,
4946
+ humanHint: `committed ${dirtyFiles.length} file(s) but push failed: ${String(e)}`
4947
+ })
4948
+ };
4949
+ }
4950
+ return {
4951
+ exitCode: ExitCode.OK,
4952
+ result: ok({
4953
+ files_committed: dirtyFiles.length,
4954
+ commit_message: commitMessage,
4955
+ pushed,
4956
+ humanHint: `committed and pushed ${dirtyFiles.length} file(s)`
4957
+ })
4958
+ };
4959
+ }
4960
+ async function runSyncPull(input) {
4961
+ const vault = input.vault;
4962
+ if (!existsSync9(join30(vault, ".git"))) {
4963
+ return {
4964
+ exitCode: ExitCode.VAULT_PATH_INVALID,
4965
+ result: err("NOT_A_GIT_REPO", { path: vault })
4966
+ };
4967
+ }
4968
+ let fetched = false;
4969
+ try {
4970
+ gitStrict(vault, ["fetch", "origin"]);
4971
+ fetched = true;
4972
+ } catch (e) {
4973
+ return {
4974
+ exitCode: ExitCode.SYNC_PULL_FAILED,
4975
+ result: err("GIT_FETCH_FAILED", { message: String(e) })
4976
+ };
4977
+ }
4978
+ let pulled = false;
4979
+ let conflicts = 0;
4980
+ let filesUpdated = 0;
4981
+ try {
4982
+ const pullOutput = gitStrict(vault, ["pull", "--rebase", "origin", "HEAD"]);
4983
+ pulled = true;
4984
+ const fileMatch = pullOutput.match(/(\d+) file[s]? changed/);
4985
+ if (fileMatch) filesUpdated = parseInt(fileMatch[1], 10);
4986
+ } catch (e) {
4987
+ const errString = String(e);
4988
+ if (errString.includes("conflict")) {
4989
+ const porcelain = git(vault, ["diff", "--name-only", "--diff-filter=U"]);
4990
+ conflicts = porcelain ? porcelain.split("\n").filter((l) => l.trim().length > 0).length : 0;
4991
+ return {
4992
+ exitCode: ExitCode.SYNC_PULL_FAILED,
4993
+ result: ok({
4994
+ fetched,
4995
+ pulled: false,
4996
+ files_updated: 0,
4997
+ conflicts,
4998
+ lint_errors: 0,
4999
+ lint_warnings: 0,
5000
+ humanHint: `pull failed with ${conflicts} conflict(s) \u2014 resolve manually`
5001
+ })
5002
+ };
5003
+ }
5004
+ return {
5005
+ exitCode: ExitCode.SYNC_PULL_FAILED,
5006
+ result: err("GIT_PULL_FAILED", { message: errString })
5007
+ };
5008
+ }
5009
+ let lintErrors = 0;
5010
+ let lintWarnings = 0;
5011
+ const lintResult = await runLint({ vault, days: 90, lines: 200, logThreshold: 500 });
5012
+ if (lintResult.result.ok) {
5013
+ lintErrors = lintResult.result.data.summary.errors;
5014
+ lintWarnings = lintResult.result.data.summary.warnings;
5015
+ }
5016
+ const hintParts = [];
5017
+ if (filesUpdated > 0) hintParts.push(`updated ${filesUpdated} file(s)`);
5018
+ else hintParts.push("already up to date");
5019
+ if (lintErrors > 0) hintParts.push(`${lintErrors} lint error(s)`);
5020
+ if (lintWarnings > 0) hintParts.push(`${lintWarnings} lint warning(s)`);
5021
+ const exitCode = lintErrors > 0 ? ExitCode.LINT_HAS_ERRORS : lintWarnings > 0 ? ExitCode.LINT_HAS_WARNINGS : ExitCode.OK;
5022
+ return {
5023
+ exitCode,
5024
+ result: ok({
5025
+ fetched,
5026
+ pulled,
5027
+ files_updated: filesUpdated,
5028
+ conflicts,
5029
+ lint_errors: lintErrors,
5030
+ lint_warnings: lintWarnings,
5031
+ humanHint: hintParts.join(", ")
5032
+ })
5033
+ };
5034
+ }
5035
+
5036
+ // src/commands/backup.ts
5037
+ import { statSync as statSync3, readdirSync as readdirSync2, readFileSync as readFileSync8, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
5038
+ import { join as join31, relative as relative3, dirname as dirname11 } from "path";
5039
+ import { PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
5040
+
5041
+ // src/utils/s3-client.ts
5042
+ import { S3Client } from "@aws-sdk/client-s3";
5043
+ function createS3Client(config) {
5044
+ const clientConfig = {
5045
+ endpoint: config.endpoint,
5046
+ region: config.region,
5047
+ credentials: {
5048
+ accessKeyId: config.accessKeyId,
5049
+ secretAccessKey: config.secretAccessKey
5050
+ },
5051
+ forcePathStyle: true
5052
+ // Required for SeaweedFS / MinIO
5053
+ };
5054
+ return new S3Client(clientConfig);
5055
+ }
5056
+
5057
+ // src/commands/backup.ts
5058
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".obsidian", "_archive", "node_modules", ".skillwiki"]);
5059
+ function* walkMarkdown(dir, base) {
5060
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
5061
+ if (SKIP_DIRS.has(entry.name)) continue;
5062
+ const full = join31(dir, entry.name);
5063
+ if (entry.isDirectory()) {
5064
+ yield* walkMarkdown(full, base);
5065
+ } else if (entry.name.endsWith(".md")) {
5066
+ yield relative3(base, full).replace(/\\/g, "/");
5067
+ }
5068
+ }
5069
+ }
5070
+ async function runBackupSync(input) {
5071
+ if (!input.accessKeyId || !input.secretAccessKey) {
5072
+ return {
5073
+ exitCode: ExitCode.BACKUP_SYNC_FAILED,
5074
+ result: err("BACKUP_SYNC_FAILED", {
5075
+ message: "Backup credentials not configured. Run: skillwiki config set BACKUP_ACCESS_KEY_ID <key>"
5076
+ })
5077
+ };
5078
+ }
5079
+ const client = createS3Client(input);
5080
+ let uploaded = 0;
5081
+ let skipped = 0;
5082
+ let failed = 0;
5083
+ const files = [...walkMarkdown(input.vault, input.vault)];
5084
+ for (const relPath of files) {
5085
+ const absPath = join31(input.vault, relPath);
5086
+ const localStat = statSync3(absPath);
5087
+ let needsUpload = true;
5088
+ try {
5089
+ const head = await client.send(new HeadObjectCommand({ Bucket: input.bucket, Key: relPath }));
5090
+ if (head.LastModified && head.LastModified >= localStat.mtime) {
5091
+ needsUpload = false;
5092
+ }
5093
+ } catch {
5094
+ }
5095
+ if (!needsUpload) {
5096
+ skipped++;
5097
+ continue;
5098
+ }
5099
+ if (input.dryRun) {
5100
+ uploaded++;
5101
+ continue;
5102
+ }
5103
+ try {
5104
+ const body = readFileSync8(absPath);
5105
+ await client.send(new PutObjectCommand({ Bucket: input.bucket, Key: relPath, Body: body }));
5106
+ uploaded++;
5107
+ } catch {
5108
+ failed++;
5109
+ }
5110
+ }
5111
+ let pruned = 0;
5112
+ if (input.prune && !input.dryRun) {
5113
+ try {
5114
+ const localSet = new Set(files);
5115
+ const list = await client.send(new ListObjectsV2Command({ Bucket: input.bucket }));
5116
+ const toDelete = (list.Contents ?? []).filter((obj) => obj.Key && !localSet.has(obj.Key)).map((obj) => ({ Key: obj.Key }));
5117
+ if (toDelete.length > 0) {
5118
+ await client.send(new DeleteObjectsCommand({ Bucket: input.bucket, Delete: { Objects: toDelete } }));
5119
+ pruned = toDelete.length;
5120
+ }
5121
+ } catch {
5122
+ }
5123
+ }
5124
+ const hintParts = [];
5125
+ if (input.dryRun) hintParts.push("DRY RUN \u2014");
5126
+ hintParts.push(`scanned: ${files.length}, uploaded: ${uploaded}, skipped: ${skipped}`);
5127
+ if (failed > 0) hintParts.push(`failed: ${failed}`);
5128
+ if (pruned > 0) hintParts.push(`pruned: ${pruned}`);
5129
+ return {
5130
+ exitCode: failed > 0 ? ExitCode.BACKUP_SYNC_FAILED : ExitCode.OK,
5131
+ result: ok({
5132
+ scanned: files.length,
5133
+ uploaded,
5134
+ skipped,
5135
+ failed,
5136
+ pruned,
5137
+ dry_run: input.dryRun ?? false,
5138
+ humanHint: hintParts.join(", ")
5139
+ })
5140
+ };
5141
+ }
5142
+ async function runBackupRestore(input) {
5143
+ if (!input.accessKeyId || !input.secretAccessKey) {
5144
+ return {
5145
+ exitCode: ExitCode.BACKUP_SYNC_FAILED,
5146
+ result: err("BACKUP_SYNC_FAILED", {
5147
+ message: "Backup credentials not configured. Run: skillwiki config set BACKUP_ACCESS_KEY_ID <key>"
5148
+ })
5149
+ };
5150
+ }
5151
+ const client = createS3Client(input);
5152
+ const target = input.target ?? input.vault;
5153
+ let downloaded = 0;
5154
+ let skipped = 0;
5155
+ let conflicts = 0;
5156
+ try {
5157
+ const list = await client.send(new ListObjectsV2Command({ Bucket: input.bucket }));
5158
+ const objects = list.Contents ?? [];
5159
+ for (const obj of objects) {
5160
+ if (!obj.Key) continue;
5161
+ const localPath = join31(target, obj.Key);
5162
+ try {
5163
+ const localStat = statSync3(localPath);
5164
+ if (obj.LastModified && localStat.mtime > obj.LastModified) {
5165
+ conflicts++;
5166
+ continue;
5167
+ }
5168
+ } catch {
5169
+ }
5170
+ try {
5171
+ const resp = await client.send(new GetObjectCommand({ Bucket: input.bucket, Key: obj.Key }));
5172
+ const body = await resp.Body?.transformToByteArray();
5173
+ if (body) {
5174
+ mkdirSync3(dirname11(localPath), { recursive: true });
5175
+ writeFileSync4(localPath, Buffer.from(body));
5176
+ downloaded++;
5177
+ }
5178
+ } catch {
5179
+ skipped++;
5180
+ }
5181
+ }
5182
+ } catch (e) {
5183
+ return {
5184
+ exitCode: ExitCode.BACKUP_SYNC_FAILED,
5185
+ result: err("BACKUP_SYNC_FAILED", { message: `Failed to list bucket: ${String(e)}` })
5186
+ };
5187
+ }
5188
+ const hintParts = [`downloaded: ${downloaded}`];
5189
+ if (skipped > 0) hintParts.push(`skipped: ${skipped}`);
5190
+ if (conflicts > 0) hintParts.push(`conflicts: ${conflicts} (local is newer)`);
5191
+ if (downloaded > 0) {
5192
+ appendLastOp(target, {
5193
+ operation: "backup-restore",
5194
+ summary: `restored ${downloaded} files from S3`,
5195
+ files: [],
5196
+ // Don't enumerate potentially hundreds of files
5197
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
5198
+ });
5199
+ }
5200
+ return {
5201
+ exitCode: conflicts > 0 ? ExitCode.BACKUP_RESTORE_CONFLICTS : ExitCode.OK,
5202
+ result: ok({ downloaded, skipped, conflicts, humanHint: hintParts.join(", ") })
5203
+ };
5204
+ }
5205
+
5206
+ // src/commands/status.ts
5207
+ import { existsSync as existsSync10, statSync as statSync4 } from "fs";
5208
+ import { readFile as readFile20 } from "fs/promises";
5209
+ import { join as join32 } from "path";
5210
+ async function runStatus(input) {
5211
+ if (!existsSync10(input.vault)) {
5212
+ return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
5213
+ }
5214
+ const scan = await scanVault(input.vault);
5215
+ if (!scan.ok) {
5216
+ return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
5217
+ }
5218
+ const typedCounts = { entities: 0, concepts: 0, comparisons: 0, queries: 0, meta: 0 };
5219
+ for (const page of scan.data.typedKnowledge) {
5220
+ const segment = page.relPath.split("/")[0];
5221
+ if (segment in typedCounts) {
5222
+ typedCounts[segment]++;
5223
+ }
5224
+ }
5225
+ let rawArticles = 0;
5226
+ let rawTranscripts = 0;
5227
+ for (const page of scan.data.raw) {
5228
+ const parts = page.relPath.split("/");
5229
+ if (parts[1] === "transcripts") rawTranscripts++;
5230
+ else rawArticles++;
5231
+ }
5232
+ const workItems = scan.data.workItems.length;
5233
+ const compound = scan.data.compound.length;
5234
+ let schemaVersion = "v1";
2838
5235
  try {
2839
- const existingText = await readFile16(indexPath, "utf8");
2840
- existing = true;
2841
- const existingEntries = existingText.split("\n").filter((l) => l.startsWith("- [["));
2842
- const existingPages = new Set(existingEntries.map((l) => {
2843
- const m = l.match(/\[\[([^\]]+)\]\]/);
2844
- return m ? m[1] : "";
2845
- }));
2846
- const currentPages = new Set(entries.map((e) => e.page.replace(/\.md$/, "")));
2847
- stale = existingPages.size !== currentPages.size || [...currentPages].some((p) => !existingPages.has(p));
5236
+ const schemaContent = await readFile20(join32(input.vault, "SCHEMA.md"), "utf8");
5237
+ const versionMatch = schemaContent.match(/version:\s*["']?([^"'\s\n]+)/i);
5238
+ if (versionMatch) schemaVersion = versionMatch[1];
2848
5239
  } catch {
2849
5240
  }
2850
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2851
- const grouped = /* @__PURE__ */ new Map();
2852
- for (const e of entries) {
2853
- const group = e.type;
2854
- if (!grouped.has(group)) grouped.set(group, []);
2855
- grouped.get(group).push(e);
5241
+ const langResult = await resolveLang({ flag: void 0, envValue: input.langEnvValue, home: input.home });
5242
+ const allPages = [
5243
+ ...scan.data.typedKnowledge,
5244
+ ...scan.data.raw,
5245
+ ...scan.data.workItems,
5246
+ ...scan.data.compound
5247
+ ];
5248
+ let lastModified = "";
5249
+ let maxTime = 0;
5250
+ for (const page of allPages) {
5251
+ try {
5252
+ const st = statSync4(page.absPath);
5253
+ if (st.mtimeMs > maxTime) {
5254
+ maxTime = st.mtimeMs;
5255
+ lastModified = st.mtime.toISOString();
5256
+ }
5257
+ } catch {
5258
+ }
2856
5259
  }
2857
- let body = `# Knowledge Index: ${slug}
5260
+ const pageCounts = {
5261
+ entities: typedCounts.entities,
5262
+ concepts: typedCounts.concepts,
5263
+ comparisons: typedCounts.comparisons,
5264
+ queries: typedCounts.queries,
5265
+ meta: typedCounts.meta,
5266
+ raw_articles: rawArticles,
5267
+ raw_transcripts: rawTranscripts,
5268
+ work_items: workItems,
5269
+ compound
5270
+ };
5271
+ const totalPages = Object.values(pageCounts).reduce((a, b) => a + b, 0);
5272
+ const rawTotal = rawArticles + rawTranscripts;
5273
+ const humanHint = [
5274
+ `vault: ${input.vault}`,
5275
+ `lang: ${langResult.value}`,
5276
+ `total: ${totalPages} pages`,
5277
+ ` entities: ${pageCounts.entities} concepts: ${pageCounts.concepts} comparisons: ${pageCounts.comparisons} queries: ${pageCounts.queries} meta: ${pageCounts.meta}`,
5278
+ ` raw: ${rawTotal} work_items: ${workItems} compound: ${compound}`,
5279
+ `last modified: ${lastModified.slice(0, 10)}`
5280
+ ].join("\n");
5281
+ return {
5282
+ exitCode: ExitCode.OK,
5283
+ result: ok({
5284
+ vault_path: input.vault,
5285
+ schema_version: schemaVersion,
5286
+ lang: langResult.canonical,
5287
+ page_counts: pageCounts,
5288
+ total_pages: totalPages,
5289
+ last_modified: lastModified,
5290
+ humanHint
5291
+ })
5292
+ };
5293
+ }
2858
5294
 
2859
- Autogenerated by \`skillwiki project-index\` on ${today}.
5295
+ // src/commands/seed.ts
5296
+ import { mkdir as mkdir12, writeFile as writeFile17, stat as stat7 } from "fs/promises";
5297
+ import { join as join33 } from "path";
5298
+ var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5299
+ var EXAMPLE_PAGES = {
5300
+ "entities/example-project.md": `---
5301
+ title: Example Project
5302
+ aliases: [example-project]
5303
+ created: ${TODAY}
5304
+ updated: ${TODAY}
5305
+ type: entity
5306
+ tags: [research]
5307
+ sources: []
5308
+ confidence: medium
5309
+ provenance: research
5310
+ ---
2860
5311
 
2861
- `;
2862
- for (const [type, items] of grouped) {
2863
- body += `## ${type}
5312
+ # Example Project
2864
5313
 
5314
+ ## Overview
5315
+
5316
+ This is a seed entity page demonstrating the typed-knowledge format. Replace it with a real entity from your research.
5317
+
5318
+ ## Key Facts
5319
+
5320
+ - This vault was seeded on ${TODAY}
5321
+ - Entity pages describe people, organizations, products, or projects
5322
+ - Each page should cite sources from the \`raw/\` directory
5323
+ `,
5324
+ "concepts/example-concept.md": `---
5325
+ title: Example Concept
5326
+ aliases: [example-concept]
5327
+ created: ${TODAY}
5328
+ updated: ${TODAY}
5329
+ type: concept
5330
+ tags: [concept]
5331
+ sources: []
5332
+ confidence: medium
5333
+ provenance: research
5334
+ ---
5335
+
5336
+ # Example Concept
5337
+
5338
+ ## Overview
5339
+
5340
+ This is a seed concept page. Concept pages capture topics, patterns, and ideas that span multiple sources.
5341
+
5342
+ ## Related
5343
+
5344
+ - [[example-project]]
5345
+
5346
+ ## Sources
5347
+
5348
+ (Add source citations here after ingesting raw material with \`wiki-ingest\`)
5349
+ `
5350
+ };
5351
+ var EXAMPLE_RAW = `---
5352
+ source_url: https://example.com
5353
+ ingested: ${TODAY}
5354
+ sha256: 0000000000000000000000000000000000000000000000000000000000000000
5355
+ ---
5356
+
5357
+ # Example Source Article
5358
+
5359
+ This is a placeholder raw source. Replace it with real content ingested via \`skillwiki hash\` and the wiki-ingest skill.
5360
+
5361
+ Real sources are immutable after ingestion \u2014 never edit them.
2865
5362
  `;
2866
- for (const item of items) {
2867
- const pageRef = item.page.replace(/\.md$/, "");
2868
- body += `- [[${pageRef}]] \u2014 ${item.title}
2869
- `;
5363
+ async function runSeed(input) {
5364
+ try {
5365
+ await stat7(join33(input.vault, "SCHEMA.md"));
5366
+ } catch {
5367
+ return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { root: input.vault, reason: "SCHEMA.md missing \u2014 run `skillwiki init` first" }) };
5368
+ }
5369
+ const created = [];
5370
+ const skipped = [];
5371
+ for (const [relPath, content] of Object.entries(EXAMPLE_PAGES)) {
5372
+ const absPath = join33(input.vault, relPath);
5373
+ try {
5374
+ await stat7(absPath);
5375
+ skipped.push(relPath);
5376
+ } catch {
5377
+ await mkdir12(join33(absPath, ".."), { recursive: true });
5378
+ await writeFile17(absPath, content, "utf8");
5379
+ created.push(relPath);
2870
5380
  }
2871
- body += "\n";
2872
5381
  }
2873
- if (entries.length === 0) {
2874
- body += `No Layer 2 pages reference \`[[${slug}]]\` in provenance_projects.
2875
- `;
5382
+ const rawPath = join33(input.vault, "raw", "articles", "example-source.md");
5383
+ try {
5384
+ await stat7(rawPath);
5385
+ skipped.push("raw/articles/example-source.md");
5386
+ } catch {
5387
+ await mkdir12(join33(rawPath, ".."), { recursive: true });
5388
+ await writeFile17(rawPath, EXAMPLE_RAW, "utf8");
5389
+ created.push("raw/articles/example-source.md");
5390
+ }
5391
+ if (created.length > 0) {
5392
+ appendLastOp(input.vault, {
5393
+ operation: "seed",
5394
+ summary: `seeded ${created.length} example pages`,
5395
+ files: created,
5396
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
5397
+ });
2876
5398
  }
2877
- if (input.apply) {
2878
- try {
2879
- await mkdir6(dirname8(indexPath), { recursive: true });
2880
- await writeFile11(indexPath, body, "utf8");
2881
- } catch (e) {
2882
- return {
2883
- exitCode: ExitCode.WRITE_FAILED,
2884
- result: err("WRITE_FAILED", { file: indexPath, message: String(e) })
2885
- };
5399
+ const hintLines = [`seeded: ${created.length}`, `skipped (already exist): ${skipped.length}`];
5400
+ if (created.length > 0) {
5401
+ hintLines.push("next steps: ingest real sources with wiki-ingest, then cite them in concept/entity pages");
5402
+ }
5403
+ return {
5404
+ exitCode: ExitCode.OK,
5405
+ result: ok({ created, skipped, humanHint: hintLines.join("\n") })
5406
+ };
5407
+ }
5408
+
5409
+ // src/commands/canvas.ts
5410
+ import { readFile as readFile21, writeFile as writeFile18 } from "fs/promises";
5411
+ import { existsSync as existsSync11 } from "fs";
5412
+ import { join as join34 } from "path";
5413
+ var NODE_WIDTH = 240;
5414
+ var NODE_HEIGHT = 60;
5415
+ var COLUMN_SPACING = 400;
5416
+ var ROW_SPACING = 80;
5417
+ var TYPE_COLUMNS = {
5418
+ entities: 0,
5419
+ concepts: 1,
5420
+ comparisons: 2,
5421
+ queries: 3,
5422
+ meta: 3
5423
+ };
5424
+ var TYPE_COLORS = {
5425
+ entities: "1",
5426
+ // red
5427
+ concepts: "4",
5428
+ // green
5429
+ comparisons: "2",
5430
+ // orange
5431
+ queries: "5",
5432
+ // cyan
5433
+ meta: "6"
5434
+ // purple
5435
+ };
5436
+ var DEFAULT_COLOR = "3";
5437
+ var DEFAULT_COLUMN = 2;
5438
+ function inferNodeType(relPath) {
5439
+ const segment = relPath.split("/")[0] ?? "";
5440
+ return TYPE_COLUMNS[segment] !== void 0 ? segment : "";
5441
+ }
5442
+ function getColumnForType(nodeType) {
5443
+ return TYPE_COLUMNS[nodeType] ?? DEFAULT_COLUMN;
5444
+ }
5445
+ function getColorForType(nodeType) {
5446
+ return TYPE_COLORS[nodeType] ?? DEFAULT_COLOR;
5447
+ }
5448
+ function buildCanvasNodes(paths) {
5449
+ const columnY = {};
5450
+ const nodes = [];
5451
+ for (const relPath of paths) {
5452
+ const nodeType = inferNodeType(relPath);
5453
+ const col = getColumnForType(nodeType);
5454
+ const y = columnY[col] ?? 0;
5455
+ columnY[col] = y + ROW_SPACING;
5456
+ nodes.push({
5457
+ id: relPath,
5458
+ type: "file",
5459
+ file: relPath,
5460
+ x: col * COLUMN_SPACING,
5461
+ y,
5462
+ width: NODE_WIDTH,
5463
+ height: NODE_HEIGHT,
5464
+ color: getColorForType(nodeType)
5465
+ });
5466
+ }
5467
+ return nodes;
5468
+ }
5469
+ function buildCanvasEdges(adjacency) {
5470
+ const edges = [];
5471
+ let edgeIndex = 0;
5472
+ const seen = /* @__PURE__ */ new Set();
5473
+ for (const [source, targets] of Object.entries(adjacency)) {
5474
+ for (const target of targets) {
5475
+ const key = `${source}->${target}`;
5476
+ if (seen.has(key)) continue;
5477
+ seen.add(key);
5478
+ edges.push({
5479
+ id: `edge-${edgeIndex++}`,
5480
+ fromNode: source,
5481
+ toNode: target,
5482
+ fromSide: "right",
5483
+ toSide: "left"
5484
+ });
2886
5485
  }
2887
5486
  }
2888
- const action = input.apply ? `written ${entries.length} entries to ${indexPath}` : `${entries.length} entries found (use --apply to write)`;
2889
- const staleHint = stale ? " (STALE \u2014 existing index outdated)" : existing ? " (up to date)" : "";
5487
+ return edges;
5488
+ }
5489
+ async function runCanvasGenerate(input) {
5490
+ const graphPath = input.graphPath ?? join34(input.vault, ".skillwiki", "graph.json");
5491
+ if (!existsSync11(graphPath)) {
5492
+ return {
5493
+ exitCode: ExitCode.FILE_NOT_FOUND,
5494
+ result: err("FILE_NOT_FOUND", {
5495
+ path: graphPath,
5496
+ hint: "Run `skillwiki graph build` first to generate graph.json"
5497
+ })
5498
+ };
5499
+ }
5500
+ let raw;
5501
+ try {
5502
+ raw = await readFile21(graphPath, "utf8");
5503
+ } catch (e) {
5504
+ return {
5505
+ exitCode: ExitCode.FILE_NOT_FOUND,
5506
+ result: err("FILE_NOT_FOUND", { path: graphPath, message: String(e) })
5507
+ };
5508
+ }
5509
+ let graph;
5510
+ try {
5511
+ graph = JSON.parse(raw);
5512
+ } catch {
5513
+ return {
5514
+ exitCode: ExitCode.SCHEMA_NOT_DETECTED,
5515
+ result: err("SCHEMA_NOT_DETECTED", { path: graphPath, reason: "Invalid JSON in graph.json" })
5516
+ };
5517
+ }
5518
+ if (!graph.adjacency || typeof graph.adjacency !== "object") {
5519
+ return {
5520
+ exitCode: ExitCode.SCHEMA_NOT_DETECTED,
5521
+ result: err("SCHEMA_NOT_DETECTED", { path: graphPath, reason: "graph.json missing adjacency field" })
5522
+ };
5523
+ }
5524
+ const paths = Object.keys(graph.adjacency);
5525
+ const nodes = buildCanvasNodes(paths);
5526
+ const edges = buildCanvasEdges(graph.adjacency);
5527
+ const canvas = { nodes, edges };
5528
+ const outPath = join34(input.vault, "vault-graph.canvas");
5529
+ try {
5530
+ await writeFile18(outPath, JSON.stringify(canvas, null, 2));
5531
+ } catch (e) {
5532
+ return {
5533
+ exitCode: ExitCode.WRITE_FAILED,
5534
+ result: err("WRITE_FAILED", { message: String(e), path: outPath })
5535
+ };
5536
+ }
2890
5537
  return {
2891
5538
  exitCode: ExitCode.OK,
2892
5539
  result: ok({
2893
- slug,
2894
- entries,
2895
- existing,
2896
- stale,
2897
- index_path: `projects/${slug}/knowledge.md`,
2898
- humanHint: `project: ${slug}
2899
- entries: ${entries.length}${staleHint}
2900
- ${action}
2901
-
2902
- ${entries.map((e) => ` ${e.type}: [[${e.page.replace(/\.md$/, "")}]] \u2014 ${e.title}`).join("\n")}`
5540
+ out_path: outPath,
5541
+ node_count: nodes.length,
5542
+ edge_count: edges.length,
5543
+ humanHint: `nodes: ${nodes.length}, edges: ${edges.length}
5544
+ written: ${outPath}`
2903
5545
  })
2904
5546
  };
2905
5547
  }
2906
5548
 
5549
+ // src/commands/query.ts
5550
+ import { readFile as readFile22, stat as stat8 } from "fs/promises";
5551
+ import { join as join35 } from "path";
5552
+ var W_KEYWORD = 2;
5553
+ var W_SOURCE_OVERLAP = 4;
5554
+ var W_WIKILINK = 3;
5555
+ var W_ADAMIC_ADAR = 1.5;
5556
+ var W_TYPE_AFFINITY = 1;
5557
+ var NON_SEED_FACTOR = 0.4;
5558
+ var CONCEPT_INDICATORS = /* @__PURE__ */ new Set([
5559
+ "what",
5560
+ "how",
5561
+ "why",
5562
+ "concept",
5563
+ "idea",
5564
+ "pattern",
5565
+ "principle",
5566
+ "theory",
5567
+ "approach",
5568
+ "method",
5569
+ "framework",
5570
+ "model",
5571
+ "definition"
5572
+ ]);
5573
+ async function runQuery(input) {
5574
+ const scan = await scanVault(input.vault);
5575
+ if (!scan.ok) return { exitCode: ExitCode.VAULT_PATH_INVALID, result: scan };
5576
+ const limit = input.limit ?? 10;
5577
+ const queryTerms = tokenize(input.text);
5578
+ if (queryTerms.length === 0) {
5579
+ return {
5580
+ exitCode: ExitCode.OK,
5581
+ result: ok({ results: [], humanHint: "no query terms" })
5582
+ };
5583
+ }
5584
+ const graph = await loadOrBuildGraph(input.vault);
5585
+ const pages = [];
5586
+ for (const p of scan.data.typedKnowledge) {
5587
+ const text = await readPage(p);
5588
+ const fm = extractFrontmatter(text);
5589
+ if (!fm.ok) continue;
5590
+ const title = String(fm.data.title ?? "");
5591
+ const type = String(fm.data.type ?? "");
5592
+ const tags = Array.isArray(fm.data.tags) ? fm.data.tags.map(String) : [];
5593
+ const sources = Array.isArray(fm.data.sources) ? fm.data.sources.map(String) : [];
5594
+ const split = splitFrontmatter(text);
5595
+ const body = split.ok ? split.data.body : text;
5596
+ const keywordScore = computeKeywordScore(queryTerms, title, tags, body);
5597
+ pages.push({ relPath: p.relPath, title, type, tags, sources, keywordScore });
5598
+ }
5599
+ const seedPaths = new Set(
5600
+ pages.filter((p) => p.keywordScore > 0).map((p) => p.relPath)
5601
+ );
5602
+ const results = pages.map((page) => {
5603
+ const sourceOverlap = scoreSourceOverlap(page, pages, seedPaths);
5604
+ const wikilink2 = scoreWikilink(page.relPath, seedPaths, graph);
5605
+ const aa = scoreAdamicAdar(page.relPath, seedPaths, graph);
5606
+ const typeAffinity = scoreTypeAffinity(page.type, queryTerms);
5607
+ const isSeed = page.keywordScore > 0;
5608
+ const structuralBoost = sourceOverlap * W_SOURCE_OVERLAP + wikilink2 * W_WIKILINK + aa * W_ADAMIC_ADAR;
5609
+ const composite = isSeed ? page.keywordScore * W_KEYWORD + structuralBoost + typeAffinity * W_TYPE_AFFINITY : structuralBoost * NON_SEED_FACTOR + typeAffinity * W_TYPE_AFFINITY;
5610
+ return {
5611
+ path: page.relPath,
5612
+ score: Math.round(composite * 1e3) / 1e3,
5613
+ title: page.title,
5614
+ type: page.type
5615
+ };
5616
+ }).filter((r) => r.score > 0).sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, limit);
5617
+ const humanHint = results.length === 0 ? "no matching pages found" : results.map((r) => `${r.path} (score: ${r.score})`).join("\n");
5618
+ return { exitCode: ExitCode.OK, result: ok({ results, humanHint }) };
5619
+ }
5620
+ function scoreSourceOverlap(page, allPages, seedPaths) {
5621
+ if (page.sources.length === 0) return 0;
5622
+ let total = 0;
5623
+ for (const seed of allPages) {
5624
+ if (seed.relPath === page.relPath || !seedPaths.has(seed.relPath)) continue;
5625
+ const shared = page.sources.filter((s) => seed.sources.includes(s)).length;
5626
+ total += shared;
5627
+ }
5628
+ return total;
5629
+ }
5630
+ function scoreWikilink(candidatePath, seedPaths, graph) {
5631
+ if (!graph) return 0;
5632
+ let count = 0;
5633
+ for (const seedPath of seedPaths) {
5634
+ const neighbors = graph.adjacency[seedPath];
5635
+ if (neighbors && neighbors.includes(candidatePath)) count++;
5636
+ }
5637
+ return count;
5638
+ }
5639
+ function scoreAdamicAdar(candidatePath, seedPaths, graph) {
5640
+ if (!graph) return 0;
5641
+ let maxScore = 0;
5642
+ const aaForCandidate = graph.adamicAdar[candidatePath];
5643
+ if (!aaForCandidate) return 0;
5644
+ for (const seedPath of seedPaths) {
5645
+ const val = aaForCandidate[seedPath];
5646
+ if (val !== void 0 && val > maxScore) maxScore = val;
5647
+ }
5648
+ return maxScore;
5649
+ }
5650
+ function scoreTypeAffinity(pageType, queryTerms) {
5651
+ const hasConceptIntent = queryTerms.some((t) => CONCEPT_INDICATORS.has(t));
5652
+ if (hasConceptIntent && pageType === "concept") return 1;
5653
+ if (!hasConceptIntent && pageType === "entity") return 0.5;
5654
+ return 0;
5655
+ }
5656
+ function tokenize(text) {
5657
+ return text.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
5658
+ }
5659
+ function computeKeywordScore(terms, title, tags, body) {
5660
+ const lowerTitle = title.toLowerCase();
5661
+ const lowerTags = tags.map((t) => t.toLowerCase());
5662
+ const lowerBody = body.toLowerCase();
5663
+ let score = 0;
5664
+ for (const term of terms) {
5665
+ if (lowerTitle.includes(term)) score += 3;
5666
+ if (lowerTags.some((t) => t.includes(term))) score += 2;
5667
+ if (lowerBody.includes(term)) score += 1;
5668
+ }
5669
+ return score;
5670
+ }
5671
+ async function loadOrBuildGraph(vault) {
5672
+ const graphPath = join35(vault, ".skillwiki", "graph.json");
5673
+ let needsBuild = false;
5674
+ try {
5675
+ const fileStat = await stat8(graphPath);
5676
+ const ageHours = (Date.now() - fileStat.mtimeMs) / (1e3 * 60 * 60);
5677
+ if (ageHours > 24) needsBuild = true;
5678
+ } catch {
5679
+ needsBuild = true;
5680
+ }
5681
+ if (needsBuild) {
5682
+ const buildResult = await runGraphBuild({ vault, out: graphPath });
5683
+ if (buildResult.exitCode !== 0) return null;
5684
+ }
5685
+ try {
5686
+ const raw = await readFile22(graphPath, "utf8");
5687
+ return JSON.parse(raw);
5688
+ } catch {
5689
+ return null;
5690
+ }
5691
+ }
5692
+
5693
+ // src/utils/auto-commit.ts
5694
+ import { existsSync as existsSync12 } from "fs";
5695
+ import { join as join36 } from "path";
5696
+ async function postCommit(vault, exitCode) {
5697
+ if (exitCode !== 0) return;
5698
+ const home = process.env.HOME ?? "";
5699
+ const dotenv = await parseDotenvFile(configPath(home));
5700
+ if (dotenv["AUTO_COMMIT"] === "false") return;
5701
+ if (!existsSync12(join36(vault, ".git"))) return;
5702
+ const lastOps = readLastOp(vault);
5703
+ if (lastOps.length === 0) return;
5704
+ const porcelain = git(vault, ["status", "--porcelain"]);
5705
+ if (!porcelain || porcelain.trim().length === 0) return;
5706
+ const { gitStrict: gitStrict2 } = await import("./git-M4WGJ5G3.js");
5707
+ try {
5708
+ gitStrict2(vault, ["add", "-A"]);
5709
+ try {
5710
+ gitStrict2(vault, ["reset", "HEAD", "--", ".skillwiki/last-op.json"]);
5711
+ } catch {
5712
+ }
5713
+ } catch (e) {
5714
+ process.stderr.write(`auto-commit: git add failed: ${String(e)}
5715
+ `);
5716
+ return;
5717
+ }
5718
+ const commitMessage = lastOps.map((op) => `${op.operation}: ${op.summary} (${op.files.length} files)`).join("; ");
5719
+ try {
5720
+ gitStrict2(vault, ["commit", "-m", commitMessage]);
5721
+ } catch (e) {
5722
+ process.stderr.write(`auto-commit: git commit failed: ${String(e)}
5723
+ `);
5724
+ return;
5725
+ }
5726
+ clearLastOp(vault);
5727
+ }
5728
+
2907
5729
  // src/cli.ts
2908
- var pkg = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
5730
+ var pkg = JSON.parse(readFileSync9(new URL("../package.json", import.meta.url), "utf8"));
2909
5731
  var program = new Command();
2910
5732
  program.name("skillwiki").description("Deterministic helpers for CodeWiki skills").version(pkg.version);
2911
5733
  program.option("--human", "render terminal-readable output instead of JSON");
2912
- function emit(r) {
5734
+ async function emit(r, vault) {
2913
5735
  if (program.opts().human) printHuman(r.result);
2914
5736
  else printJson(r.result);
5737
+ if (vault) await postCommit(vault, r.exitCode);
2915
5738
  process.exit(r.exitCode);
2916
5739
  }
2917
- program.command("hash <file>").action(async (file) => emit(await runHash({ file })));
2918
- program.command("fetch-guard <url>").action(async (url) => emit(await runFetchGuard({ url })));
2919
- program.command("validate <file>").action(async (file) => emit(await runValidate({ file })));
2920
- program.command("graph").description("graph subcommands").command("build <vault>").option("--out <path>", "graph output path", ".skillwiki/graph.json").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => emit(await runGraphBuild({ vault, out: opts.out })));
2921
- program.command("overlap <vault>").action(async (vault) => emit(await runOverlap({ vault })));
2922
- program.command("orphans [vault]").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => emit(await runOrphans({
5740
+ program.command("hash <file>").description("compute SHA-256 hash of a vault page body").action(async (file) => emit(await runHash({ file })));
5741
+ program.command("fetch-guard <url>").description("check if a URL passes fetch guard rules and sanitize secrets").action(async (url) => emit(await runFetchGuard({ url })));
5742
+ program.command("validate <file>").description("validate vault page frontmatter against its detected schema").option("--apply", "auto-update vault index.md and log.md after successful validation", false).option("--vault <dir>", "vault root directory (required with --apply)").option("--wiki <name>", "wiki profile name").action(async (file, opts) => {
5743
+ let vault;
5744
+ if (opts.apply) {
5745
+ const v = await resolveVaultArg(opts.vault, opts.wiki);
5746
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5747
+ else vault = v.vault;
5748
+ }
5749
+ emit(await runValidate({ file, apply: !!opts.apply, vault }), vault);
5750
+ });
5751
+ program.command("graph").description("graph subcommands").command("build <vault>").option("--out <path>", "graph output path (default: <vault>/.skillwiki/graph.json)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5752
+ const out = opts.out ?? join37(vault, ".skillwiki", "graph.json");
5753
+ emit(await runGraphBuild({ vault, out }), vault);
5754
+ });
5755
+ var canvasCmd = program.command("canvas").description("manage Obsidian canvas files");
5756
+ canvasCmd.command("generate [vault]").description("generate .canvas from graph.json").option("--graph-path <path>", "explicit path to graph.json").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5757
+ const v = await resolveVaultArg(vault, opts.wiki);
5758
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5759
+ else emit(await runCanvasGenerate({ vault: v.vault, graphPath: opts.graphPath }), v.vault);
5760
+ });
5761
+ program.command("overlap [vault]").description("detect typed-knowledge pages that share the same raw sources").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5762
+ const v = await resolveVaultArg(vault, opts.wiki);
5763
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5764
+ else emit(await runOverlap({ vault: v.vault }), v.vault);
5765
+ });
5766
+ program.command("query <text> [vault]").description("score and rank vault pages by relevance to a query").option("--limit <n>", "max results to return", (s) => parseInt(s, 10), 10).option("--wiki <name>", "wiki profile name").action(async (text, vault, opts) => {
5767
+ const v = await resolveVaultArg(vault, opts.wiki);
5768
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5769
+ else emit(await runQuery({ text, vault: v.vault, limit: opts.limit }), v.vault);
5770
+ });
5771
+ program.command("orphans [vault]").description("find pages not referenced by any other page").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => emit(await runOrphans({
2923
5772
  vault,
2924
5773
  envValue: process.env.WIKI_PATH,
2925
5774
  home: process.env.HOME ?? "",
2926
5775
  wiki: opts.wiki
2927
5776
  })));
2928
- program.command("audit <file>").action(async (file) => emit(await runAudit({ file })));
2929
- 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) => {
5777
+ program.command("audit <file>").description("audit citation markers and source provenance for a vault page").action(async (file) => emit(await runAudit({ file })));
5778
+ program.command("install").description("install skillwiki SKILL.md files into ~/.claude/skills/").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)").option("--symlink", "create symlinks instead of copies (dev mode \u2014 edits to source are immediately visible)", false).action(async (opts) => {
2930
5779
  const skillsRoot = opts.skillsRoot ?? new URL("../skills/", import.meta.url).pathname;
2931
- emit(await runInstall({ skillsRoot, target: opts.target, dryRun: !!opts.dryRun }));
5780
+ emit(await runInstall({ skillsRoot, target: opts.target, dryRun: !!opts.dryRun, symlink: !!opts.symlink }));
2932
5781
  });
2933
- program.command("path").option("--vault <dir>", "explicit vault override (runtime)").option("--target <dir>", "explicit target override (init-time)").option("--wiki <name>", "wiki profile name").option("--init-time", "use init-time chain instead of runtime", false).option("--explain", "include resolution chain in output", false).action(async (opts) => {
5782
+ program.command("path").description("show the resolved vault path").option("--vault <dir>", "explicit vault override (runtime)").option("--target <dir>", "explicit target override (init-time)").option("--wiki <name>", "wiki profile name").option("--init-time", "use init-time chain instead of runtime", false).option("--explain", "include resolution chain in output", false).action(async (opts) => {
2934
5783
  const initTime = !!opts.initTime;
2935
5784
  const flag = initTime ? opts.target : opts.vault;
2936
5785
  emit(await runPath({
@@ -2942,7 +5791,7 @@ program.command("path").option("--vault <dir>", "explicit vault override (runtim
2942
5791
  explain: !!opts.explain
2943
5792
  }));
2944
5793
  });
2945
- program.command("lang").option("--lang <code>", "explicit language override").option("--explain", "include resolution chain in output", false).action(async (opts) => {
5794
+ program.command("lang").description("get or set the vault language").option("--lang <code>", "explicit language override").option("--explain", "include resolution chain in output", false).action(async (opts) => {
2946
5795
  emit(await runLang({
2947
5796
  flag: opts.lang,
2948
5797
  envValue: process.env.WIKI_LANG,
@@ -2950,7 +5799,7 @@ program.command("lang").option("--lang <code>", "explicit language override").op
2950
5799
  explain: !!opts.explain
2951
5800
  }));
2952
5801
  });
2953
- 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").option("--profile <name>", "write as named wiki profile instead of WIKI_PATH").action(async (opts) => {
5802
+ program.command("init").description("bootstrap a new vault with SCHEMA.md, index.md, log.md").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").option("--profile <name>", "write as named wiki profile instead of WIKI_PATH").action(async (opts) => {
2954
5803
  const templates = new URL("../templates/", import.meta.url).pathname;
2955
5804
  const taxonomy = typeof opts.taxonomy === "string" ? opts.taxonomy.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : void 0;
2956
5805
  emit(await runInit({
@@ -2981,37 +5830,47 @@ async function resolveVaultArg(arg, wiki) {
2981
5830
  }
2982
5831
  return { ok: true, vault: r.data.path };
2983
5832
  }
2984
- program.command("links [vault]").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5833
+ program.command("links [vault]").description("check wikilink integrity across the vault").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5834
+ const v = await resolveVaultArg(vault, opts.wiki);
5835
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5836
+ else emit(await runLinks({ vault: v.vault }), v.vault);
5837
+ });
5838
+ program.command("tag-audit [vault]").description("audit tag taxonomy consistency").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5839
+ const v = await resolveVaultArg(vault, opts.wiki);
5840
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5841
+ else emit(await runTagAudit({ vault: v.vault }), v.vault);
5842
+ });
5843
+ program.command("index-check [vault]").description("verify index.md entries match actual vault pages").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
2985
5844
  const v = await resolveVaultArg(vault, opts.wiki);
2986
5845
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2987
- else emit(await runLinks({ vault: v.vault }));
5846
+ else emit(await runIndexCheck({ vault: v.vault }), v.vault);
2988
5847
  });
2989
- program.command("tag-audit [vault]").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5848
+ program.command("index-link-format [vault]").description("check index.md for markdown links that should be wikilinks").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
2990
5849
  const v = await resolveVaultArg(vault, opts.wiki);
2991
5850
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2992
- else emit(await runTagAudit({ vault: v.vault }));
5851
+ else emit(await runIndexLinkFormat({ vault: v.vault }), v.vault);
2993
5852
  });
2994
- program.command("index-check [vault]").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5853
+ program.command("topic-map-check [vault]").description("check whether a topic map page is recommended based on page count").option("--threshold <n>", "page count threshold", (s) => parseInt(s, 10), 200).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
2995
5854
  const v = await resolveVaultArg(vault, opts.wiki);
2996
5855
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
2997
- else emit(await runIndexCheck({ vault: v.vault }));
5856
+ else emit(await runTopicMapCheck({ vault: v.vault, threshold: opts.threshold }), v.vault);
2998
5857
  });
2999
- program.command("stale [vault]").option("--days <n>", "staleness threshold in days", (s) => parseInt(s, 10), 90).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5858
+ 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("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3000
5859
  const v = await resolveVaultArg(vault, opts.wiki);
3001
5860
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3002
- else emit(await runStale({ vault: v.vault, days: opts.days }));
5861
+ else emit(await runStale({ vault: v.vault, days: opts.days, archive: !!opts.archive }), v.vault);
3003
5862
  });
3004
- program.command("pagesize [vault]").option("--lines <n>", "max body lines", (s) => parseInt(s, 10), 200).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5863
+ program.command("pagesize [vault]").description("report page sizes and flag oversized pages").option("--lines <n>", "max body lines", (s) => parseInt(s, 10), 200).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3005
5864
  const v = await resolveVaultArg(vault, opts.wiki);
3006
5865
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3007
- else emit(await runPagesize({ vault: v.vault, lines: opts.lines }));
5866
+ else emit(await runPagesize({ vault: v.vault, lines: opts.lines }), v.vault);
3008
5867
  });
3009
- program.command("log-rotate [vault]").option("--threshold <n>", "entry count threshold", (s) => parseInt(s, 10), 500).option("--apply", "actually rotate", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5868
+ program.command("log-rotate [vault]").description("rotate or trim the vault log file").option("--threshold <n>", "entry count threshold", (s) => parseInt(s, 10), 500).option("--apply", "actually rotate", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3010
5869
  const v = await resolveVaultArg(vault, opts.wiki);
3011
5870
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3012
- else emit(await runLogRotate({ vault: v.vault, threshold: opts.threshold, apply: !!opts.apply }));
5871
+ else emit(await runLogRotate({ vault: v.vault, threshold: opts.threshold, apply: !!opts.apply }), v.vault);
3013
5872
  });
3014
- program.command("lint [vault]").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) => {
5873
+ 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) => {
3015
5874
  const v = await resolveVaultArg(vault, opts.wiki);
3016
5875
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3017
5876
  else emit(await runLint({
@@ -3021,7 +5880,7 @@ program.command("lint [vault]").option("--days <n>", "stale threshold", (s) => p
3021
5880
  lines: opts.lines,
3022
5881
  logThreshold: opts.logThreshold,
3023
5882
  fix: opts.fix ?? false
3024
- }));
5883
+ }), v.vault);
3025
5884
  });
3026
5885
  var configCmd = program.command("config").description("manage skillwiki configuration");
3027
5886
  configCmd.command("get <key>").description("print the value of a config key").action(async (key) => emit(await runConfigGet({ key, home: process.env.HOME ?? "" })));
@@ -3035,47 +5894,159 @@ program.command("doctor").description("diagnose skillwiki setup issues").action(
3035
5894
  currentVersion: pkg.version,
3036
5895
  cwd: process.cwd()
3037
5896
  })));
5897
+ program.command("status [vault]").description("output vault diagnostics").option("--human", "render terminal-readable output instead of JSON").option("--json", "output as JSON (default)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5898
+ const v = await resolveVaultArg(vault, opts.wiki);
5899
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5900
+ else emit(await runStatus({
5901
+ vault: v.vault,
5902
+ home: process.env.HOME ?? "",
5903
+ langEnvValue: process.env.WIKI_LANG
5904
+ }), v.vault);
5905
+ });
3038
5906
  program.command("archive <page> [vault]").description("archive a typed-knowledge or raw page").option("--wiki <name>", "wiki profile name").action(async (page, vault, opts) => {
3039
5907
  const v = await resolveVaultArg(vault, opts.wiki);
3040
5908
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3041
- else emit(await runArchive({ vault: v.vault, page }));
5909
+ else emit(await runArchive({ vault: v.vault, page }), v.vault);
3042
5910
  });
3043
5911
  program.command("drift [vault]").description("detect content drift in raw sources").option("--apply", "update sha256 in drifted sources").option("--new <date>", "list raw files ingested on/after this date (YYYY-MM-DD)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3044
5912
  const v = await resolveVaultArg(vault, opts.wiki);
3045
5913
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3046
- else emit(await runDrift({ vault: v.vault, apply: opts.apply, newSince: opts.new }));
5914
+ else emit(await runDrift({ vault: v.vault, apply: opts.apply, newSince: opts.new }), v.vault);
3047
5915
  });
3048
5916
  program.command("dedup [vault]").description("detect duplicate raw sources by sha256").option("--apply", "rewire citations and remove duplicate raw files", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3049
5917
  const v = await resolveVaultArg(vault, opts.wiki);
3050
5918
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3051
- else emit(await runDedup({ vault: v.vault, apply: opts.apply }));
5919
+ else emit(await runDedup({ vault: v.vault, apply: opts.apply }), v.vault);
3052
5920
  });
3053
5921
  program.command("migrate-citations [vault]").description("migrate ^[raw/...] markers to paragraph-end citations").option("--dry-run", "preview changes without writing", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3054
5922
  const v = await resolveVaultArg(vault, opts.wiki);
3055
5923
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3056
- else emit(await runMigrateCitations({ vault: v.vault, dryRun: !!opts.dryRun }));
5924
+ else emit(await runMigrateCitations({ vault: v.vault, dryRun: !!opts.dryRun }), v.vault);
3057
5925
  });
3058
5926
  program.command("frontmatter-fix [vault]").description("fix common frontmatter issues on typed-knowledge pages").option("--dry-run", "preview changes without writing", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3059
5927
  const v = await resolveVaultArg(vault, opts.wiki);
3060
5928
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3061
- else emit(await runFrontmatterFix({ vault: v.vault, dryRun: !!opts.dryRun }));
5929
+ else emit(await runFrontmatterFix({ vault: v.vault, dryRun: !!opts.dryRun }), v.vault);
3062
5930
  });
3063
5931
  program.command("update").description("update skillwiki CLI to the latest version").option("--tag <tag>", "npm dist-tag", "beta").action(async (opts) => emit(await runUpdate({
3064
5932
  home: process.env.HOME ?? "",
3065
5933
  distTag: opts.tag
3066
5934
  })));
5935
+ program.command("self-update").description("update skillwiki CLI from local source or npm@beta").option("--check", "check for updates without installing", false).action(async (opts) => emit(await runSelfUpdate({
5936
+ home: process.env.HOME ?? "",
5937
+ check: !!opts.check
5938
+ })));
3067
5939
  program.command("transcripts [vault]").description("list transcript files in raw/transcripts/").option("--since <date>", "only files ingested on or after this date (YYYY-MM-DD)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
3068
5940
  const v = await resolveVaultArg(vault, opts.wiki);
3069
5941
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3070
- else emit(await runTranscripts({ vault: v.vault, since: opts.since }));
5942
+ else emit(await runTranscripts({ vault: v.vault, since: opts.since }), v.vault);
3071
5943
  });
3072
5944
  program.command("project-index <slug> [vault]").description("generate a knowledge index for a project workspace").option("--apply", "write knowledge.md to the project directory", false).option("--wiki <name>", "wiki profile name").action(async (slug, vault, opts) => {
3073
5945
  const v = await resolveVaultArg(vault, opts.wiki);
3074
5946
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
3075
- else emit(await runProjectIndex({ vault: v.vault, slug, apply: !!opts.apply }));
5947
+ else emit(await runProjectIndex({ vault: v.vault, slug, apply: !!opts.apply }), v.vault);
5948
+ });
5949
+ var compoundCmd = program.command("compound").description("manage project compound entries");
5950
+ compoundCmd.command("promote [vault]").description("promote retros with Generalize?: yes to compound entries").requiredOption("--project <slug>", "project slug (e.g., llm-wiki)").option("--dry-run", "preview promotions without writing files", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5951
+ const v = await resolveVaultArg(vault, opts.wiki);
5952
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5953
+ else emit(await runCompound({ vault: v.vault, project: opts.project, dryRun: !!opts.dryRun }), v.vault);
5954
+ });
5955
+ compoundCmd.command("list [vault]").description("list compound entries for a project").requiredOption("--project <slug>", "project slug (e.g., llm-wiki)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5956
+ const v = await resolveVaultArg(vault, opts.wiki);
5957
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5958
+ else emit(await runCompoundList({ vault: v.vault, project: opts.project }));
3076
5959
  });
5960
+ compoundCmd.command("delete <entry> [vault]").description("delete a compound entry and regenerate knowledge index").requiredOption("--project <slug>", "project slug (e.g., llm-wiki)").option("--wiki <name>", "wiki profile name").action(async (entry, vault, opts) => {
5961
+ const v = await resolveVaultArg(vault, opts.wiki);
5962
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5963
+ else emit(await runCompoundDelete({ vault: v.vault, project: opts.project, entry }), v.vault);
5964
+ });
5965
+ program.command("tag-sync [vault]").description("mirror frontmatter enum values to nested Obsidian tags").option("--dry-run", "preview changes without writing", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5966
+ const v = await resolveVaultArg(vault, opts.wiki);
5967
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5968
+ else emit(await runTagSync({ vault: v.vault, dryRun: !!opts.dryRun }), v.vault);
5969
+ });
5970
+ var syncCmd = program.command("sync").description("manage vault sync");
5971
+ syncCmd.command("status [vault]").description("check vault git sync status").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5972
+ const v = await resolveVaultArg(vault, opts.wiki);
5973
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5974
+ else emit(runSyncStatus({ vault: v.vault }));
5975
+ });
5976
+ syncCmd.command("push [vault]").description("lint, commit, and push vault changes to remote").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5977
+ const v = await resolveVaultArg(vault, opts.wiki);
5978
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5979
+ else emit(await runSyncPush({ vault: v.vault }));
5980
+ });
5981
+ syncCmd.command("pull [vault]").description("pull remote vault changes and lint").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
5982
+ const v = await resolveVaultArg(vault, opts.wiki);
5983
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5984
+ else emit(await runSyncPull({ vault: v.vault }), v.vault);
5985
+ });
5986
+ var backupCmd = program.command("backup").description("manage S3-compatible remote backup");
5987
+ 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) => {
5988
+ const v = await resolveVaultArg(vault, opts.wiki);
5989
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
5990
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
5991
+ const dotenv = await parseDotenvFile(configPath(home));
5992
+ emit(await runBackupSync({
5993
+ vault: v.vault,
5994
+ bucket: opts.bucket ?? dotenv["BACKUP_BUCKET"] ?? "",
5995
+ endpoint: opts.endpoint ?? dotenv["BACKUP_ENDPOINT"] ?? "",
5996
+ region: opts.region ?? dotenv["BACKUP_REGION"] ?? "us-east-1",
5997
+ accessKeyId: dotenv["BACKUP_ACCESS_KEY_ID"] ?? "",
5998
+ secretAccessKey: dotenv["BACKUP_SECRET_ACCESS_KEY"] ?? "",
5999
+ dryRun: opts.dryRun,
6000
+ prune: opts.prune
6001
+ }), v.vault);
6002
+ });
6003
+ 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) => {
6004
+ const v = await resolveVaultArg(vault, opts.wiki);
6005
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6006
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
6007
+ const dotenv = await parseDotenvFile(configPath(home));
6008
+ emit(await runBackupRestore({
6009
+ vault: v.vault,
6010
+ bucket: opts.bucket ?? dotenv["BACKUP_BUCKET"] ?? "",
6011
+ endpoint: opts.endpoint ?? dotenv["BACKUP_ENDPOINT"] ?? "",
6012
+ region: opts.region ?? dotenv["BACKUP_REGION"] ?? "us-east-1",
6013
+ accessKeyId: dotenv["BACKUP_ACCESS_KEY_ID"] ?? "",
6014
+ secretAccessKey: dotenv["BACKUP_SECRET_ACCESS_KEY"] ?? "",
6015
+ target: opts.target
6016
+ }), v.vault);
6017
+ });
6018
+ program.command("seed [vault]").description("populate a vault with example content").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6019
+ const v = await resolveVaultArg(vault, opts.wiki);
6020
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6021
+ else emit(await runSeed({ vault: v.vault }), v.vault);
6022
+ });
6023
+ program.command("observe [vault]").description("create a raw transcript observation entry").requiredOption("--text <text>", "observation text").option("--kind <kind>", "observation kind (note|bug|task|idea|session-log)", "note").option("--project <slug>", "associated project slug").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
6024
+ const v = await resolveVaultArg(vault, opts.wiki);
6025
+ if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
6026
+ else emit(await runObserve({
6027
+ vault: v.vault,
6028
+ text: opts.text,
6029
+ kind: opts.kind,
6030
+ project: opts.project
6031
+ }), v.vault);
6032
+ });
6033
+ program.command("ingest <source>").description("ingest a source URL or local file into the vault").requiredOption("--vault <path>", "vault root directory").requiredOption("--type <type>", "typed-knowledge type (entity|concept|comparison|query)").requiredOption("--title <title>", "page title").option("--tags <csv>", "comma-separated tags").option("--provenance <provenance>", "provenance (research|project)").option("--dry-run", "preview without writing files", false).action(async (source, opts) => {
6034
+ const tags = typeof opts.tags === "string" ? opts.tags.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : [];
6035
+ emit(await runIngest({
6036
+ source,
6037
+ vault: opts.vault,
6038
+ type: opts.type,
6039
+ title: opts.title,
6040
+ tags,
6041
+ provenance: opts.provenance,
6042
+ dryRun: !!opts.dryRun
6043
+ }), opts.vault);
6044
+ });
6045
+ for (const w of getDeprecatedWarnings(process.env.HOME ?? "")) {
6046
+ process.stderr.write(w + "\n");
6047
+ }
3077
6048
  triggerAutoUpdate(process.env.HOME ?? "", pkg.version);
3078
6049
  program.parseAsync(process.argv).catch((e) => {
3079
6050
  process.stdout.write(JSON.stringify({ ok: false, error: "INTERNAL", detail: { message: String(e) } }) + "\n");
3080
- process.exit(1);
6051
+ process.exit(ExitCode.INTERNAL_ERROR);
3081
6052
  });