@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,121 @@
1
+ import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
2
+ import { evaluateLintGate } from "./lint_gate";
3
+ import { buildLintVaultContext } from "./lint_doc";
4
+ import {
5
+ parseSections,
6
+ extractBody,
7
+ hashBody,
8
+ sectionId,
9
+ resolveSection,
10
+ type SectionLoc,
11
+ } from "./sections";
12
+ import type { ToolContext } from "./types";
13
+ import { validateArgs } from "./validate_args";
14
+
15
+ const CROSS_DOC_CODES = new Set([
16
+ "asymmetric_parent_edge",
17
+ "asymmetric_child_edge",
18
+ "sibling_assoc_redundant",
19
+ ]);
20
+
21
+ const ARG_SPEC = {
22
+ allowed: ["path", "heading", "section_id", "body", "create_if_missing", "gate_on_warnings"],
23
+ required: ["path", "body"],
24
+ } as const;
25
+
26
+ function parseGateCodes(raw: unknown): string[] {
27
+ if (!Array.isArray(raw)) return [];
28
+ return raw.filter((c): c is string => typeof c === "string");
29
+ }
30
+
31
+ async function runGateOrError(
32
+ ctx: ToolContext,
33
+ rel: string,
34
+ proposed: string,
35
+ gateCodes: string[],
36
+ ): Promise<{ error: "lint_gate_failed"; fixes: unknown; original_warnings: unknown } | null> {
37
+ if (gateCodes.length === 0) return null;
38
+ const needsVault = gateCodes.some((c) => CROSS_DOC_CODES.has(c));
39
+ const vaultCtx = needsVault ? buildLintVaultContext(await loadVaultIndex(ctx), rel) : undefined;
40
+ const gate = evaluateLintGate(proposed, gateCodes, vaultCtx);
41
+ if (gate.ok) return null;
42
+ return { error: "lint_gate_failed", fixes: gate.fixes, original_warnings: gate.original_warnings };
43
+ }
44
+
45
+ function json(value: unknown) {
46
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
47
+ }
48
+
49
+ export async function appendSection(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
50
+ const argErr = validateArgs(args, ARG_SPEC);
51
+ if (argErr) return json(argErr);
52
+ const rel = String(args.path);
53
+ validatePath(rel);
54
+ const headingArg = args.heading !== undefined ? String(args.heading).trim() : "";
55
+ const sectionIdArg = args.section_id !== undefined ? String(args.section_id).trim() : "";
56
+ const body = String(args.body ?? "");
57
+ const createIfMissing = Boolean(args.create_if_missing ?? false);
58
+ if (!headingArg && !sectionIdArg) throw new Error("heading or section_id required");
59
+
60
+ const content = await readVaultFile(ctx, rel);
61
+ if (content === null) return json({ error: "doc_not_found", path: rel });
62
+
63
+ const sections = parseSections(content);
64
+ const resolved = resolveSection(sections, sectionIdArg, headingArg);
65
+ if (resolved.kind === "mismatch") {
66
+ return json({
67
+ error: "section_id_heading_mismatch",
68
+ section_id_resolves_to: resolved.section_id_resolves_to,
69
+ heading_resolves_to: resolved.heading_resolves_to,
70
+ });
71
+ }
72
+ const target: SectionLoc | undefined = resolved.kind === "ok" ? resolved.loc : undefined;
73
+
74
+ if (!target) {
75
+ if (!createIfMissing || !headingArg) {
76
+ return json({
77
+ error: "section_not_found",
78
+ heading: headingArg || undefined,
79
+ section_id: sectionIdArg || undefined,
80
+ available: resolved.kind === "not_found" ? resolved.available : [],
81
+ hint: headingArg
82
+ ? "Pass create_if_missing=true to create the section at end of file."
83
+ : "Pass a heading (not just section_id) plus create_if_missing=true to create a new section.",
84
+ });
85
+ }
86
+ const sep = content.endsWith("\n") ? "" : "\n";
87
+ const newContent = content + sep + `\n## ${headingArg}\n\n${body}\n`;
88
+ const gateCodes = parseGateCodes(args.gate_on_warnings);
89
+ const gateErr = await runGateOrError(ctx, rel, newContent, gateCodes);
90
+ if (gateErr) return json(gateErr);
91
+ await writeVaultFile(ctx, rel, newContent);
92
+ const newSections = parseSections(newContent);
93
+ const newIdx = newSections.findIndex((s) => s.heading === headingArg);
94
+ return json({
95
+ ok: true,
96
+ created: true,
97
+ content_hash: hashBody(body.trim()),
98
+ section_id: newIdx >= 0 ? sectionId(headingArg, newIdx) : undefined,
99
+ });
100
+ }
101
+
102
+ const lines = content.split("\n");
103
+ const sectionLines = lines.slice(target.headingLineIdx, target.bodyEndLineIdx);
104
+ while (sectionLines.length > 1 && sectionLines[sectionLines.length - 1].trim() === "") sectionLines.pop();
105
+ sectionLines.push("", ...body.split("\n"), "");
106
+ const newContent = [...lines.slice(0, target.headingLineIdx), ...sectionLines, ...lines.slice(target.bodyEndLineIdx)].join("\n");
107
+ const gateCodes = parseGateCodes(args.gate_on_warnings);
108
+ const gateErr = await runGateOrError(ctx, rel, newContent, gateCodes);
109
+ if (gateErr) return json(gateErr);
110
+ await writeVaultFile(ctx, rel, newContent);
111
+
112
+ const newSections = parseSections(newContent);
113
+ const newIdx = newSections.findIndex((s) => s.heading === target!.heading);
114
+ const newTarget = newIdx >= 0 ? newSections[newIdx] : undefined;
115
+ const newBody = newTarget ? extractBody(newContent, newTarget) : "";
116
+ return json({
117
+ ok: true,
118
+ content_hash: hashBody(newBody),
119
+ section_id: newIdx >= 0 ? sectionId(target.heading, newIdx) : undefined,
120
+ });
121
+ }
@@ -0,0 +1,319 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
4
+ import { lintDocContent } from "./lint";
5
+ import { buildLintVaultContext } from "./lint_doc";
6
+ import { evaluateLintGate, type LintFix } from "./lint_gate";
7
+ import { isUppercaseFilename, normalizeFilenameInPath } from "./filename";
8
+ import type { LintWarning } from "./lint";
9
+ import type { ToolContext } from "./types";
10
+ import { validateArgs } from "./validate_args";
11
+
12
+ const ARG_SPEC = {
13
+ allowed: ["parent_path", "title", "body", "summary", "child_path", "gate_on_warnings"],
14
+ required: ["parent_path", "title"],
15
+ } as const;
16
+
17
+ const H1_RE = /^#\s+(.+?)\s*$/m;
18
+ const H2_RE = /^##\s+(.+?)\s*$/;
19
+ const FENCE_RE = /^\s*(?:```|~~~)/;
20
+ const SLUG_SAFE = /^[\p{L}\p{N}\s\-_.]+$/u;
21
+
22
+ function deriveTitle(content: string, fallbackPath: string): string {
23
+ const m = content.match(H1_RE);
24
+ if (m) return m[1].trim();
25
+ return path.basename(fallbackPath, ".md");
26
+ }
27
+
28
+ function sanitizeFilename(title: string): string {
29
+ // Mirror materialize_subgroup: hyphen for em-dash (Supabase Storage key
30
+ // restriction), strip slashes/backslashes to keep the filename within
31
+ // a single directory. SPRINT-055 (SIG-004): uppercase and ASCII-only
32
+ // so create_child-derived filenames hit the project convention by
33
+ // construction (rather than relying on the user passing CAPS).
34
+ return title
35
+ .replace(/\s*—\s*/g, "-")
36
+ .replace(/[/\\]/g, "_")
37
+ .toUpperCase()
38
+ .replace(/\s+/g, "-")
39
+ .replace(/[^A-Z0-9._-]/g, "")
40
+ .replace(/-+/g, "-")
41
+ .replace(/^[-.]+|[-.]+$/g, "");
42
+ }
43
+
44
+ function hashBody(body: string): string {
45
+ return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
46
+ }
47
+
48
+ interface SectionLoc {
49
+ heading: string;
50
+ headingLineIdx: number;
51
+ bodyStartLineIdx: number;
52
+ bodyEndLineIdx: number;
53
+ }
54
+
55
+ function parseSections(content: string): SectionLoc[] {
56
+ const lines = content.split("\n");
57
+ const sections: SectionLoc[] = [];
58
+ let inFence = false;
59
+ for (let i = 0; i < lines.length; i++) {
60
+ if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
61
+ if (inFence) continue;
62
+ const m = lines[i].match(H2_RE);
63
+ if (!m) continue;
64
+ if (sections.length > 0) sections[sections.length - 1].bodyEndLineIdx = i;
65
+ sections.push({ heading: m[1].trim(), headingLineIdx: i, bodyStartLineIdx: i + 1, bodyEndLineIdx: lines.length });
66
+ }
67
+ return sections;
68
+ }
69
+
70
+ function findSection(sections: SectionLoc[], heading: string): SectionLoc | undefined {
71
+ const target = heading.trim().toLowerCase();
72
+ return sections.find((s) => s.heading.toLowerCase() === target);
73
+ }
74
+
75
+ function extractBody(content: string, loc: SectionLoc): string {
76
+ return content.split("\n")
77
+ .slice(loc.bodyStartLineIdx, loc.bodyEndLineIdx)
78
+ .join("\n")
79
+ .replace(/^\s*\n+/, "")
80
+ .replace(/\n+\s*$/, "");
81
+ }
82
+
83
+ /**
84
+ * Insert `* [[<childTitle>]]` into the parent's `## Parent of` section.
85
+ * If the section is missing, append it at end-of-file. If the bullet is
86
+ * already present (idempotent retry path), return the unchanged content
87
+ * with a flag.
88
+ */
89
+ function patchParentParentOf(parentContent: string, childTitle: string): {
90
+ newContent: string;
91
+ alreadyPresent: boolean;
92
+ parentOfBody: string;
93
+ } {
94
+ const lines = parentContent.split("\n");
95
+ const sections = parseSections(parentContent);
96
+ const parentOf = findSection(sections, "Parent of");
97
+ const bullet = `* [[${childTitle}]]`;
98
+
99
+ if (!parentOf) {
100
+ // No Parent of section — append at end of file.
101
+ const sep = parentContent.endsWith("\n") ? "" : "\n";
102
+ const newContent = parentContent + sep + `\n## Parent of\n\n${bullet}\n`;
103
+ return { newContent, alreadyPresent: false, parentOfBody: bullet };
104
+ }
105
+
106
+ // Check existing bullets for an idempotent retry.
107
+ const sectionLines = lines.slice(parentOf.headingLineIdx, parentOf.bodyEndLineIdx);
108
+ const escaped = childTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
109
+ const existsRe = new RegExp(`^\\s*[-*]\\s+\\[\\[${escaped}(\\|[^\\]]+)?\\]\\]`, "i");
110
+ if (sectionLines.some((l) => existsRe.test(l))) {
111
+ return {
112
+ newContent: parentContent,
113
+ alreadyPresent: true,
114
+ parentOfBody: extractBody(parentContent, parentOf),
115
+ };
116
+ }
117
+
118
+ // Insert the bullet after existing content, trimming trailing blanks.
119
+ const newSectionLines = [...sectionLines];
120
+ while (newSectionLines.length > 1 && newSectionLines[newSectionLines.length - 1].trim() === "") newSectionLines.pop();
121
+ newSectionLines.push("", bullet, "");
122
+ const newContent = [
123
+ ...lines.slice(0, parentOf.headingLineIdx),
124
+ ...newSectionLines,
125
+ ...lines.slice(parentOf.bodyEndLineIdx),
126
+ ].join("\n");
127
+ const newSections = parseSections(newContent);
128
+ const newParentOf = findSection(newSections, "Parent of");
129
+ return {
130
+ newContent,
131
+ alreadyPresent: false,
132
+ parentOfBody: newParentOf ? extractBody(newContent, newParentOf) : "",
133
+ };
134
+ }
135
+
136
+ function fixesFromWarnings(warnings: LintWarning[], codes: string[]): LintFix[] {
137
+ const codeSet = new Set(codes);
138
+ return warnings.filter((w) => codeSet.has(w.code)).map((w) => ({
139
+ code: w.code,
140
+ line: w.line ?? null,
141
+ fix_suggestion: w.suggestion,
142
+ original_message: w.message,
143
+ }));
144
+ }
145
+
146
+ function json(value: unknown) {
147
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
148
+ }
149
+
150
+ /**
151
+ * Atomic create-and-link: writes a new child doc with the canonical
152
+ * scaffold (H1 + summary placeholder + Child of / Parent of / Associated
153
+ * with / Notes), and patches the parent's `## Parent of` to include the
154
+ * new child bullet. Collapses the typical 5-round-trip add-child flow.
155
+ *
156
+ * Pre-flight refuses if the parent doc is missing, the child path is
157
+ * occupied, or the title collides with another doc / contains
158
+ * non-slug-safe characters.
159
+ *
160
+ * Hard-gates internally on `multiple_child_of` (defensive — the scaffold
161
+ * itself can't trigger it). Caller-supplied `gate_on_warnings` applies
162
+ * to both the proposed child content and the proposed parent content.
163
+ *
164
+ * Failure mid-flight returns `{ error: "partial_write", child_written,
165
+ * parent_written, retry_hint }`. The retry path is idempotent: re-running
166
+ * with the same args detects an existing-and-byte-equal child + an
167
+ * already-present parent bullet and converges to the same state.
168
+ */
169
+ export async function createChild(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
170
+ const argErr = validateArgs(args, ARG_SPEC);
171
+ if (argErr) return json(argErr);
172
+ const parentPath = String(args.parent_path ?? "");
173
+ const titleRaw = String(args.title ?? "").trim();
174
+ const body = args.body !== undefined ? String(args.body) : "";
175
+ const summary = args.summary !== undefined ? String(args.summary).trim() : "";
176
+ const gateCodes = Array.isArray(args.gate_on_warnings)
177
+ ? (args.gate_on_warnings as unknown[]).filter((c): c is string => typeof c === "string")
178
+ : [];
179
+
180
+ if (!parentPath) return json({ error: "parent_path required" });
181
+ if (!titleRaw) return json({ error: "title required" });
182
+ validatePath(parentPath);
183
+
184
+ // Title must be non-empty after sanitization and not contain control chars.
185
+ if (!SLUG_SAFE.test(titleRaw)) return json({ error: "title_not_slug_safe", title: titleRaw, hint: "Use letters, numbers, spaces, hyphens, underscores, periods, or em-dash (—)." });
186
+
187
+ const childPath = args.child_path
188
+ ? String(args.child_path)
189
+ : (() => {
190
+ const dir = path.dirname(parentPath);
191
+ const fname = `${sanitizeFilename(titleRaw)}.md`;
192
+ return dir === "." ? fname : `${dir}/${fname}`;
193
+ })();
194
+ validatePath(childPath);
195
+
196
+ // SPRINT-055 (SIG-004): explicit child_path must be uppercase — mirrors
197
+ // write_doc's refusal. Title-derived paths go through sanitizeFilename
198
+ // which already uppercases, so this only fires when the caller passes
199
+ // child_path directly. Surface the corrected path so the caller can
200
+ // re-run without recomputing.
201
+ if (!isUppercaseFilename(childPath)) {
202
+ return json({
203
+ error: "filename_not_uppercase",
204
+ path: childPath,
205
+ suggested: normalizeFilenameInPath(childPath),
206
+ hint: "EMDEE filenames are all-caps ASCII. Re-run create_child with `child_path: <suggested>`, or omit child_path so it's derived from the title.",
207
+ });
208
+ }
209
+
210
+ const parentContent = await readVaultFile(ctx, parentPath);
211
+ if (parentContent === null) return json({ error: "parent_not_found", path: parentPath });
212
+ const parentTitle = deriveTitle(parentContent, parentPath);
213
+
214
+ const index = await loadVaultIndex(ctx);
215
+
216
+ // Pre-flight: title collision (excluding any pre-existing version of
217
+ // this exact child path, which is the idempotent-retry case).
218
+ const titleConflict = index.docs.find(
219
+ (d) => d.path !== childPath && d.title.toLowerCase() === titleRaw.toLowerCase()
220
+ );
221
+ if (titleConflict) {
222
+ return json({ error: "title_collision", path: titleConflict.path, title: titleConflict.title });
223
+ }
224
+
225
+ // Path-collision check — but allow idempotent retry against a
226
+ // byte-equal existing child. Builds the scaffold first, then compares.
227
+ const scaffold = [
228
+ `# ${titleRaw}`,
229
+ "",
230
+ `> ${summary || "_summary pending_"}`,
231
+ "",
232
+ "## Child of",
233
+ "",
234
+ `* [[${parentTitle}]]`,
235
+ "",
236
+ "## Parent of",
237
+ "",
238
+ "## Associated with",
239
+ "",
240
+ "## Notes",
241
+ "",
242
+ body.length > 0 ? body : "",
243
+ ].join("\n").replace(/\n+$/, "\n");
244
+
245
+ const existingChild = await readVaultFile(ctx, childPath);
246
+ if (existingChild !== null && existingChild !== scaffold) {
247
+ return json({ error: "child_path_exists", path: childPath });
248
+ }
249
+ const isRetry = existingChild === scaffold;
250
+
251
+ // Gate the proposed child + proposed parent content.
252
+ // Hard-gate child on multiple_child_of (impossible by construction, defensive).
253
+ const childWarnings = lintDocContent(scaffold).warnings;
254
+ const hardFailChild = childWarnings.filter((w) => w.code === "multiple_child_of");
255
+ if (hardFailChild.length > 0) {
256
+ return json({ error: "child_scaffold_invalid", fixes: fixesFromWarnings(childWarnings, ["multiple_child_of"]) });
257
+ }
258
+
259
+ const { newContent: newParentContent, alreadyPresent, parentOfBody } = patchParentParentOf(parentContent, titleRaw);
260
+
261
+ // Soft gate: child content first, then parent content, both against caller codes.
262
+ if (gateCodes.length > 0) {
263
+ const needsVault = gateCodes.some((c) => c === "asymmetric_parent_edge" || c === "asymmetric_child_edge" || c === "sibling_assoc_redundant");
264
+ const childVaultCtx = needsVault ? buildLintVaultContext(index, childPath) : undefined;
265
+ const childGate = evaluateLintGate(scaffold, gateCodes, childVaultCtx);
266
+ if (!childGate.ok) {
267
+ return json({ error: "lint_gate_failed", side: "child", fixes: childGate.fixes, original_warnings: childGate.original_warnings });
268
+ }
269
+ const parentVaultCtx = needsVault ? buildLintVaultContext(index, parentPath) : undefined;
270
+ const parentGate = evaluateLintGate(newParentContent, gateCodes, parentVaultCtx);
271
+ if (!parentGate.ok) {
272
+ return json({ error: "lint_gate_failed", side: "parent", fixes: parentGate.fixes, original_warnings: parentGate.original_warnings });
273
+ }
274
+ }
275
+
276
+ // Write child first (so a partial failure leaves a discoverable orphan
277
+ // rather than a dangling parent bullet). On retry against an
278
+ // already-present-and-byte-equal child, skip the write.
279
+ let childWritten = isRetry;
280
+ if (!isRetry) {
281
+ try {
282
+ await writeVaultFile(ctx, childPath, scaffold);
283
+ childWritten = true;
284
+ } catch (err) {
285
+ return json({
286
+ error: "partial_write",
287
+ child_written: false,
288
+ parent_written: false,
289
+ message: (err as Error).message,
290
+ });
291
+ }
292
+ }
293
+
294
+ let parentWritten = alreadyPresent;
295
+ if (!alreadyPresent) {
296
+ try {
297
+ await writeVaultFile(ctx, parentPath, newParentContent);
298
+ parentWritten = true;
299
+ } catch (err) {
300
+ return json({
301
+ error: "partial_write",
302
+ child_written: childWritten,
303
+ parent_written: false,
304
+ message: (err as Error).message,
305
+ retry_hint: "Re-run create_child with the same args; the child write is detected as byte-equal and the parent patch is idempotent (already-present bullet is skipped).",
306
+ });
307
+ }
308
+ }
309
+
310
+ return json({
311
+ ok: true,
312
+ child_path: childPath,
313
+ child_title: titleRaw,
314
+ parent_path: parentPath,
315
+ parent_updated: !alreadyPresent,
316
+ parent_of_content_hash: hashBody(parentOfBody),
317
+ idempotent_retry: isRetry,
318
+ });
319
+ }
@@ -0,0 +1,68 @@
1
+ import { deleteVaultFile, loadVaultIndex, readVaultFile, validatePath } from "./vault";
2
+ import type { ToolContext } from "./types";
3
+ import { validateArgs } from "./validate_args";
4
+
5
+ const ARG_SPEC = { allowed: ["path"], required: ["path"] } as const;
6
+
7
+ function json(value: unknown) {
8
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
9
+ }
10
+
11
+ /**
12
+ * Permanently remove a doc from the vault. DESTRUCTIVE — there is no undo.
13
+ *
14
+ * Surfaces useful context before deletion:
15
+ * - inbound_edges: docs that link TO this one (their wiki-links will break)
16
+ * - title_conflicts: other files sharing this doc's title (the deletion
17
+ * will resolve a title→path ambiguity in the indexer)
18
+ *
19
+ * The caller is expected to fix dangling references with patch_section
20
+ * afterwards if inbound_edges is non-empty.
21
+ */
22
+ export async function deleteDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
23
+ const argErr = validateArgs(args, ARG_SPEC);
24
+ if (argErr) return json(argErr);
25
+ const rel = String(args.path);
26
+ validatePath(rel);
27
+
28
+ const content = await readVaultFile(ctx, rel);
29
+ if (content === null) {
30
+ return { content: [{ type: "text", text: `not found: ${rel}` }] };
31
+ }
32
+
33
+ const index = await loadVaultIndex(ctx);
34
+ const target = index.docs.find((d) => d.path === rel);
35
+ const targetTitleLc = target?.title.toLowerCase();
36
+
37
+ const inbound = target
38
+ ? index.edges
39
+ .filter((e) => e.to === rel && e.from !== rel)
40
+ .map((e) => ({ from: e.from, kind: e.kind }))
41
+ : [];
42
+
43
+ const titleConflicts = targetTitleLc
44
+ ? index.docs
45
+ .filter((d) => d.path !== rel && d.title.toLowerCase() === targetTitleLc)
46
+ .map((d) => d.path)
47
+ : [];
48
+
49
+ await deleteVaultFile(ctx, rel);
50
+
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: JSON.stringify(
56
+ {
57
+ deleted: rel,
58
+ title: target?.title ?? null,
59
+ inbound_edges: inbound,
60
+ title_conflicts: titleConflicts,
61
+ },
62
+ null,
63
+ 2
64
+ ),
65
+ },
66
+ ],
67
+ };
68
+ }