@velvetmonkey/flywheel-crank 0.10.1 → 0.11.2

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 (2) hide show
  1. package/dist/index.js +456 -43
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -141,42 +141,90 @@ function findSection(content, sectionName) {
141
141
  contentStartLine
142
142
  };
143
143
  }
144
+ function isInsideCodeBlock(lines, currentIndex) {
145
+ let fenceCount = 0;
146
+ for (let i = 0; i < currentIndex; i++) {
147
+ if (lines[i].trim().startsWith("```")) {
148
+ fenceCount++;
149
+ }
150
+ }
151
+ return fenceCount % 2 === 1;
152
+ }
153
+ function isStructuredLine(line) {
154
+ const trimmed = line.trimStart();
155
+ return trimmed.startsWith("|") || // Table row
156
+ trimmed.startsWith(">") || // Blockquote
157
+ trimmed.startsWith("```") || // Code fence
158
+ /^-{3,}$/.test(trimmed) || // Horizontal rule (dashes)
159
+ /^\*{3,}$/.test(trimmed) || // Horizontal rule (asterisks)
160
+ /^_{3,}$/.test(trimmed);
161
+ }
162
+ function isPreformattedList(content) {
163
+ const trimmed = content.trim();
164
+ if (!trimmed)
165
+ return false;
166
+ const firstLine = trimmed.split("\n")[0];
167
+ return /^[-*+]\s/.test(firstLine) || // Bullet
168
+ /^\d+\.\s/.test(firstLine) || // Numbered
169
+ /^[-*+]\s*\[[ xX]\]/.test(firstLine);
170
+ }
144
171
  function formatContent(content, format) {
145
172
  const trimmed = content.trim();
146
173
  switch (format) {
147
174
  case "plain":
148
175
  return trimmed;
149
176
  case "bullet": {
177
+ if (isPreformattedList(trimmed)) {
178
+ return trimmed;
179
+ }
150
180
  const lines = trimmed.split("\n");
151
181
  return lines.map((line, i) => {
152
182
  if (i === 0)
153
183
  return `- ${line}`;
154
184
  if (line === "")
155
185
  return "";
186
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
187
+ return line;
188
+ }
156
189
  return ` ${line}`;
157
190
  }).join("\n");
158
191
  }
159
192
  case "task": {
193
+ if (isPreformattedList(trimmed)) {
194
+ return trimmed;
195
+ }
160
196
  const lines = trimmed.split("\n");
161
197
  return lines.map((line, i) => {
162
198
  if (i === 0)
163
199
  return `- [ ] ${line}`;
164
200
  if (line === "")
165
201
  return "";
202
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
203
+ return line;
204
+ }
166
205
  return ` ${line}`;
167
206
  }).join("\n");
168
207
  }
169
208
  case "numbered": {
209
+ if (isPreformattedList(trimmed)) {
210
+ return trimmed;
211
+ }
170
212
  const lines = trimmed.split("\n");
171
213
  return lines.map((line, i) => {
172
214
  if (i === 0)
173
215
  return `1. ${line}`;
174
216
  if (line === "")
175
217
  return "";
218
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
219
+ return line;
220
+ }
176
221
  return ` ${line}`;
177
222
  }).join("\n");
178
223
  }
179
224
  case "timestamp-bullet": {
225
+ if (isPreformattedList(trimmed)) {
226
+ return trimmed;
227
+ }
180
228
  const now = /* @__PURE__ */ new Date();
181
229
  const hours = String(now.getHours()).padStart(2, "0");
182
230
  const minutes = String(now.getMinutes()).padStart(2, "0");
@@ -188,6 +236,9 @@ function formatContent(content, format) {
188
236
  return `${prefix}${line}`;
189
237
  if (line === "")
190
238
  return "";
239
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
240
+ return line;
241
+ }
191
242
  return `${indent}${line}`;
192
243
  }).join("\n");
193
244
  }
@@ -231,7 +282,13 @@ function insertInSection(content, section, newContent, position, options) {
231
282
  break;
232
283
  }
233
284
  if (indent) {
234
- const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
285
+ const contentLines = formattedContent.split("\n");
286
+ const indentedContent = contentLines.map((line, i) => {
287
+ if (line === "" || isInsideCodeBlock(contentLines, i) || isStructuredLine(line)) {
288
+ return line;
289
+ }
290
+ return indent + line;
291
+ }).join("\n");
235
292
  lines.splice(section.contentStartLine, 0, indentedContent);
236
293
  } else {
237
294
  lines.splice(section.contentStartLine, 0, formattedContent);
@@ -250,7 +307,13 @@ function insertInSection(content, section, newContent, position, options) {
250
307
  if (lastContentLineIdx >= section.contentStartLine && isEmptyPlaceholder(lines[lastContentLineIdx])) {
251
308
  if (options?.preserveListNesting) {
252
309
  const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
253
- const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
310
+ const contentLines = formattedContent.split("\n");
311
+ const indentedContent = contentLines.map((line, i) => {
312
+ if (line === "" || isInsideCodeBlock(contentLines, i) || isStructuredLine(line)) {
313
+ return line;
314
+ }
315
+ return indent + line;
316
+ }).join("\n");
254
317
  lines[lastContentLineIdx] = indentedContent;
255
318
  } else {
256
319
  lines[lastContentLineIdx] = formattedContent;
@@ -269,7 +332,13 @@ function insertInSection(content, section, newContent, position, options) {
269
332
  }
270
333
  if (options?.preserveListNesting) {
271
334
  const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
272
- const indentedContent = formattedContent.split("\n").map((line) => indent + line).join("\n");
335
+ const contentLines = formattedContent.split("\n");
336
+ const indentedContent = contentLines.map((line, i) => {
337
+ if (line === "" || isInsideCodeBlock(contentLines, i) || isStructuredLine(line)) {
338
+ return line;
339
+ }
340
+ return indent + line;
341
+ }).join("\n");
273
342
  lines.splice(insertLine, 0, indentedContent);
274
343
  } else {
275
344
  lines.splice(insertLine, 0, formattedContent);
@@ -454,9 +523,236 @@ function replaceInSection(content, section, search, replacement, mode = "first",
454
523
  };
455
524
  }
456
525
 
526
+ // src/core/validator.ts
527
+ var TIMESTAMP_PATTERN = /^\*\*\d{2}:\d{2}\*\*/;
528
+ var NON_MARKDOWN_BULLET_PATTERNS = [
529
+ /^[\s]*\u2022/,
530
+ // • (bullet)
531
+ /^[\s]*\u25E6/,
532
+ // ◦ (hollow bullet)
533
+ /^[\s]*\u25AA/,
534
+ // ▪ (small square)
535
+ /^[\s]*\u25AB/,
536
+ // ▫ (hollow small square)
537
+ /^[\s]*\u2023/,
538
+ // ‣ (triangular bullet)
539
+ /^[\s]*\u2043/
540
+ // ⁃ (hyphen bullet)
541
+ ];
542
+ var EMBEDDED_HEADING_PATTERN = /^#{1,6}\s+/m;
543
+ function validateInput(content, format) {
544
+ const warnings = [];
545
+ if (format === "timestamp-bullet" && TIMESTAMP_PATTERN.test(content.trim())) {
546
+ warnings.push({
547
+ type: "double-timestamp",
548
+ message: "Content already contains a timestamp prefix, and format is timestamp-bullet",
549
+ suggestion: 'Use format: "plain" or "bullet" instead to avoid duplicate timestamps'
550
+ });
551
+ }
552
+ const lines = content.split("\n");
553
+ for (const line of lines) {
554
+ for (const pattern of NON_MARKDOWN_BULLET_PATTERNS) {
555
+ if (pattern.test(line)) {
556
+ warnings.push({
557
+ type: "non-markdown-bullets",
558
+ message: `Non-markdown bullet character detected: "${line.trim().charAt(0)}"`,
559
+ suggestion: "Use markdown bullets (-) for proper rendering"
560
+ });
561
+ break;
562
+ }
563
+ }
564
+ }
565
+ if (EMBEDDED_HEADING_PATTERN.test(content)) {
566
+ warnings.push({
567
+ type: "embedded-heading",
568
+ message: "Content contains markdown heading syntax (##)",
569
+ suggestion: "Use bold (**text**) instead of headings inside list items"
570
+ });
571
+ }
572
+ const fenceCount = (content.match(/^```/gm) || []).length;
573
+ if (fenceCount % 2 !== 0) {
574
+ warnings.push({
575
+ type: "orphaned-fence",
576
+ message: "Odd number of code fence markers (```) detected",
577
+ suggestion: "Ensure code blocks are properly closed"
578
+ });
579
+ }
580
+ return {
581
+ isValid: warnings.length === 0,
582
+ warnings
583
+ };
584
+ }
585
+ function normalizeInput(content, format) {
586
+ let normalized = content;
587
+ const changes = [];
588
+ if (format === "timestamp-bullet" && TIMESTAMP_PATTERN.test(normalized.trim())) {
589
+ normalized = normalized.trim().replace(TIMESTAMP_PATTERN, "").trim();
590
+ changes.push("Removed duplicate timestamp prefix");
591
+ }
592
+ const lines = normalized.split("\n");
593
+ let bulletReplaced = false;
594
+ const normalizedLines = lines.map((line) => {
595
+ for (const pattern of NON_MARKDOWN_BULLET_PATTERNS) {
596
+ if (pattern.test(line)) {
597
+ bulletReplaced = true;
598
+ return line.replace(/^([\s]*)[•◦▪▫‣⁃]\s*/, "$1- ");
599
+ }
600
+ }
601
+ return line;
602
+ });
603
+ if (bulletReplaced) {
604
+ normalized = normalizedLines.join("\n");
605
+ changes.push('Replaced non-markdown bullets with "-"');
606
+ }
607
+ const trimmed = normalized.replace(/\n{3,}/g, "\n\n");
608
+ if (trimmed !== normalized) {
609
+ normalized = trimmed;
610
+ changes.push("Trimmed excessive blank lines");
611
+ }
612
+ return {
613
+ content: normalized,
614
+ normalized: changes.length > 0,
615
+ changes
616
+ };
617
+ }
618
+ function validateOutput(formatted) {
619
+ const issues = [];
620
+ const tableRows = formatted.match(/^\s*\|.*\|/gm);
621
+ if (tableRows && tableRows.length > 1) {
622
+ const pipeCounts = tableRows.map((row) => (row.match(/\|/g) || []).length);
623
+ const firstPipeCount = pipeCounts[0];
624
+ const hasInconsistentPipes = pipeCounts.some((count) => count !== firstPipeCount);
625
+ if (hasInconsistentPipes) {
626
+ issues.push({
627
+ type: "broken-table",
628
+ severity: "error",
629
+ message: "Table rows have inconsistent pipe counts - table alignment may be broken"
630
+ });
631
+ }
632
+ }
633
+ const fenceCount = (formatted.match(/^```/gm) || []).length;
634
+ if (fenceCount % 2 !== 0) {
635
+ issues.push({
636
+ type: "orphaned-fence",
637
+ severity: "error",
638
+ message: "Odd number of code fence markers - code block may be unclosed"
639
+ });
640
+ }
641
+ const indentedFences = formatted.match(/^[ \t]+```/gm);
642
+ if (indentedFences && indentedFences.length > 0) {
643
+ const lines = formatted.split("\n");
644
+ for (let i = 0; i < lines.length; i++) {
645
+ if (/^[ \t]+```/.test(lines[i])) {
646
+ issues.push({
647
+ type: "indented-fence",
648
+ severity: "warning",
649
+ message: "Code fence marker is indented - this may break the code block",
650
+ line: i + 1
651
+ });
652
+ }
653
+ }
654
+ }
655
+ const blockquoteLines = formatted.match(/^[ \t]*>/gm);
656
+ if (blockquoteLines) {
657
+ const lines = formatted.split("\n");
658
+ let inBlockquote = false;
659
+ for (let i = 0; i < lines.length; i++) {
660
+ const line = lines[i];
661
+ const isBlockquoteLine = /^[ \t]*>/.test(line);
662
+ if (isBlockquoteLine) {
663
+ inBlockquote = true;
664
+ } else if (inBlockquote && line.trim() !== "" && !/^[ \t]*>/.test(line)) {
665
+ if (/^[ \t]+[^-*>\d]/.test(line)) {
666
+ issues.push({
667
+ type: "broken-blockquote",
668
+ severity: "warning",
669
+ message: "Blockquote structure may be broken - continuation line not prefixed with >",
670
+ line: i + 1
671
+ });
672
+ }
673
+ inBlockquote = false;
674
+ } else if (line.trim() === "") {
675
+ inBlockquote = false;
676
+ }
677
+ }
678
+ }
679
+ return {
680
+ valid: issues.filter((i) => i.severity === "error").length === 0,
681
+ issues
682
+ };
683
+ }
684
+ function runValidationPipeline(content, format, options = {}) {
685
+ const {
686
+ validate = true,
687
+ normalize = true,
688
+ guardrails = "warn"
689
+ } = options;
690
+ let processedContent = content;
691
+ let inputWarnings = [];
692
+ let normalizationChanges = [];
693
+ let outputIssues = [];
694
+ let blocked = false;
695
+ let blockReason;
696
+ if (validate) {
697
+ const inputResult = validateInput(content, format);
698
+ inputWarnings = inputResult.warnings;
699
+ }
700
+ if (normalize) {
701
+ const normResult = normalizeInput(processedContent, format);
702
+ processedContent = normResult.content;
703
+ normalizationChanges = normResult.changes;
704
+ }
705
+ if (guardrails !== "off") {
706
+ const outputResult = validateOutput(processedContent);
707
+ outputIssues = outputResult.issues;
708
+ if (guardrails === "strict" && !outputResult.valid) {
709
+ blocked = true;
710
+ const errors = outputIssues.filter((i) => i.severity === "error");
711
+ blockReason = `Output validation failed: ${errors.map((e) => e.message).join("; ")}`;
712
+ }
713
+ }
714
+ return {
715
+ content: processedContent,
716
+ inputWarnings,
717
+ outputIssues,
718
+ normalizationChanges,
719
+ blocked,
720
+ blockReason
721
+ };
722
+ }
723
+
457
724
  // src/core/git.ts
458
725
  import { simpleGit, CheckRepoActions } from "simple-git";
459
726
  import path2 from "path";
727
+ import fs2 from "fs/promises";
728
+ var LAST_COMMIT_FILE = ".claude/last-crank-commit.json";
729
+ async function saveLastCrankCommit(vaultPath2, hash, message) {
730
+ const filePath = path2.join(vaultPath2, LAST_COMMIT_FILE);
731
+ const dirPath = path2.dirname(filePath);
732
+ await fs2.mkdir(dirPath, { recursive: true });
733
+ const data = {
734
+ hash,
735
+ message,
736
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
737
+ };
738
+ await fs2.writeFile(filePath, JSON.stringify(data, null, 2));
739
+ }
740
+ async function getLastCrankCommit(vaultPath2) {
741
+ try {
742
+ const filePath = path2.join(vaultPath2, LAST_COMMIT_FILE);
743
+ const content = await fs2.readFile(filePath, "utf-8");
744
+ return JSON.parse(content);
745
+ } catch {
746
+ return null;
747
+ }
748
+ }
749
+ async function clearLastCrankCommit(vaultPath2) {
750
+ try {
751
+ const filePath = path2.join(vaultPath2, LAST_COMMIT_FILE);
752
+ await fs2.unlink(filePath);
753
+ } catch {
754
+ }
755
+ }
460
756
  async function isGitRepo(vaultPath2) {
461
757
  try {
462
758
  const git = simpleGit(vaultPath2);
@@ -480,6 +776,9 @@ async function commitChange(vaultPath2, filePath, messagePrefix) {
480
776
  const fileName = path2.basename(filePath);
481
777
  const commitMessage = `${messagePrefix} Update ${fileName}`;
482
778
  const result = await git.commit(commitMessage);
779
+ if (result.commit) {
780
+ await saveLastCrankCommit(vaultPath2, result.commit, commitMessage);
781
+ }
483
782
  return {
484
783
  success: true,
485
784
  hash: result.commit
@@ -1755,7 +2054,7 @@ function suggestRelatedLinks(content, options = {}) {
1755
2054
  }
1756
2055
 
1757
2056
  // src/tools/mutations.ts
1758
- import fs2 from "fs/promises";
2057
+ import fs3 from "fs/promises";
1759
2058
  import path5 from "path";
1760
2059
  function registerMutationTools(server2, vaultPath2) {
1761
2060
  server2.tool(
@@ -1771,13 +2070,16 @@ function registerMutationTools(server2, vaultPath2) {
1771
2070
  skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
1772
2071
  preserveListNesting: z.boolean().default(true).describe("Detect and preserve the indentation level of surrounding list items. Set false to disable."),
1773
2072
  suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1774
- maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)")
2073
+ maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
2074
+ validate: z.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
2075
+ normalize: z.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
2076
+ guardrails: z.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables')
1775
2077
  },
1776
- async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks, maxSuggestions }) => {
2078
+ async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails }) => {
1777
2079
  try {
1778
2080
  const fullPath = path5.join(vaultPath2, notePath);
1779
2081
  try {
1780
- await fs2.access(fullPath);
2082
+ await fs3.access(fullPath);
1781
2083
  } catch {
1782
2084
  const result2 = {
1783
2085
  success: false,
@@ -1791,16 +2093,42 @@ function registerMutationTools(server2, vaultPath2) {
1791
2093
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
1792
2094
  const sectionBoundary = findSection(fileContent, section);
1793
2095
  if (!sectionBoundary) {
2096
+ const headings = extractHeadings(fileContent);
2097
+ let message;
2098
+ if (headings.length === 0) {
2099
+ message = `Section '${section}' not found. This file has no headings. Use vault_append_to_note for files without section structure.`;
2100
+ } else {
2101
+ const availableSections = headings.map((h) => h.text).join(", ");
2102
+ message = `Section '${section}' not found. Available sections: ${availableSections}`;
2103
+ }
1794
2104
  const result2 = {
1795
2105
  success: false,
1796
- message: `Section not found: ${section}`,
2106
+ message,
2107
+ path: notePath,
2108
+ tokensEstimate: 0
2109
+ };
2110
+ result2.tokensEstimate = estimateTokens(result2);
2111
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2112
+ }
2113
+ const validationResult = runValidationPipeline(content, format, {
2114
+ validate,
2115
+ normalize,
2116
+ guardrails
2117
+ });
2118
+ if (validationResult.blocked) {
2119
+ const result2 = {
2120
+ success: false,
2121
+ message: validationResult.blockReason || "Output validation failed",
1797
2122
  path: notePath,
2123
+ warnings: validationResult.inputWarnings,
2124
+ outputIssues: validationResult.outputIssues,
1798
2125
  tokensEstimate: 0
1799
2126
  };
1800
2127
  result2.tokensEstimate = estimateTokens(result2);
1801
2128
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1802
2129
  }
1803
- let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
2130
+ let workingContent = validationResult.content;
2131
+ let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(workingContent, skipWikilinks);
1804
2132
  let suggestInfo;
1805
2133
  if (suggestOutgoingLinks && !skipWikilinks) {
1806
2134
  const result2 = suggestRelatedLinks(processedContent, { maxSuggestions });
@@ -1838,8 +2166,12 @@ function registerMutationTools(server2, vaultPath2) {
1838
2166
  preview,
1839
2167
  gitCommit,
1840
2168
  gitError,
1841
- tokensEstimate: 0
2169
+ tokensEstimate: 0,
1842
2170
  // Will be set below
2171
+ // Include validation info if present
2172
+ ...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
2173
+ ...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
2174
+ ...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
1843
2175
  };
1844
2176
  result.tokensEstimate = estimateTokens(result);
1845
2177
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -1870,7 +2202,7 @@ function registerMutationTools(server2, vaultPath2) {
1870
2202
  try {
1871
2203
  const fullPath = path5.join(vaultPath2, notePath);
1872
2204
  try {
1873
- await fs2.access(fullPath);
2205
+ await fs3.access(fullPath);
1874
2206
  } catch {
1875
2207
  const result2 = {
1876
2208
  success: false,
@@ -1884,9 +2216,17 @@ function registerMutationTools(server2, vaultPath2) {
1884
2216
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
1885
2217
  const sectionBoundary = findSection(fileContent, section);
1886
2218
  if (!sectionBoundary) {
2219
+ const headings = extractHeadings(fileContent);
2220
+ let message;
2221
+ if (headings.length === 0) {
2222
+ message = `Section '${section}' not found. This file has no headings. Use vault_append_to_note for files without section structure.`;
2223
+ } else {
2224
+ const availableSections = headings.map((h) => h.text).join(", ");
2225
+ message = `Section '${section}' not found. Available sections: ${availableSections}`;
2226
+ }
1887
2227
  const result2 = {
1888
2228
  success: false,
1889
- message: `Section not found: ${section}`,
2229
+ message,
1890
2230
  path: notePath,
1891
2231
  tokensEstimate: 0
1892
2232
  };
@@ -1957,13 +2297,16 @@ function registerMutationTools(server2, vaultPath2) {
1957
2297
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1958
2298
  skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
1959
2299
  suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1960
- maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)")
2300
+ maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
2301
+ validate: z.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
2302
+ normalize: z.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
2303
+ guardrails: z.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables')
1961
2304
  },
1962
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions }) => {
2305
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails }) => {
1963
2306
  try {
1964
2307
  const fullPath = path5.join(vaultPath2, notePath);
1965
2308
  try {
1966
- await fs2.access(fullPath);
2309
+ await fs3.access(fullPath);
1967
2310
  } catch {
1968
2311
  const result2 = {
1969
2312
  success: false,
@@ -1977,16 +2320,42 @@ function registerMutationTools(server2, vaultPath2) {
1977
2320
  const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
1978
2321
  const sectionBoundary = findSection(fileContent, section);
1979
2322
  if (!sectionBoundary) {
2323
+ const headings = extractHeadings(fileContent);
2324
+ let message;
2325
+ if (headings.length === 0) {
2326
+ message = `Section '${section}' not found. This file has no headings. Use vault_append_to_note for files without section structure.`;
2327
+ } else {
2328
+ const availableSections = headings.map((h) => h.text).join(", ");
2329
+ message = `Section '${section}' not found. Available sections: ${availableSections}`;
2330
+ }
1980
2331
  const result2 = {
1981
2332
  success: false,
1982
- message: `Section not found: ${section}`,
2333
+ message,
1983
2334
  path: notePath,
1984
2335
  tokensEstimate: 0
1985
2336
  };
1986
2337
  result2.tokensEstimate = estimateTokens(result2);
1987
2338
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1988
2339
  }
1989
- let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
2340
+ const validationResult = runValidationPipeline(replacement, "plain", {
2341
+ validate,
2342
+ normalize,
2343
+ guardrails
2344
+ });
2345
+ if (validationResult.blocked) {
2346
+ const result2 = {
2347
+ success: false,
2348
+ message: validationResult.blockReason || "Output validation failed",
2349
+ path: notePath,
2350
+ warnings: validationResult.inputWarnings,
2351
+ outputIssues: validationResult.outputIssues,
2352
+ tokensEstimate: 0
2353
+ };
2354
+ result2.tokensEstimate = estimateTokens(result2);
2355
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2356
+ }
2357
+ let workingReplacement = validationResult.content;
2358
+ let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(workingReplacement, skipWikilinks);
1990
2359
  let suggestInfo;
1991
2360
  if (suggestOutgoingLinks && !skipWikilinks) {
1992
2361
  const result2 = suggestRelatedLinks(processedReplacement, { maxSuggestions });
@@ -2035,7 +2404,11 @@ function registerMutationTools(server2, vaultPath2) {
2035
2404
  preview: previewLines.join("\n"),
2036
2405
  gitCommit,
2037
2406
  gitError,
2038
- tokensEstimate: 0
2407
+ tokensEstimate: 0,
2408
+ // Include validation info if present
2409
+ ...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
2410
+ ...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
2411
+ ...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
2039
2412
  };
2040
2413
  result.tokensEstimate = estimateTokens(result);
2041
2414
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -2056,7 +2429,7 @@ function registerMutationTools(server2, vaultPath2) {
2056
2429
 
2057
2430
  // src/tools/tasks.ts
2058
2431
  import { z as z2 } from "zod";
2059
- import fs3 from "fs/promises";
2432
+ import fs4 from "fs/promises";
2060
2433
  import path6 from "path";
2061
2434
  var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
2062
2435
  function findTasks(content, section) {
@@ -2115,7 +2488,7 @@ function registerTaskTools(server2, vaultPath2) {
2115
2488
  try {
2116
2489
  const fullPath = path6.join(vaultPath2, notePath);
2117
2490
  try {
2118
- await fs3.access(fullPath);
2491
+ await fs4.access(fullPath);
2119
2492
  } catch {
2120
2493
  const result2 = {
2121
2494
  success: false,
@@ -2217,13 +2590,16 @@ function registerTaskTools(server2, vaultPath2) {
2217
2590
  skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
2218
2591
  suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
2219
2592
  maxSuggestions: z2.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
2220
- preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true")
2593
+ preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true"),
2594
+ validate: z2.boolean().default(true).describe("Check input for common issues"),
2595
+ normalize: z2.boolean().default(true).describe("Auto-fix common issues before formatting"),
2596
+ guardrails: z2.enum(["warn", "strict", "off"]).default("warn").describe("Output validation mode")
2221
2597
  },
2222
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting }) => {
2598
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting, validate, normalize, guardrails }) => {
2223
2599
  try {
2224
2600
  const fullPath = path6.join(vaultPath2, notePath);
2225
2601
  try {
2226
- await fs3.access(fullPath);
2602
+ await fs4.access(fullPath);
2227
2603
  } catch {
2228
2604
  const result2 = {
2229
2605
  success: false,
@@ -2246,7 +2622,25 @@ function registerTaskTools(server2, vaultPath2) {
2246
2622
  result2.tokensEstimate = estimateTokens(result2);
2247
2623
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2248
2624
  }
2249
- let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
2625
+ const validationResult = runValidationPipeline(task.trim(), "task", {
2626
+ validate,
2627
+ normalize,
2628
+ guardrails
2629
+ });
2630
+ if (validationResult.blocked) {
2631
+ const result2 = {
2632
+ success: false,
2633
+ message: validationResult.blockReason || "Output validation failed",
2634
+ path: notePath,
2635
+ warnings: validationResult.inputWarnings,
2636
+ outputIssues: validationResult.outputIssues,
2637
+ tokensEstimate: 0
2638
+ };
2639
+ result2.tokensEstimate = estimateTokens(result2);
2640
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2641
+ }
2642
+ let workingTask = validationResult.content;
2643
+ let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(workingTask, skipWikilinks);
2250
2644
  let suggestInfo;
2251
2645
  if (suggestOutgoingLinks && !skipWikilinks) {
2252
2646
  const result2 = suggestRelatedLinks(processedTask, { maxSuggestions });
@@ -2284,7 +2678,11 @@ function registerTaskTools(server2, vaultPath2) {
2284
2678
  (${infoLines.join("; ")})` : ""),
2285
2679
  gitCommit,
2286
2680
  gitError,
2287
- tokensEstimate: 0
2681
+ tokensEstimate: 0,
2682
+ // Include validation info if present
2683
+ ...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
2684
+ ...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
2685
+ ...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
2288
2686
  };
2289
2687
  result.tokensEstimate = estimateTokens(result);
2290
2688
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -2305,7 +2703,7 @@ function registerTaskTools(server2, vaultPath2) {
2305
2703
 
2306
2704
  // src/tools/frontmatter.ts
2307
2705
  import { z as z3 } from "zod";
2308
- import fs4 from "fs/promises";
2706
+ import fs5 from "fs/promises";
2309
2707
  import path7 from "path";
2310
2708
  function registerFrontmatterTools(server2, vaultPath2) {
2311
2709
  server2.tool(
@@ -2320,7 +2718,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2320
2718
  try {
2321
2719
  const fullPath = path7.join(vaultPath2, notePath);
2322
2720
  try {
2323
- await fs4.access(fullPath);
2721
+ await fs5.access(fullPath);
2324
2722
  } catch {
2325
2723
  const result2 = {
2326
2724
  success: false,
@@ -2376,7 +2774,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2376
2774
  try {
2377
2775
  const fullPath = path7.join(vaultPath2, notePath);
2378
2776
  try {
2379
- await fs4.access(fullPath);
2777
+ await fs5.access(fullPath);
2380
2778
  } catch {
2381
2779
  const result2 = {
2382
2780
  success: false,
@@ -2430,7 +2828,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2430
2828
 
2431
2829
  // src/tools/notes.ts
2432
2830
  import { z as z4 } from "zod";
2433
- import fs5 from "fs/promises";
2831
+ import fs6 from "fs/promises";
2434
2832
  import path8 from "path";
2435
2833
  function registerNoteTools(server2, vaultPath2) {
2436
2834
  server2.tool(
@@ -2455,7 +2853,7 @@ function registerNoteTools(server2, vaultPath2) {
2455
2853
  }
2456
2854
  const fullPath = path8.join(vaultPath2, notePath);
2457
2855
  try {
2458
- await fs5.access(fullPath);
2856
+ await fs6.access(fullPath);
2459
2857
  if (!overwrite) {
2460
2858
  const result2 = {
2461
2859
  success: false,
@@ -2467,7 +2865,7 @@ function registerNoteTools(server2, vaultPath2) {
2467
2865
  } catch {
2468
2866
  }
2469
2867
  const dir = path8.dirname(fullPath);
2470
- await fs5.mkdir(dir, { recursive: true });
2868
+ await fs6.mkdir(dir, { recursive: true });
2471
2869
  await writeVaultFile(vaultPath2, notePath, content, frontmatter);
2472
2870
  let gitCommit;
2473
2871
  let gitError;
@@ -2527,7 +2925,7 @@ Content length: ${content.length} chars`,
2527
2925
  }
2528
2926
  const fullPath = path8.join(vaultPath2, notePath);
2529
2927
  try {
2530
- await fs5.access(fullPath);
2928
+ await fs6.access(fullPath);
2531
2929
  } catch {
2532
2930
  const result2 = {
2533
2931
  success: false,
@@ -2536,7 +2934,7 @@ Content length: ${content.length} chars`,
2536
2934
  };
2537
2935
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2538
2936
  }
2539
- await fs5.unlink(fullPath);
2937
+ await fs6.unlink(fullPath);
2540
2938
  let gitCommit;
2541
2939
  let gitError;
2542
2940
  if (commit) {
@@ -2570,7 +2968,7 @@ Content length: ${content.length} chars`,
2570
2968
 
2571
2969
  // src/tools/system.ts
2572
2970
  import { z as z5 } from "zod";
2573
- import fs6 from "fs/promises";
2971
+ import fs7 from "fs/promises";
2574
2972
  import path9 from "path";
2575
2973
  function registerSystemTools(server2, vaultPath2) {
2576
2974
  server2.tool(
@@ -2593,7 +2991,7 @@ function registerSystemTools(server2, vaultPath2) {
2593
2991
  }
2594
2992
  const fullPath = path9.join(vaultPath2, notePath);
2595
2993
  try {
2596
- await fs6.access(fullPath);
2994
+ await fs7.access(fullPath);
2597
2995
  } catch {
2598
2996
  const result2 = {
2599
2997
  success: false,
@@ -2639,8 +3037,8 @@ function registerSystemTools(server2, vaultPath2) {
2639
3037
  async ({ confirm }) => {
2640
3038
  try {
2641
3039
  if (!confirm) {
2642
- const lastCommit = await getLastCommit(vaultPath2);
2643
- if (!lastCommit) {
3040
+ const lastCommit2 = await getLastCommit(vaultPath2);
3041
+ if (!lastCommit2) {
2644
3042
  const result3 = {
2645
3043
  success: false,
2646
3044
  message: "No commits found to undo",
@@ -2650,11 +3048,11 @@ function registerSystemTools(server2, vaultPath2) {
2650
3048
  }
2651
3049
  const result2 = {
2652
3050
  success: false,
2653
- message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit.message}"`,
3051
+ message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit2.message}"`,
2654
3052
  path: "",
2655
- preview: `Commit: ${lastCommit.hash.substring(0, 7)}
2656
- Message: ${lastCommit.message}
2657
- Date: ${lastCommit.date}`
3053
+ preview: `Commit: ${lastCommit2.hash.substring(0, 7)}
3054
+ Message: ${lastCommit2.message}
3055
+ Date: ${lastCommit2.date}`
2658
3056
  };
2659
3057
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2660
3058
  }
@@ -2667,6 +3065,20 @@ Date: ${lastCommit.date}`
2667
3065
  };
2668
3066
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2669
3067
  }
3068
+ const lastCrankCommit = await getLastCrankCommit(vaultPath2);
3069
+ const lastCommit = await getLastCommit(vaultPath2);
3070
+ if (lastCrankCommit && lastCommit) {
3071
+ if (lastCommit.hash !== lastCrankCommit.hash) {
3072
+ const result2 = {
3073
+ success: false,
3074
+ message: `Cannot undo: HEAD (${lastCommit.hash.substring(0, 7)}) doesn't match last Crank commit (${lastCrankCommit.hash.substring(0, 7)}). Another process may have committed since your mutation.`,
3075
+ path: "",
3076
+ preview: `Expected: ${lastCrankCommit.hash.substring(0, 7)} "${lastCrankCommit.message}"
3077
+ Actual HEAD: ${lastCommit.hash.substring(0, 7)} "${lastCommit.message}"`
3078
+ };
3079
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
3080
+ }
3081
+ }
2670
3082
  const undoResult = await undoLastCommit(vaultPath2);
2671
3083
  if (!undoResult.success) {
2672
3084
  const result2 = {
@@ -2676,6 +3088,7 @@ Date: ${lastCommit.date}`
2676
3088
  };
2677
3089
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2678
3090
  }
3091
+ await clearLastCrankCommit(vaultPath2);
2679
3092
  const result = {
2680
3093
  success: true,
2681
3094
  message: undoResult.message,
@@ -2698,7 +3111,7 @@ Message: ${undoResult.undoneCommit.message}` : void 0
2698
3111
  }
2699
3112
 
2700
3113
  // src/core/vaultRoot.ts
2701
- import * as fs7 from "fs";
3114
+ import * as fs8 from "fs";
2702
3115
  import * as path10 from "path";
2703
3116
  var VAULT_MARKERS = [".obsidian", ".claude"];
2704
3117
  function findVaultRoot(startPath) {
@@ -2706,7 +3119,7 @@ function findVaultRoot(startPath) {
2706
3119
  while (true) {
2707
3120
  for (const marker of VAULT_MARKERS) {
2708
3121
  const markerPath = path10.join(current, marker);
2709
- if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
3122
+ if (fs8.existsSync(markerPath) && fs8.statSync(markerPath).isDirectory()) {
2710
3123
  return current;
2711
3124
  }
2712
3125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.10.1",
3
+ "version": "0.11.2",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",