daily-focus 1.2.0-dev.5 → 1.2.0-dev.8
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/README.md +12 -11
- package/SETUP.md +9 -6
- package/dist/{audit-ITRXIX6T.js → audit-DNTFZOHH.js} +6 -5
- package/dist/{chunk-AIQO2YAI.js → chunk-6SMUSPZG.js} +2 -2
- package/dist/{chunk-26JG3D5C.js → chunk-GQ3NCWRT.js} +113 -272
- package/dist/{chunk-RNE27FQY.js → chunk-KZNY45XW.js} +0 -5
- package/dist/{chunk-UY6HQLGE.js → chunk-P3E2B2SD.js} +1 -1
- package/dist/cli.js +6 -6
- package/dist/{demo-KVFAVGO7.js → demo-6Q22AAC7.js} +33 -24
- package/dist/{init-57WHZZNG.js → init-XDMAXU2S.js} +1 -9
- package/dist/{seed-VALN2TCQ.js → seed-TWT7EDXJ.js} +3 -3
- package/dist/{server-SOCS3SZJ.js → server-PIYOIUKJ.js} +336 -251
- package/dist/{service-NAAAV67B.js → service-ZUHI3VKH.js} +1 -1
- package/package.json +1 -1
- package/prompts/README.md +2 -3
- package/prompts/morning-brief-personal.md +1 -25
- package/prompts/morning-brief-work.md +8 -33
- package/public/app.js +175 -345
- package/public/style.css +12 -170
package/README.md
CHANGED
|
@@ -64,13 +64,6 @@ since you completed something that advanced your objective.
|
|
|
64
64
|
- Mark tasks **Done** when you've handled them.
|
|
65
65
|
- **Snooze** an item until a date, or **Dismiss** it if it doesn't need attention.
|
|
66
66
|
- Leave a **Note** for the next brief, such as "already replied on Slack".
|
|
67
|
-
- Press **f** for focus mode, which shows your top item and agenda.
|
|
68
|
-
- Press **p** on an item to start a focus timer. Its countdown appears in the tab
|
|
69
|
-
title, and the session becomes part of the next brief's context.
|
|
70
|
-
|
|
71
|
-
The timer keeps counting after its target time. On macOS, it ends a session when
|
|
72
|
-
you've been away from the machine, excluding the time you were away. On systems
|
|
73
|
-
without idle detection, a two-hour limit caps the recorded session.
|
|
74
67
|
|
|
75
68
|
You can undo item actions except notes. The page updates as new data arrives, and
|
|
76
69
|
the theme button switches between system, light, and dark mode.
|
|
@@ -90,8 +83,10 @@ The second tab shows your open pull requests in seven buckets:
|
|
|
90
83
|
| Drafts | Pull requests you haven't marked ready for review. |
|
|
91
84
|
|
|
92
85
|
Park a pull request until a date, leave a note, or click **Nudged** after asking
|
|
93
|
-
for a review. Nudged records
|
|
94
|
-
|
|
86
|
+
for a review. Nudged records that you asked; it doesn't send a message. The pull
|
|
87
|
+
request moves to a Nudged section for a day, then goes back to Waiting on reviewers
|
|
88
|
+
with "time to ask" if nobody has reviewed it. Completing a task about a pull
|
|
89
|
+
request in the brief doesn't remove that pull request from the board.
|
|
95
90
|
|
|
96
91
|
Cancelled checks appear as "cancelled, no verdict" rather than failures.
|
|
97
92
|
When a check is rerun, the board uses its newest workflow run or attempt.
|
|
@@ -111,6 +106,14 @@ Draft pull requests count as open. Closed pull requests don't prove that a ticke
|
|
|
111
106
|
is finished, so the board leaves that decision to you. Park a reminder or leave a
|
|
112
107
|
note when there's more work planned.
|
|
113
108
|
|
|
109
|
+
Jira can take days to hear that a pull request merged, so the board also checks
|
|
110
|
+
GitHub. It searches your open pull requests and the ones closed in the last 60
|
|
111
|
+
days, and matches them to tickets by the key in the title, branch, description
|
|
112
|
+
or commit messages. An open pull request on GitHub always counts. GitHub can
|
|
113
|
+
also mark a ticket's pull requests as all closed, but only when it found at least
|
|
114
|
+
as many as Jira has linked. A row that GitHub changed lists those pull requests
|
|
115
|
+
under **GitHub, ahead of Jira**.
|
|
116
|
+
|
|
114
117
|
Switch to **Working on** to see your in-progress tickets grouped by status,
|
|
115
118
|
including ones that also need a status check. You can narrow this view in
|
|
116
119
|
[SETUP.md](SETUP.md#jira).
|
|
@@ -167,8 +170,6 @@ Press **?** in the dashboard to see the shortcuts.
|
|
|
167
170
|
| `a` | Open the panel, ready to ask the assistant |
|
|
168
171
|
| `o` | Open the source link |
|
|
169
172
|
| `u` | Undo the last action |
|
|
170
|
-
| `f` | Toggle focus mode |
|
|
171
|
-
| `p` | Start / stop a focus session |
|
|
172
173
|
| `r` | Refresh the current board |
|
|
173
174
|
| `w` | Switch Jira views |
|
|
174
175
|
| `,` | Settings |
|
package/SETUP.md
CHANGED
|
@@ -148,8 +148,8 @@ Set `DAILY_FOCUS_AGENT_MODEL` and `DAILY_FOCUS_AGENT_EFFORT` if the CLI's own
|
|
|
148
148
|
defaults are not what you want for a brief; a brief is worth a high effort. If the
|
|
149
149
|
server can't find the CLI, set `DAILY_FOCUS_AGENT_BIN`.
|
|
150
150
|
|
|
151
|
-
The agent writes `items.json`. The dashboard records your actions
|
|
152
|
-
|
|
151
|
+
The agent writes `items.json`. The dashboard records your actions for the next run
|
|
152
|
+
to read.
|
|
153
153
|
|
|
154
154
|
After the first run, read its report (click "updated … ago"): it says which sources
|
|
155
155
|
it couldn't reach. To check the brief against the payload contract, run:
|
|
@@ -237,6 +237,11 @@ Clicking a status in the dashboard writes one transition to Jira. All other Jira
|
|
|
237
237
|
access is read-only. See the [ticket board guide](README.md#the-jira-ticket-board)
|
|
238
238
|
for workflow limits and undo behavior.
|
|
239
239
|
|
|
240
|
+
The board also asks GitHub about your pull requests, because Jira can learn of
|
|
241
|
+
them days late. It uses the pull request board's accounts and scope, and nothing
|
|
242
|
+
else needs to be configured. With `DAILY_FOCUS_GITHUB=off`, the ticket board uses
|
|
243
|
+
Jira alone.
|
|
244
|
+
|
|
240
245
|
Set `DAILY_FOCUS_JIRA=off` to disable the board.
|
|
241
246
|
|
|
242
247
|
### Live calendar on macOS
|
|
@@ -456,7 +461,7 @@ DAILY_FOCUS_PROFILE=personal
|
|
|
456
461
|
The dashboard then links the personal prompt, `prompts/morning-brief-personal.md`,
|
|
457
462
|
into the store as `prompt.md`, and the source list starts from a template with
|
|
458
463
|
sections for those sources. The profile also switches off
|
|
459
|
-
the Jira board
|
|
464
|
+
the Jira board, and gives the tab a green house icon instead of the blue briefcase. The pull request
|
|
460
465
|
board stays on for side projects; point it at your personal account with
|
|
461
466
|
`DAILY_FOCUS_GITHUB_ACCOUNTS`, or set `DAILY_FOCUS_GITHUB=off`.
|
|
462
467
|
|
|
@@ -517,9 +522,7 @@ matters when `DAILY_FOCUS_FREE_WINDOWS` is off.
|
|
|
517
522
|
| `DAILY_FOCUS_MIN_FREE_WINDOW` | `45` | Minutes before a gap counts as a focus window |
|
|
518
523
|
| `DAILY_FOCUS_STALE_AFTER_HOURS` | `24` | When to expect a refresh. Shows an informational message for 45 minutes before warning that the agent may not have run. Counted only in hours a run was due, so days off never trip it |
|
|
519
524
|
| `DAILY_FOCUS_AGENT_DAYS` | `1-5` | Weekdays the dashboard runs the agent on, cron-style and cron-numbered: `1-5` for Monday to Friday, `0-4` for Sunday to Thursday, `0,6` for a weekend-only run |
|
|
520
|
-
| `
|
|
521
|
-
| `DAILY_FOCUS_AWAY_AFTER` | `10`; `0` for personal | Minutes of an untouched machine before a session is closed at the last sign of life. `0` turns it off |
|
|
522
|
-
| `DAILY_FOCUS_GITHUB` | `on` | `off` disables the pull request board; nothing is polled |
|
|
525
|
+
| `DAILY_FOCUS_GITHUB` | `on` | `off` disables the pull request board, and the ticket board stops checking GitHub; nothing is polled |
|
|
523
526
|
| `DAILY_FOCUS_GITHUB_ACCOUNTS` | *gh's active account* | Logins to poll as, comma-separated, each resolved with `gh auth token --user` |
|
|
524
527
|
| `DAILY_FOCUS_GITHUB_SCOPE` | *everything* | Organisations or `owner/repo` entries to limit the board to, comma-separated |
|
|
525
528
|
| `DAILY_FOCUS_GITHUB_MERGE_GATE_CHECKS` | *none* | Check names that stand for a repository's whole merge policy, comma-separated and matched exactly. Their rows get the *Waiting on merge gate* bucket |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
readSystemZone,
|
|
3
3
|
startingZone
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-6SMUSPZG.js";
|
|
5
5
|
import {
|
|
6
6
|
linkState
|
|
7
7
|
} from "./chunk-QKT2FAB5.js";
|
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
parseBrief,
|
|
12
12
|
readArchiveIndex,
|
|
13
13
|
resolveItems,
|
|
14
|
-
runContractChecks
|
|
15
|
-
|
|
14
|
+
runContractChecks,
|
|
15
|
+
setsStatus
|
|
16
|
+
} from "./chunk-GQ3NCWRT.js";
|
|
16
17
|
import {
|
|
17
18
|
BRIEF_REFRESH_GRACE_HOURS,
|
|
18
19
|
describeSchedule,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
resolveSchedule,
|
|
23
24
|
runsOn,
|
|
24
25
|
workingMsBetween
|
|
25
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-KZNY45XW.js";
|
|
26
27
|
import {
|
|
27
28
|
command
|
|
28
29
|
} from "./chunk-BC2W7NND.js";
|
|
@@ -180,7 +181,7 @@ if (!previous) {
|
|
|
180
181
|
const reproduced = [...previousIds].filter((id) => currentIds.has(id));
|
|
181
182
|
const dropped = [...previousIds].filter((id) => !currentIds.has(id));
|
|
182
183
|
const added = [...currentIds].filter((id) => !previousIds.has(id));
|
|
183
|
-
const actedOn = new Set(actions.filter(
|
|
184
|
+
const actedOn = new Set(actions.filter(setsStatus).map((a) => a.id));
|
|
184
185
|
const addedFingerprints = new Map(added.map((id) => [fingerprintId(id), id]));
|
|
185
186
|
const handled = dropped.filter((id) => actedOn.has(id));
|
|
186
187
|
const drifted = dropped.filter((id) => !actedOn.has(id) && addedFingerprints.has(fingerprintId(id)));
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
startOfLocalDay,
|
|
15
15
|
workingDaysSince,
|
|
16
16
|
workingMsBetween
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-KZNY45XW.js";
|
|
18
18
|
|
|
19
19
|
// src/fs.ts
|
|
20
20
|
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
@@ -38,101 +38,6 @@ function fingerprintId(id) {
|
|
|
38
38
|
return id.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// src/archive.ts
|
|
42
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
43
|
-
import { resolve } from "node:path";
|
|
44
|
-
var ARCHIVE_FILE = /^items-(\d{4}-\d{2}-\d{2})\.json$/;
|
|
45
|
-
var MAX_ARCHIVE_FILES = 130;
|
|
46
|
-
var RECENT_WINDOW_DAYS = 14;
|
|
47
|
-
function archivePath(config, dateKey) {
|
|
48
|
-
return resolve(config.archiveDir, `items-${dateKey}.json`);
|
|
49
|
-
}
|
|
50
|
-
function briefDateKey(brief) {
|
|
51
|
-
if (brief.date && /^\d{4}-\d{2}-\d{2}$/.test(brief.date)) return brief.date;
|
|
52
|
-
const generated = parseISO(brief.generatedAt);
|
|
53
|
-
return localDateKey(generated ?? /* @__PURE__ */ new Date());
|
|
54
|
-
}
|
|
55
|
-
async function archiveBrief(config, brief) {
|
|
56
|
-
const dateKey = briefDateKey(brief);
|
|
57
|
-
const target = archivePath(config, dateKey);
|
|
58
|
-
try {
|
|
59
|
-
const existing = JSON.parse(await readFile(target, "utf8"));
|
|
60
|
-
const existingAt = parseISO(existing.generatedAt)?.getTime() ?? 0;
|
|
61
|
-
const incomingAt = parseISO(brief.generatedAt)?.getTime() ?? 0;
|
|
62
|
-
if (existingAt >= incomingAt) return false;
|
|
63
|
-
} catch {
|
|
64
|
-
}
|
|
65
|
-
await writeJsonAtomic(target, brief);
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
async function listArchivedDates(config) {
|
|
69
|
-
let names;
|
|
70
|
-
try {
|
|
71
|
-
names = await readdir(config.archiveDir);
|
|
72
|
-
} catch {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
return names.map((name) => ARCHIVE_FILE.exec(name)?.[1]).filter((date) => date !== void 0).sort();
|
|
76
|
-
}
|
|
77
|
-
async function readArchiveIndex(config) {
|
|
78
|
-
const index = /* @__PURE__ */ new Map();
|
|
79
|
-
const dated = (await listArchivedDates(config)).reverse().slice(0, MAX_ARCHIVE_FILES);
|
|
80
|
-
for (const date of dated) {
|
|
81
|
-
let brief;
|
|
82
|
-
try {
|
|
83
|
-
brief = JSON.parse(await readFile(archivePath(config, date), "utf8"));
|
|
84
|
-
} catch {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
for (const item of brief.items ?? []) {
|
|
88
|
-
if (!item?.id) continue;
|
|
89
|
-
const existing = index.get(item.id);
|
|
90
|
-
if (!existing) {
|
|
91
|
-
index.set(item.id, {
|
|
92
|
-
title: item.title ?? item.id,
|
|
93
|
-
advancesObjective: item.advancesObjective === true,
|
|
94
|
-
firstSeen: item.firstSeen
|
|
95
|
-
});
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (item.firstSeen && (!existing.firstSeen || item.firstSeen < existing.firstSeen)) {
|
|
99
|
-
existing.firstSeen = item.firstSeen;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return index;
|
|
104
|
-
}
|
|
105
|
-
function computeObjectiveProgress(index, actions, now, counts = isWorkingDay) {
|
|
106
|
-
const lastAction = /* @__PURE__ */ new Map();
|
|
107
|
-
for (const action of actions) {
|
|
108
|
-
if (action.action === "note") continue;
|
|
109
|
-
lastAction.set(canonicalId(action.id), action);
|
|
110
|
-
}
|
|
111
|
-
const byCanonical = /* @__PURE__ */ new Map();
|
|
112
|
-
for (const [id, entry] of index) byCanonical.set(canonicalId(id), entry);
|
|
113
|
-
let lastAt = null;
|
|
114
|
-
let lastTitle = null;
|
|
115
|
-
let recentCount = 0;
|
|
116
|
-
for (const [id, action] of lastAction) {
|
|
117
|
-
if (action.action !== "done") continue;
|
|
118
|
-
if (!byCanonical.get(id)?.advancesObjective) continue;
|
|
119
|
-
const at = parseISO(action.at);
|
|
120
|
-
if (!at) continue;
|
|
121
|
-
if (workingDaysSince(at, now, counts) <= RECENT_WINDOW_DAYS) recentCount++;
|
|
122
|
-
if (!lastAt || at.getTime() > lastAt.getTime()) {
|
|
123
|
-
lastAt = at;
|
|
124
|
-
lastTitle = byCanonical.get(id)?.title ?? null;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return {
|
|
128
|
-
workingDaysSince: lastAt ? workingDaysSince(lastAt, now, counts) : null,
|
|
129
|
-
lastAt: lastAt ? lastAt.toISOString() : null,
|
|
130
|
-
lastTitle,
|
|
131
|
-
recentCount,
|
|
132
|
-
windowDays: RECENT_WINDOW_DAYS
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
41
|
// src/validate.ts
|
|
137
42
|
var SOURCES = /* @__PURE__ */ new Set([
|
|
138
43
|
"github",
|
|
@@ -300,6 +205,10 @@ function parseBrief(text) {
|
|
|
300
205
|
if (raw.dayEnd !== void 0 && !dayEnd) warnings.push('items.json has an unreadable "dayEnd"; using the default');
|
|
301
206
|
return { brief, error: null, warnings };
|
|
302
207
|
}
|
|
208
|
+
var ACTION_TYPES = ["done", "snooze", "dismiss", "reopen", "note", "nudge"];
|
|
209
|
+
function setsStatus(action) {
|
|
210
|
+
return action.action !== "note" && action.action !== "nudge";
|
|
211
|
+
}
|
|
303
212
|
function parseActionLine(line) {
|
|
304
213
|
const trimmed = line.trim();
|
|
305
214
|
if (trimmed === "") return null;
|
|
@@ -313,7 +222,7 @@ function parseActionLine(line) {
|
|
|
313
222
|
const id = optionalString(raw.id);
|
|
314
223
|
const action = optionalString(raw.action);
|
|
315
224
|
if (!id || !action) return null;
|
|
316
|
-
if (!
|
|
225
|
+
if (!ACTION_TYPES.includes(action)) return null;
|
|
317
226
|
const result = {
|
|
318
227
|
id,
|
|
319
228
|
action,
|
|
@@ -326,6 +235,101 @@ function parseActionLine(line) {
|
|
|
326
235
|
return result;
|
|
327
236
|
}
|
|
328
237
|
|
|
238
|
+
// src/archive.ts
|
|
239
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
240
|
+
import { resolve } from "node:path";
|
|
241
|
+
var ARCHIVE_FILE = /^items-(\d{4}-\d{2}-\d{2})\.json$/;
|
|
242
|
+
var MAX_ARCHIVE_FILES = 130;
|
|
243
|
+
var RECENT_WINDOW_DAYS = 14;
|
|
244
|
+
function archivePath(config, dateKey) {
|
|
245
|
+
return resolve(config.archiveDir, `items-${dateKey}.json`);
|
|
246
|
+
}
|
|
247
|
+
function briefDateKey(brief) {
|
|
248
|
+
if (brief.date && /^\d{4}-\d{2}-\d{2}$/.test(brief.date)) return brief.date;
|
|
249
|
+
const generated = parseISO(brief.generatedAt);
|
|
250
|
+
return localDateKey(generated ?? /* @__PURE__ */ new Date());
|
|
251
|
+
}
|
|
252
|
+
async function archiveBrief(config, brief) {
|
|
253
|
+
const dateKey = briefDateKey(brief);
|
|
254
|
+
const target = archivePath(config, dateKey);
|
|
255
|
+
try {
|
|
256
|
+
const existing = JSON.parse(await readFile(target, "utf8"));
|
|
257
|
+
const existingAt = parseISO(existing.generatedAt)?.getTime() ?? 0;
|
|
258
|
+
const incomingAt = parseISO(brief.generatedAt)?.getTime() ?? 0;
|
|
259
|
+
if (existingAt >= incomingAt) return false;
|
|
260
|
+
} catch {
|
|
261
|
+
}
|
|
262
|
+
await writeJsonAtomic(target, brief);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
async function listArchivedDates(config) {
|
|
266
|
+
let names;
|
|
267
|
+
try {
|
|
268
|
+
names = await readdir(config.archiveDir);
|
|
269
|
+
} catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
return names.map((name) => ARCHIVE_FILE.exec(name)?.[1]).filter((date) => date !== void 0).sort();
|
|
273
|
+
}
|
|
274
|
+
async function readArchiveIndex(config) {
|
|
275
|
+
const index = /* @__PURE__ */ new Map();
|
|
276
|
+
const dated = (await listArchivedDates(config)).reverse().slice(0, MAX_ARCHIVE_FILES);
|
|
277
|
+
for (const date of dated) {
|
|
278
|
+
let brief;
|
|
279
|
+
try {
|
|
280
|
+
brief = JSON.parse(await readFile(archivePath(config, date), "utf8"));
|
|
281
|
+
} catch {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (const item of brief.items ?? []) {
|
|
285
|
+
if (!item?.id) continue;
|
|
286
|
+
const existing = index.get(item.id);
|
|
287
|
+
if (!existing) {
|
|
288
|
+
index.set(item.id, {
|
|
289
|
+
title: item.title ?? item.id,
|
|
290
|
+
advancesObjective: item.advancesObjective === true,
|
|
291
|
+
firstSeen: item.firstSeen
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (item.firstSeen && (!existing.firstSeen || item.firstSeen < existing.firstSeen)) {
|
|
296
|
+
existing.firstSeen = item.firstSeen;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return index;
|
|
301
|
+
}
|
|
302
|
+
function computeObjectiveProgress(index, actions, now, counts = isWorkingDay) {
|
|
303
|
+
const lastAction = /* @__PURE__ */ new Map();
|
|
304
|
+
for (const action of actions) {
|
|
305
|
+
if (!setsStatus(action)) continue;
|
|
306
|
+
lastAction.set(canonicalId(action.id), action);
|
|
307
|
+
}
|
|
308
|
+
const byCanonical = /* @__PURE__ */ new Map();
|
|
309
|
+
for (const [id, entry] of index) byCanonical.set(canonicalId(id), entry);
|
|
310
|
+
let lastAt = null;
|
|
311
|
+
let lastTitle = null;
|
|
312
|
+
let recentCount = 0;
|
|
313
|
+
for (const [id, action] of lastAction) {
|
|
314
|
+
if (action.action !== "done") continue;
|
|
315
|
+
if (!byCanonical.get(id)?.advancesObjective) continue;
|
|
316
|
+
const at = parseISO(action.at);
|
|
317
|
+
if (!at) continue;
|
|
318
|
+
if (workingDaysSince(at, now, counts) <= RECENT_WINDOW_DAYS) recentCount++;
|
|
319
|
+
if (!lastAt || at.getTime() > lastAt.getTime()) {
|
|
320
|
+
lastAt = at;
|
|
321
|
+
lastTitle = byCanonical.get(id)?.title ?? null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
workingDaysSince: lastAt ? workingDaysSince(lastAt, now, counts) : null,
|
|
326
|
+
lastAt: lastAt ? lastAt.toISOString() : null,
|
|
327
|
+
lastTitle,
|
|
328
|
+
recentCount,
|
|
329
|
+
windowDays: RECENT_WINDOW_DAYS
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
329
333
|
// src/checks.ts
|
|
330
334
|
var GENEROUS_FLAG_THRESHOLD = 5;
|
|
331
335
|
function runContractChecks({ brief, items, focus, history }) {
|
|
@@ -386,172 +390,9 @@ function findLeakedPhrase(brief, focus) {
|
|
|
386
390
|
return null;
|
|
387
391
|
}
|
|
388
392
|
|
|
389
|
-
// src/sessions.ts
|
|
390
|
-
import { appendFile, mkdir as mkdir2, readFile as readFile2, unlink } from "node:fs/promises";
|
|
391
|
-
var MAX_SESSION_MINUTES = 120;
|
|
392
|
-
var MIN_LOGGABLE_MINUTES = 1;
|
|
393
|
-
async function readActive(config) {
|
|
394
|
-
try {
|
|
395
|
-
return JSON.parse(await readFile2(config.sessionFile, "utf8"));
|
|
396
|
-
} catch {
|
|
397
|
-
return null;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
async function writeActive(config, session) {
|
|
401
|
-
await writeJsonAtomic(config.sessionFile, session);
|
|
402
|
-
}
|
|
403
|
-
async function clearActive(config) {
|
|
404
|
-
try {
|
|
405
|
-
await unlink(config.sessionFile);
|
|
406
|
-
} catch {
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
async function logSession(config, session, endedAt, endedBy) {
|
|
410
|
-
const startedAt = parseISO(session.startedAt);
|
|
411
|
-
if (!startedAt) return null;
|
|
412
|
-
const actualMinutes = Math.round((endedAt.getTime() - startedAt.getTime()) / 6e4);
|
|
413
|
-
if (actualMinutes < MIN_LOGGABLE_MINUTES) return null;
|
|
414
|
-
const entry = {
|
|
415
|
-
id: session.id,
|
|
416
|
-
title: session.title,
|
|
417
|
-
startedAt: session.startedAt,
|
|
418
|
-
endedAt: endedAt.toISOString(),
|
|
419
|
-
plannedMinutes: session.minutes,
|
|
420
|
-
actualMinutes,
|
|
421
|
-
reachedTarget: actualMinutes >= session.minutes,
|
|
422
|
-
advancesObjective: session.advancesObjective,
|
|
423
|
-
endedBy
|
|
424
|
-
};
|
|
425
|
-
await mkdir2(config.dataDir, { recursive: true });
|
|
426
|
-
await appendFile(config.sessionsLogFile, `${JSON.stringify(entry)}
|
|
427
|
-
`, "utf8");
|
|
428
|
-
return entry;
|
|
429
|
-
}
|
|
430
|
-
async function startSession(config, opts) {
|
|
431
|
-
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
432
|
-
await stopSession(config, now);
|
|
433
|
-
await writeActive(config, {
|
|
434
|
-
id: opts.id,
|
|
435
|
-
title: opts.title,
|
|
436
|
-
startedAt: now.toISOString(),
|
|
437
|
-
minutes: opts.minutes,
|
|
438
|
-
advancesObjective: opts.advancesObjective,
|
|
439
|
-
// The click that got here is itself the first evidence of a person.
|
|
440
|
-
lastActiveAt: now.toISOString()
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
async function stopSession(config, now = /* @__PURE__ */ new Date(), endedBy = "user") {
|
|
444
|
-
const active = await readActive(config);
|
|
445
|
-
if (!active) return null;
|
|
446
|
-
await logSession(config, active, now, endedBy);
|
|
447
|
-
await clearActive(config);
|
|
448
|
-
return active;
|
|
449
|
-
}
|
|
450
|
-
async function reconcileSession(config, probe, now = /* @__PURE__ */ new Date()) {
|
|
451
|
-
if (config.awayAfterMinutes <= 0) return null;
|
|
452
|
-
const active = await readActive(config);
|
|
453
|
-
if (!active) return null;
|
|
454
|
-
const startedAt = parseISO(active.startedAt);
|
|
455
|
-
if (!startedAt) {
|
|
456
|
-
await clearActive(config);
|
|
457
|
-
return null;
|
|
458
|
-
}
|
|
459
|
-
const awayAfterMs = config.awayAfterMinutes * 6e4;
|
|
460
|
-
const previous = parseISO(active.lastActiveAt) ?? startedAt;
|
|
461
|
-
if (now.getTime() - previous.getTime() > awayAfterMs) {
|
|
462
|
-
const logged = await logSession(config, active, previous, "away");
|
|
463
|
-
await clearActive(config);
|
|
464
|
-
return logged;
|
|
465
|
-
}
|
|
466
|
-
const idleSeconds = await probe();
|
|
467
|
-
const lastInput = idleSeconds === null ? now : new Date(now.getTime() - Math.max(0, idleSeconds) * 1e3);
|
|
468
|
-
const confirmed = lastInput.getTime() > previous.getTime() ? lastInput : previous;
|
|
469
|
-
if (confirmed.toISOString() !== active.lastActiveAt) {
|
|
470
|
-
await writeActive(config, { ...active, lastActiveAt: confirmed.toISOString() });
|
|
471
|
-
}
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
async function closeIfPastBackstop(config, active, now) {
|
|
475
|
-
if (!active) return null;
|
|
476
|
-
const startedAt = parseISO(active.startedAt);
|
|
477
|
-
const elapsed = startedAt ? (now.getTime() - startedAt.getTime()) / 6e4 : Infinity;
|
|
478
|
-
if (elapsed <= MAX_SESSION_MINUTES) return active;
|
|
479
|
-
const ceiling = new Date((startedAt?.getTime() ?? now.getTime()) + MAX_SESSION_MINUTES * 6e4);
|
|
480
|
-
await stopSession(config, ceiling, "limit");
|
|
481
|
-
return null;
|
|
482
|
-
}
|
|
483
|
-
function lastUnattendedClose(todayLog) {
|
|
484
|
-
const last = todayLog.at(-1);
|
|
485
|
-
if (!last || last.endedBy === void 0 || last.endedBy === "user") return null;
|
|
486
|
-
return {
|
|
487
|
-
id: last.id,
|
|
488
|
-
title: last.title,
|
|
489
|
-
endedAt: last.endedAt,
|
|
490
|
-
actualMinutes: last.actualMinutes,
|
|
491
|
-
reason: last.endedBy
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
async function readSessionState(config, agenda, now = /* @__PURE__ */ new Date()) {
|
|
495
|
-
const active = await closeIfPastBackstop(config, await readActive(config), now);
|
|
496
|
-
const todayLog = await readTodaysSessions(config, now);
|
|
497
|
-
const completedToday = todayLog.length;
|
|
498
|
-
const minutesToday = todayLog.reduce((sum, entry) => sum + entry.actualMinutes, 0);
|
|
499
|
-
const unattendedClose = active ? null : lastUnattendedClose(todayLog);
|
|
500
|
-
if (!active) {
|
|
501
|
-
return { active: null, completedToday, minutesToday, unattendedClose };
|
|
502
|
-
}
|
|
503
|
-
const startedAt = parseISO(active.startedAt);
|
|
504
|
-
const endsAt = new Date(startedAt.getTime() + active.minutes * 6e4);
|
|
505
|
-
const remainingSeconds = Math.round((endsAt.getTime() - now.getTime()) / 1e3);
|
|
506
|
-
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];
|
|
507
|
-
return {
|
|
508
|
-
active: {
|
|
509
|
-
id: active.id,
|
|
510
|
-
title: active.title,
|
|
511
|
-
startedAt: active.startedAt,
|
|
512
|
-
advancesObjective: active.advancesObjective,
|
|
513
|
-
lastActiveAt: active.lastActiveAt,
|
|
514
|
-
endsAt: endsAt.toISOString(),
|
|
515
|
-
minutes: active.minutes,
|
|
516
|
-
remainingSeconds,
|
|
517
|
-
overrun: remainingSeconds < 0,
|
|
518
|
-
collidesWithNextEvent: nextEvent ? nextEvent.getTime() < endsAt.getTime() : false,
|
|
519
|
-
nextEventAt: nextEvent ? nextEvent.toISOString() : null
|
|
520
|
-
},
|
|
521
|
-
completedToday,
|
|
522
|
-
minutesToday,
|
|
523
|
-
unattendedClose
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
async function readSessions(config) {
|
|
527
|
-
let text;
|
|
528
|
-
try {
|
|
529
|
-
text = await readFile2(config.sessionsLogFile, "utf8");
|
|
530
|
-
} catch {
|
|
531
|
-
return [];
|
|
532
|
-
}
|
|
533
|
-
const sessions = [];
|
|
534
|
-
for (const line of text.split("\n")) {
|
|
535
|
-
if (line.trim() === "") continue;
|
|
536
|
-
try {
|
|
537
|
-
const entry = JSON.parse(line);
|
|
538
|
-
if (entry?.id && entry.startedAt) sessions.push(entry);
|
|
539
|
-
} catch {
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
return sessions;
|
|
543
|
-
}
|
|
544
|
-
async function readTodaysSessions(config, now) {
|
|
545
|
-
const all = await readSessions(config);
|
|
546
|
-
return all.filter((entry) => {
|
|
547
|
-
const started = parseISO(entry.startedAt);
|
|
548
|
-
return started !== null && started.getFullYear() === now.getFullYear() && started.getMonth() === now.getMonth() && started.getDate() === now.getDate();
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
|
|
552
393
|
// src/store.ts
|
|
553
394
|
import { createHash } from "node:crypto";
|
|
554
|
-
import { appendFile
|
|
395
|
+
import { appendFile, mkdir as mkdir2, readFile as readFile2, stat } from "node:fs/promises";
|
|
555
396
|
import { setTimeout as delay } from "node:timers/promises";
|
|
556
397
|
|
|
557
398
|
// src/agenda.ts
|
|
@@ -708,7 +549,7 @@ var Store = class {
|
|
|
708
549
|
this.config = config;
|
|
709
550
|
}
|
|
710
551
|
async ensureDataDir() {
|
|
711
|
-
await
|
|
552
|
+
await mkdir2(this.config.dataDir, { recursive: true });
|
|
712
553
|
}
|
|
713
554
|
/**
|
|
714
555
|
* Which store this is, as an opaque fingerprint: its path, and the directory's
|
|
@@ -736,7 +577,7 @@ var Store = class {
|
|
|
736
577
|
for (let attempt = 0; attempt < READ_RETRIES; attempt++) {
|
|
737
578
|
let text;
|
|
738
579
|
try {
|
|
739
|
-
text = await
|
|
580
|
+
text = await readFile2(this.config.itemsFile, "utf8");
|
|
740
581
|
} catch (err) {
|
|
741
582
|
if (err.code === "ENOENT") {
|
|
742
583
|
return {
|
|
@@ -757,7 +598,7 @@ var Store = class {
|
|
|
757
598
|
async readActions() {
|
|
758
599
|
let text;
|
|
759
600
|
try {
|
|
760
|
-
text = await
|
|
601
|
+
text = await readFile2(this.config.actionsFile, "utf8");
|
|
761
602
|
} catch (err) {
|
|
762
603
|
if (err.code === "ENOENT") return [];
|
|
763
604
|
throw err;
|
|
@@ -789,7 +630,7 @@ var Store = class {
|
|
|
789
630
|
/** Read the standing objective. Returns null when focus.md hasn't been written. */
|
|
790
631
|
async readFocus() {
|
|
791
632
|
try {
|
|
792
|
-
return parseFocus(await
|
|
633
|
+
return parseFocus(await readFile2(this.config.focusFile, "utf8"));
|
|
793
634
|
} catch (err) {
|
|
794
635
|
if (err.code === "ENOENT") return null;
|
|
795
636
|
console.warn(`[daily-focus] could not read focus.md: ${err.message}`);
|
|
@@ -802,7 +643,7 @@ var Store = class {
|
|
|
802
643
|
`;
|
|
803
644
|
const write = this.#writeQueue.then(async () => {
|
|
804
645
|
await this.ensureDataDir();
|
|
805
|
-
await
|
|
646
|
+
await appendFile(this.config.actionsFile, line, "utf8");
|
|
806
647
|
});
|
|
807
648
|
this.#writeQueue = write.catch(() => {
|
|
808
649
|
});
|
|
@@ -870,12 +711,10 @@ var Store = class {
|
|
|
870
711
|
}
|
|
871
712
|
objectiveProgress = computeObjectiveProgress(withToday, actions, now, isRunDay);
|
|
872
713
|
}
|
|
873
|
-
const session = await readSessionState(this.config, agenda, now);
|
|
874
714
|
return {
|
|
875
715
|
// toPublicFocus, not focus — the agent-only section must never be serialised.
|
|
876
716
|
focus: focus ? toPublicFocus(focus) : null,
|
|
877
717
|
objectiveProgress,
|
|
878
|
-
session,
|
|
879
718
|
schedule: {
|
|
880
719
|
days: [...schedule.days],
|
|
881
720
|
source: schedule.source,
|
|
@@ -931,6 +770,9 @@ function foldActionLog(actions, now) {
|
|
|
931
770
|
case "note":
|
|
932
771
|
if (action.text) entry.notes.push({ text: action.text, at: action.at });
|
|
933
772
|
break;
|
|
773
|
+
case "nudge":
|
|
774
|
+
entry.nudgedAt = action.at;
|
|
775
|
+
break;
|
|
934
776
|
case "done":
|
|
935
777
|
entry.status = "done";
|
|
936
778
|
delete entry.snoozedUntil;
|
|
@@ -986,12 +828,11 @@ export {
|
|
|
986
828
|
tempPathFor,
|
|
987
829
|
canonicalId,
|
|
988
830
|
fingerprintId,
|
|
989
|
-
readArchiveIndex,
|
|
990
831
|
parseBrief,
|
|
832
|
+
ACTION_TYPES,
|
|
833
|
+
setsStatus,
|
|
834
|
+
readArchiveIndex,
|
|
991
835
|
runContractChecks,
|
|
992
|
-
startSession,
|
|
993
|
-
stopSession,
|
|
994
|
-
reconcileSession,
|
|
995
836
|
Store,
|
|
996
837
|
foldActionLog,
|
|
997
838
|
resolveItems
|
|
@@ -440,15 +440,10 @@ function loadConfig(env = loadEnv()) {
|
|
|
440
440
|
schemaFile: resolve2(dataDir, "items.schema.json"),
|
|
441
441
|
schemaSource: resolve2(import.meta.dirname, "..", "schema", "items.schema.json"),
|
|
442
442
|
archiveDir: resolve2(dataDir, "archive"),
|
|
443
|
-
sessionFile: resolve2(dataDir, "session.json"),
|
|
444
|
-
sessionsLogFile: resolve2(dataDir, "sessions.jsonl"),
|
|
445
443
|
pullsFile: resolve2(dataDir, "prs.json"),
|
|
446
444
|
calendarFile: resolve2(dataDir, "calendar.json"),
|
|
447
445
|
ticketsFile: resolve2(dataDir, "tickets.json"),
|
|
448
446
|
timeZoneFile: resolve2(dataDir, "timezone.json"),
|
|
449
|
-
// The same 1 to 120 the session endpoint holds a start to.
|
|
450
|
-
sessionMinutes: envBounded("DAILY_FOCUS_SESSION_MINUTES", 25, 1, 120, env),
|
|
451
|
-
awayAfterMinutes: envBounded("DAILY_FOCUS_AWAY_AFTER", profile === "personal" ? 0 : 10, 0, 24 * 60, env),
|
|
452
447
|
port: envBounded("DAILY_FOCUS_PORT", 4321, 0, 65535, env),
|
|
453
448
|
host: envString("DAILY_FOCUS_HOST", "127.0.0.1", env),
|
|
454
449
|
freeWindows: envFlag("DAILY_FOCUS_FREE_WINDOWS", profile !== "personal", env),
|