daily-focus 1.1.1-dev.3 → 1.2.0-dev.11

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.
@@ -1,7 +1,15 @@
1
1
  import {
2
2
  ADAPTERS,
3
- Assistant
4
- } from "./chunk-GFCWPYEI.js";
3
+ Assistant,
4
+ AutoAsk,
5
+ CodexGate,
6
+ codexLatestModel,
7
+ codexVersionWarning,
8
+ computerUseOff,
9
+ countBoard,
10
+ refusalFor,
11
+ resolveBoard
12
+ } from "./chunk-EV44BC2F.js";
5
13
  import {
6
14
  FOCUS_TEMPLATE,
7
15
  sourcesTemplate
@@ -9,22 +17,20 @@ import {
9
17
  import {
10
18
  TimeZoneKeeper,
11
19
  validZone
12
- } from "./chunk-AUBJ2IDQ.js";
20
+ } from "./chunk-GVLNDQVQ.js";
13
21
  import {
14
22
  describeLink,
15
23
  linkStore,
16
24
  storeLinkWarning
17
25
  } from "./chunk-QKT2FAB5.js";
18
26
  import {
27
+ ACTION_TYPES,
19
28
  Store,
20
29
  canonicalId,
21
30
  foldActionLog,
22
- reconcileSession,
23
- startSession,
24
- stopSession,
25
31
  tempPathFor,
26
32
  writeJsonAtomic
27
- } from "./chunk-C6FXWHGT.js";
33
+ } from "./chunk-CPJ6LOVC.js";
28
34
  import {
29
35
  CLI_NAMES,
30
36
  LAUNCH_SETTINGS,
@@ -32,15 +38,15 @@ import {
32
38
  briefWritingTools,
33
39
  describeTime,
34
40
  envSources,
41
+ expandHome,
35
42
  laterISO,
36
43
  layerEnv,
37
44
  loadConfig,
38
45
  nextScheduledRun,
39
- parseISO,
40
46
  readDotEnv,
41
47
  resolveSchedule,
42
48
  scheduledRunDue
43
- } from "./chunk-NWYOCDUU.js";
49
+ } from "./chunk-4DZEBUPU.js";
44
50
  import {
45
51
  PACKAGED,
46
52
  command,
@@ -52,19 +58,259 @@ import {
52
58
  import { createServer } from "node:http";
53
59
  import { createReadStream } from "node:fs";
54
60
  import { stat as stat4 } from "node:fs/promises";
55
- import { basename, extname, join as join4, normalize, resolve, sep } from "node:path";
61
+ import { basename, extname, join as join6, normalize, resolve, sep } from "node:path";
56
62
  import { fileURLToPath } from "node:url";
57
63
 
64
+ // src/clis.ts
65
+ import { execFile as execFile2 } from "node:child_process";
66
+ import { homedir } from "node:os";
67
+ import { join as join2 } from "node:path";
68
+
69
+ // src/setup.ts
70
+ import { execFile } from "node:child_process";
71
+ import { access, constants, readdir, readFile, stat } from "node:fs/promises";
72
+ import { delimiter, isAbsolute, join } from "node:path";
73
+ import { promisify } from "node:util";
74
+ var run = promisify(execFile);
75
+ var PLACEHOLDERS = [
76
+ "Your Name",
77
+ "you@example.com",
78
+ "you@personal.example",
79
+ "your-github-login",
80
+ "Some Shared Calendar",
81
+ "Some Noisy Calendar",
82
+ "<where your colleagues are>",
83
+ "<where you are>",
84
+ "<where you live>",
85
+ "<document id>"
86
+ ];
87
+ function placeholdersIn(text) {
88
+ return PLACEHOLDERS.filter((placeholder) => text.includes(placeholder)).length;
89
+ }
90
+ async function findExecutable(name, path = process.env.PATH ?? "") {
91
+ const candidates = isAbsolute(name) ? [name] : path.split(delimiter).filter(Boolean).map((dir) => join(dir, name));
92
+ for (const candidate of candidates) {
93
+ try {
94
+ await access(candidate, constants.X_OK);
95
+ if ((await stat(candidate)).isFile()) return candidate;
96
+ } catch {
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+ async function findClis(config) {
102
+ const found = {};
103
+ for (const cli2 of CLI_NAMES) {
104
+ const candidates = [
105
+ config.agent.cli === cli2 ? config.agent.binPath : null,
106
+ config.assistant.agent === cli2 ? config.assistant.binPath : null,
107
+ cli2
108
+ ].filter((candidate) => candidate !== null);
109
+ found[cli2] = null;
110
+ for (const candidate of candidates) {
111
+ found[cli2] = await findExecutable(candidate);
112
+ if (found[cli2]) break;
113
+ }
114
+ }
115
+ return found;
116
+ }
117
+ async function hasArchive(config) {
118
+ try {
119
+ return (await readdir(config.archiveDir)).some((name) => name.endsWith(".json"));
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+ async function exists(path) {
125
+ try {
126
+ await stat(path);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+ async function firstBriefPending(config) {
133
+ return !await exists(config.itemsFile) && !await hasArchive(config);
134
+ }
135
+ async function setupState(config) {
136
+ let sources = null;
137
+ try {
138
+ sources = await readFile(config.sourcesFile, "utf8");
139
+ } catch {
140
+ sources = null;
141
+ }
142
+ const supported = process.platform === "darwin";
143
+ return {
144
+ // The file, not whether it parsed: a brief that is there but broken is a
145
+ // problem for its banner to report, not a store to set up from scratch. The
146
+ // agent's clock asks the same question.
147
+ needed: await firstBriefPending(config),
148
+ profile: config.profile,
149
+ sources: { exists: sources !== null, placeholders: sources === null ? 0 : placeholdersIn(sources) },
150
+ clis: await findClis(config),
151
+ calendar: {
152
+ supported,
153
+ built: supported && await exists(config.calendar.appPath),
154
+ chosen: config.calendar.names.length,
155
+ buildCommand: command("build-calendar")
156
+ }
157
+ };
158
+ }
159
+ var identity = null;
160
+ function ghLogins(json) {
161
+ try {
162
+ const hosts = JSON.parse(json).hosts ?? {};
163
+ const accounts = Array.isArray(hosts["github.com"]) ? hosts["github.com"] : [];
164
+ return accounts.filter((account) => typeof account.login === "string" && account.state !== "error").sort((a, b) => Number(b.active === true) - Number(a.active === true)).map((account) => account.login);
165
+ } catch {
166
+ return [];
167
+ }
168
+ }
169
+ function detectIdentity(config) {
170
+ identity ??= (async () => {
171
+ const [user, logins, email] = await Promise.all([
172
+ run(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
173
+ ({ stdout }) => stdout.split("\n"),
174
+ () => []
175
+ ),
176
+ run(config.github.ghPath, ["auth", "status", "--json", "hosts"], { timeout: 5e3 }).then(
177
+ ({ stdout }) => ghLogins(stdout),
178
+ () => []
179
+ ),
180
+ run("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
181
+ ({ stdout }) => stdout.trim() || null,
182
+ () => null
183
+ )
184
+ ]);
185
+ const login = config.github.accounts[0] ?? (user[0]?.trim() || null);
186
+ return {
187
+ login,
188
+ logins: logins.length ? logins : login ? [login] : [],
189
+ name: user[1]?.trim() || null,
190
+ email
191
+ };
192
+ })();
193
+ return identity;
194
+ }
195
+ function fillIdentity(template, who) {
196
+ let text = template;
197
+ if (who.name) text = text.replaceAll("`Your Name`", `\`${who.name}\``);
198
+ if (who.email) text = text.replaceAll("`you@example.com`", `\`${who.email}\``).replaceAll("`you@personal.example`", `\`${who.email}\``);
199
+ if (who.login) text = text.replaceAll("`your-github-login`", `\`${who.login}\``);
200
+ return text;
201
+ }
202
+
203
+ // src/clis.ts
204
+ var CLI_PREFERENCE = ["codex", "claude"];
205
+ var PROBE_TIMEOUT_MS = 1e4;
206
+ var defaultProbe = (file, args) => new Promise((resolve2, reject) => {
207
+ execFile2(file, [...args], { timeout: PROBE_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
208
+ const code = err ? typeof err.code === "number" ? err.code : null : 0;
209
+ if (err && err.code === "ENOENT") return reject(err);
210
+ resolve2({ code, out: `${stdout}
211
+ ${stderr}`.trim() });
212
+ });
213
+ });
214
+ var CLI_LABEL = { claude: "Claude Code", codex: "Codex" };
215
+ function knownLocations(cli2, home = homedir()) {
216
+ const common = [join2(home, ".local", "bin", cli2), `/opt/homebrew/bin/${cli2}`, `/usr/local/bin/${cli2}`];
217
+ return cli2 === "codex" ? [join2(home, ".codex", "packages", "standalone", "current", "bin", "codex"), ...common] : [...common, join2(home, ".claude", "local", "claude")];
218
+ }
219
+ var VERSION = /(\d+\.\d+\.\d+)/;
220
+ function identify(output) {
221
+ if (/claude code/i.test(output)) return "claude";
222
+ if (/\bcodex(-cli)?\b/i.test(output)) return "codex";
223
+ return null;
224
+ }
225
+ function loggedIn(cli2, result) {
226
+ if (cli2 === "claude") {
227
+ const json = /\{[\s\S]*\}/.exec(result.out)?.[0];
228
+ try {
229
+ const status = json ? JSON.parse(json) : null;
230
+ return typeof status?.loggedIn === "boolean" ? status.loggedIn : null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+ if (/not logged in|logged out/i.test(result.out)) return false;
236
+ if (/logged in/i.test(result.out)) return true;
237
+ return result.code !== null && result.code !== 0 ? false : null;
238
+ }
239
+ var LOGIN = {
240
+ claude: { args: ["auth", "status"], fix: "claude auth login" },
241
+ codex: { args: ["login", "status"], fix: "codex login" }
242
+ };
243
+ async function probeCli(path, expected, onPath, probe = defaultProbe) {
244
+ const missing = (problem) => ({
245
+ cli: expected,
246
+ found: { path, version: null, onPath, ready: false, problem, fix: null }
247
+ });
248
+ let version;
249
+ try {
250
+ version = await probe(path, ["--version"]);
251
+ } catch {
252
+ return missing(`Nothing runs at ${path}`);
253
+ }
254
+ const cli2 = identify(version.out) ?? expected;
255
+ if (version.code !== 0 || !cli2) {
256
+ const said = version.out.split("\n")[0]?.slice(0, 120) || "nothing";
257
+ return missing(cli2 ? `${path} didn't say its version` : `${path} isn't Codex or Claude Code: it says \u201C${said}\u201D`);
258
+ }
259
+ const number = VERSION.exec(version.out)?.[1] ?? null;
260
+ const tooOld = cli2 === "codex" ? refusalFor(number) : null;
261
+ if (tooOld) {
262
+ return { cli: cli2, found: { path, version: number, onPath, ready: false, problem: tooOld, fix: "codex update" } };
263
+ }
264
+ let login = null;
265
+ try {
266
+ login = loggedIn(cli2, await probe(path, LOGIN[cli2].args));
267
+ } catch {
268
+ login = null;
269
+ }
270
+ return {
271
+ cli: cli2,
272
+ found: login === false ? { path, version: number, onPath, ready: false, problem: `${CLI_LABEL[cli2]} is installed but not logged in`, fix: LOGIN[cli2].fix } : { path, version: number, onPath, ready: true, problem: null, fix: null }
273
+ };
274
+ }
275
+ async function detectClis(configured = {}, opts = {}) {
276
+ const report = {};
277
+ await Promise.all(
278
+ CLI_NAMES.map(async (cli2) => {
279
+ const onPath = await findExecutable(cli2, opts.path);
280
+ const candidates = [
281
+ ...configured[cli2] ? [await findExecutable(configured[cli2], opts.path)] : [],
282
+ onPath,
283
+ ...await Promise.all((opts.locations ?? knownLocations)(cli2).map((location) => findExecutable(location)))
284
+ ];
285
+ const seen = /* @__PURE__ */ new Set();
286
+ for (const candidate of candidates) {
287
+ if (!candidate || seen.has(candidate)) continue;
288
+ seen.add(candidate);
289
+ const { found } = await probeCli(candidate, cli2, candidate === onPath, opts.probe);
290
+ if (found.version !== null || found.ready) {
291
+ report[cli2] = found;
292
+ return;
293
+ }
294
+ }
295
+ report[cli2] = { path: null, version: null, onPath: false, ready: false, problem: `${CLI_LABEL[cli2]} isn't installed`, fix: null };
296
+ })
297
+ );
298
+ return report;
299
+ }
300
+ function preferredCli(report) {
301
+ return CLI_PREFERENCE.find((cli2) => report[cli2].ready) ?? null;
302
+ }
303
+
58
304
  // src/editable.ts
59
305
  import { createHash } from "node:crypto";
60
- import { readFile, rename, writeFile } from "node:fs/promises";
306
+ import { readFile as readFile2, rename, writeFile } from "node:fs/promises";
61
307
  var ABSENT = "absent";
62
308
  function versionOf(text) {
63
309
  return text === null ? ABSENT : createHash("sha256").update(text).digest("hex").slice(0, 16);
64
310
  }
65
311
  async function readEditable(path) {
66
312
  try {
67
- const text = await readFile(path, "utf8");
313
+ const text = await readFile2(path, "utf8");
68
314
  return { text, version: versionOf(text) };
69
315
  } catch (error) {
70
316
  if (error.code === "ENOENT") return { text: null, version: ABSENT };
@@ -125,7 +371,7 @@ ${lines.join("\n")}
125
371
  }
126
372
 
127
373
  // src/board.ts
128
- import { readFile as readFile2 } from "node:fs/promises";
374
+ import { readFile as readFile3 } from "node:fs/promises";
129
375
 
130
376
  // src/failure.ts
131
377
  var MAX_DETAIL = 4e3;
@@ -219,9 +465,9 @@ function reasonFor(code) {
219
465
  }
220
466
 
221
467
  // src/github.ts
222
- import { execFile } from "node:child_process";
223
- import { promisify } from "node:util";
224
- var run = promisify(execFile);
468
+ import { execFile as execFile3 } from "node:child_process";
469
+ import { promisify as promisify2 } from "node:util";
470
+ var run2 = promisify2(execFile3);
225
471
  var GH_TIMEOUT_MS = 15e3;
226
472
  var FETCH_TIMEOUT_MS = 3e4;
227
473
  var GRAPHQL_URL = "https://api.github.com/graphql";
@@ -244,7 +490,7 @@ var GhAuthError = class extends Error {
244
490
  };
245
491
  async function gh(ghPath, args) {
246
492
  try {
247
- const { stdout, stderr } = await run(ghPath, args, { env: GH_ENV, timeout: GH_TIMEOUT_MS, maxBuffer: 1 << 20 });
493
+ const { stdout, stderr } = await run2(ghPath, args, { env: GH_ENV, timeout: GH_TIMEOUT_MS, maxBuffer: 1 << 20 });
248
494
  return { stdout, stderr, code: 0 };
249
495
  } catch (err) {
250
496
  const failure = err;
@@ -497,9 +743,9 @@ function checkSlot(node) {
497
743
  return `check\0${node.name ?? ""}`;
498
744
  }
499
745
  function checkGeneration(node) {
500
- const run6 = node.checkSuite?.workflowRun;
501
- const sequence = run6?.runNumber ?? run6?.databaseId;
502
- if (typeof sequence === "number") return [sequence, run6?.runAttempt ?? 0];
746
+ const run5 = node.checkSuite?.workflowRun;
747
+ const sequence = run5?.runNumber ?? run5?.databaseId;
748
+ if (typeof sequence === "number") return [sequence, run5?.runAttempt ?? 0];
503
749
  const at = node.completedAt ?? node.startedAt ?? node.createdAt ?? null;
504
750
  const time = at ? Date.parse(at) : Number.NaN;
505
751
  return [Number.isFinite(time) ? time : 0, 0];
@@ -815,6 +1061,7 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
815
1061
  for (const [nodeId, head] of checks.heads) {
816
1062
  const pull = found.get(nodeId);
817
1063
  if (!pull) continue;
1064
+ pull.headSha = head.oid;
818
1065
  applyChecks(pull, head.rollup);
819
1066
  if (head.rollup && rollupTruncated(head.rollup)) {
820
1067
  partial.push({ id: nodeId, pull, oid: head.oid, rollup: head.rollup });
@@ -869,131 +1116,84 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
869
1116
  }
870
1117
  return { login, pulls, warnings, orgs, rateLimitRemaining };
871
1118
  }
872
-
873
- // src/prs.ts
874
- var NUDGE_AFTER_MS = 24 * 36e5;
875
- var STALE_DRAFT_AFTER_MS = 14 * 864e5;
876
- var COURT_ORDER = ["you", "ready", "reviewers", "gate", "blocked", "checks", "draft"];
877
- function courtOrder(court) {
878
- return COURT_ORDER.indexOf(court);
879
- }
880
- function ms(iso) {
881
- const parsed = parseISO(iso);
882
- return parsed ? parsed.getTime() : null;
1119
+ var TICKET_PAGE_SIZE = 25;
1120
+ var TICKET_MAX_PAGES = 12;
1121
+ var TICKET_COMMITS = 100;
1122
+ var TICKET_SEARCH_QUERY = `
1123
+ query($q: String!, $first: Int!, $after: String) {
1124
+ search(query: $q, type: ISSUE, first: $first, after: $after) {
1125
+ pageInfo { hasNextPage endCursor }
1126
+ nodes {
1127
+ ... on PullRequest {
1128
+ number
1129
+ title
1130
+ url
1131
+ state
1132
+ isDraft
1133
+ closedAt
1134
+ mergedAt
1135
+ headRefName
1136
+ body
1137
+ repository { nameWithOwner }
1138
+ commits(first: ${TICKET_COMMITS}) { nodes { commit { message } } }
1139
+ }
1140
+ }
1141
+ }
1142
+ }`;
1143
+ function buildTicketSearchQueries(scope, closedSince) {
1144
+ return [
1145
+ ["is:pr", "author:@me", "is:open", ...scope].join(" "),
1146
+ ["is:pr", "author:@me", "is:closed", `closed:>=${closedSince}`, ...scope].join(" ")
1147
+ ];
883
1148
  }
884
- function deriveDecision(pr) {
885
- if (pr.reviewDecision) return pr.reviewDecision;
886
- if (pr.reviews.some((review) => review.state === "CHANGES_REQUESTED")) return "CHANGES_REQUESTED";
887
- if (pr.reviews.some((review) => review.state === "APPROVED")) return "APPROVED";
1149
+ function toLinkedState(state, isDraft) {
1150
+ if (state === "MERGED") return "merged";
1151
+ if (state === "CLOSED") return "closed";
1152
+ if (state === "OPEN") return isDraft === true ? "draft" : "open";
888
1153
  return null;
889
1154
  }
890
- function matchMergeGates(pr, gates) {
891
- if (gates.length === 0) return [];
892
- return (pr.pendingChecks ?? []).filter((check) => gates.includes(check.name));
893
- }
894
- function newest(pr, state) {
895
- return pr.reviews.filter((review) => review.state === state).sort((a, b) => a.at < b.at ? 1 : -1)[0];
896
- }
897
- function judge(pr, now, lastNoteAt, gates = []) {
898
- const decision = deriveDecision(pr);
899
- const you = pr.lastActivityByYou ?? null;
900
- const others = pr.lastActivityByOthers ?? null;
901
- const mergeState = pr.mergeStateStatus ?? null;
902
- if (pr.isDraft) {
903
- const touched = you ?? pr.createdAt;
904
- const idle = now.getTime() - (ms(touched) ?? now.getTime());
905
- return { court: "draft", decision, reasons: [], stale: idle >= STALE_DRAFT_AFTER_MS, nudge: false, since: touched };
906
- }
907
- const reasons = [];
908
- if (decision === "CHANGES_REQUESTED") {
909
- const request = newest(pr, "CHANGES_REQUESTED");
910
- reasons.push({ kind: "changes-requested", login: request?.login, at: request?.at });
911
- }
912
- if (pr.checks === "failure") reasons.push({ kind: "ci-failing" });
913
- if (pr.mergeable === "CONFLICTING" || mergeState === "DIRTY") reasons.push({ kind: "conflicts" });
914
- if (mergeState === "BEHIND") reasons.push({ kind: "behind" });
915
- const theirMove = others !== null && (you === null || others.at > you);
916
- const approval = newest(pr, "APPROVED");
917
- const afterApproval = others !== null && approval !== void 0 && others.at > approval.at;
918
- const approved = decision === "APPROVED" && !afterApproval;
919
- if (theirMove && others && !approved) {
920
- reasons.push({ kind: "activity", login: others.login, at: others.at, activity: others.kind });
921
- }
922
- if (reasons.length > 0) {
923
- const since = theirMove && others ? others.at : pr.updatedAt;
924
- return { court: "you", decision, reasons, stale: false, nudge: false, since };
925
- }
926
- const waitingSince = laterISO(pr.readyAt, you) ?? pr.createdAt;
927
- const settled = (court, reason) => ({
928
- court,
929
- decision,
930
- reasons: [reason],
931
- stale: false,
932
- nudge: false,
933
- since: waitingSince
934
- });
935
- if (decision !== "REVIEW_REQUIRED") {
936
- const gate = matchMergeGates(pr, gates);
937
- if (gate.length > 0) return settled("gate", { kind: "merge-gate", checks: gate });
938
- const pending = pr.pendingChecks ?? [];
939
- const running = { kind: "checks-pending" };
940
- if (pending.length > 0) running.checks = pending;
941
- if (mergeState === "BLOCKED" && pending.length === 0) {
942
- return settled("blocked", { kind: "merge-blocked" });
943
- }
944
- if (mergeState === "BLOCKED" || mergeState === "UNSTABLE") return settled("checks", running);
945
- if (mergeState === "UNKNOWN") return settled("checks", { kind: "mergeability-unknown" });
946
- if (approved) {
947
- if (mergeState === "CLEAN" || mergeState === "HAS_HOOKS") {
948
- return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
1155
+ function normalizeTicketPull(raw) {
1156
+ const repo = raw.repository?.nameWithOwner;
1157
+ const url = safeUrl(raw.url);
1158
+ const state = toLinkedState(raw.state, raw.isDraft);
1159
+ if (typeof raw.number !== "number" || typeof repo !== "string" || typeof raw.title !== "string" || !url || !state) {
1160
+ return null;
1161
+ }
1162
+ const closedAt = state === "merged" ? raw.mergedAt ?? raw.closedAt ?? null : state === "closed" ? raw.closedAt ?? null : null;
1163
+ const text = [raw.title, raw.headRefName ?? "", raw.body ?? ""];
1164
+ for (const node of raw.commits?.nodes ?? []) {
1165
+ const message = node?.commit?.message;
1166
+ if (typeof message === "string") text.push(message);
1167
+ }
1168
+ return { pull: { repo, number: raw.number, title: raw.title, url, state, closedAt }, text };
1169
+ }
1170
+ async function fetchTicketPulls(token, scope, closedSince, signal) {
1171
+ const pulls = [];
1172
+ let complete = true;
1173
+ for (const q of buildTicketSearchQueries(scope, closedSince)) {
1174
+ let after = null;
1175
+ for (let page = 0; ; page++) {
1176
+ const result = await graphql(token, TICKET_SEARCH_QUERY, { q, first: TICKET_PAGE_SIZE, after }, signal);
1177
+ const fatal = result.errors.filter((error) => !isSamlError(error));
1178
+ if (result.data?.search === void 0 && fatal.length > 0) {
1179
+ throw new GitHubRequestError(`GitHub search failed: ${fatal.map((error) => error.message).join("; ")}`);
949
1180
  }
950
- if (mergeState === null) {
951
- const unfinished = pr.checks === "pending" || pending.length > 0;
952
- if (!unfinished) {
953
- return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
954
- }
955
- return settled("checks", running);
1181
+ if (result.errors.length > 0) complete = false;
1182
+ for (const node of result.data?.search?.nodes ?? []) {
1183
+ const found = node ? normalizeTicketPull(node) : null;
1184
+ if (found) pulls.push(found);
1185
+ else complete = false;
1186
+ }
1187
+ const pageInfo = result.data?.search?.pageInfo;
1188
+ if (!pageInfo?.hasNextPage || !pageInfo.endCursor) break;
1189
+ if (page === TICKET_MAX_PAGES - 1) {
1190
+ complete = false;
1191
+ break;
956
1192
  }
1193
+ after = pageInfo.endCursor;
957
1194
  }
958
1195
  }
959
- const quietSince = laterISO(waitingSince, lastNoteAt) ?? waitingSince;
960
- const waited = now.getTime() - (ms(quietSince) ?? now.getTime());
961
- return { court: "reviewers", decision, reasons, stale: false, nudge: waited >= NUDGE_AFTER_MS, since: waitingSince };
962
- }
963
- function resolveBoard(pulls, actions, now, gates = []) {
964
- const folded = foldActionLog(actions, now);
965
- const rows = pulls.map((pr) => {
966
- const log = folded.get(canonicalId(pr.id));
967
- const notes = log?.notes ?? [];
968
- const lastNoteAt = notes.length > 0 ? notes[notes.length - 1].at : null;
969
- const verdict = judge(pr, now, lastNoteAt, gates);
970
- const row = { ...pr, ...verdict, status: log?.status === "snoozed" ? "snoozed" : "open", notes };
971
- if (row.status === "snoozed" && log?.snoozedUntil) row.snoozedUntil = log.snoozedUntil;
972
- return row;
973
- });
974
- return rows.sort((a, b) => {
975
- const court = courtOrder(a.court) - courtOrder(b.court);
976
- if (court !== 0) return court;
977
- if (a.since !== b.since) return a.since < b.since ? -1 : 1;
978
- return a.id < b.id ? -1 : 1;
979
- });
980
- }
981
- function countBoard(rows) {
982
- const counts = {
983
- you: 0,
984
- ready: 0,
985
- reviewers: 0,
986
- gate: 0,
987
- blocked: 0,
988
- checks: 0,
989
- draft: 0,
990
- parked: 0
991
- };
992
- for (const row of rows) {
993
- if (row.status === "snoozed") counts.parked++;
994
- else counts[row.court]++;
995
- }
996
- return counts;
1196
+ return { pulls, complete };
997
1197
  }
998
1198
 
999
1199
  // src/board.ts
@@ -1222,7 +1422,7 @@ ${details[i] || "(nothing more)"}`).join("\n\n");
1222
1422
  let lowestRemaining = null;
1223
1423
  const results = await Promise.allSettled(
1224
1424
  identities.map(
1225
- (identity2) => this.#deps.fetch(identity2.token, this.#config.github.scope, identity2.account.login, this.#abort.signal)
1425
+ (identity3) => this.#deps.fetch(identity3.token, this.#config.github.scope, identity3.account.login, this.#abort.signal)
1226
1426
  )
1227
1427
  );
1228
1428
  results.forEach((result, i) => {
@@ -1357,6 +1557,9 @@ function normalizeStoredPull(raw) {
1357
1557
  autoMerge: raw.autoMerge === true,
1358
1558
  requestedReviewers: strings(raw.requestedReviewers),
1359
1559
  reviews,
1560
+ // Kept, or a restart would hand the assistant's answers ahead about failing
1561
+ // checks a key the live read doesn't, and they would be asked again.
1562
+ headSha: typeof raw.headSha === "string" ? raw.headSha : null,
1360
1563
  lastActivityByYou: typeof raw.lastActivityByYou === "string" ? raw.lastActivityByYou : null,
1361
1564
  lastActivityByOthers
1362
1565
  };
@@ -1364,7 +1567,7 @@ function normalizeStoredPull(raw) {
1364
1567
  async function readPullsFile(path) {
1365
1568
  let text;
1366
1569
  try {
1367
- text = await readFile2(path, "utf8");
1570
+ text = await readFile3(path, "utf8");
1368
1571
  } catch {
1369
1572
  return null;
1370
1573
  }
@@ -1388,12 +1591,12 @@ async function readPullsFile(path) {
1388
1591
  }
1389
1592
 
1390
1593
  // src/calendar.ts
1391
- import { execFile as execFile2 } from "node:child_process";
1392
- import { readFile as readFile3, rm, stat } from "node:fs/promises";
1594
+ import { execFile as execFile4 } from "node:child_process";
1595
+ import { readFile as readFile4, rm, stat as stat2 } from "node:fs/promises";
1393
1596
  import { tmpdir } from "node:os";
1394
- import { join } from "node:path";
1395
- import { promisify as promisify2 } from "node:util";
1396
- var run2 = promisify2(execFile2);
1597
+ import { join as join3 } from "node:path";
1598
+ import { promisify as promisify3 } from "node:util";
1599
+ var run3 = promisify3(execFile4);
1397
1600
  var HELPER_TIMEOUT_MS = 2e4;
1398
1601
  var POLL_MS = 150;
1399
1602
  var DECLINED = 3;
@@ -1416,17 +1619,17 @@ function parseCalendarFacts(raw) {
1416
1619
  if (typeof raw !== "object" || raw === null) return null;
1417
1620
  const obj = raw;
1418
1621
  if (str2(obj.error)) return null;
1419
- const calendars = [];
1622
+ const calendars2 = [];
1420
1623
  if (Array.isArray(obj.calendars)) {
1421
1624
  for (const entry of obj.calendars) {
1422
1625
  const id = str2(entry?.id);
1423
1626
  const title = str2(entry?.title);
1424
- if (id && title) calendars.push({ id, title, source: str2(entry?.source) ?? "" });
1627
+ if (id && title) calendars2.push({ id, title, source: str2(entry?.source) ?? "" });
1425
1628
  }
1426
1629
  }
1427
1630
  return {
1428
1631
  generatedAt: str2(obj.generatedAt) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
1429
- calendars,
1632
+ calendars: calendars2,
1430
1633
  events: Array.isArray(obj.events) ? obj.events : []
1431
1634
  };
1432
1635
  }
@@ -1483,17 +1686,17 @@ var CalendarHelperError = class extends Error {
1483
1686
  };
1484
1687
  async function runHelper(appPath, selfAddresses) {
1485
1688
  try {
1486
- await stat(appPath);
1689
+ await stat2(appPath);
1487
1690
  } catch {
1488
1691
  throw new CalendarHelperError(
1489
1692
  `the calendar helper isn't built at ${appPath} \u2014 run ${command("build-calendar")}, or point DAILY_FOCUS_CALENDAR_APP at an existing build`
1490
1693
  );
1491
1694
  }
1492
- const out = join(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1695
+ const out = join3(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1493
1696
  try {
1494
1697
  try {
1495
1698
  const zone = process.env.TZ ? ["--env", `TZ=${process.env.TZ}`] : [];
1496
- await run2("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1699
+ await run3("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1497
1700
  timeout: HELPER_TIMEOUT_MS
1498
1701
  });
1499
1702
  } catch (err) {
@@ -1504,7 +1707,7 @@ async function runHelper(appPath, selfAddresses) {
1504
1707
  for (; ; ) {
1505
1708
  let text = null;
1506
1709
  try {
1507
- text = await readFile3(out, "utf8");
1710
+ text = await readFile4(out, "utf8");
1508
1711
  } catch {
1509
1712
  text = null;
1510
1713
  }
@@ -1528,7 +1731,7 @@ async function runHelper(appPath, selfAddresses) {
1528
1731
  }
1529
1732
 
1530
1733
  // src/calendarboard.ts
1531
- import { readFile as readFile4 } from "node:fs/promises";
1734
+ import { readFile as readFile5 } from "node:fs/promises";
1532
1735
  var MAX_BACKOFF_MS2 = 15 * 6e4;
1533
1736
  var realDeps2 = { run: runHelper };
1534
1737
  var CalendarBoard = class {
@@ -1680,19 +1883,100 @@ var CalendarBoard = class {
1680
1883
  };
1681
1884
  async function readCalendarFile(path) {
1682
1885
  try {
1683
- return parseCalendarFacts(JSON.parse(await readFile4(path, "utf8")));
1886
+ return parseCalendarFacts(JSON.parse(await readFile5(path, "utf8")));
1684
1887
  } catch {
1685
1888
  return null;
1686
1889
  }
1687
1890
  }
1688
1891
 
1689
1892
  // src/ticketboard.ts
1690
- import { readFile as readFile5 } from "node:fs/promises";
1893
+ import { readFile as readFile6 } from "node:fs/promises";
1691
1894
 
1692
1895
  // src/jira.ts
1693
- import { execFile as execFile3 } from "node:child_process";
1694
- import { promisify as promisify3 } from "node:util";
1695
- var run3 = promisify3(execFile3);
1896
+ import { execFile as execFile5 } from "node:child_process";
1897
+ import { promisify as promisify4 } from "node:util";
1898
+
1899
+ // src/linked.ts
1900
+ var CLOSED_LOOKBACK_DAYS = 60;
1901
+ var KEYISH = /(?<![A-Za-z0-9])([A-Za-z][A-Za-z0-9_]*)-(\d+)/g;
1902
+ function issueKeysIn(text) {
1903
+ const keys = /* @__PURE__ */ new Set();
1904
+ for (const match of text.matchAll(KEYISH)) {
1905
+ const parts = match[1].split("_");
1906
+ for (let i = 0; i < parts.length; i++) {
1907
+ const project = parts.slice(i).join("_");
1908
+ if (/^[A-Za-z]/.test(project)) keys.add(`${project.toUpperCase()}-${match[2]}`);
1909
+ }
1910
+ }
1911
+ return keys;
1912
+ }
1913
+ var realDeps3 = { token: ghToken, search: fetchTicketPulls };
1914
+ var CONSEQUENCE = "so a merged pull request counts only once Jira has it";
1915
+ async function readLinkedPulls(github, now, signal, deps = realDeps3) {
1916
+ const closedSince = new Date(now.getTime() - CLOSED_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
1917
+ const logins = github.accounts.length > 0 ? [...github.accounts] : [null];
1918
+ const results = await Promise.allSettled(
1919
+ logins.map(async (login) => deps.search(await deps.token(github.ghPath, login), github.scope, closedSince, signal))
1920
+ );
1921
+ if (results.every((result) => result.status === "rejected")) {
1922
+ throw results[0].reason;
1923
+ }
1924
+ const warnings = [];
1925
+ let complete = true;
1926
+ const byKey = /* @__PURE__ */ new Map();
1927
+ const seen = /* @__PURE__ */ new Set();
1928
+ results.forEach((result, i) => {
1929
+ const who = logins[i] ? ` as ${logins[i]}` : "";
1930
+ if (result.status === "rejected") {
1931
+ complete = false;
1932
+ warnings.push(`Could not ask GitHub${who} about these tickets' pull requests (${result.reason.message}), ${CONSEQUENCE}.`);
1933
+ return;
1934
+ }
1935
+ if (!result.value.complete) {
1936
+ complete = false;
1937
+ warnings.push(`GitHub did not list every pull request${who}, ${CONSEQUENCE}.`);
1938
+ }
1939
+ for (const { pull, text } of result.value.pulls) {
1940
+ if (seen.has(pull.url)) continue;
1941
+ seen.add(pull.url);
1942
+ const keys = /* @__PURE__ */ new Set();
1943
+ for (const piece of text) for (const key of issueKeysIn(piece)) keys.add(key);
1944
+ for (const key of keys) byKey.set(key, [...byKey.get(key) ?? [], pull]);
1945
+ }
1946
+ });
1947
+ return { byKey, complete, warnings };
1948
+ }
1949
+ function isOpen(pull) {
1950
+ return pull.state === "open" || pull.state === "draft";
1951
+ }
1952
+ function byRecency(a, b) {
1953
+ if (isOpen(a) !== isOpen(b)) return isOpen(a) ? -1 : 1;
1954
+ return (b.closedAt ?? "").localeCompare(a.closedAt ?? "");
1955
+ }
1956
+ function linkedTo(ticket, linked) {
1957
+ return linked?.byKey.get(ticket.key.toUpperCase()) ?? [];
1958
+ }
1959
+ function needsJiraCount(ticket, linked) {
1960
+ const found = linkedTo(ticket, linked);
1961
+ return Boolean(linked?.complete) && found.length > 0 && !found.some(isOpen) && ticket.hasAnyPr && !ticket.allPrsClosed;
1962
+ }
1963
+ function reconcile(ticket, linked, jiraCount) {
1964
+ const found = [...linkedTo(ticket, linked)].sort(byRecency);
1965
+ if (found.length === 0) return ticket;
1966
+ const jira = { hasAnyPr: ticket.hasAnyPr, hasOpenPr: ticket.hasOpenPr, allPrsClosed: ticket.allPrsClosed };
1967
+ let reading = jira;
1968
+ if (found.some(isOpen)) {
1969
+ reading = { hasAnyPr: true, hasOpenPr: true, allPrsClosed: false };
1970
+ } else if (linked?.complete && !ticket.allPrsClosed) {
1971
+ const known = ticket.hasAnyPr ? jiraCount : 0;
1972
+ if (known !== null && found.length >= known) reading = { hasAnyPr: true, hasOpenPr: false, allPrsClosed: true };
1973
+ }
1974
+ const changed = reading.hasAnyPr !== jira.hasAnyPr || reading.hasOpenPr !== jira.hasOpenPr || reading.allPrsClosed !== jira.allPrsClosed;
1975
+ return { ...ticket, ...reading, pulls: found, ...changed ? { fromGitHub: true } : {} };
1976
+ }
1977
+
1978
+ // src/jira.ts
1979
+ var run4 = promisify4(execFile5);
1696
1980
  var ACLI_TIMEOUT_MS = 45e3;
1697
1981
  var PAGE_LIMIT = 200;
1698
1982
  var ACLI_ENV = { ...process.env, NO_COLOR: "1" };
@@ -1728,7 +2012,7 @@ var JiraTransitionError = class extends Error {
1728
2012
  };
1729
2013
  async function acli(acliPath, args) {
1730
2014
  try {
1731
- const { stdout, stderr } = await run3(acliPath, args, {
2015
+ const { stdout, stderr } = await run4(acliPath, args, {
1732
2016
  env: ACLI_ENV,
1733
2017
  timeout: ACLI_TIMEOUT_MS,
1734
2018
  // A hundred tickets of raw Jira issue JSON measured 135 KB; this is room
@@ -1874,13 +2158,13 @@ function jsonObjectAt(text, from) {
1874
2158
  }
1875
2159
  return null;
1876
2160
  }
1877
- function readDevPullRequests(field) {
1878
- if (typeof field !== "string") return null;
1879
- const marker = field.indexOf("json=");
2161
+ function readDevPullRequests(field2) {
2162
+ if (typeof field2 !== "string") return null;
2163
+ const marker = field2.indexOf("json=");
1880
2164
  if (marker === -1) return null;
1881
- const opens = field.indexOf("{", marker);
2165
+ const opens = field2.indexOf("{", marker);
1882
2166
  if (opens === -1) return null;
1883
- const body = jsonObjectAt(field, opens);
2167
+ const body = jsonObjectAt(field2, opens);
1884
2168
  if (body === null) return null;
1885
2169
  let parsed;
1886
2170
  try {
@@ -1975,22 +2259,23 @@ async function fetchTickets(acliPath, opts) {
1975
2259
  if (unreadable > 0) {
1976
2260
  warnings.push(`Skipped ${unreadable} work ${unreadable === 1 ? "item" : "items"} Jira described in a way this board could not read.`);
1977
2261
  }
1978
- const candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
1979
- const panels = await mapLimit(
1980
- candidates,
1981
- DEV_CONCURRENCY,
1982
- (ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
1983
- (dev) => ({ asked: true, dev }),
1984
- () => ({ asked: false, dev: null })
1985
- )
2262
+ const readPanel = (ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
2263
+ (dev) => ({ asked: true, dev }),
2264
+ () => ({ asked: false, dev: null })
1986
2265
  );
2266
+ const candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
2267
+ const reading = mapLimit(candidates, DEV_CONCURRENCY, readPanel);
2268
+ const linked = await opts.linked ?? null;
2269
+ const panels = await reading;
2270
+ const counts = /* @__PURE__ */ new Map();
1987
2271
  const unanswered = [];
1988
2272
  const unaccounted = [];
1989
2273
  candidates.forEach((ticket, i) => {
1990
- const { asked, dev } = panels[i];
2274
+ const { asked: asked2, dev } = panels[i];
2275
+ if (dev) counts.set(ticket.key, dev.count);
1991
2276
  if (allPullRequestsClosed(dev)) {
1992
2277
  ticket.allPrsClosed = true;
1993
- } else if (!asked) {
2278
+ } else if (!asked2) {
1994
2279
  unanswered.push(ticket.key);
1995
2280
  } else if (dev === null) {
1996
2281
  unaccounted.push(ticket.key);
@@ -1998,19 +2283,30 @@ async function fetchTickets(acliPath, opts) {
1998
2283
  ticket.hasOpenPr = true;
1999
2284
  }
2000
2285
  });
2001
- const listed = (keys2) => keys2.map((key) => {
2286
+ const asked = new Set(candidates);
2287
+ const behind = tickets.filter((ticket) => !asked.has(ticket) && needsJiraCount(ticket, linked));
2288
+ const extra = await mapLimit(behind, DEV_CONCURRENCY, readPanel);
2289
+ behind.forEach((ticket, i) => {
2290
+ const dev = extra[i].dev;
2291
+ if (dev) counts.set(ticket.key, dev.count);
2292
+ });
2293
+ const reconciled = tickets.map((ticket) => reconcile(ticket, linked, counts.get(ticket.key) ?? null));
2294
+ const undecided = new Set(reconciled.filter((ticket) => !ticket.hasOpenPr && !ticket.allPrsClosed).map((ticket) => ticket.key));
2295
+ const unansweredNow = unanswered.filter((key) => undecided.has(key));
2296
+ const unaccountedNow = unaccounted.filter((key) => undecided.has(key));
2297
+ const listed2 = (keys2) => keys2.map((key) => {
2002
2298
  const url = browseUrl(opts.site, key);
2003
2299
  return url === null ? key : `[${key}](${url})`;
2004
2300
  }).join(", ");
2005
2301
  const heldOut = (keys2) => `${keys2.length === 1 ? "it is" : "they are"} held out of "no open pull requests left" rather than guessed at`;
2006
- if (unanswered.length > 0) {
2302
+ if (unansweredNow.length > 0) {
2007
2303
  warnings.push(
2008
- `Could not read Jira's development panel for ${listed(unanswered)}, so ${heldOut(unanswered)}. The next read may well manage it.`
2304
+ `Could not read Jira's development panel for ${listed2(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
2009
2305
  );
2010
2306
  }
2011
- if (unaccounted.length > 0) {
2307
+ if (unaccountedNow.length > 0) {
2012
2308
  warnings.push(
2013
- `Jira's search says ${listed(unaccounted)} ${unaccounted.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccounted)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
2309
+ `Jira's search says ${listed2(unaccountedNow)} ${unaccountedNow.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccountedNow)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
2014
2310
  );
2015
2311
  }
2016
2312
  if (!opts.site) {
@@ -2027,7 +2323,7 @@ async function fetchTickets(acliPath, opts) {
2027
2323
  );
2028
2324
  }
2029
2325
  return {
2030
- tickets,
2326
+ tickets: reconciled,
2031
2327
  statuses: statusesByProject(base, ...finished.map((batch) => batch ?? [])),
2032
2328
  warnings
2033
2329
  };
@@ -2099,16 +2395,16 @@ function readTransitionReport(stdout, key, status) {
2099
2395
  }
2100
2396
 
2101
2397
  // src/tickets.ts
2102
- var COURT_ORDER2 = ["settled", "started", "idle"];
2398
+ var COURT_ORDER = ["settled", "started", "idle"];
2103
2399
  function ticketCourtOrder(court) {
2104
- return COURT_ORDER2.indexOf(court);
2400
+ return COURT_ORDER.indexOf(court);
2105
2401
  }
2106
2402
  function inStatus(ticket, statuses) {
2107
2403
  const status = ticket.workflowStatus.trim().toLowerCase();
2108
2404
  if (status === "") return false;
2109
- return statuses.some((listed) => listed.trim().toLowerCase() === status);
2405
+ return statuses.some((listed2) => listed2.trim().toLowerCase() === status);
2110
2406
  }
2111
- function judge2(ticket, holdStatuses = []) {
2407
+ function judge(ticket, holdStatuses = []) {
2112
2408
  if (ticket.statusCategory === "done") return null;
2113
2409
  if (ticket.hasAnyPr && !ticket.hasOpenPr && ticket.allPrsClosed) return "settled";
2114
2410
  if (ticket.statusCategory === "new" && ticket.hasOpenPr) return "started";
@@ -2119,7 +2415,7 @@ function resolveTickets(tickets, actions, now, holdStatuses = []) {
2119
2415
  const folded = foldActionLog(actions, now);
2120
2416
  const rows = [];
2121
2417
  for (const ticket of tickets) {
2122
- const court = judge2(ticket, holdStatuses);
2418
+ const court = judge(ticket, holdStatuses);
2123
2419
  if (!court) continue;
2124
2420
  const log = folded.get(canonicalId(ticket.id));
2125
2421
  const row = {
@@ -2148,7 +2444,13 @@ function countTickets(rows) {
2148
2444
 
2149
2445
  // src/ticketboard.ts
2150
2446
  var MAX_BACKOFF_MS3 = 30 * 6e4;
2151
- var realDeps3 = { identity: acliIdentity, fetch: fetchTickets, transition: transitionTicket };
2447
+ var GITHUB_DEADLINE_MS = 9e4;
2448
+ var realDeps4 = {
2449
+ identity: acliIdentity,
2450
+ fetch: fetchTickets,
2451
+ transition: transitionTicket,
2452
+ linked: (github, now, signal) => readLinkedPulls(github, now, signal)
2453
+ };
2152
2454
  var TicketBoard = class {
2153
2455
  #config;
2154
2456
  #deps;
@@ -2165,7 +2467,8 @@ var TicketBoard = class {
2165
2467
  #audience = 0;
2166
2468
  #stopped = false;
2167
2469
  #lastReadAt = 0;
2168
- constructor(config, onChange, deps = realDeps3) {
2470
+ #abort = new AbortController();
2471
+ constructor(config, onChange, deps = realDeps4) {
2169
2472
  this.#config = config;
2170
2473
  this.#deps = deps;
2171
2474
  this.#onChange = onChange;
@@ -2179,10 +2482,12 @@ var TicketBoard = class {
2179
2482
  this.#file = await readTicketsFile(this.#config.ticketsFile);
2180
2483
  await this.refresh();
2181
2484
  }
2485
+ /** Stop reading, and cancel a GitHub request in flight so nothing lands after close. */
2182
2486
  stop() {
2183
2487
  this.#stopped = true;
2184
2488
  if (this.#timer) clearTimeout(this.#timer);
2185
2489
  this.#timer = null;
2490
+ this.#abort.abort();
2186
2491
  }
2187
2492
  /** How many browsers are watching. Polling pauses at zero. */
2188
2493
  setAudience(count) {
@@ -2288,24 +2593,40 @@ var TicketBoard = class {
2288
2593
  }
2289
2594
  async #read() {
2290
2595
  const { acliPath, projects, site: configuredSite } = this.#config.jira;
2291
- let identity2;
2596
+ let identity3;
2292
2597
  try {
2293
- identity2 = await this.#deps.identity(acliPath);
2598
+ identity3 = await this.#deps.identity(acliPath);
2294
2599
  } catch (err) {
2295
2600
  this.#reason = { message: `${err.message}, or set DAILY_FOCUS_JIRA=off`, detail: errorDetail(err) };
2296
2601
  this.#streak = extend(this.#streak);
2297
2602
  return;
2298
2603
  }
2299
- const site = configuredSite ?? identity2.site;
2604
+ const site = configuredSite ?? identity3.site;
2605
+ const githubWarnings = [];
2606
+ const linked = this.#config.github.enabled ? this.#deps.linked(this.#config.github, /* @__PURE__ */ new Date(), AbortSignal.any([this.#abort.signal, AbortSignal.timeout(GITHUB_DEADLINE_MS)])).then(
2607
+ (read) => {
2608
+ githubWarnings.push(...read.warnings);
2609
+ return read;
2610
+ },
2611
+ (err) => {
2612
+ githubWarnings.push(
2613
+ `Could not ask GitHub about these tickets' pull requests (${err.message}), so they are as Jira has them, which can be days behind.`
2614
+ );
2615
+ return null;
2616
+ }
2617
+ ) : Promise.resolve(null);
2300
2618
  try {
2301
- const { tickets, statuses, warnings } = await this.#deps.fetch(acliPath, { projects, site });
2619
+ const fetched = await this.#deps.fetch(acliPath, { projects, site, linked });
2620
+ await linked;
2621
+ const { tickets, statuses } = fetched;
2622
+ const warnings = [...fetched.warnings, ...githubWarnings];
2302
2623
  this.#reason = null;
2303
2624
  this.#warnings = warnings;
2304
2625
  this.#streak = null;
2305
2626
  this.#file = {
2306
2627
  version: 1,
2307
2628
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
2308
- account: identity2.account,
2629
+ account: identity3.account,
2309
2630
  projects: [...projects],
2310
2631
  warnings,
2311
2632
  tickets,
@@ -2334,7 +2655,7 @@ function normalizeStoredTicket(raw) {
2334
2655
  const { key, url, workflowStatus, statusCategory, issueType, summary } = raw;
2335
2656
  if (typeof key !== "string" || key === "") return null;
2336
2657
  const site = typeof url === "string" ? /^https:\/\/([^/]+)\/browse\//.exec(url)?.[1] ?? null : null;
2337
- return toTicket(
2658
+ const ticket = toTicket(
2338
2659
  {
2339
2660
  key,
2340
2661
  fields: {
@@ -2351,11 +2672,30 @@ function normalizeStoredTicket(raw) {
2351
2672
  // files are exactly the ones whose settled court counted drafts as merges.
2352
2673
  raw.allPrsClosed === true
2353
2674
  );
2675
+ if (!ticket) return null;
2676
+ const pulls = storedPulls(raw.pulls);
2677
+ if (pulls.length > 0) {
2678
+ ticket.pulls = pulls;
2679
+ if (raw.fromGitHub === true) ticket.fromGitHub = true;
2680
+ }
2681
+ return ticket;
2682
+ }
2683
+ var PULL_STATES = ["open", "draft", "merged", "closed"];
2684
+ function storedPulls(value) {
2685
+ if (!Array.isArray(value)) return [];
2686
+ return value.flatMap((entry) => {
2687
+ if (!isRecord3(entry)) return [];
2688
+ const { repo, number, title, url, state, closedAt } = entry;
2689
+ if (typeof repo !== "string" || typeof number !== "number" || typeof title !== "string") return [];
2690
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return [];
2691
+ if (typeof state !== "string" || !PULL_STATES.includes(state)) return [];
2692
+ return [{ repo, number, title, url, state, closedAt: typeof closedAt === "string" ? closedAt : null }];
2693
+ });
2354
2694
  }
2355
2695
  async function readTicketsFile(path) {
2356
2696
  let text;
2357
2697
  try {
2358
- text = await readFile5(path, "utf8");
2698
+ text = await readFile6(path, "utf8");
2359
2699
  } catch {
2360
2700
  return null;
2361
2701
  }
@@ -2388,8 +2728,8 @@ async function readTicketsFile(path) {
2388
2728
  // src/agent.ts
2389
2729
  import { spawn } from "node:child_process";
2390
2730
  import { randomUUID } from "node:crypto";
2391
- import { appendFile, mkdir, readFile as readFile6 } from "node:fs/promises";
2392
- import { homedir } from "node:os";
2731
+ import { appendFile, mkdir, readFile as readFile7 } from "node:fs/promises";
2732
+ import { homedir as homedir2 } from "node:os";
2393
2733
  import { createInterface } from "node:readline";
2394
2734
  var RUN_TIMEOUT_MS = 60 * 6e4;
2395
2735
  var TURN_TIMEOUT_MS = 10 * 6e4;
@@ -2419,7 +2759,7 @@ function followUpPrompt(question) {
2419
2759
  question.trim()
2420
2760
  ].join("\n");
2421
2761
  }
2422
- function displayPath(path, home = homedir()) {
2762
+ function displayPath(path, home = homedir2()) {
2423
2763
  return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
2424
2764
  }
2425
2765
  function foldAgentLog(text) {
@@ -2451,11 +2791,11 @@ function foldAgentLog(text) {
2451
2791
  });
2452
2792
  continue;
2453
2793
  }
2454
- const run6 = runs.get(record.run);
2455
- if (!run6) continue;
2794
+ const run5 = runs.get(record.run);
2795
+ if (!run5) continue;
2456
2796
  if (record.event === "asked") {
2457
2797
  if (typeof record.turn !== "string" || typeof record.question !== "string") continue;
2458
- run6.turns.push({
2798
+ run5.turns.push({
2459
2799
  id: record.turn,
2460
2800
  question: record.question,
2461
2801
  startedAt: record.at,
@@ -2467,7 +2807,7 @@ function foldAgentLog(text) {
2467
2807
  continue;
2468
2808
  }
2469
2809
  if (record.event === "replied" || record.event === "turn-failed" || record.event === "turn-aborted") {
2470
- const turn = run6.turns.find((candidate) => candidate.id === record.turn);
2810
+ const turn = run5.turns.find((candidate) => candidate.id === record.turn);
2471
2811
  if (!turn) continue;
2472
2812
  turn.reply = typeof record.reply === "string" ? record.reply : turn.reply;
2473
2813
  turn.endedAt = record.at;
@@ -2482,18 +2822,18 @@ function foldAgentLog(text) {
2482
2822
  }
2483
2823
  continue;
2484
2824
  }
2485
- run6.sessionId = typeof record.sessionId === "string" ? record.sessionId : run6.sessionId;
2486
- run6.report = typeof record.report === "string" ? record.report : run6.report;
2487
- run6.messages = Array.isArray(record.messages) ? record.messages.filter((message) => typeof message === "string") : run6.report ? [run6.report] : [];
2488
- run6.endedAt = record.at;
2825
+ run5.sessionId = typeof record.sessionId === "string" ? record.sessionId : run5.sessionId;
2826
+ run5.report = typeof record.report === "string" ? record.report : run5.report;
2827
+ run5.messages = Array.isArray(record.messages) ? record.messages.filter((message) => typeof message === "string") : run5.report ? [run5.report] : [];
2828
+ run5.endedAt = record.at;
2489
2829
  if (record.event === "finished") {
2490
- run6.status = "done";
2830
+ run5.status = "done";
2491
2831
  } else if (record.event === "failed") {
2492
- run6.status = "failed";
2493
- run6.error = record.error;
2832
+ run5.status = "failed";
2833
+ run5.error = record.error;
2494
2834
  } else if (record.event === "aborted") {
2495
- run6.status = "aborted";
2496
- run6.error = record.reason;
2835
+ run5.status = "aborted";
2836
+ run5.error = record.reason;
2497
2837
  }
2498
2838
  }
2499
2839
  return [...runs.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
@@ -2506,10 +2846,13 @@ var AgentRunner = class {
2506
2846
  config;
2507
2847
  hooks;
2508
2848
  spawnFn;
2509
- constructor(config, hooks, spawnFn = spawn) {
2849
+ /** Refuses a Codex too old to hold to the connector rules; see `codex.ts`. */
2850
+ codexGate;
2851
+ constructor(config, hooks, spawnFn = spawn, execFn) {
2510
2852
  this.config = config;
2511
2853
  this.hooks = hooks;
2512
2854
  this.spawnFn = spawnFn;
2855
+ this.codexGate = new CodexGate(config.agent.binPath, execFn);
2513
2856
  }
2514
2857
  get enabled() {
2515
2858
  return this.config.agent.cli !== null;
@@ -2523,26 +2866,26 @@ var AgentRunner = class {
2523
2866
  this.loaded = true;
2524
2867
  let text = "";
2525
2868
  try {
2526
- text = await readFile6(this.config.agentLogFile, "utf8");
2869
+ text = await readFile7(this.config.agentLogFile, "utf8");
2527
2870
  } catch {
2528
2871
  return;
2529
2872
  }
2530
2873
  this.runs = foldAgentLog(text);
2531
2874
  const at = (/* @__PURE__ */ new Date()).toISOString();
2532
2875
  const reason = "the dashboard restarted while this was running";
2533
- for (const run6 of this.runs) {
2534
- if (run6.status === "running") {
2535
- run6.status = "aborted";
2536
- run6.error = reason;
2537
- run6.endedAt = at;
2538
- await this.append({ event: "aborted", run: run6.id, sessionId: run6.sessionId, reason, report: run6.report, messages: run6.messages, at });
2876
+ for (const run5 of this.runs) {
2877
+ if (run5.status === "running") {
2878
+ run5.status = "aborted";
2879
+ run5.error = reason;
2880
+ run5.endedAt = at;
2881
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason, report: run5.report, messages: run5.messages, at });
2539
2882
  }
2540
- for (const turn of run6.turns) {
2883
+ for (const turn of run5.turns) {
2541
2884
  if (turn.status !== "running") continue;
2542
2885
  turn.status = "aborted";
2543
2886
  turn.error = reason;
2544
2887
  turn.endedAt = at;
2545
- await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason, reply: turn.reply, at });
2888
+ await this.append({ event: "turn-aborted", run: run5.id, turn: turn.id, reason, reply: turn.reply, at });
2546
2889
  }
2547
2890
  }
2548
2891
  }
@@ -2555,7 +2898,7 @@ var AgentRunner = class {
2555
2898
  return this.runs.at(-1) ?? null;
2556
2899
  }
2557
2900
  view(now = /* @__PURE__ */ new Date()) {
2558
- const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run6) => ({ ...run6, resumable: this.resumable(run6) }));
2901
+ const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run5) => ({ ...run5, resumable: this.resumable(run5) }));
2559
2902
  return {
2560
2903
  enabled: this.enabled,
2561
2904
  cli: this.config.agent.cli,
@@ -2601,17 +2944,17 @@ var AgentRunner = class {
2601
2944
  * decided on.
2602
2945
  */
2603
2946
  async run(trigger = "hand", now = /* @__PURE__ */ new Date()) {
2604
- const cli = this.config.agent.cli;
2605
- if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2947
+ const cli2 = this.config.agent.cli;
2948
+ if (!cli2) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2606
2949
  if (this.running) {
2607
2950
  throw new Error(
2608
2951
  this.running.turn ? "the morning agent is still answering a question" : "the morning agent is already running"
2609
2952
  );
2610
2953
  }
2611
2954
  await this.start();
2612
- const run6 = {
2955
+ const run5 = {
2613
2956
  id: randomUUID(),
2614
- cli,
2957
+ cli: cli2,
2615
2958
  trigger,
2616
2959
  sessionId: null,
2617
2960
  startedAt: now.toISOString(),
@@ -2623,38 +2966,40 @@ var AgentRunner = class {
2623
2966
  turns: [],
2624
2967
  resumable: false
2625
2968
  };
2626
- this.runs.push(run6);
2627
- await this.append({ event: "started", run: run6.id, cli, trigger, at: run6.startedAt });
2628
- const adapter = ADAPTERS[cli];
2969
+ this.runs.push(run5);
2970
+ await this.append({ event: "started", run: run5.id, cli: cli2, trigger, at: run5.startedAt });
2971
+ const adapter = ADAPTERS[cli2];
2629
2972
  const args = adapter.args({
2630
2973
  prompt: agentPrompt(displayPath(this.config.promptFile)),
2631
2974
  sessionId: null,
2632
2975
  model: this.config.agent.model,
2633
2976
  effort: this.config.agent.effort,
2634
2977
  tools: [...briefWritingTools(), ...this.config.agent.tools],
2635
- denyEdits: false
2978
+ denyEdits: false,
2979
+ drafts: false,
2980
+ codexConfig: []
2636
2981
  });
2637
- await this.spawnInto(run6, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
2638
- return run6;
2982
+ await this.spawnInto(run5, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
2983
+ return run5;
2639
2984
  }
2640
2985
  /**
2641
2986
  * Ask a finished run a question, in its own session. Returns once the process
2642
2987
  * is running; the answer arrives through `onChange` and the next state.
2643
2988
  */
2644
2989
  async ask(runId, question) {
2645
- const cli = this.config.agent.cli;
2646
- if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2990
+ const cli2 = this.config.agent.cli;
2991
+ if (!cli2) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2647
2992
  await this.start();
2648
- const run6 = this.runs.find((candidate) => candidate.id === runId);
2649
- if (!run6) throw new Error("no such run");
2993
+ const run5 = this.runs.find((candidate) => candidate.id === runId);
2994
+ if (!run5) throw new Error("no such run");
2650
2995
  if (this.running) {
2651
2996
  throw new Error(
2652
2997
  this.running.turn ? "the morning agent is still answering a question" : "the morning agent is still running"
2653
2998
  );
2654
2999
  }
2655
- if (!this.resumable(run6)) {
3000
+ if (!this.resumable(run5)) {
2656
3001
  throw new Error(
2657
- run6.sessionId === null ? "this run left no session to continue" : `this run was made by ${run6.cli}, and the agent is ${cli} now`
3002
+ run5.sessionId === null ? "this run left no session to continue" : `this run was made by ${run5.cli}, and the agent is ${cli2} now`
2658
3003
  );
2659
3004
  }
2660
3005
  const text = question.trim();
@@ -2668,19 +3013,21 @@ var AgentRunner = class {
2668
3013
  reply: "",
2669
3014
  error: null
2670
3015
  };
2671
- run6.turns.push(turn);
2672
- await this.append({ event: "asked", run: run6.id, turn: turn.id, question: text, at: turn.startedAt });
2673
- const adapter = ADAPTERS[cli];
3016
+ run5.turns.push(turn);
3017
+ await this.append({ event: "asked", run: run5.id, turn: turn.id, question: text, at: turn.startedAt });
3018
+ const adapter = ADAPTERS[cli2];
2674
3019
  const args = adapter.args({
2675
3020
  prompt: followUpPrompt(text),
2676
- sessionId: run6.sessionId,
3021
+ sessionId: run5.sessionId,
2677
3022
  model: this.config.agent.model,
2678
3023
  effort: this.config.agent.effort,
2679
3024
  // The sources, and not the brief: a chat is given nothing to write.
2680
3025
  tools: this.config.agent.tools,
2681
- denyEdits: true
3026
+ denyEdits: true,
3027
+ drafts: false,
3028
+ codexConfig: []
2682
3029
  });
2683
- await this.spawnInto(run6, turn, args, TURN_TIMEOUT_MS, "gave up after ten minutes");
3030
+ await this.spawnInto(run5, turn, args, TURN_TIMEOUT_MS, "gave up after ten minutes");
2684
3031
  return turn;
2685
3032
  }
2686
3033
  /**
@@ -2688,10 +3035,15 @@ var AgentRunner = class {
2688
3035
  * run when there is none. The store in both cases: Claude Code files a
2689
3036
  * session under the directory it was started in, and looks for it there.
2690
3037
  */
2691
- async spawnInto(run6, turn, args, timeoutMs, timeoutReason) {
2692
- const cli = run6.cli;
2693
- const adapter = ADAPTERS[cli];
3038
+ async spawnInto(run5, turn, args, timeoutMs, timeoutReason) {
3039
+ const cli2 = run5.cli;
3040
+ const adapter = ADAPTERS[cli2];
2694
3041
  await mkdir(this.config.dataDir, { recursive: true });
3042
+ const refusal2 = cli2 === "codex" ? await this.codexGate.refusal() : null;
3043
+ if (refusal2) {
3044
+ await this.finish(run5, turn, { ok: false, error: refusal2 });
3045
+ return;
3046
+ }
2695
3047
  let child;
2696
3048
  try {
2697
3049
  child = this.spawnFn(this.config.agent.binPath, args, {
@@ -2701,12 +3053,12 @@ var AgentRunner = class {
2701
3053
  env: process.env
2702
3054
  });
2703
3055
  } catch (err) {
2704
- await this.finish(run6, turn, { ok: false, error: this.spawnError(err) });
3056
+ await this.finish(run5, turn, { ok: false, error: this.spawnError(err) });
2705
3057
  return;
2706
3058
  }
2707
3059
  const timer = setTimeout(() => void this.stop(timeoutReason), timeoutMs);
2708
3060
  timer.unref();
2709
- const running = { run: run6, turn, child, timer, abortReason: null };
3061
+ const running = { run: run5, turn, child, timer, abortReason: null };
2710
3062
  this.running = running;
2711
3063
  this.hooks.onChange();
2712
3064
  let done;
@@ -2720,15 +3072,15 @@ var AgentRunner = class {
2720
3072
  lines.on("line", (line) => {
2721
3073
  const event = adapter.parse(line);
2722
3074
  if (!event) return;
2723
- if (event.sessionId && !turn) run6.sessionId = event.sessionId;
3075
+ if (event.sessionId && !turn) run5.sessionId = event.sessionId;
2724
3076
  if (event.text) {
2725
3077
  if (turn) {
2726
3078
  turn.reply = turn.reply ? `${turn.reply}
2727
3079
 
2728
3080
  ${event.text}` : event.text;
2729
3081
  } else {
2730
- run6.messages.push(event.text);
2731
- run6.report = event.text;
3082
+ run5.messages.push(event.text);
3083
+ run5.report = event.text;
2732
3084
  }
2733
3085
  this.hooks.onChange();
2734
3086
  }
@@ -2743,9 +3095,9 @@ ${event.text}` : event.text;
2743
3095
  this.running = null;
2744
3096
  const outcome = running.abortReason ? { ok: false, error: running.abortReason } : done ?? {
2745
3097
  ok: false,
2746
- error: `${cli} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
3098
+ error: `${cli2} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
2747
3099
  };
2748
- void this.finish(run6, turn, outcome, running.abortReason !== null);
3100
+ void this.finish(run5, turn, outcome, running.abortReason !== null);
2749
3101
  });
2750
3102
  }
2751
3103
  /**
@@ -2762,45 +3114,45 @@ ${event.text}` : event.text;
2762
3114
  async close() {
2763
3115
  await this.stop("the dashboard shut down");
2764
3116
  }
2765
- resumable(run6) {
2766
- return run6.status !== "running" && run6.sessionId !== null && run6.cli === this.config.agent.cli;
3117
+ resumable(run5) {
3118
+ return run5.status !== "running" && run5.sessionId !== null && run5.cli === this.config.agent.cli;
2767
3119
  }
2768
- async finish(run6, turn, outcome, aborted = false) {
3120
+ async finish(run5, turn, outcome, aborted = false) {
2769
3121
  const at = (/* @__PURE__ */ new Date()).toISOString();
2770
3122
  if (turn) {
2771
3123
  const reply = outcome.reply ?? turn.reply;
2772
3124
  turn.reply = reply;
2773
3125
  turn.endedAt = at;
2774
3126
  if (outcome.ok) {
2775
- await this.append({ event: "replied", run: run6.id, turn: turn.id, reply, at });
3127
+ await this.append({ event: "replied", run: run5.id, turn: turn.id, reply, at });
2776
3128
  turn.status = "done";
2777
3129
  } else if (aborted) {
2778
3130
  turn.error = outcome.error ?? "stopped";
2779
- await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason: turn.error, reply, at });
3131
+ await this.append({ event: "turn-aborted", run: run5.id, turn: turn.id, reason: turn.error, reply, at });
2780
3132
  turn.status = "aborted";
2781
3133
  } else {
2782
3134
  turn.error = outcome.error ?? "failed";
2783
- await this.append({ event: "turn-failed", run: run6.id, turn: turn.id, error: turn.error, reply, at });
3135
+ await this.append({ event: "turn-failed", run: run5.id, turn: turn.id, error: turn.error, reply, at });
2784
3136
  turn.status = "failed";
2785
3137
  }
2786
3138
  this.hooks.onChange();
2787
3139
  return;
2788
3140
  }
2789
- const report = outcome.reply ?? run6.report;
2790
- run6.report = report;
2791
- run6.endedAt = at;
2792
- const messages = run6.messages;
3141
+ const report = outcome.reply ?? run5.report;
3142
+ run5.report = report;
3143
+ run5.endedAt = at;
3144
+ const messages = run5.messages;
2793
3145
  if (outcome.ok) {
2794
- await this.append({ event: "finished", run: run6.id, sessionId: run6.sessionId, report, messages, at });
2795
- run6.status = "done";
3146
+ await this.append({ event: "finished", run: run5.id, sessionId: run5.sessionId, report, messages, at });
3147
+ run5.status = "done";
2796
3148
  } else if (aborted) {
2797
- run6.error = outcome.error ?? "stopped";
2798
- await this.append({ event: "aborted", run: run6.id, sessionId: run6.sessionId, reason: run6.error, report, messages, at });
2799
- run6.status = "aborted";
3149
+ run5.error = outcome.error ?? "stopped";
3150
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason: run5.error, report, messages, at });
3151
+ run5.status = "aborted";
2800
3152
  } else {
2801
- run6.error = outcome.error ?? "failed";
2802
- await this.append({ event: "failed", run: run6.id, sessionId: run6.sessionId, error: run6.error, report, messages, at });
2803
- run6.status = "failed";
3153
+ run5.error = outcome.error ?? "failed";
3154
+ await this.append({ event: "failed", run: run5.id, sessionId: run5.sessionId, error: run5.error, report, messages, at });
3155
+ run5.status = "failed";
2804
3156
  }
2805
3157
  this.hooks.onChange();
2806
3158
  }
@@ -2846,23 +3198,23 @@ function watchDataDir(dir, onChange, { debounceMs = 120, ignore = [] } = {}) {
2846
3198
  }
2847
3199
 
2848
3200
  // src/assets.ts
2849
- import { readdir, stat as stat2 } from "node:fs/promises";
2850
- import { join as join2 } from "node:path";
3201
+ import { readdir as readdir2, stat as stat3 } from "node:fs/promises";
3202
+ import { join as join4 } from "node:path";
2851
3203
  async function computeAssetVersion(dir) {
2852
3204
  const parts = [];
2853
3205
  async function walk(current) {
2854
3206
  let entries;
2855
3207
  try {
2856
- entries = await readdir(current, { withFileTypes: true });
3208
+ entries = await readdir2(current, { withFileTypes: true });
2857
3209
  } catch {
2858
3210
  return;
2859
3211
  }
2860
3212
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
2861
- const path = join2(current, entry.name);
3213
+ const path = join4(current, entry.name);
2862
3214
  if (entry.isDirectory()) {
2863
3215
  await walk(path);
2864
3216
  } else {
2865
- const info = await stat2(path);
3217
+ const info = await stat3(path);
2866
3218
  parts.push(`${entry.name}:${info.size}:${info.mtimeMs}`);
2867
3219
  }
2868
3220
  }
@@ -2881,8 +3233,8 @@ var FAVICONS = {
2881
3233
  work: `<path d="M34 30V20a8 8 0 0 1 8-8h16a8 8 0 0 1 8 8v10" fill="none" stroke="#2a78d6" stroke-width="9"/><path fill="#2a78d6" fill-rule="evenodd" d="M16 30h68a10 10 0 0 1 10 10v38a10 10 0 0 1-10 10H16A10 10 0 0 1 6 78V40a10 10 0 0 1 10-10ZM40 46v14h20V46Z"/>`,
2882
3234
  personal: `<path fill="#1baf7a" fill-rule="evenodd" d="M50 8 96 48H84v44H16V48H4ZM41 92V66h18v26Z"/>`
2883
3235
  };
2884
- function faviconSvg(profile) {
2885
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${FAVICONS[profile]}</svg>`;
3236
+ function faviconSvg(profile2) {
3237
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${FAVICONS[profile2]}</svg>`;
2886
3238
  }
2887
3239
 
2888
3240
  // src/guard.ts
@@ -2914,31 +3266,540 @@ function refusal(req, listenHost) {
2914
3266
  return null;
2915
3267
  }
2916
3268
 
2917
- // src/presence.ts
2918
- import { execFile as execFile4 } from "node:child_process";
2919
- import { platform } from "node:os";
2920
- import { promisify as promisify4 } from "node:util";
2921
- var run4 = promisify4(execFile4);
2922
- var PROBE_TIMEOUT_MS = 4e3;
2923
- var HID_IDLE = /"HIDIdleTime"\s*=\s*(\d+)/;
2924
- var readIdleSeconds = async () => {
2925
- if (platform() !== "darwin") return null;
2926
- try {
2927
- const { stdout } = await run4("ioreg", ["-c", "IOHIDSystem", "-d", "1", "-r"], {
2928
- timeout: PROBE_TIMEOUT_MS,
2929
- maxBuffer: 1024 * 1024
3269
+ // src/onboarding.ts
3270
+ import { spawn as spawn2 } from "node:child_process";
3271
+ import { mkdir as mkdir2, mkdtemp, readFile as readFile8, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
3272
+ import { tmpdir as tmpdir2 } from "node:os";
3273
+ import { join as join5 } from "node:path";
3274
+ import { createInterface as createInterface2 } from "node:readline";
3275
+
3276
+ // src/questions.ts
3277
+ var INSTALL = {
3278
+ codex: { label: "Install Codex", url: "https://developers.openai.com/codex/cli" },
3279
+ claude: { label: "Install Claude Code", url: "https://code.claude.com/docs" }
3280
+ };
3281
+ function option(value, label, detail = null, group = null) {
3282
+ return { value, label, detail, group };
3283
+ }
3284
+ function choice(id, options, selected, extra = {}) {
3285
+ return { id, label: null, multiple: false, options, selected, custom: null, ...extra };
3286
+ }
3287
+ function field(id, label, value, extra = {}) {
3288
+ return { id, label, value, placeholder: null, help: null, ...extra };
3289
+ }
3290
+ function card(parts) {
3291
+ return { why: null, choices: [], fields: [], problem: null, ...parts };
3292
+ }
3293
+ function one(answer, id) {
3294
+ return answer.choices[id]?.[0] ?? null;
3295
+ }
3296
+ function str3(value) {
3297
+ return typeof value === "string" ? value.trim() : "";
3298
+ }
3299
+ function proposalOf(ctx, id) {
3300
+ const proposal = ctx.proposals[id];
3301
+ return typeof proposal === "object" && proposal !== null && !Array.isArray(proposal) ? proposal : null;
3302
+ }
3303
+ function listed(names) {
3304
+ if (names.length <= 1) return names.join("");
3305
+ return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
3306
+ }
3307
+ function ticked(name) {
3308
+ return `\`${name.replaceAll("`", "'")}\``;
3309
+ }
3310
+ function binFor(ctx, cli2) {
3311
+ const found = cli2 ? ctx.clis?.[cli2] : null;
3312
+ return found?.path && !found.onPath ? found.path : null;
3313
+ }
3314
+ var cli = {
3315
+ id: "cli",
3316
+ title: () => "Which agent should write your brief?",
3317
+ topic: "your coding agent",
3318
+ keys: ["DAILY_FOCUS_AGENT"],
3319
+ later: { label: "Decide later", summary: "No agent yet: the boards work, and there is no brief until you choose one." },
3320
+ card(ctx) {
3321
+ if (!ctx.clis) return card({});
3322
+ const report = ctx.clis;
3323
+ const ready = CLI_PREFERENCE.filter((name) => report[name].ready);
3324
+ if (ready.length === 0) {
3325
+ const lines = CLI_PREFERENCE.map((name) => `${report[name].problem ?? `${CLI_LABEL[name]} can't be used`}.`);
3326
+ const fix = CLI_PREFERENCE.map((name) => report[name].fix).find((command3) => command3) ?? null;
3327
+ return card({
3328
+ problem: {
3329
+ message: `${lines.join(" ")} ${fix ? "Run this in a terminal, then check again." : "Install one, log in once in a terminal, then check again."}`,
3330
+ command: fix,
3331
+ blocking: true,
3332
+ links: CLI_PREFERENCE.filter((name) => !report[name].path).map((name) => INSTALL[name])
3333
+ },
3334
+ fields: [
3335
+ field("path", "Installed somewhere setup didn\u2019t look? Its path", "", {
3336
+ placeholder: "/opt/homebrew/bin/codex",
3337
+ help: "The program itself, codex or claude. Setup checks it before using it."
3338
+ })
3339
+ ]
3340
+ });
3341
+ }
3342
+ const set = ctx.current.DAILY_FOCUS_AGENT;
3343
+ const others = CLI_PREFERENCE.filter((name) => !report[name].ready && report[name].path).map((name) => `${report[name].problem}.`);
3344
+ return card({
3345
+ why: [
3346
+ ready.length > 1 ? `Both are ready on this ${ctx.machine}, so it uses Codex unless you say otherwise.` : `${CLI_LABEL[ready[0]]} is the one ready on this ${ctx.machine}.`,
3347
+ ...others
3348
+ ].join(" "),
3349
+ choices: [
3350
+ choice(
3351
+ "cli",
3352
+ ready.map((name) => option(name, CLI_LABEL[name], [report[name].version, report[name].path].filter(Boolean).join(", at "))),
3353
+ [set && ready.includes(set) ? set : ready[0]]
3354
+ )
3355
+ ]
2930
3356
  });
2931
- return parseIdleSeconds(stdout);
2932
- } catch {
2933
- return null;
3357
+ },
3358
+ summary: (answer) => {
3359
+ const chosen = CLI_LABEL[one(answer, "cli")];
3360
+ return chosen ? `${chosen} writes your brief.` : "No agent writes your brief yet.";
3361
+ },
3362
+ // The assistant comes with it, on the same CLI, unless it has been set: it
3363
+ // only reads and drafts, and it is what the Ask button on every row needs.
3364
+ settings: (answer, ctx) => {
3365
+ const chosen = one(answer, "cli");
3366
+ const bin = binFor(ctx, chosen);
3367
+ const assistant = chosen && !ctx.current.DAILY_FOCUS_ASSISTANT;
3368
+ return {
3369
+ DAILY_FOCUS_AGENT: chosen,
3370
+ ...bin ? { DAILY_FOCUS_AGENT_BIN: bin } : {},
3371
+ ...assistant ? { DAILY_FOCUS_ASSISTANT: chosen, ...bin ? { DAILY_FOCUS_ASSISTANT_BIN: bin } : {} } : {}
3372
+ };
2934
3373
  }
2935
3374
  };
2936
- function parseIdleSeconds(ioregOutput) {
2937
- const match = HID_IDLE.exec(ioregOutput);
2938
- if (!match) return null;
2939
- const nanoseconds = Number(match[1]);
2940
- if (!Number.isFinite(nanoseconds) || nanoseconds < 0) return null;
2941
- return nanoseconds / 1e9;
3375
+ var profile = {
3376
+ id: "profile",
3377
+ topic: "what it covers",
3378
+ title: () => "What should your brief cover?",
3379
+ keys: ["DAILY_FOCUS_PROFILE"],
3380
+ later: null,
3381
+ card: (ctx) => card({
3382
+ choices: [
3383
+ choice(
3384
+ "profile",
3385
+ [
3386
+ option("work", "Your work", "Work mail and calendars, pull requests, tickets and meeting notes."),
3387
+ option("personal", "Your personal life", "Personal mail, family calendars, reminders and messages.")
3388
+ ],
3389
+ [ctx.current.DAILY_FOCUS_PROFILE === "personal" ? "personal" : "work"]
3390
+ )
3391
+ ],
3392
+ why: "One dashboard briefs one of them. For both, run a second dashboard with its own store."
3393
+ }),
3394
+ summary: (answer) => one(answer, "profile") === "personal" ? "It briefs your personal life." : "It briefs your work.",
3395
+ settings: (answer) => ({ DAILY_FOCUS_PROFILE: one(answer, "profile") })
3396
+ };
3397
+ var TIMES = ["06:00", "06:30", "07:00", "07:30", "08:00"];
3398
+ var DAYS = { "1-5": "Weekdays", "0-6": "Every day" };
3399
+ var schedule = {
3400
+ id: "schedule",
3401
+ topic: "when it runs",
3402
+ title: () => "When should your brief be ready?",
3403
+ applies: (ctx) => ctx.cli !== null,
3404
+ keys: ["DAILY_FOCUS_AGENT_AT", "DAILY_FOCUS_AGENT_DAYS"],
3405
+ later: null,
3406
+ card(ctx) {
3407
+ const at = ctx.current.DAILY_FOCUS_AGENT_AT || "07:00";
3408
+ const days = ctx.current.DAILY_FOCUS_AGENT_DAYS || "1-5";
3409
+ const times = [.../* @__PURE__ */ new Set([...TIMES, ...at === "off" ? [] : [at]])].sort();
3410
+ return card({
3411
+ why: `It starts then and takes a few minutes. If the ${ctx.machine} is asleep or the dashboard isn't running then, it runs as soon as it is, that day.`,
3412
+ choices: [
3413
+ choice("at", [...times.map((time) => option(time, time)), option("off", "Only when I ask")], [at], {
3414
+ label: "Time",
3415
+ custom: "Another time, such as 05:45"
3416
+ }),
3417
+ choice("days", Object.entries({ ...DAYS, ...DAYS[days] ? {} : { [days]: days } }).map(([value, label]) => option(value, label)), [days], {
3418
+ label: "Days"
3419
+ })
3420
+ ]
3421
+ });
3422
+ },
3423
+ summary(answer) {
3424
+ const at = one(answer, "at");
3425
+ if (!at || at === "off") return "It runs only when you ask.";
3426
+ const days = one(answer, "days") ?? "1-5";
3427
+ return `It runs at ${at} ${days === "1-5" ? "on weekdays" : days === "0-6" ? "every day" : `on days ${days}`}.`;
3428
+ },
3429
+ settings: (answer) => ({ DAILY_FOCUS_AGENT_AT: one(answer, "at"), DAILY_FOCUS_AGENT_DAYS: one(answer, "days") })
3430
+ };
3431
+ function proposedAccounts(proposal) {
3432
+ const raw = Array.isArray(proposal?.githubAccounts) ? proposal.githubAccounts : [];
3433
+ return raw.flatMap((entry) => {
3434
+ const account = typeof entry === "object" && entry !== null ? entry : {};
3435
+ const login = str3(account.login);
3436
+ return login ? [{ login, note: str3(account.note) }] : [];
3437
+ });
3438
+ }
3439
+ function githubAccounts(ctx) {
3440
+ const accounts = /* @__PURE__ */ new Map();
3441
+ for (const login of ctx.identity.logins) accounts.set(login.toLowerCase(), { login, note: "" });
3442
+ for (const { login, note } of proposedAccounts(proposalOf(ctx, "identity"))) {
3443
+ accounts.set(login.toLowerCase(), { login: accounts.get(login.toLowerCase())?.login ?? login, note });
3444
+ }
3445
+ return [...accounts.values()];
3446
+ }
3447
+ var identity2 = {
3448
+ id: "identity",
3449
+ topic: "who you are",
3450
+ title: () => "Is this you?",
3451
+ keys: [],
3452
+ // Clearing three fields and unticking the accounts is a chore; one button, named for what it does, isn't.
3453
+ later: { label: "Leave this out", summary: "Who you are is left out, so the brief can\u2019t tell your to-dos from other people\u2019s." },
3454
+ card(ctx) {
3455
+ const proposal = proposalOf(ctx, "identity");
3456
+ const names = Array.isArray(proposal?.names) ? proposal.names.map(str3).filter(Boolean) : [];
3457
+ const name = names.length ? names.join(", ") : ctx.identity.name ?? "";
3458
+ const email = str3(proposal?.email) || (ctx.identity.email ?? "");
3459
+ const accounts = githubAccounts(ctx);
3460
+ return card({
3461
+ why: str3(proposal?.why) || "The brief tells your to-dos from everyone else\u2019s by these.",
3462
+ fields: [
3463
+ field("name", "Your name, as meeting notes write it", name, {
3464
+ placeholder: "Robin Lark",
3465
+ help: "Add short forms after a comma, such as Robin L."
3466
+ }),
3467
+ field("email", ctx.profile === "personal" ? "Your email" : "Your work email", email, { placeholder: "robin@example.com" })
3468
+ ],
3469
+ choices: [
3470
+ choice(
3471
+ "github",
3472
+ accounts.map((account) => option(account.login, account.login, account.note || null)),
3473
+ accounts.map((account) => account.login),
3474
+ { label: "Your GitHub accounts", multiple: true, custom: "Add a GitHub login" }
3475
+ )
3476
+ ]
3477
+ });
3478
+ },
3479
+ summary(answer) {
3480
+ const name = answer.fields.name?.split(",")[0]?.trim();
3481
+ const logins = answer.choices.github ?? [];
3482
+ const rest = [answer.fields.email?.trim() ?? "", logins.length ? `${listed(logins)} on GitHub` : ""].filter(Boolean);
3483
+ if (!name) return rest.length ? `You're ${listed(rest)}.` : "No details about you.";
3484
+ return `You're ${name}${rest.length ? `, ${rest.join(", ")}` : ""}.`;
3485
+ },
3486
+ settings(answer, ctx) {
3487
+ const values = {};
3488
+ if (answer.fields.email?.trim()) values.DAILY_FOCUS_CALENDAR_ADDRESSES = answer.fields.email.trim();
3489
+ const logins = answer.choices.github ?? [];
3490
+ if (logins.length > 1 || logins.length === 1 && logins[0] !== ctx.identity.logins[0]) {
3491
+ values.DAILY_FOCUS_GITHUB_ACCOUNTS = logins.join(", ");
3492
+ }
3493
+ return values;
3494
+ },
3495
+ sources(answer, ctx) {
3496
+ const names = (answer.fields.name ?? "").split(",").map((n) => n.trim()).filter(Boolean);
3497
+ const email = answer.fields.email?.trim();
3498
+ const notes = new Map(githubAccounts(ctx).map((account) => [account.login, account.note]));
3499
+ const logins = answer.choices.github ?? [];
3500
+ const lines = [
3501
+ names.length ? `- Name, as meeting notes and Doc assignments write it: ${ticked(names[0])}${names.length > 1 ? `, also ${listed(names.slice(1).map(ticked))}` : ""}` : null,
3502
+ email ? `- ${ctx.profile === "personal" ? "Personal" : "Work"} email: ${ticked(email)}` : null,
3503
+ logins.length ? [
3504
+ `- GitHub ${logins.length > 1 ? "logins" : "login"}, which review requests and authorship are judged against:`,
3505
+ ...logins.map((login) => ` - ${ticked(login)}${notes.get(login) ? ` \u2014 ${notes.get(login)}` : ""}`)
3506
+ ].join("\n") : null
3507
+ ].filter((line) => line !== null);
3508
+ return lines.length ? [{ heading: "Identity", body: lines.join("\n") }] : [];
3509
+ },
3510
+ agent: {
3511
+ brief: (ctx) => "Who the user is, as their morning brief needs to know them: their name as meeting notes and document assignments write it, with any short forms colleagues use, their " + (ctx.profile === "personal" ? "personal" : "work") + " email address, and every GitHub account they work as: gh may hold several, such as one for an employer\u2019s organisation. Give each account a few words on what it is for when you can tell. Start from what the dashboard found, below. Check the address they send mail from, how others address them, and `gh auth status`.",
3512
+ facts: (ctx) => [
3513
+ ctx.identity.name ? `- gh says their name is ${ctx.identity.name}` : null,
3514
+ ctx.identity.logins.length ? `- gh is logged in to GitHub as ${listed(ctx.identity.logins)}; the first is active` : null,
3515
+ ctx.identity.email ? `- git's global email is ${ctx.identity.email}` : null
3516
+ ].filter(Boolean).join("\n") || null,
3517
+ schema: {
3518
+ type: "object",
3519
+ properties: {
3520
+ why: { type: "string", description: "One or two sentences on where these came from." },
3521
+ names: { type: "array", items: { type: "string" }, description: "Full name first, then short forms." },
3522
+ email: { type: "string" },
3523
+ githubAccounts: {
3524
+ type: "array",
3525
+ items: {
3526
+ type: "object",
3527
+ properties: {
3528
+ login: { type: "string" },
3529
+ note: { type: "string", description: "What the account is for, in a few words, or empty." }
3530
+ },
3531
+ required: ["login", "note"],
3532
+ additionalProperties: false
3533
+ }
3534
+ }
3535
+ },
3536
+ required: ["why", "names", "email", "githubAccounts"],
3537
+ additionalProperties: false
3538
+ }
3539
+ }
3540
+ };
3541
+ function healthText(board, via) {
3542
+ if (board.reason) return board.reason;
3543
+ if (board.fetchedAt) return `Connected through ${via}.`;
3544
+ if (board.enabled) return `Checking ${via}\u2026`;
3545
+ return null;
3546
+ }
3547
+ var boards = {
3548
+ id: "boards",
3549
+ topic: "the boards",
3550
+ title: () => "What else should the dashboard show?",
3551
+ keys: ["DAILY_FOCUS_GITHUB", "DAILY_FOCUS_JIRA"],
3552
+ later: null,
3553
+ card(ctx) {
3554
+ const { github, jira } = ctx.boards;
3555
+ const selected = [
3556
+ github.enabled && !github.reason ? "github" : null,
3557
+ jira.enabled && !jira.reason && ctx.profile === "work" ? "jira" : null
3558
+ ].filter((value) => value !== null);
3559
+ return card({
3560
+ why: "Both are read live, beside the brief, so a merge or a status change shows up within minutes.",
3561
+ choices: [
3562
+ choice(
3563
+ "boards",
3564
+ [
3565
+ option("github", "Your pull requests", healthText(github, "gh") ?? "Through the GitHub CLI, gh."),
3566
+ option("jira", "Your Jira tickets", healthText(jira, "acli") ?? "Through the Atlassian CLI, acli.")
3567
+ ],
3568
+ selected,
3569
+ { multiple: true }
3570
+ )
3571
+ ]
3572
+ });
3573
+ },
3574
+ summary(answer) {
3575
+ const on = answer.choices.boards ?? [];
3576
+ const names = [on.includes("github") ? "your pull requests" : null, on.includes("jira") ? "your Jira tickets" : null].filter(
3577
+ (name) => name !== null
3578
+ );
3579
+ return names.length ? `It shows ${listed(names)}.` : "No boards beside the brief.";
3580
+ },
3581
+ settings(answer) {
3582
+ const on = answer.choices.boards ?? [];
3583
+ return { DAILY_FOCUS_GITHUB: on.includes("github") ? "on" : "off", DAILY_FOCUS_JIRA: on.includes("jira") ? "on" : "off" };
3584
+ }
3585
+ };
3586
+ var HOLIDAYS = "Holidays";
3587
+ function calendarEntries(ctx) {
3588
+ const proposal = proposalOf(ctx, "calendars");
3589
+ const verdicts = Array.isArray(proposal?.calendars) ? proposal.calendars.flatMap((raw) => {
3590
+ const entry = typeof raw === "object" && raw !== null ? raw : {};
3591
+ const title = str3(entry.title);
3592
+ return title ? [{ title, account: str3(entry.account), include: entry.include === true, holiday: entry.holiday === true, briefName: str3(entry.briefName), note: str3(entry.note) }] : [];
3593
+ }) : [];
3594
+ const configured = new Set((ctx.current.DAILY_FOCUS_CALENDARS ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
3595
+ const used = /* @__PURE__ */ new Set();
3596
+ const entries = [];
3597
+ for (const calendar of Array.isArray(ctx.calendars) ? ctx.calendars : []) {
3598
+ const key = calendar.title.toLowerCase();
3599
+ const verdict = verdicts.find((v) => !used.has(v) && v.title.toLowerCase() === key && v.account.toLowerCase() === calendar.source.toLowerCase()) ?? verdicts.find((v) => !used.has(v) && v.title.toLowerCase() === key);
3600
+ if (verdict) used.add(verdict);
3601
+ entries.push({
3602
+ value: `live:${calendar.source}:${calendar.title}`,
3603
+ title: calendar.title,
3604
+ group: verdict?.holiday ? HOLIDAYS : calendar.source || "Calendar.app",
3605
+ live: true,
3606
+ holiday: verdict?.holiday ?? false,
3607
+ briefName: verdict?.briefName ?? "",
3608
+ note: verdict?.note ?? "",
3609
+ include: verdict ? verdict.include : configured.has(key)
3610
+ });
3611
+ }
3612
+ for (const verdict of verdicts) {
3613
+ if (used.has(verdict)) continue;
3614
+ entries.push({
3615
+ value: `brief:${verdict.account}:${verdict.title}`,
3616
+ title: verdict.title,
3617
+ group: verdict.holiday ? HOLIDAYS : verdict.account || "Your calendar account",
3618
+ live: false,
3619
+ holiday: verdict.holiday,
3620
+ briefName: verdict.briefName,
3621
+ note: verdict.note,
3622
+ include: verdict.include
3623
+ });
3624
+ }
3625
+ return entries.sort((a, b) => Number(a.group === HOLIDAYS) - Number(b.group === HOLIDAYS));
3626
+ }
3627
+ function chosenCalendars(answer, ctx) {
3628
+ const all = new Map(calendarEntries(ctx).map((entry) => [entry.value, entry]));
3629
+ const values = answer.choices.calendars ?? [];
3630
+ return {
3631
+ entries: values.flatMap((value) => all.get(value) ?? []),
3632
+ typed: values.filter((value) => !all.has(value)).map((value) => value.trim()).filter(Boolean)
3633
+ };
3634
+ }
3635
+ var calendars = {
3636
+ id: "calendars",
3637
+ topic: "your calendars",
3638
+ title: (ctx) => ctx.profile === "personal" ? "Which calendars belong in your brief?" : "Which calendars belong in your work brief?",
3639
+ keys: ["DAILY_FOCUS_CALENDARS"],
3640
+ later: null,
3641
+ card(ctx) {
3642
+ const entries = calendarEntries(ctx);
3643
+ const proposal = proposalOf(ctx, "calendars");
3644
+ const problem = ctx.calendar.supported && !ctx.calendar.built ? {
3645
+ message: "The live agenda reads Calendar.app through a small helper, built once with this command. Until then it uses the brief\u2019s events, so this list is from your calendar account.",
3646
+ command: ctx.calendar.buildCommand,
3647
+ blocking: false
3648
+ } : typeof ctx.calendars === "string" ? { message: `Calendar.app couldn't be read: ${ctx.calendars}`, command: null, blocking: false } : null;
3649
+ return card({
3650
+ why: str3(proposal?.why) || null,
3651
+ problem,
3652
+ choices: [
3653
+ choice(
3654
+ "calendars",
3655
+ entries.map((entry) => option(entry.value, entry.title, entry.note || null, entry.group)),
3656
+ entries.filter((entry) => entry.include).map((entry) => entry.value),
3657
+ { multiple: true, custom: "Add a calendar by name" }
3658
+ )
3659
+ ]
3660
+ });
3661
+ },
3662
+ summary(answer, ctx) {
3663
+ const { entries, typed } = chosenCalendars(answer, ctx);
3664
+ const names = [...entries.map((entry) => entry.title), ...typed];
3665
+ if (names.length === 0) return "No calendars.";
3666
+ if (names.length <= 3) return `It reads ${listed(names)}.`;
3667
+ return `It reads ${names.slice(0, 2).join(", ")} and ${names.length - 2} more calendars.`;
3668
+ },
3669
+ settings(answer, ctx) {
3670
+ const { entries, typed } = chosenCalendars(answer, ctx);
3671
+ const live = [.../* @__PURE__ */ new Set([...entries.filter((entry) => entry.live).map((entry) => entry.title), ...typed])];
3672
+ return { DAILY_FOCUS_CALENDARS: live.filter((name) => !name.includes(",")).join(", ") || null };
3673
+ },
3674
+ sources(answer, ctx) {
3675
+ const { entries, typed } = chosenCalendars(answer, ctx);
3676
+ const line = (entry) => `- ${ticked(entry.briefName || entry.title)}${entry.group !== HOLIDAYS && entry.group ? ` (${entry.group})` : ""}`;
3677
+ const own = [...entries.filter((entry) => !entry.holiday).map(line), ...typed.map((name) => `- ${ticked(name)}`)];
3678
+ const holidays = entries.filter((entry) => entry.holiday).map(line);
3679
+ return [
3680
+ ...own.length ? [{ heading: "Calendars", body: `Query these, and treat their events as blocking time:
3681
+
3682
+ ${own.join("\n")}` }] : [],
3683
+ ...holidays.length ? [{ heading: "Holiday calendars", body: `Call out a holiday today or within the next 14 days:
3684
+
3685
+ ${holidays.join("\n")}` }] : []
3686
+ ];
3687
+ },
3688
+ agent: {
3689
+ brief: (ctx) => `Which calendars the user's ${ctx.profile} brief should read. ` + (Array.isArray(ctx.calendars) ? "Below is every calendar Calendar.app on this Mac has, with the account it is on and how many events it has today. " : "List the calendars their calendar connector reaches. ") + "Include " + (ctx.profile === "personal" ? "their own personal and family calendars, " : "their own work calendar, team calendars whose events block their time, ") + "and the holiday calendars for where they and the people they work with are. Leave out " + (ctx.profile === "personal" ? "work calendars, " : "personal and family calendars, ") + "birthdays, and shared calendars that are mostly other people's events. If you can reach their calendar through a connector, look at the coming week for evidence, and give `briefName`, the name the connector uses where it differs from Calendar.app\u2019s. " + (Array.isArray(ctx.calendars) ? "List every calendar from the list below, and any your connector has that Calendar.app does not. " : "") + '`note` is a few words shown under the name, such as "Your primary calendar" or "12 of your meetings this week", or empty.',
3690
+ facts(ctx) {
3691
+ if (typeof ctx.calendars === "string") return `Calendar.app could not be read (${ctx.calendars}), so work from your calendar connector alone.`;
3692
+ if (!Array.isArray(ctx.calendars)) return "There is no list from Calendar.app here, so work from your calendar connector alone.";
3693
+ if (ctx.calendars.length === 0) return "Calendar.app has no calendars.";
3694
+ return [
3695
+ "Calendar.app has these, as title (account): events today.",
3696
+ "",
3697
+ ...ctx.calendars.map((calendar) => `- ${calendar.title} (${calendar.source || "no account"}): ${calendar.eventsToday}`)
3698
+ ].join("\n");
3699
+ },
3700
+ schema: {
3701
+ type: "object",
3702
+ properties: {
3703
+ why: { type: "string", description: "One or two sentences on which you ticked and why." },
3704
+ calendars: {
3705
+ type: "array",
3706
+ items: {
3707
+ type: "object",
3708
+ properties: {
3709
+ title: { type: "string", description: "Exactly as Calendar.app spells it, or the connector's name for one Calendar.app lacks." },
3710
+ account: { type: "string" },
3711
+ include: { type: "boolean" },
3712
+ holiday: { type: "boolean" },
3713
+ briefName: { type: "string" },
3714
+ note: { type: "string" }
3715
+ },
3716
+ required: ["title", "account", "include", "holiday", "briefName", "note"],
3717
+ additionalProperties: false
3718
+ }
3719
+ }
3720
+ },
3721
+ required: ["why", "calendars"],
3722
+ additionalProperties: false
3723
+ }
3724
+ }
3725
+ };
3726
+ function suggestedObjectives(proposal) {
3727
+ const raw = Array.isArray(proposal?.suggestions) ? proposal.suggestions : [];
3728
+ return raw.flatMap((entry) => {
3729
+ const suggestion = typeof entry === "object" && entry !== null ? entry : {};
3730
+ const text = str3(suggestion.objective);
3731
+ return text ? [{ text, detail: str3(suggestion.evidence) || null }] : [];
3732
+ });
3733
+ }
3734
+ var objective = {
3735
+ id: "objective",
3736
+ topic: "your objective",
3737
+ title: () => "What are you trying to get done right now?",
3738
+ keys: [],
3739
+ // Left empty, Continue says so; see `empty` on its card.
3740
+ later: null,
3741
+ card(ctx) {
3742
+ const proposal = proposalOf(ctx, "objective");
3743
+ return card({
3744
+ reply: "Tell it what you\u2019re working on, however roughly",
3745
+ empty: "Continue without one",
3746
+ why: str3(proposal?.why) || "The brief ranks the day against it. A next move works better than a project name. Tell it what you\u2019re working on, however roughly, and it will write it up.",
3747
+ fields: [
3748
+ field("objective", "Your objective", str3(proposal?.objective), {
3749
+ placeholder: "Get the staging deploy working again",
3750
+ suggestions: suggestedObjectives(proposal)
3751
+ }),
3752
+ field("blocker", "What\u2019s in the way, if anything", str3(proposal?.blocker), { placeholder: "Nobody can reproduce the failure" })
3753
+ ]
3754
+ });
3755
+ },
3756
+ summary: (answer) => {
3757
+ const text = answer.fields.objective?.trim();
3758
+ const blocker = answer.fields.blocker?.trim();
3759
+ if (!text) return "No objective for now.";
3760
+ return `Your objective: ${text}${blocker ? `. In the way: ${blocker}` : ""}`;
3761
+ },
3762
+ objective: (answer) => answer.fields.objective?.trim() ? { objective: answer.fields.objective.trim(), blocker: answer.fields.blocker?.trim() || null } : null,
3763
+ agent: {
3764
+ brief: (ctx) => `The user's current objective: the one thing they are trying to get done right now, which their ${ctx.profile} brief ranks the day against. Look at what they have been doing lately: their own recent and open pull requests, tickets assigned to them and in progress, and anything on the calendar that keeps coming back. Suggest up to three objectives, each only where there is a strong signal, with the evidence in a few words, such as "your 4 open pull requests on checkout". If nothing stands out, suggest none and say so. Leave \`objective\` and \`blocker\` empty until the user tells you what they are working on. When they do, write it up as one objective: a next move rather than a project name, in their own terms, under 12 words. Put what is in the way, if they mention anything, in \`blocker\`, as briefly.`,
3765
+ schema: {
3766
+ type: "object",
3767
+ properties: {
3768
+ why: { type: "string", description: "One or two sentences: what you looked at, or how you wrote up what they said." },
3769
+ objective: { type: "string" },
3770
+ blocker: { type: "string" },
3771
+ suggestions: {
3772
+ type: "array",
3773
+ items: {
3774
+ type: "object",
3775
+ properties: {
3776
+ objective: { type: "string" },
3777
+ evidence: { type: "string", description: "What it was drawn from, in a few words." }
3778
+ },
3779
+ required: ["objective", "evidence"],
3780
+ additionalProperties: false
3781
+ }
3782
+ }
3783
+ },
3784
+ required: ["why", "objective", "blocker", "suggestions"],
3785
+ additionalProperties: false
3786
+ }
3787
+ }
3788
+ };
3789
+ var QUESTIONS = [cli, profile, schedule, identity2, boards, calendars, objective];
3790
+ function questionById(id) {
3791
+ return QUESTIONS.find((question) => question.id === id);
3792
+ }
3793
+ function composeSources(profile2, sections, today) {
3794
+ const intro = [
3795
+ "# Sources",
3796
+ "",
3797
+ `Who you are and where to look, for your ${profile2 === "personal" ? "personal" : "work"} morning brief. Written by setup on ${today}; change it freely. The prompt next to this file (\`prompt.md\`) says *what* to gather and how to judge it; this file says *who* and *where*. A section that is missing is fine: the agent falls back to your primary calendar and connected accounts, and says what it missed.`
3798
+ ];
3799
+ return [...intro, ...sections.flatMap((section) => ["", `## ${section.heading}`, "", section.body])].join("\n") + "\n";
3800
+ }
3801
+ function machineName(platform = process.platform) {
3802
+ return platform === "darwin" ? "Mac" : platform === "win32" ? "PC" : "computer";
2942
3803
  }
2943
3804
 
2944
3805
  // src/settings.ts
@@ -2949,8 +3810,7 @@ var SETTING_GROUPS = [
2949
3810
  { id: "github", title: "Pull requests" },
2950
3811
  { id: "jira", title: "Jira tickets" },
2951
3812
  { id: "calendar", title: "Calendar" },
2952
- { id: "day", title: "Working day" },
2953
- { id: "sessions", title: "Focus sessions" }
3813
+ { id: "day", title: "Working day" }
2954
3814
  ];
2955
3815
  var OFF_OR_CLI = [
2956
3816
  { value: "off", label: "Off" },
@@ -2964,7 +3824,7 @@ var SETTINGS = [
2964
3824
  key: "DAILY_FOCUS_PROFILE",
2965
3825
  group: "instance",
2966
3826
  label: "What this dashboard briefs",
2967
- help: "Work or home. Picks the morning prompt and the defaults below: the personal profile switches off Jira, free time and away detection.",
3827
+ help: "Work or home. Picks the morning prompt and the defaults below: the personal profile switches off Jira and free time.",
2968
3828
  kind: "choice",
2969
3829
  choices: PROFILES.map((value) => ({ value, label: value === "work" ? "Work" : "Personal life" })),
2970
3830
  fallback: always("Work")
@@ -3015,7 +3875,7 @@ var SETTINGS = [
3015
3875
  key: "DAILY_FOCUS_AGENT_TOOLS",
3016
3876
  group: "agent",
3017
3877
  label: "Tools Claude Code may use",
3018
- help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. Ignored by Codex.",
3878
+ help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. The default allows every Gmail tool, sending included; name the tools you want instead to narrow it. Ignored by Codex.",
3019
3879
  kind: "list",
3020
3880
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3021
3881
  advanced: true
@@ -3042,7 +3902,7 @@ var SETTINGS = [
3042
3902
  key: "DAILY_FOCUS_ASSISTANT",
3043
3903
  group: "assistant",
3044
3904
  label: "Answer questions with",
3045
- help: "Puts an assistant in every item's panel. It reads and drafts, and never sends, posts or edits.",
3905
+ help: "Puts an assistant in every item's panel, told to read and draft and never to send, post or edit. Under Codex, every connector write but a Gmail draft is refused.",
3046
3906
  kind: "choice",
3047
3907
  choices: OFF_OR_CLI,
3048
3908
  fallback: always("Off")
@@ -3068,11 +3928,36 @@ var SETTINGS = [
3068
3928
  key: "DAILY_FOCUS_ASSISTANT_TOOLS",
3069
3929
  group: "assistant",
3070
3930
  label: "Tools Claude Code may use",
3071
- help: "In Claude Code's permission syntax, comma-separated. Editing tools are denied whatever this says. Ignored by Codex.",
3931
+ help: "In Claude Code's permission syntax, comma-separated. Editing tools are denied whatever this says. The default allows every Gmail tool, sending included; name the tools you want instead to narrow it. Ignored by Codex.",
3072
3932
  kind: "list",
3073
3933
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3074
3934
  advanced: true
3075
3935
  },
3936
+ {
3937
+ key: "DAILY_FOCUS_ASSISTANT_COMPUTER_USE",
3938
+ group: "assistant",
3939
+ label: "Computer use",
3940
+ help: "Codex only. On lets the assistant use Codex's computer-use and browser plugins, which act in your apps and browser with your sign-ins. Off switches them off for its runs. Claude Code uses only the tools in its list.",
3941
+ kind: "flag",
3942
+ fallback: always("Off")
3943
+ },
3944
+ {
3945
+ key: "DAILY_FOCUS_ASSISTANT_AUTO",
3946
+ group: "assistant",
3947
+ label: "Answer ahead on pull requests",
3948
+ help: "When a pull request starts waiting on you because of a review or a failing check, asks the assistant about it before you do, so the answer is on the row when you look. Only reads; it never drafts. It spends the CLI\u2019s usage without you pressing anything.",
3949
+ kind: "flag",
3950
+ fallback: always("Off")
3951
+ },
3952
+ {
3953
+ key: "DAILY_FOCUS_ASSISTANT_AUTO_LIMIT",
3954
+ group: "assistant",
3955
+ label: "Answer ahead at most",
3956
+ help: "Times a day the assistant may start on its own, counted on the dashboard\u2019s calendar.",
3957
+ kind: "number",
3958
+ fallback: always("10 a day"),
3959
+ advanced: true
3960
+ },
3076
3961
  {
3077
3962
  key: "DAILY_FOCUS_ASSISTANT_BIN",
3078
3963
  group: "assistant",
@@ -3086,7 +3971,7 @@ var SETTINGS = [
3086
3971
  key: "DAILY_FOCUS_GITHUB",
3087
3972
  group: "github",
3088
3973
  label: "Pull request board",
3089
- help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login.",
3974
+ help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login. The Jira ticket board uses it too, to catch merges Jira hasn't heard about yet.",
3090
3975
  kind: "flag",
3091
3976
  fallback: always("On")
3092
3977
  },
@@ -3139,7 +4024,7 @@ var SETTINGS = [
3139
4024
  label: "Jira ticket board",
3140
4025
  help: "Flags tickets whose status disagrees with their pull requests, through the Atlassian CLI. Sign in with acli jira auth login.",
3141
4026
  kind: "flag",
3142
- fallback: (profile) => profile === "personal" ? "Off" : "On"
4027
+ fallback: (profile2) => profile2 === "personal" ? "Off" : "On"
3143
4028
  },
3144
4029
  {
3145
4030
  key: "DAILY_FOCUS_JIRA_PROJECTS",
@@ -3240,7 +4125,7 @@ var SETTINGS = [
3240
4125
  label: "Show free time",
3241
4126
  help: "Find focus windows between meetings in your working hours. Off shows events only.",
3242
4127
  kind: "flag",
3243
- fallback: (profile) => profile === "personal" ? "Off" : "On"
4128
+ fallback: (profile2) => profile2 === "personal" ? "Off" : "On"
3244
4129
  },
3245
4130
  {
3246
4131
  key: "DAILY_FOCUS_WORK_START",
@@ -3265,22 +4150,6 @@ var SETTINGS = [
3265
4150
  help: "Minutes a gap has to last to count as time for focused work.",
3266
4151
  kind: "number",
3267
4152
  fallback: always("45 minutes")
3268
- },
3269
- {
3270
- key: "DAILY_FOCUS_SESSION_MINUTES",
3271
- group: "sessions",
3272
- label: "Session length",
3273
- help: "Minutes a focus session is set for when you start one.",
3274
- kind: "number",
3275
- fallback: always("25 minutes")
3276
- },
3277
- {
3278
- key: "DAILY_FOCUS_AWAY_AFTER",
3279
- group: "sessions",
3280
- label: "End a session when away for",
3281
- help: "Minutes without touching the Mac before a session is closed where you left. 0 turns it off.",
3282
- kind: "number",
3283
- fallback: (profile) => profile === "personal" ? "0, off" : "10 minutes"
3284
4153
  }
3285
4154
  ];
3286
4155
  var BY_KEY = new Map(SETTINGS.map((def) => [def.key, def]));
@@ -3293,7 +4162,7 @@ function profileOf(sources, settings = sources.settings) {
3293
4162
  return PROFILES.includes(raw) ? raw : "work";
3294
4163
  }
3295
4164
  function describeSettings(sources) {
3296
- const profile = profileOf(sources);
4165
+ const profile2 = profileOf(sources);
3297
4166
  const settings = SETTINGS.map((def) => {
3298
4167
  const fromEnv = present(sources.process, def.key);
3299
4168
  const fromSettings = present(sources.settings, def.key);
@@ -3309,7 +4178,7 @@ function describeSettings(sources) {
3309
4178
  advanced: def.advanced === true,
3310
4179
  value,
3311
4180
  source,
3312
- fallback: def.fallback(profile)
4181
+ fallback: def.fallback(profile2)
3313
4182
  };
3314
4183
  });
3315
4184
  return { groups: SETTING_GROUPS, settings, error: sources.settingsError };
@@ -3342,134 +4211,758 @@ async function saveSettings(path, settings) {
3342
4211
  await writeJsonAtomic(path, sorted);
3343
4212
  }
3344
4213
 
3345
- // src/setup.ts
3346
- import { execFile as execFile5 } from "node:child_process";
3347
- import { access, constants, readdir as readdir2, readFile as readFile7, stat as stat3 } from "node:fs/promises";
3348
- import { delimiter, isAbsolute, join as join3 } from "node:path";
3349
- import { promisify as promisify5 } from "node:util";
3350
- var run5 = promisify5(execFile5);
3351
- var PLACEHOLDERS = [
3352
- "Your Name",
3353
- "you@example.com",
3354
- "you@personal.example",
3355
- "your-github-login",
3356
- "Some Shared Calendar",
3357
- "Some Noisy Calendar",
3358
- "<where your colleagues are>",
3359
- "<where you are>",
3360
- "<where you live>",
3361
- "<document id>"
4214
+ // src/onboarding.ts
4215
+ var TURN_TIMEOUT_MS2 = 4 * 6e4;
4216
+ var KILL_AFTER_MS = 5e3;
4217
+ var STEPS_KEPT = 40;
4218
+ var SESSION_MODEL = {
4219
+ claude: { model: "opus", effort: "low" },
4220
+ codex: { model: null, effort: "low" }
4221
+ };
4222
+ var ONBOARDING_TOOLS = [
4223
+ "Bash(gh *)",
4224
+ "Bash(acli *)",
4225
+ "Bash(git config *)",
4226
+ "mcp__claude_ai_Gmail__search_threads",
4227
+ "mcp__claude_ai_Gmail__get_thread",
4228
+ "mcp__claude_ai_Gmail__get_message",
4229
+ "mcp__claude_ai_Gmail__list_labels",
4230
+ "mcp__claude_ai_Google_Calendar__list_calendars",
4231
+ "mcp__claude_ai_Google_Calendar__list_events",
4232
+ "mcp__claude_ai_Google_Calendar__search_events",
4233
+ "mcp__claude_ai_Google_Calendar__get_event",
4234
+ "mcp__claude_ai_Google_Drive__search_files",
4235
+ "mcp__claude_ai_Google_Drive__list_recent_files",
4236
+ "mcp__claude_ai_Google_Drive__get_file_metadata",
4237
+ "mcp__claude_ai_Google_Drive__read_file_content"
3362
4238
  ];
3363
- function placeholdersIn(text) {
3364
- return PLACEHOLDERS.filter((placeholder) => text.includes(placeholder)).length;
4239
+ var RESTARTED = "the dashboard restarted while this was running";
4240
+ function blankFile() {
4241
+ return { startedAt: (/* @__PURE__ */ new Date()).toISOString(), finishedAt: null, briefRequested: false, answers: {}, proposals: {} };
3365
4242
  }
3366
- async function findExecutable(name, path = process.env.PATH ?? "") {
3367
- const candidates = isAbsolute(name) ? [name] : path.split(delimiter).filter(Boolean).map((dir) => join3(dir, name));
3368
- for (const candidate of candidates) {
3369
- try {
3370
- await access(candidate, constants.X_OK);
3371
- if ((await stat3(candidate)).isFile()) return candidate;
3372
- } catch {
4243
+ function parseFile(text) {
4244
+ const file = blankFile();
4245
+ let raw;
4246
+ try {
4247
+ raw = JSON.parse(text);
4248
+ } catch {
4249
+ return file;
4250
+ }
4251
+ if (typeof raw !== "object" || raw === null) return file;
4252
+ const obj = raw;
4253
+ if (typeof obj.startedAt === "string") file.startedAt = obj.startedAt;
4254
+ if (typeof obj.finishedAt === "string") file.finishedAt = obj.finishedAt;
4255
+ file.briefRequested = obj.briefRequested === true;
4256
+ if (typeof obj.answers === "object" && obj.answers !== null) {
4257
+ for (const [id, answer] of Object.entries(obj.answers)) {
4258
+ const clean = cleanAnswer(answer);
4259
+ if (clean) file.answers[id] = clean;
3373
4260
  }
3374
4261
  }
3375
- return null;
3376
- }
3377
- async function findClis(config) {
3378
- const found = {};
3379
- for (const cli of CLI_NAMES) {
3380
- const candidates = [
3381
- config.agent.cli === cli ? config.agent.binPath : null,
3382
- config.assistant.agent === cli ? config.assistant.binPath : null,
3383
- cli
3384
- ].filter((candidate) => candidate !== null);
3385
- found[cli] = null;
3386
- for (const candidate of candidates) {
3387
- found[cli] = await findExecutable(candidate);
3388
- if (found[cli]) break;
4262
+ if (typeof obj.proposals === "object" && obj.proposals !== null) {
4263
+ for (const [id, value] of Object.entries(obj.proposals)) {
4264
+ const record = value;
4265
+ if (!record || !CLI_NAMES.includes(record.cli) || !PROFILES.includes(record.profile)) continue;
4266
+ file.proposals[id] = {
4267
+ cli: record.cli,
4268
+ profile: record.profile,
4269
+ sessionId: typeof record.sessionId === "string" ? record.sessionId : null,
4270
+ status: record.status === "ready" || record.status === "failed" ? record.status : "working",
4271
+ result: record.result ?? null,
4272
+ error: typeof record.error === "string" ? record.error : null,
4273
+ version: typeof record.version === "number" ? record.version : 0,
4274
+ conversation: Array.isArray(record.conversation) ? record.conversation.filter(
4275
+ (line) => typeof line === "object" && line !== null && (line.from === "you" || line.from === "it") && typeof line.text === "string"
4276
+ ) : []
4277
+ };
3389
4278
  }
3390
4279
  }
3391
- return found;
3392
- }
3393
- async function hasArchive(config) {
3394
- try {
3395
- return (await readdir2(config.archiveDir)).some((name) => name.endsWith(".json"));
3396
- } catch {
3397
- return false;
4280
+ return file;
4281
+ }
4282
+ function cleanAnswer(value) {
4283
+ if (typeof value !== "object" || value === null) return null;
4284
+ const obj = value;
4285
+ if (obj.later === true) return { later: true };
4286
+ const choices = {};
4287
+ const fields = {};
4288
+ if (typeof obj.choices === "object" && obj.choices !== null) {
4289
+ for (const [id, values] of Object.entries(obj.choices)) {
4290
+ if (Array.isArray(values)) choices[id] = [...new Set(values.filter((v) => typeof v === "string" && v.trim() !== ""))];
4291
+ }
3398
4292
  }
3399
- }
3400
- async function exists(path) {
3401
- try {
3402
- await stat3(path);
3403
- return true;
3404
- } catch {
3405
- return false;
4293
+ if (typeof obj.fields === "object" && obj.fields !== null) {
4294
+ for (const [id, text] of Object.entries(obj.fields)) {
4295
+ if (typeof text === "string") fields[id] = text;
4296
+ }
3406
4297
  }
4298
+ return { choices, fields };
3407
4299
  }
3408
- async function firstBriefPending(config) {
3409
- return !await exists(config.itemsFile) && !await hasArchive(config);
4300
+ function given(answer) {
4301
+ return answer && answer.later !== true ? answer : null;
3410
4302
  }
3411
- async function setupState(config, opts) {
3412
- let sources = null;
3413
- try {
3414
- sources = await readFile7(config.sourcesFile, "utf8");
3415
- } catch {
3416
- sources = null;
3417
- }
3418
- const supported = process.platform === "darwin";
4303
+ function answerFromCard(card2) {
3419
4304
  return {
3420
- // The file, not whether it parsed: a brief that is there but broken is a
3421
- // problem for its banner to report, not a store to set up from scratch. The
3422
- // agent's clock asks the same question.
3423
- needed: await firstBriefPending(config),
3424
- profile: config.profile,
3425
- profileChosen: opts.profileChosen,
3426
- sources: { exists: sources !== null, placeholders: sources === null ? 0 : placeholdersIn(sources) },
3427
- clis: await findClis(config),
3428
- calendar: {
3429
- supported,
3430
- built: supported && await exists(config.calendar.appPath),
3431
- chosen: config.calendar.names.length,
3432
- buildCommand: command("build-calendar")
3433
- }
4305
+ choices: Object.fromEntries(card2.choices.map((choice2) => [choice2.id, choice2.selected])),
4306
+ fields: Object.fromEntries(card2.fields.map((field2) => [field2.id, field2.value]))
3434
4307
  };
3435
4308
  }
3436
- var identity = null;
3437
- function detectIdentity(config) {
3438
- identity ??= (async () => {
3439
- const [user, email] = await Promise.all([
3440
- run5(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
3441
- ({ stdout }) => stdout.split("\n"),
3442
- () => []
3443
- ),
3444
- run5("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
3445
- ({ stdout }) => stdout.trim() || null,
3446
- () => null
3447
- )
3448
- ]);
4309
+ var Onboarding = class {
4310
+ file = null;
4311
+ loaded = false;
4312
+ running = /* @__PURE__ */ new Map();
4313
+ steps = /* @__PURE__ */ new Map();
4314
+ /** Calendar.app's calendars being listed, which only starting the sessions waits for. */
4315
+ calendars = null;
4316
+ /** What listing them found, once it has: the calendars, or why they couldn't be listed. */
4317
+ listed = null;
4318
+ finishError = null;
4319
+ config;
4320
+ deps;
4321
+ spawnFn;
4322
+ gates = /* @__PURE__ */ new Map();
4323
+ codexOff = null;
4324
+ codexModel = null;
4325
+ /** What looking for the CLIs found, once the screen has asked. Not kept: what is installed changes. */
4326
+ detected = null;
4327
+ detecting = null;
4328
+ /** The pass starting sessions, which the next one waits for; see `startProposals`. */
4329
+ starting = Promise.resolve();
4330
+ /** The write in progress, which the next one waits for. */
4331
+ writes = Promise.resolve();
4332
+ constructor(config, deps) {
4333
+ this.config = config;
4334
+ this.deps = deps;
4335
+ this.spawnFn = deps.spawnFn ?? spawn2;
4336
+ }
4337
+ /** Whether a session has a process going. A restart waits for none. */
4338
+ get busy() {
4339
+ return this.running.size > 0;
4340
+ }
4341
+ /**
4342
+ * Read the file, and start again any session the last process left going.
4343
+ * Only while setup is still to do: `needed` is the caller's `firstBriefPending`.
4344
+ */
4345
+ async start(needed) {
4346
+ await this.load();
4347
+ if (!needed || this.file?.finishedAt) return;
4348
+ for (const record of Object.values(this.file?.proposals ?? {})) {
4349
+ if (record.status === "working") {
4350
+ record.status = "failed";
4351
+ record.error = RESTARTED;
4352
+ }
4353
+ }
4354
+ this.startProposals();
4355
+ }
4356
+ /** The screen, while setup is to do: the store has never had a brief, and setup hasn't finished. */
4357
+ async view(setup) {
4358
+ await this.load();
4359
+ if (!setup.needed || this.file?.finishedAt) return null;
4360
+ const file = this.file ?? blankFile();
4361
+ const ctx = await this.context({ setup });
4362
+ const sources = this.deps.readSources();
4363
+ const answers = this.effective(ctx, file.answers);
4364
+ const steps = [];
4365
+ for (const question of this.questions(ctx)) {
4366
+ const locked = this.locked(question, sources);
4367
+ const drawn = question.card(ctx);
4368
+ const record = file.proposals[question.id];
4369
+ const answer = locked ? question.id === "cli" ? { choices: { cli: ctx.cli ? [ctx.cli] : [] }, fields: {} } : answerFromCard(drawn) : answers[question.id] ?? null;
4370
+ const chosen = given(answer);
4371
+ const looking = question.id === "cli" && !answer && !this.detected;
4372
+ steps.push({
4373
+ id: question.id,
4374
+ title: question.title(ctx),
4375
+ topic: question.topic,
4376
+ status: answer ? chosen ? "answered" : "later" : "open",
4377
+ summary: chosen ? question.summary(chosen, ctx) : answer ? question.later?.summary ?? null : null,
4378
+ locked,
4379
+ card: { ...drawn, version: record?.version ?? 0, revisable: Boolean(question.agent && record), later: question.later?.label ?? null },
4380
+ proposal: looking ? {
4381
+ status: "working",
4382
+ conversation: [],
4383
+ steps: [{ kind: "tool", text: "Look for Codex and Claude Code", detail: null, state: "running" }],
4384
+ error: null
4385
+ } : record ? {
4386
+ status: record.status,
4387
+ conversation: record.conversation,
4388
+ steps: this.steps.get(question.id) ?? [],
4389
+ error: record.status === "failed" ? record.error : null
4390
+ } : null,
4391
+ answer
4392
+ });
4393
+ }
3449
4394
  return {
3450
- login: config.github.accounts[0] ?? (user[0]?.trim() || null),
3451
- name: user[1]?.trim() || null,
3452
- email
4395
+ detected: this.detected !== null,
4396
+ steps,
4397
+ current: steps.find((step) => step.status === "open")?.id ?? null,
4398
+ error: this.finishError
3453
4399
  };
3454
- })();
3455
- return identity;
3456
- }
3457
- function fillIdentity(template, who) {
3458
- let text = template;
3459
- if (who.name) text = text.replaceAll("`Your Name`", `\`${who.name}\``);
3460
- if (who.email) text = text.replaceAll("`you@example.com`", `\`${who.email}\``).replaceAll("`you@personal.example`", `\`${who.email}\``);
3461
- if (who.login) text = text.replaceAll("`your-github-login`", `\`${who.login}\``);
3462
- return text;
4400
+ }
4401
+ /**
4402
+ * Look for the CLIs, which the screen asks for when it opens: with one ready,
4403
+ * the first question answers itself. Asked again by Check again.
4404
+ */
4405
+ async detect() {
4406
+ await this.load();
4407
+ await this.lookForClis(true);
4408
+ this.startProposals();
4409
+ this.deps.onChange();
4410
+ }
4411
+ /**
4412
+ * Record an answer, or that the user would rather decide later. One whose
4413
+ * settings the dashboard would refuse is refused here, while the card that
4414
+ * can fix it is still open.
4415
+ */
4416
+ async answer(id, raw) {
4417
+ await this.load();
4418
+ const file = this.file ??= blankFile();
4419
+ if (file.finishedAt) return "setup has finished";
4420
+ const question = questionById(id);
4421
+ let answer = cleanAnswer(raw);
4422
+ if (!question || !answer) return `there is no question ${JSON.stringify(id)}, or that is not an answer to it`;
4423
+ if (!given(answer) && !question.later) return `"${question.topic}" needs an answer`;
4424
+ if (id === "cli" && given(answer)) {
4425
+ const chosen = await this.chooseCli(given(answer));
4426
+ if (typeof chosen === "string") return chosen;
4427
+ answer = chosen;
4428
+ }
4429
+ const ctx = await this.context();
4430
+ const before = { cli: ctx.cli, profile: ctx.profile };
4431
+ if (given(answer) && question.settings) {
4432
+ const refused = this.deps.checkSettings(this.settingsOf({ ...file.answers, [id]: answer }, await this.context({ answers: { ...file.answers, [id]: answer } })));
4433
+ if (refused) return refused;
4434
+ }
4435
+ file.answers[id] = answer;
4436
+ this.finishError = null;
4437
+ await this.stop(id, "you continued before it finished");
4438
+ const after = await this.context();
4439
+ if (after.profile !== before.profile) {
4440
+ for (const other of QUESTIONS) {
4441
+ if (!other.agent) continue;
4442
+ delete file.answers[other.id];
4443
+ await this.drop(other.id);
4444
+ }
4445
+ } else if (after.cli !== before.cli) {
4446
+ for (const other of QUESTIONS) if (other.agent && !file.answers[other.id]) await this.drop(other.id);
4447
+ }
4448
+ await this.save();
4449
+ this.startProposals();
4450
+ this.deps.onChange();
4451
+ return null;
4452
+ }
4453
+ /** Tell a question's session what to change. `draft` is what the card holds now, which it should start from. */
4454
+ async revise(id, text, draft) {
4455
+ await this.load();
4456
+ const question = questionById(id);
4457
+ const record = this.file?.proposals[id];
4458
+ if (!question?.agent || !record) return "nothing is looking into that question";
4459
+ if (this.running.has(id)) return "it is still working on that question";
4460
+ const ctx = await this.context();
4461
+ const current = cleanAnswer(draft);
4462
+ const request = [
4463
+ `The user says: ${text.trim()}`,
4464
+ ...given(current) ? ["", "What the card holds now, which they may have changed:", "", "```json", JSON.stringify(current), "```"] : [],
4465
+ "",
4466
+ "Revise your proposal, and answer with the whole of it again."
4467
+ ].join("\n");
4468
+ const resume = record.sessionId && record.cli === ctx.cli ? record.sessionId : null;
4469
+ const said = [...record.conversation, { from: "you", text: text.trim() }];
4470
+ await this.ask(question, ctx, resume ? request : `${await this.firstMessage(question, ctx)}
4471
+
4472
+ ${request}`, resume, said);
4473
+ return null;
4474
+ }
4475
+ /**
4476
+ * Look again: ask a question's session again from the start, after it failed,
4477
+ * and list Calendar.app's calendars again for the question that shows them,
4478
+ * since what failed may have been the access the user has since granted.
4479
+ */
4480
+ async retry(id) {
4481
+ await this.load();
4482
+ const question = questionById(id);
4483
+ if (!question) return `there is no question ${JSON.stringify(id)}`;
4484
+ if (this.running.has(id)) return "it is still working on that question";
4485
+ if (id === "calendars") {
4486
+ this.calendars = null;
4487
+ this.listed = null;
4488
+ }
4489
+ if (id === "cli") await this.lookForClis(true);
4490
+ if (question.agent) await this.drop(id);
4491
+ this.startProposals();
4492
+ this.deps.onChange();
4493
+ return null;
4494
+ }
4495
+ /**
4496
+ * Save everything, and ask for the first brief. Files first, then the
4497
+ * settings, whose save restarts the dashboard: the start after it begins the
4498
+ * run. Resolves with why not, or null.
4499
+ */
4500
+ async finish() {
4501
+ await this.load();
4502
+ const file = this.file ??= blankFile();
4503
+ if (file.finishedAt) return { error: "setup has already finished" };
4504
+ if (!file.answers.cli) await this.lookForClis(false);
4505
+ const ctx = await this.context();
4506
+ const sources = this.deps.readSources();
4507
+ const answers = this.effective(ctx, file.answers);
4508
+ const sections = [];
4509
+ let objective2 = null;
4510
+ for (const question of this.questions(ctx)) {
4511
+ const answer = answers[question.id];
4512
+ if (!answer && !this.locked(question, sources)) return this.refuse(`answer "${question.title(ctx)}", or decide it later`);
4513
+ const chosen = this.locked(question, sources) ? null : given(answer);
4514
+ if (!chosen) continue;
4515
+ sections.push(...question.sources?.(chosen, ctx) ?? []);
4516
+ objective2 = question.objective?.(chosen) ?? objective2;
4517
+ }
4518
+ const values = this.settingsOf(file.answers, ctx);
4519
+ const refused = this.deps.checkSettings(values);
4520
+ if (refused) return this.refuse(refused);
4521
+ await this.starting;
4522
+ for (const id of [...this.running.keys()]) await this.stop(id, "setup finished");
4523
+ const existing = await readEditable(this.config.sourcesFile);
4524
+ if (existing.text === null && sections.length > 0) {
4525
+ await mkdir2(this.config.dataDir, { recursive: true });
4526
+ await saveEditable(this.config.sourcesFile, composeSources(ctx.profile, sections, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)), ABSENT);
4527
+ }
4528
+ if (objective2) {
4529
+ const focus = await readEditable(this.config.focusFile);
4530
+ const saved = await saveEditable(this.config.focusFile, setFocusFields(focus.text, objective2, FOCUS_TEMPLATE), focus.version);
4531
+ if (!saved.saved) return this.refuse("focus.md changed on disk while saving; finish again");
4532
+ }
4533
+ file.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
4534
+ file.briefRequested = ctx.cli !== null;
4535
+ await this.save();
4536
+ const before = JSON.stringify(Object.entries(sources.settings).sort());
4537
+ const saveRefused = await this.deps.saveSettings(values);
4538
+ if (saveRefused) {
4539
+ file.finishedAt = null;
4540
+ file.briefRequested = false;
4541
+ await this.save();
4542
+ return this.refuse(saveRefused);
4543
+ }
4544
+ this.finishError = null;
4545
+ this.deps.onChange();
4546
+ return { restart: JSON.stringify(Object.entries(this.deps.readSources().settings).sort()) !== before };
4547
+ }
4548
+ /**
4549
+ * Whether the finish asked for a first brief that hasn't been started, and
4550
+ * forget it: asked once, so a run that fails is retried by the user, not by
4551
+ * every start.
4552
+ */
4553
+ async takeBriefRequest() {
4554
+ await this.load();
4555
+ if (!this.file?.briefRequested) return false;
4556
+ this.file.briefRequested = false;
4557
+ await this.save();
4558
+ return true;
4559
+ }
4560
+ /** What the finish would write as the source list, or null when the store already has one it would keep. */
4561
+ async previewSources() {
4562
+ await this.load();
4563
+ const existing = await readEditable(this.config.sourcesFile);
4564
+ if (existing.text !== null) return { text: null, kept: true };
4565
+ const ctx = await this.context();
4566
+ const sections = [];
4567
+ for (const question of this.questions(ctx)) {
4568
+ const chosen = given(this.file?.answers[question.id]);
4569
+ if (chosen) sections.push(...question.sources?.(chosen, ctx) ?? []);
4570
+ }
4571
+ return { text: sections.length ? composeSources(ctx.profile, sections, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) : null, kept: false };
4572
+ }
4573
+ /** Stop every session, for shutdown, and wait until each has exited and been written down. */
4574
+ async close() {
4575
+ await this.starting;
4576
+ const over = [...this.running.values()].map((run5) => run5.over);
4577
+ for (const id of [...this.running.keys()]) await this.stop(id, RESTARTED);
4578
+ await Promise.all(over);
4579
+ }
4580
+ /* ---------- the sessions ---------- */
4581
+ /**
4582
+ * Start whatever sessions the answers so far call for, without holding up the
4583
+ * reply that asked: starting one under Codex runs a few checks first, and a
4584
+ * screen waiting on them has every button greyed out with nothing to say why.
4585
+ * One pass at a time, so two quick answers can't both start the same question.
4586
+ */
4587
+ startProposals() {
4588
+ this.starting = this.starting.then(() => this.ensureProposals()).catch((err) => console.error(`[daily-focus] setup could not start looking: ${err.message}`));
4589
+ }
4590
+ /** Resolves once the sessions asked for so far have started: for tests, and for anything that must not race them. */
4591
+ started() {
4592
+ return this.starting;
4593
+ }
4594
+ /** Start a session for each question that has one and needs it: the CLI and the profile are known, and nobody has asked yet. */
4595
+ async ensureProposals() {
4596
+ const file = this.file;
4597
+ if (!file || file.finishedAt) return;
4598
+ if (Object.keys(file.answers).length === 0) return;
4599
+ const set = (this.deps.readSources().process.DAILY_FOCUS_AGENT ?? "").trim();
4600
+ if (file.answers.cli && !given(file.answers.cli) || set && !CLI_NAMES.includes(set)) return;
4601
+ await this.lookForClis(false);
4602
+ const ctx = await this.context({ listCalendars: true });
4603
+ const sources = this.deps.readSources();
4604
+ const profileKnown = given(file.answers.profile) !== null || (sources.process.DAILY_FOCUS_PROFILE ?? "").trim() !== "";
4605
+ if (!ctx.cli || !profileKnown) return;
4606
+ for (const question of this.questions(ctx)) {
4607
+ if (!question.agent || file.answers[question.id] || this.running.has(question.id)) continue;
4608
+ const record = file.proposals[question.id];
4609
+ if (record && record.cli === ctx.cli && record.profile === ctx.profile && record.status !== "failed") continue;
4610
+ if (record?.status === "failed" && record.error !== RESTARTED && record.cli === ctx.cli && record.profile === ctx.profile) continue;
4611
+ await this.ask(question, ctx, await this.firstMessage(question, ctx), null);
4612
+ }
4613
+ }
4614
+ /** The first message of a question's session: the instructions, what is known, and the question. */
4615
+ async firstMessage(question, ctx) {
4616
+ const instructions = await readFile8(this.config.onboardingPromptSource, "utf8").catch(
4617
+ () => "You are helping someone set up Daily Focus. Only read; never send, post or edit anything. Answer with the JSON the schema asks for."
4618
+ );
4619
+ const agent = question.agent;
4620
+ const facts = agent.facts?.(ctx) ?? null;
4621
+ return [
4622
+ instructions.trim(),
4623
+ "",
4624
+ "## This setup",
4625
+ "",
4626
+ `- Profile: ${ctx.profile}`,
4627
+ `- Today: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`,
4628
+ "",
4629
+ `## The question: ${question.title(ctx)}`,
4630
+ "",
4631
+ agent.brief(ctx),
4632
+ ...facts ? ["", "### What the dashboard already knows", "", facts] : []
4633
+ ].join("\n");
4634
+ }
4635
+ async ask(question, ctx, prompt, resume, conversation = []) {
4636
+ const file = this.file ??= blankFile();
4637
+ const cli2 = ctx.cli;
4638
+ const previous = file.proposals[question.id];
4639
+ const record = {
4640
+ cli: cli2,
4641
+ profile: ctx.profile,
4642
+ sessionId: resume,
4643
+ status: "working",
4644
+ result: previous?.cli === cli2 && previous.profile === ctx.profile ? previous.result : null,
4645
+ error: null,
4646
+ version: previous?.version ?? 0,
4647
+ conversation
4648
+ };
4649
+ file.proposals[question.id] = record;
4650
+ this.steps.set(question.id, []);
4651
+ await this.save();
4652
+ this.deps.onChange();
4653
+ const bin = ctx.clis?.[cli2]?.path ?? null;
4654
+ if (!bin) return this.settle(question.id, record, { ok: false, error: `could not find ${cli2} on this machine` });
4655
+ if (cli2 === "codex") {
4656
+ const gate = this.gates.get(bin) ?? new CodexGate(bin, this.deps.execFn);
4657
+ this.gates.set(bin, gate);
4658
+ const refusal2 = await gate.refusal();
4659
+ if (refusal2) return this.settle(question.id, record, { ok: false, error: refusal2 });
4660
+ }
4661
+ let codexConfig = [];
4662
+ if (cli2 === "codex") {
4663
+ try {
4664
+ this.codexOff ??= computerUseOff(bin, this.deps.execFn);
4665
+ codexConfig = await this.codexOff;
4666
+ } catch (err) {
4667
+ this.codexOff = null;
4668
+ return this.settle(question.id, record, { ok: false, error: `Codex could not say which plugins to switch off: ${err.message}` });
4669
+ }
4670
+ }
4671
+ const schemaDir = await mkdtemp(join5(tmpdir2(), "daily-focus-setup-"));
4672
+ const schemaFile = join5(schemaDir, "schema.json");
4673
+ const json = JSON.stringify(question.agent.schema);
4674
+ await writeFile2(schemaFile, json, "utf8");
4675
+ await mkdir2(this.config.onboardingDir, { recursive: true });
4676
+ const adapter = ADAPTERS[cli2];
4677
+ const session = SESSION_MODEL[cli2];
4678
+ const model = cli2 === "codex" ? await (this.codexModel ??= codexLatestModel(bin, this.deps.execFn)) : session.model;
4679
+ const args = adapter.args({
4680
+ prompt,
4681
+ sessionId: resume,
4682
+ model,
4683
+ effort: session.effort,
4684
+ tools: ONBOARDING_TOOLS,
4685
+ denyEdits: true,
4686
+ drafts: false,
4687
+ codexConfig,
4688
+ schema: { json, file: schemaFile }
4689
+ });
4690
+ let child;
4691
+ try {
4692
+ child = this.spawnFn(bin, args, { cwd: this.config.onboardingDir, stdio: ["ignore", "pipe", "pipe"], env: process.env });
4693
+ } catch (err) {
4694
+ await rm2(schemaDir, { recursive: true, force: true });
4695
+ return this.settle(question.id, record, { ok: false, error: `could not run ${bin}: ${err.message}` });
4696
+ }
4697
+ const timer = setTimeout(() => void this.stop(question.id, "gave up after four minutes"), TURN_TIMEOUT_MS2);
4698
+ timer.unref();
4699
+ let ended;
4700
+ const run5 = { child, timer, abortReason: null, over: new Promise((resolve2) => ended = resolve2) };
4701
+ this.running.set(question.id, run5);
4702
+ let done;
4703
+ let said = "";
4704
+ const stderr = [];
4705
+ child.stderr?.on("data", (chunk) => {
4706
+ stderr.push(chunk.toString("utf8"));
4707
+ if (stderr.length > 50) stderr.shift();
4708
+ });
4709
+ const calls = /* @__PURE__ */ new Map();
4710
+ const steps = this.steps.get(question.id);
4711
+ const add = (step) => {
4712
+ steps.push(step);
4713
+ if (steps.length > STEPS_KEPT) steps.shift();
4714
+ };
4715
+ if (child.stdout) {
4716
+ createInterface2({ input: child.stdout }).on("line", (line) => {
4717
+ const event = adapter.parse(line);
4718
+ if (!event) return;
4719
+ if (event.sessionId) record.sessionId = event.sessionId;
4720
+ if (event.text) said = event.text;
4721
+ for (const parsed of event.steps ?? []) {
4722
+ const known = calls.get(parsed.id);
4723
+ if (known) {
4724
+ if (parsed.text) known.text = parsed.text;
4725
+ if (parsed.detail) known.detail = parsed.detail;
4726
+ known.state = parsed.state;
4727
+ } else if (parsed.text) {
4728
+ const step = { kind: "tool", text: parsed.text, detail: parsed.detail ?? null, state: parsed.state };
4729
+ calls.set(parsed.id, step);
4730
+ add(step);
4731
+ }
4732
+ }
4733
+ if (event.steps) this.deps.onChange();
4734
+ if (event.done) done = event.done;
4735
+ });
4736
+ }
4737
+ child.on("error", (err) => {
4738
+ done = { ok: false, error: `could not run ${bin}: ${err.message}` };
4739
+ });
4740
+ child.on("exit", () => {
4741
+ if (run5.abortReason === null) return;
4742
+ child.stdout?.destroy();
4743
+ child.stderr?.destroy();
4744
+ });
4745
+ child.on("close", (code) => {
4746
+ clearTimeout(timer);
4747
+ this.running.delete(question.id);
4748
+ void rm2(schemaDir, { recursive: true, force: true });
4749
+ const outcome = run5.abortReason ? { ok: false, error: run5.abortReason } : done ?? {
4750
+ ok: false,
4751
+ error: `${cli2} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
4752
+ };
4753
+ void this.settle(question.id, record, outcome.ok ? { ok: true, reply: outcome.reply ?? said } : outcome).catch((err) => console.error(`[daily-focus] setup could not keep what it found: ${err.message}`)).finally(ended);
4754
+ });
4755
+ }
4756
+ /** A session is over: keep its answer when it is JSON, or why it isn't. */
4757
+ async settle(id, record, outcome) {
4758
+ if (this.file?.proposals[id] !== record) return;
4759
+ for (const step of this.steps.get(id) ?? []) if (step.state === "running") step.state = outcome.ok ? "done" : "failed";
4760
+ if (outcome.ok) {
4761
+ try {
4762
+ record.result = JSON.parse(stripFence(outcome.reply ?? ""));
4763
+ record.status = "ready";
4764
+ record.error = null;
4765
+ record.version += 1;
4766
+ const why = record.result?.why;
4767
+ if (typeof why === "string" && why.trim()) record.conversation.push({ from: "it", text: why.trim() });
4768
+ } catch {
4769
+ record.status = "failed";
4770
+ record.error = "it answered, but not in the shape asked for";
4771
+ }
4772
+ } else {
4773
+ record.status = "failed";
4774
+ record.error = outcome.error ?? "failed";
4775
+ }
4776
+ await this.save();
4777
+ this.deps.onChange();
4778
+ }
4779
+ /** Stop a session: asked to, then made to if it hasn't gone within a few seconds. */
4780
+ async stop(id, reason) {
4781
+ const run5 = this.running.get(id);
4782
+ if (!run5 || run5.abortReason !== null) return;
4783
+ run5.abortReason = reason;
4784
+ run5.child.kill("SIGTERM");
4785
+ const kill = setTimeout(() => {
4786
+ if (run5.child.exitCode === null && run5.child.signalCode === null) run5.child.kill("SIGKILL");
4787
+ }, KILL_AFTER_MS);
4788
+ kill.unref();
4789
+ void run5.over.then(() => clearTimeout(kill));
4790
+ }
4791
+ /** Forget a question's proposal, stopping its session first. */
4792
+ async drop(id) {
4793
+ await this.stop(id, "no longer needed");
4794
+ if (this.file) delete this.file.proposals[id];
4795
+ this.steps.delete(id);
4796
+ }
4797
+ /* ---------- what a question may read ---------- */
4798
+ /**
4799
+ * Look for the CLIs, once per process unless `again`. Only from a click or
4800
+ * from setup already under way: `--version` and a login check are programs
4801
+ * run on the user's behalf, and a new store's state is read before anyone is
4802
+ * at the screen.
4803
+ */
4804
+ async lookForClis(again) {
4805
+ if (this.detected && !again) return;
4806
+ this.detecting ??= this.deps.detectClis().finally(() => {
4807
+ this.detecting = null;
4808
+ });
4809
+ this.detected = await this.detecting;
4810
+ }
4811
+ /**
4812
+ * The CLI answer to keep: one of the ready ones, or a path the user typed,
4813
+ * which is used only once it has said what it is and that it is ready.
4814
+ * Otherwise why not, in a sentence the card shows.
4815
+ */
4816
+ async chooseCli(answer) {
4817
+ const typed = answer.fields.path?.trim();
4818
+ if (!typed) {
4819
+ const picked = answer.choices.cli?.[0];
4820
+ await this.lookForClis(false);
4821
+ if (picked && this.detected?.[picked]?.ready) return { choices: { cli: [picked] }, fields: {} };
4822
+ return "Enter the path to codex or claude, or decide this later";
4823
+ }
4824
+ const { cli: cli2, found } = await this.deps.probeCli(expandHome(typed));
4825
+ if (!cli2 || !found.ready) {
4826
+ return `${found.problem ?? "That can't be used"}${found.fix ? `: run ${found.fix} in a terminal, then try again.` : "."}`;
4827
+ }
4828
+ await this.lookForClis(false);
4829
+ this.detected = { ...this.detected, [cli2]: found };
4830
+ return { choices: { cli: [cli2] }, fields: { path: found.path } };
4831
+ }
4832
+ /**
4833
+ * The answers as they stand, with the CLI setup picked without asking when
4834
+ * the user hasn't picked one: the first ready, or the one already set.
4835
+ */
4836
+ effective(ctx, answers) {
4837
+ if (answers.cli || !ctx.cli) return { ...answers };
4838
+ return { ...answers, cli: { choices: { cli: [ctx.cli] }, fields: {} } };
4839
+ }
4840
+ /** Set in the environment, which wins over setup: shown as it stands, never asked. */
4841
+ locked(question, sources) {
4842
+ return question.keys.length > 0 && question.keys.every((key) => (sources.process[key] ?? "").trim() !== "");
4843
+ }
4844
+ /**
4845
+ * The settings these answers add up to, in question order, less what the
4846
+ * environment sets: that can't be saved over, and wins anyway.
4847
+ */
4848
+ settingsOf(answers, ctx) {
4849
+ const sources = this.deps.readSources();
4850
+ const values = {};
4851
+ const effective = this.effective(ctx, answers);
4852
+ for (const question of this.questions(ctx)) {
4853
+ const chosen = this.locked(question, sources) ? null : given(effective[question.id]);
4854
+ if (chosen) Object.assign(values, question.settings?.(chosen, ctx) ?? {});
4855
+ }
4856
+ for (const key of Object.keys(values)) if ((sources.process[key] ?? "").trim() !== "") delete values[key];
4857
+ return values;
4858
+ }
4859
+ questions(ctx) {
4860
+ return QUESTIONS.filter((question) => !question.applies || question.applies(ctx));
4861
+ }
4862
+ /**
4863
+ * Everything a question may read. The CLI and the profile are the answers
4864
+ * when given, else what is in effect, else the card's own pick, so the
4865
+ * questions that follow from them are counted from the start.
4866
+ *
4867
+ * Calendar.app's calendars only as already listed, unless `listCalendars`:
4868
+ * the helper may ask for calendar access, so it is launched only from a
4869
+ * click, never from a state read, which any GET makes.
4870
+ */
4871
+ async context(opts = {}) {
4872
+ const sources = this.deps.readSources();
4873
+ const env = layerEnv(sources);
4874
+ const current = {};
4875
+ for (const def of SETTINGS) {
4876
+ const value = (env[def.key] ?? "").trim();
4877
+ if (value) current[def.key] = value;
4878
+ }
4879
+ const setup = opts.setup ?? await this.deps.setup();
4880
+ const answers = opts.answers ?? this.file?.answers ?? {};
4881
+ const profileAnswer = given(answers.profile)?.choices.profile?.[0];
4882
+ const profile2 = PROFILES.includes(profileAnswer) ? profileAnswer : current.DAILY_FOCUS_PROFILE === "personal" ? "personal" : "work";
4883
+ const cliAnswer = given(answers.cli);
4884
+ const typed = cliAnswer?.fields.path?.trim();
4885
+ const answered = cliAnswer?.choices.cli?.[0];
4886
+ let clis = this.detected;
4887
+ if (clis && typed && answered && CLI_NAMES.includes(answered) && clis[answered].path !== typed) {
4888
+ clis = { ...clis, [answered]: { ...clis[answered], path: typed, onPath: false, ready: true, problem: null, fix: null } };
4889
+ }
4890
+ const set = current.DAILY_FOCUS_AGENT;
4891
+ const cliLocked = (sources.process.DAILY_FOCUS_AGENT ?? "").trim() !== "";
4892
+ const cli2 = answers.cli ? answered ?? null : cliLocked ? set && CLI_NAMES.includes(set) ? set : null : set && clis?.[set]?.ready ? set : clis ? preferredCli(clis) : null;
4893
+ const started = Object.keys(answers).length > 0;
4894
+ const profileKnown = started && (given(answers.profile) !== null || (sources.process.DAILY_FOCUS_PROFILE ?? "").trim() !== "");
4895
+ let calendars2 = null;
4896
+ const liveAgenda = !["off", "false", "0", "no"].includes((current.DAILY_FOCUS_CALENDAR ?? "").toLowerCase());
4897
+ if (setup.calendar.supported && setup.calendar.built && liveAgenda) {
4898
+ if (opts.listCalendars === true && profileKnown) {
4899
+ this.calendars ??= this.deps.listCalendars().catch((err) => err instanceof Error ? err.message : String(err)).then((listed2) => {
4900
+ this.listed = listed2;
4901
+ });
4902
+ await this.calendars;
4903
+ }
4904
+ calendars2 = this.listed;
4905
+ }
4906
+ const proposals = {};
4907
+ for (const [id, record] of Object.entries(this.file?.proposals ?? {})) {
4908
+ if (record.cli === cli2 && record.profile === profile2 && record.result !== null) proposals[id] = record.result;
4909
+ }
4910
+ return {
4911
+ machine: machineName(),
4912
+ profile: profile2,
4913
+ cli: cli2 && CLI_NAMES.includes(cli2) ? cli2 : null,
4914
+ clis,
4915
+ identity: started ? await this.deps.identity() : { name: null, login: null, logins: [], email: null },
4916
+ calendars: calendars2,
4917
+ calendar: setup.calendar,
4918
+ boards: await this.deps.boards(),
4919
+ current,
4920
+ proposals
4921
+ };
4922
+ }
4923
+ /* ---------- the file ---------- */
4924
+ async load() {
4925
+ if (this.loaded) return;
4926
+ this.loaded = true;
4927
+ try {
4928
+ this.file = parseFile(await readFile8(this.config.onboardingFile, "utf8"));
4929
+ } catch {
4930
+ this.file = null;
4931
+ }
4932
+ }
4933
+ /**
4934
+ * Write the file as it is now. One write at a time: sessions settle side by
4935
+ * side, and two renames of the same temporary file would trip over each other.
4936
+ */
4937
+ save() {
4938
+ const write = this.writes.then(async () => {
4939
+ if (!this.file) return;
4940
+ await mkdir2(this.config.dataDir, { recursive: true });
4941
+ await writeJsonAtomic(this.config.onboardingFile, this.file);
4942
+ });
4943
+ this.writes = write.catch(() => {
4944
+ });
4945
+ return write;
4946
+ }
4947
+ refuse(error) {
4948
+ this.finishError = error;
4949
+ this.deps.onChange();
4950
+ return { error };
4951
+ }
4952
+ };
4953
+ function stripFence(text) {
4954
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/.exec(text.trim());
4955
+ return fenced ? fenced[1] : text.trim();
3463
4956
  }
3464
4957
 
3465
4958
  // src/shellpath.ts
3466
- import { spawn as spawn2 } from "node:child_process";
4959
+ import { spawn as spawn3 } from "node:child_process";
3467
4960
  import { userInfo } from "node:os";
3468
4961
  var START = "__DAILY_FOCUS_PATH_START__";
3469
4962
  var END = "__DAILY_FOCUS_PATH_END__";
3470
4963
  var SHELL_TIMEOUT_MS = 5e3;
3471
4964
  var defaultExec = (file, args, { timeout }) => new Promise((resolve2, reject) => {
3472
- const child = spawn2(file, args, { detached: true, stdio: ["ignore", "pipe", "ignore"] });
4965
+ const child = spawn3(file, args, { detached: true, stdio: ["ignore", "pipe", "ignore"] });
3473
4966
  let output = "";
3474
4967
  let settled = false;
3475
4968
  const finish = (error) => {
@@ -3490,8 +4983,8 @@ var defaultExec = (file, args, { timeout }) => new Promise((resolve2, reject) =>
3490
4983
  child.on("error", (error) => finish(error));
3491
4984
  child.on("close", () => finish(null));
3492
4985
  });
3493
- function shellCandidates(envShell, loginShell, platform2) {
3494
- const fallback = platform2 === "darwin" ? "/bin/zsh" : platform2 === "linux" ? "/bin/bash" : void 0;
4986
+ function shellCandidates(envShell, loginShell, platform) {
4987
+ const fallback = platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : void 0;
3495
4988
  const shells = [];
3496
4989
  for (const shell of [envShell, loginShell, fallback]) {
3497
4990
  const trimmed = shell?.trim();
@@ -3536,11 +5029,11 @@ function currentLoginShell() {
3536
5029
  return void 0;
3537
5030
  }
3538
5031
  }
3539
- async function hydratePath(env = process.env, platform2 = process.platform, exec = defaultExec, loginShell = currentLoginShell()) {
3540
- if (platform2 === "win32") return null;
5032
+ async function hydratePath(env = process.env, platform = process.platform, exec = defaultExec, loginShell = currentLoginShell()) {
5033
+ if (platform === "win32") return null;
3541
5034
  let found = null;
3542
5035
  let from = null;
3543
- for (const shell of shellCandidates(env.SHELL, loginShell, platform2)) {
5036
+ for (const shell of shellCandidates(env.SHELL, loginShell, platform)) {
3544
5037
  try {
3545
5038
  found = await readLoginShellPath(shell, exec);
3546
5039
  } catch {
@@ -3550,7 +5043,7 @@ async function hydratePath(env = process.env, platform2 = process.platform, exec
3550
5043
  break;
3551
5044
  }
3552
5045
  }
3553
- if (!found && platform2 === "darwin") {
5046
+ if (!found && platform === "darwin") {
3554
5047
  found = await readLaunchctlPath(exec);
3555
5048
  if (found) from = "launchctl";
3556
5049
  }
@@ -3561,7 +5054,7 @@ async function hydratePath(env = process.env, platform2 = process.platform, exec
3561
5054
 
3562
5055
  // src/server.ts
3563
5056
  var PUBLIC_DIR = resolve(fileURLToPath(new URL("../public", import.meta.url)));
3564
- var VERSION = packageVersion();
5057
+ var VERSION2 = packageVersion();
3565
5058
  var MIME = {
3566
5059
  ".html": "text/html; charset=utf-8",
3567
5060
  ".js": "text/javascript; charset=utf-8",
@@ -3576,15 +5069,8 @@ var TEXT_FILES = {
3576
5069
  "/api/text/focus": { file: "focusFile", name: "focus.md" },
3577
5070
  "/api/text/sources": { file: "sourcesFile", name: "sources.md" }
3578
5071
  };
3579
- var VALID_ACTIONS = /* @__PURE__ */ new Set([
3580
- "done",
3581
- "snooze",
3582
- "dismiss",
3583
- "reopen",
3584
- "note"
3585
- ]);
5072
+ var VALID_ACTIONS = new Set(ACTION_TYPES);
3586
5073
  var MAX_BODY_BYTES = 64 * 1024;
3587
- var PRESENCE_POLL_MS = 3e4;
3588
5074
  var AGENT_CLOCK_MS = 3e4;
3589
5075
  function sendJSON(res, status, body) {
3590
5076
  const payload = JSON.stringify(body);
@@ -3611,7 +5097,7 @@ async function readBody(req) {
3611
5097
  }
3612
5098
  async function serveStatic(res, urlPath) {
3613
5099
  const relative = normalize(decodeURIComponent(urlPath === "/" ? "/index.html" : urlPath));
3614
- const filePath = join4(PUBLIC_DIR, relative);
5100
+ const filePath = join6(PUBLIC_DIR, relative);
3615
5101
  if (filePath !== PUBLIC_DIR && !filePath.startsWith(PUBLIC_DIR + sep)) {
3616
5102
  res.writeHead(403).end("Forbidden");
3617
5103
  return;
@@ -3661,7 +5147,7 @@ function configure(sources) {
3661
5147
  function statusOf(state, config, busy) {
3662
5148
  const last = state.agentRun.last;
3663
5149
  return {
3664
- version: VERSION,
5150
+ version: VERSION2,
3665
5151
  dataDir: config.dataDir,
3666
5152
  profile: config.profile,
3667
5153
  setupNeeded: state.setup.needed,
@@ -3700,34 +5186,123 @@ async function startServer(env, options = {}) {
3700
5186
  let restart = null;
3701
5187
  let restarting = false;
3702
5188
  let settingsWrites = Promise.resolve();
3703
- await stat4(join4(PUBLIC_DIR, "app.js")).catch(() => {
5189
+ await stat4(join6(PUBLIC_DIR, "app.js")).catch(() => {
3704
5190
  console.warn(
3705
5191
  PACKAGED ? "[daily-focus] public/app.js is missing from the package: reinstall it" : "[daily-focus] public/app.js is missing: run `npm run build` (npm start does so on its own)"
3706
5192
  );
3707
5193
  });
3708
5194
  const subscribers = /* @__PURE__ */ new Set();
3709
5195
  let assetVersion = await computeAssetVersion(PUBLIC_DIR);
3710
- const board = new Board(config, () => void broadcast(), options.integrations?.github);
5196
+ let autoAsk = null;
5197
+ const board = new Board(
5198
+ config,
5199
+ () => {
5200
+ void broadcast();
5201
+ autoAsk?.poke();
5202
+ },
5203
+ options.integrations?.github
5204
+ );
3711
5205
  const calendar = new CalendarBoard(config, () => void broadcast());
3712
5206
  const tickets = new TicketBoard(config, () => void broadcast(), options.integrations?.jira);
3713
5207
  const assistant = new Assistant(config, {
3714
5208
  onChange: () => {
3715
5209
  void broadcast();
3716
5210
  restartWhenIdle();
5211
+ autoAsk?.poke();
3717
5212
  }
3718
5213
  });
5214
+ autoAsk = new AutoAsk({
5215
+ config,
5216
+ assistant,
5217
+ rows: async () => board.view(await store.readActions(), /* @__PURE__ */ new Date()).rows,
5218
+ context: async (id) => contextFor(id, await buildState()),
5219
+ // Nothing new starts while saved settings wait on the restart that applies them.
5220
+ canStart: () => restart !== "waiting"
5221
+ });
5222
+ const ahead = autoAsk;
3719
5223
  const agent = new AgentRunner(config, {
3720
5224
  onChange: () => {
3721
5225
  void broadcast();
3722
5226
  restartWhenIdle();
3723
5227
  }
3724
5228
  });
5229
+ const onboarding = new Onboarding(config, {
5230
+ onChange: () => {
5231
+ void broadcast();
5232
+ restartWhenIdle();
5233
+ },
5234
+ // A path already set for either CLI is looked at before PATH.
5235
+ detectClis: () => {
5236
+ const configured = {};
5237
+ if (config.agent.cli && config.agent.binPath !== config.agent.cli) configured[config.agent.cli] = config.agent.binPath;
5238
+ if (config.assistant.agent && config.assistant.binPath !== config.assistant.agent) configured[config.assistant.agent] = config.assistant.binPath;
5239
+ return detectClis(configured);
5240
+ },
5241
+ probeCli: (path) => probeCli(path, null, false),
5242
+ readSources,
5243
+ setup: () => setupState(config),
5244
+ identity: () => detectIdentity(config),
5245
+ boards: async () => {
5246
+ const actions = await store.readActions();
5247
+ const now = /* @__PURE__ */ new Date();
5248
+ const health = (view) => ({
5249
+ enabled: view.enabled,
5250
+ fetchedAt: view.fetchedAt,
5251
+ reason: view.reason?.message ?? null
5252
+ });
5253
+ return { github: health(board.view(actions, now)), jira: health(tickets.view(actions, now)) };
5254
+ },
5255
+ listCalendars: async () => {
5256
+ const facts = await runHelper(config.calendar.appPath, []);
5257
+ const today = /* @__PURE__ */ new Map();
5258
+ for (const event of facts.events) {
5259
+ if (typeof event.calendarId === "string") today.set(event.calendarId, (today.get(event.calendarId) ?? 0) + 1);
5260
+ }
5261
+ return facts.calendars.map(({ id, title, source }) => ({ title, source, eventsToday: today.get(id) ?? 0 }));
5262
+ },
5263
+ checkSettings: (values) => {
5264
+ const applied = applySettingsChange(readSources(), values);
5265
+ return "error" in applied ? applied.error : null;
5266
+ },
5267
+ saveSettings: (values) => saveSettingsValues(values)
5268
+ });
5269
+ let codexWarnings = [];
5270
+ const codexBins = /* @__PURE__ */ new Set();
5271
+ if (config.agent.cli === "codex") codexBins.add(config.agent.binPath);
5272
+ if (config.assistant.agent === "codex") codexBins.add(config.assistant.binPath);
5273
+ void Promise.all([...codexBins].map((bin) => codexVersionWarning(bin))).then((found) => {
5274
+ codexWarnings = [...new Set(found.filter((warning) => warning !== null))];
5275
+ for (const warning of codexWarnings) console.warn(`[daily-focus] ${warning}`);
5276
+ if (codexWarnings.length > 0) void broadcast();
5277
+ });
5278
+ async function startRequestedBrief() {
5279
+ if (!agent.enabled || !await firstBriefPending(config) || !await onboarding.takeBriefRequest()) return;
5280
+ await agent.run("hand").catch((err) => {
5281
+ console.error(`[daily-focus] could not start the first brief: ${err.message}`);
5282
+ });
5283
+ }
5284
+ function saveSettingsValues(values) {
5285
+ const saved = settingsWrites.then(async () => {
5286
+ const sources = readSources();
5287
+ const applied = applySettingsChange(sources, values);
5288
+ if ("error" in applied) return applied.error;
5289
+ const changed = JSON.stringify(Object.entries(applied.settings).sort()) !== JSON.stringify(Object.entries(sources.settings).sort());
5290
+ if (changed) {
5291
+ await saveSettings(sources.settingsPath, applied.settings);
5292
+ requestRestart();
5293
+ }
5294
+ return null;
5295
+ });
5296
+ settingsWrites = saved.catch(() => {
5297
+ });
5298
+ return saved;
5299
+ }
3725
5300
  function requestRestart() {
3726
5301
  restart = options.onRestart ? "waiting" : "manual";
3727
5302
  restartWhenIdle();
3728
5303
  }
3729
5304
  function restartWhenIdle() {
3730
- if (restart !== "waiting" || restarting || agent.busy || assistant.busy) return;
5305
+ if (restart !== "waiting" || restarting || agent.busy || assistant.busy || onboarding.busy) return;
3731
5306
  restarting = true;
3732
5307
  setImmediate(() => options.onRestart?.());
3733
5308
  }
@@ -3739,23 +5314,30 @@ async function startServer(env, options = {}) {
3739
5314
  // A link that can't be read is no reason to withhold the page.
3740
5315
  storeLinkWarning(config).catch(() => null)
3741
5316
  ]);
3742
- const sources = readSources();
3743
- const setup = await setupState(config, {
3744
- profileChosen: Boolean(layerEnv(sources).DAILY_FOCUS_PROFILE?.trim())
3745
- });
5317
+ const setup = await setupState(config);
5318
+ const pulls = board.view(actions, now);
5319
+ const onboardingState = await onboarding.view(setup);
3746
5320
  return {
3747
5321
  ...state,
3748
- board: board.view(actions, now),
5322
+ board: pulls,
3749
5323
  tickets: tickets.view(actions, now),
3750
- assistant: assistant.view(),
5324
+ assistant: { ...assistant.view(), auto: ahead.view(pulls.rows) },
3751
5325
  agentRun: agent.view(now),
3752
- setupWarnings: [...configWarnings, ...linkWarning ? [linkWarning] : []],
5326
+ setupWarnings: [...configWarnings, ...codexWarnings, ...linkWarning ? [linkWarning] : []],
3753
5327
  restart,
3754
5328
  setup,
5329
+ onboarding: onboardingState,
3755
5330
  assetVersion,
3756
5331
  storeId
3757
5332
  };
3758
5333
  }
5334
+ function contextFor(id, current) {
5335
+ return {
5336
+ item: current.items.find((item) => item.id === id),
5337
+ pull: current.board.rows.find((row) => row.id === id),
5338
+ ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
5339
+ };
5340
+ }
3759
5341
  async function broadcast(prebuilt) {
3760
5342
  if (subscribers.size === 0) return;
3761
5343
  let payload;
@@ -3777,13 +5359,11 @@ data: ${payload}
3777
5359
  () => {
3778
5360
  void store.archiveCurrentBrief().then(() => broadcast());
3779
5361
  },
3780
- // The timer re-stamps its own file every poll and the two boards rewrite
3781
- // prs.json and tickets.json every fetch; that's this process talking to
3782
- // itself, and all three already broadcast when something actually changed.
5362
+ // The two boards rewrite prs.json and tickets.json every fetch; that's this
5363
+ // process talking to itself, and both already broadcast when something
5364
+ // actually changed.
3783
5365
  {
3784
5366
  ignore: [
3785
- basename(config.sessionFile),
3786
- basename(tempPathFor(config.sessionFile)),
3787
5367
  basename(config.pullsFile),
3788
5368
  basename(tempPathFor(config.pullsFile)),
3789
5369
  basename(config.ticketsFile),
@@ -3792,7 +5372,10 @@ data: ${payload}
3792
5372
  basename(tempPathFor(config.timeZoneFile)),
3793
5373
  // Appended by this process, which broadcasts on its own.
3794
5374
  basename(config.assistantLogFile),
3795
- basename(config.agentLogFile)
5375
+ basename(config.agentLogFile),
5376
+ // Rewritten by the setup screen, which broadcasts on its own.
5377
+ basename(config.onboardingFile),
5378
+ basename(tempPathFor(config.onboardingFile))
3796
5379
  ]
3797
5380
  }
3798
5381
  );
@@ -3803,34 +5386,10 @@ data: ${payload}
3803
5386
  void broadcast();
3804
5387
  });
3805
5388
  });
3806
- let sessionTick = null;
3807
- function scheduleSessionTick(endsAt) {
3808
- if (sessionTick) clearTimeout(sessionTick);
3809
- sessionTick = null;
3810
- if (!endsAt) return;
3811
- const delay = new Date(endsAt).getTime() - Date.now();
3812
- if (delay <= 0 || delay > 36e5) return;
3813
- sessionTick = setTimeout(() => void broadcast(), delay);
3814
- sessionTick.unref();
3815
- }
3816
- async function checkPresence() {
3817
- let closed;
3818
- try {
3819
- closed = await reconcileSession(config, readIdleSeconds);
3820
- } catch (err) {
3821
- console.error(`[daily-focus] presence check failed: ${err.message}`);
3822
- return;
3823
- }
3824
- if (!closed) return;
3825
- console.log(
3826
- `[daily-focus] stopped "${closed.title}" at ${closed.endedAt} after ${closed.actualMinutes} min \u2014 machine untouched since then`
3827
- );
3828
- scheduleSessionTick(null);
3829
- await broadcast();
3830
- }
3831
- await checkPresence();
3832
5389
  await assistant.start();
3833
5390
  await agent.start();
5391
+ await onboarding.start(await firstBriefPending(config));
5392
+ await startRequestedBrief();
3834
5393
  let ticks = Promise.resolve();
3835
5394
  function clockTick() {
3836
5395
  ticks = ticks.then(tickOnce).catch((err) => {
@@ -3853,10 +5412,10 @@ data: ${payload}
3853
5412
  void clockTick();
3854
5413
  }, AGENT_CLOCK_MS);
3855
5414
  agentClock.unref();
3856
- scheduleSessionTick((await buildState()).session.active?.endsAt ?? null);
3857
5415
  void board.start().catch((err) => {
3858
5416
  console.error(`[daily-focus] pull request board failed to start: ${err.message}`);
3859
5417
  });
5418
+ ahead.poke();
3860
5419
  void calendar.start().catch((err) => {
3861
5420
  console.error(`[daily-focus] live agenda failed to start: ${err.message}`);
3862
5421
  });
@@ -3867,10 +5426,6 @@ data: ${payload}
3867
5426
  void broadcast();
3868
5427
  }, 6e4);
3869
5428
  heartbeat.unref();
3870
- const presencePoll = setInterval(() => {
3871
- void checkPresence();
3872
- }, PRESENCE_POLL_MS);
3873
- presencePoll.unref();
3874
5429
  const server = createServer((req, res) => {
3875
5430
  void handle(req, res).catch((err) => {
3876
5431
  console.error(`[daily-focus] ${req.method} ${req.url} failed:`, err);
@@ -4017,12 +5572,7 @@ data: ${JSON.stringify(await buildState())}
4017
5572
  sendJSON(res, 409, { error: "the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex" });
4018
5573
  return;
4019
5574
  }
4020
- const current = await buildState();
4021
- const context = {
4022
- item: current.items.find((item) => item.id === id),
4023
- pull: current.board.rows.find((row) => row.id === id),
4024
- ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
4025
- };
5575
+ const context = contextFor(id, await buildState());
4026
5576
  if (!context.item && !context.pull && !context.ticket) {
4027
5577
  sendJSON(res, 404, { error: `no item with id ${id}` });
4028
5578
  return;
@@ -4058,8 +5608,8 @@ data: ${JSON.stringify(await buildState())}
4058
5608
  sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
4059
5609
  return;
4060
5610
  }
4061
- const { run: run6, text } = typeof body === "object" && body !== null ? body : {};
4062
- if (typeof run6 !== "string" || run6 === "") {
5611
+ const { run: run5, text } = typeof body === "object" && body !== null ? body : {};
5612
+ if (typeof run5 !== "string" || run5 === "") {
4063
5613
  sendJSON(res, 400, { error: "run must be a run id" });
4064
5614
  return;
4065
5615
  }
@@ -4068,7 +5618,7 @@ data: ${JSON.stringify(await buildState())}
4068
5618
  return;
4069
5619
  }
4070
5620
  try {
4071
- await agent.ask(run6, text);
5621
+ await agent.ask(run5, text);
4072
5622
  } catch (err) {
4073
5623
  sendJSON(res, 409, { error: err.message });
4074
5624
  return;
@@ -4138,53 +5688,40 @@ data: ${JSON.stringify(await buildState())}
4138
5688
  void broadcast(state);
4139
5689
  return;
4140
5690
  }
4141
- if (path === "/api/session" && req.method === "POST") {
4142
- let body;
4143
- try {
4144
- body = JSON.parse(await readBody(req));
4145
- } catch (err) {
4146
- sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
4147
- return;
4148
- }
4149
- if (typeof body !== "object" || body === null) {
4150
- sendJSON(res, 400, { error: "body must be an object" });
5691
+ if (path.startsWith("/api/onboarding/") && req.method === "POST") {
5692
+ const body = await readJsonObject(req, res);
5693
+ if (!body) return;
5694
+ const step = typeof body.step === "string" ? body.step : "";
5695
+ let refused2 = null;
5696
+ if (path === "/api/onboarding/answer") {
5697
+ refused2 = await onboarding.answer(step, body.answer);
5698
+ } else if (path === "/api/onboarding/revise") {
5699
+ refused2 = typeof body.text === "string" && body.text.trim() !== "" ? await onboarding.revise(step, body.text, body.draft) : '"text" is required: what to change';
5700
+ } else if (path === "/api/onboarding/retry") {
5701
+ refused2 = await onboarding.retry(step);
5702
+ } else if (path === "/api/onboarding/detect") {
5703
+ await onboarding.detect();
5704
+ } else if (path === "/api/onboarding/finish") {
5705
+ const finished = await onboarding.finish();
5706
+ if ("error" in finished) refused2 = finished.error;
5707
+ else if (!finished.restart) await startRequestedBrief();
5708
+ } else {
5709
+ sendJSON(res, 404, { error: `no such setup action: ${path}` });
4151
5710
  return;
4152
5711
  }
4153
- const { action, id, minutes } = body;
4154
- if (action === "stop") {
4155
- await stopSession(config);
4156
- } else if (action === "start") {
4157
- if (typeof id !== "string" || id === "") {
4158
- sendJSON(res, 400, { error: '"id" is required to start a session' });
4159
- return;
4160
- }
4161
- const { brief } = await store.readBrief();
4162
- const item = brief?.items.find((candidate) => candidate.id === id);
4163
- if (!item) {
4164
- sendJSON(res, 404, { error: `no item with id ${id}` });
4165
- return;
4166
- }
4167
- const requested = typeof minutes === "number" && Number.isFinite(minutes) ? Math.round(minutes) : config.sessionMinutes;
4168
- if (requested < 1 || requested > 120) {
4169
- sendJSON(res, 400, { error: '"minutes" must be between 1 and 120' });
4170
- return;
4171
- }
4172
- await startSession(config, {
4173
- id,
4174
- title: item.title,
4175
- minutes: requested,
4176
- advancesObjective: item.advancesObjective === true
4177
- });
4178
- } else {
4179
- sendJSON(res, 400, { error: '"action" must be "start" or "stop"' });
5712
+ if (refused2) {
5713
+ sendJSON(res, 409, { error: refused2 });
4180
5714
  return;
4181
5715
  }
4182
5716
  const state = await buildState();
4183
5717
  sendState(res, state);
4184
- scheduleSessionTick(state.session.active?.endsAt ?? null);
4185
5718
  void broadcast(state);
4186
5719
  return;
4187
5720
  }
5721
+ if (path === "/api/onboarding/sources" && req.method === "GET") {
5722
+ sendJSON(res, 200, await onboarding.previewSources());
5723
+ return;
5724
+ }
4188
5725
  if (path === "/api/settings" && req.method === "GET") {
4189
5726
  sendJSON(res, 200, { ...describeSettings(readSources()), restart });
4190
5727
  return;
@@ -4197,20 +5734,7 @@ data: ${JSON.stringify(await buildState())}
4197
5734
  sendJSON(res, 400, { error: '"values" must be an object of settings' });
4198
5735
  return;
4199
5736
  }
4200
- const saved = settingsWrites.then(async () => {
4201
- const sources = readSources();
4202
- const applied = applySettingsChange(sources, values);
4203
- if ("error" in applied) return applied.error;
4204
- const changed = JSON.stringify(Object.entries(applied.settings).sort()) !== JSON.stringify(Object.entries(sources.settings).sort());
4205
- if (changed) {
4206
- await saveSettings(sources.settingsPath, applied.settings);
4207
- requestRestart();
4208
- }
4209
- return null;
4210
- });
4211
- settingsWrites = saved.catch(() => {
4212
- });
4213
- const refused2 = await saved;
5737
+ const refused2 = await saveSettingsValues(values);
4214
5738
  if (refused2) {
4215
5739
  sendJSON(res, 400, { error: refused2 });
4216
5740
  return;
@@ -4250,8 +5774,8 @@ data: ${JSON.stringify(await buildState())}
4250
5774
  if (path === "/api/focus/objective" && req.method === "POST") {
4251
5775
  const body = await readJsonObject(req, res);
4252
5776
  if (!body) return;
4253
- const { objective, blocker } = body;
4254
- for (const [name, value] of [["objective", objective], ["blocker", blocker]]) {
5777
+ const { objective: objective2, blocker } = body;
5778
+ for (const [name, value] of [["objective", objective2], ["blocker", blocker]]) {
4255
5779
  if (value !== void 0 && value !== null && typeof value !== "string") {
4256
5780
  sendJSON(res, 400, { error: `"${name}" must be text, or null to clear it` });
4257
5781
  return;
@@ -4260,7 +5784,7 @@ data: ${JSON.stringify(await buildState())}
4260
5784
  const current = await readEditable(config.focusFile);
4261
5785
  const next = setFocusFields(
4262
5786
  current.text,
4263
- { objective: objective ?? null, blocker: blocker ?? null },
5787
+ { objective: objective2 ?? null, blocker: blocker ?? null },
4264
5788
  FOCUS_TEMPLATE
4265
5789
  );
4266
5790
  const result = await saveEditable(config.focusFile, next, current.version);
@@ -4277,10 +5801,10 @@ data: ${JSON.stringify(await buildState())}
4277
5801
  try {
4278
5802
  const facts = await runHelper(config.calendar.appPath, []);
4279
5803
  const seen = /* @__PURE__ */ new Set();
4280
- const calendars = facts.calendars.filter((entry) => !seen.has(`${entry.title}
5804
+ const calendars2 = facts.calendars.filter((entry) => !seen.has(`${entry.title}
4281
5805
  ${entry.source}`) && seen.add(`${entry.title}
4282
5806
  ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) => a.title.localeCompare(b.title));
4283
- sendJSON(res, 200, { calendars });
5807
+ sendJSON(res, 200, { calendars: calendars2 });
4284
5808
  } catch (err) {
4285
5809
  sendJSON(res, 409, { error: err.message });
4286
5810
  }
@@ -4312,14 +5836,14 @@ ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) =
4312
5836
  }
4313
5837
  async function stopEverything() {
4314
5838
  clearInterval(heartbeat);
4315
- clearInterval(presencePoll);
4316
5839
  clearInterval(agentClock);
4317
- if (sessionTick) clearTimeout(sessionTick);
5840
+ ahead.close();
4318
5841
  board.stop();
4319
5842
  calendar.stop();
4320
5843
  tickets.stop();
4321
5844
  await assistant.close();
4322
5845
  await agent.close();
5846
+ await onboarding.close();
4323
5847
  stopWatching();
4324
5848
  stopWatchingAssets();
4325
5849
  for (const res of subscribers) res.end();