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

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,12 @@
1
1
  import {
2
2
  ADAPTERS,
3
- Assistant
4
- } from "./chunk-GFCWPYEI.js";
3
+ Assistant,
4
+ AutoAsk,
5
+ CodexGate,
6
+ codexVersionWarning,
7
+ countBoard,
8
+ resolveBoard
9
+ } from "./chunk-ODJGQCM3.js";
5
10
  import {
6
11
  FOCUS_TEMPLATE,
7
12
  sourcesTemplate
@@ -9,22 +14,20 @@ import {
9
14
  import {
10
15
  TimeZoneKeeper,
11
16
  validZone
12
- } from "./chunk-AUBJ2IDQ.js";
17
+ } from "./chunk-Z4SFMMRQ.js";
13
18
  import {
14
19
  describeLink,
15
20
  linkStore,
16
21
  storeLinkWarning
17
22
  } from "./chunk-QKT2FAB5.js";
18
23
  import {
24
+ ACTION_TYPES,
19
25
  Store,
20
26
  canonicalId,
21
27
  foldActionLog,
22
- reconcileSession,
23
- startSession,
24
- stopSession,
25
28
  tempPathFor,
26
29
  writeJsonAtomic
27
- } from "./chunk-C6FXWHGT.js";
30
+ } from "./chunk-RZNQJ6UG.js";
28
31
  import {
29
32
  CLI_NAMES,
30
33
  LAUNCH_SETTINGS,
@@ -36,11 +39,10 @@ import {
36
39
  layerEnv,
37
40
  loadConfig,
38
41
  nextScheduledRun,
39
- parseISO,
40
42
  readDotEnv,
41
43
  resolveSchedule,
42
44
  scheduledRunDue
43
- } from "./chunk-NWYOCDUU.js";
45
+ } from "./chunk-J7ZYVUG6.js";
44
46
  import {
45
47
  PACKAGED,
46
48
  command,
@@ -497,9 +499,9 @@ function checkSlot(node) {
497
499
  return `check\0${node.name ?? ""}`;
498
500
  }
499
501
  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];
502
+ const run5 = node.checkSuite?.workflowRun;
503
+ const sequence = run5?.runNumber ?? run5?.databaseId;
504
+ if (typeof sequence === "number") return [sequence, run5?.runAttempt ?? 0];
503
505
  const at = node.completedAt ?? node.startedAt ?? node.createdAt ?? null;
504
506
  const time = at ? Date.parse(at) : Number.NaN;
505
507
  return [Number.isFinite(time) ? time : 0, 0];
@@ -815,6 +817,7 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
815
817
  for (const [nodeId, head] of checks.heads) {
816
818
  const pull = found.get(nodeId);
817
819
  if (!pull) continue;
820
+ pull.headSha = head.oid;
818
821
  applyChecks(pull, head.rollup);
819
822
  if (head.rollup && rollupTruncated(head.rollup)) {
820
823
  partial.push({ id: nodeId, pull, oid: head.oid, rollup: head.rollup });
@@ -869,131 +872,84 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
869
872
  }
870
873
  return { login, pulls, warnings, orgs, rateLimitRemaining };
871
874
  }
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;
875
+ var TICKET_PAGE_SIZE = 25;
876
+ var TICKET_MAX_PAGES = 12;
877
+ var TICKET_COMMITS = 100;
878
+ var TICKET_SEARCH_QUERY = `
879
+ query($q: String!, $first: Int!, $after: String) {
880
+ search(query: $q, type: ISSUE, first: $first, after: $after) {
881
+ pageInfo { hasNextPage endCursor }
882
+ nodes {
883
+ ... on PullRequest {
884
+ number
885
+ title
886
+ url
887
+ state
888
+ isDraft
889
+ closedAt
890
+ mergedAt
891
+ headRefName
892
+ body
893
+ repository { nameWithOwner }
894
+ commits(first: ${TICKET_COMMITS}) { nodes { commit { message } } }
895
+ }
896
+ }
897
+ }
898
+ }`;
899
+ function buildTicketSearchQueries(scope, closedSince) {
900
+ return [
901
+ ["is:pr", "author:@me", "is:open", ...scope].join(" "),
902
+ ["is:pr", "author:@me", "is:closed", `closed:>=${closedSince}`, ...scope].join(" ")
903
+ ];
883
904
  }
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";
905
+ function toLinkedState(state, isDraft) {
906
+ if (state === "MERGED") return "merged";
907
+ if (state === "CLOSED") return "closed";
908
+ if (state === "OPEN") return isDraft === true ? "draft" : "open";
888
909
  return null;
889
910
  }
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 };
911
+ function normalizeTicketPull(raw) {
912
+ const repo = raw.repository?.nameWithOwner;
913
+ const url = safeUrl(raw.url);
914
+ const state = toLinkedState(raw.state, raw.isDraft);
915
+ if (typeof raw.number !== "number" || typeof repo !== "string" || typeof raw.title !== "string" || !url || !state) {
916
+ return null;
917
+ }
918
+ const closedAt = state === "merged" ? raw.mergedAt ?? raw.closedAt ?? null : state === "closed" ? raw.closedAt ?? null : null;
919
+ const text = [raw.title, raw.headRefName ?? "", raw.body ?? ""];
920
+ for (const node of raw.commits?.nodes ?? []) {
921
+ const message = node?.commit?.message;
922
+ if (typeof message === "string") text.push(message);
923
+ }
924
+ return { pull: { repo, number: raw.number, title: raw.title, url, state, closedAt }, text };
925
+ }
926
+ async function fetchTicketPulls(token, scope, closedSince, signal) {
927
+ const pulls = [];
928
+ let complete = true;
929
+ for (const q of buildTicketSearchQueries(scope, closedSince)) {
930
+ let after = null;
931
+ for (let page = 0; ; page++) {
932
+ const result = await graphql(token, TICKET_SEARCH_QUERY, { q, first: TICKET_PAGE_SIZE, after }, signal);
933
+ const fatal = result.errors.filter((error) => !isSamlError(error));
934
+ if (result.data?.search === void 0 && fatal.length > 0) {
935
+ throw new GitHubRequestError(`GitHub search failed: ${fatal.map((error) => error.message).join("; ")}`);
949
936
  }
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);
937
+ if (result.errors.length > 0) complete = false;
938
+ for (const node of result.data?.search?.nodes ?? []) {
939
+ const found = node ? normalizeTicketPull(node) : null;
940
+ if (found) pulls.push(found);
941
+ else complete = false;
942
+ }
943
+ const pageInfo = result.data?.search?.pageInfo;
944
+ if (!pageInfo?.hasNextPage || !pageInfo.endCursor) break;
945
+ if (page === TICKET_MAX_PAGES - 1) {
946
+ complete = false;
947
+ break;
956
948
  }
949
+ after = pageInfo.endCursor;
957
950
  }
958
951
  }
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;
952
+ return { pulls, complete };
997
953
  }
998
954
 
999
955
  // src/board.ts
@@ -1357,6 +1313,9 @@ function normalizeStoredPull(raw) {
1357
1313
  autoMerge: raw.autoMerge === true,
1358
1314
  requestedReviewers: strings(raw.requestedReviewers),
1359
1315
  reviews,
1316
+ // Kept, or a restart would hand the assistant's answers ahead about failing
1317
+ // checks a key the live read doesn't, and they would be asked again.
1318
+ headSha: typeof raw.headSha === "string" ? raw.headSha : null,
1360
1319
  lastActivityByYou: typeof raw.lastActivityByYou === "string" ? raw.lastActivityByYou : null,
1361
1320
  lastActivityByOthers
1362
1321
  };
@@ -1692,6 +1651,87 @@ import { readFile as readFile5 } from "node:fs/promises";
1692
1651
  // src/jira.ts
1693
1652
  import { execFile as execFile3 } from "node:child_process";
1694
1653
  import { promisify as promisify3 } from "node:util";
1654
+
1655
+ // src/linked.ts
1656
+ var CLOSED_LOOKBACK_DAYS = 60;
1657
+ var KEYISH = /(?<![A-Za-z0-9])([A-Za-z][A-Za-z0-9_]*)-(\d+)/g;
1658
+ function issueKeysIn(text) {
1659
+ const keys = /* @__PURE__ */ new Set();
1660
+ for (const match of text.matchAll(KEYISH)) {
1661
+ const parts = match[1].split("_");
1662
+ for (let i = 0; i < parts.length; i++) {
1663
+ const project = parts.slice(i).join("_");
1664
+ if (/^[A-Za-z]/.test(project)) keys.add(`${project.toUpperCase()}-${match[2]}`);
1665
+ }
1666
+ }
1667
+ return keys;
1668
+ }
1669
+ var realDeps3 = { token: ghToken, search: fetchTicketPulls };
1670
+ var CONSEQUENCE = "so a merged pull request counts only once Jira has it";
1671
+ async function readLinkedPulls(github, now, signal, deps = realDeps3) {
1672
+ const closedSince = new Date(now.getTime() - CLOSED_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
1673
+ const logins = github.accounts.length > 0 ? [...github.accounts] : [null];
1674
+ const results = await Promise.allSettled(
1675
+ logins.map(async (login) => deps.search(await deps.token(github.ghPath, login), github.scope, closedSince, signal))
1676
+ );
1677
+ if (results.every((result) => result.status === "rejected")) {
1678
+ throw results[0].reason;
1679
+ }
1680
+ const warnings = [];
1681
+ let complete = true;
1682
+ const byKey = /* @__PURE__ */ new Map();
1683
+ const seen = /* @__PURE__ */ new Set();
1684
+ results.forEach((result, i) => {
1685
+ const who = logins[i] ? ` as ${logins[i]}` : "";
1686
+ if (result.status === "rejected") {
1687
+ complete = false;
1688
+ warnings.push(`Could not ask GitHub${who} about these tickets' pull requests (${result.reason.message}), ${CONSEQUENCE}.`);
1689
+ return;
1690
+ }
1691
+ if (!result.value.complete) {
1692
+ complete = false;
1693
+ warnings.push(`GitHub did not list every pull request${who}, ${CONSEQUENCE}.`);
1694
+ }
1695
+ for (const { pull, text } of result.value.pulls) {
1696
+ if (seen.has(pull.url)) continue;
1697
+ seen.add(pull.url);
1698
+ const keys = /* @__PURE__ */ new Set();
1699
+ for (const piece of text) for (const key of issueKeysIn(piece)) keys.add(key);
1700
+ for (const key of keys) byKey.set(key, [...byKey.get(key) ?? [], pull]);
1701
+ }
1702
+ });
1703
+ return { byKey, complete, warnings };
1704
+ }
1705
+ function isOpen(pull) {
1706
+ return pull.state === "open" || pull.state === "draft";
1707
+ }
1708
+ function byRecency(a, b) {
1709
+ if (isOpen(a) !== isOpen(b)) return isOpen(a) ? -1 : 1;
1710
+ return (b.closedAt ?? "").localeCompare(a.closedAt ?? "");
1711
+ }
1712
+ function linkedTo(ticket, linked) {
1713
+ return linked?.byKey.get(ticket.key.toUpperCase()) ?? [];
1714
+ }
1715
+ function needsJiraCount(ticket, linked) {
1716
+ const found = linkedTo(ticket, linked);
1717
+ return Boolean(linked?.complete) && found.length > 0 && !found.some(isOpen) && ticket.hasAnyPr && !ticket.allPrsClosed;
1718
+ }
1719
+ function reconcile(ticket, linked, jiraCount) {
1720
+ const found = [...linkedTo(ticket, linked)].sort(byRecency);
1721
+ if (found.length === 0) return ticket;
1722
+ const jira = { hasAnyPr: ticket.hasAnyPr, hasOpenPr: ticket.hasOpenPr, allPrsClosed: ticket.allPrsClosed };
1723
+ let reading = jira;
1724
+ if (found.some(isOpen)) {
1725
+ reading = { hasAnyPr: true, hasOpenPr: true, allPrsClosed: false };
1726
+ } else if (linked?.complete && !ticket.allPrsClosed) {
1727
+ const known = ticket.hasAnyPr ? jiraCount : 0;
1728
+ if (known !== null && found.length >= known) reading = { hasAnyPr: true, hasOpenPr: false, allPrsClosed: true };
1729
+ }
1730
+ const changed = reading.hasAnyPr !== jira.hasAnyPr || reading.hasOpenPr !== jira.hasOpenPr || reading.allPrsClosed !== jira.allPrsClosed;
1731
+ return { ...ticket, ...reading, pulls: found, ...changed ? { fromGitHub: true } : {} };
1732
+ }
1733
+
1734
+ // src/jira.ts
1695
1735
  var run3 = promisify3(execFile3);
1696
1736
  var ACLI_TIMEOUT_MS = 45e3;
1697
1737
  var PAGE_LIMIT = 200;
@@ -1975,22 +2015,23 @@ async function fetchTickets(acliPath, opts) {
1975
2015
  if (unreadable > 0) {
1976
2016
  warnings.push(`Skipped ${unreadable} work ${unreadable === 1 ? "item" : "items"} Jira described in a way this board could not read.`);
1977
2017
  }
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
- )
2018
+ const readPanel = (ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
2019
+ (dev) => ({ asked: true, dev }),
2020
+ () => ({ asked: false, dev: null })
1986
2021
  );
2022
+ const candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
2023
+ const reading = mapLimit(candidates, DEV_CONCURRENCY, readPanel);
2024
+ const linked = await opts.linked ?? null;
2025
+ const panels = await reading;
2026
+ const counts = /* @__PURE__ */ new Map();
1987
2027
  const unanswered = [];
1988
2028
  const unaccounted = [];
1989
2029
  candidates.forEach((ticket, i) => {
1990
- const { asked, dev } = panels[i];
2030
+ const { asked: asked2, dev } = panels[i];
2031
+ if (dev) counts.set(ticket.key, dev.count);
1991
2032
  if (allPullRequestsClosed(dev)) {
1992
2033
  ticket.allPrsClosed = true;
1993
- } else if (!asked) {
2034
+ } else if (!asked2) {
1994
2035
  unanswered.push(ticket.key);
1995
2036
  } else if (dev === null) {
1996
2037
  unaccounted.push(ticket.key);
@@ -1998,19 +2039,30 @@ async function fetchTickets(acliPath, opts) {
1998
2039
  ticket.hasOpenPr = true;
1999
2040
  }
2000
2041
  });
2042
+ const asked = new Set(candidates);
2043
+ const behind = tickets.filter((ticket) => !asked.has(ticket) && needsJiraCount(ticket, linked));
2044
+ const extra = await mapLimit(behind, DEV_CONCURRENCY, readPanel);
2045
+ behind.forEach((ticket, i) => {
2046
+ const dev = extra[i].dev;
2047
+ if (dev) counts.set(ticket.key, dev.count);
2048
+ });
2049
+ const reconciled = tickets.map((ticket) => reconcile(ticket, linked, counts.get(ticket.key) ?? null));
2050
+ const undecided = new Set(reconciled.filter((ticket) => !ticket.hasOpenPr && !ticket.allPrsClosed).map((ticket) => ticket.key));
2051
+ const unansweredNow = unanswered.filter((key) => undecided.has(key));
2052
+ const unaccountedNow = unaccounted.filter((key) => undecided.has(key));
2001
2053
  const listed = (keys2) => keys2.map((key) => {
2002
2054
  const url = browseUrl(opts.site, key);
2003
2055
  return url === null ? key : `[${key}](${url})`;
2004
2056
  }).join(", ");
2005
2057
  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) {
2058
+ if (unansweredNow.length > 0) {
2007
2059
  warnings.push(
2008
- `Could not read Jira's development panel for ${listed(unanswered)}, so ${heldOut(unanswered)}. The next read may well manage it.`
2060
+ `Could not read Jira's development panel for ${listed(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
2009
2061
  );
2010
2062
  }
2011
- if (unaccounted.length > 0) {
2063
+ if (unaccountedNow.length > 0) {
2012
2064
  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.`
2065
+ `Jira's search says ${listed(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
2066
  );
2015
2067
  }
2016
2068
  if (!opts.site) {
@@ -2027,7 +2079,7 @@ async function fetchTickets(acliPath, opts) {
2027
2079
  );
2028
2080
  }
2029
2081
  return {
2030
- tickets,
2082
+ tickets: reconciled,
2031
2083
  statuses: statusesByProject(base, ...finished.map((batch) => batch ?? [])),
2032
2084
  warnings
2033
2085
  };
@@ -2099,16 +2151,16 @@ function readTransitionReport(stdout, key, status) {
2099
2151
  }
2100
2152
 
2101
2153
  // src/tickets.ts
2102
- var COURT_ORDER2 = ["settled", "started", "idle"];
2154
+ var COURT_ORDER = ["settled", "started", "idle"];
2103
2155
  function ticketCourtOrder(court) {
2104
- return COURT_ORDER2.indexOf(court);
2156
+ return COURT_ORDER.indexOf(court);
2105
2157
  }
2106
2158
  function inStatus(ticket, statuses) {
2107
2159
  const status = ticket.workflowStatus.trim().toLowerCase();
2108
2160
  if (status === "") return false;
2109
2161
  return statuses.some((listed) => listed.trim().toLowerCase() === status);
2110
2162
  }
2111
- function judge2(ticket, holdStatuses = []) {
2163
+ function judge(ticket, holdStatuses = []) {
2112
2164
  if (ticket.statusCategory === "done") return null;
2113
2165
  if (ticket.hasAnyPr && !ticket.hasOpenPr && ticket.allPrsClosed) return "settled";
2114
2166
  if (ticket.statusCategory === "new" && ticket.hasOpenPr) return "started";
@@ -2119,7 +2171,7 @@ function resolveTickets(tickets, actions, now, holdStatuses = []) {
2119
2171
  const folded = foldActionLog(actions, now);
2120
2172
  const rows = [];
2121
2173
  for (const ticket of tickets) {
2122
- const court = judge2(ticket, holdStatuses);
2174
+ const court = judge(ticket, holdStatuses);
2123
2175
  if (!court) continue;
2124
2176
  const log = folded.get(canonicalId(ticket.id));
2125
2177
  const row = {
@@ -2148,7 +2200,13 @@ function countTickets(rows) {
2148
2200
 
2149
2201
  // src/ticketboard.ts
2150
2202
  var MAX_BACKOFF_MS3 = 30 * 6e4;
2151
- var realDeps3 = { identity: acliIdentity, fetch: fetchTickets, transition: transitionTicket };
2203
+ var GITHUB_DEADLINE_MS = 9e4;
2204
+ var realDeps4 = {
2205
+ identity: acliIdentity,
2206
+ fetch: fetchTickets,
2207
+ transition: transitionTicket,
2208
+ linked: (github, now, signal) => readLinkedPulls(github, now, signal)
2209
+ };
2152
2210
  var TicketBoard = class {
2153
2211
  #config;
2154
2212
  #deps;
@@ -2165,7 +2223,8 @@ var TicketBoard = class {
2165
2223
  #audience = 0;
2166
2224
  #stopped = false;
2167
2225
  #lastReadAt = 0;
2168
- constructor(config, onChange, deps = realDeps3) {
2226
+ #abort = new AbortController();
2227
+ constructor(config, onChange, deps = realDeps4) {
2169
2228
  this.#config = config;
2170
2229
  this.#deps = deps;
2171
2230
  this.#onChange = onChange;
@@ -2179,10 +2238,12 @@ var TicketBoard = class {
2179
2238
  this.#file = await readTicketsFile(this.#config.ticketsFile);
2180
2239
  await this.refresh();
2181
2240
  }
2241
+ /** Stop reading, and cancel a GitHub request in flight so nothing lands after close. */
2182
2242
  stop() {
2183
2243
  this.#stopped = true;
2184
2244
  if (this.#timer) clearTimeout(this.#timer);
2185
2245
  this.#timer = null;
2246
+ this.#abort.abort();
2186
2247
  }
2187
2248
  /** How many browsers are watching. Polling pauses at zero. */
2188
2249
  setAudience(count) {
@@ -2297,8 +2358,24 @@ var TicketBoard = class {
2297
2358
  return;
2298
2359
  }
2299
2360
  const site = configuredSite ?? identity2.site;
2361
+ const githubWarnings = [];
2362
+ 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(
2363
+ (read) => {
2364
+ githubWarnings.push(...read.warnings);
2365
+ return read;
2366
+ },
2367
+ (err) => {
2368
+ githubWarnings.push(
2369
+ `Could not ask GitHub about these tickets' pull requests (${err.message}), so they are as Jira has them, which can be days behind.`
2370
+ );
2371
+ return null;
2372
+ }
2373
+ ) : Promise.resolve(null);
2300
2374
  try {
2301
- const { tickets, statuses, warnings } = await this.#deps.fetch(acliPath, { projects, site });
2375
+ const fetched = await this.#deps.fetch(acliPath, { projects, site, linked });
2376
+ await linked;
2377
+ const { tickets, statuses } = fetched;
2378
+ const warnings = [...fetched.warnings, ...githubWarnings];
2302
2379
  this.#reason = null;
2303
2380
  this.#warnings = warnings;
2304
2381
  this.#streak = null;
@@ -2334,7 +2411,7 @@ function normalizeStoredTicket(raw) {
2334
2411
  const { key, url, workflowStatus, statusCategory, issueType, summary } = raw;
2335
2412
  if (typeof key !== "string" || key === "") return null;
2336
2413
  const site = typeof url === "string" ? /^https:\/\/([^/]+)\/browse\//.exec(url)?.[1] ?? null : null;
2337
- return toTicket(
2414
+ const ticket = toTicket(
2338
2415
  {
2339
2416
  key,
2340
2417
  fields: {
@@ -2351,6 +2428,25 @@ function normalizeStoredTicket(raw) {
2351
2428
  // files are exactly the ones whose settled court counted drafts as merges.
2352
2429
  raw.allPrsClosed === true
2353
2430
  );
2431
+ if (!ticket) return null;
2432
+ const pulls = storedPulls(raw.pulls);
2433
+ if (pulls.length > 0) {
2434
+ ticket.pulls = pulls;
2435
+ if (raw.fromGitHub === true) ticket.fromGitHub = true;
2436
+ }
2437
+ return ticket;
2438
+ }
2439
+ var PULL_STATES = ["open", "draft", "merged", "closed"];
2440
+ function storedPulls(value) {
2441
+ if (!Array.isArray(value)) return [];
2442
+ return value.flatMap((entry) => {
2443
+ if (!isRecord3(entry)) return [];
2444
+ const { repo, number, title, url, state, closedAt } = entry;
2445
+ if (typeof repo !== "string" || typeof number !== "number" || typeof title !== "string") return [];
2446
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return [];
2447
+ if (typeof state !== "string" || !PULL_STATES.includes(state)) return [];
2448
+ return [{ repo, number, title, url, state, closedAt: typeof closedAt === "string" ? closedAt : null }];
2449
+ });
2354
2450
  }
2355
2451
  async function readTicketsFile(path) {
2356
2452
  let text;
@@ -2451,11 +2547,11 @@ function foldAgentLog(text) {
2451
2547
  });
2452
2548
  continue;
2453
2549
  }
2454
- const run6 = runs.get(record.run);
2455
- if (!run6) continue;
2550
+ const run5 = runs.get(record.run);
2551
+ if (!run5) continue;
2456
2552
  if (record.event === "asked") {
2457
2553
  if (typeof record.turn !== "string" || typeof record.question !== "string") continue;
2458
- run6.turns.push({
2554
+ run5.turns.push({
2459
2555
  id: record.turn,
2460
2556
  question: record.question,
2461
2557
  startedAt: record.at,
@@ -2467,7 +2563,7 @@ function foldAgentLog(text) {
2467
2563
  continue;
2468
2564
  }
2469
2565
  if (record.event === "replied" || record.event === "turn-failed" || record.event === "turn-aborted") {
2470
- const turn = run6.turns.find((candidate) => candidate.id === record.turn);
2566
+ const turn = run5.turns.find((candidate) => candidate.id === record.turn);
2471
2567
  if (!turn) continue;
2472
2568
  turn.reply = typeof record.reply === "string" ? record.reply : turn.reply;
2473
2569
  turn.endedAt = record.at;
@@ -2482,18 +2578,18 @@ function foldAgentLog(text) {
2482
2578
  }
2483
2579
  continue;
2484
2580
  }
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;
2581
+ run5.sessionId = typeof record.sessionId === "string" ? record.sessionId : run5.sessionId;
2582
+ run5.report = typeof record.report === "string" ? record.report : run5.report;
2583
+ run5.messages = Array.isArray(record.messages) ? record.messages.filter((message) => typeof message === "string") : run5.report ? [run5.report] : [];
2584
+ run5.endedAt = record.at;
2489
2585
  if (record.event === "finished") {
2490
- run6.status = "done";
2586
+ run5.status = "done";
2491
2587
  } else if (record.event === "failed") {
2492
- run6.status = "failed";
2493
- run6.error = record.error;
2588
+ run5.status = "failed";
2589
+ run5.error = record.error;
2494
2590
  } else if (record.event === "aborted") {
2495
- run6.status = "aborted";
2496
- run6.error = record.reason;
2591
+ run5.status = "aborted";
2592
+ run5.error = record.reason;
2497
2593
  }
2498
2594
  }
2499
2595
  return [...runs.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
@@ -2506,10 +2602,13 @@ var AgentRunner = class {
2506
2602
  config;
2507
2603
  hooks;
2508
2604
  spawnFn;
2509
- constructor(config, hooks, spawnFn = spawn) {
2605
+ /** Refuses a Codex too old to hold to the connector rules; see `codex.ts`. */
2606
+ codexGate;
2607
+ constructor(config, hooks, spawnFn = spawn, execFn) {
2510
2608
  this.config = config;
2511
2609
  this.hooks = hooks;
2512
2610
  this.spawnFn = spawnFn;
2611
+ this.codexGate = new CodexGate(config.agent.binPath, execFn);
2513
2612
  }
2514
2613
  get enabled() {
2515
2614
  return this.config.agent.cli !== null;
@@ -2530,19 +2629,19 @@ var AgentRunner = class {
2530
2629
  this.runs = foldAgentLog(text);
2531
2630
  const at = (/* @__PURE__ */ new Date()).toISOString();
2532
2631
  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 });
2632
+ for (const run5 of this.runs) {
2633
+ if (run5.status === "running") {
2634
+ run5.status = "aborted";
2635
+ run5.error = reason;
2636
+ run5.endedAt = at;
2637
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason, report: run5.report, messages: run5.messages, at });
2539
2638
  }
2540
- for (const turn of run6.turns) {
2639
+ for (const turn of run5.turns) {
2541
2640
  if (turn.status !== "running") continue;
2542
2641
  turn.status = "aborted";
2543
2642
  turn.error = reason;
2544
2643
  turn.endedAt = at;
2545
- await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason, reply: turn.reply, at });
2644
+ await this.append({ event: "turn-aborted", run: run5.id, turn: turn.id, reason, reply: turn.reply, at });
2546
2645
  }
2547
2646
  }
2548
2647
  }
@@ -2555,7 +2654,7 @@ var AgentRunner = class {
2555
2654
  return this.runs.at(-1) ?? null;
2556
2655
  }
2557
2656
  view(now = /* @__PURE__ */ new Date()) {
2558
- const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run6) => ({ ...run6, resumable: this.resumable(run6) }));
2657
+ const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run5) => ({ ...run5, resumable: this.resumable(run5) }));
2559
2658
  return {
2560
2659
  enabled: this.enabled,
2561
2660
  cli: this.config.agent.cli,
@@ -2609,7 +2708,7 @@ var AgentRunner = class {
2609
2708
  );
2610
2709
  }
2611
2710
  await this.start();
2612
- const run6 = {
2711
+ const run5 = {
2613
2712
  id: randomUUID(),
2614
2713
  cli,
2615
2714
  trigger,
@@ -2623,8 +2722,8 @@ var AgentRunner = class {
2623
2722
  turns: [],
2624
2723
  resumable: false
2625
2724
  };
2626
- this.runs.push(run6);
2627
- await this.append({ event: "started", run: run6.id, cli, trigger, at: run6.startedAt });
2725
+ this.runs.push(run5);
2726
+ await this.append({ event: "started", run: run5.id, cli, trigger, at: run5.startedAt });
2628
2727
  const adapter = ADAPTERS[cli];
2629
2728
  const args = adapter.args({
2630
2729
  prompt: agentPrompt(displayPath(this.config.promptFile)),
@@ -2632,10 +2731,12 @@ var AgentRunner = class {
2632
2731
  model: this.config.agent.model,
2633
2732
  effort: this.config.agent.effort,
2634
2733
  tools: [...briefWritingTools(), ...this.config.agent.tools],
2635
- denyEdits: false
2734
+ denyEdits: false,
2735
+ drafts: false,
2736
+ codexConfig: []
2636
2737
  });
2637
- await this.spawnInto(run6, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
2638
- return run6;
2738
+ await this.spawnInto(run5, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
2739
+ return run5;
2639
2740
  }
2640
2741
  /**
2641
2742
  * Ask a finished run a question, in its own session. Returns once the process
@@ -2645,16 +2746,16 @@ var AgentRunner = class {
2645
2746
  const cli = this.config.agent.cli;
2646
2747
  if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2647
2748
  await this.start();
2648
- const run6 = this.runs.find((candidate) => candidate.id === runId);
2649
- if (!run6) throw new Error("no such run");
2749
+ const run5 = this.runs.find((candidate) => candidate.id === runId);
2750
+ if (!run5) throw new Error("no such run");
2650
2751
  if (this.running) {
2651
2752
  throw new Error(
2652
2753
  this.running.turn ? "the morning agent is still answering a question" : "the morning agent is still running"
2653
2754
  );
2654
2755
  }
2655
- if (!this.resumable(run6)) {
2756
+ if (!this.resumable(run5)) {
2656
2757
  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`
2758
+ run5.sessionId === null ? "this run left no session to continue" : `this run was made by ${run5.cli}, and the agent is ${cli} now`
2658
2759
  );
2659
2760
  }
2660
2761
  const text = question.trim();
@@ -2668,19 +2769,21 @@ var AgentRunner = class {
2668
2769
  reply: "",
2669
2770
  error: null
2670
2771
  };
2671
- run6.turns.push(turn);
2672
- await this.append({ event: "asked", run: run6.id, turn: turn.id, question: text, at: turn.startedAt });
2772
+ run5.turns.push(turn);
2773
+ await this.append({ event: "asked", run: run5.id, turn: turn.id, question: text, at: turn.startedAt });
2673
2774
  const adapter = ADAPTERS[cli];
2674
2775
  const args = adapter.args({
2675
2776
  prompt: followUpPrompt(text),
2676
- sessionId: run6.sessionId,
2777
+ sessionId: run5.sessionId,
2677
2778
  model: this.config.agent.model,
2678
2779
  effort: this.config.agent.effort,
2679
2780
  // The sources, and not the brief: a chat is given nothing to write.
2680
2781
  tools: this.config.agent.tools,
2681
- denyEdits: true
2782
+ denyEdits: true,
2783
+ drafts: false,
2784
+ codexConfig: []
2682
2785
  });
2683
- await this.spawnInto(run6, turn, args, TURN_TIMEOUT_MS, "gave up after ten minutes");
2786
+ await this.spawnInto(run5, turn, args, TURN_TIMEOUT_MS, "gave up after ten minutes");
2684
2787
  return turn;
2685
2788
  }
2686
2789
  /**
@@ -2688,10 +2791,15 @@ var AgentRunner = class {
2688
2791
  * run when there is none. The store in both cases: Claude Code files a
2689
2792
  * session under the directory it was started in, and looks for it there.
2690
2793
  */
2691
- async spawnInto(run6, turn, args, timeoutMs, timeoutReason) {
2692
- const cli = run6.cli;
2794
+ async spawnInto(run5, turn, args, timeoutMs, timeoutReason) {
2795
+ const cli = run5.cli;
2693
2796
  const adapter = ADAPTERS[cli];
2694
2797
  await mkdir(this.config.dataDir, { recursive: true });
2798
+ const refusal2 = cli === "codex" ? await this.codexGate.refusal() : null;
2799
+ if (refusal2) {
2800
+ await this.finish(run5, turn, { ok: false, error: refusal2 });
2801
+ return;
2802
+ }
2695
2803
  let child;
2696
2804
  try {
2697
2805
  child = this.spawnFn(this.config.agent.binPath, args, {
@@ -2701,12 +2809,12 @@ var AgentRunner = class {
2701
2809
  env: process.env
2702
2810
  });
2703
2811
  } catch (err) {
2704
- await this.finish(run6, turn, { ok: false, error: this.spawnError(err) });
2812
+ await this.finish(run5, turn, { ok: false, error: this.spawnError(err) });
2705
2813
  return;
2706
2814
  }
2707
2815
  const timer = setTimeout(() => void this.stop(timeoutReason), timeoutMs);
2708
2816
  timer.unref();
2709
- const running = { run: run6, turn, child, timer, abortReason: null };
2817
+ const running = { run: run5, turn, child, timer, abortReason: null };
2710
2818
  this.running = running;
2711
2819
  this.hooks.onChange();
2712
2820
  let done;
@@ -2720,15 +2828,15 @@ var AgentRunner = class {
2720
2828
  lines.on("line", (line) => {
2721
2829
  const event = adapter.parse(line);
2722
2830
  if (!event) return;
2723
- if (event.sessionId && !turn) run6.sessionId = event.sessionId;
2831
+ if (event.sessionId && !turn) run5.sessionId = event.sessionId;
2724
2832
  if (event.text) {
2725
2833
  if (turn) {
2726
2834
  turn.reply = turn.reply ? `${turn.reply}
2727
2835
 
2728
2836
  ${event.text}` : event.text;
2729
2837
  } else {
2730
- run6.messages.push(event.text);
2731
- run6.report = event.text;
2838
+ run5.messages.push(event.text);
2839
+ run5.report = event.text;
2732
2840
  }
2733
2841
  this.hooks.onChange();
2734
2842
  }
@@ -2745,7 +2853,7 @@ ${event.text}` : event.text;
2745
2853
  ok: false,
2746
2854
  error: `${cli} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
2747
2855
  };
2748
- void this.finish(run6, turn, outcome, running.abortReason !== null);
2856
+ void this.finish(run5, turn, outcome, running.abortReason !== null);
2749
2857
  });
2750
2858
  }
2751
2859
  /**
@@ -2762,45 +2870,45 @@ ${event.text}` : event.text;
2762
2870
  async close() {
2763
2871
  await this.stop("the dashboard shut down");
2764
2872
  }
2765
- resumable(run6) {
2766
- return run6.status !== "running" && run6.sessionId !== null && run6.cli === this.config.agent.cli;
2873
+ resumable(run5) {
2874
+ return run5.status !== "running" && run5.sessionId !== null && run5.cli === this.config.agent.cli;
2767
2875
  }
2768
- async finish(run6, turn, outcome, aborted = false) {
2876
+ async finish(run5, turn, outcome, aborted = false) {
2769
2877
  const at = (/* @__PURE__ */ new Date()).toISOString();
2770
2878
  if (turn) {
2771
2879
  const reply = outcome.reply ?? turn.reply;
2772
2880
  turn.reply = reply;
2773
2881
  turn.endedAt = at;
2774
2882
  if (outcome.ok) {
2775
- await this.append({ event: "replied", run: run6.id, turn: turn.id, reply, at });
2883
+ await this.append({ event: "replied", run: run5.id, turn: turn.id, reply, at });
2776
2884
  turn.status = "done";
2777
2885
  } else if (aborted) {
2778
2886
  turn.error = outcome.error ?? "stopped";
2779
- await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason: turn.error, reply, at });
2887
+ await this.append({ event: "turn-aborted", run: run5.id, turn: turn.id, reason: turn.error, reply, at });
2780
2888
  turn.status = "aborted";
2781
2889
  } else {
2782
2890
  turn.error = outcome.error ?? "failed";
2783
- await this.append({ event: "turn-failed", run: run6.id, turn: turn.id, error: turn.error, reply, at });
2891
+ await this.append({ event: "turn-failed", run: run5.id, turn: turn.id, error: turn.error, reply, at });
2784
2892
  turn.status = "failed";
2785
2893
  }
2786
2894
  this.hooks.onChange();
2787
2895
  return;
2788
2896
  }
2789
- const report = outcome.reply ?? run6.report;
2790
- run6.report = report;
2791
- run6.endedAt = at;
2792
- const messages = run6.messages;
2897
+ const report = outcome.reply ?? run5.report;
2898
+ run5.report = report;
2899
+ run5.endedAt = at;
2900
+ const messages = run5.messages;
2793
2901
  if (outcome.ok) {
2794
- await this.append({ event: "finished", run: run6.id, sessionId: run6.sessionId, report, messages, at });
2795
- run6.status = "done";
2902
+ await this.append({ event: "finished", run: run5.id, sessionId: run5.sessionId, report, messages, at });
2903
+ run5.status = "done";
2796
2904
  } 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";
2905
+ run5.error = outcome.error ?? "stopped";
2906
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason: run5.error, report, messages, at });
2907
+ run5.status = "aborted";
2800
2908
  } 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";
2909
+ run5.error = outcome.error ?? "failed";
2910
+ await this.append({ event: "failed", run: run5.id, sessionId: run5.sessionId, error: run5.error, report, messages, at });
2911
+ run5.status = "failed";
2804
2912
  }
2805
2913
  this.hooks.onChange();
2806
2914
  }
@@ -2914,33 +3022,6 @@ function refusal(req, listenHost) {
2914
3022
  return null;
2915
3023
  }
2916
3024
 
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
2930
- });
2931
- return parseIdleSeconds(stdout);
2932
- } catch {
2933
- return null;
2934
- }
2935
- };
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;
2942
- }
2943
-
2944
3025
  // src/settings.ts
2945
3026
  var SETTING_GROUPS = [
2946
3027
  { id: "instance", title: "This dashboard" },
@@ -2949,8 +3030,7 @@ var SETTING_GROUPS = [
2949
3030
  { id: "github", title: "Pull requests" },
2950
3031
  { id: "jira", title: "Jira tickets" },
2951
3032
  { id: "calendar", title: "Calendar" },
2952
- { id: "day", title: "Working day" },
2953
- { id: "sessions", title: "Focus sessions" }
3033
+ { id: "day", title: "Working day" }
2954
3034
  ];
2955
3035
  var OFF_OR_CLI = [
2956
3036
  { value: "off", label: "Off" },
@@ -2964,7 +3044,7 @@ var SETTINGS = [
2964
3044
  key: "DAILY_FOCUS_PROFILE",
2965
3045
  group: "instance",
2966
3046
  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.",
3047
+ help: "Work or home. Picks the morning prompt and the defaults below: the personal profile switches off Jira and free time.",
2968
3048
  kind: "choice",
2969
3049
  choices: PROFILES.map((value) => ({ value, label: value === "work" ? "Work" : "Personal life" })),
2970
3050
  fallback: always("Work")
@@ -3015,7 +3095,7 @@ var SETTINGS = [
3015
3095
  key: "DAILY_FOCUS_AGENT_TOOLS",
3016
3096
  group: "agent",
3017
3097
  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.",
3098
+ 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
3099
  kind: "list",
3020
3100
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3021
3101
  advanced: true
@@ -3042,7 +3122,7 @@ var SETTINGS = [
3042
3122
  key: "DAILY_FOCUS_ASSISTANT",
3043
3123
  group: "assistant",
3044
3124
  label: "Answer questions with",
3045
- help: "Puts an assistant in every item's panel. It reads and drafts, and never sends, posts or edits.",
3125
+ 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
3126
  kind: "choice",
3047
3127
  choices: OFF_OR_CLI,
3048
3128
  fallback: always("Off")
@@ -3068,11 +3148,36 @@ var SETTINGS = [
3068
3148
  key: "DAILY_FOCUS_ASSISTANT_TOOLS",
3069
3149
  group: "assistant",
3070
3150
  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.",
3151
+ 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
3152
  kind: "list",
3073
3153
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3074
3154
  advanced: true
3075
3155
  },
3156
+ {
3157
+ key: "DAILY_FOCUS_ASSISTANT_COMPUTER_USE",
3158
+ group: "assistant",
3159
+ label: "Computer use",
3160
+ 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.",
3161
+ kind: "flag",
3162
+ fallback: always("Off")
3163
+ },
3164
+ {
3165
+ key: "DAILY_FOCUS_ASSISTANT_AUTO",
3166
+ group: "assistant",
3167
+ label: "Answer ahead on pull requests",
3168
+ 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.",
3169
+ kind: "flag",
3170
+ fallback: always("Off")
3171
+ },
3172
+ {
3173
+ key: "DAILY_FOCUS_ASSISTANT_AUTO_LIMIT",
3174
+ group: "assistant",
3175
+ label: "Answer ahead at most",
3176
+ help: "Times a day the assistant may start on its own, counted on the dashboard\u2019s calendar.",
3177
+ kind: "number",
3178
+ fallback: always("10 a day"),
3179
+ advanced: true
3180
+ },
3076
3181
  {
3077
3182
  key: "DAILY_FOCUS_ASSISTANT_BIN",
3078
3183
  group: "assistant",
@@ -3086,7 +3191,7 @@ var SETTINGS = [
3086
3191
  key: "DAILY_FOCUS_GITHUB",
3087
3192
  group: "github",
3088
3193
  label: "Pull request board",
3089
- help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login.",
3194
+ 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
3195
  kind: "flag",
3091
3196
  fallback: always("On")
3092
3197
  },
@@ -3265,22 +3370,6 @@ var SETTINGS = [
3265
3370
  help: "Minutes a gap has to last to count as time for focused work.",
3266
3371
  kind: "number",
3267
3372
  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
3373
  }
3285
3374
  ];
3286
3375
  var BY_KEY = new Map(SETTINGS.map((def) => [def.key, def]));
@@ -3343,11 +3432,11 @@ async function saveSettings(path, settings) {
3343
3432
  }
3344
3433
 
3345
3434
  // src/setup.ts
3346
- import { execFile as execFile5 } from "node:child_process";
3435
+ import { execFile as execFile4 } from "node:child_process";
3347
3436
  import { access, constants, readdir as readdir2, readFile as readFile7, stat as stat3 } from "node:fs/promises";
3348
3437
  import { delimiter, isAbsolute, join as join3 } from "node:path";
3349
- import { promisify as promisify5 } from "node:util";
3350
- var run5 = promisify5(execFile5);
3438
+ import { promisify as promisify4 } from "node:util";
3439
+ var run4 = promisify4(execFile4);
3351
3440
  var PLACEHOLDERS = [
3352
3441
  "Your Name",
3353
3442
  "you@example.com",
@@ -3437,11 +3526,11 @@ var identity = null;
3437
3526
  function detectIdentity(config) {
3438
3527
  identity ??= (async () => {
3439
3528
  const [user, email] = await Promise.all([
3440
- run5(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
3529
+ run4(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
3441
3530
  ({ stdout }) => stdout.split("\n"),
3442
3531
  () => []
3443
3532
  ),
3444
- run5("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
3533
+ run4("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
3445
3534
  ({ stdout }) => stdout.trim() || null,
3446
3535
  () => null
3447
3536
  )
@@ -3490,8 +3579,8 @@ var defaultExec = (file, args, { timeout }) => new Promise((resolve2, reject) =>
3490
3579
  child.on("error", (error) => finish(error));
3491
3580
  child.on("close", () => finish(null));
3492
3581
  });
3493
- function shellCandidates(envShell, loginShell, platform2) {
3494
- const fallback = platform2 === "darwin" ? "/bin/zsh" : platform2 === "linux" ? "/bin/bash" : void 0;
3582
+ function shellCandidates(envShell, loginShell, platform) {
3583
+ const fallback = platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : void 0;
3495
3584
  const shells = [];
3496
3585
  for (const shell of [envShell, loginShell, fallback]) {
3497
3586
  const trimmed = shell?.trim();
@@ -3536,11 +3625,11 @@ function currentLoginShell() {
3536
3625
  return void 0;
3537
3626
  }
3538
3627
  }
3539
- async function hydratePath(env = process.env, platform2 = process.platform, exec = defaultExec, loginShell = currentLoginShell()) {
3540
- if (platform2 === "win32") return null;
3628
+ async function hydratePath(env = process.env, platform = process.platform, exec = defaultExec, loginShell = currentLoginShell()) {
3629
+ if (platform === "win32") return null;
3541
3630
  let found = null;
3542
3631
  let from = null;
3543
- for (const shell of shellCandidates(env.SHELL, loginShell, platform2)) {
3632
+ for (const shell of shellCandidates(env.SHELL, loginShell, platform)) {
3544
3633
  try {
3545
3634
  found = await readLoginShellPath(shell, exec);
3546
3635
  } catch {
@@ -3550,7 +3639,7 @@ async function hydratePath(env = process.env, platform2 = process.platform, exec
3550
3639
  break;
3551
3640
  }
3552
3641
  }
3553
- if (!found && platform2 === "darwin") {
3642
+ if (!found && platform === "darwin") {
3554
3643
  found = await readLaunchctlPath(exec);
3555
3644
  if (found) from = "launchctl";
3556
3645
  }
@@ -3576,15 +3665,8 @@ var TEXT_FILES = {
3576
3665
  "/api/text/focus": { file: "focusFile", name: "focus.md" },
3577
3666
  "/api/text/sources": { file: "sourcesFile", name: "sources.md" }
3578
3667
  };
3579
- var VALID_ACTIONS = /* @__PURE__ */ new Set([
3580
- "done",
3581
- "snooze",
3582
- "dismiss",
3583
- "reopen",
3584
- "note"
3585
- ]);
3668
+ var VALID_ACTIONS = new Set(ACTION_TYPES);
3586
3669
  var MAX_BODY_BYTES = 64 * 1024;
3587
- var PRESENCE_POLL_MS = 3e4;
3588
3670
  var AGENT_CLOCK_MS = 3e4;
3589
3671
  function sendJSON(res, status, body) {
3590
3672
  const payload = JSON.stringify(body);
@@ -3707,21 +3789,48 @@ async function startServer(env, options = {}) {
3707
3789
  });
3708
3790
  const subscribers = /* @__PURE__ */ new Set();
3709
3791
  let assetVersion = await computeAssetVersion(PUBLIC_DIR);
3710
- const board = new Board(config, () => void broadcast(), options.integrations?.github);
3792
+ let autoAsk = null;
3793
+ const board = new Board(
3794
+ config,
3795
+ () => {
3796
+ void broadcast();
3797
+ autoAsk?.poke();
3798
+ },
3799
+ options.integrations?.github
3800
+ );
3711
3801
  const calendar = new CalendarBoard(config, () => void broadcast());
3712
3802
  const tickets = new TicketBoard(config, () => void broadcast(), options.integrations?.jira);
3713
3803
  const assistant = new Assistant(config, {
3714
3804
  onChange: () => {
3715
3805
  void broadcast();
3716
3806
  restartWhenIdle();
3807
+ autoAsk?.poke();
3717
3808
  }
3718
3809
  });
3810
+ autoAsk = new AutoAsk({
3811
+ config,
3812
+ assistant,
3813
+ rows: async () => board.view(await store.readActions(), /* @__PURE__ */ new Date()).rows,
3814
+ context: async (id) => contextFor(id, await buildState()),
3815
+ // Nothing new starts while saved settings wait on the restart that applies them.
3816
+ canStart: () => restart !== "waiting"
3817
+ });
3818
+ const ahead = autoAsk;
3719
3819
  const agent = new AgentRunner(config, {
3720
3820
  onChange: () => {
3721
3821
  void broadcast();
3722
3822
  restartWhenIdle();
3723
3823
  }
3724
3824
  });
3825
+ let codexWarnings = [];
3826
+ const codexBins = /* @__PURE__ */ new Set();
3827
+ if (config.agent.cli === "codex") codexBins.add(config.agent.binPath);
3828
+ if (config.assistant.agent === "codex") codexBins.add(config.assistant.binPath);
3829
+ void Promise.all([...codexBins].map((bin) => codexVersionWarning(bin))).then((found) => {
3830
+ codexWarnings = [...new Set(found.filter((warning) => warning !== null))];
3831
+ for (const warning of codexWarnings) console.warn(`[daily-focus] ${warning}`);
3832
+ if (codexWarnings.length > 0) void broadcast();
3833
+ });
3725
3834
  function requestRestart() {
3726
3835
  restart = options.onRestart ? "waiting" : "manual";
3727
3836
  restartWhenIdle();
@@ -3743,19 +3852,27 @@ async function startServer(env, options = {}) {
3743
3852
  const setup = await setupState(config, {
3744
3853
  profileChosen: Boolean(layerEnv(sources).DAILY_FOCUS_PROFILE?.trim())
3745
3854
  });
3855
+ const pulls = board.view(actions, now);
3746
3856
  return {
3747
3857
  ...state,
3748
- board: board.view(actions, now),
3858
+ board: pulls,
3749
3859
  tickets: tickets.view(actions, now),
3750
- assistant: assistant.view(),
3860
+ assistant: { ...assistant.view(), auto: ahead.view(pulls.rows) },
3751
3861
  agentRun: agent.view(now),
3752
- setupWarnings: [...configWarnings, ...linkWarning ? [linkWarning] : []],
3862
+ setupWarnings: [...configWarnings, ...codexWarnings, ...linkWarning ? [linkWarning] : []],
3753
3863
  restart,
3754
3864
  setup,
3755
3865
  assetVersion,
3756
3866
  storeId
3757
3867
  };
3758
3868
  }
3869
+ function contextFor(id, current) {
3870
+ return {
3871
+ item: current.items.find((item) => item.id === id),
3872
+ pull: current.board.rows.find((row) => row.id === id),
3873
+ ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
3874
+ };
3875
+ }
3759
3876
  async function broadcast(prebuilt) {
3760
3877
  if (subscribers.size === 0) return;
3761
3878
  let payload;
@@ -3777,13 +3894,11 @@ data: ${payload}
3777
3894
  () => {
3778
3895
  void store.archiveCurrentBrief().then(() => broadcast());
3779
3896
  },
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.
3897
+ // The two boards rewrite prs.json and tickets.json every fetch; that's this
3898
+ // process talking to itself, and both already broadcast when something
3899
+ // actually changed.
3783
3900
  {
3784
3901
  ignore: [
3785
- basename(config.sessionFile),
3786
- basename(tempPathFor(config.sessionFile)),
3787
3902
  basename(config.pullsFile),
3788
3903
  basename(tempPathFor(config.pullsFile)),
3789
3904
  basename(config.ticketsFile),
@@ -3803,32 +3918,6 @@ data: ${payload}
3803
3918
  void broadcast();
3804
3919
  });
3805
3920
  });
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
3921
  await assistant.start();
3833
3922
  await agent.start();
3834
3923
  let ticks = Promise.resolve();
@@ -3853,10 +3942,10 @@ data: ${payload}
3853
3942
  void clockTick();
3854
3943
  }, AGENT_CLOCK_MS);
3855
3944
  agentClock.unref();
3856
- scheduleSessionTick((await buildState()).session.active?.endsAt ?? null);
3857
3945
  void board.start().catch((err) => {
3858
3946
  console.error(`[daily-focus] pull request board failed to start: ${err.message}`);
3859
3947
  });
3948
+ ahead.poke();
3860
3949
  void calendar.start().catch((err) => {
3861
3950
  console.error(`[daily-focus] live agenda failed to start: ${err.message}`);
3862
3951
  });
@@ -3867,10 +3956,6 @@ data: ${payload}
3867
3956
  void broadcast();
3868
3957
  }, 6e4);
3869
3958
  heartbeat.unref();
3870
- const presencePoll = setInterval(() => {
3871
- void checkPresence();
3872
- }, PRESENCE_POLL_MS);
3873
- presencePoll.unref();
3874
3959
  const server = createServer((req, res) => {
3875
3960
  void handle(req, res).catch((err) => {
3876
3961
  console.error(`[daily-focus] ${req.method} ${req.url} failed:`, err);
@@ -4017,12 +4102,7 @@ data: ${JSON.stringify(await buildState())}
4017
4102
  sendJSON(res, 409, { error: "the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex" });
4018
4103
  return;
4019
4104
  }
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
- };
4105
+ const context = contextFor(id, await buildState());
4026
4106
  if (!context.item && !context.pull && !context.ticket) {
4027
4107
  sendJSON(res, 404, { error: `no item with id ${id}` });
4028
4108
  return;
@@ -4058,8 +4138,8 @@ data: ${JSON.stringify(await buildState())}
4058
4138
  sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
4059
4139
  return;
4060
4140
  }
4061
- const { run: run6, text } = typeof body === "object" && body !== null ? body : {};
4062
- if (typeof run6 !== "string" || run6 === "") {
4141
+ const { run: run5, text } = typeof body === "object" && body !== null ? body : {};
4142
+ if (typeof run5 !== "string" || run5 === "") {
4063
4143
  sendJSON(res, 400, { error: "run must be a run id" });
4064
4144
  return;
4065
4145
  }
@@ -4068,7 +4148,7 @@ data: ${JSON.stringify(await buildState())}
4068
4148
  return;
4069
4149
  }
4070
4150
  try {
4071
- await agent.ask(run6, text);
4151
+ await agent.ask(run5, text);
4072
4152
  } catch (err) {
4073
4153
  sendJSON(res, 409, { error: err.message });
4074
4154
  return;
@@ -4138,53 +4218,6 @@ data: ${JSON.stringify(await buildState())}
4138
4218
  void broadcast(state);
4139
4219
  return;
4140
4220
  }
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" });
4151
- return;
4152
- }
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"' });
4180
- return;
4181
- }
4182
- const state = await buildState();
4183
- sendState(res, state);
4184
- scheduleSessionTick(state.session.active?.endsAt ?? null);
4185
- void broadcast(state);
4186
- return;
4187
- }
4188
4221
  if (path === "/api/settings" && req.method === "GET") {
4189
4222
  sendJSON(res, 200, { ...describeSettings(readSources()), restart });
4190
4223
  return;
@@ -4312,9 +4345,8 @@ ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) =
4312
4345
  }
4313
4346
  async function stopEverything() {
4314
4347
  clearInterval(heartbeat);
4315
- clearInterval(presencePoll);
4316
4348
  clearInterval(agentClock);
4317
- if (sessionTick) clearTimeout(sessionTick);
4349
+ ahead.close();
4318
4350
  board.stop();
4319
4351
  calendar.stop();
4320
4352
  tickets.stop();