@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,439 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
4
+ import { buildLintVaultContext } from "./lint_doc";
5
+ import { evaluateLintGate } from "./lint_gate";
6
+ import type { LintVaultContext } from "./lint";
7
+ import { resolveWikiLink } from "../../../core/resolveLink";
8
+ import type { ToolContext } from "./types";
9
+ import { validateArgs } from "./validate_args";
10
+
11
+ const ARG_SPEC = {
12
+ allowed: ["path", "new_parent_path", "old_parent_path", "position", "gate_on_warnings"],
13
+ required: ["path", "new_parent_path"],
14
+ } as const;
15
+
16
+ // SPRINT-054 (SIG-003): atomic reparenting. Replaces the 3-write manual dance
17
+ // (old parent's Parent of, new parent's Parent of, child's Child of) with
18
+ // one tool call that handles all three sides + their idempotency guards.
19
+
20
+ const H1_RE = /^#\s+(.+?)\s*$/m;
21
+ const H2_RE = /^##\s+(.+?)\s*$/;
22
+ const FENCE_RE = /^\s*(?:```|~~~)/;
23
+ const BULLET_LINK_RE = /^\s*[-*]\s+\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/i;
24
+
25
+ function deriveTitle(content: string, fallbackPath: string): string {
26
+ const m = content.match(H1_RE);
27
+ if (m) return m[1].trim();
28
+ return path.basename(fallbackPath, ".md");
29
+ }
30
+
31
+ function hashBody(body: string): string {
32
+ return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
33
+ }
34
+
35
+ interface SectionLoc {
36
+ heading: string;
37
+ headingLineIdx: number;
38
+ bodyStartLineIdx: number;
39
+ bodyEndLineIdx: number;
40
+ }
41
+
42
+ function parseSections(content: string): SectionLoc[] {
43
+ const lines = content.split("\n");
44
+ const sections: SectionLoc[] = [];
45
+ let inFence = false;
46
+ for (let i = 0; i < lines.length; i++) {
47
+ if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
48
+ if (inFence) continue;
49
+ const m = lines[i].match(H2_RE);
50
+ if (!m) continue;
51
+ if (sections.length > 0) sections[sections.length - 1].bodyEndLineIdx = i;
52
+ sections.push({
53
+ heading: m[1].trim(),
54
+ headingLineIdx: i,
55
+ bodyStartLineIdx: i + 1,
56
+ bodyEndLineIdx: lines.length,
57
+ });
58
+ }
59
+ return sections;
60
+ }
61
+
62
+ function findSection(sections: SectionLoc[], heading: string): SectionLoc | undefined {
63
+ const target = heading.trim().toLowerCase();
64
+ return sections.find((s) => s.heading.toLowerCase() === target);
65
+ }
66
+
67
+ function extractBody(content: string, loc: SectionLoc): string {
68
+ return content
69
+ .split("\n")
70
+ .slice(loc.bodyStartLineIdx, loc.bodyEndLineIdx)
71
+ .join("\n")
72
+ .replace(/^\s*\n+/, "")
73
+ .replace(/\n+\s*$/, "");
74
+ }
75
+
76
+ function extractBulletTitles(content: string, loc: SectionLoc): string[] {
77
+ const lines = content.split("\n").slice(loc.bodyStartLineIdx, loc.bodyEndLineIdx);
78
+ const titles: string[] = [];
79
+ for (const line of lines) {
80
+ const m = line.match(BULLET_LINK_RE);
81
+ if (m) titles.push(m[1].trim());
82
+ }
83
+ return titles;
84
+ }
85
+
86
+ function removeBulletByTitle(
87
+ parentContent: string,
88
+ sectionHeading: string,
89
+ title: string,
90
+ ): { newContent: string; removed: boolean; newSectionBody: string } {
91
+ const lines = parentContent.split("\n");
92
+ const sections = parseSections(parentContent);
93
+ const section = findSection(sections, sectionHeading);
94
+ if (!section) {
95
+ return { newContent: parentContent, removed: false, newSectionBody: "" };
96
+ }
97
+
98
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ const bulletRe = new RegExp(`^\\s*[-*]\\s+\\[\\[${escaped}(\\|[^\\]]+)?\\]\\]`, "i");
100
+
101
+ const before = lines.slice(0, section.bodyStartLineIdx);
102
+ const sectionLines = lines.slice(section.bodyStartLineIdx, section.bodyEndLineIdx);
103
+ const after = lines.slice(section.bodyEndLineIdx);
104
+
105
+ let removed = false;
106
+ const filtered: string[] = [];
107
+ for (const line of sectionLines) {
108
+ if (!removed && bulletRe.test(line)) {
109
+ removed = true;
110
+ continue;
111
+ }
112
+ filtered.push(line);
113
+ }
114
+
115
+ if (!removed) {
116
+ return {
117
+ newContent: parentContent,
118
+ removed: false,
119
+ newSectionBody: extractBody(parentContent, section),
120
+ };
121
+ }
122
+
123
+ // Collapse consecutive blank lines that the removal may have created.
124
+ const collapsed: string[] = [];
125
+ for (let i = 0; i < filtered.length; i++) {
126
+ const line = filtered[i];
127
+ const prev = collapsed[collapsed.length - 1];
128
+ if (line.trim() === "" && prev !== undefined && prev.trim() === "") continue;
129
+ collapsed.push(line);
130
+ }
131
+
132
+ const newLines = [...before, ...collapsed, ...after];
133
+ const newContent = newLines.join("\n");
134
+ const newSections = parseSections(newContent);
135
+ const newSec = findSection(newSections, sectionHeading);
136
+ return {
137
+ newContent,
138
+ removed: true,
139
+ newSectionBody: newSec ? extractBody(newContent, newSec) : "",
140
+ };
141
+ }
142
+
143
+ function insertBulletInParentOf(
144
+ parentContent: string,
145
+ childTitle: string,
146
+ position?: number,
147
+ ): { newContent: string; alreadyPresent: boolean; newSectionBody: string } {
148
+ const lines = parentContent.split("\n");
149
+ const sections = parseSections(parentContent);
150
+ const parentOf = findSection(sections, "Parent of");
151
+ const bullet = `* [[${childTitle}]]`;
152
+
153
+ if (!parentOf) {
154
+ const sep = parentContent.endsWith("\n") ? "" : "\n";
155
+ const newContent = parentContent + sep + `\n## Parent of\n\n${bullet}\n`;
156
+ return { newContent, alreadyPresent: false, newSectionBody: bullet };
157
+ }
158
+
159
+ const sectionLines = lines.slice(parentOf.headingLineIdx, parentOf.bodyEndLineIdx);
160
+ const escaped = childTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
161
+ const existsRe = new RegExp(`^\\s*[-*]\\s+\\[\\[${escaped}(\\|[^\\]]+)?\\]\\]`, "i");
162
+ if (sectionLines.some((l) => existsRe.test(l))) {
163
+ return {
164
+ newContent: parentContent,
165
+ alreadyPresent: true,
166
+ newSectionBody: extractBody(parentContent, parentOf),
167
+ };
168
+ }
169
+
170
+ const body = lines.slice(parentOf.bodyStartLineIdx, parentOf.bodyEndLineIdx);
171
+
172
+ if (position === undefined || position < 0) {
173
+ let endIdx = body.length;
174
+ while (endIdx > 0 && body[endIdx - 1].trim() === "") endIdx--;
175
+ const newBody = [...body.slice(0, endIdx), bullet, ...body.slice(endIdx)];
176
+ const newContent = [
177
+ ...lines.slice(0, parentOf.bodyStartLineIdx),
178
+ ...newBody,
179
+ ...lines.slice(parentOf.bodyEndLineIdx),
180
+ ].join("\n");
181
+ const newSections = parseSections(newContent);
182
+ const newSec = findSection(newSections, "Parent of");
183
+ return {
184
+ newContent,
185
+ alreadyPresent: false,
186
+ newSectionBody: newSec ? extractBody(newContent, newSec) : "",
187
+ };
188
+ }
189
+
190
+ // Position-based: insert at bullet position N (skipping non-bullet lines).
191
+ let bulletsSeen = 0;
192
+ let insertAt = body.length;
193
+ for (let i = 0; i < body.length; i++) {
194
+ if (BULLET_LINK_RE.test(body[i])) {
195
+ if (bulletsSeen === position) { insertAt = i; break; }
196
+ bulletsSeen++;
197
+ }
198
+ }
199
+ const newBody = [...body.slice(0, insertAt), bullet, ...body.slice(insertAt)];
200
+ const newContent = [
201
+ ...lines.slice(0, parentOf.bodyStartLineIdx),
202
+ ...newBody,
203
+ ...lines.slice(parentOf.bodyEndLineIdx),
204
+ ].join("\n");
205
+ const newSections = parseSections(newContent);
206
+ const newSec = findSection(newSections, "Parent of");
207
+ return {
208
+ newContent,
209
+ alreadyPresent: false,
210
+ newSectionBody: newSec ? extractBody(newContent, newSec) : "",
211
+ };
212
+ }
213
+
214
+ function rewriteChildOf(
215
+ childContent: string,
216
+ newParentTitle: string,
217
+ ): { newContent: string; newSectionBody: string } {
218
+ const lines = childContent.split("\n");
219
+ const sections = parseSections(childContent);
220
+ const childOf = findSection(sections, "Child of");
221
+ const newBullet = `* [[${newParentTitle}]]`;
222
+
223
+ if (!childOf) {
224
+ const sep = childContent.endsWith("\n") ? "" : "\n";
225
+ const newContent = childContent + sep + `\n## Child of\n\n${newBullet}\n`;
226
+ return { newContent, newSectionBody: newBullet };
227
+ }
228
+
229
+ const before = lines.slice(0, childOf.bodyStartLineIdx);
230
+ const after = lines.slice(childOf.bodyEndLineIdx);
231
+ const newBody = ["", newBullet, ""];
232
+ const newLines = [...before, ...newBody, ...after];
233
+ return { newContent: newLines.join("\n"), newSectionBody: newBullet };
234
+ }
235
+
236
+ function json(value: unknown) {
237
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
238
+ }
239
+
240
+ /**
241
+ * Atomic reparenting. Removes the child's bullet from old parent's
242
+ * `## Parent of`, adds it to new parent's, and rewrites the child's
243
+ * `## Child of` to declare the new parent.
244
+ *
245
+ * Requires:
246
+ * - child_path exists with a `## Child of` section
247
+ * - new_parent_path exists
248
+ * - If the child has more than one Child of bullet, `old_parent_path` MUST
249
+ * be provided to disambiguate which edge to break; otherwise refuses
250
+ * with `ambiguous_parent`.
251
+ * - If old_parent_path not provided, resolves it from the child's single
252
+ * Child of bullet via the indexer.
253
+ *
254
+ * Idempotent: if the child already declares ONLY new_parent_path as its
255
+ * parent AND new_parent already has the child bullet, returns ok with
256
+ * `*_updated: false` on all sides.
257
+ *
258
+ * Write order: child first (Child of update), then old parent (remove
259
+ * bullet), then new parent (add bullet). Partial failure leaves the graph
260
+ * asymmetric; retry is safe — every helper has idempotency built in.
261
+ */
262
+ export async function moveDoc(
263
+ ctx: ToolContext,
264
+ args: Record<string, unknown>,
265
+ ): Promise<unknown> {
266
+ const argErr = validateArgs(args, ARG_SPEC);
267
+ if (argErr) return json(argErr);
268
+ const childPath = String(args.path ?? "");
269
+ const newParentPath = String(args.new_parent_path ?? "");
270
+ const oldParentPathArg =
271
+ args.old_parent_path !== undefined ? String(args.old_parent_path) : "";
272
+ const position =
273
+ args.position !== undefined && typeof args.position === "number"
274
+ ? args.position
275
+ : undefined;
276
+ const gateCodes = Array.isArray(args.gate_on_warnings)
277
+ ? (args.gate_on_warnings as unknown[]).filter((c): c is string => typeof c === "string")
278
+ : [];
279
+
280
+ if (!childPath) return json({ error: "path required" });
281
+ if (!newParentPath) return json({ error: "new_parent_path required" });
282
+ if (childPath === newParentPath) {
283
+ return json({ error: "cannot_move_to_self", path: childPath });
284
+ }
285
+ validatePath(childPath);
286
+ validatePath(newParentPath);
287
+ if (oldParentPathArg) validatePath(oldParentPathArg);
288
+
289
+ const childContent = await readVaultFile(ctx, childPath);
290
+ if (childContent === null) return json({ error: "child_not_found", path: childPath });
291
+ const newParentContent = await readVaultFile(ctx, newParentPath);
292
+ if (newParentContent === null) {
293
+ return json({ error: "new_parent_not_found", path: newParentPath });
294
+ }
295
+
296
+ const childSections = parseSections(childContent);
297
+ const childOfLoc = findSection(childSections, "Child of");
298
+ const declaredParentTitles = childOfLoc
299
+ ? extractBulletTitles(childContent, childOfLoc)
300
+ : [];
301
+
302
+ const index = await loadVaultIndex(ctx);
303
+
304
+ const declaredParentPaths: string[] = [];
305
+ for (const title of declaredParentTitles) {
306
+ const resolved = resolveWikiLink(index, title, childPath);
307
+ if (resolved && resolved.path) declaredParentPaths.push(resolved.path);
308
+ }
309
+
310
+ let oldParentPath = oldParentPathArg;
311
+ if (!oldParentPath) {
312
+ if (declaredParentPaths.length === 0) {
313
+ return json({
314
+ error: "no_existing_parent",
315
+ path: childPath,
316
+ hint: "child has no resolvable Child of bullet — use create_child or patch_section to give it a parent first",
317
+ });
318
+ }
319
+ if (declaredParentPaths.length > 1) {
320
+ return json({
321
+ error: "ambiguous_parent",
322
+ path: childPath,
323
+ declared_parents: declaredParentPaths,
324
+ hint: "child has multiple Child of bullets — pass old_parent_path to disambiguate which edge to break",
325
+ });
326
+ }
327
+ oldParentPath = declaredParentPaths[0];
328
+ } else if (!declaredParentPaths.includes(oldParentPath)) {
329
+ return json({
330
+ error: "old_parent_not_declared",
331
+ path: childPath,
332
+ old_parent_path: oldParentPath,
333
+ declared_parents: declaredParentPaths,
334
+ hint: "old_parent_path must match one of the child's existing Child of bullets",
335
+ });
336
+ }
337
+
338
+ // Idempotency: child already declares only new parent.
339
+ if (oldParentPath === newParentPath && declaredParentPaths.length === 1) {
340
+ return json({
341
+ ok: true,
342
+ path: childPath,
343
+ new_parent_path: newParentPath,
344
+ old_parent_path: oldParentPath,
345
+ child_updated: false,
346
+ old_parent_updated: false,
347
+ new_parent_updated: false,
348
+ note: "child already declares new_parent as its only parent",
349
+ });
350
+ }
351
+
352
+ const oldParentContent = await readVaultFile(ctx, oldParentPath);
353
+ if (oldParentContent === null) {
354
+ return json({ error: "old_parent_not_found", path: oldParentPath });
355
+ }
356
+
357
+ const childTitle = deriveTitle(childContent, childPath);
358
+ const newParentTitle = deriveTitle(newParentContent, newParentPath);
359
+
360
+ const childPatch = rewriteChildOf(childContent, newParentTitle);
361
+ const oldParentPatch = removeBulletByTitle(oldParentContent, "Parent of", childTitle);
362
+ const newParentPatch = insertBulletInParentOf(newParentContent, childTitle, position);
363
+
364
+ if (gateCodes.length > 0) {
365
+ const childCtx: LintVaultContext = buildLintVaultContext(index, childPath);
366
+ const childGate = evaluateLintGate(childPatch.newContent, gateCodes, childCtx);
367
+ if (!childGate.ok) {
368
+ return json({
369
+ error: "lint_gate_failed",
370
+ side: "child",
371
+ fixes: childGate.fixes,
372
+ original_warnings: childGate.original_warnings,
373
+ });
374
+ }
375
+ }
376
+
377
+ const prevChildBody = childOfLoc ? extractBody(childContent, childOfLoc) : "";
378
+ const childChanged = childPatch.newSectionBody !== prevChildBody;
379
+
380
+ let childWritten = false;
381
+ if (childChanged) {
382
+ try {
383
+ await writeVaultFile(ctx, childPath, childPatch.newContent);
384
+ childWritten = true;
385
+ } catch (err) {
386
+ return json({
387
+ error: "partial_write",
388
+ stage: "child",
389
+ message: (err as Error).message,
390
+ });
391
+ }
392
+ }
393
+
394
+ let oldParentWritten = false;
395
+ if (oldParentPatch.removed) {
396
+ try {
397
+ await writeVaultFile(ctx, oldParentPath, oldParentPatch.newContent);
398
+ oldParentWritten = true;
399
+ } catch (err) {
400
+ return json({
401
+ error: "partial_write",
402
+ stage: "old_parent",
403
+ child_written: childWritten,
404
+ message: (err as Error).message,
405
+ retry_hint: "Re-run move_doc; the child write is idempotent.",
406
+ });
407
+ }
408
+ }
409
+
410
+ let newParentWritten = false;
411
+ if (!newParentPatch.alreadyPresent) {
412
+ try {
413
+ await writeVaultFile(ctx, newParentPath, newParentPatch.newContent);
414
+ newParentWritten = true;
415
+ } catch (err) {
416
+ return json({
417
+ error: "partial_write",
418
+ stage: "new_parent",
419
+ child_written: childWritten,
420
+ old_parent_written: oldParentWritten,
421
+ message: (err as Error).message,
422
+ retry_hint: "Re-run move_doc; the child + old-parent writes are idempotent.",
423
+ });
424
+ }
425
+ }
426
+
427
+ return json({
428
+ ok: true,
429
+ path: childPath,
430
+ new_parent_path: newParentPath,
431
+ old_parent_path: oldParentPath,
432
+ child_updated: childWritten,
433
+ old_parent_updated: oldParentWritten,
434
+ new_parent_updated: newParentWritten,
435
+ child_of_hash: hashBody(childPatch.newSectionBody),
436
+ new_parent_of_hash: hashBody(newParentPatch.newSectionBody),
437
+ old_parent_of_hash: hashBody(oldParentPatch.newSectionBody),
438
+ });
439
+ }
@@ -0,0 +1,145 @@
1
+ import { createHash } from "node:crypto";
2
+ import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
3
+ import { evaluateLintGate } from "./lint_gate";
4
+ import { buildLintVaultContext } from "./lint_doc";
5
+ import type { ToolContext } from "./types";
6
+ import { validateArgs } from "./validate_args";
7
+
8
+ const CROSS_DOC_CODES = new Set([
9
+ "asymmetric_parent_edge",
10
+ "asymmetric_child_edge",
11
+ "sibling_assoc_redundant",
12
+ ]);
13
+
14
+ const ARG_SPEC = {
15
+ allowed: ["path", "body", "expected_content_hash", "gate_on_warnings"],
16
+ required: ["path", "body", "expected_content_hash"],
17
+ } as const;
18
+
19
+ function parseGateCodes(raw: unknown): string[] {
20
+ if (!Array.isArray(raw)) return [];
21
+ return raw.filter((c): c is string => typeof c === "string");
22
+ }
23
+
24
+ function json(value: unknown) {
25
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
26
+ }
27
+
28
+ const FENCE_RE = /^\s*(?:```|~~~)/;
29
+ const H1_RE = /^#\s+(.+?)\s*$/;
30
+ const H2_RE = /^##\s+(.+?)\s*$/;
31
+
32
+ interface PreambleLoc {
33
+ h1LineIdx: number;
34
+ bodyStartLineIdx: number;
35
+ bodyEndLineIdx: number;
36
+ }
37
+
38
+ /**
39
+ * Find the preamble region: the lines between the H1 and the first H2.
40
+ * Returns null if the doc has no H1.
41
+ *
42
+ * The blockquote summary lives inside this region, so callers patching the
43
+ * preamble are also replacing the summary — that's the point. Most docs only
44
+ * have a blockquote + a few intro paragraphs here; load-bearing wiki-links
45
+ * occasionally land in those paragraphs (see SPRINT-012's INSTRUCTIONS.md /
46
+ * VAULT.md straggler), which `patch_section` can't reach.
47
+ */
48
+ function findPreamble(content: string): PreambleLoc | null {
49
+ const lines = content.split("\n");
50
+ let inFence = false;
51
+ let h1Idx = -1;
52
+ for (let i = 0; i < lines.length; i++) {
53
+ if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
54
+ if (inFence) continue;
55
+ if (H1_RE.test(lines[i])) { h1Idx = i; break; }
56
+ }
57
+ if (h1Idx === -1) return null;
58
+
59
+ let firstH2Idx = lines.length;
60
+ inFence = false;
61
+ for (let i = h1Idx + 1; i < lines.length; i++) {
62
+ if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
63
+ if (inFence) continue;
64
+ if (H2_RE.test(lines[i])) { firstH2Idx = i; break; }
65
+ }
66
+ return { h1LineIdx: h1Idx, bodyStartLineIdx: h1Idx + 1, bodyEndLineIdx: firstH2Idx };
67
+ }
68
+
69
+ export function extractPreamble(content: string): { body: string; content_hash: string } | null {
70
+ const loc = findPreamble(content);
71
+ if (!loc) return null;
72
+ const body = content
73
+ .split("\n")
74
+ .slice(loc.bodyStartLineIdx, loc.bodyEndLineIdx)
75
+ .join("\n")
76
+ .replace(/^\s*\n+/, "")
77
+ .replace(/\n+\s*$/, "");
78
+ return { body, content_hash: hashBody(body) };
79
+ }
80
+
81
+ function hashBody(body: string): string {
82
+ return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
83
+ }
84
+
85
+ /**
86
+ * Replace everything between the H1 and the first H2 (the "preamble" region:
87
+ * blockquote summary + any intro paragraphs). Version-guarded with
88
+ * `expected_content_hash` from a recent `get_doc` call.
89
+ *
90
+ * The body argument is the FULL new preamble — typically a blockquote line
91
+ * plus one or two paragraphs. The H1 itself is not touched. To rename the
92
+ * doc's title, use `rename_doc`.
93
+ */
94
+ export async function patchPreamble(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
95
+ const argErr = validateArgs(args, ARG_SPEC);
96
+ if (argErr) return json(argErr);
97
+ const rel = String(args.path);
98
+ validatePath(rel);
99
+ const body = String(args.body ?? "");
100
+ const expected = String(args.expected_content_hash ?? "");
101
+ if (!expected) throw new Error("expected_content_hash required");
102
+
103
+ const content = await readVaultFile(ctx, rel);
104
+ if (content === null) return json({ error: "doc_not_found", path: rel });
105
+
106
+ const loc = findPreamble(content);
107
+ if (!loc) return json({ error: "no_h1", message: "Doc has no H1 so there is no preamble region to patch." });
108
+
109
+ const lines = content.split("\n");
110
+ const currentBody = lines
111
+ .slice(loc.bodyStartLineIdx, loc.bodyEndLineIdx)
112
+ .join("\n")
113
+ .replace(/^\s*\n+/, "")
114
+ .replace(/\n+\s*$/, "");
115
+ const currentHash = hashBody(currentBody);
116
+ if (currentHash !== expected) {
117
+ return json({
118
+ error: "version_conflict",
119
+ expected_content_hash: expected,
120
+ actual_content_hash: currentHash,
121
+ message: "Preamble was modified since you last read it. Call get_doc again and reconcile.",
122
+ });
123
+ }
124
+
125
+ const newContent = [
126
+ ...lines.slice(0, loc.bodyStartLineIdx),
127
+ "",
128
+ ...body.split("\n"),
129
+ "",
130
+ ...lines.slice(loc.bodyEndLineIdx),
131
+ ].join("\n");
132
+
133
+ const gateCodes = parseGateCodes(args.gate_on_warnings);
134
+ if (gateCodes.length > 0) {
135
+ const needsVault = gateCodes.some((c) => CROSS_DOC_CODES.has(c));
136
+ const vaultCtx = needsVault ? buildLintVaultContext(await loadVaultIndex(ctx), rel) : undefined;
137
+ const gate = evaluateLintGate(newContent, gateCodes, vaultCtx);
138
+ if (!gate.ok) {
139
+ return json({ error: "lint_gate_failed", fixes: gate.fixes, original_warnings: gate.original_warnings });
140
+ }
141
+ }
142
+
143
+ await writeVaultFile(ctx, rel, newContent);
144
+ return json({ ok: true, content_hash: hashBody(body.trim()) });
145
+ }
@@ -0,0 +1,113 @@
1
+ import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
2
+ import { lintDocContent } from "./lint";
3
+ import { evaluateLintGate } from "./lint_gate";
4
+ import { buildLintVaultContext } from "./lint_doc";
5
+ import {
6
+ parseSections,
7
+ extractBody,
8
+ hashBody,
9
+ sectionId,
10
+ resolveSection,
11
+ type SectionLoc,
12
+ } from "./sections";
13
+ import type { ToolContext } from "./types";
14
+ import { validateArgs } from "./validate_args";
15
+
16
+ const CROSS_DOC_CODES = new Set([
17
+ "asymmetric_parent_edge",
18
+ "asymmetric_child_edge",
19
+ "sibling_assoc_redundant",
20
+ ]);
21
+
22
+ const ARG_SPEC = {
23
+ allowed: ["path", "heading", "section_id", "body", "expected_content_hash", "gate_on_warnings"],
24
+ required: ["path", "body", "expected_content_hash"],
25
+ } as const;
26
+
27
+ function parseGateCodes(raw: unknown): string[] {
28
+ if (!Array.isArray(raw)) return [];
29
+ return raw.filter((c): c is string => typeof c === "string");
30
+ }
31
+
32
+ function json(value: unknown) {
33
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
34
+ }
35
+
36
+ export async function patchSection(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
37
+ const argErr = validateArgs(args, ARG_SPEC);
38
+ if (argErr) return json(argErr);
39
+ const rel = String(args.path);
40
+ validatePath(rel);
41
+ const headingArg = args.heading !== undefined ? String(args.heading).trim() : "";
42
+ const sectionIdArg = args.section_id !== undefined ? String(args.section_id).trim() : "";
43
+ const body = String(args.body ?? "");
44
+ const expected = String(args.expected_content_hash ?? "");
45
+ if (!headingArg && !sectionIdArg) throw new Error("heading or section_id required");
46
+ if (!expected) throw new Error("expected_content_hash required");
47
+
48
+ const content = await readVaultFile(ctx, rel);
49
+ if (content === null) return json({ error: "doc_not_found", path: rel });
50
+
51
+ const sections = parseSections(content);
52
+ const resolved = resolveSection(sections, sectionIdArg, headingArg);
53
+ if (resolved.kind === "mismatch") {
54
+ return json({
55
+ error: "section_id_heading_mismatch",
56
+ section_id_resolves_to: resolved.section_id_resolves_to,
57
+ heading_resolves_to: resolved.heading_resolves_to,
58
+ });
59
+ }
60
+ if (resolved.kind === "not_found") {
61
+ return json({
62
+ error: "section_not_found",
63
+ heading: headingArg || undefined,
64
+ section_id: sectionIdArg || undefined,
65
+ available: resolved.available,
66
+ });
67
+ }
68
+ const target: SectionLoc = resolved.loc;
69
+
70
+ const currentBody = extractBody(content, target);
71
+ const currentHash = hashBody(currentBody);
72
+ if (currentHash !== expected) {
73
+ return json({ error: "version_conflict", heading: target.heading, expected_content_hash: expected, actual_content_hash: currentHash, message: "Section was modified since you last read it. Call get_doc again and reconcile." });
74
+ }
75
+
76
+ const lines = content.split("\n");
77
+ const newContent = [
78
+ ...lines.slice(0, target.headingLineIdx + 1),
79
+ "",
80
+ ...body.split("\n"),
81
+ "",
82
+ ...lines.slice(target.bodyEndLineIdx),
83
+ ].join("\n");
84
+
85
+ const gateCodes = parseGateCodes(args.gate_on_warnings);
86
+ if (gateCodes.length > 0) {
87
+ const needsVault = gateCodes.some((c) => CROSS_DOC_CODES.has(c));
88
+ const vaultCtx = needsVault ? buildLintVaultContext(await loadVaultIndex(ctx), rel) : undefined;
89
+ const gate = evaluateLintGate(newContent, gateCodes, vaultCtx);
90
+ if (!gate.ok) {
91
+ return json({ error: "lint_gate_failed", fixes: gate.fixes, original_warnings: gate.original_warnings });
92
+ }
93
+ }
94
+
95
+ await writeVaultFile(ctx, rel, newContent);
96
+
97
+ // Re-derive ordinal under the new content so the returned section_id
98
+ // remains stable for chained edits (the section may have shifted
99
+ // position if the body insertion grew it). In practice patch_section
100
+ // never reshuffles H2 order — included defensively.
101
+ const newSections = parseSections(newContent);
102
+ const newIdx = newSections.findIndex((s) => s.heading === target!.heading);
103
+ const newId = newIdx >= 0 ? sectionId(target!.heading, newIdx) : undefined;
104
+
105
+ const lint = lintDocContent(newContent);
106
+ const payload: Record<string, unknown> = {
107
+ ok: true,
108
+ content_hash: hashBody(body.trim()),
109
+ section_id: newId,
110
+ };
111
+ if (lint.warnings.length > 0) payload.warnings = lint.warnings;
112
+ return json(payload);
113
+ }