Skip to content
SlashClone
Made by@opsdesk

Meeting notes to actions

@opsdesk/meeting-notes-to-actions

Turn messy meeting notes into a clean list of decisions and action items, each with one owner and a real due date, plus a CSV you can paste into any tracker or spreadsheet.

SkillOtherv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Pull action items out of meeting notes into a CSV with owner and due date.
  3. 3// Usage: node scripts/actions-to-csv.mjs <notes.md> <out.csv> [--meeting-date 2026-11-04] [--meeting "Weekly ops"]
  4. 4// Recognized lines:
  5. 5// - [ ] Owner: do the thing (due 2026-11-12)
  6. 6// - [ ] @Owner do the thing by Friday
  7. 7// Action: Owner to do the thing by end of week
  8. 8// AI / TODO / Next step: ...
  9. 9// Only Node built-ins. Prints a summary with unassigned and undated items.
  10. 11import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
  11. 12import { dirname } from "node:path";
  12. 14function fail(message) {
  13. 15 console.error(`actions-to-csv: ${message}`);
  14. 16 console.error(
  15. 17 'Usage: node scripts/actions-to-csv.mjs <notes.md> <out.csv> [--meeting-date YYYY-MM-DD] [--meeting "Weekly ops"]',
  16. 18 );
  17. 19 process.exit(1);
  18. 22const args = process.argv.slice(2);
  19. 23const positional = [];
  20. 24let meetingDate = "";
  21. 25let meeting = "";
  22. 26for (let i = 0; i < args.length; i++) {
  23. 27 const a = args[i];
  24. 28 if (a === "--meeting-date") meetingDate = args[++i] ?? "";
  25. 29 else if (a === "--meeting") meeting = args[++i] ?? "";
  26. 30 else if (a.startsWith("--")) fail(`unknown option ${a}`);
  27. 31 else positional.push(a);
  28. 33const [notesPath, outPath] = positional;
  29. 34if (!notesPath || !outPath) fail("need a notes file and an output CSV path.");
  30. 36let text;
  31. 37try {
  32. 38 text = readFileSync(notesPath, "utf8");
  33. 39} catch (e) {
  34. 40 fail(`cannot read ${notesPath}: ${e.message}`);
  35. 43// Meeting date: flag, else the first YYYY-MM-DD in the notes, else none (relative dates stay blank).
  36. 44const ISO = /\b(\d{4}-\d{2}-\d{2})\b/;
  37. 45if (!meetingDate) meetingDate = ISO.exec(text)?.[1] ?? "";
  38. 46if (meetingDate && !/^\d{4}-\d{2}-\d{2}$/.test(meetingDate))
  39. 47 fail(`--meeting-date must look like 2026-11-04, got "${meetingDate}".`);
  40. 48if (!meeting) meeting = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? "";
  41. 50const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
  42. 51const iso = (d) => d.toISOString().slice(0, 10);
  43. 52const base = meetingDate ? new Date(`${meetingDate}T00:00:00Z`) : null;
  44. 53const addDays = (n) => {
  45. 54 const d = new Date(base);
  46. 55 d.setUTCDate(d.getUTCDate() + n);
  47. 56 return iso(d);
  48. 57};
  49. 58const nextWeekday = (dow) => {
  50. 59 // Strictly after the meeting day: "by Friday" said on a Friday means next Friday.
  51. 60 let n = (dow - base.getUTCDay() + 7) % 7;
  52. 61 if (n === 0) n = 7;
  53. 62 return addDays(n);
  54. 63};
  55. 65function resolveDue(s) {
  56. 66 const explicit = /\b(?:due|by|before|on)?\s*:?\s*(\d{4}-\d{2}-\d{2})\b/i.exec(s);
  57. 67 if (explicit) return { due: explicit[1], phrase: explicit[0].trim() };
  58. 68 const rel =
  59. 69 /\b(?:due|by|before|on)\s+(today|tomorrow|eod|eow|end of (?:the )?week|end of (?:the )?month|eom|next week|(?:next\s+)?(?:mon|tues|wednes|thurs|fri|satur|sun)day)\b/i.exec(
  60. 70 s,
  61. 71 );
  62. 72 if (!rel) return { due: "", phrase: "" };
  63. 73 const word = rel[1].toLowerCase();
  64. 74 if (!base) return { due: "", phrase: rel[0], unresolved: true };
  65. 75 let due = "";
  66. 76 if (word === "today" || word === "eod") due = addDays(0);
  67. 77 else if (word === "tomorrow") due = addDays(1);
  68. 78 else if (word === "eow" || /end of (the )?week/.test(word)) due = nextWeekday(5);
  69. 79 else if (word === "next week") due = addDays(7);
  70. 80 else if (word === "eom" || /end of (the )?month/.test(word)) {
  71. 81 const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + 1, 0));
  72. 82 due = iso(d);
  73. 83 } else {
  74. 84 const name = word.replace(/^next\s+/, "");
  75. 85 due = nextWeekday(DAYS.indexOf(name));
  76. 86 if (word.startsWith("next ") && DAYS.indexOf(name) > base.getUTCDay()) {
  77. 87 // "next Thursday" said on a Monday usually means the week after.
  78. 88 const d = new Date(`${due}T00:00:00Z`);
  79. 89 d.setUTCDate(d.getUTCDate() + 7);
  80. 90 due = iso(d);
  81. 91 }
  82. 92 }
  83. 93 return { due, phrase: rel[0] };
  84. 96// An owner is a name or role: capitalized words, optionally joined by "of", "the", "for", "&", "and".
  85. 97const NAME = String.raw`[A-Z][\w.'-]*(?:\s+(?:(?:of|the|for|and|&)\s+)?[A-Z][\w.'-]*){0,3}`;
  86. 98// Capitalized verbs at the start of a line are tasks, not owners ("Send to legal").
  87. 99const VERBS = new Set(
  88. 100 "send review share draft write update check confirm schedule book follow ask email call set move add fix ship plan prep prepare post get make find circle loop".split(" "),
  89. 101);
  90. 102const isName = (s) => !VERBS.has(s.split(/\s+/)[0].toLowerCase());
  91. 104const items = [];
  92. 105const lines = text.split(/\r?\n/);
  93. 106lines.forEach((raw, index) => {
  94. 107 const line = raw.trim();
  95. 108 let body = null;
  96. 109 let status = "open";
  97. 110 const box = /^[-*+]\s+\[( |x|X)\]\s+(.+)$/.exec(line);
  98. 111 if (box) {
  99. 112 body = box[2];
  100. 113 status = box[1] === " " ? "open" : "done";
  101. 114 } else {
  102. 115 const tagged = /^(?:[-*+]\s+)?(?:\*\*)?(?:action(?: item)?|ai|todo|next step|follow[- ]up)(?:\*\*)?\s*:\s*(?:\*\*)?\s*(.+)$/i.exec(
  103. 116 line,
  104. 117 );
  105. 118 if (tagged) body = tagged[1];
  106. 119 }
  107. 120 if (!body) return;
  108. 122 let owner = "";
  109. 123 let action = body;
  110. 124 let m;
  111. 125 if ((m = /^@([\w.-]+)\s*[:,-]?\s+(.+)$/.exec(action))) {
  112. 126 owner = m[1];
  113. 127 action = m[2];
  114. 128 } else if ((m = new RegExp(`^(${NAME})\\s*:\\s+(.+)$`).exec(action)) && isName(m[1])) {
  115. 129 owner = m[1];
  116. 130 action = m[2];
  117. 131 } else if ((m = new RegExp(`^(${NAME})\\s+(?:to|will)\\s+(.+)$`).exec(action)) && isName(m[1])) {
  118. 132 owner = m[1];
  119. 133 action = m[2];
  120. 134 } else if ((m = new RegExp(`\\(\\s*(?:owner\\s*:\\s*)?@?(${NAME})\\s*\\)`).exec(action)) && isName(m[1])) {
  121. 135 owner = m[1];
  122. 136 action = action.replace(m[0], "");
  123. 137 }
  124. 138 const { due, phrase, unresolved } = resolveDue(action);
  125. 139 if (phrase) {
  126. 140 action = action
  127. 141 .replace(new RegExp(`\\(?\\s*${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\)?`, "i"), "")
  128. 142 .trim();
  129. 143 }
  130. 144 action = action.replace(/\s{2,}/g, " ").replace(/[\s,;.-]+$/, "").trim();
  131. 145 action = action.charAt(0).toUpperCase() + action.slice(1);
  132. 146 items.push({
  133. 147 owner: owner || "UNASSIGNED",
  134. 148 action,
  135. 149 due: due || "",
  136. 150 due_note: unresolved ? `"${phrase}" (no meeting date)` : "",
  137. 151 status,
  138. 152 line: index + 1,
  139. 153 });
  140. 154});
  141. 156const header = ["id", "owner", "action", "due", "status", "meeting", "meeting_date", "source_line", "due_note"];
  142. 157const esc = (v) => {
  143. 158 const s = String(v ?? "");
  144. 159 return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  145. 160};
  146. 161const rows = items.map((it, i) =>
  147. 162 [i + 1, it.owner, it.action, it.due, it.status, meeting, meetingDate, it.line, it.due_note].map(esc).join(","),
  148. 163);
  149. 164try {
  150. 165 mkdirSync(dirname(outPath), { recursive: true });
  151. 166 writeFileSync(outPath, [header.join(","), ...rows].join("\n") + "\n");
  152. 167} catch (e) {
  153. 168 fail(`cannot write ${outPath}: ${e.message}`);
  154. 171const unassigned = items.filter((i) => i.owner === "UNASSIGNED").length;
  155. 172const undated = items.filter((i) => !i.due && i.status === "open").length;
  156. 173console.log(
  157. 174 `Wrote ${outPath}: ${items.length} action items, ${unassigned} without an owner, ${undated} open without a due date.`,
  158. 175);
  159. 176if (items.length === 0)
  160. 177 console.log('No action items found. Mark them as "- [ ] Owner: task" or "Action: Owner to task by Friday".');