@velvetmonkey/flywheel-crank 0.10.0 → 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 +469 -47
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -141,22 +141,106 @@ 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
- case "bullet":
150
- return `- ${trimmed}`;
151
- case "task":
152
- return `- [ ] ${trimmed}`;
153
- case "numbered":
154
- return `1. ${trimmed}`;
176
+ case "bullet": {
177
+ if (isPreformattedList(trimmed)) {
178
+ return trimmed;
179
+ }
180
+ const lines = trimmed.split("\n");
181
+ return lines.map((line, i) => {
182
+ if (i === 0)
183
+ return `- ${line}`;
184
+ if (line === "")
185
+ return "";
186
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
187
+ return line;
188
+ }
189
+ return ` ${line}`;
190
+ }).join("\n");
191
+ }
192
+ case "task": {
193
+ if (isPreformattedList(trimmed)) {
194
+ return trimmed;
195
+ }
196
+ const lines = trimmed.split("\n");
197
+ return lines.map((line, i) => {
198
+ if (i === 0)
199
+ return `- [ ] ${line}`;
200
+ if (line === "")
201
+ return "";
202
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
203
+ return line;
204
+ }
205
+ return ` ${line}`;
206
+ }).join("\n");
207
+ }
208
+ case "numbered": {
209
+ if (isPreformattedList(trimmed)) {
210
+ return trimmed;
211
+ }
212
+ const lines = trimmed.split("\n");
213
+ return lines.map((line, i) => {
214
+ if (i === 0)
215
+ return `1. ${line}`;
216
+ if (line === "")
217
+ return "";
218
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
219
+ return line;
220
+ }
221
+ return ` ${line}`;
222
+ }).join("\n");
223
+ }
155
224
  case "timestamp-bullet": {
225
+ if (isPreformattedList(trimmed)) {
226
+ return trimmed;
227
+ }
156
228
  const now = /* @__PURE__ */ new Date();
157
229
  const hours = String(now.getHours()).padStart(2, "0");
158
230
  const minutes = String(now.getMinutes()).padStart(2, "0");
159
- return `- **${hours}:${minutes}** ${trimmed}`;
231
+ const prefix = `- **${hours}:${minutes}** `;
232
+ const lines = trimmed.split("\n");
233
+ const indent = " ";
234
+ return lines.map((line, i) => {
235
+ if (i === 0)
236
+ return `${prefix}${line}`;
237
+ if (line === "")
238
+ return "";
239
+ if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
240
+ return line;
241
+ }
242
+ return `${indent}${line}`;
243
+ }).join("\n");
160
244
  }
161
245
  default:
162
246
  return trimmed;
@@ -198,7 +282,13 @@ function insertInSection(content, section, newContent, position, options) {
198
282
  break;
199
283
  }
200
284
  if (indent) {
201
- 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");
202
292
  lines.splice(section.contentStartLine, 0, indentedContent);
203
293
  } else {
204
294
  lines.splice(section.contentStartLine, 0, formattedContent);
@@ -217,7 +307,13 @@ function insertInSection(content, section, newContent, position, options) {
217
307
  if (lastContentLineIdx >= section.contentStartLine && isEmptyPlaceholder(lines[lastContentLineIdx])) {
218
308
  if (options?.preserveListNesting) {
219
309
  const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
220
- 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");
221
317
  lines[lastContentLineIdx] = indentedContent;
222
318
  } else {
223
319
  lines[lastContentLineIdx] = formattedContent;
@@ -236,7 +332,13 @@ function insertInSection(content, section, newContent, position, options) {
236
332
  }
237
333
  if (options?.preserveListNesting) {
238
334
  const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
239
- 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");
240
342
  lines.splice(insertLine, 0, indentedContent);
241
343
  } else {
242
344
  lines.splice(insertLine, 0, formattedContent);
@@ -421,9 +523,236 @@ function replaceInSection(content, section, search, replacement, mode = "first",
421
523
  };
422
524
  }
423
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
+
424
724
  // src/core/git.ts
425
725
  import { simpleGit, CheckRepoActions } from "simple-git";
426
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
+ }
427
756
  async function isGitRepo(vaultPath2) {
428
757
  try {
429
758
  const git = simpleGit(vaultPath2);
@@ -447,6 +776,9 @@ async function commitChange(vaultPath2, filePath, messagePrefix) {
447
776
  const fileName = path2.basename(filePath);
448
777
  const commitMessage = `${messagePrefix} Update ${fileName}`;
449
778
  const result = await git.commit(commitMessage);
779
+ if (result.commit) {
780
+ await saveLastCrankCommit(vaultPath2, result.commit, commitMessage);
781
+ }
450
782
  return {
451
783
  success: true,
452
784
  hash: result.commit
@@ -1722,7 +2054,7 @@ function suggestRelatedLinks(content, options = {}) {
1722
2054
  }
1723
2055
 
1724
2056
  // src/tools/mutations.ts
1725
- import fs2 from "fs/promises";
2057
+ import fs3 from "fs/promises";
1726
2058
  import path5 from "path";
1727
2059
  function registerMutationTools(server2, vaultPath2) {
1728
2060
  server2.tool(
@@ -1738,13 +2070,16 @@ function registerMutationTools(server2, vaultPath2) {
1738
2070
  skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
1739
2071
  preserveListNesting: z.boolean().default(true).describe("Detect and preserve the indentation level of surrounding list items. Set false to disable."),
1740
2072
  suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1741
- 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')
1742
2077
  },
1743
- 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 }) => {
1744
2079
  try {
1745
2080
  const fullPath = path5.join(vaultPath2, notePath);
1746
2081
  try {
1747
- await fs2.access(fullPath);
2082
+ await fs3.access(fullPath);
1748
2083
  } catch {
1749
2084
  const result2 = {
1750
2085
  success: false,
@@ -1767,7 +2102,25 @@ function registerMutationTools(server2, vaultPath2) {
1767
2102
  result2.tokensEstimate = estimateTokens(result2);
1768
2103
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1769
2104
  }
1770
- 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);
1771
2124
  let suggestInfo;
1772
2125
  if (suggestOutgoingLinks && !skipWikilinks) {
1773
2126
  const result2 = suggestRelatedLinks(processedContent, { maxSuggestions });
@@ -1805,8 +2158,12 @@ function registerMutationTools(server2, vaultPath2) {
1805
2158
  preview,
1806
2159
  gitCommit,
1807
2160
  gitError,
1808
- tokensEstimate: 0
2161
+ tokensEstimate: 0,
1809
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 }
1810
2167
  };
1811
2168
  result.tokensEstimate = estimateTokens(result);
1812
2169
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -1837,7 +2194,7 @@ function registerMutationTools(server2, vaultPath2) {
1837
2194
  try {
1838
2195
  const fullPath = path5.join(vaultPath2, notePath);
1839
2196
  try {
1840
- await fs2.access(fullPath);
2197
+ await fs3.access(fullPath);
1841
2198
  } catch {
1842
2199
  const result2 = {
1843
2200
  success: false,
@@ -1924,13 +2281,16 @@ function registerMutationTools(server2, vaultPath2) {
1924
2281
  commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
1925
2282
  skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
1926
2283
  suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
1927
- 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')
1928
2288
  },
1929
- 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 }) => {
1930
2290
  try {
1931
2291
  const fullPath = path5.join(vaultPath2, notePath);
1932
2292
  try {
1933
- await fs2.access(fullPath);
2293
+ await fs3.access(fullPath);
1934
2294
  } catch {
1935
2295
  const result2 = {
1936
2296
  success: false,
@@ -1953,7 +2313,25 @@ function registerMutationTools(server2, vaultPath2) {
1953
2313
  result2.tokensEstimate = estimateTokens(result2);
1954
2314
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1955
2315
  }
1956
- 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);
1957
2335
  let suggestInfo;
1958
2336
  if (suggestOutgoingLinks && !skipWikilinks) {
1959
2337
  const result2 = suggestRelatedLinks(processedReplacement, { maxSuggestions });
@@ -2002,7 +2380,11 @@ function registerMutationTools(server2, vaultPath2) {
2002
2380
  preview: previewLines.join("\n"),
2003
2381
  gitCommit,
2004
2382
  gitError,
2005
- 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 }
2006
2388
  };
2007
2389
  result.tokensEstimate = estimateTokens(result);
2008
2390
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -2023,7 +2405,7 @@ function registerMutationTools(server2, vaultPath2) {
2023
2405
 
2024
2406
  // src/tools/tasks.ts
2025
2407
  import { z as z2 } from "zod";
2026
- import fs3 from "fs/promises";
2408
+ import fs4 from "fs/promises";
2027
2409
  import path6 from "path";
2028
2410
  var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
2029
2411
  function findTasks(content, section) {
@@ -2082,7 +2464,7 @@ function registerTaskTools(server2, vaultPath2) {
2082
2464
  try {
2083
2465
  const fullPath = path6.join(vaultPath2, notePath);
2084
2466
  try {
2085
- await fs3.access(fullPath);
2467
+ await fs4.access(fullPath);
2086
2468
  } catch {
2087
2469
  const result2 = {
2088
2470
  success: false,
@@ -2184,13 +2566,16 @@ function registerTaskTools(server2, vaultPath2) {
2184
2566
  skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
2185
2567
  suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
2186
2568
  maxSuggestions: z2.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
2187
- 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")
2188
2573
  },
2189
- 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 }) => {
2190
2575
  try {
2191
2576
  const fullPath = path6.join(vaultPath2, notePath);
2192
2577
  try {
2193
- await fs3.access(fullPath);
2578
+ await fs4.access(fullPath);
2194
2579
  } catch {
2195
2580
  const result2 = {
2196
2581
  success: false,
@@ -2213,7 +2598,25 @@ function registerTaskTools(server2, vaultPath2) {
2213
2598
  result2.tokensEstimate = estimateTokens(result2);
2214
2599
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2215
2600
  }
2216
- 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);
2217
2620
  let suggestInfo;
2218
2621
  if (suggestOutgoingLinks && !skipWikilinks) {
2219
2622
  const result2 = suggestRelatedLinks(processedTask, { maxSuggestions });
@@ -2251,7 +2654,11 @@ function registerTaskTools(server2, vaultPath2) {
2251
2654
  (${infoLines.join("; ")})` : ""),
2252
2655
  gitCommit,
2253
2656
  gitError,
2254
- 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 }
2255
2662
  };
2256
2663
  result.tokensEstimate = estimateTokens(result);
2257
2664
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -2272,7 +2679,7 @@ function registerTaskTools(server2, vaultPath2) {
2272
2679
 
2273
2680
  // src/tools/frontmatter.ts
2274
2681
  import { z as z3 } from "zod";
2275
- import fs4 from "fs/promises";
2682
+ import fs5 from "fs/promises";
2276
2683
  import path7 from "path";
2277
2684
  function registerFrontmatterTools(server2, vaultPath2) {
2278
2685
  server2.tool(
@@ -2287,7 +2694,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2287
2694
  try {
2288
2695
  const fullPath = path7.join(vaultPath2, notePath);
2289
2696
  try {
2290
- await fs4.access(fullPath);
2697
+ await fs5.access(fullPath);
2291
2698
  } catch {
2292
2699
  const result2 = {
2293
2700
  success: false,
@@ -2343,7 +2750,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2343
2750
  try {
2344
2751
  const fullPath = path7.join(vaultPath2, notePath);
2345
2752
  try {
2346
- await fs4.access(fullPath);
2753
+ await fs5.access(fullPath);
2347
2754
  } catch {
2348
2755
  const result2 = {
2349
2756
  success: false,
@@ -2397,7 +2804,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
2397
2804
 
2398
2805
  // src/tools/notes.ts
2399
2806
  import { z as z4 } from "zod";
2400
- import fs5 from "fs/promises";
2807
+ import fs6 from "fs/promises";
2401
2808
  import path8 from "path";
2402
2809
  function registerNoteTools(server2, vaultPath2) {
2403
2810
  server2.tool(
@@ -2422,7 +2829,7 @@ function registerNoteTools(server2, vaultPath2) {
2422
2829
  }
2423
2830
  const fullPath = path8.join(vaultPath2, notePath);
2424
2831
  try {
2425
- await fs5.access(fullPath);
2832
+ await fs6.access(fullPath);
2426
2833
  if (!overwrite) {
2427
2834
  const result2 = {
2428
2835
  success: false,
@@ -2434,7 +2841,7 @@ function registerNoteTools(server2, vaultPath2) {
2434
2841
  } catch {
2435
2842
  }
2436
2843
  const dir = path8.dirname(fullPath);
2437
- await fs5.mkdir(dir, { recursive: true });
2844
+ await fs6.mkdir(dir, { recursive: true });
2438
2845
  await writeVaultFile(vaultPath2, notePath, content, frontmatter);
2439
2846
  let gitCommit;
2440
2847
  let gitError;
@@ -2494,7 +2901,7 @@ Content length: ${content.length} chars`,
2494
2901
  }
2495
2902
  const fullPath = path8.join(vaultPath2, notePath);
2496
2903
  try {
2497
- await fs5.access(fullPath);
2904
+ await fs6.access(fullPath);
2498
2905
  } catch {
2499
2906
  const result2 = {
2500
2907
  success: false,
@@ -2503,7 +2910,7 @@ Content length: ${content.length} chars`,
2503
2910
  };
2504
2911
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2505
2912
  }
2506
- await fs5.unlink(fullPath);
2913
+ await fs6.unlink(fullPath);
2507
2914
  let gitCommit;
2508
2915
  let gitError;
2509
2916
  if (commit) {
@@ -2537,7 +2944,7 @@ Content length: ${content.length} chars`,
2537
2944
 
2538
2945
  // src/tools/system.ts
2539
2946
  import { z as z5 } from "zod";
2540
- import fs6 from "fs/promises";
2947
+ import fs7 from "fs/promises";
2541
2948
  import path9 from "path";
2542
2949
  function registerSystemTools(server2, vaultPath2) {
2543
2950
  server2.tool(
@@ -2560,7 +2967,7 @@ function registerSystemTools(server2, vaultPath2) {
2560
2967
  }
2561
2968
  const fullPath = path9.join(vaultPath2, notePath);
2562
2969
  try {
2563
- await fs6.access(fullPath);
2970
+ await fs7.access(fullPath);
2564
2971
  } catch {
2565
2972
  const result2 = {
2566
2973
  success: false,
@@ -2606,8 +3013,8 @@ function registerSystemTools(server2, vaultPath2) {
2606
3013
  async ({ confirm }) => {
2607
3014
  try {
2608
3015
  if (!confirm) {
2609
- const lastCommit = await getLastCommit(vaultPath2);
2610
- if (!lastCommit) {
3016
+ const lastCommit2 = await getLastCommit(vaultPath2);
3017
+ if (!lastCommit2) {
2611
3018
  const result3 = {
2612
3019
  success: false,
2613
3020
  message: "No commits found to undo",
@@ -2617,11 +3024,11 @@ function registerSystemTools(server2, vaultPath2) {
2617
3024
  }
2618
3025
  const result2 = {
2619
3026
  success: false,
2620
- message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit.message}"`,
3027
+ message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit2.message}"`,
2621
3028
  path: "",
2622
- preview: `Commit: ${lastCommit.hash.substring(0, 7)}
2623
- Message: ${lastCommit.message}
2624
- Date: ${lastCommit.date}`
3029
+ preview: `Commit: ${lastCommit2.hash.substring(0, 7)}
3030
+ Message: ${lastCommit2.message}
3031
+ Date: ${lastCommit2.date}`
2625
3032
  };
2626
3033
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2627
3034
  }
@@ -2634,6 +3041,20 @@ Date: ${lastCommit.date}`
2634
3041
  };
2635
3042
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2636
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
+ }
2637
3058
  const undoResult = await undoLastCommit(vaultPath2);
2638
3059
  if (!undoResult.success) {
2639
3060
  const result2 = {
@@ -2643,6 +3064,7 @@ Date: ${lastCommit.date}`
2643
3064
  };
2644
3065
  return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
2645
3066
  }
3067
+ await clearLastCrankCommit(vaultPath2);
2646
3068
  const result = {
2647
3069
  success: true,
2648
3070
  message: undoResult.message,
@@ -2665,7 +3087,7 @@ Message: ${undoResult.undoneCommit.message}` : void 0
2665
3087
  }
2666
3088
 
2667
3089
  // src/core/vaultRoot.ts
2668
- import * as fs7 from "fs";
3090
+ import * as fs8 from "fs";
2669
3091
  import * as path10 from "path";
2670
3092
  var VAULT_MARKERS = [".obsidian", ".claude"];
2671
3093
  function findVaultRoot(startPath) {
@@ -2673,7 +3095,7 @@ function findVaultRoot(startPath) {
2673
3095
  while (true) {
2674
3096
  for (const marker of VAULT_MARKERS) {
2675
3097
  const markerPath = path10.join(current, marker);
2676
- if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
3098
+ if (fs8.existsSync(markerPath) && fs8.statSync(markerPath).isDirectory()) {
2677
3099
  return current;
2678
3100
  }
2679
3101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-crank",
3
- "version": "0.10.0",
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",