@forwardimpact/outpost 3.12.0 → 3.13.0

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.
Files changed (129) hide show
  1. package/README.md +2 -2
  2. package/bin/fit-outpost.js +3 -2
  3. package/package.json +6 -5
  4. package/src/agent-path.js +10 -6
  5. package/src/agent-runner.js +28 -24
  6. package/src/index.js +2 -3
  7. package/src/kb-manager.js +75 -20
  8. package/src/kb-validator.js +762 -0
  9. package/src/outpost.js +108 -39
  10. package/src/posture.js +3 -3
  11. package/src/privilege.js +11 -11
  12. package/src/scheduler.js +14 -12
  13. package/src/socket-server.js +19 -18
  14. package/src/spawn-env.js +7 -6
  15. package/src/state-manager.js +2 -2
  16. package/templates/.claude/agents/chief-of-staff.md +32 -24
  17. package/templates/.claude/agents/concierge.md +26 -17
  18. package/templates/.claude/agents/head-hunter.md +34 -26
  19. package/templates/.claude/agents/librarian.md +26 -18
  20. package/templates/.claude/agents/postman.md +26 -18
  21. package/templates/.claude/agents/recruiter.md +38 -27
  22. package/templates/.claude/skills/anarlog-follow/SKILL.md +91 -66
  23. package/templates/.claude/skills/anarlog-follow/references/coaching.md +22 -21
  24. package/templates/.claude/skills/anarlog-follow/references/sessions.md +52 -0
  25. package/templates/.claude/skills/anarlog-process/SKILL.md +108 -62
  26. package/templates/.claude/skills/anarlog-process/references/extraction.md +41 -29
  27. package/templates/.claude/skills/anarlog-process/references/sessions.md +92 -58
  28. package/templates/.claude/skills/anarlog-process/scripts/scan.mjs +402 -160
  29. package/templates/.claude/skills/candidate-report/SKILL.md +41 -36
  30. package/templates/.claude/skills/candidate-report/references/report-template.html +10 -10
  31. package/templates/.claude/skills/candidate-report/references/report.css +4 -4
  32. package/templates/.claude/skills/candidate-report/references/rubric.md +15 -14
  33. package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +2 -2
  34. package/templates/.claude/skills/changelog/SKILL.md +122 -67
  35. package/templates/.claude/skills/deck-create/SKILL.md +90 -32
  36. package/templates/.claude/skills/deck-create/references/slide.css +4 -4
  37. package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +6 -6
  38. package/templates/.claude/skills/deck-review/SKILL.md +63 -58
  39. package/templates/.claude/skills/deck-review/assets/slide-annotator.js +58 -49
  40. package/templates/.claude/skills/deck-summarize/SKILL.md +55 -45
  41. package/templates/.claude/skills/deck-summarize/references/brief-template.md +5 -4
  42. package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +8 -7
  43. package/templates/.claude/skills/doc-collab/SKILL.md +39 -34
  44. package/templates/.claude/skills/doc-create/SKILL.md +27 -23
  45. package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +6 -6
  46. package/templates/.claude/skills/draft-emails/SKILL.md +63 -53
  47. package/templates/.claude/skills/draft-emails/references/template.md +2 -2
  48. package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +15 -8
  49. package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +19 -11
  50. package/templates/.claude/skills/extract-entities/SKILL.md +76 -75
  51. package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +6 -5
  52. package/templates/.claude/skills/extract-entities/references/conditions.md +20 -18
  53. package/templates/.claude/skills/extract-entities/references/content.md +10 -9
  54. package/templates/.claude/skills/extract-entities/references/links.md +37 -13
  55. package/templates/.claude/skills/extract-entities/references/recruitment.md +30 -24
  56. package/templates/.claude/skills/extract-entities/references/resolution.md +14 -14
  57. package/templates/.claude/skills/extract-entities/references/sources.md +9 -9
  58. package/templates/.claude/skills/extract-entities/references/templates-conditions.md +12 -6
  59. package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +29 -10
  60. package/templates/.claude/skills/extract-entities/references/templates-priorities.md +10 -4
  61. package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +19 -7
  62. package/templates/.claude/skills/extract-entities/scripts/state.mjs +9 -8
  63. package/templates/.claude/skills/meeting-prep/SKILL.md +42 -36
  64. package/templates/.claude/skills/organize-files/SKILL.md +27 -22
  65. package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +5 -4
  66. package/templates/.claude/skills/organize-files/scripts/summarize.mjs +4 -4
  67. package/templates/.claude/skills/person-identify/SKILL.md +77 -27
  68. package/templates/.claude/skills/person-identify/scripts/identify.sh +125 -26
  69. package/templates/.claude/skills/person-lookup/SKILL.md +34 -30
  70. package/templates/.claude/skills/person-lookup/scripts/lookup.sh +46 -17
  71. package/templates/.claude/skills/req-assess/SKILL.md +44 -36
  72. package/templates/.claude/skills/req-assess/references/interview-template.md +7 -2
  73. package/templates/.claude/skills/req-assess/references/panel-template.md +7 -2
  74. package/templates/.claude/skills/req-assess/references/rubric.md +13 -13
  75. package/templates/.claude/skills/req-bundle/SKILL.md +164 -0
  76. package/templates/.claude/skills/req-bundle/references/matching.md +70 -0
  77. package/templates/.claude/skills/req-bundle/references/pdf-structure.md +74 -0
  78. package/templates/.claude/skills/req-bundle/scripts/split-bundle.mjs +377 -0
  79. package/templates/.claude/skills/req-decide/SKILL.md +52 -41
  80. package/templates/.claude/skills/req-decide/references/rubric.md +11 -10
  81. package/templates/.claude/skills/req-decide/references/template.md +2 -2
  82. package/templates/.claude/skills/req-forget/SKILL.md +58 -39
  83. package/templates/.claude/skills/req-forget/references/classify.md +15 -13
  84. package/templates/.claude/skills/req-forget/references/locations.md +27 -22
  85. package/templates/.claude/skills/req-forget/references/report-template.md +24 -16
  86. package/templates/.claude/skills/req-scan/SKILL.md +27 -25
  87. package/templates/.claude/skills/req-scan/references/fallbacks.md +9 -9
  88. package/templates/.claude/skills/req-scan/references/filters.md +13 -13
  89. package/templates/.claude/skills/req-scan/references/sources.md +7 -7
  90. package/templates/.claude/skills/req-scan/references/state.md +1 -1
  91. package/templates/.claude/skills/req-scan/references/template.md +11 -1
  92. package/templates/.claude/skills/req-scan/scripts/state.mjs +3 -2
  93. package/templates/.claude/skills/req-screen/SKILL.md +96 -50
  94. package/templates/.claude/skills/req-screen/references/rubric.md +46 -50
  95. package/templates/.claude/skills/req-screen/references/scoring.md +79 -0
  96. package/templates/.claude/skills/req-screen/references/template.md +29 -17
  97. package/templates/.claude/skills/req-track/SKILL.md +76 -76
  98. package/templates/.claude/skills/req-track/references/fields.md +31 -27
  99. package/templates/.claude/skills/req-track/references/overlays.md +42 -0
  100. package/templates/.claude/skills/req-track/references/signals.md +12 -12
  101. package/templates/.claude/skills/req-track/references/statuses.md +3 -2
  102. package/templates/.claude/skills/req-track/references/templates.md +28 -27
  103. package/templates/.claude/skills/req-workday/SKILL.md +69 -40
  104. package/templates/.claude/skills/req-workday/references/brief.md +81 -0
  105. package/templates/.claude/skills/req-workday/references/status-mapping.md +5 -5
  106. package/templates/.claude/skills/req-workday/references/templates.md +11 -78
  107. package/templates/.claude/skills/req-workday/references/xlsx-format.md +26 -7
  108. package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +113 -35
  109. package/templates/.claude/skills/send-chat/SKILL.md +58 -54
  110. package/templates/.claude/skills/sync-apple-calendar/SKILL.md +23 -19
  111. package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +7 -7
  112. package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +6 -6
  113. package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +9 -8
  114. package/templates/.claude/skills/sync-apple-mail/SKILL.md +20 -14
  115. package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +10 -10
  116. package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +9 -9
  117. package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +16 -13
  118. package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +3 -3
  119. package/templates/.claude/skills/sync-teams/SKILL.md +46 -43
  120. package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +22 -21
  121. package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +4 -4
  122. package/templates/.claude/skills/sync-teams/scripts/sync.mjs +4 -4
  123. package/templates/.claude/skills/upstream-instructions/SKILL.md +43 -41
  124. package/templates/.claude/skills/upstream-instructions/references/examples.md +5 -5
  125. package/templates/CLAUDE.md +97 -85
  126. package/templates/MIGRATION.md +359 -0
  127. package/templates/registry.yaml +25 -0
  128. package/templates/.claude/skills/anarlog-follow/scripts/follow.mjs +0 -243
  129. package/templates/.claude/skills/anarlog-trim/SKILL.md +0 -183
@@ -0,0 +1,762 @@
1
+ /**
2
+ * kb-validator — pure knowledge checks for one Outpost knowledge base.
3
+ *
4
+ * The module takes a KB root (the Obsidian vault that holds the numbered
5
+ * tier directories) and an injected async `fs` surface. It returns findings.
6
+ * It never logs, never exits, and never reads scheduler config. The only
7
+ * files it consults besides the vault content are the vault-local
8
+ * `registry.yaml` and `validation-baseline.json`.
9
+ *
10
+ * The frontmatter block-splitter is deliberately local. Reusing libdoc's
11
+ * helper would pull a site-generator package into an end-user CLI for ten
12
+ * lines of code.
13
+ */
14
+
15
+ import { join, dirname } from "node:path";
16
+ import { parse as parseYaml } from "yaml";
17
+
18
+ /**
19
+ * @typedef {object} Finding
20
+ * @property {string} kind - One of the link, directory, or frontmatter kinds.
21
+ * @property {boolean} baselined - True when a baseline entry matches.
22
+ * @property {string} [file] - Source note, relative to the KB root.
23
+ * @property {number} [line] - 1-based line of the link or property.
24
+ * @property {string} [link] - The link target or path string as written.
25
+ * @property {string|null} [sourceTier] - Tier directory name of the source.
26
+ * @property {string|null} [targetTier] - Tier directory name of the target.
27
+ * @property {string} [path] - Root entry a directory finding names.
28
+ * @property {string} [property] - Frontmatter property a finding names.
29
+ * @property {string|null} [value] - Offending frontmatter value.
30
+ * @property {string} [message] - Human hint; legacy kinds name MIGRATION.md.
31
+ */
32
+
33
+ /**
34
+ * @typedef {object} Ctx
35
+ * @property {object} fs - Injected async fs surface.
36
+ * @property {string} kbRoot - Absolute vault root.
37
+ * @property {Finding[]} findings - Accumulator.
38
+ * @property {{name: string, rank: number}[]} tiers
39
+ * @property {Map<string, {name: string, rank: number}>} tierByName
40
+ * @property {string[]} personalNames - Non-tier root entries.
41
+ * @property {Map<string, {rel: string, tier: object}>} index
42
+ * @property {Map<string, string[]>} byBase - Resolution name to paths.
43
+ * @property {object|null} registry - Parsed registry.yaml.
44
+ * @property {Set<string>|null} typeVocab - Registry type values.
45
+ * @property {Map<string, object>} tagRows - Registry tag rows by tag.
46
+ * @property {(rel: string) => Promise<boolean>} exists
47
+ */
48
+
49
+ const TIER_RE = /^[0-9]-/;
50
+ const NEAR_MISS_RE = /^[0-9]{2,3}-/;
51
+ const PERSONAL_DIGITS_RE = /^[0-9]{4,}-/;
52
+ /** The legacy layout markers; kb-manager keys the MIGRATION.md install on them. */
53
+ export const LEGACY_ROOTS = ["Knowledge", "Drafts"];
54
+ const LEGACY_ENTITIES = [
55
+ "People",
56
+ "Organizations",
57
+ "Projects",
58
+ "Topics",
59
+ "Candidates",
60
+ "Priorities",
61
+ "Conditions",
62
+ "Roles",
63
+ "Prospects",
64
+ "Erasure",
65
+ "Tasks",
66
+ "Goals",
67
+ ];
68
+ const REGISTRY_FILE = "registry.yaml";
69
+ const BASELINE_FILE = "validation-baseline.json";
70
+ const CORE_KEYS = ["type", "created", "updated"];
71
+ const DATE_KEYS = ["created", "updated"];
72
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
73
+ const ALIAS_TYPES = ["person", "candidate", "organization"];
74
+ const STATUS_TYPES = ["candidate", "prospect"];
75
+ const WIKI_LINK_RE = /!?\[\[([^[\]]+)\]\]/g;
76
+ const MD_LINK_RE = /!?\[[^\]]*\]\(([^)]+)\)/g;
77
+ const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
78
+ // Deliberately narrower than "any #token": the tag taxonomy is a closed,
79
+ // namespaced `topic/` vocabulary, and the field study found most bare-hash
80
+ // tokens are noise (hex colors, ticket ids, UUID fragments). Only a
81
+ // namespaced token counts as an inline tag.
82
+ const INLINE_TAG_RE = /(?:^|[\s(])#([A-Za-z][\w-]*(?:\/[\w-]+)+)/g;
83
+
84
+ /** @param {string} raw - A wiki-link inner text. @returns {string} The bare target. */
85
+ function wikiTarget(raw) {
86
+ return raw
87
+ .split(/\\\||\|/)[0]
88
+ .replace(/#.*$/, "")
89
+ .trim();
90
+ }
91
+
92
+ /** @param {*} v @returns {boolean} True for a scalar frontmatter value. */
93
+ function isScalar(v) {
94
+ return (
95
+ v === null ||
96
+ typeof v === "string" ||
97
+ typeof v === "number" ||
98
+ typeof v === "boolean"
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Split a note into its frontmatter block and body. The block only counts
104
+ * when line 1 opens it.
105
+ * @param {string[]} lines - The note's lines.
106
+ * @returns {{blockLines: string[]|null, bodyStart: number}}
107
+ */
108
+ function splitFrontmatter(lines) {
109
+ if (lines[0] !== "---") return { blockLines: null, bodyStart: 0 };
110
+ const end = lines.indexOf("---", 1);
111
+ if (end === -1) return { blockLines: null, bodyStart: 0 };
112
+ return { blockLines: lines.slice(1, end), bodyStart: end + 1 };
113
+ }
114
+
115
+ /**
116
+ * Build the baseline key of a finding or a baseline entry. Vocabulary kinds
117
+ * include the value; no key includes a line number, so edits elsewhere in a
118
+ * note never resurface a grandfathered finding.
119
+ * @param {object} f - A finding or a baseline entry.
120
+ * @returns {string}
121
+ */
122
+ function baselineKey(f) {
123
+ if (f.path !== undefined) return ["d", f.kind, f.path].join(" ");
124
+ if (f.property !== undefined) {
125
+ const value = f.kind === "frontmatter-vocabulary" ? (f.value ?? "") : "";
126
+ return ["p", f.kind, f.file, f.property, value].join(" ");
127
+ }
128
+ return ["l", f.kind, f.file, f.link].join(" ");
129
+ }
130
+
131
+ /**
132
+ * Pass 1: read the root and collect tiers, near misses, and personal names.
133
+ * @param {Ctx} ctx
134
+ * @returns {Promise<string[]>} The sorted root directory names.
135
+ */
136
+ async function collectTiers(ctx) {
137
+ const rootNames = (
138
+ await ctx.fs.readdir(ctx.kbRoot, { withFileTypes: true })
139
+ ).map((d) => d.name);
140
+ rootNames.sort();
141
+ const rootDirs = [];
142
+ for (const name of rootNames) {
143
+ const stat = await ctx.fs.stat(join(ctx.kbRoot, name)).catch(() => null);
144
+ if (stat?.isDirectory()) rootDirs.push(name);
145
+ if (!stat?.isDirectory() || PERSONAL_DIGITS_RE.test(name)) {
146
+ ctx.personalNames.push(name);
147
+ } else if (NEAR_MISS_RE.test(name)) {
148
+ ctx.findings.push({ kind: "out-of-grammar-rank", path: name });
149
+ } else if (TIER_RE.test(name)) {
150
+ const rank = Number(name[0]);
151
+ const twin = ctx.tiers.find((t) => t.rank === rank);
152
+ if (twin) {
153
+ ctx.findings.push({
154
+ kind: "duplicate-rank",
155
+ path: name,
156
+ message: `rank ${rank} is claimed by both ${twin.name}/ and ${name}/`,
157
+ });
158
+ }
159
+ ctx.tiers.push({ name, rank });
160
+ } else {
161
+ ctx.personalNames.push(name);
162
+ }
163
+ }
164
+ return rootDirs;
165
+ }
166
+
167
+ /**
168
+ * Pass 2: flag the legacy layout. `Knowledge/` and `Drafts/` fail at any
169
+ * time; the historical entity directories fail only at a tier-less root;
170
+ * a root with no tiers and no legacy markers fails no-tiers. Only
171
+ * directories count — a root file with a legacy name is personal.
172
+ * @param {Ctx} ctx
173
+ * @param {string[]} rootDirs
174
+ * @returns {void}
175
+ */
176
+ function detectLegacy(ctx, rootDirs) {
177
+ const markers = LEGACY_ROOTS.filter((name) => rootDirs.includes(name));
178
+ if (ctx.tiers.length === 0) {
179
+ markers.push(...LEGACY_ENTITIES.filter((n) => rootDirs.includes(n)));
180
+ }
181
+ for (const name of markers) {
182
+ ctx.findings.push({
183
+ kind: "legacy-layout",
184
+ path: name,
185
+ message: `legacy layout: ${name}/ at the KB root — see MIGRATION.md`,
186
+ });
187
+ }
188
+ if (ctx.tiers.length === 0 && markers.length === 0) {
189
+ ctx.findings.push({
190
+ kind: "no-tiers",
191
+ path: ".",
192
+ message: "no tier directories at the KB root — see MIGRATION.md",
193
+ });
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Pass 3: index every file under the tiers (through symlinks), keyed by
199
+ * root-relative path and by resolution name (basename without `.md`).
200
+ * @param {Ctx} ctx
201
+ * @param {string} relDir
202
+ * @param {{name: string, rank: number}} tier
203
+ * @returns {Promise<void>}
204
+ */
205
+ async function walkTier(ctx, relDir, tier) {
206
+ const names = (
207
+ await ctx.fs.readdir(join(ctx.kbRoot, relDir), { withFileTypes: true })
208
+ ).map((d) => d.name);
209
+ names.sort();
210
+ for (const name of names) {
211
+ const rel = `${relDir}/${name}`;
212
+ // A broken symlink inside a synced tier is a vault defect, not a reason
213
+ // to abort the whole run; skip it like the root-level pass does.
214
+ const stat = await ctx.fs.stat(join(ctx.kbRoot, rel)).catch(() => null);
215
+ if (stat === null) continue;
216
+ if (stat.isDirectory()) {
217
+ await walkTier(ctx, rel, tier);
218
+ continue;
219
+ }
220
+ ctx.index.set(rel, { rel, tier });
221
+ const key = name.endsWith(".md") ? name.slice(0, -3) : name;
222
+ if (!ctx.byBase.has(key)) ctx.byBase.set(key, []);
223
+ ctx.byBase.get(key).push(rel);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Extract links from a frontmatter block. A property wiki link must be
229
+ * double-quoted; an unquoted one is a serialization finding, not a link.
230
+ * @param {Ctx} ctx
231
+ * @param {{rel: string, tier: object}} note
232
+ * @param {string[]} blockLines
233
+ * @param {boolean} shared
234
+ * @param {object[]} links
235
+ * @returns {object|null} The parsed frontmatter, or null when unparseable.
236
+ */
237
+ function extractFrontmatter(ctx, note, blockLines, shared, links) {
238
+ let frontmatter = null;
239
+ try {
240
+ frontmatter = parseYaml(blockLines.join("\n")) ?? {};
241
+ } catch {
242
+ if (shared) {
243
+ ctx.findings.push({
244
+ kind: "frontmatter-invalid",
245
+ file: note.rel,
246
+ line: 1,
247
+ property: "frontmatter",
248
+ value: "unparseable YAML",
249
+ });
250
+ }
251
+ }
252
+ blockLines.forEach((raw, i) => {
253
+ for (const match of raw.matchAll(WIKI_LINK_RE)) {
254
+ const quoted =
255
+ raw[match.index - 1] === '"' &&
256
+ raw[match.index + match[0].length] === '"';
257
+ if (quoted) {
258
+ links.push({ line: i + 2, target: wikiTarget(match[1]) });
259
+ } else if (shared) {
260
+ ctx.findings.push({
261
+ kind: "frontmatter-invalid",
262
+ file: note.rel,
263
+ line: i + 2,
264
+ property: raw.split(":")[0].trim().replace(/^- /, ""),
265
+ value: match[0],
266
+ });
267
+ }
268
+ }
269
+ });
270
+ return frontmatter;
271
+ }
272
+
273
+ /**
274
+ * Flag literal path strings that name a narrower tier or a personal surface.
275
+ * @param {Ctx} ctx
276
+ * @param {{rel: string, tier: object}} note
277
+ * @param {string} stripped - The line with link spans blanked.
278
+ * @param {number} lineNo
279
+ * @returns {void}
280
+ */
281
+ function scanPathStrings(ctx, note, stripped, lineNo) {
282
+ const names = [...ctx.tiers.map((t) => t.name), ...ctx.personalNames];
283
+ for (const name of names) {
284
+ const at = stripped.indexOf(`${name}/`);
285
+ if (at === -1) continue;
286
+ // A word, bracket, or slash before the match means the name sits inside
287
+ // a longer token (a wiki link, or a deeper path like `3-Team/Projects/`
288
+ // when a personal folder shares the entity name).
289
+ if (at > 0 && /[\w[/]/.test(stripped[at - 1])) continue;
290
+ const tier = ctx.tierByName.get(name);
291
+ if (tier && tier.rank >= note.tier.rank) continue;
292
+ ctx.findings.push({
293
+ kind: "path-string",
294
+ file: note.rel,
295
+ line: lineNo,
296
+ link: stripped.slice(at).split(/[\s)\]"'`]/)[0],
297
+ sourceTier: note.tier.name,
298
+ targetTier: tier?.name ?? null,
299
+ });
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Extract body links, path strings, and inline tags. Fenced code blocks are
305
+ * opaque.
306
+ * @param {Ctx} ctx
307
+ * @param {{rel: string, tier: object}} note
308
+ * @param {string[]} lines
309
+ * @param {number} bodyStart
310
+ * @param {boolean} shared
311
+ * @param {object[]} links
312
+ * @returns {void}
313
+ */
314
+ function extractBody(ctx, note, lines, bodyStart, shared, links) {
315
+ let fenced = false;
316
+ for (let i = bodyStart; i < lines.length; i++) {
317
+ if (/^```/.test(lines[i].trim())) {
318
+ fenced = !fenced;
319
+ continue;
320
+ }
321
+ if (fenced) {
322
+ // Fenced blocks carry no links or tags, but embedded commands are
323
+ // exactly where literal narrower-tier paths leak, so the mechanical
324
+ // path-string detection still runs inside them.
325
+ if (shared) scanPathStrings(ctx, note, lines[i], i + 1);
326
+ continue;
327
+ }
328
+ const stripped = extractLineLinks(lines[i], i + 1, links);
329
+ if (!shared) continue;
330
+ scanPathStrings(ctx, note, stripped, i + 1);
331
+ for (const match of stripped.matchAll(INLINE_TAG_RE)) {
332
+ ctx.findings.push({
333
+ kind: "frontmatter-invalid",
334
+ file: note.rel,
335
+ line: i + 1,
336
+ property: "tags",
337
+ value: `#${match[1]}`,
338
+ });
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Extract the wiki and relative markdown links on one body line.
345
+ * @param {string} line
346
+ * @param {number} lineNo
347
+ * @param {object[]} links - The accumulator.
348
+ * @returns {string} The line with every link span blanked.
349
+ */
350
+ function extractLineLinks(line, lineNo, links) {
351
+ let stripped = line;
352
+ for (const match of line.matchAll(WIKI_LINK_RE)) {
353
+ links.push({ line: lineNo, target: wikiTarget(match[1]) });
354
+ stripped = stripped.replace(match[0], " ".repeat(match[0].length));
355
+ }
356
+ for (const match of stripped.matchAll(MD_LINK_RE)) {
357
+ const target = mdTarget(match[1]);
358
+ if (target === null) continue;
359
+ links.push({ line: lineNo, target, relative: true });
360
+ stripped = stripped.replace(match[0], " ".repeat(match[0].length));
361
+ }
362
+ return stripped;
363
+ }
364
+
365
+ /**
366
+ * Normalize a markdown link target. URLs with a scheme and pure anchors are
367
+ * out of scope.
368
+ * @param {string} raw
369
+ * @returns {string|null}
370
+ */
371
+ function mdTarget(raw) {
372
+ const target = raw.split(/\s+/)[0];
373
+ if (URL_SCHEME_RE.test(target) || target.startsWith("#")) return null;
374
+ try {
375
+ return decodeURIComponent(target);
376
+ } catch {
377
+ return target;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Whether a path-form target names something outside the tier set that
383
+ * exists on disk (a personal surface).
384
+ * @param {Ctx} ctx @param {string} target
385
+ * @returns {Promise<boolean>}
386
+ */
387
+ async function isPersonalTarget(ctx, target) {
388
+ const first = target.split("/")[0];
389
+ if (!target.includes("/") || ctx.tierByName.has(first)) return false;
390
+ return (await ctx.exists(target)) || (await ctx.exists(`${target}.md`));
391
+ }
392
+
393
+ /**
394
+ * Resolve a wiki target from the KB root, then by unique basename.
395
+ * @param {Ctx} ctx @param {string} target
396
+ * @returns {Promise<{rel: string}|{ambiguous: true}|{personal: true}|null>}
397
+ */
398
+ async function resolveWikiTarget(ctx, target) {
399
+ const hit = ctx.index.get(target) ?? ctx.index.get(`${target}.md`);
400
+ if (hit) return { rel: hit.rel };
401
+ if (await isPersonalTarget(ctx, target)) return { personal: true };
402
+ // The basename map keys notes without their `.md`, so an explicit
403
+ // `[[Note.md]]` strips before the fallback; assets keep their extension.
404
+ const base = target.split("/").pop();
405
+ const key = base.endsWith(".md") ? base.slice(0, -3) : base;
406
+ const matches = ctx.byBase.get(key) ?? [];
407
+ if (matches.length === 1) return { rel: matches[0] };
408
+ return matches.length > 1 ? { ambiguous: true } : null;
409
+ }
410
+
411
+ /**
412
+ * Normalize a relative markdown target against the source directory.
413
+ * @param {string} sourceRel @param {string} target
414
+ * @returns {string} The KB-root-relative path, or "" when it escapes.
415
+ */
416
+ function resolveRelative(sourceRel, target) {
417
+ const parts = dirname(sourceRel).split("/");
418
+ for (const seg of target.split("/")) {
419
+ if (seg === "" || seg === ".") continue;
420
+ if (seg === "..") {
421
+ if (parts.length === 0) return "";
422
+ parts.pop();
423
+ continue;
424
+ }
425
+ parts.push(seg);
426
+ }
427
+ return parts.join("/");
428
+ }
429
+
430
+ /**
431
+ * Resolve one extracted link to an indexed path, pushing the resolution
432
+ * finding when it fails.
433
+ * @param {Ctx} ctx
434
+ * @param {{rel: string, tier: object}} note
435
+ * @param {object} link
436
+ * @param {object} base - The shared finding fields.
437
+ * @returns {Promise<string|null>} The resolved root-relative path.
438
+ */
439
+ async function resolveLink(ctx, note, link, base) {
440
+ if (!link.relative) {
441
+ const hit = await resolveWikiTarget(ctx, link.target);
442
+ if (hit?.rel) return hit.rel;
443
+ if (hit?.personal) {
444
+ ctx.findings.push({ kind: "narrower-link", ...base, targetTier: null });
445
+ } else if (hit?.ambiguous) {
446
+ ctx.findings.push({ kind: "ambiguous", ...base, targetTier: null });
447
+ } else {
448
+ ctx.findings.push({ kind: "unresolved", ...base, targetTier: null });
449
+ }
450
+ return null;
451
+ }
452
+ const rel = resolveRelative(note.rel, link.target);
453
+ if (rel && ctx.index.has(rel)) return rel;
454
+ const outside = rel && !ctx.tierByName.has(rel.split("/")[0]);
455
+ const kind =
456
+ outside && (await ctx.exists(rel)) ? "narrower-link" : "unresolved";
457
+ ctx.findings.push({ kind, ...base, targetTier: null });
458
+ return null;
459
+ }
460
+
461
+ /**
462
+ * Whether two paths sit inside one entity subdirectory
463
+ * (`<tier>/<entity>/...`), the folder-atomic unit that moves whole.
464
+ * @param {string} a @param {string} b
465
+ * @returns {boolean}
466
+ */
467
+ function sameEntityDir(a, b) {
468
+ const [aTier, aEntity, ...aRest] = a.split("/");
469
+ const [bTier, bEntity, ...bRest] = b.split("/");
470
+ return (
471
+ aRest.length > 0 &&
472
+ bRest.length > 0 &&
473
+ aTier === bTier &&
474
+ aEntity === bEntity
475
+ );
476
+ }
477
+
478
+ /**
479
+ * Apply the format contract in a shared tier: a wiki link must be
480
+ * tier-prefixed and vault-absolute, with no exemption. Only a relative
481
+ * markdown link whose source and resolved target share one entity
482
+ * subdirectory is exempt, so folder-atomic units move as single units.
483
+ * @param {Ctx} ctx
484
+ * @param {{rel: string, tier: object}} note
485
+ * @param {object} link
486
+ * @param {string} resolvedRel
487
+ * @param {{name: string}} targetTier
488
+ * @param {object} base - The shared finding fields.
489
+ * @returns {void}
490
+ */
491
+ function checkLinkFormat(ctx, note, link, resolvedRel, targetTier, base) {
492
+ if (note.tier.rank < 1) return;
493
+ if (!link.relative && link.target.split("/")[0] === targetTier.name) return;
494
+ if (link.relative && sameEntityDir(note.rel, resolvedRel)) return;
495
+ ctx.findings.push({
496
+ kind: "bare-basename",
497
+ ...base,
498
+ targetTier: targetTier.name,
499
+ });
500
+ }
501
+
502
+ /**
503
+ * Passes 5–7 for one link: resolution, legality, format.
504
+ * @param {Ctx} ctx
505
+ * @param {{rel: string, tier: object}} note
506
+ * @param {object} link
507
+ * @returns {Promise<void>}
508
+ */
509
+ async function checkLink(ctx, note, link) {
510
+ const base = {
511
+ file: note.rel,
512
+ line: link.line,
513
+ link: link.target,
514
+ sourceTier: note.tier.name,
515
+ };
516
+ const resolvedRel = await resolveLink(ctx, note, link, base);
517
+ if (resolvedRel === null) return;
518
+ const targetTier = ctx.index.get(resolvedRel).tier;
519
+ if (targetTier.rank < note.tier.rank) {
520
+ ctx.findings.push({
521
+ kind: "narrower-link",
522
+ ...base,
523
+ targetTier: targetTier.name,
524
+ });
525
+ return;
526
+ }
527
+ checkLinkFormat(ctx, note, link, resolvedRel, targetTier, base);
528
+ }
529
+
530
+ /**
531
+ * Push one frontmatter finding.
532
+ * @param {Ctx} ctx @param {string} kind @param {string} rel
533
+ * @param {string} property @param {string|null} value
534
+ * @returns {void}
535
+ */
536
+ function fmFinding(ctx, kind, rel, property, value) {
537
+ ctx.findings.push({ kind, file: rel, line: 1, property, value });
538
+ }
539
+
540
+ /**
541
+ * Core keys and validator-decidable conditional triggers.
542
+ * @param {Ctx} ctx @param {{rel: string, tier: object}} note @param {object} fm
543
+ * @returns {void}
544
+ */
545
+ function checkRequiredKeys(ctx, note, fm) {
546
+ for (const key of CORE_KEYS) {
547
+ if (fm[key] === undefined) {
548
+ fmFinding(ctx, "frontmatter-missing", note.rel, key, null);
549
+ }
550
+ }
551
+ if (ALIAS_TYPES.includes(fm.type) && fm.aliases === undefined) {
552
+ fmFinding(ctx, "frontmatter-missing", note.rel, "aliases", null);
553
+ }
554
+ if (STATUS_TYPES.includes(fm.type) && fm.status === undefined) {
555
+ fmFinding(ctx, "frontmatter-missing", note.rel, "status", null);
556
+ }
557
+ if (note.tier.rank === 4 && fm.verified === undefined) {
558
+ fmFinding(ctx, "frontmatter-missing", note.rel, "verified", null);
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Serialization contract: a flat block and ISO dates.
564
+ * @param {Ctx} ctx @param {{rel: string}} note @param {object} fm
565
+ * @returns {void}
566
+ */
567
+ function checkSerialization(ctx, note, fm) {
568
+ for (const [key, value] of Object.entries(fm)) {
569
+ const flat =
570
+ isScalar(value) || (Array.isArray(value) && value.every(isScalar));
571
+ if (!flat) {
572
+ fmFinding(ctx, "frontmatter-invalid", note.rel, key, String(value));
573
+ }
574
+ }
575
+ for (const key of DATE_KEYS) {
576
+ const value = fm[key];
577
+ if (typeof value === "string" && !ISO_DATE_RE.test(value)) {
578
+ fmFinding(ctx, "frontmatter-invalid", note.rel, key, value);
579
+ }
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Registry-dependent vocabulary checks: type, status, tags, and tag tier
585
+ * bounds. Skipped entirely when no registry file is present.
586
+ * @param {Ctx} ctx @param {{rel: string, tier: object}} note @param {object} fm
587
+ * @returns {void}
588
+ */
589
+ function checkVocabulary(ctx, note, fm) {
590
+ if (typeof fm.type === "string" && !ctx.typeVocab.has(fm.type)) {
591
+ fmFinding(ctx, "frontmatter-vocabulary", note.rel, "type", fm.type);
592
+ }
593
+ const statusVocab = ctx.registry.status?.[fm.type];
594
+ if (statusVocab && fm.status !== undefined) {
595
+ if (!statusVocab.includes(fm.status)) {
596
+ fmFinding(
597
+ ctx,
598
+ "frontmatter-vocabulary",
599
+ note.rel,
600
+ "status",
601
+ String(fm.status),
602
+ );
603
+ }
604
+ }
605
+ const tags = Array.isArray(fm.tags) ? fm.tags : [fm.tags].filter(Boolean);
606
+ for (const tag of tags) {
607
+ const row = ctx.tagRows.get(tag);
608
+ if (!row || note.tier.rank > row.bound) {
609
+ fmFinding(ctx, "frontmatter-vocabulary", note.rel, "tags", String(tag));
610
+ }
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Pass 8 for one shared-tier note. A note whose block failed to parse never
616
+ * reaches this pass — the parse failure is already one finding, and the
617
+ * missing-key findings would only restate the same root cause.
618
+ * @param {Ctx} ctx
619
+ * @param {{rel: string, tier: object}} note
620
+ * @param {object|undefined} fm - Parsed frontmatter; undefined means none.
621
+ * @returns {void}
622
+ */
623
+ function checkNoteFrontmatter(ctx, note, fm) {
624
+ if (fm === undefined) {
625
+ for (const key of CORE_KEYS) {
626
+ fmFinding(ctx, "frontmatter-missing", note.rel, key, null);
627
+ }
628
+ return;
629
+ }
630
+ checkRequiredKeys(ctx, note, fm);
631
+ checkSerialization(ctx, note, fm);
632
+ if (ctx.registry) checkVocabulary(ctx, note, fm);
633
+ }
634
+
635
+ /**
636
+ * Overlay declarations: a cross-tier duplicate basename inside the same
637
+ * entity-subdirectory name marks the narrower note as an overlay, and an
638
+ * overlay declares itself through `canonical`.
639
+ * @param {Ctx} ctx
640
+ * @param {{rel: string, tier: object}[]} notes
641
+ * @param {Map<string, object|null>} frontmatterByRel
642
+ * @returns {void}
643
+ */
644
+ function checkOverlays(ctx, notes, frontmatterByRel) {
645
+ const families = new Map();
646
+ for (const note of notes) {
647
+ const segments = note.rel.split("/");
648
+ if (note.tier.rank < 1 || segments.length < 3) continue;
649
+ const key = `${segments[1]}/${segments[segments.length - 1]}`;
650
+ if (!families.has(key)) families.set(key, []);
651
+ families.get(key).push(note);
652
+ }
653
+ for (const family of families.values()) {
654
+ const widest = Math.max(...family.map((n) => n.tier.rank));
655
+ for (const note of family) {
656
+ const fm = frontmatterByRel.get(note.rel);
657
+ if (note.tier.rank < widest && fm?.canonical === undefined) {
658
+ fmFinding(ctx, "overlay-undeclared", note.rel, "canonical", null);
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Validate one knowledge base.
666
+ * @param {string} kbRoot - Absolute path to the KB root (the vault).
667
+ * @param {{fs: object}} runtime - Injected async fs surface.
668
+ * @returns {Promise<{findings: Finding[], tierCount: number}>}
669
+ */
670
+ export async function validateKnowledgeBase(kbRoot, runtime) {
671
+ const { fs } = runtime;
672
+ /** @type {Ctx} */
673
+ const ctx = {
674
+ fs,
675
+ kbRoot,
676
+ findings: [],
677
+ tiers: [],
678
+ tierByName: new Map(),
679
+ personalNames: [],
680
+ index: new Map(),
681
+ byBase: new Map(),
682
+ registry: null,
683
+ typeVocab: null,
684
+ tagRows: new Map(),
685
+ exists: (rel) =>
686
+ fs.access(join(kbRoot, rel)).then(
687
+ () => true,
688
+ () => false,
689
+ ),
690
+ };
691
+
692
+ const rootDirs = await collectTiers(ctx);
693
+ ctx.tierByName = new Map(ctx.tiers.map((t) => [t.name, t]));
694
+ detectLegacy(ctx, rootDirs);
695
+ for (const tier of ctx.tiers) await walkTier(ctx, tier.name, tier);
696
+ await loadRegistry(ctx);
697
+ await checkNotes(ctx);
698
+ await applyBaseline(ctx);
699
+ return { findings: ctx.findings, tierCount: ctx.tiers.length };
700
+ }
701
+
702
+ /**
703
+ * Read the vault-local registry when present. Vocabulary checks stay off
704
+ * without it, so recipient suffixes still validate.
705
+ * @param {Ctx} ctx
706
+ * @returns {Promise<void>}
707
+ */
708
+ async function loadRegistry(ctx) {
709
+ if (!(await ctx.exists(REGISTRY_FILE))) return;
710
+ const parsed = parseYaml(
711
+ await ctx.fs.readFile(join(ctx.kbRoot, REGISTRY_FILE), "utf8"),
712
+ );
713
+ // An empty or comment-only registry parses to null; treat it as absent so
714
+ // the registry-dependent checks skip instead of crashing.
715
+ if (!parsed) return;
716
+ ctx.registry = parsed;
717
+ ctx.typeVocab = new Set([
718
+ ...Object.values(ctx.registry.types ?? {}),
719
+ ...Object.values(ctx.registry.reserved ?? {}),
720
+ ]);
721
+ ctx.tagRows = new Map((ctx.registry.tags ?? []).map((r) => [r.tag, r]));
722
+ }
723
+
724
+ /**
725
+ * Passes 4–8: extract, resolve, and check every indexed note.
726
+ * @param {Ctx} ctx
727
+ * @returns {Promise<void>}
728
+ */
729
+ async function checkNotes(ctx) {
730
+ const notes = [...ctx.index.values()].filter((f) => f.rel.endsWith(".md"));
731
+ const frontmatterByRel = new Map();
732
+ for (const note of notes) {
733
+ const shared = note.tier.rank >= 1;
734
+ const text = await ctx.fs.readFile(join(ctx.kbRoot, note.rel), "utf8");
735
+ const lines = text.split("\n");
736
+ const { blockLines, bodyStart } = splitFrontmatter(lines);
737
+ const links = [];
738
+ const fm = blockLines
739
+ ? extractFrontmatter(ctx, note, blockLines, shared, links)
740
+ : undefined;
741
+ frontmatterByRel.set(note.rel, fm);
742
+ extractBody(ctx, note, lines, bodyStart, shared, links);
743
+ for (const link of links) await checkLink(ctx, note, link);
744
+ if (shared && fm !== null) checkNoteFrontmatter(ctx, note, fm);
745
+ }
746
+ checkOverlays(ctx, notes, frontmatterByRel);
747
+ }
748
+
749
+ /**
750
+ * Pass 9: mark findings the vault-local baseline grandfathers.
751
+ * @param {Ctx} ctx
752
+ * @returns {Promise<void>}
753
+ */
754
+ async function applyBaseline(ctx) {
755
+ const baseline = (await ctx.exists(BASELINE_FILE))
756
+ ? JSON.parse(await ctx.fs.readFile(join(ctx.kbRoot, BASELINE_FILE), "utf8"))
757
+ : null;
758
+ const baselined = new Set((baseline?.findings ?? []).map(baselineKey));
759
+ for (const finding of ctx.findings) {
760
+ finding.baselined = baselined.has(baselineKey(finding));
761
+ }
762
+ }