skillwiki 0.2.1-beta.8 → 0.2.1

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