@knpkv/jira-clockify 1.3.0 → 1.4.1

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.
Files changed (242) hide show
  1. package/README.md +245 -1
  2. package/dist/package.json +36 -12
  3. package/dist/src/agent/agentSettings.d.ts +18 -0
  4. package/dist/src/agent/agentSettings.d.ts.map +1 -0
  5. package/dist/src/agent/agentSettings.js +23 -0
  6. package/dist/src/agent/agentSettings.js.map +1 -0
  7. package/dist/src/agent/schedule.d.ts +20 -0
  8. package/dist/src/agent/schedule.d.ts.map +1 -0
  9. package/dist/src/agent/schedule.js +189 -0
  10. package/dist/src/agent/schedule.js.map +1 -0
  11. package/dist/src/agent/sessions.d.ts +427 -0
  12. package/dist/src/agent/sessions.d.ts.map +1 -0
  13. package/dist/src/agent/sessions.js +868 -0
  14. package/dist/src/agent/sessions.js.map +1 -0
  15. package/dist/src/agent/sourceConsumption.d.ts +79 -0
  16. package/dist/src/agent/sourceConsumption.d.ts.map +1 -0
  17. package/dist/src/agent/sourceConsumption.js +191 -0
  18. package/dist/src/agent/sourceConsumption.js.map +1 -0
  19. package/dist/src/agent/watch.d.ts +76 -0
  20. package/dist/src/agent/watch.d.ts.map +1 -0
  21. package/dist/src/agent/watch.js +45 -0
  22. package/dist/src/agent/watch.js.map +1 -0
  23. package/dist/src/agent/writePlanning.d.ts +154 -0
  24. package/dist/src/agent/writePlanning.d.ts.map +1 -0
  25. package/dist/src/agent/writePlanning.js +210 -0
  26. package/dist/src/agent/writePlanning.js.map +1 -0
  27. package/dist/src/bin.d.ts +3 -0
  28. package/dist/src/bin.d.ts.map +1 -0
  29. package/dist/src/bin.js +9 -3
  30. package/dist/src/bin.js.map +1 -1
  31. package/dist/src/cli/agentWrite.d.ts +164 -0
  32. package/dist/src/cli/agentWrite.d.ts.map +1 -0
  33. package/dist/src/cli/agentWrite.js +362 -0
  34. package/dist/src/cli/agentWrite.js.map +1 -0
  35. package/dist/src/cli/auth.d.ts +8 -0
  36. package/dist/src/cli/auth.d.ts.map +1 -0
  37. package/dist/src/cli/auth.js +10 -10
  38. package/dist/src/cli/auth.js.map +1 -1
  39. package/dist/src/cli/calendar.d.ts +42 -0
  40. package/dist/src/cli/calendar.d.ts.map +1 -0
  41. package/dist/src/cli/calendar.js +189 -0
  42. package/dist/src/cli/calendar.js.map +1 -0
  43. package/dist/src/cli/config.d.ts +12 -0
  44. package/dist/src/cli/config.d.ts.map +1 -0
  45. package/dist/src/cli/config.js +163 -21
  46. package/dist/src/cli/config.js.map +1 -1
  47. package/dist/src/cli/fetchTicket.d.ts +51 -0
  48. package/dist/src/cli/fetchTicket.d.ts.map +1 -0
  49. package/dist/src/cli/fetchTicket.js +18 -2
  50. package/dist/src/cli/fetchTicket.js.map +1 -1
  51. package/dist/src/cli/fuzzySelect.d.ts +24 -0
  52. package/dist/src/cli/fuzzySelect.d.ts.map +1 -0
  53. package/dist/src/cli/layers.d.ts +40 -0
  54. package/dist/src/cli/layers.d.ts.map +1 -0
  55. package/dist/src/cli/layers.js +57 -13
  56. package/dist/src/cli/layers.js.map +1 -1
  57. package/dist/src/cli/list.d.ts +8 -0
  58. package/dist/src/cli/list.d.ts.map +1 -0
  59. package/dist/src/cli/list.js +2 -2
  60. package/dist/src/cli/list.js.map +1 -1
  61. package/dist/src/cli/reconcile.d.ts +129 -0
  62. package/dist/src/cli/reconcile.d.ts.map +1 -0
  63. package/dist/src/cli/reconcile.js +635 -46
  64. package/dist/src/cli/reconcile.js.map +1 -1
  65. package/dist/src/cli/root.d.ts +15 -0
  66. package/dist/src/cli/root.d.ts.map +1 -0
  67. package/dist/src/cli/root.js +4 -3
  68. package/dist/src/cli/root.js.map +1 -1
  69. package/dist/src/cli/runtimeFailure.d.ts +10 -0
  70. package/dist/src/cli/runtimeFailure.d.ts.map +1 -0
  71. package/dist/src/cli/runtimeFailure.js +17 -0
  72. package/dist/src/cli/runtimeFailure.js.map +1 -0
  73. package/dist/src/cli/setup.d.ts +10 -0
  74. package/dist/src/cli/setup.d.ts.map +1 -0
  75. package/dist/src/cli/setup.js +7 -7
  76. package/dist/src/cli/setup.js.map +1 -1
  77. package/dist/src/cli/timer/discard.d.ts +4 -0
  78. package/dist/src/cli/timer/discard.d.ts.map +1 -0
  79. package/dist/src/cli/timer/discard.js +2 -2
  80. package/dist/src/cli/timer/discard.js.map +1 -1
  81. package/dist/src/cli/timer/edit.d.ts +22 -0
  82. package/dist/src/cli/timer/edit.d.ts.map +1 -0
  83. package/dist/src/cli/timer/edit.js +62 -42
  84. package/dist/src/cli/timer/edit.js.map +1 -1
  85. package/dist/src/cli/timer/index.d.ts +12 -0
  86. package/dist/src/cli/timer/index.d.ts.map +1 -0
  87. package/dist/src/cli/timer/log.d.ts +16 -0
  88. package/dist/src/cli/timer/log.d.ts.map +1 -0
  89. package/dist/src/cli/timer/log.js +6 -6
  90. package/dist/src/cli/timer/log.js.map +1 -1
  91. package/dist/src/cli/timer/start.d.ts +21 -0
  92. package/dist/src/cli/timer/start.d.ts.map +1 -0
  93. package/dist/src/cli/timer/start.js +9 -9
  94. package/dist/src/cli/timer/start.js.map +1 -1
  95. package/dist/src/cli/timer/status.d.ts +22 -0
  96. package/dist/src/cli/timer/status.d.ts.map +1 -0
  97. package/dist/src/cli/timer/status.js +3 -3
  98. package/dist/src/cli/timer/status.js.map +1 -1
  99. package/dist/src/cli/timer/stop.d.ts +48 -0
  100. package/dist/src/cli/timer/stop.d.ts.map +1 -0
  101. package/dist/src/cli/timer/stop.js +19 -19
  102. package/dist/src/cli/timer/stop.js.map +1 -1
  103. package/dist/src/cli/timer.d.ts +11 -0
  104. package/dist/src/cli/timer.d.ts.map +1 -0
  105. package/dist/src/cli/timer.js +1 -1
  106. package/dist/src/cli/timer.js.map +1 -1
  107. package/dist/src/cli/watch.d.ts +54 -0
  108. package/dist/src/cli/watch.d.ts.map +1 -0
  109. package/dist/src/cli/watch.js +412 -0
  110. package/dist/src/cli/watch.js.map +1 -0
  111. package/dist/src/cli/watchLease.d.ts +99 -0
  112. package/dist/src/cli/watchLease.d.ts.map +1 -0
  113. package/dist/src/cli/watchLease.js +204 -0
  114. package/dist/src/cli/watchLease.js.map +1 -0
  115. package/dist/src/cli/writerGuard.d.ts +37 -0
  116. package/dist/src/cli/writerGuard.d.ts.map +1 -0
  117. package/dist/src/cli/writerGuard.js +92 -0
  118. package/dist/src/cli/writerGuard.js.map +1 -0
  119. package/dist/src/index.d.ts +46 -0
  120. package/dist/src/index.d.ts.map +1 -0
  121. package/dist/src/index.js +46 -0
  122. package/dist/src/index.js.map +1 -0
  123. package/dist/src/main.d.ts +4 -0
  124. package/dist/src/main.d.ts.map +1 -0
  125. package/dist/src/services/AgentSessionReader.d.ts +121 -0
  126. package/dist/src/services/AgentSessionReader.d.ts.map +1 -0
  127. package/dist/src/services/AgentSessionReader.js +392 -0
  128. package/dist/src/services/AgentSessionReader.js.map +1 -0
  129. package/dist/src/services/ClockifyAuth.d.ts +47 -0
  130. package/dist/src/services/ClockifyAuth.d.ts.map +1 -0
  131. package/dist/src/services/ClockifyAuth.js +2 -2
  132. package/dist/src/services/ClockifyAuth.js.map +1 -1
  133. package/dist/src/services/CodexTranscript.d.ts +112 -0
  134. package/dist/src/services/CodexTranscript.d.ts.map +1 -0
  135. package/dist/src/services/CodexTranscript.js +96 -0
  136. package/dist/src/services/CodexTranscript.js.map +1 -0
  137. package/dist/src/services/ConfigService.d.ts +99 -0
  138. package/dist/src/services/ConfigService.d.ts.map +1 -0
  139. package/dist/src/services/ConfigService.js +79 -7
  140. package/dist/src/services/ConfigService.js.map +1 -1
  141. package/dist/src/services/HomeDirectory.d.ts +17 -0
  142. package/dist/src/services/HomeDirectory.d.ts.map +1 -0
  143. package/dist/src/services/HomeDirectory.js +1 -1
  144. package/dist/src/services/IssueFacts.d.ts +127 -0
  145. package/dist/src/services/IssueFacts.d.ts.map +1 -0
  146. package/dist/src/services/IssueFacts.js +277 -0
  147. package/dist/src/services/IssueFacts.js.map +1 -0
  148. package/dist/src/services/ProviderDeletion.d.ts +19 -0
  149. package/dist/src/services/ProviderDeletion.d.ts.map +1 -0
  150. package/dist/src/services/ProviderDeletion.js +37 -0
  151. package/dist/src/services/ProviderDeletion.js.map +1 -0
  152. package/dist/src/services/ReconcileService.d.ts +378 -0
  153. package/dist/src/services/ReconcileService.d.ts.map +1 -0
  154. package/dist/src/services/ReconcileService.js +1270 -79
  155. package/dist/src/services/ReconcileService.js.map +1 -1
  156. package/dist/src/services/SavedEntries.d.ts +40 -0
  157. package/dist/src/services/SavedEntries.d.ts.map +1 -0
  158. package/dist/src/services/SavedEntries.js +209 -0
  159. package/dist/src/services/SavedEntries.js.map +1 -0
  160. package/dist/src/services/SessionAttributor.d.ts +114 -0
  161. package/dist/src/services/SessionAttributor.d.ts.map +1 -0
  162. package/dist/src/services/SessionAttributor.js +240 -0
  163. package/dist/src/services/SessionAttributor.js.map +1 -0
  164. package/dist/src/services/SourceLedger.d.ts +104 -0
  165. package/dist/src/services/SourceLedger.d.ts.map +1 -0
  166. package/dist/src/services/SourceLedger.js +302 -0
  167. package/dist/src/services/SourceLedger.js.map +1 -0
  168. package/dist/src/services/StateWriter.d.ts +39 -0
  169. package/dist/src/services/StateWriter.d.ts.map +1 -0
  170. package/dist/src/services/TicketService.d.ts +63 -0
  171. package/dist/src/services/TicketService.d.ts.map +1 -0
  172. package/dist/src/services/TimerService.d.ts +145 -0
  173. package/dist/src/services/TimerService.d.ts.map +1 -0
  174. package/dist/src/services/TimerService.js +113 -80
  175. package/dist/src/services/TimerService.js.map +1 -1
  176. package/dist/src/services/internal/JiraWorklogPost.d.ts +7 -0
  177. package/dist/src/services/internal/JiraWorklogPost.d.ts.map +1 -0
  178. package/dist/src/services/internal/JiraWorklogPost.js +32 -0
  179. package/dist/src/services/internal/JiraWorklogPost.js.map +1 -0
  180. package/dist/src/testing/fakeHeadless.d.ts +318 -0
  181. package/dist/src/testing/fakeHeadless.d.ts.map +1 -0
  182. package/dist/src/testing/fakeHeadless.js +1149 -0
  183. package/dist/src/testing/fakeHeadless.js.map +1 -0
  184. package/dist/src/tui/App.d.ts +6 -0
  185. package/dist/src/tui/App.d.ts.map +1 -0
  186. package/dist/src/tui/App.js +1 -1
  187. package/dist/src/tui/App.js.map +1 -1
  188. package/dist/src/tui/atoms/runtime.d.ts +8 -0
  189. package/dist/src/tui/atoms/runtime.d.ts.map +1 -0
  190. package/dist/src/tui/atoms/runtime.js +1 -1
  191. package/dist/src/tui/atoms/runtime.js.map +1 -1
  192. package/dist/src/tui/atoms/tickets.d.ts +3 -0
  193. package/dist/src/tui/atoms/tickets.d.ts.map +1 -0
  194. package/dist/src/tui/atoms/timer.d.ts +11 -0
  195. package/dist/src/tui/atoms/timer.d.ts.map +1 -0
  196. package/dist/src/tui/atoms/timer.js +1 -1
  197. package/dist/src/tui/atoms/timer.js.map +1 -1
  198. package/dist/src/tui/atoms/ui.d.ts +11 -0
  199. package/dist/src/tui/atoms/ui.d.ts.map +1 -0
  200. package/dist/src/tui/atoms/ui.js +1 -1
  201. package/dist/src/tui/atoms/ui.js.map +1 -1
  202. package/dist/src/tui/components/BigTimer.d.ts +2 -0
  203. package/dist/src/tui/components/BigTimer.d.ts.map +1 -0
  204. package/dist/src/tui/components/Footer.d.ts +2 -0
  205. package/dist/src/tui/components/Footer.d.ts.map +1 -0
  206. package/dist/src/tui/components/Header.d.ts +2 -0
  207. package/dist/src/tui/components/Header.d.ts.map +1 -0
  208. package/dist/src/tui/components/Header.js +1 -1
  209. package/dist/src/tui/components/Header.js.map +1 -1
  210. package/dist/src/tui/components/PopupInput.d.ts +18 -0
  211. package/dist/src/tui/components/PopupInput.d.ts.map +1 -0
  212. package/dist/src/tui/components/PopupMessage.d.ts +20 -0
  213. package/dist/src/tui/components/PopupMessage.d.ts.map +1 -0
  214. package/dist/src/tui/components/TicketList.d.ts +2 -0
  215. package/dist/src/tui/components/TicketList.d.ts.map +1 -0
  216. package/dist/src/tui/components/TicketList.js +1 -1
  217. package/dist/src/tui/components/TicketList.js.map +1 -1
  218. package/dist/src/tui/components/TicketRow.d.ts +8 -0
  219. package/dist/src/tui/components/TicketRow.d.ts.map +1 -0
  220. package/dist/src/tui/components/TimerDisplay.d.ts +2 -0
  221. package/dist/src/tui/components/TimerDisplay.d.ts.map +1 -0
  222. package/dist/src/tui/components/index.d.ts +12 -0
  223. package/dist/src/tui/components/index.d.ts.map +1 -0
  224. package/dist/src/tui/context/theme.d.ts +15 -0
  225. package/dist/src/tui/context/theme.d.ts.map +1 -0
  226. package/dist/src/tui/hooks/useElapsedTimer.d.ts +6 -0
  227. package/dist/src/tui/hooks/useElapsedTimer.d.ts.map +1 -0
  228. package/dist/src/tui/hooks/useElapsedTimer.js +1 -1
  229. package/dist/src/tui/hooks/useElapsedTimer.js.map +1 -1
  230. package/dist/src/tui/hooks/useTerminalSize.d.ts +5 -0
  231. package/dist/src/tui/hooks/useTerminalSize.d.ts.map +1 -0
  232. package/dist/src/utils/hints.d.ts +12 -0
  233. package/dist/src/utils/hints.d.ts.map +1 -0
  234. package/dist/src/utils/hints.js +12 -0
  235. package/dist/src/utils/hints.js.map +1 -0
  236. package/dist/src/utils/time.d.ts +104 -0
  237. package/dist/src/utils/time.d.ts.map +1 -0
  238. package/dist/src/utils/time.js +74 -8
  239. package/dist/src/utils/time.js.map +1 -1
  240. package/dist/tsconfig.tsbuildinfo +1 -1
  241. package/package.json +36 -18
  242. package/skills/jcf/SKILL.md +93 -1
@@ -0,0 +1,868 @@
1
+ import { Data } from "effect";
2
+ import { scheduleRuns } from "./schedule.js";
3
+ import * as SourceConsumption from "./sourceConsumption.js";
4
+ /**
5
+ * Pure core for turning Agent Session evidence into Proposed Worklogs.
6
+ *
7
+ * **Mental model**
8
+ *
9
+ * - **Evidence, not a side**: a transcript records that work happened at particular moments,
10
+ * never how long it lasted. Everything here derives a *proposal*; nothing here is
11
+ * authoritative and nothing here writes. See ADR-0006.
12
+ * - **Last-touch partition**: every instant between two adjacent Session Activity events is
13
+ * credited to the *earlier* event's session, and only if the gap is within the Idle Cap.
14
+ * Each interval is therefore credited exactly once, so the sum over all Issue Keys can never
15
+ * exceed the wall clock of the window. That inequality is the safety property that makes a
16
+ * Proposed Worklog safe to accept without auditing the rest of the day.
17
+ * - **Attribution precedence**: branch, then path, then Standing Attribution, then Coding Agent.
18
+ * The first signal to yield an Issue Key wins, so re-running never reshuffles attributions.
19
+ * - **Closed choice set**: a Coding Agent may only pick an Issue Key that literally appears in
20
+ * the transcript. {@link attributeSession} enforces that structurally, so an invented key is
21
+ * impossible rather than merely discouraged.
22
+ *
23
+ * **Gotchas**
24
+ *
25
+ * - Provider-agnostic on purpose: nothing here knows about Claude, JSONL, or the filesystem, so
26
+ * adding a second Coding Agent is additive.
27
+ * - Placeholder rejection applies to *mined* candidates only, never to a branch or path. A branch
28
+ * name is a deliberate act; a number in prose is not. A real project numbered `PROJ-123` would
29
+ * otherwise be unattributable.
30
+ * - Credit is bounded to the window by the caller: only Session Activity inside `[from, to)` is
31
+ * passed in, so the last event of the window contributes no trailing interval. That is a
32
+ * deliberate under-count of at most one Idle Cap per window edge.
33
+ *
34
+ * @module
35
+ */
36
+ import { localDay, nextLocalMidnight } from "../utils/time.js";
37
+ /**
38
+ * Attribution Signals in precedence order — the first to yield an Issue Key wins. Also the
39
+ * trust order: a bucket built from several sessions reports its *weakest* signal, because a row
40
+ * is only as trustworthy as its least trustworthy evidence.
41
+ */
42
+ export const ATTRIBUTION_PRECEDENCE = [
43
+ "branch",
44
+ "path",
45
+ "standing",
46
+ "agent",
47
+ "none"
48
+ ];
49
+ const signalRank = (signal) => {
50
+ const index = ATTRIBUTION_PRECEDENCE.indexOf(signal);
51
+ return index === -1 ? ATTRIBUTION_PRECEDENCE.length : index;
52
+ };
53
+ /**
54
+ * An Issue Key as Jira writes them: at least two leading uppercase alphanumerics then a number.
55
+ * Deliberately stricter than the Clockify description parser — this runs over free prose and
56
+ * directory names, where `feat/jcf-ai` and `v2-3` must not look like tickets.
57
+ *
58
+ * Bounded by explicit non-alphanumerics rather than `\b`, because `_` is a word character: `\b` put
59
+ * no boundary between `_` and `P`, so the branch and worktree names people actually use —
60
+ * `feature_PROJ-42_work` — matched nothing at all, and the session went unattributed and therefore
61
+ * unlogged. Underscore is the only difference; letters and digits still have to stop.
62
+ */
63
+ const TICKET_KEY = /(?<![A-Za-z0-9])[A-Z][A-Z0-9]{1,9}-\d{1,6}(?![A-Za-z0-9])/g;
64
+ const matchTicketKeys = (text) => [...text.matchAll(TICKET_KEY)].map((m) => m[0]);
65
+ /**
66
+ * True when a string is an Issue Key and nothing else.
67
+ *
68
+ * A Standing Attribution is the one Issue Key nobody mines — it is typed into a config file — so it
69
+ * is also the one that can be empty or malformed. An empty key writes a Clockify description of
70
+ * `[] …`, which {@link parseTicketKey} then refuses to read back, so the next tally cannot see the
71
+ * entry and a watch writes the same time again on every settled tick, forever.
72
+ */
73
+ export const isTicketKey = (value) => {
74
+ const matches = matchTicketKeys(value);
75
+ return matches.length === 1 && matches[0] === value;
76
+ };
77
+ /**
78
+ * True for Issue Keys whose number reads as documentation filler rather than a real ticket:
79
+ * an ascending run from 1 (`123`, `1234`) or a repeated digit (`333`, `4444`), both at least
80
+ * three digits long. Short numbers are left alone so a young project's `PROJ-12` still counts.
81
+ *
82
+ * Only ever applied to keys mined from prose. `PROJ-XXXX` needs no rule — it has no digits and
83
+ * so never matches {@link TICKET_KEY} in the first place.
84
+ */
85
+ export const isPlaceholderTicketKey = (key) => {
86
+ const digits = key.slice(key.lastIndexOf("-") + 1);
87
+ if (digits.length < 3)
88
+ return false;
89
+ const ascendingFromOne = digits.split("").every((d, i) => d === String((i + 1) % 10));
90
+ const allSameDigit = digits.split("").every((d) => d === digits[0]);
91
+ return ascendingFromOne || allSameDigit;
92
+ };
93
+ const DEFAULT_CANDIDATE_LIMIT = 20;
94
+ /**
95
+ * Issue Keys mentioned anywhere in a transcript, deduped and in order of first appearance,
96
+ * with placeholders dropped and the list capped so a Coding Agent prompt stays bounded.
97
+ *
98
+ * Deliberately *unordered by frequency*: a known-issues document mentions the tickets it
99
+ * describes dozens of times, so frequency is anti-correlated with what should be billed.
100
+ */
101
+ export const mineTicketKeys = (text, options) => {
102
+ const limit = options?.limit ?? DEFAULT_CANDIDATE_LIMIT;
103
+ const seen = new Set();
104
+ const keys = [];
105
+ for (const key of matchTicketKeys(text)) {
106
+ if (seen.has(key) || isPlaceholderTicketKey(key))
107
+ continue;
108
+ seen.add(key);
109
+ keys.push(key);
110
+ if (keys.length >= limit)
111
+ break;
112
+ }
113
+ return keys;
114
+ };
115
+ /**
116
+ * The Issue Key a git branch names, or null. Takes the first match so `PROJ-1/PROJ-2` resolves
117
+ * predictably, and applies no placeholder filter — naming a branch is a deliberate act.
118
+ */
119
+ export const ticketKeyFromBranch = (branch) => {
120
+ if (branch === null || branch === undefined || branch === "")
121
+ return null;
122
+ return matchTicketKeys(branch)[0] ?? null;
123
+ };
124
+ /**
125
+ * The Issue Key a working directory path names, or null. Takes the *last* match so the deepest
126
+ * segment wins — a worktree at `~/dev/repo/worktrees/PROJ-1/PROJ-2` is working on `PROJ-2`.
127
+ *
128
+ * This is what makes detached worktrees (whose branch reads `HEAD`) attributable.
129
+ */
130
+ export const ticketKeyFromPath = (cwd) => {
131
+ const keys = matchTicketKeys(cwd);
132
+ return keys.length > 0 ? keys[keys.length - 1] : null;
133
+ };
134
+ const withoutTrailingSlash = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
135
+ /** Expand a leading `~` against `home`. Paths without one are returned unchanged. */
136
+ export const expandHomePath = (path, home) => {
137
+ if (path === "~")
138
+ return home;
139
+ if (path.startsWith("~/"))
140
+ return `${withoutTrailingSlash(home)}/${path.slice(2)}`;
141
+ if (path.startsWith("~\\"))
142
+ return `${home.replace(/[\\/]+$/, "")}\\${path.slice(2)}`;
143
+ return path;
144
+ };
145
+ const windowsAbsolute = (value) => /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value);
146
+ /**
147
+ * True when `path` is `prefix` or sits beneath it. Compares on a path *separator boundary*, so
148
+ * `/a/bc` is not inside `/a/b` — the bug a bare `startsWith` would introduce.
149
+ */
150
+ export const isWithinPrefix = (path, prefix) => {
151
+ const windows = windowsAbsolute(path);
152
+ if (windows !== windowsAbsolute(prefix))
153
+ return false;
154
+ const normalise = (value) => windows
155
+ ? value.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase()
156
+ : withoutTrailingSlash(value);
157
+ const normalisedPath = normalise(path);
158
+ const normalisedPrefix = normalise(prefix);
159
+ if (normalisedPrefix.length === 0)
160
+ return false;
161
+ return normalisedPath === normalisedPrefix || normalisedPath.startsWith(`${normalisedPrefix}/`);
162
+ };
163
+ /**
164
+ * True when a session's working directory sits inside any Session Root. Sessions outside every
165
+ * root are never read, so scratch directories and side projects cannot generate proposals.
166
+ */
167
+ export const isWithinSessionRoots = (cwd, roots) => roots.some((root) => isWithinPrefix(cwd, root));
168
+ /**
169
+ * The Standing Attribution for a working directory: the Issue Key mapped to the *longest*
170
+ * matching directory prefix, so `~/dev/docs/releases` and `~/dev/docs/interviews` can differ.
171
+ */
172
+ export const standingAttribution = (cwd, map) => {
173
+ let best = null;
174
+ let bestLength = -1;
175
+ for (const [prefix, ticketKey] of Object.entries(map)) {
176
+ if (!isWithinPrefix(cwd, prefix) || prefix.length <= bestLength)
177
+ continue;
178
+ best = ticketKey;
179
+ bestLength = prefix.length;
180
+ }
181
+ return best;
182
+ };
183
+ /**
184
+ * Place a session using the deterministic signals only, or null when none apply.
185
+ *
186
+ * Kept separate from {@link attributeSession} because the *absence* of a result here is exactly
187
+ * the condition for spending a Coding Agent call — which is why a run where every session is
188
+ * branch-attributed costs nothing.
189
+ */
190
+ export const deterministicAttribution = (session, options) => {
191
+ const fromBranch = ticketKeyFromBranch(session.gitBranch);
192
+ if (fromBranch !== null)
193
+ return { ticketKey: fromBranch, signal: "branch" };
194
+ const fromPath = ticketKeyFromPath(session.cwd);
195
+ if (fromPath !== null)
196
+ return { ticketKey: fromPath, signal: "path" };
197
+ const standing = standingAttribution(session.cwd, options.standingMap);
198
+ if (standing !== null)
199
+ return { ticketKey: standing, signal: "standing" };
200
+ return null;
201
+ };
202
+ /**
203
+ * Resolve one session's final attribution, applying the full precedence and both guards on a
204
+ * Coding Agent's answer: the chosen key must appear in the session's own candidate set, and a
205
+ * choice below `confidenceFloor` is marked so it can be reported without being offered.
206
+ */
207
+ export const attributeSession = (session, options) => {
208
+ const deterministic = deterministicAttribution(session, { standingMap: options.standingMap });
209
+ if (deterministic !== null) {
210
+ return {
211
+ sessionId: session.sessionId,
212
+ ticketKey: deterministic.ticketKey,
213
+ signal: deterministic.signal,
214
+ confidence: null,
215
+ belowConfidenceFloor: false
216
+ };
217
+ }
218
+ const choice = options.agentChoice;
219
+ const unattributed = {
220
+ sessionId: session.sessionId,
221
+ ticketKey: null,
222
+ signal: "none",
223
+ confidence: null,
224
+ belowConfidenceFloor: false
225
+ };
226
+ // The choice set is closed over the transcript's own text: a key the transcript never
227
+ // mentioned cannot reach a worklog, however confidently it was named.
228
+ if (choice === null || choice === undefined || !session.candidateKeys.includes(choice.ticketKey)) {
229
+ return unattributed;
230
+ }
231
+ return {
232
+ sessionId: session.sessionId,
233
+ ticketKey: choice.ticketKey,
234
+ signal: "agent",
235
+ confidence: choice.confidence,
236
+ belowConfidenceFloor: choice.confidence < options.confidenceFloor
237
+ };
238
+ };
239
+ /**
240
+ * Merge spans that touch or overlap, so a hundred one-minute credits read as one block of work.
241
+ */
242
+ export const mergeSpans = (spans) => {
243
+ const sorted = [...spans].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
244
+ const merged = [];
245
+ for (const span of sorted) {
246
+ const last = merged[merged.length - 1];
247
+ if (last !== undefined && span.startMs <= last.endMs) {
248
+ last.endMs = Math.max(last.endMs, span.endMs);
249
+ continue;
250
+ }
251
+ merged.push({ startMs: span.startMs, endMs: span.endMs });
252
+ }
253
+ return merged;
254
+ };
255
+ /**
256
+ * Merge overlapping spans, then cut the result at every local midnight.
257
+ *
258
+ * Merging alone is not enough anywhere spans are day-bucketed afterwards: two windows that meet at
259
+ * midnight touch, so a plain merge welds them into one span whose start decides the day — putting a
260
+ * whole morning's work onto the previous day. Every merge in this module goes through here.
261
+ */
262
+ export const mergeSpansWithinDays = (spans) => {
263
+ const bounded = [];
264
+ for (const span of mergeSpans(spans)) {
265
+ let cursor = span.startMs;
266
+ while (cursor < span.endMs) {
267
+ const chunkEnd = Math.min(span.endMs, nextLocalMidnight(cursor));
268
+ bounded.push({ startMs: cursor, endMs: chunkEnd });
269
+ cursor = chunkEnd;
270
+ }
271
+ }
272
+ return bounded;
273
+ };
274
+ /**
275
+ * The windows a single session was active in: each gap between its own adjacent events, cut off at
276
+ * the Idle Cap, and split at local midnight. Merged, so contiguous work reads as one block.
277
+ */
278
+ const sessionActiveWindows = (events, capMs, observedAtMs) => {
279
+ const raw = [];
280
+ const sorted = [...events].sort((a, b) => a - b);
281
+ for (let index = 0; index < sorted.length - 1; index++) {
282
+ const from = sorted[index];
283
+ const next = sorted[index + 1];
284
+ if (from === undefined || next === undefined)
285
+ continue;
286
+ // Beyond the Idle Cap nobody was working, so the window ends there rather than at the next event.
287
+ raw.push({ startMs: from, endMs: Math.min(next, from + capMs) });
288
+ }
289
+ // The last prompt gets its window too, bounded by the Idle Cap and by how far we can see.
290
+ //
291
+ // Not cosmetic. Without it a final prompt contributes nothing *until* some later prompt arrives,
292
+ // and then a window appears retroactively — one that can overlap a block already settled and
293
+ // written, halving that block's share after the fact while the new share is written as well. Two
294
+ // tickets then hold more time between them than the clock has. Materialising it on sight is what
295
+ // makes "settled" mean settled: every window a prompt will ever produce exists as soon as the
296
+ // prompt does.
297
+ const last = sorted[sorted.length - 1];
298
+ if (last !== undefined && observedAtMs > last) {
299
+ raw.push({ startMs: last, endMs: Math.min(observedAtMs, last + capMs) });
300
+ }
301
+ return mergeSpansWithinDays(raw);
302
+ };
303
+ /**
304
+ * When each session was working: the gaps between its own adjacent events, cut off at the Idle Cap.
305
+ *
306
+ * Deliberately stops short of dividing anything. Sharing has to happen between *Issue Keys*, and
307
+ * which key a session belongs to is not known until it has been attributed — two sessions on the
308
+ * same ticket must not halve each other's time. See {@link splitCredits}.
309
+ */
310
+ export const activeWindows = (activity, options) => {
311
+ const capMs = Math.max(0, options.idleCapSeconds) * 1000;
312
+ const eventsBySession = new Map();
313
+ for (const event of activity) {
314
+ const events = eventsBySession.get(event.sessionId) ?? [];
315
+ events.push(event.atMs);
316
+ eventsBySession.set(event.sessionId, events);
317
+ }
318
+ return [...eventsBySession.entries()]
319
+ .map(([sessionId, events]) => ({
320
+ sessionId,
321
+ spans: sessionActiveWindows(events, capMs, Math.min(options.observedAtMs, options.boundsBySession?.get(sessionId) ?? Number.POSITIVE_INFINITY))
322
+ }))
323
+ .filter((session) => session.spans.length > 0)
324
+ .sort((a, b) => a.sessionId.localeCompare(b.sessionId));
325
+ };
326
+ /**
327
+ * Every instant where at least one bucket was active, cut into slices at each point where the set
328
+ * of active buckets changes, with the buckets active in that slice.
329
+ */
330
+ const overlapSlices = (spansByBucket) => {
331
+ const boundaries = new Set();
332
+ for (const spans of spansByBucket.values()) {
333
+ for (const span of spans) {
334
+ boundaries.add(span.startMs);
335
+ boundaries.add(span.endMs);
336
+ }
337
+ }
338
+ const ordered = [...boundaries].sort((a, b) => a - b);
339
+ const slices = [];
340
+ for (let i = 0; i < ordered.length - 1; i++) {
341
+ const startMs = ordered[i];
342
+ const endMs = ordered[i + 1];
343
+ const bucketIds = [...spansByBucket.entries()]
344
+ .filter(([, spans]) => spans.some((span) => span.startMs <= startMs && span.endMs >= endMs))
345
+ .map(([bucketId]) => bucketId);
346
+ if (bucketIds.length > 0)
347
+ slices.push({ startMs, endMs, bucketIds });
348
+ }
349
+ return slices;
350
+ };
351
+ /**
352
+ * The shortest stretch that may own time on its own, in seconds.
353
+ *
354
+ * Fifteen minutes, because a timeline that changes ticket every three minutes is not a record of how
355
+ * anyone works — it is an artefact of reading several concurrent transcripts at once. A day of that
356
+ * is unreadable on a calendar and indefensible on a timesheet.
357
+ */
358
+ export const DEFAULT_DWELL_SECONDS = 900;
359
+ const sameOwners = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
360
+ /**
361
+ * True when two touching stretches may become one: same owners, same local day.
362
+ *
363
+ * The day check is load-bearing. Runs are bucketed by the day their start falls in, so welding a
364
+ * stretch ending at midnight to the one beginning there would move the small hours of Tuesday onto
365
+ * Monday and report Tuesday as empty. The windows arrive already split at midnight for exactly this
366
+ * reason; coalescing must not undo it.
367
+ */
368
+ const joinable = (previous, next) => previous.endMs === next.startMs &&
369
+ sameOwners(previous.bucketIds, next.bucketIds) &&
370
+ localDay(new Date(previous.startMs)) === localDay(new Date(next.startMs));
371
+ /** Join touching stretches with identical owners. A gap of idle time always separates two runs. */
372
+ const mergeRuns = (runs) => {
373
+ const merged = [];
374
+ for (const run of runs) {
375
+ const previous = merged[merged.length - 1];
376
+ if (previous !== undefined && joinable(previous, run)) {
377
+ merged[merged.length - 1] = { ...previous, endMs: run.endMs };
378
+ continue;
379
+ }
380
+ merged.push(run);
381
+ }
382
+ return merged;
383
+ };
384
+ /**
385
+ * Coalesce ownership so it changes no more often than the Dwell Floor.
386
+ *
387
+ * **What this is for.** Three concurrent sessions on three Issue Keys interleave their prompts, and
388
+ * read literally that says the work changed ticket every few minutes. It did not — that is an
389
+ * artefact of reading several transcripts at once. A day of it is two dozen slivers on a calendar and
390
+ * indefensible on a timesheet.
391
+ *
392
+ * **Two rules, in order.**
393
+ *
394
+ * 1. *A ticket present for less than the floor inside one connected source cluster, interrupting work
395
+ * that resumes after it, was not a ticket that was worked on.* It was a keystroke inside other work — a branch
396
+ * checked, a file opened, a question asked — so its time goes to the work around it. This is what
397
+ * actually removes the slivers: they rarely touch anything, because a transcript goes quiet
398
+ * between prompts, and they are as often overlapping as adjacent, because concurrent sessions
399
+ * overlap by definition. Adjacency and overlap were both tried as the test and both left the
400
+ * interleaving as they found it. The requirement for work on *both* sides is what keeps a genuine
401
+ * short piece of work — eight minutes on another ticket, and then the day moves on — from being
402
+ * swallowed by what came before it.
403
+ * 2. *Tenancy, not adjacency.* Between tickets that were genuinely worked on, whoever takes the
404
+ * timeline holds it for at least the floor: a stretch beginning inside that tenure and owned by
405
+ * someone else is credited to the incumbent. This catches two real tickets alternating quickly.
406
+ *
407
+ * **What it never does.** It reassigns time and never creates or drops any — idle gaps are not swept
408
+ * into a tenure — so the inequality that makes a proposal safe to accept survives intact. Nothing is
409
+ * ever welded across a local midnight, because runs are bucketed by the day they start in.
410
+ *
411
+ * **Unplaced hours take no part.** A stretch nothing placed neither holds a tenure nor loses its time
412
+ * to one: promoting it would bill work no transcript placed on that ticket, and demoting an
413
+ * attributed sliver into it would quietly discard billable work. An attributed tenure simply
414
+ * continues across it.
415
+ *
416
+ * **A minor ticket with nothing to belong to keeps its own time.** Four minutes alone in an otherwise
417
+ * empty day has no surrounding work to join, and dropping it would lose work that happened.
418
+ */
419
+ export const applyDwellFloor = (slices, options) => {
420
+ const dwellMs = Math.max(0, options.dwellSeconds) * 1000;
421
+ if (dwellMs === 0)
422
+ return mergeRuns(slices);
423
+ const runs = mergeRuns(slices);
424
+ // How long each attributed ticket was present in one connected source cluster, sharing ignored:
425
+ // the question is whether it was worked on at all, not how much of a shared minute it would be
426
+ // credited. A later disconnected cluster must not change ownership a watch already settled.
427
+ const clusterIds = [];
428
+ let clusterId = 0;
429
+ for (let index = 0; index < runs.length; index++) {
430
+ const run = runs[index];
431
+ const previous = runs[index - 1];
432
+ if (previous !== undefined &&
433
+ (previous.endMs !== run.startMs || localDay(new Date(previous.startMs)) !== localDay(new Date(run.startMs)))) {
434
+ clusterId++;
435
+ }
436
+ clusterIds.push(clusterId);
437
+ }
438
+ const presence = new Map();
439
+ const presenceKey = (bucketId, cluster) => `${bucketId}\u0000${String(cluster)}`;
440
+ for (let index = 0; index < runs.length; index++) {
441
+ const run = runs[index];
442
+ const cluster = clusterIds[index];
443
+ for (const bucketId of run.bucketIds) {
444
+ const key = presenceKey(bucketId, cluster);
445
+ presence.set(key, (presence.get(key) ?? 0) + (run.endMs - run.startMs));
446
+ }
447
+ }
448
+ const major = (bucketId, index) => !options.attributed(bucketId) || (presence.get(presenceKey(bucketId, clusterIds[index])) ?? 0) >= dwellMs;
449
+ /** The nearest major, attributed owners on the same day, looking one way from `index`. */
450
+ const majorOwnersToward = (index, day, step) => {
451
+ let boundary = step === -1 ? runs[index].startMs : runs[index].endMs;
452
+ for (let at = index + step; at >= 0 && at < runs.length; at += step) {
453
+ const candidate = runs[at];
454
+ if (localDay(new Date(candidate.startMs)) !== day)
455
+ return undefined;
456
+ // A settled idle gap is a temporal barrier: work appended beyond it must never reassign a
457
+ // block a watch could already have written. Only a connected source cluster may absorb a
458
+ // minor interruption.
459
+ if (step === -1 ? candidate.endMs !== boundary : candidate.startMs !== boundary)
460
+ return undefined;
461
+ boundary = step === -1 ? candidate.startMs : candidate.endMs;
462
+ const owners = candidate.bucketIds.filter((id) => major(id, at) && options.attributed(id));
463
+ if (owners.length > 0)
464
+ return owners;
465
+ }
466
+ return undefined;
467
+ };
468
+ const withoutMinors = runs.map((run, index) => {
469
+ const day = localDay(new Date(run.startMs));
470
+ const kept = run.bucketIds.filter((id) => major(id, index));
471
+ if (kept.length > 0)
472
+ return { ...run, bucketIds: kept };
473
+ // Every owner was minor, so this is only an *interruption* if the work it interrupts resumes:
474
+ // there has to be major work on both sides of it. Eight minutes on another ticket after half an
475
+ // hour, with nothing after it, is a short piece of work rather than a keystroke inside a longer
476
+ // one — and swallowing it would lose the change of ticket a person actually made.
477
+ const before = majorOwnersToward(index, day, -1);
478
+ const after = majorOwnersToward(index, day, 1);
479
+ return before === undefined || after === undefined ? run : { ...run, bucketIds: before };
480
+ });
481
+ // Phase two: between tickets that were genuinely worked on, ownership holds for the floor.
482
+ let tenant = null;
483
+ const held = [];
484
+ for (const run of mergeRuns(withoutMinors)) {
485
+ const previous = held[held.length - 1];
486
+ if (previous !== undefined &&
487
+ (previous.endMs !== run.startMs || localDay(new Date(previous.startMs)) !== localDay(new Date(run.startMs)))) {
488
+ tenant = null;
489
+ }
490
+ const attributedRun = run.bucketIds.every(options.attributed);
491
+ if (!attributedRun) {
492
+ held.push(run);
493
+ continue;
494
+ }
495
+ const sameDay = tenant !== null && localDay(new Date(tenant.sinceMs)) === localDay(new Date(run.startMs));
496
+ if (tenant !== null && sameDay && sameOwners(tenant.bucketIds, run.bucketIds)) {
497
+ held.push(run);
498
+ continue;
499
+ }
500
+ const overlapsTenant = tenant !== null && run.bucketIds.some((id) => tenant.bucketIds.includes(id));
501
+ if (tenant !== null && sameDay && run.startMs - tenant.sinceMs < dwellMs && !overlapsTenant) {
502
+ held.push({ ...run, bucketIds: tenant.bucketIds });
503
+ continue;
504
+ }
505
+ tenant = { bucketIds: run.bucketIds, sinceMs: run.startMs };
506
+ held.push(run);
507
+ }
508
+ return mergeRuns(held);
509
+ };
510
+ /**
511
+ * Coalesce a bucket's runs into the blocks a person sees, adding up the credit each one carries.
512
+ *
513
+ * `gapMs` is the Dwell Floor. Once ownership cannot change inside the floor, two runs of the same
514
+ * ticket four minutes apart are one stretch of work with a pause in it, and offering them as two
515
+ * blocks says something about the day that is not true. Runs that merely touch are joined at any
516
+ * floor, including zero.
517
+ *
518
+ * Never across a local midnight, because everything downstream buckets by day: welding the last run
519
+ * of Monday to the first of Tuesday would file a whole morning under the wrong date.
520
+ */
521
+ const coalesceBlocks = (spans, gapMs) => {
522
+ const ordered = [...spans].sort((a, b) => a.startMs - b.startMs);
523
+ const joined = [];
524
+ for (const span of ordered) {
525
+ const previous = joined[joined.length - 1];
526
+ if (previous !== undefined &&
527
+ span.startMs - previous.endMs <= gapMs &&
528
+ localDay(new Date(previous.startMs)) === localDay(new Date(span.startMs))) {
529
+ joined[joined.length - 1] = {
530
+ creditedMs: previous.creditedMs + span.creditedMs,
531
+ endMs: Math.max(previous.endMs, span.endMs),
532
+ startMs: previous.startMs,
533
+ sourceStartMs: Math.min(previous.sourceStartMs, span.sourceStartMs)
534
+ };
535
+ continue;
536
+ }
537
+ joined.push(span);
538
+ }
539
+ return joined;
540
+ };
541
+ /**
542
+ * Give every block whole seconds that add up to the day's total, exactly.
543
+ *
544
+ * Flooring each block on its own leaves the row short by up to a second per block, and a row whose
545
+ * blocks do not add up to it is a row nobody can check — the panel would offer 56m 36s and its five
546
+ * blocks would come to 56m 32s. The seconds the flooring drops go to the blocks with the largest
547
+ * fractional part, which is the same rule an invoice uses to make its lines sum to its total.
548
+ */
549
+ const wholeSeconds = (spans, totalSeconds) => {
550
+ const floored = spans.map((span) => ({
551
+ remainder: (span.creditedMs / 1000) - Math.floor(span.creditedMs / 1000),
552
+ seconds: Math.floor(span.creditedMs / 1000),
553
+ span
554
+ }));
555
+ let spare = totalSeconds - floored.reduce((sum, entry) => sum + entry.seconds, 0);
556
+ // Largest fractional part first, and an earlier block wins a tie so the result never depends on
557
+ // the order runs happened to arrive in.
558
+ const order = [...floored].sort((a, b) => b.remainder - a.remainder || a.span.startMs - b.span.startMs);
559
+ for (const entry of order) {
560
+ if (spare <= 0)
561
+ break;
562
+ entry.seconds += 1;
563
+ spare -= 1;
564
+ }
565
+ const nextIndex = new Map();
566
+ return floored.map((entry) => {
567
+ const allocationIndex = nextIndex.get(entry.span.sourceStartMs) ?? 0;
568
+ nextIndex.set(entry.span.sourceStartMs, allocationIndex + 1);
569
+ return {
570
+ allocationIndex,
571
+ endMs: entry.span.endMs,
572
+ seconds: entry.seconds,
573
+ startMs: entry.span.startMs,
574
+ sourceStartMs: entry.span.sourceStartMs
575
+ };
576
+ });
577
+ };
578
+ /**
579
+ * Share time between buckets, scheduling overlapping attributed work into sequential blocks.
580
+ *
581
+ * Where several buckets were active at the same instant, that instant is divided equally between
582
+ * them, so an hour spent on three tickets at once credits twenty minutes to each rather than an
583
+ * hour to whichever session happened to log an event first. Two sessions on the *same* bucket do
584
+ * not divide anything — their windows are unioned first, because there is no ambiguity about which
585
+ * Issue Key that time belongs to.
586
+ *
587
+ * A slice's duration is divided, never duplicated, so the total across every bucket can never
588
+ * exceed the wall clock of the day. With a positive dwell floor, overlapping attributed runs
589
+ * are scheduled evenly among the strongest tickets that fit; zero preserves raw shares.
590
+ */
591
+ const shareBetweenBuckets = (spansByBucket, options) => {
592
+ const totals = new Map();
593
+ const add = (bucketId, day, creditedMs, activeMs) => {
594
+ const byDay = totals.get(bucketId) ?? new Map();
595
+ const existing = byDay.get(day) ?? { creditedMs: 0, activeMs: 0 };
596
+ byDay.set(day, { creditedMs: existing.creditedMs + creditedMs, activeMs: existing.activeMs + activeMs });
597
+ totals.set(bucketId, byDay);
598
+ };
599
+ // Ownership is coalesced first, so the hours and the picture come from the same timeline. Deriving
600
+ // the totals from the runs and the spans from the original windows would put a row's seconds and
601
+ // its blocks at odds — and the blocks are what a person checks the seconds against.
602
+ const originalRuns = applyDwellFloor(overlapSlices(spansByBucket).map((run) => ({
603
+ ...run,
604
+ bucketIds: run.bucketIds.filter((id) => !options.coveredByCertain(id, run.bucketIds))
605
+ })), options);
606
+ const runs = scheduleRuns(originalRuns, options.dwellSeconds, options.attributed);
607
+ for (const run of originalRuns) {
608
+ for (const id of run.bucketIds)
609
+ add(id, localDay(new Date(run.startMs)), 0, run.endMs - run.startMs);
610
+ }
611
+ const runSpans = new Map();
612
+ const settlementEnds = new Map();
613
+ const sourceStarts = new Map();
614
+ for (const run of runs) {
615
+ // Windows are already day-bounded, so a run never straddles two days.
616
+ const day = localDay(new Date(run.startMs));
617
+ const duration = run.endMs - run.startMs;
618
+ // Divided, never duplicated: this is the one place an instant becomes seconds, and it is the
619
+ // same number that reaches the row's total and the block a person accepts.
620
+ const creditedMs = duration / run.bucketIds.length;
621
+ for (const [index, bucketId] of run.bucketIds.entries()) {
622
+ add(bucketId, day, creditedMs, 0);
623
+ const byDay = settlementEnds.get(bucketId) ?? new Map();
624
+ byDay.set(day, Math.max(byDay.get(day) ?? 0, run.settlementEndMs));
625
+ settlementEnds.set(bucketId, byDay);
626
+ const startsByDay = sourceStarts.get(bucketId) ?? new Map();
627
+ startsByDay.set(day, Math.min(startsByDay.get(day) ?? Number.POSITIVE_INFINITY, run.sourceStartMs));
628
+ sourceStarts.set(bucketId, startsByDay);
629
+ runSpans.set(bucketId, [...(runSpans.get(bucketId) ?? []), {
630
+ creditedMs,
631
+ endMs: options.dwellSeconds > 0 ? run.startMs + creditedMs * (index + 1) : run.endMs,
632
+ startMs: options.dwellSeconds > 0 ? run.startMs + creditedMs * index : run.startMs,
633
+ sourceStartMs: run.sourceStartMs
634
+ }]);
635
+ }
636
+ }
637
+ const result = new Map();
638
+ for (const [bucketId, byDay] of totals) {
639
+ const spansByDay = new Map();
640
+ for (const span of runSpans.get(bucketId) ?? []) {
641
+ const day = localDay(new Date(span.startMs));
642
+ spansByDay.set(day, [...(spansByDay.get(day) ?? []), span]);
643
+ }
644
+ const perDay = new Map();
645
+ for (const [day, sums] of byDay) {
646
+ // Floor, not round. A share of an odd-length slice is fractional, and rounding each bucket
647
+ // up independently can push the day one second past the wall clock it occupied — breaking
648
+ // the one invariant this shape exists to guarantee. Flooring errs the way the design prefers.
649
+ const seconds = Math.floor(sums.creditedMs / 1000);
650
+ perDay.set(day, {
651
+ activeSeconds: Math.floor(sums.activeMs / 1000),
652
+ // The blocks are the row: the same runs, coalesced for reading, sharing out the same total.
653
+ blocks: wholeSeconds(coalesceBlocks(spansByDay.get(day) ?? [], 0), seconds),
654
+ settlementEndMs: settlementEnds.get(bucketId)?.get(day) ?? 0,
655
+ sourceStartMs: sourceStarts.get(bucketId)?.get(day) ?? 0,
656
+ seconds
657
+ });
658
+ }
659
+ result.set(bucketId, perDay);
660
+ }
661
+ return result;
662
+ };
663
+ /** The lower of two confidences, treating "no Coding Agent involved" as absent rather than zero. */
664
+ const weakerConfidence = (a, b) => {
665
+ if (a === null)
666
+ return b;
667
+ if (b === null)
668
+ return a;
669
+ return Math.min(a, b);
670
+ };
671
+ const bucketId = (kind, ticketKey) => `${kind}\u0000${ticketKey ?? ""}`;
672
+ class MissingDayAttributionError extends Data.TaggedError("MissingDayAttributionError") {
673
+ }
674
+ /**
675
+ * Fold per-session active windows onto Issue Keys, dividing every overlap between *distinct* keys.
676
+ *
677
+ * Bucketing happens before sharing, which is the whole point: two sessions on the same Issue Key
678
+ * union their windows and lose nothing, while two sessions on different keys split the instants they
679
+ * share. Below-floor and unplaced time gets its own buckets so neither can be silently dropped nor
680
+ * silently proposed — and both still take part in the split, because they occupied real time.
681
+ */
682
+ export const splitCredits = (windows, attributions, options) => {
683
+ const bySession = new Map(attributions.map((attribution) => [attribution.sessionId, attribution]));
684
+ const spansByBucket = new Map();
685
+ const metaByBucket = new Map();
686
+ // Which sessions contributed to a bucket *on a given day*, not merely to the bucket.
687
+ //
688
+ // A week's run emits one row per day, and each row's evidence has to be that day's. Carrying the
689
+ // bucket's whole session set would ask a Coding Agent to describe Monday's work from Friday's
690
+ // prompts — and that sentence is written verbatim into a Clockify description and a Jira worklog.
691
+ const sessionsByBucketDay = new Map();
692
+ const evidenceByBucketDay = new Map();
693
+ const dayKey = (bucketId, day) => `${bucketId}\u0000${day}`;
694
+ for (const session of windows) {
695
+ const attribution = bySession.get(session.sessionId);
696
+ const ticketKey = attribution?.ticketKey ?? null;
697
+ const kind = ticketKey === null
698
+ ? "unattributed"
699
+ : attribution?.belowConfidenceFloor === true
700
+ ? "withheld"
701
+ : "attributed";
702
+ const id = bucketId(kind, ticketKey);
703
+ spansByBucket.set(id, [...(spansByBucket.get(id) ?? []), ...session.spans]);
704
+ // Session windows are already day-bounded, so a span belongs to exactly one day.
705
+ for (const span of session.spans) {
706
+ const key = dayKey(id, localDay(new Date(span.startMs)));
707
+ sessionsByBucketDay.set(key, (sessionsByBucketDay.get(key) ?? new Set()).add(session.sessionId));
708
+ const previous = evidenceByBucketDay.get(key);
709
+ const signal = attribution?.signal ?? "none";
710
+ const confidence = attribution?.confidence ?? null;
711
+ evidenceByBucketDay.set(key, {
712
+ signal: previous === undefined || signalRank(signal) > signalRank(previous.signal) ? signal : previous.signal,
713
+ confidence: previous === undefined ? confidence : weakerConfidence(previous.confidence, confidence)
714
+ });
715
+ }
716
+ const existing = metaByBucket.get(id);
717
+ metaByBucket.set(id, {
718
+ kind,
719
+ ticketKey,
720
+ sessions: new Set([...(existing?.sessions ?? []), session.sessionId])
721
+ });
722
+ }
723
+ const shared = shareBetweenBuckets(new Map([...spansByBucket.entries()].map(([id, spans]) => [id, mergeSpansWithinDays(spans)])), {
724
+ attributed: (id) => metaByBucket.get(id)?.kind === "attributed",
725
+ coveredByCertain: (id, activeIds) => {
726
+ const held = metaByBucket.get(id);
727
+ return held?.kind === "withheld" && activeIds.some((other) => {
728
+ const certain = metaByBucket.get(other);
729
+ return certain?.kind === "attributed" && certain.ticketKey === held.ticketKey;
730
+ });
731
+ },
732
+ dwellSeconds: options?.dwellSeconds ?? DEFAULT_DWELL_SECONDS
733
+ });
734
+ const attributed = [];
735
+ const withheld = [];
736
+ const unattributed = [];
737
+ for (const [id, perDay] of shared) {
738
+ const meta = metaByBucket.get(id);
739
+ if (meta === undefined)
740
+ continue;
741
+ for (const [day, credit] of perDay) {
742
+ if (credit.seconds <= 0)
743
+ continue;
744
+ const daySessions = sessionsByBucketDay.get(dayKey(id, day)) ?? meta.sessions;
745
+ const dayEvidence = evidenceByBucketDay.get(dayKey(id, day));
746
+ if (dayEvidence === undefined) {
747
+ throw new MissingDayAttributionError({ message: "Scheduled credit has no same-day evidence" });
748
+ }
749
+ if (meta.kind === "unattributed") {
750
+ const cwds = [
751
+ ...new Set([...daySessions].flatMap((sessionId) => {
752
+ const cwd = options?.cwdBySession?.get(sessionId);
753
+ return cwd === undefined ? [] : [cwd];
754
+ }))
755
+ ].sort();
756
+ unattributed.push({ day, seconds: credit.seconds, sessionCount: daySessions.size, cwds });
757
+ continue;
758
+ }
759
+ const row = {
760
+ ticketKey: meta.ticketKey ?? "",
761
+ day,
762
+ seconds: credit.seconds,
763
+ signal: dayEvidence.signal,
764
+ confidence: dayEvidence.confidence,
765
+ activeSeconds: credit.activeSeconds,
766
+ blocks: credit.blocks,
767
+ sourceStartMs: credit.sourceStartMs,
768
+ settlementEndMs: credit.settlementEndMs,
769
+ sessionIds: [...daySessions].sort()
770
+ };
771
+ if (meta.kind === "withheld")
772
+ withheld.push(row);
773
+ else
774
+ attributed.push(row);
775
+ }
776
+ }
777
+ const byDayThenTicket = (a, b) => (a.day === b.day ? a.ticketKey.localeCompare(b.ticketKey) : a.day.localeCompare(b.day));
778
+ return {
779
+ attributed: attributed.sort(byDayThenTicket),
780
+ withheld: withheld.sort(byDayThenTicket),
781
+ unattributed: unattributed.sort((a, b) => a.day.localeCompare(b.day))
782
+ };
783
+ };
784
+ /** Both systems: what reconciliation means unless someone says otherwise. */
785
+ export const bothSides = { clockify: true, jira: true };
786
+ export const buildSessionProposals = (credits, recorded, options) => {
787
+ const sides = options.sides ?? bothSides;
788
+ const excluded = new Set(options.excludedDays);
789
+ const recordedByBucket = new Map(recorded.map((r) => [`${r.ticketKey}\u0000${r.day}`, r]));
790
+ const consumptionRows = [
791
+ ...recorded.map((row) => ({ ticketKey: row.ticketKey, day: row.day, intervals: row.intervals ?? [] })),
792
+ ...(options.consumptionRows ?? [])
793
+ ];
794
+ const gap = (sessionSeconds, recordedSeconds, minimumSeconds) => {
795
+ const delta = sessionSeconds - recordedSeconds;
796
+ return delta >= minimumSeconds ? delta : 0;
797
+ };
798
+ return credits
799
+ .filter((credit) => !excluded.has(credit.day))
800
+ .map((credit) => {
801
+ const existing = recordedByBucket.get(`${credit.ticketKey}\u0000${credit.day}`);
802
+ const clockifySeconds = existing?.clockifySeconds ?? 0;
803
+ const jiraSeconds = existing?.jiraSeconds ?? 0;
804
+ const sourceRowId = `${credit.day}:${credit.ticketKey}`;
805
+ const heldByBlock = SourceConsumption.consumptionForBlocks(consumptionRows, sourceRowId, credit.blocks, sides, options.sourceEntries);
806
+ const ambiguousClockify = SourceConsumption.unlinkedClockifyOverlaps(credit.blocks, options.consumptionRows ?? [], options.sourceEntries);
807
+ const blocks = credit.blocks.map((block, index) => {
808
+ const held = heldByBlock[index] ?? { clockify: 0, jira: 0 };
809
+ return {
810
+ ...block,
811
+ ...(sides.clockify && ambiguousClockify[index] && { clockifyRefusal: "unlinked-overlap" }),
812
+ ...(held.clockify > 0 && { clockifyConsumedSeconds: Math.min(block.seconds, held.clockify) }),
813
+ ...(held.jira > 0 && { jiraConsumedSeconds: Math.min(block.seconds, held.jira) })
814
+ };
815
+ });
816
+ const correctedClockifySeconds = blocks.reduce((sum, block) => sum + (block.clockifyConsumedSeconds ?? 0), 0);
817
+ const correctedJiraSeconds = blocks.reduce((sum, block) => sum + (block.jiraConsumedSeconds ?? 0), 0);
818
+ return {
819
+ ticketKey: credit.ticketKey,
820
+ day: credit.day,
821
+ signal: credit.signal,
822
+ confidence: credit.confidence,
823
+ blocks,
824
+ sourceStartMs: credit.sourceStartMs,
825
+ settlementEndMs: credit.settlementEndMs,
826
+ sessionSeconds: credit.seconds,
827
+ activeSeconds: credit.activeSeconds,
828
+ clockifySeconds,
829
+ jiraSeconds,
830
+ recordedIntervals: (existing?.intervals ?? []).flatMap((interval) => interval.source === undefined || interval.startMs === undefined || interval.endMs === undefined
831
+ ? []
832
+ : [{ source: interval.source, startMs: interval.startMs, endMs: interval.endMs }]),
833
+ // Zero for a side that is out of scope, never the whole day: its tally was not read, so the
834
+ // only defensible statement about its gap is that this run makes none.
835
+ clockifyDelta: sides.clockify ? gap(credit.seconds, clockifySeconds + correctedClockifySeconds, 1) : 0,
836
+ jiraDelta: sides.jira ? gap(credit.seconds, jiraSeconds + correctedJiraSeconds, options.minimumSeconds) : 0,
837
+ sessionIds: credit.sessionIds
838
+ };
839
+ })
840
+ .filter((proposal) => proposal.clockifyDelta > 0 || proposal.jiraDelta > 0);
841
+ };
842
+ // ---------------------------------------------------------------------------
843
+ // Session digest
844
+ // ---------------------------------------------------------------------------
845
+ const DEFAULT_DIGEST_CHARS = 4000;
846
+ /**
847
+ * A compact, bounded digest of a session's prompts for a Coding Agent to read. Takes text from
848
+ * the start of the session — where the task is stated — and stops at the character budget, so
849
+ * one very long transcript cannot dominate the prompt.
850
+ */
851
+ export const buildSessionDigest = (texts, options) => {
852
+ const maxChars = options?.maxChars ?? DEFAULT_DIGEST_CHARS;
853
+ const parts = [];
854
+ let length = 0;
855
+ for (const raw of texts) {
856
+ const text = raw.trim();
857
+ if (text === "")
858
+ continue;
859
+ const remaining = maxChars - length;
860
+ if (remaining <= 0)
861
+ break;
862
+ const clipped = text.length > remaining ? text.slice(0, remaining) : text;
863
+ parts.push(clipped);
864
+ length += clipped.length + 1;
865
+ }
866
+ return parts.join("\n");
867
+ };
868
+ //# sourceMappingURL=sessions.js.map