@velvetmonkey/flywheel-crank 0.10.1 → 0.11.1

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 +429 -40
  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,
@@ -1800,7 +2102,25 @@ function registerMutationTools(server2, vaultPath2) {
1800
2102
  result2.tokensEstimate = estimateTokens(result2);
1801
2103
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1802
2104
  }
1803
- let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(content, skipWikilinks);
2105
+ const validationResult = runValidationPipeline(content, format, {
2106
+ validate,
2107
+ normalize,
2108
+ guardrails
2109
+ });
2110
+ if (validationResult.blocked) {
2111
+ const result2 = {
2112
+ success: false,
2113
+ message: validationResult.blockReason || "Output validation failed",
2114
+ path: notePath,
2115
+ warnings: validationResult.inputWarnings,
2116
+ outputIssues: validationResult.outputIssues,
2117
+ tokensEstimate: 0
2118
+ };
2119
+ result2.tokensEstimate = estimateTokens(result2);
2120
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2121
+ }
2122
+ let workingContent = validationResult.content;
2123
+ let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(workingContent, skipWikilinks);
1804
2124
  let suggestInfo;
1805
2125
  if (suggestOutgoingLinks && !skipWikilinks) {
1806
2126
  const result2 = suggestRelatedLinks(processedContent, { maxSuggestions });
@@ -1838,8 +2158,12 @@ function registerMutationTools(server2, vaultPath2) {
1838
2158
  preview,
1839
2159
  gitCommit,
1840
2160
  gitError,
1841
- tokensEstimate: 0
2161
+ tokensEstimate: 0,
1842
2162
  // Will be set below
2163
+ // Include validation info if present
2164
+ ...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
2165
+ ...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
2166
+ ...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
1843
2167
  };
1844
2168
  result.tokensEstimate = estimateTokens(result);
1845
2169
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -1870,7 +2194,7 @@ function registerMutationTools(server2, vaultPath2) {
1870
2194
  try {
1871
2195
  const fullPath = path5.join(vaultPath2, notePath);
1872
2196
  try {
1873
- await fs2.access(fullPath);
2197
+ await fs3.access(fullPath);
1874
2198
  } catch {
1875
2199
  const result2 = {
1876
2200
  success: false,
@@ -1957,13 +2281,16 @@ function registerMutationTools(server2, vaultPath2) {
1957
2281
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1958
2282
  skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
1959
2283
  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)")
2284
+ maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
2285
+ validate: z.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
2286
+ normalize: z.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
2287
+ guardrails: z.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables')
1961
2288
  },
1962
- async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions }) => {
2289
+ async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails }) => {
1963
2290
  try {
1964
2291
  const fullPath = path5.join(vaultPath2, notePath);
1965
2292
  try {
1966
- await fs2.access(fullPath);
2293
+ await fs3.access(fullPath);
1967
2294
  } catch {
1968
2295
  const result2 = {
1969
2296
  success: false,
@@ -1986,7 +2313,25 @@ function registerMutationTools(server2, vaultPath2) {
1986
2313
  result2.tokensEstimate = estimateTokens(result2);
1987
2314
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1988
2315
  }
1989
- let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(replacement, skipWikilinks);
2316
+ const validationResult = runValidationPipeline(replacement, "plain", {
2317
+ validate,
2318
+ normalize,
2319
+ guardrails
2320
+ });
2321
+ if (validationResult.blocked) {
2322
+ const result2 = {
2323
+ success: false,
2324
+ message: validationResult.blockReason || "Output validation failed",
2325
+ path: notePath,
2326
+ warnings: validationResult.inputWarnings,
2327
+ outputIssues: validationResult.outputIssues,
2328
+ tokensEstimate: 0
2329
+ };
2330
+ result2.tokensEstimate = estimateTokens(result2);
2331
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2332
+ }
2333
+ let workingReplacement = validationResult.content;
2334
+ let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(workingReplacement, skipWikilinks);
1990
2335
  let suggestInfo;
1991
2336
  if (suggestOutgoingLinks && !skipWikilinks) {
1992
2337
  const result2 = suggestRelatedLinks(processedReplacement, { maxSuggestions });
@@ -2035,7 +2380,11 @@ function registerMutationTools(server2, vaultPath2) {
2035
2380
  preview: previewLines.join("\n"),
2036
2381
  gitCommit,
2037
2382
  gitError,
2038
- tokensEstimate: 0
2383
+ tokensEstimate: 0,
2384
+ // Include validation info if present
2385
+ ...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
2386
+ ...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
2387
+ ...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
2039
2388
  };
2040
2389
  result.tokensEstimate = estimateTokens(result);
2041
2390
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -2056,7 +2405,7 @@ function registerMutationTools(server2, vaultPath2) {
2056
2405
 
2057
2406
  // src/tools/tasks.ts
2058
2407
  import { z as z2 } from "zod";
2059
- import fs3 from "fs/promises";
2408
+ import fs4 from "fs/promises";
2060
2409
  import path6 from "path";
2061
2410
  var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
2062
2411
  function findTasks(content, section) {
@@ -2115,7 +2464,7 @@ function registerTaskTools(server2, vaultPath2) {
2115
2464
  try {
2116
2465
  const fullPath = path6.join(vaultPath2, notePath);
2117
2466
  try {
2118
- await fs3.access(fullPath);
2467
+ await fs4.access(fullPath);
2119
2468
  } catch {
2120
2469
  const result2 = {
2121
2470
  success: false,
@@ -2217,13 +2566,16 @@ function registerTaskTools(server2, vaultPath2) {
2217
2566
  skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
2218
2567
  suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
2219
2568
  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")
2569
+ preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true"),
2570
+ validate: z2.boolean().default(true).describe("Check input for common issues"),
2571
+ normalize: z2.boolean().default(true).describe("Auto-fix common issues before formatting"),
2572
+ guardrails: z2.enum(["warn", "strict", "off"]).default("warn").describe("Output validation mode")
2221
2573
  },
2222
- async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting }) => {
2574
+ async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting, validate, normalize, guardrails }) => {
2223
2575
  try {
2224
2576
  const fullPath = path6.join(vaultPath2, notePath);
2225
2577
  try {
2226
- await fs3.access(fullPath);
2578
+ await fs4.access(fullPath);
2227
2579
  } catch {
2228
2580
  const result2 = {
2229
2581
  success: false,
@@ -2246,7 +2598,25 @@ function registerTaskTools(server2, vaultPath2) {
2246
2598
  result2.tokensEstimate = estimateTokens(result2);
2247
2599
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2248
2600
  }
2249
- let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(task.trim(), skipWikilinks);
2601
+ const validationResult = runValidationPipeline(task.trim(), "task", {
2602
+ validate,
2603
+ normalize,
2604
+ guardrails
2605
+ });
2606
+ if (validationResult.blocked) {
2607
+ const result2 = {
2608
+ success: false,
2609
+ message: validationResult.blockReason || "Output validation failed",
2610
+ path: notePath,
2611
+ warnings: validationResult.inputWarnings,
2612
+ outputIssues: validationResult.outputIssues,
2613
+ tokensEstimate: 0
2614
+ };
2615
+ result2.tokensEstimate = estimateTokens(result2);
2616
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2617
+ }
2618
+ let workingTask = validationResult.content;
2619
+ let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(workingTask, skipWikilinks);
2250
2620
  let suggestInfo;
2251
2621
  if (suggestOutgoingLinks && !skipWikilinks) {
2252
2622
  const result2 = suggestRelatedLinks(processedTask, { maxSuggestions });
@@ -2284,7 +2654,11 @@ function registerTaskTools(server2, vaultPath2) {
2284
2654
  (${infoLines.join("; ")})` : ""),
2285
2655
  gitCommit,
2286
2656
  gitError,
2287
- tokensEstimate: 0
2657
+ tokensEstimate: 0,
2658
+ // Include validation info if present
2659
+ ...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
2660
+ ...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
2661
+ ...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
2288
2662
  };
2289
2663
  result.tokensEstimate = estimateTokens(result);
2290
2664
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -2305,7 +2679,7 @@ function registerTaskTools(server2, vaultPath2) {
2305
2679
 
2306
2680
  // src/tools/frontmatter.ts
2307
2681
  import { z as z3 } from "zod";
2308
- import fs4 from "fs/promises";
2682
+ import fs5 from "fs/promises";
2309
2683
  import path7 from "path";
2310
2684
  function registerFrontmatterTools(server2, vaultPath2) {
2311
2685
  server2.tool(
@@ -2320,7 +2694,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2320
2694
  try {
2321
2695
  const fullPath = path7.join(vaultPath2, notePath);
2322
2696
  try {
2323
- await fs4.access(fullPath);
2697
+ await fs5.access(fullPath);
2324
2698
  } catch {
2325
2699
  const result2 = {
2326
2700
  success: false,
@@ -2376,7 +2750,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2376
2750
  try {
2377
2751
  const fullPath = path7.join(vaultPath2, notePath);
2378
2752
  try {
2379
- await fs4.access(fullPath);
2753
+ await fs5.access(fullPath);
2380
2754
  } catch {
2381
2755
  const result2 = {
2382
2756
  success: false,
@@ -2430,7 +2804,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2430
2804
 
2431
2805
  // src/tools/notes.ts
2432
2806
  import { z as z4 } from "zod";
2433
- import fs5 from "fs/promises";
2807
+ import fs6 from "fs/promises";
2434
2808
  import path8 from "path";
2435
2809
  function registerNoteTools(server2, vaultPath2) {
2436
2810
  server2.tool(
@@ -2455,7 +2829,7 @@ function registerNoteTools(server2, vaultPath2) {
2455
2829
  }
2456
2830
  const fullPath = path8.join(vaultPath2, notePath);
2457
2831
  try {
2458
- await fs5.access(fullPath);
2832
+ await fs6.access(fullPath);
2459
2833
  if (!overwrite) {
2460
2834
  const result2 = {
2461
2835
  success: false,
@@ -2467,7 +2841,7 @@ function registerNoteTools(server2, vaultPath2) {
2467
2841
  } catch {
2468
2842
  }
2469
2843
  const dir = path8.dirname(fullPath);
2470
- await fs5.mkdir(dir, { recursive: true });
2844
+ await fs6.mkdir(dir, { recursive: true });
2471
2845
  await writeVaultFile(vaultPath2, notePath, content, frontmatter);
2472
2846
  let gitCommit;
2473
2847
  let gitError;
@@ -2527,7 +2901,7 @@ Content length: ${content.length} chars`,
2527
2901
  }
2528
2902
  const fullPath = path8.join(vaultPath2, notePath);
2529
2903
  try {
2530
- await fs5.access(fullPath);
2904
+ await fs6.access(fullPath);
2531
2905
  } catch {
2532
2906
  const result2 = {
2533
2907
  success: false,
@@ -2536,7 +2910,7 @@ Content length: ${content.length} chars`,
2536
2910
  };
2537
2911
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2538
2912
  }
2539
- await fs5.unlink(fullPath);
2913
+ await fs6.unlink(fullPath);
2540
2914
  let gitCommit;
2541
2915
  let gitError;
2542
2916
  if (commit) {
@@ -2570,7 +2944,7 @@ Content length: ${content.length} chars`,
2570
2944
 
2571
2945
  // src/tools/system.ts
2572
2946
  import { z as z5 } from "zod";
2573
- import fs6 from "fs/promises";
2947
+ import fs7 from "fs/promises";
2574
2948
  import path9 from "path";
2575
2949
  function registerSystemTools(server2, vaultPath2) {
2576
2950
  server2.tool(
@@ -2593,7 +2967,7 @@ function registerSystemTools(server2, vaultPath2) {
2593
2967
  }
2594
2968
  const fullPath = path9.join(vaultPath2, notePath);
2595
2969
  try {
2596
- await fs6.access(fullPath);
2970
+ await fs7.access(fullPath);
2597
2971
  } catch {
2598
2972
  const result2 = {
2599
2973
  success: false,
@@ -2639,8 +3013,8 @@ function registerSystemTools(server2, vaultPath2) {
2639
3013
  async ({ confirm }) => {
2640
3014
  try {
2641
3015
  if (!confirm) {
2642
- const lastCommit = await getLastCommit(vaultPath2);
2643
- if (!lastCommit) {
3016
+ const lastCommit2 = await getLastCommit(vaultPath2);
3017
+ if (!lastCommit2) {
2644
3018
  const result3 = {
2645
3019
  success: false,
2646
3020
  message: "No commits found to undo",
@@ -2650,11 +3024,11 @@ function registerSystemTools(server2, vaultPath2) {
2650
3024
  }
2651
3025
  const result2 = {
2652
3026
  success: false,
2653
- message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit.message}"`,
3027
+ message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit2.message}"`,
2654
3028
  path: "",
2655
- preview: `Commit: ${lastCommit.hash.substring(0, 7)}
2656
- Message: ${lastCommit.message}
2657
- Date: ${lastCommit.date}`
3029
+ preview: `Commit: ${lastCommit2.hash.substring(0, 7)}
3030
+ Message: ${lastCommit2.message}
3031
+ Date: ${lastCommit2.date}`
2658
3032
  };
2659
3033
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2660
3034
  }
@@ -2667,6 +3041,20 @@ Date: ${lastCommit.date}`
2667
3041
  };
2668
3042
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2669
3043
  }
3044
+ const lastCrankCommit = await getLastCrankCommit(vaultPath2);
3045
+ const lastCommit = await getLastCommit(vaultPath2);
3046
+ if (lastCrankCommit && lastCommit) {
3047
+ if (lastCommit.hash !== lastCrankCommit.hash) {
3048
+ const result2 = {
3049
+ success: false,
3050
+ 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.`,
3051
+ path: "",
3052
+ preview: `Expected: ${lastCrankCommit.hash.substring(0, 7)} "${lastCrankCommit.message}"
3053
+ Actual HEAD: ${lastCommit.hash.substring(0, 7)} "${lastCommit.message}"`
3054
+ };
3055
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
3056
+ }
3057
+ }
2670
3058
  const undoResult = await undoLastCommit(vaultPath2);
2671
3059
  if (!undoResult.success) {
2672
3060
  const result2 = {
@@ -2676,6 +3064,7 @@ Date: ${lastCommit.date}`
2676
3064
  };
2677
3065
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2678
3066
  }
3067
+ await clearLastCrankCommit(vaultPath2);
2679
3068
  const result = {
2680
3069
  success: true,
2681
3070
  message: undoResult.message,
@@ -2698,7 +3087,7 @@ Message: ${undoResult.undoneCommit.message}` : void 0
2698
3087
  }
2699
3088
 
2700
3089
  // src/core/vaultRoot.ts
2701
- import * as fs7 from "fs";
3090
+ import * as fs8 from "fs";
2702
3091
  import * as path10 from "path";
2703
3092
  var VAULT_MARKERS = [".obsidian", ".claude"];
2704
3093
  function findVaultRoot(startPath) {
@@ -2706,7 +3095,7 @@ function findVaultRoot(startPath) {
2706
3095
  while (true) {
2707
3096
  for (const marker of VAULT_MARKERS) {
2708
3097
  const markerPath = path10.join(current, marker);
2709
- if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
3098
+ if (fs8.existsSync(markerPath) && fs8.statSync(markerPath).isDirectory()) {
2710
3099
  return current;
2711
3100
  }
2712
3101
  }
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.1",
4
4
  "description": "Deterministic vault mutations for Obsidian via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",