@aisystemresources/emdee 0.1.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 (82) hide show
  1. package/README.md +85 -0
  2. package/bin/emdee.js +196 -0
  3. package/package.json +78 -0
  4. package/src/cli/read-commands.ts +64 -0
  5. package/src/core/indexer.ts +346 -0
  6. package/src/core/parseEdges.ts +106 -0
  7. package/src/core/resolveLink.ts +136 -0
  8. package/src/core/siblings.ts +65 -0
  9. package/src/core/syncDocEdges.ts +427 -0
  10. package/src/lib/cache/bust.ts +44 -0
  11. package/src/lib/cache/invalidation.ts +28 -0
  12. package/src/lib/mcp/activity.ts +254 -0
  13. package/src/lib/mcp/tools/add_association.ts +311 -0
  14. package/src/lib/mcp/tools/append_doc.ts +70 -0
  15. package/src/lib/mcp/tools/append_section.ts +121 -0
  16. package/src/lib/mcp/tools/create_child.ts +319 -0
  17. package/src/lib/mcp/tools/delete_doc.ts +68 -0
  18. package/src/lib/mcp/tools/distill_doc.ts +262 -0
  19. package/src/lib/mcp/tools/filename.ts +63 -0
  20. package/src/lib/mcp/tools/get_context.ts +227 -0
  21. package/src/lib/mcp/tools/get_doc.ts +91 -0
  22. package/src/lib/mcp/tools/get_image.ts +62 -0
  23. package/src/lib/mcp/tools/get_neighbors.ts +96 -0
  24. package/src/lib/mcp/tools/get_summary.ts +18 -0
  25. package/src/lib/mcp/tools/index.ts +26 -0
  26. package/src/lib/mcp/tools/lint.ts +552 -0
  27. package/src/lib/mcp/tools/lint_doc.ts +76 -0
  28. package/src/lib/mcp/tools/lint_gate.ts +49 -0
  29. package/src/lib/mcp/tools/list_docs.ts +18 -0
  30. package/src/lib/mcp/tools/list_summary_drift.ts +95 -0
  31. package/src/lib/mcp/tools/materialize_subgroup.ts +274 -0
  32. package/src/lib/mcp/tools/move_doc.ts +439 -0
  33. package/src/lib/mcp/tools/patch_preamble.ts +145 -0
  34. package/src/lib/mcp/tools/patch_section.ts +113 -0
  35. package/src/lib/mcp/tools/read_doc_section.ts +70 -0
  36. package/src/lib/mcp/tools/rename_doc.ts +167 -0
  37. package/src/lib/mcp/tools/restore_doc.ts +41 -0
  38. package/src/lib/mcp/tools/search.ts +36 -0
  39. package/src/lib/mcp/tools/sections.ts +130 -0
  40. package/src/lib/mcp/tools/split_doc.ts +129 -0
  41. package/src/lib/mcp/tools/trash_doc.ts +116 -0
  42. package/src/lib/mcp/tools/types.ts +7 -0
  43. package/src/lib/mcp/tools/upload_image.ts +77 -0
  44. package/src/lib/mcp/tools/validate_args.ts +36 -0
  45. package/src/lib/mcp/tools/vault.ts +430 -0
  46. package/src/lib/mcp/tools/write_doc.ts +81 -0
  47. package/src/lib/mcp/tools/write_doc_preview.ts +59 -0
  48. package/src/lib/owner/identity.ts +65 -0
  49. package/src/lib/storage/FilesystemStorage.ts +88 -0
  50. package/src/lib/storage/SupabaseStorage.ts +358 -0
  51. package/src/lib/storage/VaultStorage.ts +35 -0
  52. package/src/lib/storage/index.ts +41 -0
  53. package/src/lib/supabase/admin.ts +16 -0
  54. package/src/lib/supabase/client.ts +8 -0
  55. package/src/lib/supabase/oauth.ts +296 -0
  56. package/src/lib/supabase/server.ts +22 -0
  57. package/src/lib/system-nodes.ts +68 -0
  58. package/src/lib/trash/state.ts +88 -0
  59. package/src/mcp/server.ts +380 -0
  60. package/templates/types/CONCEPT.md +21 -0
  61. package/templates/types/HACKATHON.md +28 -0
  62. package/templates/types/NOVEL/CHARACTERS.md +29 -0
  63. package/templates/types/NOVEL/DRAFT.md +15 -0
  64. package/templates/types/NOVEL/EDITS.md +19 -0
  65. package/templates/types/NOVEL/INBOX.md +15 -0
  66. package/templates/types/NOVEL/INSTRUCTIONS.md +32 -0
  67. package/templates/types/NOVEL/LEARNINGS.md +9 -0
  68. package/templates/types/NOVEL/OUTBOX.md +15 -0
  69. package/templates/types/NOVEL/PLOT.md +27 -0
  70. package/templates/types/NOVEL/WORLDBUILDING.md +23 -0
  71. package/templates/types/NOVEL.md +32 -0
  72. package/templates/types/PERSON.md +27 -0
  73. package/templates/types/PROJECT/BRAND.md +23 -0
  74. package/templates/types/PROJECT/BUILD.md +9 -0
  75. package/templates/types/PROJECT/IDEAS.md +11 -0
  76. package/templates/types/PROJECT/INBOX.md +15 -0
  77. package/templates/types/PROJECT/INSTRUCTIONS.md +23 -0
  78. package/templates/types/PROJECT/LEARNINGS.md +9 -0
  79. package/templates/types/PROJECT/LOGS.md +9 -0
  80. package/templates/types/PROJECT/OUTBOX.md +15 -0
  81. package/templates/types/PROJECT/SPRINT.md +56 -0
  82. package/templates/types/PROJECT.md +26 -0
@@ -0,0 +1,552 @@
1
+ // Shared lint engine — one implementation, three callsites: the standalone
2
+ // lint_doc MCP tool, and the post-write response paths of write_doc and
3
+ // patch_section. Returns warnings + structural info; never throws on a
4
+ // "bad" doc. Lint is signal, not gate.
5
+ //
6
+ // Single-doc rules (always run): missing_preamble,
7
+ // inline_mention_without_declared_edge, multiple_child_of.
8
+ // Cross-doc rules (run only when a vault context is passed):
9
+ // asymmetric_parent_edge, asymmetric_child_edge. These need the vault's
10
+ // title→doc map to check that every declared edge has a reciprocal
11
+ // declaration in the target doc.
12
+
13
+ const FENCE_RE = /^\s*(?:```|~~~)/;
14
+ const H1_RE = /^#\s+(.+?)\s*$/;
15
+ const H2_RE = /^##\s+(.+?)\s*$/;
16
+ const H3_RE = /^###\s+(.+?)\s*$/;
17
+ const BULLET_RE = /^\s*[-*]\s+/;
18
+ const WIKI_LINK_RE = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/;
19
+ const DECLARED_EDGE_HEADINGS = new Set(["child of", "parent of", "associated with"]);
20
+ const INLINE_MENTION_THRESHOLD = 3;
21
+
22
+ // Distillation thresholds. A doc with 4+ H3 sub-sections each carrying
23
+ // substantive content (~150 words = roughly one solid paragraph) is a
24
+ // candidate for split_doc — each H3 is competing to be its own atomic
25
+ // concept node. A `## Parent of` H3 subgroup with 3+ items is a
26
+ // candidate for materialize_subgroup — the user has already done the
27
+ // semantic grouping, we just need to promote it to a real intermediate
28
+ // parent doc.
29
+ const SPLIT_SUBSECTION_MIN_WORDS = 150;
30
+ const SPLIT_SUBSECTION_COUNT_THRESHOLD = 4;
31
+ const SUBGROUP_BULLET_THRESHOLD = 3;
32
+
33
+ export interface LintWarning {
34
+ code:
35
+ | "missing_preamble"
36
+ | "inline_mention_without_declared_edge"
37
+ | "multiple_child_of"
38
+ | "asymmetric_parent_edge"
39
+ | "asymmetric_child_edge"
40
+ | "associate_duplicates_hierarchy"
41
+ | "sibling_assoc_redundant"
42
+ | "split_candidate"
43
+ | "subgroup_materialization_candidate"
44
+ | "media_asset_missing_url"
45
+ | "filename_not_uppercase";
46
+ message: string;
47
+ suggestion: string;
48
+ title?: string;
49
+ count?: number;
50
+ asymmetric_target?: string;
51
+ /** For split_candidate / subgroup_materialization_candidate — the
52
+ * subsection/subgroup headings that triggered the warning. */
53
+ candidates?: string[];
54
+ /** 1-indexed source line where the violation lives. Null for
55
+ * doc-level codes (split_candidate, subgroup_materialization_candidate)
56
+ * that don't have a single anchoring line. */
57
+ line: number | null;
58
+ }
59
+
60
+ export interface LintInfo {
61
+ has_preamble: boolean;
62
+ preamble_word_count: number;
63
+ has_child_of: boolean;
64
+ child_of_count: number;
65
+ declared_edges_total: number;
66
+ inline_mentions: Array<{ title: string; count: number }>;
67
+ section_count: number;
68
+ }
69
+
70
+ export interface LintResult {
71
+ warnings: LintWarning[];
72
+ info: LintInfo;
73
+ }
74
+
75
+ export interface LintDocInfo {
76
+ path: string;
77
+ title: string;
78
+ declaredParents: string[];
79
+ declaredChildren: string[];
80
+ }
81
+
82
+ /** Minimal vault context the cross-doc rules need. */
83
+ export interface LintVaultContext {
84
+ /** Path of the doc being linted, so cross-doc checks can locate "me" by path. */
85
+ selfPath: string;
86
+ /** This doc's resolved parent paths (from `## Child of`). Used by the
87
+ * sibling-assoc check to detect when an assoc target is actually a sibling
88
+ * (i.e. shares one of these parents). */
89
+ selfDeclaredParents: string[];
90
+ /** Resolve a wiki-link target (raw title or slug text) to a doc info entry
91
+ * using the locality-aware resolver. Returns null for dangling links.
92
+ * Cross-doc checks (asymmetric edges, sibling assoc) MUST use this
93
+ * rather than a title-only map so that links like `[[GBI-DAY3]]` —
94
+ * where the slug matches but the title is "GBI — DAY3" — resolve
95
+ * correctly, and so that ambiguous titles get disambiguated by the
96
+ * linking doc's path. */
97
+ resolveTarget: (target: string) => LintDocInfo | null;
98
+ }
99
+
100
+ function findPreambleBlock(content: string): { body: string; h1LineIdx: number } | null {
101
+ const lines = content.split("\n");
102
+ let inFence = false;
103
+ let h1Idx = -1;
104
+ for (let i = 0; i < lines.length; i++) {
105
+ if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
106
+ if (inFence) continue;
107
+ if (H1_RE.test(lines[i])) { h1Idx = i; break; }
108
+ }
109
+ if (h1Idx === -1) return null;
110
+
111
+ let firstH2Idx = lines.length;
112
+ inFence = false;
113
+ for (let i = h1Idx + 1; i < lines.length; i++) {
114
+ if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
115
+ if (inFence) continue;
116
+ if (H2_RE.test(lines[i])) { firstH2Idx = i; break; }
117
+ }
118
+ const body = lines.slice(h1Idx + 1, firstH2Idx).join("\n").trim();
119
+ return { body, h1LineIdx: h1Idx };
120
+ }
121
+
122
+ /**
123
+ * Collect titles referenced under each declared-edge section, with bullet
124
+ * counts per section so we can detect `multiple_child_of` precisely. The
125
+ * bullet count is what matters for the lint, not the number of links —
126
+ * `* [[A]] — collaborated with [[B]]` is one declared edge to A, even
127
+ * though two wiki-links appear.
128
+ *
129
+ * For each declared-edge heading we also track:
130
+ * - `bulletLines`: 1-indexed line numbers of every leading-wiki-link bullet,
131
+ * in source order, so callers (notably the `multiple_child_of` rule) can
132
+ * point at the offending line.
133
+ * - `titleLines`: first-seen 1-indexed line for each lowercase title
134
+ * appearing under the heading, so per-title warnings (assoc-duplicates,
135
+ * sibling-assoc, asymmetric edges) can anchor on the actual bullet.
136
+ */
137
+ function collectDeclaredEdges(content: string): {
138
+ titlesByHeading: Map<string, Set<string>>;
139
+ bulletCountByHeading: Map<string, number>;
140
+ bulletLinesByHeading: Map<string, number[]>;
141
+ titleLinesByHeading: Map<string, Map<string, number>>;
142
+ allTitles: Set<string>;
143
+ } {
144
+ const lines = content.split("\n");
145
+ const titlesByHeading = new Map<string, Set<string>>();
146
+ const bulletCountByHeading = new Map<string, number>();
147
+ const bulletLinesByHeading = new Map<string, number[]>();
148
+ const titleLinesByHeading = new Map<string, Map<string, number>>();
149
+ const allTitles = new Set<string>();
150
+ let inFence = false;
151
+ let currentHeading: string | null = null;
152
+
153
+ for (let i = 0; i < lines.length; i++) {
154
+ const line = lines[i];
155
+ if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
156
+ if (inFence) continue;
157
+ const h2 = line.match(H2_RE);
158
+ if (h2) {
159
+ currentHeading = h2[1].trim().toLowerCase();
160
+ if (DECLARED_EDGE_HEADINGS.has(currentHeading)) {
161
+ if (!titlesByHeading.has(currentHeading)) titlesByHeading.set(currentHeading, new Set());
162
+ if (!bulletCountByHeading.has(currentHeading)) bulletCountByHeading.set(currentHeading, 0);
163
+ if (!bulletLinesByHeading.has(currentHeading)) bulletLinesByHeading.set(currentHeading, []);
164
+ if (!titleLinesByHeading.has(currentHeading)) titleLinesByHeading.set(currentHeading, new Map());
165
+ }
166
+ continue;
167
+ }
168
+ if (!currentHeading || !DECLARED_EDGE_HEADINGS.has(currentHeading)) continue;
169
+
170
+ let isBullet = false;
171
+ if (BULLET_RE.test(line)) {
172
+ // Only count bullets whose leading link is a wiki-link — defensive
173
+ // against random "*" lines that aren't edge declarations.
174
+ const leading = line.replace(BULLET_RE, "").match(WIKI_LINK_RE);
175
+ if (leading) {
176
+ bulletCountByHeading.set(currentHeading, (bulletCountByHeading.get(currentHeading) ?? 0) + 1);
177
+ bulletLinesByHeading.get(currentHeading)!.push(i + 1);
178
+ isBullet = true;
179
+ }
180
+ }
181
+
182
+ for (const m of line.matchAll(/\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g)) {
183
+ const title = m[1].trim().toLowerCase();
184
+ titlesByHeading.get(currentHeading)!.add(title);
185
+ allTitles.add(title);
186
+ // Track the first line we saw this title on, so warnings can anchor
187
+ // on the original source line even if the title repeats.
188
+ const titleLines = titleLinesByHeading.get(currentHeading)!;
189
+ if (!titleLines.has(title) && isBullet) titleLines.set(title, i + 1);
190
+ else if (!titleLines.has(title)) titleLines.set(title, i + 1);
191
+ }
192
+ }
193
+ return { titlesByHeading, bulletCountByHeading, bulletLinesByHeading, titleLinesByHeading, allTitles };
194
+ }
195
+
196
+ function collectInlineMentions(content: string): Map<string, { count: number; firstLine: number }> {
197
+ const lines = content.split("\n");
198
+ const counts = new Map<string, { count: number; firstLine: number }>();
199
+ let inFence = false;
200
+ let currentHeading: string | null = null;
201
+ for (let i = 0; i < lines.length; i++) {
202
+ const line = lines[i];
203
+ if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
204
+ if (inFence) continue;
205
+ const h2 = line.match(H2_RE);
206
+ if (h2) {
207
+ currentHeading = h2[1].trim().toLowerCase();
208
+ continue;
209
+ }
210
+ if (currentHeading && DECLARED_EDGE_HEADINGS.has(currentHeading)) continue;
211
+
212
+ for (const m of line.matchAll(/\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g)) {
213
+ const title = m[1].trim().toLowerCase();
214
+ const prev = counts.get(title);
215
+ if (prev) prev.count++;
216
+ else counts.set(title, { count: 1, firstLine: i + 1 });
217
+ }
218
+ }
219
+ return counts;
220
+ }
221
+
222
+ function countSections(content: string): number {
223
+ const lines = content.split("\n");
224
+ let count = 0;
225
+ let inFence = false;
226
+ for (const line of lines) {
227
+ if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
228
+ if (inFence) continue;
229
+ if (H2_RE.test(line)) count++;
230
+ }
231
+ return count;
232
+ }
233
+
234
+ /**
235
+ * Collect H3 sub-sections living under non-edge H2s (Notes, free-form
236
+ * body sections), with a word count of each sub-section's body. Used by
237
+ * the `split_candidate` rule — H3s carrying substantive content are
238
+ * effectively atomic concepts waiting to be extracted.
239
+ */
240
+ function collectSubstantiveSubsections(content: string): Array<{ heading: string; wordCount: number }> {
241
+ const lines = content.split("\n");
242
+ const out: Array<{ heading: string; wordCount: number }> = [];
243
+ let inFence = false;
244
+ let inEdgeSection = false;
245
+ let currentH3: { heading: string; words: string[] } | null = null;
246
+ const flush = () => {
247
+ if (currentH3) {
248
+ out.push({ heading: currentH3.heading, wordCount: currentH3.words.filter(Boolean).length });
249
+ currentH3 = null;
250
+ }
251
+ };
252
+ for (const line of lines) {
253
+ if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
254
+ if (inFence) {
255
+ // Code-fenced content counts toward the word total of the open H3.
256
+ if (currentH3) currentH3.words.push(...line.split(/\s+/));
257
+ continue;
258
+ }
259
+ const h2 = line.match(H2_RE);
260
+ if (h2) {
261
+ flush();
262
+ inEdgeSection = DECLARED_EDGE_HEADINGS.has(h2[1].trim().toLowerCase());
263
+ continue;
264
+ }
265
+ if (inEdgeSection) continue;
266
+ const h3 = line.match(H3_RE);
267
+ if (h3) {
268
+ flush();
269
+ currentH3 = { heading: h3[1].trim(), words: [] };
270
+ continue;
271
+ }
272
+ if (currentH3) currentH3.words.push(...line.split(/\s+/));
273
+ }
274
+ flush();
275
+ return out;
276
+ }
277
+
278
+ /**
279
+ * Collect H3 subgroups inside the `## Parent of` section, with the
280
+ * bullet count per subgroup. Used by `subgroup_materialization_candidate`
281
+ * and (eventually) the `materialize_subgroup` MCP tool.
282
+ */
283
+ function collectParentOfSubgroups(content: string): Array<{ heading: string; bulletCount: number }> {
284
+ const lines = content.split("\n");
285
+ const out: Array<{ heading: string; bulletCount: number }> = [];
286
+ let inFence = false;
287
+ let inParentOf = false;
288
+ let currentSubgroup: { heading: string; bulletCount: number } | null = null;
289
+ const flush = () => {
290
+ if (currentSubgroup) {
291
+ out.push(currentSubgroup);
292
+ currentSubgroup = null;
293
+ }
294
+ };
295
+ for (const line of lines) {
296
+ if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
297
+ if (inFence) continue;
298
+ const h2 = line.match(H2_RE);
299
+ if (h2) {
300
+ flush();
301
+ inParentOf = h2[1].trim().toLowerCase() === "parent of";
302
+ continue;
303
+ }
304
+ if (!inParentOf) continue;
305
+ const h3 = line.match(H3_RE);
306
+ if (h3) {
307
+ flush();
308
+ currentSubgroup = { heading: h3[1].trim(), bulletCount: 0 };
309
+ continue;
310
+ }
311
+ if (!currentSubgroup) continue;
312
+ if (BULLET_RE.test(line)) {
313
+ const leading = line.replace(BULLET_RE, "").match(WIKI_LINK_RE);
314
+ if (leading) currentSubgroup.bulletCount++;
315
+ }
316
+ }
317
+ flush();
318
+ return out;
319
+ }
320
+
321
+ export function lintDocContent(content: string, ctx?: LintVaultContext): LintResult {
322
+ const preamble = findPreambleBlock(content);
323
+ const has_preamble = !!preamble && preamble.body.length > 0 && !/^>\s*$/m.test(preamble.body)
324
+ ? /^>\s*\S/.test(preamble.body)
325
+ : false;
326
+ const preamble_word_count = preamble
327
+ ? preamble.body.replace(/^>\s*/gm, "").trim().split(/\s+/).filter(Boolean).length
328
+ : 0;
329
+
330
+ const { titlesByHeading, bulletCountByHeading, bulletLinesByHeading, titleLinesByHeading } =
331
+ collectDeclaredEdges(content);
332
+ const declaredTitles = new Set<string>();
333
+ for (const titles of titlesByHeading.values()) for (const t of titles) declaredTitles.add(t);
334
+
335
+ const declared_edges_total = Array.from(bulletCountByHeading.values()).reduce((a, b) => a + b, 0);
336
+ const child_of_count = bulletCountByHeading.get("child of") ?? 0;
337
+ const has_child_of = child_of_count > 0;
338
+
339
+ // Pass the original content (not noFenceContent) so reported line numbers
340
+ // match the source. collectInlineMentions handles fences internally.
341
+ const inlineCounts = collectInlineMentions(content);
342
+
343
+ const warnings: LintWarning[] = [];
344
+
345
+ // SPRINT-055 (SIG-004): filename casing rule. The on-disk filename should
346
+ // be all-caps + ASCII (CLAUDE.md, SPRINT-029.md). Display titles stay
347
+ // free-form because wiki-links are case-insensitive. ctx.selfPath required
348
+ // because this is a path-level check; standalone lint of content alone
349
+ // has no filename to evaluate.
350
+ if (ctx?.selfPath) {
351
+ const base = ctx.selfPath.split("/").pop() ?? ctx.selfPath;
352
+ const ok = /^[A-Z0-9._-]+\.md$/.test(base);
353
+ if (!ok) {
354
+ warnings.push({
355
+ code: "filename_not_uppercase",
356
+ message:
357
+ `Filename \`${base}\` isn't uppercase ASCII. EMDEE convention: on-disk filenames are all-caps (the H1 display title can stay free-form — wiki-links are case-insensitive).`,
358
+ suggestion:
359
+ `Rename via \`rename_doc\` so the basename matches \`[A-Z0-9._-]+\\.md\`. Inbound \`[[wiki-links]]\` reference the H1 title, not the path, so the rename leaves them intact.`,
360
+ title: base,
361
+ line: 1,
362
+ });
363
+ }
364
+ }
365
+
366
+ if (!has_preamble && preamble !== null) {
367
+ warnings.push({
368
+ code: "missing_preamble",
369
+ message:
370
+ "No `>` blockquote summary found directly under the H1. The MCP `get_summary` tool returns empty for this doc — it'll be invisible to cheap retrieval.",
371
+ suggestion:
372
+ "Add a `> one-line summary` line immediately after the H1, then a blank line, then the body. Keep it to 1–3 sentences.",
373
+ line: preamble.h1LineIdx + 1,
374
+ });
375
+ }
376
+
377
+ // Hierarchy beats associate: a target that appears in `## Child of` or
378
+ // `## Parent of` shouldn't also appear in `## Associated with` for the
379
+ // same doc — the hierarchy edge already conveys the connection, and
380
+ // duplicating it as an associate just clutters the graph and the
381
+ // neighbours list. Suppressed at the edge-building layer; surfaced
382
+ // here so the user can clean the markdown when convenient.
383
+ const childOfTitles = titlesByHeading.get("child of") ?? new Set<string>();
384
+ const parentOfTitles = titlesByHeading.get("parent of") ?? new Set<string>();
385
+ const assocTitles = titlesByHeading.get("associated with") ?? new Set<string>();
386
+ const assocTitleLines = titleLinesByHeading.get("associated with") ?? new Map<string, number>();
387
+ for (const t of assocTitles) {
388
+ if (childOfTitles.has(t) || parentOfTitles.has(t)) {
389
+ warnings.push({
390
+ code: "associate_duplicates_hierarchy",
391
+ message: `\`[[${t}]]\` appears in both \`## Associated with\` and the hierarchy (Child of / Parent of). The hierarchy edge already covers it.`,
392
+ suggestion: `Remove \`[[${t}]]\` from \`## Associated with\` — the parent/child relationship is the canonical link and the assoc bullet is being suppressed in the graph anyway.`,
393
+ title: t,
394
+ line: assocTitleLines.get(t) ?? null,
395
+ });
396
+ }
397
+ }
398
+
399
+ // Sibling assoc redundancy: when an associate target shares one of
400
+ // this doc's parents, the two are siblings. The shared-parent edge
401
+ // already implies the relationship — `## Associated with` is for
402
+ // cross-tree links (e.g. project↔person), not for linking peers under
403
+ // the same parent. Indexer suppresses these in the graph; this lint
404
+ // surfaces them so the markdown can be cleaned.
405
+ if (ctx && ctx.selfDeclaredParents.length > 0) {
406
+ const selfParents = new Set(ctx.selfDeclaredParents);
407
+ for (const t of assocTitles) {
408
+ // Skip pairs already flagged by the hierarchy-overlap rule above.
409
+ if (childOfTitles.has(t) || parentOfTitles.has(t)) continue;
410
+ const target = ctx.resolveTarget(t);
411
+ if (!target) continue;
412
+ const sharedParent = target.declaredParents.find((p) => selfParents.has(p));
413
+ if (sharedParent) {
414
+ warnings.push({
415
+ code: "sibling_assoc_redundant",
416
+ message: `\`[[${t}]]\` is listed in \`## Associated with\` but shares the parent \`${sharedParent}\` with this doc — the two are siblings, already related through their common parent.`,
417
+ suggestion: `Remove \`[[${t}]]\` from \`## Associated with\`. \`## Associated with\` is for cross-tree links (e.g. project↔person, sprint↔learning), not for connecting docs that share a parent.`,
418
+ title: t,
419
+ line: assocTitleLines.get(t) ?? null,
420
+ });
421
+ }
422
+ }
423
+ }
424
+
425
+ if (child_of_count > 1) {
426
+ // Anchor on the SECOND bullet — that's the violating one (the first
427
+ // bullet is the legitimate canonical parent).
428
+ const childOfBulletLines = bulletLinesByHeading.get("child of") ?? [];
429
+ warnings.push({
430
+ code: "multiple_child_of",
431
+ message: `\`## Child of\` declares ${child_of_count} parents. The vault convention is single-parent — a doc lives under one canonical parent and uses \`## Associated with\` for cross-cutting connections.`,
432
+ suggestion:
433
+ "Keep one parent in `## Child of` (the canonical hierarchy placement) and move the others to `## Associated with` with a short prose note explaining the connection.",
434
+ count: child_of_count,
435
+ line: childOfBulletLines[1] ?? null,
436
+ });
437
+ }
438
+
439
+ // Distillation candidates. Detection-only — execution lives in the
440
+ // split_doc and materialize_subgroup MCP tools, run by a human.
441
+ const substantiveSubsections = collectSubstantiveSubsections(content)
442
+ .filter((s) => s.wordCount >= SPLIT_SUBSECTION_MIN_WORDS);
443
+ if (substantiveSubsections.length >= SPLIT_SUBSECTION_COUNT_THRESHOLD) {
444
+ warnings.push({
445
+ code: "split_candidate",
446
+ message: `This doc has ${substantiveSubsections.length} H3 sub-sections with substantive content (≥${SPLIT_SUBSECTION_MIN_WORDS} words each). Each one is large enough to live as its own atomic concept node.`,
447
+ suggestion: `Plan an extraction with the \`distill_doc\` MCP, then execute with \`split_doc\`. Each H3 → its own doc, with the source rewritten as a thin index of wiki-links.`,
448
+ count: substantiveSubsections.length,
449
+ candidates: substantiveSubsections.map((s) => s.heading),
450
+ line: null,
451
+ });
452
+ }
453
+
454
+ const subgroups = collectParentOfSubgroups(content);
455
+ const materializable = subgroups.filter((g) => g.bulletCount >= SUBGROUP_BULLET_THRESHOLD);
456
+ if (materializable.length > 0) {
457
+ warnings.push({
458
+ code: "subgroup_materialization_candidate",
459
+ message: `\`## Parent of\` contains ${materializable.length} H3 subgroup(s) with ≥${SUBGROUP_BULLET_THRESHOLD} items: ${materializable.map((g) => `"${g.heading}" (${g.bulletCount})`).join(", ")}. Each is large enough to become its own intermediate parent doc.`,
460
+ suggestion: `Run \`materialize_subgroup\` per subgroup. Each promoted H3 becomes a real intermediate node; its bullets move under it and their \`## Child of\` rewires from this doc to the new intermediate.`,
461
+ count: materializable.length,
462
+ candidates: materializable.map((g) => g.heading),
463
+ line: null,
464
+ });
465
+ }
466
+
467
+ // Cross-doc rules — only run when caller provided vault context.
468
+ if (ctx) {
469
+ const parentOfTitleLines = titleLinesByHeading.get("parent of") ?? new Map<string, number>();
470
+ const childOfTitleLines = titleLinesByHeading.get("child of") ?? new Map<string, number>();
471
+
472
+ const declaredChildrenTitles = titlesByHeading.get("parent of") ?? new Set<string>();
473
+ for (const childTitle of declaredChildrenTitles) {
474
+ const child = ctx.resolveTarget(childTitle);
475
+ if (!child) continue; // dangling link — separate concern, not asymmetric
476
+ const childDeclaresMe = child.declaredParents.some((p) => p === ctx.selfPath);
477
+ if (!childDeclaresMe) {
478
+ warnings.push({
479
+ code: "asymmetric_parent_edge",
480
+ message: `This doc lists \`[[${childTitle}]]\` in Parent of, but [[${child.title}]] doesn't declare this doc back in its Child of. The edge is one-sided.`,
481
+ suggestion: `Either remove \`[[${childTitle}]]\` from this doc's Parent of, or add this doc to ${child.title}'s Child of so the edge is reciprocal.`,
482
+ asymmetric_target: child.title,
483
+ line: parentOfTitleLines.get(childTitle) ?? null,
484
+ });
485
+ }
486
+ }
487
+
488
+ const declaredParentTitles = titlesByHeading.get("child of") ?? new Set<string>();
489
+ for (const parentTitle of declaredParentTitles) {
490
+ const parent = ctx.resolveTarget(parentTitle);
491
+ if (!parent) continue;
492
+ const parentDeclaresMe = parent.declaredChildren.some((c) => c === ctx.selfPath);
493
+ if (!parentDeclaresMe) {
494
+ warnings.push({
495
+ code: "asymmetric_child_edge",
496
+ message: `This doc lists \`[[${parentTitle}]]\` in Child of, but [[${parent.title}]] doesn't list this doc back in its Parent of. The edge is one-sided.`,
497
+ suggestion: `Either remove \`[[${parentTitle}]]\` from this doc's Child of, or add this doc to ${parent.title}'s Parent of so the edge is reciprocal.`,
498
+ asymmetric_target: parent.title,
499
+ line: childOfTitleLines.get(parentTitle) ?? null,
500
+ });
501
+ }
502
+ }
503
+ }
504
+
505
+ // Media asset rule: an ## Asset section must contain a `url:` line.
506
+ // Fires on any doc that has the section (not just images/ paths) so the
507
+ // rule works whether the doc is linted individually or batch-linted.
508
+ {
509
+ const assetMatch = content.match(/^## Asset\s*\n([\s\S]*?)(?=^## |\z)/m);
510
+ if (assetMatch) {
511
+ const assetBody = assetMatch[1];
512
+ const assetHeadingLine = content.split("\n").findIndex((l) => /^## Asset\s*$/.test(l)) + 1;
513
+ if (!/^\s*url\s*:/m.test(assetBody)) {
514
+ warnings.push({
515
+ code: "media_asset_missing_url",
516
+ message: "`## Asset` block is missing a required `url:` line. Consumers cannot find the binary without a stable public URL.",
517
+ suggestion: "Add `url: https://...` as the first line of the `## Asset` block — use the Supabase Storage public URL returned by `/api/media` or `/api/image`.",
518
+ line: assetHeadingLine || null,
519
+ });
520
+ }
521
+ }
522
+ }
523
+
524
+ const inline_mentions: Array<{ title: string; count: number }> = [];
525
+ for (const [title, { count, firstLine }] of inlineCounts) {
526
+ inline_mentions.push({ title, count });
527
+ if (count >= INLINE_MENTION_THRESHOLD && !declaredTitles.has(title)) {
528
+ warnings.push({
529
+ code: "inline_mention_without_declared_edge",
530
+ message: `\`[[${title}]]\` is mentioned ${count} times inline but is not declared in Child of / Parent of / Associated with.`,
531
+ suggestion: `Consider adding \`* [[${title}]]\` to the Associated with section if this is a real cross-cutting connection.`,
532
+ title,
533
+ count,
534
+ line: firstLine,
535
+ });
536
+ }
537
+ }
538
+ inline_mentions.sort((a, b) => b.count - a.count);
539
+
540
+ return {
541
+ warnings,
542
+ info: {
543
+ has_preamble,
544
+ preamble_word_count,
545
+ has_child_of,
546
+ child_of_count,
547
+ declared_edges_total,
548
+ inline_mentions,
549
+ section_count: countSections(content),
550
+ },
551
+ };
552
+ }
@@ -0,0 +1,76 @@
1
+ import { validatePath, readVaultFile, loadVaultIndex } from "./vault";
2
+ import { lintDocContent, type LintVaultContext, type LintDocInfo } from "./lint";
3
+ import { resolveWikiLink } from "../../../core/resolveLink";
4
+ import type { DocIndex } from "../../../core/indexer";
5
+ import type { ToolContext } from "./types";
6
+
7
+ // Re-exported for callsites in other tools that need the same context
8
+ // shape — keeps lint_doc the single authority for the resolver wiring.
9
+ export type { LintVaultContext } from "./lint";
10
+
11
+ function json(value: unknown) {
12
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
13
+ }
14
+
15
+ /**
16
+ * Build a LintVaultContext for a given doc using a pre-loaded vault
17
+ * index. Extracted so the lint gate, add_association, and any other
18
+ * caller that already has the index can reuse the resolveTarget closure
19
+ * without re-walking the index. The closure resolves wiki-link targets
20
+ * through the locality-aware resolveWikiLink — same behaviour the
21
+ * lintDoc tool uses.
22
+ */
23
+ export function buildLintVaultContext(index: DocIndex, selfPath: string): LintVaultContext {
24
+ const docInfoByPath = new Map<string, LintDocInfo>();
25
+ for (const d of index.docs) {
26
+ const declaredParents = d.parents
27
+ .map((l) => resolveWikiLink(index, l.title, d.path)?.path)
28
+ .filter((p): p is string => !!p);
29
+ const declaredChildren = d.children
30
+ .map((l) => resolveWikiLink(index, l.title, d.path)?.path)
31
+ .filter((p): p is string => !!p);
32
+ docInfoByPath.set(d.path, {
33
+ path: d.path,
34
+ title: d.title,
35
+ declaredParents,
36
+ declaredChildren,
37
+ });
38
+ }
39
+ const selfInfo = docInfoByPath.get(selfPath);
40
+ const selfDeclaredParents = selfInfo?.declaredParents ?? [];
41
+ const resolveTarget = (target: string): LintDocInfo | null => {
42
+ const resolved = resolveWikiLink(index, target, selfPath);
43
+ if (!resolved) return null;
44
+ return docInfoByPath.get(resolved.path) ?? null;
45
+ };
46
+ return { selfPath, selfDeclaredParents, resolveTarget };
47
+ }
48
+
49
+ /**
50
+ * Audit a doc for known quality defects. Returns warnings + structural info.
51
+ * Never throws on a "bad" doc — lint is a signal, not a gate.
52
+ *
53
+ * Single-doc rules (missing preamble, undeclared inline mentions,
54
+ * multi-parent, distillation candidates) and cross-doc rules (asymmetric
55
+ * Parent/Child edges, sibling-assoc redundancy) both run here — this
56
+ * tool loads the full vault index, so the cross-doc checks have the
57
+ * context they need. The post-write fast paths in write_doc and
58
+ * patch_section skip the cross-doc rules to stay cheap.
59
+ *
60
+ * Cross-doc resolution goes through the locality-aware resolveWikiLink,
61
+ * so a `[[GBI-DAY3]]` link resolves via slug match to the file named
62
+ * `GBI-DAY3.md` even though its H1 title is `GBI — DAY3`. A title-only
63
+ * map (the previous implementation) would have left every such link
64
+ * unresolved and falsely flagged the edges as asymmetric.
65
+ */
66
+ export async function lintDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
67
+ const rel = String(args.path);
68
+ validatePath(rel);
69
+ const content = await readVaultFile(ctx, rel);
70
+ if (content === null) return json({ error: "doc_not_found", path: rel });
71
+
72
+ const index = await loadVaultIndex(ctx);
73
+ const lintCtx = buildLintVaultContext(index, rel);
74
+ const result = lintDocContent(content, lintCtx);
75
+ return json({ path: rel, ...result });
76
+ }
@@ -0,0 +1,49 @@
1
+ // SPRINT-019 Phase A — pre-write lint gate. The write tools call
2
+ // evaluateLintGate against proposed-but-not-yet-persisted content, with a
3
+ // caller-supplied list of warning codes that should HARD-BLOCK the write.
4
+ // If any of those codes fire, the gate returns the structured fixes and
5
+ // the write is skipped. With an empty gate list (the default everywhere),
6
+ // every write proceeds and warnings remain advisory — the existing
7
+ // post-write lint envelope behaviour is unchanged.
8
+ //
9
+ // The shape `{ ok: true } | { ok: false; ... }` is deliberately the same
10
+ // envelope each write tool serialises into its response so the caller
11
+ // can branch on `ok` alone.
12
+
13
+ import { lintDocContent, type LintVaultContext, type LintWarning } from "./lint";
14
+
15
+ export interface LintFix {
16
+ code: LintWarning["code"];
17
+ line: number | null;
18
+ fix_suggestion: string;
19
+ original_message: string;
20
+ }
21
+
22
+ export type LintGateResult =
23
+ | { ok: true }
24
+ | { ok: false; fixes: LintFix[]; original_warnings: LintWarning[] };
25
+
26
+ /**
27
+ * Run the lint engine on `proposedContent` and decide whether the write
28
+ * should proceed. Cross-doc rules (asymmetric_*, sibling_assoc_redundant)
29
+ * only fire when `vaultCtx` is supplied — callers that want to gate on
30
+ * those must pass it in. Single-doc codes work without a vault context.
31
+ */
32
+ export function evaluateLintGate(
33
+ proposedContent: string,
34
+ gateOnCodes: string[],
35
+ vaultCtx?: LintVaultContext,
36
+ ): LintGateResult {
37
+ if (gateOnCodes.length === 0) return { ok: true };
38
+ const { warnings } = lintDocContent(proposedContent, vaultCtx);
39
+ const gateSet = new Set(gateOnCodes);
40
+ const offending = warnings.filter((w) => gateSet.has(w.code));
41
+ if (offending.length === 0) return { ok: true };
42
+ const fixes: LintFix[] = offending.map((w) => ({
43
+ code: w.code,
44
+ line: w.line ?? null,
45
+ fix_suggestion: w.suggestion,
46
+ original_message: w.message,
47
+ }));
48
+ return { ok: false, fixes, original_warnings: offending };
49
+ }