@forwardimpact/outpost 3.0.3

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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +15 -0
  3. package/bin/fit-outpost.js +3 -0
  4. package/config/scheduler.json +34 -0
  5. package/package.json +63 -0
  6. package/src/agent-runner.js +189 -0
  7. package/src/index.js +5 -0
  8. package/src/kb-manager.js +228 -0
  9. package/src/outpost.js +417 -0
  10. package/src/scheduler.js +195 -0
  11. package/src/socket-server.js +336 -0
  12. package/src/state-manager.js +126 -0
  13. package/templates/.claude/agents/chief-of-staff.md +58 -0
  14. package/templates/.claude/agents/concierge.md +46 -0
  15. package/templates/.claude/agents/head-hunter.md +57 -0
  16. package/templates/.claude/agents/librarian.md +60 -0
  17. package/templates/.claude/agents/postman.md +47 -0
  18. package/templates/.claude/agents/recruiter.md +63 -0
  19. package/templates/.claude/settings.json +88 -0
  20. package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
  21. package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
  22. package/templates/.claude/skills/candidate-report/references/report.css +438 -0
  23. package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
  24. package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
  25. package/templates/.claude/skills/deck-create/SKILL.md +75 -0
  26. package/templates/.claude/skills/deck-create/references/slide.css +35 -0
  27. package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
  28. package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
  29. package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
  30. package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
  31. package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
  32. package/templates/.claude/skills/doc-create/SKILL.md +105 -0
  33. package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
  34. package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
  35. package/templates/.claude/skills/draft-emails/references/template.md +26 -0
  36. package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  37. package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
  38. package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
  39. package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
  40. package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
  41. package/templates/.claude/skills/extract-entities/references/content.md +104 -0
  42. package/templates/.claude/skills/extract-entities/references/links.md +48 -0
  43. package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
  44. package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
  45. package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
  46. package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
  47. package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
  48. package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
  49. package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
  50. package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
  51. package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
  52. package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
  53. package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
  54. package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
  55. package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
  56. package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
  57. package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
  58. package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
  59. package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
  60. package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
  61. package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
  62. package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
  63. package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
  64. package/templates/.claude/skills/organize-files/SKILL.md +146 -0
  65. package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  66. package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
  67. package/templates/.claude/skills/req-assess/SKILL.md +137 -0
  68. package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
  69. package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
  70. package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
  71. package/templates/.claude/skills/req-decide/SKILL.md +163 -0
  72. package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
  73. package/templates/.claude/skills/req-decide/references/template.md +118 -0
  74. package/templates/.claude/skills/req-forget/SKILL.md +154 -0
  75. package/templates/.claude/skills/req-forget/references/classify.md +23 -0
  76. package/templates/.claude/skills/req-forget/references/locations.md +59 -0
  77. package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
  78. package/templates/.claude/skills/req-scan/SKILL.md +145 -0
  79. package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
  80. package/templates/.claude/skills/req-scan/references/filters.md +83 -0
  81. package/templates/.claude/skills/req-scan/references/sources.md +76 -0
  82. package/templates/.claude/skills/req-scan/references/state.md +54 -0
  83. package/templates/.claude/skills/req-scan/references/template.md +36 -0
  84. package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
  85. package/templates/.claude/skills/req-screen/SKILL.md +151 -0
  86. package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
  87. package/templates/.claude/skills/req-screen/references/template.md +91 -0
  88. package/templates/.claude/skills/req-track/SKILL.md +189 -0
  89. package/templates/.claude/skills/req-track/references/fields.md +114 -0
  90. package/templates/.claude/skills/req-track/references/signals.md +42 -0
  91. package/templates/.claude/skills/req-track/references/statuses.md +32 -0
  92. package/templates/.claude/skills/req-track/references/templates.md +127 -0
  93. package/templates/.claude/skills/req-workday/SKILL.md +162 -0
  94. package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
  95. package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
  96. package/templates/.claude/skills/req-workday/references/templates.md +126 -0
  97. package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
  98. package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
  99. package/templates/.claude/skills/send-chat/SKILL.md +170 -0
  100. package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
  101. package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
  102. package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
  103. package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
  104. package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
  105. package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
  106. package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
  107. package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
  108. package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
  109. package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
  110. package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
  111. package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
  112. package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
  113. package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
  114. package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
  115. package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
  116. package/templates/.claude/skills/weekly-update/references/template.md +55 -0
  117. package/templates/CLAUDE.md +134 -0
  118. package/templates/USER.md +3 -0
  119. package/templates/apm.yml +5 -0
  120. package/templates/knowledge/Briefings/.gitkeep +0 -0
@@ -0,0 +1,80 @@
1
+ # Apple Calendar Database Schema
2
+
3
+ The Apple Calendar SQLite database uses Core Data. Key tables and their actual
4
+ column names (verified on macOS Sonoma+).
5
+
6
+ ## CalendarItem (events and reminders)
7
+
8
+ | Column | Type | Notes |
9
+ | ---------------- | ------- | ---------------------------------------------------------- |
10
+ | `ROWID` | INTEGER | Primary key |
11
+ | `summary` | TEXT | Event title |
12
+ | `start_date` | REAL | Core Data timestamp (seconds since 2001-01-01 UTC) |
13
+ | `end_date` | REAL | Core Data timestamp (null for all-day events) |
14
+ | `start_tz` | TEXT | IANA timezone (e.g., `Europe/Paris`), `_float` for all-day |
15
+ | `end_tz` | TEXT | IANA timezone, `_float` for all-day |
16
+ | `all_day` | INTEGER | 1 = all-day event |
17
+ | `location_id` | INTEGER | FK → Location.ROWID |
18
+ | `description` | TEXT | Event notes/body |
19
+ | `organizer_id` | INTEGER | FK → Identity.ROWID |
20
+ | `calendar_id` | INTEGER | FK → Calendar.ROWID |
21
+ | `has_attendees` | INTEGER | 1 = event has attendees |
22
+ | `conference_url` | TEXT | Video call URL (often null — check description too) |
23
+ | `entity_type` | INTEGER | 2 = calendar event |
24
+
25
+ ## Identity (organizer info)
26
+
27
+ | Column | Type | Notes |
28
+ | -------------- | ---- | ------------------------------------------------------------- |
29
+ | `display_name` | TEXT | Full name (e.g., `"Chen, Sarah"`) |
30
+ | `address` | TEXT | Email with `mailto:` prefix (e.g., `"mailto:sarah@acme.com"`) |
31
+ | `first_name` | TEXT | Usually null — `display_name` is the reliable field |
32
+ | `last_name` | TEXT | Usually null — `display_name` is the reliable field |
33
+
34
+ **IMPORTANT:** Identity does NOT have an `email` column. Use `address` and strip
35
+ the `mailto:` prefix. Use `display_name` for the name (not
36
+ `first_name`/`last_name`, which are typically null).
37
+
38
+ ## Participant (attendees and organizer)
39
+
40
+ | Column | Type | Notes |
41
+ | ------------- | ------- | -------------------------------------------------- |
42
+ | `ROWID` | INTEGER | Primary key |
43
+ | `entity_type` | INTEGER | 7 = attendee, 8 = organizer |
44
+ | `owner_id` | INTEGER | FK → CalendarItem.ROWID |
45
+ | `identity_id` | INTEGER | FK → Identity.ROWID (for display_name lookup) |
46
+ | `email` | TEXT | Email address (no `mailto:` prefix) |
47
+ | `status` | INTEGER | EKParticipantStatus (see mapping below) |
48
+ | `role` | INTEGER | 0 = unknown, 1 = required, 2 = optional, 3 = chair |
49
+ | `is_self` | INTEGER | 1 = this is the calendar owner |
50
+
51
+ **IMPORTANT:** Participant does NOT have `display_name`, `first_name`, or
52
+ `last_name` columns. To get the attendee's name, JOIN with Identity via
53
+ `identity_id`. There is NO `Attendee` table — only use `Participant`.
54
+
55
+ ### EKParticipantStatus mapping
56
+
57
+ | Value | Status |
58
+ | ----- | ---------- |
59
+ | 0 | unknown |
60
+ | 1 | pending |
61
+ | 2 | accepted |
62
+ | 3 | declined |
63
+ | 4 | tentative |
64
+ | 5 | delegated |
65
+ | 6 | completed |
66
+ | 7 | in-process |
67
+
68
+ ## Calendar (calendar metadata)
69
+
70
+ | Column | Type | Notes |
71
+ | ------- | ------- | ------------- |
72
+ | `ROWID` | INTEGER | Primary key |
73
+ | `title` | TEXT | Calendar name |
74
+
75
+ ## Location
76
+
77
+ | Column | Type | Notes |
78
+ | ------- | ------- | --------------- |
79
+ | `ROWID` | INTEGER | Primary key |
80
+ | `title` | TEXT | Location string |
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Query synced Apple Calendar events by date/time window.
4
+ *
5
+ * Reads JSON event files from ~/.cache/fit/outpost/apple_calendar/ and filters
6
+ * them by date range, time window, or upcoming interval. Designed to eliminate
7
+ * the need for agents to write bespoke calendar-parsing scripts.
8
+ *
9
+ * Usage:
10
+ * node scripts/query.mjs --today Today's events
11
+ * node scripts/query.mjs --tomorrow Tomorrow's events
12
+ * node scripts/query.mjs --upcoming 2h Events in the next 2 hours
13
+ * node scripts/query.mjs --date 2026-03-09 Events on a specific date
14
+ * node scripts/query.mjs --range 2026-03-09 2026-03-11 Events in date range
15
+ * node scripts/query.mjs --today --tomorrow Combine multiple filters
16
+ * node scripts/query.mjs --json Output as JSON (default: table)
17
+ * node scripts/query.mjs --include-all-day Include all-day events
18
+ */
19
+
20
+ import { readFileSync, readdirSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+
24
+ const HOME = homedir();
25
+ const CAL_DIR = join(HOME, ".cache/fit/outpost/apple_calendar");
26
+
27
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
28
+ console.log(`query — filter synced calendar events by date/time
29
+
30
+ Usage:
31
+ node scripts/query.mjs [options]
32
+
33
+ Time filters (combinable):
34
+ --today Events starting today
35
+ --tomorrow Events starting tomorrow
36
+ --upcoming <interval> Events starting within interval (e.g., 2h, 30m, 1d)
37
+ --date <YYYY-MM-DD> Events on a specific date
38
+ --range <start> <end> Events between two dates (inclusive)
39
+
40
+ Output options:
41
+ --json Output as JSON array (default: formatted table)
42
+ --include-all-day Include all-day events (excluded by default)
43
+ --no-attendees Omit attendee names from output
44
+
45
+ Defaults to --today if no time filter is specified.`);
46
+ process.exit(0);
47
+ }
48
+
49
+ // --- Parse arguments ---
50
+
51
+ const args = process.argv.slice(2);
52
+ const flags = {
53
+ today: args.includes("--today"),
54
+ tomorrow: args.includes("--tomorrow"),
55
+ json: args.includes("--json"),
56
+ includeAllDay: args.includes("--include-all-day"),
57
+ noAttendees: args.includes("--no-attendees"),
58
+ upcoming: null,
59
+ date: null,
60
+ rangeStart: null,
61
+ rangeEnd: null,
62
+ };
63
+
64
+ const upcomingIdx = args.indexOf("--upcoming");
65
+ if (upcomingIdx !== -1 && args[upcomingIdx + 1]) {
66
+ flags.upcoming = parseInterval(args[upcomingIdx + 1]);
67
+ }
68
+
69
+ const dateIdx = args.indexOf("--date");
70
+ if (dateIdx !== -1 && args[dateIdx + 1]) {
71
+ flags.date = args[dateIdx + 1];
72
+ }
73
+
74
+ const rangeIdx = args.indexOf("--range");
75
+ if (rangeIdx !== -1 && args[rangeIdx + 1] && args[rangeIdx + 2]) {
76
+ flags.rangeStart = args[rangeIdx + 1];
77
+ flags.rangeEnd = args[rangeIdx + 2];
78
+ }
79
+
80
+ // Default to --today if no time filter specified
81
+ if (
82
+ !flags.today &&
83
+ !flags.tomorrow &&
84
+ !flags.upcoming &&
85
+ !flags.date &&
86
+ !flags.rangeStart
87
+ ) {
88
+ flags.today = true;
89
+ }
90
+
91
+ /**
92
+ * Parse an interval string like "2h", "30m", "1d" into milliseconds.
93
+ */
94
+ function parseInterval(str) {
95
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*(m|min|h|hr|d)$/i);
96
+ if (!match) {
97
+ console.error(`Invalid interval: ${str}. Use e.g., 2h, 30m, 1d`);
98
+ process.exit(1);
99
+ }
100
+ const n = parseFloat(match[1]);
101
+ const unit = match[2].toLowerCase();
102
+ if (unit === "m" || unit === "min") return n * 60 * 1000;
103
+ if (unit === "h" || unit === "hr") return n * 60 * 60 * 1000;
104
+ if (unit === "d") return n * 24 * 60 * 60 * 1000;
105
+ return n * 60 * 60 * 1000; // default hours
106
+ }
107
+
108
+ /**
109
+ * Get the start of a day in local time.
110
+ */
111
+ function dayStart(date) {
112
+ const d = new Date(date);
113
+ d.setHours(0, 0, 0, 0);
114
+ return d;
115
+ }
116
+
117
+ /**
118
+ * Get the end of a day in local time.
119
+ */
120
+ function dayEnd(date) {
121
+ const d = new Date(date);
122
+ d.setHours(23, 59, 59, 999);
123
+ return d;
124
+ }
125
+
126
+ // --- Load events ---
127
+
128
+ let files;
129
+ try {
130
+ files = readdirSync(CAL_DIR).filter((f) => f.endsWith(".json"));
131
+ } catch {
132
+ console.error(
133
+ `Calendar directory not found: ${CAL_DIR}\nRun sync first: node scripts/sync.mjs`,
134
+ );
135
+ process.exit(1);
136
+ }
137
+
138
+ const events = [];
139
+ for (const f of files) {
140
+ try {
141
+ const ev = JSON.parse(readFileSync(join(CAL_DIR, f), "utf8"));
142
+
143
+ // Skip all-day events unless requested
144
+ if (ev.allDay && !flags.includeAllDay) continue;
145
+
146
+ const startStr = ev.start?.dateTime || ev.start?.date;
147
+ if (!startStr) continue;
148
+
149
+ const startDt = new Date(startStr);
150
+ if (isNaN(startDt.getTime())) continue;
151
+
152
+ const endStr = ev.end?.dateTime || ev.end?.date;
153
+ const endDt = endStr ? new Date(endStr) : startDt;
154
+
155
+ events.push({
156
+ summary: ev.summary || "(untitled)",
157
+ start: startDt,
158
+ end: endDt,
159
+ startIso: startStr,
160
+ endIso: endStr || startStr,
161
+ timeZone: ev.start?.timeZone || null,
162
+ allDay: Boolean(ev.allDay),
163
+ location: ev.location || null,
164
+ conferenceUrl: ev.conferenceUrl || null,
165
+ calendar: ev.calendar || null,
166
+ organizer: ev.organizer || null,
167
+ attendees: (ev.attendees || [])
168
+ .filter((a) => !a.self)
169
+ .map((a) => ({ name: a.name || null, email: a.email })),
170
+ });
171
+ } catch {
172
+ // Skip malformed files
173
+ }
174
+ }
175
+
176
+ events.sort((a, b) => a.start - b.start);
177
+
178
+ // --- Filter events ---
179
+
180
+ const now = new Date();
181
+ const today = dayStart(now);
182
+ const todayE = dayEnd(now);
183
+ const tomorrow = dayStart(new Date(now.getTime() + 86400000));
184
+ const tomorrowE = dayEnd(new Date(now.getTime() + 86400000));
185
+
186
+ function inRange(dt, lo, hi) {
187
+ return dt >= lo && dt <= hi;
188
+ }
189
+
190
+ function matchesFilters(ev, f, ref) {
191
+ if (f.today && inRange(ev.start, ref.today, ref.todayE)) return true;
192
+ if (f.tomorrow && inRange(ev.start, ref.tomorrow, ref.tomorrowE)) return true;
193
+ if (f.upcoming) {
194
+ const cutoff = new Date(ref.now.getTime() + f.upcoming);
195
+ if (inRange(ev.start, ref.now, cutoff)) return true;
196
+ }
197
+ if (f.date) {
198
+ if (
199
+ inRange(
200
+ ev.start,
201
+ dayStart(f.date + "T00:00:00"),
202
+ dayEnd(f.date + "T00:00:00"),
203
+ )
204
+ )
205
+ return true;
206
+ }
207
+ if (f.rangeStart && f.rangeEnd) {
208
+ if (
209
+ inRange(
210
+ ev.start,
211
+ dayStart(f.rangeStart + "T00:00:00"),
212
+ dayEnd(f.rangeEnd + "T00:00:00"),
213
+ )
214
+ )
215
+ return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ const ref = { now, today, todayE, tomorrow, tomorrowE };
221
+ const filtered = events.filter((ev) => matchesFilters(ev, flags, ref));
222
+
223
+ // --- Output ---
224
+
225
+ if (flags.json) {
226
+ const output = filtered.map((ev) => {
227
+ const obj = {
228
+ summary: ev.summary,
229
+ start: ev.startIso,
230
+ end: ev.endIso,
231
+ timeZone: ev.timeZone,
232
+ allDay: ev.allDay,
233
+ location: ev.location,
234
+ conferenceUrl: ev.conferenceUrl,
235
+ calendar: ev.calendar,
236
+ organizer: ev.organizer,
237
+ };
238
+ if (!flags.noAttendees) {
239
+ obj.attendees = ev.attendees;
240
+ }
241
+ return obj;
242
+ });
243
+ console.log(JSON.stringify(output, null, 2));
244
+ } else {
245
+ // Formatted table output
246
+ if (filtered.length === 0) {
247
+ console.log("No events found.");
248
+ process.exit(0);
249
+ }
250
+
251
+ // Group by date
252
+ const byDate = new Map();
253
+ for (const ev of filtered) {
254
+ const dateKey = ev.start.toLocaleDateString("sv-SE"); // YYYY-MM-DD
255
+ if (!byDate.has(dateKey)) byDate.set(dateKey, []);
256
+ byDate.get(dateKey).push(ev);
257
+ }
258
+
259
+ for (const [date, dayEvents] of byDate) {
260
+ const isToday = date === now.toLocaleDateString("sv-SE");
261
+ const isTomorrow =
262
+ date === new Date(now.getTime() + 86400000).toLocaleDateString("sv-SE");
263
+ const label = isToday ? " (today)" : isTomorrow ? " (tomorrow)" : "";
264
+ console.log(`\n=== ${date}${label} ===`);
265
+
266
+ for (const ev of dayEvents) {
267
+ const tz =
268
+ ev.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
269
+ const time = ev.allDay
270
+ ? "all-day"
271
+ : ev.start.toLocaleTimeString("en-GB", {
272
+ hour: "2-digit",
273
+ minute: "2-digit",
274
+ timeZone: tz,
275
+ });
276
+ const endTime = ev.allDay
277
+ ? ""
278
+ : "-" +
279
+ ev.end.toLocaleTimeString("en-GB", {
280
+ hour: "2-digit",
281
+ minute: "2-digit",
282
+ timeZone: tz,
283
+ });
284
+
285
+ // Minutes until start (only for future events)
286
+ let countdown = "";
287
+ if (ev.start > now && flags.upcoming) {
288
+ const mins = Math.round((ev.start - now) / 60000);
289
+ countdown = ` [in ${mins}min]`;
290
+ }
291
+
292
+ const parts = [`${time}${endTime}`, ev.summary];
293
+
294
+ if (!flags.noAttendees && ev.attendees.length > 0) {
295
+ const names = ev.attendees
296
+ .slice(0, 8)
297
+ .map((a) => a.name || a.email)
298
+ .join(", ");
299
+ parts.push(names);
300
+ }
301
+
302
+ console.log(parts.join(" | ") + countdown);
303
+ }
304
+ }
305
+
306
+ console.log(`\n${filtered.length} event(s) | now: ${now.toISOString()}`);
307
+ }