@masslessai/push-todo 4.3.0 → 4.5.0

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.
package/lib/api.js CHANGED
@@ -154,9 +154,12 @@ export async function markTaskCompleted(taskId, comment = '') {
154
154
  * @param {string} options.title - Todo title (required)
155
155
  * @param {string|null} options.content - Detailed content (optional)
156
156
  * @param {boolean} options.backlog - Whether to create as backlog item
157
- * @returns {Promise<Object>} Created todo with { id, displayNumber, title, createdAt }
157
+ * @param {string|null} options.reminderDate - ISO8601 reminder date (optional)
158
+ * @param {string|null} options.reminderTimeSource - Time source enum (optional)
159
+ * @param {boolean} options.alarmEnabled - Whether to mark as urgent (optional)
160
+ * @returns {Promise<Object>} Created todo with { id, displayNumber, title, createdAt, reminderDate, reminderEnabled }
158
161
  */
159
- export async function createTodo({ title, content = null, backlog = false }) {
162
+ export async function createTodo({ title, content = null, backlog = false, reminderDate = null, reminderTimeSource = null, alarmEnabled = false }) {
160
163
  const response = await apiRequest('create-todo', {
161
164
  method: 'POST',
162
165
  body: JSON.stringify({
@@ -164,6 +167,10 @@ export async function createTodo({ title, content = null, backlog = false }) {
164
167
  normalizedContent: content || null,
165
168
  isBacklog: backlog,
166
169
  createdByClient: 'cli',
170
+ reminderDate: reminderDate || null,
171
+ reminderEnabled: reminderDate != null,
172
+ reminderTimeSource: reminderTimeSource || null,
173
+ alarmEnabled,
167
174
  }),
168
175
  });
169
176
 
package/lib/cli.js CHANGED
@@ -19,6 +19,7 @@ import { install as installLaunchAgent, uninstall as uninstallLaunchAgent, getSt
19
19
  import { getScreenshotPath, screenshotExists, openScreenshot } from './utils/screenshots.js';
20
20
  import { bold, red, cyan, dim, green } from './utils/colors.js';
21
21
  import { getMachineId } from './machine-id.js';
22
+ import { parseReminder } from './reminder-parser.js';
22
23
 
23
24
  const __filename = fileURLToPath(import.meta.url);
24
25
  const __dirname = dirname(__filename);
@@ -45,6 +46,9 @@ ${bold('USAGE:')}
45
46
  push-todo [options] List active tasks
46
47
  push-todo <number> Show specific task
47
48
  push-todo create <title> Create a new todo
49
+ --remind <text> Set reminder ("tomorrow night", "in 2 hours")
50
+ --remind-at <iso> Set reminder at exact ISO8601 time
51
+ --alarm Mark as urgent (bypasses Focus)
48
52
  push-todo connect Run connection doctor
49
53
  push-todo search <query> Search tasks
50
54
  push-todo review Review completed tasks
@@ -92,6 +96,8 @@ ${bold('EXAMPLES:')}
92
96
  push-todo List active tasks for current project
93
97
  push-todo 427 Show task #427
94
98
  push-todo create "Fix auth bug" Create a new todo
99
+ push-todo create "Debug" --remind "tomorrow night"
100
+ push-todo create "Call" --remind "at 3pm" --alarm
95
101
  push-todo create "Item" --backlog Create as backlog item
96
102
  push-todo -a List all tasks across projects
97
103
  push-todo --queue 1,2,3 Queue tasks 1, 2, 3 for daemon
@@ -126,6 +132,7 @@ ${bold('SCHEDULE (remote scheduled jobs):')}
126
132
  --create-todo <title> Create a new todo each fire
127
133
  --queue-todo <todoId> Re-queue an existing todo each fire
128
134
  --git-remote <remote> Route created todos to a project
135
+ --agent-type <type> Agent: claude-code, openclaw, openai-codex
129
136
  --content <body> Expanded content for created todos
130
137
  push-todo schedule list List all schedules
131
138
  push-todo schedule remove <id> Remove a schedule
@@ -178,6 +185,10 @@ const options = {
178
185
  'store-e2ee-key': { type: 'string' },
179
186
  'description': { type: 'string' },
180
187
  'auto': { type: 'boolean' },
188
+ // Create command options
189
+ 'remind': { type: 'string' },
190
+ 'remind-at': { type: 'string' },
191
+ 'alarm': { type: 'boolean' },
181
192
  // Confirm command options
182
193
  'type': { type: 'string' },
183
194
  'title': { type: 'string' },
@@ -191,6 +202,7 @@ const options = {
191
202
  'cron': { type: 'string' },
192
203
  'create-todo': { type: 'string' },
193
204
  'git-remote': { type: 'string' },
205
+ 'agent-type': { type: 'string' },
194
206
  'queue-todo': { type: 'string' },
195
207
  // Skill CLI options (Phase 3)
196
208
  'report-progress': { type: 'string' },
@@ -684,22 +696,74 @@ export async function run(argv) {
684
696
  console.error('');
685
697
  console.error('Usage:');
686
698
  console.error(' push-todo create "Fix the auth bug"');
687
- console.error(' push-todo create "Fix the auth bug" --backlog');
688
- console.error(' push-todo create "Fix the auth bug" --content "Detailed description"');
699
+ console.error(' push-todo create "Debug daemon" --remind "tomorrow night"');
700
+ console.error(' push-todo create "Call dentist" --remind "at 3pm" --alarm');
701
+ console.error(' push-todo create "Deploy" --remind-at "2026-03-03T14:00:00"');
689
702
  process.exit(1);
690
703
  }
691
704
 
705
+ // Conflict check
706
+ if (values.remind && values['remind-at']) {
707
+ console.error(red('Error: Use --remind OR --remind-at, not both.'));
708
+ process.exit(1);
709
+ }
710
+
711
+ // Parse reminder
712
+ let reminderDate = null;
713
+ let reminderTimeSource = null;
714
+ const alarmEnabled = values.alarm || false;
715
+
716
+ if (values['remind-at']) {
717
+ const parsed = new Date(values['remind-at']);
718
+ if (isNaN(parsed.getTime())) {
719
+ console.error(red('Error: --remind-at must be a valid ISO8601 date.'));
720
+ console.error(dim(' Example: 2026-03-03T14:00:00'));
721
+ process.exit(1);
722
+ }
723
+ if (parsed <= new Date()) {
724
+ console.error(red('Error: --remind-at must be in the future.'));
725
+ process.exit(1);
726
+ }
727
+ reminderDate = parsed.toISOString();
728
+ reminderTimeSource = 'userSpecified';
729
+ } else if (values.remind) {
730
+ const result = parseReminder(values.remind);
731
+ if (!result.date) {
732
+ console.error(red(`Error: Could not parse reminder: "${values.remind}"`));
733
+ console.error('');
734
+ console.error('Examples:');
735
+ console.error(' --remind "tomorrow"');
736
+ console.error(' --remind "tonight"');
737
+ console.error(' --remind "in 2 hours"');
738
+ console.error(' --remind "next monday at 3pm"');
739
+ process.exit(1);
740
+ }
741
+ reminderDate = result.date.toISOString();
742
+ reminderTimeSource = result.timeSource;
743
+ }
744
+
692
745
  try {
693
746
  const todo = await api.createTodo({
694
747
  title,
695
748
  content: values.content || null,
696
749
  backlog: values.backlog || false,
750
+ reminderDate,
751
+ reminderTimeSource,
752
+ alarmEnabled,
697
753
  });
698
754
 
699
755
  if (values.json) {
700
756
  console.log(JSON.stringify(todo, null, 2));
701
757
  } else {
702
- console.log(green(`Created todo #${todo.displayNumber}: ${todo.title}`));
758
+ let msg = green(`Created todo #${todo.displayNumber}: ${todo.title}`);
759
+ if (reminderDate) {
760
+ const d = new Date(reminderDate);
761
+ msg += `\n ${dim('Reminder:')} ${d.toLocaleString()}`;
762
+ }
763
+ if (alarmEnabled) {
764
+ msg += ` ${dim('(urgent)')}`;
765
+ }
766
+ console.log(msg);
703
767
  }
704
768
  } catch (error) {
705
769
  console.error(red(`Failed to create todo: ${error.message}`));
@@ -763,6 +827,9 @@ export async function run(argv) {
763
827
  process.exit(1);
764
828
  }
765
829
 
830
+ // Resolve agent type: explicit flag > auto-detect from caller
831
+ const agentType = values['agent-type'] || null;
832
+
766
833
  try {
767
834
  const response = await api.apiRequest('manage-schedules', {
768
835
  method: 'POST',
@@ -771,6 +838,7 @@ export async function run(argv) {
771
838
  scheduleType,
772
839
  scheduleValue,
773
840
  actionType,
841
+ agentType,
774
842
  actionTitle,
775
843
  actionContent,
776
844
  gitRemote,
@@ -822,7 +890,8 @@ export async function run(argv) {
822
890
  const actionStr = s.action_type === 'create-todo'
823
891
  ? `create-todo: ${s.action_title || ''}`
824
892
  : `queue-todo: ${s.todo_id || '?'}`;
825
- console.log(` ${status} ${s.name} [${schedStr}] ${actionStr}`);
893
+ const agentStr = s.agent_type && s.agent_type !== 'claude-code' ? ` (${s.agent_type})` : '';
894
+ console.log(` ${status} ${s.name} [${schedStr}] → ${actionStr}${agentStr}`);
826
895
  console.log(dim(` ID: ${s.id.slice(0, 8)} | Next: ${s.next_run_at || 'N/A'} | Last: ${s.last_run_at || 'never'}`));
827
896
  }
828
897
  } catch (error) {
@@ -896,6 +965,7 @@ ${bold('Examples:')}
896
965
  push-todo schedule add --name "Daily standup" --every 24h --create-todo "Write standup update"
897
966
  push-todo schedule add --name "Weekly review" --cron "0 9 * * 1" --create-todo "Weekly code review" --git-remote github.com/user/repo
898
967
  push-todo schedule add --name "Re-run task" --every 4h --queue-todo <todoId>
968
+ push-todo schedule add --name "Codex review" --every 12h --create-todo "Review PRs" --git-remote github.com/user/repo --agent-type openai-codex
899
969
  `);
900
970
  return;
901
971
  }
package/lib/cron.js CHANGED
@@ -196,16 +196,21 @@ export function computeNextRun(schedule, fromDate = new Date()) {
196
196
  * @param {Function} [logFn] - Optional log function
197
197
  * @param {Object} [context] - Injected dependencies from daemon
198
198
  * @param {Function} [context.apiRequest] - API request function
199
+ * @param {string} [context.machineId] - This machine's ID for targeted schedule filtering
199
200
  */
200
201
  export async function checkAndRunRemoteSchedules(logFn, context = {}) {
201
202
  const log = logFn || (() => {});
202
203
 
203
204
  if (!context.apiRequest) return;
204
205
 
205
- // 1. Fetch due schedules
206
+ // 1. Fetch due schedules (filtered by machine if available)
206
207
  let schedules;
207
208
  try {
208
- const response = await context.apiRequest('manage-schedules?due=true', {
209
+ let endpoint = 'manage-schedules?due=true';
210
+ if (context.machineId) {
211
+ endpoint += `&machine_id=${encodeURIComponent(context.machineId)}`;
212
+ }
213
+ const response = await context.apiRequest(endpoint, {
209
214
  method: 'GET',
210
215
  });
211
216
  if (!response.ok) {
@@ -226,6 +231,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
226
231
  // 2. Fire each due schedule
227
232
  for (const schedule of schedules) {
228
233
  const expectedNextRunAt = schedule.next_run_at;
234
+ let actionSucceeded = false;
229
235
 
230
236
  try {
231
237
  if (schedule.action_type === 'create-todo') {
@@ -238,7 +244,8 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
238
244
  };
239
245
  if (schedule.git_remote) {
240
246
  payload.gitRemote = schedule.git_remote;
241
- payload.actionType = 'claude-code';
247
+ // Use schedule's agent_type instead of hardcoding claude-code
248
+ payload.actionType = schedule.agent_type || 'claude-code';
242
249
  }
243
250
 
244
251
  const todoResponse = await context.apiRequest('create-todo', {
@@ -248,6 +255,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
248
255
  if (todoResponse.ok) {
249
256
  const todoData = await todoResponse.json();
250
257
  log(`Schedule "${schedule.name}": created todo #${todoData.todo?.displayNumber || '?'}`);
258
+ actionSucceeded = true;
251
259
  } else {
252
260
  log(`Schedule "${schedule.name}": create-todo failed (HTTP ${todoResponse.status})`);
253
261
  }
@@ -277,6 +285,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
277
285
  });
278
286
  if (queueResponse.ok) {
279
287
  log(`Schedule "${schedule.name}": queued todo ${schedule.todo_id}`);
288
+ actionSucceeded = true;
280
289
  } else {
281
290
  log(`Schedule "${schedule.name}": queue-todo failed (HTTP ${queueResponse.status})`);
282
291
  }
@@ -285,7 +294,12 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
285
294
  log(`Schedule "${schedule.name}": execution error: ${error.message}`);
286
295
  }
287
296
 
288
- // 3. Advance next_run_at (with optimistic lock)
297
+ // 3. Advance next_run_at only if action succeeded (prevents silent lost fires)
298
+ if (!actionSucceeded) {
299
+ log(`Schedule "${schedule.name}": action failed, will retry next cycle`);
300
+ continue;
301
+ }
302
+
289
303
  const now = new Date();
290
304
  const scheduleConfig = { type: schedule.schedule_type, value: schedule.schedule_value };
291
305
 
package/lib/daemon.js CHANGED
@@ -550,85 +550,6 @@ async function fetchQueuedTasks() {
550
550
  }
551
551
  }
552
552
 
553
- // ==================== Scheduled Reminder Bridge ====================
554
-
555
- async function fetchScheduledTodos() {
556
- try {
557
- const machineId = getMachineId();
558
- const params = new URLSearchParams();
559
- params.set('scheduled_before', new Date().toISOString());
560
- if (machineId) {
561
- params.set('machine_id', machineId);
562
- }
563
-
564
- // Include registered git_remotes so backend can scope to this machine's projects
565
- const projects = getListedProjects();
566
- const gitRemotes = Object.keys(projects);
567
- const headers = {};
568
- if (machineId && gitRemotes.length > 0) {
569
- headers['X-Machine-Id'] = machineId;
570
- headers['X-Git-Remotes'] = gitRemotes.join(',');
571
- }
572
-
573
- const response = await apiRequest(`synced-todos?${params}`, { headers });
574
- if (!response.ok) return [];
575
-
576
- const data = await response.json();
577
- return data.todos || [];
578
- } catch (error) {
579
- logError(`Failed to fetch scheduled todos: ${error.message}`);
580
- return [];
581
- }
582
- }
583
-
584
- async function checkAndQueueScheduledTodos() {
585
- const scheduledTodos = await fetchScheduledTodos();
586
- if (scheduledTodos.length === 0) return;
587
-
588
- const registeredProjects = getListedProjects();
589
-
590
- for (const todo of scheduledTodos) {
591
- const dn = todo.displayNumber || todo.display_number;
592
- const todoId = todo.id;
593
-
594
- // Skip tasks already in an execution state (running, queued, completed, failed, etc.)
595
- const execStatus = todo.executionStatus || todo.execution_status;
596
- if (execStatus && execStatus !== 'none') {
597
- continue;
598
- }
599
-
600
- // Skip completed tasks
601
- if (todo.isCompleted || todo.is_completed) {
602
- continue;
603
- }
604
-
605
- // Skip tasks whose project is not registered on this machine
606
- const gitRemote = todo.gitRemote || todo.git_remote;
607
- if (gitRemote && Object.keys(registeredProjects).length > 0) {
608
- if (!(gitRemote in registeredProjects)) {
609
- log(`Schedule skipped #${dn}: project ${gitRemote} not registered on this machine`);
610
- continue;
611
- }
612
- }
613
-
614
- log(`Schedule triggered for #${dn} (reminder_date: ${todo.reminderDate || todo.reminder_date})`);
615
-
616
- try {
617
- await updateTaskStatus(dn, 'queued', {
618
- event: {
619
- type: 'scheduled_trigger',
620
- timestamp: new Date().toISOString(),
621
- summary: 'Auto-queued: reminder time reached',
622
- }
623
- }, todoId);
624
-
625
- log(`Queued #${dn} via schedule trigger`);
626
- } catch (error) {
627
- logError(`Failed to queue scheduled todo #${dn}: ${error.message}`);
628
- }
629
- }
630
- }
631
-
632
553
  // ==================== Task Status Updates ====================
633
554
 
634
555
  async function updateTaskStatus(displayNumber, status, extra = {}, todoId = null) {
@@ -3239,18 +3160,11 @@ async function mainLoop() {
3239
3160
  try {
3240
3161
  await checkTimeouts();
3241
3162
 
3242
- // Schedule bridge: auto-queue todos whose reminder time has arrived
3243
- try {
3244
- await checkAndQueueScheduledTodos();
3245
- } catch (error) {
3246
- logError(`Scheduled todo check error: ${error.message}`);
3247
- }
3248
-
3249
3163
  await pollAndExecute();
3250
3164
 
3251
3165
  // Remote schedules from Supabase
3252
3166
  try {
3253
- await checkAndRunRemoteSchedules(log, { apiRequest });
3167
+ await checkAndRunRemoteSchedules(log, { apiRequest, machineId: getMachineId() });
3254
3168
  } catch (error) {
3255
3169
  logError(`Remote schedules error: ${error.message}`);
3256
3170
  }
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Reminder parser for Push CLI.
3
+ *
4
+ * Ports the iOS three-tier agent scheduling model to JavaScript.
5
+ * Source of truth: App/Data/TodoItem+ReminderParsing.swift
6
+ *
7
+ * Three tiers:
8
+ * Tier 1 — ASAP: "today", "now", "asap" → now + 1 minute
9
+ * Tier 2 — Deferred: "tomorrow", "next week", "morning" → target day at default hour
10
+ * Tier 3 — Precise: "at 3pm", "in 2 hours" → exact requested time
11
+ *
12
+ * Key difference from iOS: CLI cannot access user-configurable ReminderSettings.
13
+ * Uses factory defaults from ReminderSettings.swift instead.
14
+ */
15
+
16
+ // Fixed defaults matching iOS ReminderSettings factory values
17
+ const DEFAULTS = {
18
+ general: 9, // ReminderSettings.swift:32 — defaultReminderHour
19
+ morning: 9, // ReminderSettings.swift:45 — defaultMorningHour
20
+ evening: 18, // ReminderSettings.swift:53 — defaultEveningHour
21
+ night: 21, // ReminderParsing.swift:223 — hardcoded
22
+ afternoon: 14, // ReminderParsing.swift:200 — hardcoded
23
+ endOfDay: 17, // ReminderParsing.swift:171 — hardcoded
24
+ noon: 12, // ReminderParsing.swift:233 — hardcoded
25
+ };
26
+
27
+ /**
28
+ * Parse a natural language reminder description into a concrete Date.
29
+ *
30
+ * @param {string|null} description - Natural language like "tomorrow night", "at 3pm"
31
+ * @param {Date} [now] - Current time (for testing). Defaults to new Date().
32
+ * @returns {{ date: Date|null, timeSource: string|null }}
33
+ */
34
+ export function parseReminder(description, now = new Date()) {
35
+ if (!description || typeof description !== 'string') {
36
+ return { date: null, timeSource: null };
37
+ }
38
+
39
+ const text = description.toLowerCase().trim();
40
+ if (!text) {
41
+ return { date: null, timeSource: null };
42
+ }
43
+
44
+ // Tier 1 anchor: 1-minute buffer so reminderDate is always > now after sync latency.
45
+ const asapDate = new Date(now.getTime() + 60_000);
46
+
47
+ // ============================================================
48
+ // PHASE 1: Precise relative patterns (Tier 3)
49
+ // "in X minutes/hours" — inherently future, no rollover needed.
50
+ // "in X days" — day-level, uses defaultReminderHour on target day.
51
+ // ============================================================
52
+
53
+ if (text.startsWith('in ') || text.includes('half an hour') || text.includes('half hour')) {
54
+ if (text.includes('half an hour') || text.includes('half hour')) {
55
+ return { date: addMinutes(now, 30), timeSource: 'userSpecified' };
56
+ }
57
+ if (text.includes('an hour') && !text.includes('half')) {
58
+ return { date: addHours(now, 1), timeSource: 'userSpecified' };
59
+ }
60
+ if (text.includes('minute')) {
61
+ const n = extractNumber(text);
62
+ if (n) return { date: addMinutes(now, n), timeSource: 'userSpecified' };
63
+ }
64
+ if (text.includes('hour')) {
65
+ const n = extractNumber(text);
66
+ if (n) return { date: addHours(now, n), timeSource: 'userSpecified' };
67
+ }
68
+ if (text.includes('day')) {
69
+ const n = extractNumber(text);
70
+ if (n) {
71
+ const future = addDays(now, n);
72
+ return { date: setTime(future, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
73
+ }
74
+ }
75
+ }
76
+
77
+ // ============================================================
78
+ // PHASE 2: ASAP (Tier 1) — execute on next daemon poll
79
+ // ============================================================
80
+
81
+ if (text.includes('right now') || text === 'now' ||
82
+ text.includes('asap') || text === 'soon' ||
83
+ text.includes('immediately')) {
84
+ return { date: asapDate, timeSource: 'userSpecified' };
85
+ }
86
+
87
+ if (text === 'today') {
88
+ return { date: asapDate, timeSource: 'userSpecified' };
89
+ }
90
+
91
+ if (text.includes('later today') || text.includes('later on')) {
92
+ return { date: asapDate, timeSource: 'userSpecified' };
93
+ }
94
+
95
+ // ============================================================
96
+ // PHASE 3: End-of-day expressions (Tier 2)
97
+ // ============================================================
98
+
99
+ if (text.includes('end of day') || text.includes('eod') ||
100
+ text.includes('close of business') || text.includes('cob') ||
101
+ text.includes('by end of')) {
102
+ const baseDate = extractBaseDate(text, now);
103
+ const eodDate = setTime(baseDate, DEFAULTS.endOfDay, 0);
104
+ return { date: ensureFutureTime(eodDate, now), timeSource: 'userSpecified' };
105
+ }
106
+
107
+ // ============================================================
108
+ // PHASE 4: Time-of-day patterns (Tier 2, with day-anchor awareness)
109
+ // ============================================================
110
+
111
+ if (text.includes('morning')) {
112
+ const baseDate = extractBaseDate(text, now);
113
+ const morningDate = setTime(baseDate, DEFAULTS.morning, 0);
114
+ if (isDayAnchored(text) && morningDate <= now) {
115
+ return { date: asapDate, timeSource: 'userSpecified' };
116
+ }
117
+ return { date: ensureFutureTime(morningDate, now), timeSource: 'defaultMorning' };
118
+ }
119
+
120
+ if (text.includes('afternoon')) {
121
+ const baseDate = extractBaseDate(text, now);
122
+ const afternoonDate = setTime(baseDate, DEFAULTS.afternoon, 0);
123
+ if (isDayAnchored(text) && afternoonDate <= now) {
124
+ return { date: asapDate, timeSource: 'userSpecified' };
125
+ }
126
+ return { date: ensureFutureTime(afternoonDate, now), timeSource: 'userSpecified' };
127
+ }
128
+
129
+ if (text.includes('evening') || text.includes('tonight')) {
130
+ const baseDate = extractBaseDate(text, now);
131
+ const eveningDate = setTime(baseDate, DEFAULTS.evening, 0);
132
+ if (isDayAnchored(text) && eveningDate <= now) {
133
+ return { date: asapDate, timeSource: 'defaultEvening' };
134
+ }
135
+ return { date: ensureFutureTime(eveningDate, now), timeSource: 'defaultEvening' };
136
+ }
137
+
138
+ // "night" without "tonight" (checked after evening/tonight to avoid double-match)
139
+ if (text.includes('night') && !text.includes('tonight')) {
140
+ const baseDate = extractBaseDate(text, now);
141
+ const nightDate = setTime(baseDate, DEFAULTS.night, 0);
142
+ if (isDayAnchored(text) && nightDate <= now) {
143
+ return { date: asapDate, timeSource: 'userSpecified' };
144
+ }
145
+ return { date: ensureFutureTime(nightDate, now), timeSource: 'userSpecified' };
146
+ }
147
+
148
+ if (text.includes('noon') || text.includes('midday')) {
149
+ const baseDate = extractBaseDate(text, now);
150
+ const noonDate = setTime(baseDate, DEFAULTS.noon, 0);
151
+ if (isDayAnchored(text) && noonDate <= now) {
152
+ return { date: asapDate, timeSource: 'userSpecified' };
153
+ }
154
+ return { date: ensureFutureTime(noonDate, now), timeSource: 'userSpecified' };
155
+ }
156
+
157
+ // ============================================================
158
+ // PHASE 5: Specific time patterns (Tier 3 — Precise)
159
+ // ============================================================
160
+
161
+ const time = extractTime(text, now);
162
+ if (time) {
163
+ const baseDate = extractBaseDate(text, now);
164
+ const specificDate = setTime(baseDate, time.hour, time.minute);
165
+ return { date: ensureFutureTime(specificDate, now), timeSource: 'userSpecified' };
166
+ }
167
+
168
+ // ============================================================
169
+ // PHASE 6: Deferred day references (Tier 2)
170
+ // Note: compound forms like "tomorrow morning" are caught in Phase 4.
171
+ // ============================================================
172
+
173
+ if (text.includes('tomorrow')) {
174
+ const tomorrow = addDays(now, 1);
175
+ return { date: setTime(tomorrow, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
176
+ }
177
+
178
+ if (text.includes('next week')) {
179
+ const nextWeek = addDays(now, 7);
180
+ return { date: setTime(nextWeek, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
181
+ }
182
+
183
+ if (text.includes('this week')) {
184
+ const friday = thisFriday(now, DEFAULTS.general);
185
+ if (friday === null) {
186
+ // It's Saturday — this week's Friday is yesterday
187
+ return { date: asapDate, timeSource: 'defaultGeneral' };
188
+ }
189
+ if (friday <= now) {
190
+ return { date: asapDate, timeSource: 'defaultGeneral' };
191
+ }
192
+ return { date: friday, timeSource: 'defaultGeneral' };
193
+ }
194
+
195
+ // Weekday names
196
+ const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
197
+ for (let i = 0; i < weekdays.length; i++) {
198
+ if (text.includes(weekdays[i])) {
199
+ const currentWeekday = now.getDay(); // 0=Sunday
200
+
201
+ if (currentWeekday === i) {
202
+ // Today IS the named weekday
203
+ const weekdayTime = extractTime(text, now);
204
+ if (weekdayTime) {
205
+ const specificDate = setTime(now, weekdayTime.hour, weekdayTime.minute);
206
+ return { date: ensureFutureTime(specificDate, now), timeSource: 'userSpecified' };
207
+ }
208
+ // No time specified → ASAP (user means "this [weekday]" = today)
209
+ return { date: asapDate, timeSource: 'userSpecified' };
210
+ }
211
+
212
+ // Different weekday → next occurrence
213
+ const nextDate = nextWeekday(i, now);
214
+ if (nextDate) {
215
+ const weekdayTime = extractTime(text, now);
216
+ if (weekdayTime) {
217
+ return { date: setTime(nextDate, weekdayTime.hour, weekdayTime.minute), timeSource: 'userSpecified' };
218
+ }
219
+ return { date: setTime(nextDate, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
220
+ }
221
+ }
222
+ }
223
+
224
+ // No pattern matched
225
+ return { date: null, timeSource: null };
226
+ }
227
+
228
+ // ==================== Helper Functions ====================
229
+
230
+ /**
231
+ * Extract a positive integer from text. Handles digits and word numbers.
232
+ * Port of iOS extractNumber(from:)
233
+ */
234
+ function extractNumber(text) {
235
+ // Try numeric digits
236
+ const digits = text.replace(/[^\d]/g, '');
237
+ if (digits) {
238
+ const n = parseInt(digits, 10);
239
+ if (n > 0) return n;
240
+ }
241
+
242
+ // Word numbers
243
+ const wordNumbers = {
244
+ 'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5,
245
+ 'six': 6, 'seven': 7, 'eight': 8, 'nine': 9, 'ten': 10,
246
+ 'fifteen': 15, 'twenty': 20, 'thirty': 30, 'forty five': 45,
247
+ };
248
+
249
+ for (const [word, number] of Object.entries(wordNumbers)) {
250
+ if (text.includes(word)) return number;
251
+ }
252
+
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * Extract hour:minute from time expressions.
258
+ * Port of iOS extractTime(from:now:)
259
+ */
260
+ function extractTime(text, now = new Date()) {
261
+ // Pattern 1: X:XX am/pm or X:XX p.m.
262
+ const colonAmPm = text.match(/(\d{1,2}):(\d{2})\s*(a\.?m\.?|p\.?m\.?)/i);
263
+ if (colonAmPm) {
264
+ let hour = parseInt(colonAmPm[1], 10);
265
+ const minute = parseInt(colonAmPm[2], 10);
266
+ const period = colonAmPm[3].toLowerCase();
267
+ if (period.startsWith('p') && hour < 12) hour += 12;
268
+ if (period.startsWith('a') && hour === 12) hour = 0;
269
+ if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
270
+ return { hour, minute };
271
+ }
272
+ }
273
+
274
+ // Pattern 2: X am/pm or X p.m.
275
+ const bareAmPm = text.match(/(\d{1,2})\s*(a\.?m\.?|p\.?m\.?)/i);
276
+ if (bareAmPm) {
277
+ let hour = parseInt(bareAmPm[1], 10);
278
+ const period = bareAmPm[2].toLowerCase();
279
+ if (period.startsWith('p') && hour < 12) hour += 12;
280
+ if (period.startsWith('a') && hour === 12) hour = 0;
281
+ if (hour >= 0 && hour <= 23) {
282
+ return { hour, minute: 0 };
283
+ }
284
+ }
285
+
286
+ // Pattern 3: 24-hour format X:XX (no am/pm)
287
+ const twentyFour = text.match(/(\d{1,2}):(\d{2})(?!\s*(a|p))/i);
288
+ if (twentyFour) {
289
+ const hour = parseInt(twentyFour[1], 10);
290
+ const minute = parseInt(twentyFour[2], 10);
291
+ if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
292
+ return { hour, minute };
293
+ }
294
+ }
295
+
296
+ // Pattern 4: Bare number with "at" prefix — infer AM/PM from current time
297
+ const bareNumber = text.match(/(?:at\s+)?(\d{1,2})(?:\s*o'?clock)?/i);
298
+ if (bareNumber) {
299
+ const num = parseInt(bareNumber[1], 10);
300
+ if (num >= 1 && num <= 12) {
301
+ const currentHour = now.getHours();
302
+ const amHour = num === 12 ? 0 : num;
303
+ const pmHour = num === 12 ? 12 : num + 12;
304
+
305
+ let inferredHour;
306
+ if (currentHour < amHour) {
307
+ inferredHour = amHour;
308
+ } else if (currentHour < pmHour) {
309
+ inferredHour = pmHour;
310
+ } else {
311
+ inferredHour = amHour; // Both passed → AM tomorrow (ensureFutureTime will roll)
312
+ }
313
+ return { hour: inferredHour, minute: 0 };
314
+ }
315
+ }
316
+
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * Extract the base date from compound expressions.
322
+ * Port of iOS extractBaseDate(from:calendar:now:)
323
+ */
324
+ function extractBaseDate(text, now) {
325
+ if (text.includes('tomorrow')) {
326
+ return addDays(now, 1);
327
+ }
328
+ if (text.includes('today') || text.includes('tonight') || text.includes('this')) {
329
+ return now;
330
+ }
331
+
332
+ const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
333
+ for (let i = 0; i < weekdays.length; i++) {
334
+ if (text.includes(weekdays[i])) {
335
+ return nextWeekday(i, now) || now;
336
+ }
337
+ }
338
+
339
+ return now;
340
+ }
341
+
342
+ /**
343
+ * Check if the description is explicitly pinned to this calendar day.
344
+ * Port of iOS isDayAnchored(_:)
345
+ */
346
+ function isDayAnchored(text) {
347
+ if (text.includes('tonight')) return true;
348
+
349
+ const phrases = [
350
+ 'this morning', 'this afternoon', 'this evening', 'this night', 'this noon',
351
+ 'today morning', 'today afternoon', 'today evening', 'today night', 'today noon',
352
+ ];
353
+ return phrases.some(p => text.includes(p));
354
+ }
355
+
356
+ /**
357
+ * Returns this week's Friday at the given hour, or null if it's Saturday.
358
+ * Port of iOS thisFriday(from:calendar:defaultHour:)
359
+ */
360
+ function thisFriday(now, defaultHour) {
361
+ const fridayWeekday = 5; // JS: 0=Sun, 5=Fri
362
+ const currentWeekday = now.getDay();
363
+ const daysToFriday = fridayWeekday - currentWeekday;
364
+
365
+ if (daysToFriday < 0) {
366
+ // It's Saturday — this week's Friday is yesterday
367
+ return null;
368
+ }
369
+
370
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
371
+ const targetDay = addDays(startOfDay, daysToFriday);
372
+ return setTime(targetDay, defaultHour, 0);
373
+ }
374
+
375
+ /**
376
+ * Returns the next occurrence of the given weekday.
377
+ * Port of iOS nextWeekday(_:from:)
378
+ */
379
+ function nextWeekday(weekday, now) {
380
+ const currentWeekday = now.getDay();
381
+ let daysToAdd = weekday - currentWeekday;
382
+ if (daysToAdd <= 0) {
383
+ daysToAdd += 7;
384
+ }
385
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
386
+ return addDays(startOfDay, daysToAdd);
387
+ }
388
+
389
+ /**
390
+ * Rolls a past time to the next day at the same time.
391
+ * Port of iOS ensureFutureTime(_:now:calendar:)
392
+ */
393
+ function ensureFutureTime(date, now) {
394
+ if (date > now) return date;
395
+ return addDays(date, 1);
396
+ }
397
+
398
+ // ==================== Date Utilities ====================
399
+
400
+ function addMinutes(date, minutes) {
401
+ return new Date(date.getTime() + minutes * 60_000);
402
+ }
403
+
404
+ function addHours(date, hours) {
405
+ return new Date(date.getTime() + hours * 3_600_000);
406
+ }
407
+
408
+ function addDays(date, days) {
409
+ const result = new Date(date);
410
+ result.setDate(result.getDate() + days);
411
+ return result;
412
+ }
413
+
414
+ function setTime(date, hour, minute) {
415
+ const result = new Date(date);
416
+ result.setHours(hour, minute, 0, 0);
417
+ return result;
418
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {