daily-focus 0.1.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.
@@ -0,0 +1,987 @@
1
+ import {
2
+ BRIEF_REFRESH_GRACE_HOURS,
3
+ describeSchedule,
4
+ nextRunDate,
5
+ resolveSchedule,
6
+ runsOn
7
+ } from "./chunk-G4K24I4M.js";
8
+ import {
9
+ calendarDaysBetween,
10
+ isDateOnly,
11
+ isSameLocalDay,
12
+ isWorkingDay,
13
+ localDateKey,
14
+ parseISO,
15
+ startOfLocalDay,
16
+ workingDaysSince,
17
+ workingMsBetween
18
+ } from "./chunk-EXGTFXNV.js";
19
+
20
+ // src/fs.ts
21
+ import { mkdir, rename, writeFile } from "node:fs/promises";
22
+ import { dirname } from "node:path";
23
+ async function writeJsonAtomic(path, value) {
24
+ await mkdir(dirname(path), { recursive: true });
25
+ const temp = tempPathFor(path);
26
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}
27
+ `, "utf8");
28
+ await rename(temp, path);
29
+ }
30
+ function tempPathFor(path) {
31
+ return `${path}.tmp`;
32
+ }
33
+
34
+ // src/ids.ts
35
+ function canonicalId(id) {
36
+ return id.trim().toLowerCase();
37
+ }
38
+ function fingerprintId(id) {
39
+ return id.toLowerCase().replace(/[^a-z0-9]/g, "");
40
+ }
41
+
42
+ // src/archive.ts
43
+ import { readFile, readdir } from "node:fs/promises";
44
+ import { resolve } from "node:path";
45
+ var ARCHIVE_FILE = /^items-(\d{4}-\d{2}-\d{2})\.json$/;
46
+ var MAX_ARCHIVE_FILES = 130;
47
+ var RECENT_WINDOW_DAYS = 14;
48
+ function archivePath(config, dateKey) {
49
+ return resolve(config.archiveDir, `items-${dateKey}.json`);
50
+ }
51
+ function briefDateKey(brief) {
52
+ if (brief.date && /^\d{4}-\d{2}-\d{2}$/.test(brief.date)) return brief.date;
53
+ const generated = parseISO(brief.generatedAt);
54
+ return localDateKey(generated ?? /* @__PURE__ */ new Date());
55
+ }
56
+ async function archiveBrief(config, brief) {
57
+ const dateKey = briefDateKey(brief);
58
+ const target = archivePath(config, dateKey);
59
+ try {
60
+ const existing = JSON.parse(await readFile(target, "utf8"));
61
+ const existingAt = parseISO(existing.generatedAt)?.getTime() ?? 0;
62
+ const incomingAt = parseISO(brief.generatedAt)?.getTime() ?? 0;
63
+ if (existingAt >= incomingAt) return false;
64
+ } catch {
65
+ }
66
+ await writeJsonAtomic(target, brief);
67
+ return true;
68
+ }
69
+ async function listArchivedDates(config) {
70
+ let names;
71
+ try {
72
+ names = await readdir(config.archiveDir);
73
+ } catch {
74
+ return [];
75
+ }
76
+ return names.map((name) => ARCHIVE_FILE.exec(name)?.[1]).filter((date) => date !== void 0).sort();
77
+ }
78
+ async function readArchiveIndex(config) {
79
+ const index = /* @__PURE__ */ new Map();
80
+ const dated = (await listArchivedDates(config)).reverse().slice(0, MAX_ARCHIVE_FILES);
81
+ for (const date of dated) {
82
+ let brief;
83
+ try {
84
+ brief = JSON.parse(await readFile(archivePath(config, date), "utf8"));
85
+ } catch {
86
+ continue;
87
+ }
88
+ for (const item of brief.items ?? []) {
89
+ if (!item?.id) continue;
90
+ const existing = index.get(item.id);
91
+ if (!existing) {
92
+ index.set(item.id, {
93
+ title: item.title ?? item.id,
94
+ advancesObjective: item.advancesObjective === true,
95
+ firstSeen: item.firstSeen
96
+ });
97
+ continue;
98
+ }
99
+ if (item.firstSeen && (!existing.firstSeen || item.firstSeen < existing.firstSeen)) {
100
+ existing.firstSeen = item.firstSeen;
101
+ }
102
+ }
103
+ }
104
+ return index;
105
+ }
106
+ function computeObjectiveProgress(index, actions, now, counts = isWorkingDay) {
107
+ const lastAction = /* @__PURE__ */ new Map();
108
+ for (const action of actions) {
109
+ if (action.action === "note") continue;
110
+ lastAction.set(canonicalId(action.id), action);
111
+ }
112
+ const byCanonical = /* @__PURE__ */ new Map();
113
+ for (const [id, entry] of index) byCanonical.set(canonicalId(id), entry);
114
+ let lastAt = null;
115
+ let lastTitle = null;
116
+ let recentCount = 0;
117
+ for (const [id, action] of lastAction) {
118
+ if (action.action !== "done") continue;
119
+ if (!byCanonical.get(id)?.advancesObjective) continue;
120
+ const at = parseISO(action.at);
121
+ if (!at) continue;
122
+ if (workingDaysSince(at, now, counts) <= RECENT_WINDOW_DAYS) recentCount++;
123
+ if (!lastAt || at.getTime() > lastAt.getTime()) {
124
+ lastAt = at;
125
+ lastTitle = byCanonical.get(id)?.title ?? null;
126
+ }
127
+ }
128
+ return {
129
+ workingDaysSince: lastAt ? workingDaysSince(lastAt, now, counts) : null,
130
+ lastAt: lastAt ? lastAt.toISOString() : null,
131
+ lastTitle,
132
+ recentCount,
133
+ windowDays: RECENT_WINDOW_DAYS
134
+ };
135
+ }
136
+
137
+ // src/validate.ts
138
+ var SOURCES = /* @__PURE__ */ new Set([
139
+ "github",
140
+ "email",
141
+ "calendar",
142
+ "jira",
143
+ "slack",
144
+ "workday",
145
+ "tasks",
146
+ "atlassian",
147
+ "reminders",
148
+ "messages",
149
+ "eboks",
150
+ "other"
151
+ ]);
152
+ var KINDS = /* @__PURE__ */ new Set(["task", "event", "info"]);
153
+ var SOURCE_ALIASES = {
154
+ gmail: "email",
155
+ mail: "email",
156
+ gcal: "calendar",
157
+ "google-calendar": "calendar",
158
+ "google-tasks": "tasks",
159
+ gtasks: "tasks",
160
+ todo: "tasks",
161
+ pr: "github",
162
+ gh: "github",
163
+ confluence: "atlassian",
164
+ "apple-reminders": "reminders",
165
+ imessage: "messages",
166
+ "apple-messages": "messages",
167
+ sms: "messages",
168
+ "e-boks": "eboks"
169
+ };
170
+ function isObject(v) {
171
+ return typeof v === "object" && v !== null && !Array.isArray(v);
172
+ }
173
+ function optionalString(v) {
174
+ return typeof v === "string" && v.trim() !== "" ? v : void 0;
175
+ }
176
+ function optionalStringArray(v) {
177
+ if (!Array.isArray(v)) return void 0;
178
+ const out = v.filter((x) => typeof x === "string" && x.trim() !== "");
179
+ return out.length > 0 ? out : void 0;
180
+ }
181
+ function normalizeSource(v) {
182
+ if (typeof v !== "string") return "other";
183
+ const key = v.trim().toLowerCase();
184
+ if (SOURCES.has(key)) return key;
185
+ return SOURCE_ALIASES[key] ?? "other";
186
+ }
187
+ function normalizeKind(v, hasStart) {
188
+ if (typeof v === "string" && KINDS.has(v.trim().toLowerCase())) {
189
+ return v.trim().toLowerCase();
190
+ }
191
+ return hasStart ? "event" : "task";
192
+ }
193
+ function parseClockTime(value) {
194
+ if (typeof value !== "string" || value.trim() === "") return void 0;
195
+ const text = value.trim();
196
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(text);
197
+ if (clock) {
198
+ const hour = Number(clock[1]);
199
+ const minute = Number(clock[2]);
200
+ if (hour > 23 || minute > 59) return void 0;
201
+ return `${String(hour).padStart(2, "0")}:${clock[2]}`;
202
+ }
203
+ const parsed = new Date(text);
204
+ if (Number.isNaN(parsed.getTime())) return void 0;
205
+ return `${String(parsed.getHours()).padStart(2, "0")}:${String(parsed.getMinutes()).padStart(2, "0")}`;
206
+ }
207
+ function parseItem(raw, index, warnings) {
208
+ if (!isObject(raw)) {
209
+ warnings.push(`items[${index}] is not an object; skipped`);
210
+ return null;
211
+ }
212
+ const id = optionalString(raw.id);
213
+ if (!id) {
214
+ warnings.push(`items[${index}] has no "id"; skipped (ids are how actions stay attached)`);
215
+ return null;
216
+ }
217
+ const title = optionalString(raw.title);
218
+ if (!title) {
219
+ warnings.push(`items[${index}] (${id}) has no "title"; skipped`);
220
+ return null;
221
+ }
222
+ const start = optionalString(raw.start);
223
+ const item = {
224
+ id,
225
+ title,
226
+ source: normalizeSource(raw.source),
227
+ kind: normalizeKind(raw.kind, start !== void 0)
228
+ };
229
+ const detail = optionalString(raw.detail);
230
+ if (detail) item.detail = detail;
231
+ const url = optionalString(raw.url);
232
+ if (url && /^https?:\/\//i.test(url)) item.url = url;
233
+ else if (url) warnings.push(`items[${index}] (${id}) has a non-http url; dropped`);
234
+ if (typeof raw.priority === "number" && Number.isFinite(raw.priority)) {
235
+ item.priority = raw.priority;
236
+ }
237
+ const due = optionalString(raw.due);
238
+ if (due) item.due = due;
239
+ if (start) item.start = start;
240
+ const end = optionalString(raw.end);
241
+ if (end) item.end = end;
242
+ if (raw.blocking === false) item.blocking = false;
243
+ const tags = optionalStringArray(raw.tags);
244
+ if (tags) item.tags = tags;
245
+ const people = optionalStringArray(raw.people);
246
+ if (people) item.people = people;
247
+ const firstSeen = optionalString(raw.firstSeen);
248
+ if (firstSeen) item.firstSeen = firstSeen;
249
+ if (raw.advancesObjective === true) item.advancesObjective = true;
250
+ return item;
251
+ }
252
+ function parseBrief(text) {
253
+ const warnings = [];
254
+ let raw;
255
+ try {
256
+ raw = JSON.parse(text);
257
+ } catch (err) {
258
+ return {
259
+ brief: null,
260
+ error: `items.json is not valid JSON: ${err.message}`,
261
+ warnings
262
+ };
263
+ }
264
+ if (!isObject(raw)) {
265
+ return { brief: null, error: "items.json must contain a JSON object", warnings };
266
+ }
267
+ if (raw.version !== void 0 && raw.version !== 1) {
268
+ warnings.push(`items.json declares version ${String(raw.version)}; this dashboard understands version 1`);
269
+ }
270
+ if (!Array.isArray(raw.items)) {
271
+ return { brief: null, error: 'items.json must have an "items" array', warnings };
272
+ }
273
+ const items = [];
274
+ const seen = /* @__PURE__ */ new Set();
275
+ raw.items.forEach((entry, i) => {
276
+ const item = parseItem(entry, i, warnings);
277
+ if (!item) return;
278
+ if (seen.has(item.id)) {
279
+ warnings.push(`items[${i}] repeats id "${item.id}"; kept the first occurrence`);
280
+ return;
281
+ }
282
+ seen.add(item.id);
283
+ items.push(item);
284
+ });
285
+ const generatedAt = optionalString(raw.generatedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
286
+ if (!optionalString(raw.generatedAt)) {
287
+ warnings.push('items.json has no "generatedAt"; assuming it was written just now');
288
+ }
289
+ const brief = { version: 1, generatedAt, items };
290
+ const generatedBy = optionalString(raw.generatedBy);
291
+ if (generatedBy) brief.generatedBy = generatedBy;
292
+ const date = optionalString(raw.date);
293
+ if (date) brief.date = date;
294
+ const headline = optionalString(raw.headline);
295
+ if (headline) brief.headline = headline;
296
+ const dayStart = parseClockTime(raw.dayStart);
297
+ if (dayStart) brief.dayStart = dayStart;
298
+ const dayEnd = parseClockTime(raw.dayEnd);
299
+ if (dayEnd) brief.dayEnd = dayEnd;
300
+ if (raw.dayStart !== void 0 && !dayStart) warnings.push('items.json has an unreadable "dayStart"; using the default');
301
+ if (raw.dayEnd !== void 0 && !dayEnd) warnings.push('items.json has an unreadable "dayEnd"; using the default');
302
+ return { brief, error: null, warnings };
303
+ }
304
+ function parseActionLine(line) {
305
+ const trimmed = line.trim();
306
+ if (trimmed === "") return null;
307
+ let raw;
308
+ try {
309
+ raw = JSON.parse(trimmed);
310
+ } catch {
311
+ return null;
312
+ }
313
+ if (!isObject(raw)) return null;
314
+ const id = optionalString(raw.id);
315
+ const action = optionalString(raw.action);
316
+ if (!id || !action) return null;
317
+ if (!["done", "snooze", "dismiss", "reopen", "note"].includes(action)) return null;
318
+ const result = {
319
+ id,
320
+ action,
321
+ at: optionalString(raw.at) ?? (/* @__PURE__ */ new Date(0)).toISOString()
322
+ };
323
+ const until = optionalString(raw.until);
324
+ if (until) result.until = until;
325
+ const text = optionalString(raw.text);
326
+ if (text) result.text = text;
327
+ return result;
328
+ }
329
+
330
+ // src/checks.ts
331
+ var GENEROUS_FLAG_THRESHOLD = 5;
332
+ function runContractChecks({ brief, items, focus, history }) {
333
+ const problems = [];
334
+ if (focus?.objective) {
335
+ const aligned = items.filter((item) => item.advancesObjective);
336
+ const openAligned = aligned.filter((item) => item.status === "open");
337
+ if (aligned.length === 0) {
338
+ problems.push(
339
+ `Nothing in today's brief advances the objective (${focus.objective}). Either the agent missed it, or today genuinely has no path to it \u2014 both are worth knowing.`
340
+ );
341
+ } else if (openAligned.length === 0) {
342
+ problems.push(`Everything advancing the objective is already handled. Worth queuing the next step.`);
343
+ }
344
+ if (aligned.length > GENEROUS_FLAG_THRESHOLD) {
345
+ problems.push(
346
+ `${aligned.length} items are flagged as advancing the objective. That's generous enough that "days since progress" is probably flattering you.`
347
+ );
348
+ }
349
+ }
350
+ const reset = brief.items.filter((item) => {
351
+ const previous = history.get(item.id)?.firstSeen;
352
+ return previous && item.firstSeen && previous < item.firstSeen;
353
+ });
354
+ if (reset.length > 0) {
355
+ problems.push(
356
+ `${reset.length} item(s) had their "first seen" date moved forward, which resets how old they look. Oldest affected: ${reset[0].title}`
357
+ );
358
+ }
359
+ const byFingerprint = /* @__PURE__ */ new Map();
360
+ for (const id of history.keys()) byFingerprint.set(fingerprintId(id), id);
361
+ for (const item of brief.items) {
362
+ if (history.has(item.id)) continue;
363
+ const previous = byFingerprint.get(fingerprintId(item.id));
364
+ if (previous) {
365
+ problems.push(
366
+ `Item id changed shape: "${previous}" is now "${item.id}". Anything you already completed under the old id will come back.`
367
+ );
368
+ }
369
+ }
370
+ const leak = findLeakedPhrase(brief, focus);
371
+ if (leak) {
372
+ problems.push(
373
+ `Private text from focus.md appears in the brief ("${leak}\u2026"), so it is on screen. That section is meant to stay out of the rendered page.`
374
+ );
375
+ }
376
+ return problems;
377
+ }
378
+ function findLeakedPhrase(brief, focus) {
379
+ if (!focus?.agentOnly) return null;
380
+ const words = (text) => text.toLowerCase().split(/[^a-z0-9-]+/).filter(Boolean);
381
+ const rendered = words(JSON.stringify(brief)).join(" ");
382
+ const secret = words(focus.agentOnly);
383
+ for (let i = 0; i + 6 <= secret.length; i++) {
384
+ const phrase = secret.slice(i, i + 6).join(" ");
385
+ if (rendered.includes(phrase)) return phrase;
386
+ }
387
+ return null;
388
+ }
389
+
390
+ // src/sessions.ts
391
+ import { appendFile, mkdir as mkdir2, readFile as readFile2, unlink } from "node:fs/promises";
392
+ var MAX_SESSION_MINUTES = 120;
393
+ var MIN_LOGGABLE_MINUTES = 1;
394
+ async function readActive(config) {
395
+ try {
396
+ return JSON.parse(await readFile2(config.sessionFile, "utf8"));
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+ async function writeActive(config, session) {
402
+ await writeJsonAtomic(config.sessionFile, session);
403
+ }
404
+ async function clearActive(config) {
405
+ try {
406
+ await unlink(config.sessionFile);
407
+ } catch {
408
+ }
409
+ }
410
+ async function logSession(config, session, endedAt, endedBy) {
411
+ const startedAt = parseISO(session.startedAt);
412
+ if (!startedAt) return null;
413
+ const actualMinutes = Math.round((endedAt.getTime() - startedAt.getTime()) / 6e4);
414
+ if (actualMinutes < MIN_LOGGABLE_MINUTES) return null;
415
+ const entry = {
416
+ id: session.id,
417
+ title: session.title,
418
+ startedAt: session.startedAt,
419
+ endedAt: endedAt.toISOString(),
420
+ plannedMinutes: session.minutes,
421
+ actualMinutes,
422
+ reachedTarget: actualMinutes >= session.minutes,
423
+ advancesObjective: session.advancesObjective,
424
+ endedBy
425
+ };
426
+ await mkdir2(config.dataDir, { recursive: true });
427
+ await appendFile(config.sessionsLogFile, `${JSON.stringify(entry)}
428
+ `, "utf8");
429
+ return entry;
430
+ }
431
+ async function startSession(config, opts) {
432
+ const now = opts.now ?? /* @__PURE__ */ new Date();
433
+ await stopSession(config, now);
434
+ await writeActive(config, {
435
+ id: opts.id,
436
+ title: opts.title,
437
+ startedAt: now.toISOString(),
438
+ minutes: opts.minutes,
439
+ advancesObjective: opts.advancesObjective,
440
+ // The click that got here is itself the first evidence of a person.
441
+ lastActiveAt: now.toISOString()
442
+ });
443
+ }
444
+ async function stopSession(config, now = /* @__PURE__ */ new Date(), endedBy = "user") {
445
+ const active = await readActive(config);
446
+ if (!active) return null;
447
+ await logSession(config, active, now, endedBy);
448
+ await clearActive(config);
449
+ return active;
450
+ }
451
+ async function reconcileSession(config, probe, now = /* @__PURE__ */ new Date()) {
452
+ if (config.awayAfterMinutes <= 0) return null;
453
+ const active = await readActive(config);
454
+ if (!active) return null;
455
+ const startedAt = parseISO(active.startedAt);
456
+ if (!startedAt) {
457
+ await clearActive(config);
458
+ return null;
459
+ }
460
+ const awayAfterMs = config.awayAfterMinutes * 6e4;
461
+ const previous = parseISO(active.lastActiveAt) ?? startedAt;
462
+ if (now.getTime() - previous.getTime() > awayAfterMs) {
463
+ const logged = await logSession(config, active, previous, "away");
464
+ await clearActive(config);
465
+ return logged;
466
+ }
467
+ const idleSeconds = await probe();
468
+ const lastInput = idleSeconds === null ? now : new Date(now.getTime() - Math.max(0, idleSeconds) * 1e3);
469
+ const confirmed = lastInput.getTime() > previous.getTime() ? lastInput : previous;
470
+ if (confirmed.toISOString() !== active.lastActiveAt) {
471
+ await writeActive(config, { ...active, lastActiveAt: confirmed.toISOString() });
472
+ }
473
+ return null;
474
+ }
475
+ async function closeIfPastBackstop(config, active, now) {
476
+ if (!active) return null;
477
+ const startedAt = parseISO(active.startedAt);
478
+ const elapsed = startedAt ? (now.getTime() - startedAt.getTime()) / 6e4 : Infinity;
479
+ if (elapsed <= MAX_SESSION_MINUTES) return active;
480
+ const ceiling = new Date((startedAt?.getTime() ?? now.getTime()) + MAX_SESSION_MINUTES * 6e4);
481
+ await stopSession(config, ceiling, "limit");
482
+ return null;
483
+ }
484
+ function lastUnattendedClose(todayLog) {
485
+ const last = todayLog.at(-1);
486
+ if (!last || last.endedBy === void 0 || last.endedBy === "user") return null;
487
+ return {
488
+ id: last.id,
489
+ title: last.title,
490
+ endedAt: last.endedAt,
491
+ actualMinutes: last.actualMinutes,
492
+ reason: last.endedBy
493
+ };
494
+ }
495
+ async function readSessionState(config, agenda, now = /* @__PURE__ */ new Date()) {
496
+ const active = await closeIfPastBackstop(config, await readActive(config), now);
497
+ const todayLog = await readTodaysSessions(config, now);
498
+ const completedToday = todayLog.length;
499
+ const minutesToday = todayLog.reduce((sum, entry) => sum + entry.actualMinutes, 0);
500
+ const unattendedClose = active ? null : lastUnattendedClose(todayLog);
501
+ if (!active) {
502
+ return { active: null, completedToday, minutesToday, unattendedClose };
503
+ }
504
+ const startedAt = parseISO(active.startedAt);
505
+ const endsAt = new Date(startedAt.getTime() + active.minutes * 6e4);
506
+ const remainingSeconds = Math.round((endsAt.getTime() - now.getTime()) / 1e3);
507
+ const nextEvent = agenda.events.map((event) => parseISO(event.start)).filter((start) => start !== null && start.getTime() > now.getTime()).sort((a, b) => a.getTime() - b.getTime())[0];
508
+ return {
509
+ active: {
510
+ id: active.id,
511
+ title: active.title,
512
+ startedAt: active.startedAt,
513
+ advancesObjective: active.advancesObjective,
514
+ lastActiveAt: active.lastActiveAt,
515
+ endsAt: endsAt.toISOString(),
516
+ minutes: active.minutes,
517
+ remainingSeconds,
518
+ overrun: remainingSeconds < 0,
519
+ collidesWithNextEvent: nextEvent ? nextEvent.getTime() < endsAt.getTime() : false,
520
+ nextEventAt: nextEvent ? nextEvent.toISOString() : null
521
+ },
522
+ completedToday,
523
+ minutesToday,
524
+ unattendedClose
525
+ };
526
+ }
527
+ async function readSessions(config) {
528
+ let text;
529
+ try {
530
+ text = await readFile2(config.sessionsLogFile, "utf8");
531
+ } catch {
532
+ return [];
533
+ }
534
+ const sessions = [];
535
+ for (const line of text.split("\n")) {
536
+ if (line.trim() === "") continue;
537
+ try {
538
+ const entry = JSON.parse(line);
539
+ if (entry?.id && entry.startedAt) sessions.push(entry);
540
+ } catch {
541
+ }
542
+ }
543
+ return sessions;
544
+ }
545
+ async function readTodaysSessions(config, now) {
546
+ const all = await readSessions(config);
547
+ return all.filter((entry) => {
548
+ const started = parseISO(entry.startedAt);
549
+ return started !== null && started.getFullYear() === now.getFullYear() && started.getMonth() === now.getMonth() && started.getDate() === now.getDate();
550
+ });
551
+ }
552
+
553
+ // src/store.ts
554
+ import { appendFile as appendFile2, mkdir as mkdir3, readFile as readFile3 } from "node:fs/promises";
555
+ import { setTimeout as delay } from "node:timers/promises";
556
+
557
+ // src/agenda.ts
558
+ var DEFAULT_EVENT_MINUTES = 30;
559
+ var MS_PER_MINUTE = 6e4;
560
+ function busySpan(item) {
561
+ if (isDateOnly(item.start)) return null;
562
+ if (item.blocking === false) return null;
563
+ const start = parseISO(item.start);
564
+ if (!start) return null;
565
+ const parsedEnd = parseISO(item.end);
566
+ const end = parsedEnd && parsedEnd.getTime() > start.getTime() ? parsedEnd : new Date(start.getTime() + DEFAULT_EVENT_MINUTES * MS_PER_MINUTE);
567
+ return { start: start.getTime(), end: end.getTime() };
568
+ }
569
+ function mergeIntervals(intervals) {
570
+ const sorted = [...intervals].sort((a, b) => a.start - b.start);
571
+ const merged = [];
572
+ for (const next of sorted) {
573
+ const last = merged[merged.length - 1];
574
+ if (last && next.start <= last.end) {
575
+ last.end = Math.max(last.end, next.end);
576
+ } else {
577
+ merged.push({ ...next });
578
+ }
579
+ }
580
+ return merged;
581
+ }
582
+ function dayBoundary(now, clock, fallbackHour) {
583
+ const parsed = clock ? /^(\d{1,2}):(\d{2})$/.exec(clock) : null;
584
+ const hour = parsed ? Number(parsed[1]) : fallbackHour;
585
+ const minute = parsed ? Number(parsed[2]) : 0;
586
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
587
+ }
588
+ function clockString(d) {
589
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
590
+ }
591
+ function buildAgenda(items, now, opts) {
592
+ const events = items.filter((item) => item.kind === "event" && item.status !== "dismissed").filter((item) => {
593
+ const start = parseISO(item.start);
594
+ return start !== null && isSameLocalDay(start, now);
595
+ }).sort((a, b) => {
596
+ const aAllDay = isDateOnly(a.start);
597
+ const bAllDay = isDateOnly(b.start);
598
+ if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
599
+ const at = parseISO(a.start)?.getTime() ?? 0;
600
+ const bt = parseISO(b.start)?.getTime() ?? 0;
601
+ return at - bt;
602
+ });
603
+ const spans = /* @__PURE__ */ new Map();
604
+ for (const event of events) {
605
+ const span = busySpan(event);
606
+ if (span) spans.set(event.id, span);
607
+ }
608
+ const conflictIds = /* @__PURE__ */ new Set();
609
+ const entries = [...spans.entries()];
610
+ for (let i = 0; i < entries.length; i++) {
611
+ for (let j = i + 1; j < entries.length; j++) {
612
+ const [idA, a] = entries[i];
613
+ const [idB, b] = entries[j];
614
+ if (a.start < b.end && b.start < a.end) {
615
+ conflictIds.add(idA);
616
+ conflictIds.add(idB);
617
+ }
618
+ }
619
+ }
620
+ const dayStart = dayBoundary(now, opts.dayStart, opts.workStartHour);
621
+ const dayEnd = dayBoundary(now, opts.dayEnd, opts.workEndHour);
622
+ const busy = mergeIntervals(
623
+ [...spans.values()].map((span) => ({
624
+ start: Math.max(span.start, dayStart.getTime()),
625
+ end: Math.min(span.end, dayEnd.getTime())
626
+ })).filter((span) => span.end > span.start)
627
+ );
628
+ const freeWindows = [];
629
+ let cursor = dayStart.getTime();
630
+ const stops = opts.freeWindows ? [...busy, { start: dayEnd.getTime(), end: dayEnd.getTime() }] : [];
631
+ for (const span of stops) {
632
+ const minutes = Math.round((span.start - cursor) / MS_PER_MINUTE);
633
+ if (minutes >= opts.minFreeWindowMinutes) {
634
+ freeWindows.push({
635
+ start: new Date(cursor).toISOString(),
636
+ end: new Date(span.start).toISOString(),
637
+ minutes
638
+ });
639
+ }
640
+ cursor = Math.max(cursor, span.end);
641
+ }
642
+ let remainingFocusMinutes = 0;
643
+ for (const window of freeWindows) {
644
+ const start = Math.max(new Date(window.start).getTime(), now.getTime());
645
+ const end = new Date(window.end).getTime();
646
+ if (end > start) remainingFocusMinutes += Math.round((end - start) / MS_PER_MINUTE);
647
+ }
648
+ return {
649
+ events,
650
+ conflictIds: [...conflictIds],
651
+ tracksFreeTime: opts.freeWindows,
652
+ freeWindows,
653
+ remainingFocusMinutes,
654
+ dayStart: clockString(dayStart),
655
+ dayEnd: clockString(dayEnd)
656
+ };
657
+ }
658
+
659
+ // src/focus.ts
660
+ var AGENT_ONLY_MARKER = /^[ \t]*<!--[ \t]*agent-only[ \t]*-->[ \t]*$/im;
661
+ var FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
662
+ var COMMENT = /<!--[\s\S]*?-->/g;
663
+ var KNOWN_KEYS = /* @__PURE__ */ new Set(["objective", "blocker"]);
664
+ function clean(value) {
665
+ const trimmed = value.trim().replace(/^(['"])([\s\S]*)\1$/, "$2").trim();
666
+ return trimmed === "" ? null : trimmed;
667
+ }
668
+ function parseFocus(text) {
669
+ const focus = { objective: null, blocker: null, note: null, agentOnly: null };
670
+ let body = text;
671
+ const frontmatter = FRONTMATTER.exec(text);
672
+ if (frontmatter) {
673
+ body = text.slice(frontmatter[0].length);
674
+ for (const line of (frontmatter[1] ?? "").split("\n")) {
675
+ const separator = line.indexOf(":");
676
+ if (separator === -1) continue;
677
+ const key = line.slice(0, separator).trim().toLowerCase();
678
+ if (KNOWN_KEYS.has(key)) {
679
+ focus[key] = clean(line.slice(separator + 1));
680
+ }
681
+ }
682
+ }
683
+ const marker = AGENT_ONLY_MARKER.exec(body);
684
+ if (marker) {
685
+ focus.agentOnly = clean(body.slice(marker.index + marker[0].length));
686
+ body = body.slice(0, marker.index);
687
+ }
688
+ focus.note = clean(body.replace(COMMENT, ""));
689
+ if (!focus.objective && focus.note) {
690
+ const [first, ...rest] = focus.note.split("\n");
691
+ focus.objective = clean(first ?? "");
692
+ focus.note = clean(rest.join("\n"));
693
+ }
694
+ return focus;
695
+ }
696
+ function toPublicFocus(focus) {
697
+ return { objective: focus.objective, blocker: focus.blocker, note: focus.note };
698
+ }
699
+
700
+ // src/store.ts
701
+ var READ_RETRIES = 3;
702
+ var READ_RETRY_MS = 40;
703
+ var Store = class {
704
+ config;
705
+ /** Appends are chained so two concurrent clicks can't interleave a half-written line. */
706
+ #writeQueue = Promise.resolve();
707
+ constructor(config) {
708
+ this.config = config;
709
+ }
710
+ async ensureDataDir() {
711
+ await mkdir3(this.config.dataDir, { recursive: true });
712
+ }
713
+ /**
714
+ * Read and parse items.json.
715
+ *
716
+ * Retries a parse failure a couple of times: if the agent writes the file
717
+ * non-atomically we can catch it mid-truncation, and a transient blank read
718
+ * should not flash an error banner at the user.
719
+ */
720
+ async readBrief() {
721
+ let last = {
722
+ brief: null,
723
+ error: "items.json has not been written yet",
724
+ warnings: []
725
+ };
726
+ for (let attempt = 0; attempt < READ_RETRIES; attempt++) {
727
+ let text;
728
+ try {
729
+ text = await readFile3(this.config.itemsFile, "utf8");
730
+ } catch (err) {
731
+ if (err.code === "ENOENT") {
732
+ return {
733
+ brief: null,
734
+ error: `No brief yet \u2014 nothing has written ${this.config.itemsFile}`,
735
+ warnings: []
736
+ };
737
+ }
738
+ return { brief: null, error: `Could not read items.json: ${err.message}`, warnings: [] };
739
+ }
740
+ last = parseBrief(text);
741
+ if (last.error === null) return last;
742
+ if (attempt < READ_RETRIES - 1) await delay(READ_RETRY_MS);
743
+ }
744
+ return last;
745
+ }
746
+ /** Read the whole action log, oldest first. */
747
+ async readActions() {
748
+ let text;
749
+ try {
750
+ text = await readFile3(this.config.actionsFile, "utf8");
751
+ } catch (err) {
752
+ if (err.code === "ENOENT") return [];
753
+ throw err;
754
+ }
755
+ const actions = [];
756
+ for (const line of text.split("\n")) {
757
+ const action = parseActionLine(line);
758
+ if (action) actions.push(action);
759
+ }
760
+ return actions;
761
+ }
762
+ /**
763
+ * Snapshot the current brief into the archive if it isn't already there.
764
+ *
765
+ * Safe to call often — it's a no-op unless items.json is newer than what's on
766
+ * file for that date. Called on startup and whenever the store changes, so a
767
+ * brief written while the dashboard was closed still gets captured.
768
+ */
769
+ async archiveCurrentBrief() {
770
+ const { brief } = await this.readBrief();
771
+ if (!brief) return false;
772
+ try {
773
+ return await archiveBrief(this.config, brief);
774
+ } catch (err) {
775
+ console.warn(`[daily-focus] could not archive the brief: ${err.message}`);
776
+ return false;
777
+ }
778
+ }
779
+ /** Read the standing objective. Returns null when focus.md hasn't been written. */
780
+ async readFocus() {
781
+ try {
782
+ return parseFocus(await readFile3(this.config.focusFile, "utf8"));
783
+ } catch (err) {
784
+ if (err.code === "ENOENT") return null;
785
+ console.warn(`[daily-focus] could not read focus.md: ${err.message}`);
786
+ return null;
787
+ }
788
+ }
789
+ /** Append one action. The log is append-only; corrections are new lines, never edits. */
790
+ async appendAction(action) {
791
+ const line = `${JSON.stringify(action)}
792
+ `;
793
+ const write = this.#writeQueue.then(async () => {
794
+ await this.ensureDataDir();
795
+ await appendFile2(this.config.actionsFile, line, "utf8");
796
+ });
797
+ this.#writeQueue = write.catch(() => {
798
+ });
799
+ await write;
800
+ }
801
+ /**
802
+ * Read the store and fold it into everything the client needs.
803
+ *
804
+ * Everything except `assetVersion`, `board` and `tickets` — the first describes
805
+ * the served UI, not the data, and the other two are fetched rather than read,
806
+ * so the server owns all three and layers them on. Keeping them out here means
807
+ * the store has no opinion about how it's being displayed, or about GitHub and
808
+ * Jira.
809
+ */
810
+ async getState(now = /* @__PURE__ */ new Date(), actions, calendar) {
811
+ const [{ brief, error, warnings }, readActions, focus] = await Promise.all([
812
+ this.readBrief(),
813
+ // The caller may have read the log already, to fold the board from the same
814
+ // snapshot; two reads could straddle an append and disagree.
815
+ actions ? Promise.resolve(actions) : this.readActions(),
816
+ this.readFocus()
817
+ ]);
818
+ actions = readActions;
819
+ const schedule = resolveSchedule(this.config);
820
+ const isRunDay = (d) => runsOn(schedule, d);
821
+ const items = brief ? resolveItems(brief.items, actions, now) : [];
822
+ const live = calendar?.live === true;
823
+ const agendaItems = live ? resolveItems(calendar.events, actions, now) : items;
824
+ const agendaSource = {
825
+ live,
826
+ fetchedAt: calendar?.fetchedAt ?? null,
827
+ problem: calendar?.problem ?? null,
828
+ failure: calendar?.failure ?? null,
829
+ warnings: calendar?.warnings ?? []
830
+ };
831
+ const agenda = buildAgenda(agendaItems, now, {
832
+ freeWindows: this.config.freeWindows,
833
+ workStartHour: this.config.workStartHour,
834
+ workEndHour: this.config.workEndHour,
835
+ minFreeWindowMinutes: this.config.minFreeWindowMinutes,
836
+ // The brief's own bounds win: the agent read today's calendar, config didn't.
837
+ dayStart: brief?.dayStart,
838
+ dayEnd: brief?.dayEnd
839
+ });
840
+ const generatedAt = brief ? parseISO(brief.generatedAt) : null;
841
+ const ageHours = generatedAt ? Math.floor((now.getTime() - generatedAt.getTime()) / 36e5) : null;
842
+ const scheduledAgeHours = generatedAt ? workingMsBetween(generatedAt, now, isRunDay) / 36e5 : null;
843
+ const refreshDue = scheduledAgeHours !== null && scheduledAgeHours >= this.config.staleAfterHours;
844
+ const stale = scheduledAgeHours !== null && scheduledAgeHours >= this.config.staleAfterHours + BRIEF_REFRESH_GRACE_HOURS;
845
+ const visible = items.filter((item) => item.status === "open");
846
+ const todayStart = startOfLocalDay(now);
847
+ const history = brief ? await readArchiveIndex(this.config) : /* @__PURE__ */ new Map();
848
+ const contractIssues = brief ? runContractChecks({ brief, items, focus, history }) : [];
849
+ let objectiveProgress = null;
850
+ if (focus?.objective && brief) {
851
+ const withToday = new Map(history);
852
+ for (const item of brief.items) {
853
+ if (!withToday.has(item.id)) {
854
+ withToday.set(item.id, {
855
+ title: item.title,
856
+ advancesObjective: item.advancesObjective === true,
857
+ firstSeen: item.firstSeen
858
+ });
859
+ }
860
+ }
861
+ objectiveProgress = computeObjectiveProgress(withToday, actions, now, isRunDay);
862
+ }
863
+ const session = await readSessionState(this.config, agenda, now);
864
+ return {
865
+ // toPublicFocus, not focus — the agent-only section must never be serialised.
866
+ focus: focus ? toPublicFocus(focus) : null,
867
+ objectiveProgress,
868
+ session,
869
+ schedule: {
870
+ days: [...schedule.days],
871
+ source: schedule.source,
872
+ description: describeSchedule(schedule),
873
+ runsToday: isRunDay(now),
874
+ nextRunDate: nextRunDate(schedule, now)
875
+ },
876
+ brief: {
877
+ generatedAt: brief?.generatedAt ?? null,
878
+ generatedBy: brief?.generatedBy ?? null,
879
+ date: brief?.date ?? (generatedAt ? localDateKey(generatedAt) : null),
880
+ headline: brief?.headline ?? null,
881
+ ageHours,
882
+ refreshPending: refreshDue && !stale && isRunDay(now),
883
+ stale
884
+ },
885
+ items,
886
+ agenda,
887
+ agendaSource,
888
+ stats: {
889
+ open: visible.filter((item) => item.kind !== "event").length,
890
+ topPriority: visible.filter((item) => item.priority !== void 0).length,
891
+ completedToday: items.filter((item) => {
892
+ if (item.status !== "done") return false;
893
+ const at = parseISO(item.statusAt);
894
+ return at !== null && isSameLocalDay(at, now);
895
+ }).length,
896
+ overdue: visible.filter((item) => {
897
+ const due = parseISO(item.due);
898
+ return due !== null && due.getTime() < todayStart.getTime();
899
+ }).length
900
+ },
901
+ problem: error,
902
+ // Parser complaints and contract failures render the same way: both are
903
+ // things the agent got wrong that you would not otherwise see.
904
+ warnings: [...warnings, ...contractIssues],
905
+ now: now.toISOString()
906
+ };
907
+ }
908
+ };
909
+ function foldActionLog(actions, now) {
910
+ const folded = /* @__PURE__ */ new Map();
911
+ const today = startOfLocalDay(now);
912
+ for (const action of actions) {
913
+ const key = canonicalId(action.id);
914
+ let entry = folded.get(key);
915
+ if (!entry) {
916
+ entry = { status: "open", notes: [] };
917
+ folded.set(key, entry);
918
+ }
919
+ switch (action.action) {
920
+ case "note":
921
+ if (action.text) entry.notes.push({ text: action.text, at: action.at });
922
+ break;
923
+ case "done":
924
+ entry.status = "done";
925
+ delete entry.snoozedUntil;
926
+ entry.statusAt = action.at;
927
+ break;
928
+ case "dismiss":
929
+ entry.status = "dismissed";
930
+ delete entry.snoozedUntil;
931
+ entry.statusAt = action.at;
932
+ break;
933
+ case "snooze":
934
+ entry.status = "snoozed";
935
+ if (action.until) entry.snoozedUntil = action.until;
936
+ else delete entry.snoozedUntil;
937
+ entry.statusAt = action.at;
938
+ break;
939
+ case "reopen":
940
+ entry.status = "open";
941
+ delete entry.snoozedUntil;
942
+ entry.statusAt = action.at;
943
+ break;
944
+ }
945
+ }
946
+ for (const entry of folded.values()) {
947
+ if (entry.status !== "snoozed") continue;
948
+ const until = parseISO(entry.snoozedUntil);
949
+ if (until && until.getTime() <= today.getTime()) {
950
+ entry.status = "open";
951
+ delete entry.snoozedUntil;
952
+ }
953
+ }
954
+ return folded;
955
+ }
956
+ function resolveItems(items, actions, now) {
957
+ const folded = foldActionLog(actions, now);
958
+ return items.map((item) => {
959
+ const log = folded.get(canonicalId(item.id));
960
+ const firstSeen = parseISO(item.firstSeen);
961
+ const resolved = {
962
+ ...item,
963
+ status: log?.status ?? "open",
964
+ notes: log?.notes ?? [],
965
+ ageDays: firstSeen ? Math.max(0, calendarDaysBetween(firstSeen, now)) : 0
966
+ };
967
+ if (log?.snoozedUntil) resolved.snoozedUntil = log.snoozedUntil;
968
+ if (log?.statusAt) resolved.statusAt = log.statusAt;
969
+ return resolved;
970
+ });
971
+ }
972
+
973
+ export {
974
+ writeJsonAtomic,
975
+ tempPathFor,
976
+ canonicalId,
977
+ fingerprintId,
978
+ readArchiveIndex,
979
+ parseBrief,
980
+ runContractChecks,
981
+ startSession,
982
+ stopSession,
983
+ reconcileSession,
984
+ Store,
985
+ foldActionLog,
986
+ resolveItems
987
+ };