@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
@@ -16,47 +16,196 @@
16
16
  *
17
17
  * - Jira worklogs carry no Clockify id, so matching is heuristic (ticket + day), never entry-to-entry.
18
18
  * - Clockify entries must encode the ticket as `[KEY] …` or `KEY: …` in the description; entries
19
- * without a parseable key (or still running) are ignored.
19
+ * without a parseable key remain outside reconciliation proposals. Running entries are excluded from totals.
20
20
  * - Days are local calendar days so the buckets line up with how a person reads their timesheet.
21
21
  *
22
22
  * @module
23
23
  */
24
- import { ClockifyApiClient } from "@knpkv/clockify-api-client";
25
- import { JiraApiClient } from "@knpkv/jira-api-client";
24
+ import { ClockifyApi, make as makeClockifyApi } from "@knpkv/clockify-api-client";
25
+ import { JiraApiClient, make as makeJiraApi } from "@knpkv/jira-api-client";
26
26
  import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
27
+ import * as Clock from "effect/Clock";
27
28
  import * as Context from "effect/Context";
28
29
  import * as Data from "effect/Data";
29
30
  import * as Effect from "effect/Effect";
31
+ import * as FileSystem from "effect/FileSystem";
32
+ import * as HttpClient from "effect/http/HttpClient";
33
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
34
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
30
35
  import * as Layer from "effect/Layer";
36
+ import * as Path from "effect/Path";
31
37
  import * as Predicate from "effect/Predicate";
38
+ import * as Redacted from "effect/Redacted";
39
+ import * as Schema from "effect/Schema";
40
+ import { activeWindows, attributeSession, bothSides, buildSessionProposals, deterministicAttribution, expandHomePath, splitCredits } from "../agent/sessions.js";
41
+ import * as SourceConsumption from "../agent/sourceConsumption.js";
42
+ import * as WriterGuard from "../cli/writerGuard.js";
43
+ import { localDay, nextLocalMidnight, splitIntervalByLocalDay } from "../utils/time.js";
44
+ import { AgentSessionReader } from "./AgentSessionReader.js";
32
45
  import { ClockifyAuth } from "./ClockifyAuth.js";
33
46
  import { ConfigService } from "./ConfigService.js";
47
+ import { HomeDirectory } from "./HomeDirectory.js";
48
+ import { postJiraWorklog } from "./internal/JiraWorklogPost.js";
49
+ import { readJiraDeletionEvidence } from "./ProviderDeletion.js";
50
+ import { jiraWorklogDescription } from "./SavedEntries.js";
51
+ import { SessionAttributor } from "./SessionAttributor.js";
52
+ import { SourceLedger } from "./SourceLedger.js";
34
53
  import { TimerService } from "./TimerService.js";
54
+ export { bothSides } from "../agent/sessions.js";
55
+ const mergeIntervals = (intervals) => {
56
+ const sorted = [...intervals].sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
57
+ const merged = [];
58
+ for (const interval of sorted) {
59
+ const previous = merged.at(-1);
60
+ if (previous === undefined || interval.startMs > previous.endMs) {
61
+ merged.push(interval);
62
+ }
63
+ else if (interval.endMs > previous.endMs) {
64
+ merged[merged.length - 1] = { startMs: previous.startMs, endMs: interval.endMs };
65
+ }
66
+ }
67
+ return merged;
68
+ };
69
+ const subtractIntervals = (source, consumed) => {
70
+ let remaining = [source];
71
+ for (const interval of mergeIntervals(consumed)) {
72
+ remaining = remaining.flatMap((candidate) => {
73
+ if (interval.endMs <= candidate.startMs || interval.startMs >= candidate.endMs)
74
+ return [candidate];
75
+ return [
76
+ ...(interval.startMs > candidate.startMs
77
+ ? [{ startMs: candidate.startMs, endMs: Math.min(interval.startMs, candidate.endMs) }]
78
+ : []),
79
+ ...(interval.endMs < candidate.endMs
80
+ ? [{ startMs: Math.max(interval.endMs, candidate.startMs), endMs: candidate.endMs }]
81
+ : [])
82
+ ];
83
+ });
84
+ }
85
+ return remaining.filter((interval) => interval.endMs > interval.startMs);
86
+ };
35
87
  export class ReconcileError extends Data.TaggedError("ReconcileError") {
36
88
  }
89
+ /**
90
+ * Jira floors worklogs to the minute. Clockify proposals retain exact positive seconds;
91
+ * each provider's proposal floor matches its own confirmation floor.
92
+ */
93
+ const MINIMUM_PROPOSAL_SECONDS = 60;
94
+ /**
95
+ * How many Coding Agent calls may be in flight at once.
96
+ *
97
+ * Each call spawns a CLI process that takes tens of seconds, so running them one at a time makes
98
+ * the command look hung. But these processes are heavy and contend for the machine: measured at
99
+ * four, a batch took ~75s and every call in the *second* batch hit the timeout, turning
100
+ * parallelism into lost attributions. Two keeps calls comfortably inside their timeout while still
101
+ * halving the wall clock — faster in practice than four, because nothing is thrown away.
102
+ */
103
+ const ATTRIBUTOR_CONCURRENCY = 2;
104
+ /**
105
+ * Characters of digest a *description* prompt spends on one work item.
106
+ *
107
+ * Smaller than a whole session digest on purpose: what was worked on is stated early, and a bucket
108
+ * that spans four sessions must not spend four times the budget to say one sentence.
109
+ */
110
+ const DESCRIBE_DIGEST_CHARS = 3_000;
111
+ /**
112
+ * How much digest text one Coding Agent call may carry, and how many sessions.
113
+ *
114
+ * Sessions are batched because a call's cost is almost entirely fixed: measured against the real
115
+ * CLI, one session cost $0.080 and 6.3s while seven together cost $0.049 and 10.2s. Chunking rather
116
+ * than one giant call bounds two things — the prompt, and the damage a single timeout does, since a
117
+ * failed chunk costs the attributions of every session in it.
118
+ */
119
+ const ATTRIBUTOR_BATCH_CHARS = 24_000;
120
+ const ATTRIBUTOR_BATCH_SESSIONS = 8;
121
+ /** Split requests into calls, cut on whichever bound is reached first. */
122
+ const batchRequests = (requests) => {
123
+ const batches = [];
124
+ let current = [];
125
+ let chars = 0;
126
+ for (const request of requests) {
127
+ const tooManyChars = current.length > 0 && chars + request.digest.length > ATTRIBUTOR_BATCH_CHARS;
128
+ if (tooManyChars || current.length >= ATTRIBUTOR_BATCH_SESSIONS) {
129
+ batches.push(current);
130
+ current = [];
131
+ chars = 0;
132
+ }
133
+ current.push(request);
134
+ chars += request.digest.length;
135
+ }
136
+ if (current.length > 0)
137
+ batches.push(current);
138
+ return batches;
139
+ };
140
+ /**
141
+ * Provider totals that may size a write. Compatibility Jira rows remain visible, but cannot settle
142
+ * an executable gap until the same credential and account have been verified.
143
+ *
144
+ * @internal
145
+ */
146
+ export const recordedForExecution = (recorded, jiraAvailability) => jiraAvailability === undefined || jiraAvailability === "verified"
147
+ ? recorded
148
+ : recorded.map((row) => ({
149
+ ...row,
150
+ jiraSeconds: 0,
151
+ intervals: row.intervals.filter((interval) => interval.source !== "jira" && interval.entry?.source !== "jira")
152
+ }));
37
153
  export class ReconcileService extends Context.Service()("jcf/ReconcileService") {
38
154
  }
39
155
  // ---------------------------------------------------------------------------
40
156
  // Pure helpers (exported for testing)
41
157
  // ---------------------------------------------------------------------------
158
+ /**
159
+ * One digest per proposal: the sessions behind it, joined and bounded.
160
+ *
161
+ * Bounded twice over — each session's digest is already capped when the transcript is read, and a
162
+ * bucket that ran across several sessions is truncated again here, because a prompt that grows with
163
+ * the number of sessions in a day is a prompt that eventually times out.
164
+ */
165
+ export const bucketDigest = (proposal, digests) => {
166
+ const parts = [];
167
+ let length = 0;
168
+ for (const sessionId of proposal.sessionIds) {
169
+ const digest = digests.get(sessionId)?.trim();
170
+ if (digest === undefined || digest === "")
171
+ continue;
172
+ const remaining = DESCRIBE_DIGEST_CHARS - length;
173
+ if (remaining <= 0)
174
+ break;
175
+ const clipped = digest.length > remaining ? digest.slice(0, remaining) : digest;
176
+ parts.push(clipped);
177
+ length += clipped.length + 1;
178
+ }
179
+ return parts.join("\n");
180
+ };
181
+ /**
182
+ * Standing Attributions with `~` resolved, so the form the config command stores actually matches.
183
+ *
184
+ * `jcf config set session-ticket ~/dev/docs PROJ-42` deliberately keeps the `~` — a config file that
185
+ * reads back the way it was typed is the point — but attribution compares against an absolute
186
+ * working directory, where `~/dev/docs` can never match anything. Session Roots have always been
187
+ * expanded on read for exactly this reason; this is the same treatment for the other prefix.
188
+ */
189
+ export const expandedStandingMap = (map, home) => Object.fromEntries(Object.entries(map).map(([prefix, ticketKey]) => [expandHomePath(prefix, home), ticketKey]));
42
190
  /** Parse a ticket key from a Clockify description (`[KEY] summary` or `KEY: summary`). */
43
191
  export const parseTicketKey = (description) => {
44
192
  const desc = description ?? "";
45
- const bracket = desc.match(/^\[([^\]]+)\]/);
46
- if (bracket?.[1])
47
- return bracket[1].trim();
193
+ const bracket = desc.match(/^\[\s*([A-Za-z][A-Za-z0-9]*-\d+)\s*\]/);
194
+ const bracketKey = bracket?.[1];
195
+ if (bracketKey !== undefined && bracketKey !== "")
196
+ return bracketKey.trim();
48
197
  const colon = desc.match(/^([A-Za-z][A-Za-z0-9]*-\d+):/);
49
- if (colon?.[1])
50
- return colon[1].trim();
198
+ const colonKey = colon?.[1];
199
+ if (colonKey !== undefined && colonKey !== "")
200
+ return colonKey.trim();
51
201
  return null;
52
202
  };
53
- /** Local calendar day (`YYYY-MM-DD`) of an instant — matches how a timesheet reads. */
54
- export const localDay = (date) => {
55
- const y = date.getFullYear();
56
- const m = String(date.getMonth() + 1).padStart(2, "0");
57
- const d = String(date.getDate()).padStart(2, "0");
58
- return `${y}-${m}-${d}`;
59
- };
203
+ /**
204
+ * Local calendar day (`YYYY-MM-DD`) of an instant — matches how a timesheet reads.
205
+ * Re-exported from the shared time utilities so every day bucket in jcf (reconcile rows,
206
+ * Attributed Intervals, Proposed Worklogs) is keyed by one definition.
207
+ */
208
+ export { localDay };
60
209
  /** Strip the leading ticket marker (`[KEY] ` or `KEY: `) so the remainder reads as a plain note. */
61
210
  export const stripTicketPrefix = (description) => description
62
211
  .replace(/^\[[^\]]*\]\s*/, "")
@@ -72,7 +221,7 @@ export const combineDescriptions = (descriptions) => {
72
221
  const parts = [];
73
222
  for (const raw of descriptions) {
74
223
  const text = stripTicketPrefix(raw ?? "");
75
- if (text && !seen.has(text)) {
224
+ if (text !== "" && !seen.has(text)) {
76
225
  seen.add(text);
77
226
  parts.push(text);
78
227
  }
@@ -88,19 +237,35 @@ export const buildReconcileRows = (clockify, jira) => {
88
237
  const clockifyByKey = new Map();
89
238
  const jiraByKey = new Map();
90
239
  const descByKey = new Map();
240
+ const intervalsByKey = new Map();
91
241
  const meta = new Map();
242
+ const addInterval = (k, entry, source) => {
243
+ if (entry.startMs === undefined || entry.endMs === undefined)
244
+ return;
245
+ intervalsByKey.set(k, [
246
+ ...(intervalsByKey.get(k) ?? []),
247
+ {
248
+ endMs: entry.endMs,
249
+ source,
250
+ startMs: entry.startMs,
251
+ ...(entry.entry !== undefined && { entry: entry.entry })
252
+ }
253
+ ]);
254
+ };
92
255
  for (const e of clockify) {
93
256
  const k = key(e.ticketKey, e.day);
94
257
  clockifyByKey.set(k, (clockifyByKey.get(k) ?? 0) + e.seconds);
95
258
  const descs = descByKey.get(k) ?? [];
96
259
  descs.push(e.description);
97
260
  descByKey.set(k, descs);
261
+ addInterval(k, e, "clockify");
98
262
  if (!meta.has(k))
99
263
  meta.set(k, { ticketKey: e.ticketKey, day: e.day });
100
264
  }
101
265
  for (const e of jira) {
102
266
  const k = key(e.ticketKey, e.day);
103
267
  jiraByKey.set(k, (jiraByKey.get(k) ?? 0) + e.seconds);
268
+ addInterval(k, e, "jira");
104
269
  if (!meta.has(k))
105
270
  meta.set(k, { ticketKey: e.ticketKey, day: e.day });
106
271
  }
@@ -110,10 +275,18 @@ export const buildReconcileRows = (clockify, jira) => {
110
275
  day,
111
276
  clockifySeconds: clockifyByKey.get(k) ?? 0,
112
277
  jiraSeconds: jiraByKey.get(k) ?? 0,
113
- clockifyDescription: combineDescriptions(descByKey.get(k) ?? [])
278
+ clockifyDescription: combineDescriptions(descByKey.get(k) ?? []),
279
+ intervals: (intervalsByKey.get(k) ?? []).sort((a, b) => a.startMs - b.startMs)
114
280
  }))
115
281
  .sort((a, b) => (a.day === b.day ? a.ticketKey.localeCompare(b.ticketKey) : a.day.localeCompare(b.day)));
116
282
  };
283
+ const ReadableWorklogFields = Schema.Struct({
284
+ id: Schema.String.check(Schema.isPattern(/\S/u)),
285
+ author: Schema.Struct({ accountId: Schema.NonEmptyString }),
286
+ started: Schema.NonEmptyString,
287
+ timeSpentSeconds: Schema.Natural
288
+ });
289
+ const isReadableWorklog = Schema.is(ReadableWorklogFields);
117
290
  const issueKey = (issue) => {
118
291
  if (!Predicate.isObject(issue))
119
292
  return null;
@@ -126,116 +299,1134 @@ const toRawWorklog = (value) => {
126
299
  const author = Predicate.isObject(value.author) ? value.author : undefined;
127
300
  const accountId = author?.accountId;
128
301
  return {
302
+ ...((Predicate.isString(value.id)) && { id: value.id }),
303
+ comment: value.comment,
129
304
  ...((Predicate.isString(accountId)) && { author: { accountId } }),
130
305
  ...((Predicate.isString(value.started)) && { started: value.started }),
131
306
  ...((Predicate.isNumber(value.timeSpentSeconds)) && { timeSpentSeconds: value.timeSpentSeconds })
132
307
  };
133
308
  };
134
- const toRawWorklogs = (response) => {
135
- if (!Predicate.isObject(response) || !Array.isArray(response.worklogs))
136
- return [];
137
- return response.worklogs.flatMap((worklog) => {
138
- const parsed = toRawWorklog(worklog);
139
- return parsed ? [parsed] : [];
140
- });
141
- };
142
309
  export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
143
- const clockify = yield* ClockifyApiClient;
144
310
  const clockifyAuth = yield* ClockifyAuth;
145
311
  const jira = yield* JiraApiClient;
146
312
  const jiraAuth = yield* JiraAuth;
313
+ const httpClient = yield* HttpClient.HttpClient;
147
314
  const config = yield* ConfigService;
315
+ const fileSystem = yield* FileSystem.FileSystem;
316
+ const path = yield* Path.Path;
317
+ const home = (yield* HomeDirectory).path;
148
318
  const timer = yield* TimerService;
319
+ const sessionReader = yield* AgentSessionReader;
320
+ const attributor = yield* SessionAttributor;
321
+ const sourceLedger = yield* SourceLedger;
322
+ const provideGuard = (effect) => effect.pipe(Effect.provideService(ConfigService, config), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path));
149
323
  const getAuth = clockifyAuth.getConfig.pipe(Effect.mapError((e) => new ReconcileError({ message: e.message })));
150
- // Tally Clockify entries in the period by (ticket, day).
151
- const clockifyTally = (period) => Effect.gen(function* () {
324
+ /** One decoded credential and endpoint back every Clockify read and write in this operation. */
325
+ const clockifyWriteSnapshot = Effect.gen(function* () {
152
326
  const auth = yield* getAuth;
153
- const entries = yield* clockify.getTimeEntries(auth.workspaceId, auth.userId, {
154
- start: period.from.toISOString(),
155
- end: period.to.toISOString()
327
+ const endpoint = yield* Effect.try({
328
+ try: () => new URL(auth.baseUrl),
329
+ catch: (cause) => new ReconcileError({ message: "Configured Clockify endpoint is invalid", cause })
330
+ });
331
+ if (!["https:", "http:"].includes(endpoint.protocol) || endpoint.username !== "" ||
332
+ endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "")
333
+ return yield* new ReconcileError({ message: "Configured Clockify endpoint is invalid" });
334
+ const canonicalEndpoint = `${endpoint.origin}${endpoint.pathname.replace(/\/+$/u, "")}`;
335
+ const pinnedAuth = { ...auth, baseUrl: canonicalEndpoint };
336
+ const client = makeClockifyApi(httpClient, pinnedAuth);
337
+ const user = yield* client.getLoggedUser(undefined).pipe(Effect.mapError((cause) => new ReconcileError({ message: "Cannot verify the Clockify account", cause })));
338
+ if (user.id === "" || auth.userId !== user.id || auth.workspaceId === "" || auth.baseUrl === "") {
339
+ return yield* new ReconcileError({ message: "Configured Clockify account does not match the credential" });
340
+ }
341
+ return {
342
+ auth: pinnedAuth,
343
+ client,
344
+ scope: JSON.stringify(["clockify-v3", canonicalEndpoint, auth.workspaceId, user.id]),
345
+ legacyScope: JSON.stringify([auth.workspaceId, user.id])
346
+ };
347
+ });
348
+ /** Bind verification and worklog POST to one OAuth credential and selected site. */
349
+ const jiraWriteState = Effect.gen(function* () {
350
+ const login = yield* Effect.result(jiraAuth.isLoggedIn());
351
+ if (login._tag === "Failure")
352
+ return { availability: "unverified", snapshot: null };
353
+ if (!login.success)
354
+ return { availability: "not-logged-in", snapshot: null };
355
+ const tokenResult = yield* Effect.result(jiraAuth.getAccessToken());
356
+ const profileResult = yield* Effect.result(jiraAuth.getActiveProfile());
357
+ if (tokenResult._tag === "Failure" || profileResult._tag === "Failure") {
358
+ return { availability: "unverified", snapshot: null };
359
+ }
360
+ const token = tokenResult.success;
361
+ const profile = profileResult.success;
362
+ if (profile === null || profile.token.cloud_id === "" || profile.token.site_url === "" ||
363
+ profile.token.access_token !== Redacted.value(token))
364
+ return { availability: "unverified", snapshot: null };
365
+ const client = makeJiraApi(httpClient, {
366
+ baseUrl: "",
367
+ auth: { type: "oauth2", accessToken: token, cloudId: profile.token.cloud_id }
368
+ });
369
+ const live = yield* client.getCurrentUser({}).pipe(Effect.orElseSucceed(() => null));
370
+ if (live?.accountId === undefined || live.accountId === "") {
371
+ return { availability: "unverified", snapshot: null };
372
+ }
373
+ return {
374
+ availability: "verified",
375
+ snapshot: {
376
+ client,
377
+ ledgerScope: JSON.stringify([profile.token.cloud_id, live.accountId]),
378
+ heldScope: JSON.stringify([profile.token.cloud_id, profile.token.site_url, live.accountId]),
379
+ accountId: live.accountId,
380
+ cloudId: profile.token.cloud_id,
381
+ siteUrl: profile.token.site_url
382
+ }
383
+ };
384
+ });
385
+ const jiraWriteSnapshot = jiraWriteState.pipe(Effect.map((state) => state.snapshot));
386
+ /**
387
+ * How many entries to ask Clockify for at a time, and how many pages to accept.
388
+ *
389
+ * The page size is explicit because Clockify's own default is 50 — small enough that an ordinary
390
+ * week silently arrives truncated, and a truncated *recorded* side is the dangerous direction:
391
+ * every caller subtracts this tally from something and writes the difference, so an entry that
392
+ * fell off the page reads as time Clockify never had.
393
+ */
394
+ const CLOCKIFY_PAGE_SIZE = 200;
395
+ const CLOCKIFY_MAX_PAGES = 50;
396
+ // Tally Clockify entries in the period by (ticket, day).
397
+ const clockifyTally = (period, pinned) => Effect.gen(function* () {
398
+ const { auth, client } = pinned ?? (yield* clockifyWriteSnapshot);
399
+ const getPage = (page) => client.getTimeEntries(auth.workspaceId, auth.userId, {
400
+ params: {
401
+ start: period.from.toISOString(),
402
+ end: period.to.toISOString(),
403
+ page,
404
+ "page-size": CLOCKIFY_PAGE_SIZE
405
+ }
156
406
  }).pipe(Effect.mapError((e) => new ReconcileError({ message: `Clockify fetch failed: ${e.message}`, cause: e })));
407
+ const entries = [];
408
+ let pages = 0;
409
+ // Stops on an *empty* page rather than a short one. Clockify is free to serve fewer entries
410
+ // than the page size asked for, and treating a short page as the last one would truncate
411
+ // exactly as reading a single page did — just less often, and so less visibly.
412
+ for (; pages < CLOCKIFY_MAX_PAGES; pages++) {
413
+ const batch = yield* getPage(pages + 1);
414
+ if (batch.length === 0)
415
+ break;
416
+ for (const entry of batch)
417
+ entries.push(entry);
418
+ }
419
+ if (pages === CLOCKIFY_MAX_PAGES) {
420
+ // Failing costs a run. Proceeding on a partial tally costs someone a duplicated day.
421
+ return yield* new ReconcileError({
422
+ message: "Clockify returned more entries than this run will read; narrow the window."
423
+ });
424
+ }
157
425
  const tally = [];
426
+ const unlinked = [];
427
+ const fromMs = period.from.getTime();
428
+ const toMs = period.to.getTime();
158
429
  for (const entry of entries) {
159
430
  const ticketKey = parseTicketKey(entry.description);
160
431
  const start = entry.timeInterval?.start;
161
432
  const end = entry.timeInterval?.end;
162
- if (!ticketKey || !start || !end)
163
- continue; // skip running / unparseable entries
164
- const seconds = Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 1000));
165
- tally.push({ ticketKey, day: localDay(new Date(start)), seconds, description: entry.description ?? null });
433
+ // A missing end is a running timer. A completed entry with missing or malformed bounds
434
+ // cannot be treated as absent: doing so makes its time look available for another write.
435
+ if (end === undefined || end === null)
436
+ continue;
437
+ if (start === undefined || start === null) {
438
+ return yield* new ReconcileError({ message: "Clockify returned an incomplete entry" });
439
+ }
440
+ const startMs = new Date(start).getTime();
441
+ const endMs = new Date(end).getTime();
442
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
443
+ return yield* new ReconcileError({ message: "Clockify returned an incomplete entry" });
444
+ }
445
+ if (endMs <= fromMs || startMs >= toMs)
446
+ continue;
447
+ for (const bucket of splitIntervalByLocalDay(Math.max(startMs, fromMs), Math.min(Math.max(startMs, endMs), toMs))) {
448
+ const slice = {
449
+ ...bucket,
450
+ description: entry.description ?? null,
451
+ entry: {
452
+ id: entry.id,
453
+ source: "clockify",
454
+ ticketKey,
455
+ startMs,
456
+ endMs,
457
+ description: entry.description ?? null
458
+ }
459
+ };
460
+ if (ticketKey === null)
461
+ unlinked.push(slice);
462
+ else
463
+ tally.push({ ...slice, ticketKey });
464
+ }
465
+ }
466
+ return { tally, unlinked };
467
+ });
468
+ /** Read every Jira page before treating an absent provider ID as deletion evidence. */
469
+ const readJiraWorklogs = Effect.fn("ReconcileService.readJiraWorklogs")(function* (client, issueKey, startedAfter, startedBefore) {
470
+ const worklogs = [];
471
+ const seenIds = new Set();
472
+ let nextStartAt = 0;
473
+ let expectedTotal;
474
+ for (;;) {
475
+ const page = yield* client.getIssueWorklog(issueKey, {
476
+ params: { startAt: nextStartAt, startedAfter, startedBefore }
477
+ }).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
478
+ const { maxResults, startAt, total, worklogs: entries } = page;
479
+ if (startAt !== nextStartAt ||
480
+ maxResults === undefined || !Number.isSafeInteger(maxResults) || maxResults < 0 ||
481
+ total === undefined || !Number.isSafeInteger(total) || total < 0 ||
482
+ entries === undefined || entries.length > maxResults ||
483
+ (expectedTotal !== undefined && total !== expectedTotal) ||
484
+ startAt + entries.length > total ||
485
+ (entries.length === 0 && startAt < total)) {
486
+ return yield* new ReconcileError({ message: `Jira returned an incomplete worklog page for ${issueKey}` });
487
+ }
488
+ expectedTotal = total;
489
+ for (const entry of entries) {
490
+ const parsed = toRawWorklog(entry);
491
+ if (parsed?.id === undefined) {
492
+ return yield* new ReconcileError({
493
+ message: `Jira returned a worklog without an ID for ${issueKey}; session consumption needs manual review`
494
+ });
495
+ }
496
+ if (!isReadableWorklog(parsed) ||
497
+ !Number.isFinite(new Date(parsed.started).getTime()) ||
498
+ seenIds.has(parsed.id)) {
499
+ return yield* new ReconcileError({ message: `Jira returned inconsistent worklogs for ${issueKey}` });
500
+ }
501
+ seenIds.add(parsed.id);
502
+ worklogs.push(parsed);
503
+ }
504
+ nextStartAt += entries.length;
505
+ if (nextStartAt === total)
506
+ return worklogs;
166
507
  }
167
- return tally;
168
508
  });
169
509
  // Tally the current user's Jira worklogs in the period by (ticket, day).
170
- const jiraTally = (period) => Effect.gen(function* () {
171
- const user = yield* jiraAuth.getCurrentUser().pipe(Effect.catch(() => Effect.succeed(null)));
172
- const accountId = user?.account_id ?? null;
173
- const fromDay = localDay(period.from);
174
- const toDay = localDay(period.to);
510
+ const jiraTally = (period, requiredIssueKeys = [], pinned = undefined) => Effect.gen(function* () {
511
+ // A verified snapshot pins the account, site and credential-backed client for every page.
512
+ // Falling back is only for a side without a usable write credential; that side cannot be
513
+ // written because its source scope remains null, but independent providers may still proceed.
514
+ const snapshot = pinned === undefined ? yield* jiraWriteSnapshot : pinned;
515
+ const client = snapshot?.client ?? jira;
516
+ const cachedAccountId = (yield* jiraAuth.getCurrentUser().pipe(Effect.mapError((cause) => new ReconcileError({ message: "Could not read the Jira account", cause }))))?.account_id;
517
+ if (snapshot !== null && snapshot !== undefined && cachedAccountId !== snapshot.accountId) {
518
+ return yield* new ReconcileError({
519
+ message: "The cached Jira account does not match the verified credential"
520
+ });
521
+ }
522
+ const accountId = snapshot?.accountId ?? cachedAccountId;
523
+ if (accountId === undefined || accountId === "") {
524
+ return yield* new ReconcileError({
525
+ message: "Jira account identity is unavailable; recorded time cannot be tallied"
526
+ });
527
+ }
528
+ const fromMs = period.from.getTime();
529
+ const from = period.from;
530
+ // Jira indexes a worklog by its start day. Include the prior local day so an overnight
531
+ // worklog can contribute the slice that overlaps this period's first day.
532
+ const lookupFromMs = new Date(from.getFullYear(), from.getMonth(), from.getDate() - 1).getTime();
533
+ const fromDay = localDay(new Date(lookupFromMs));
534
+ // `period.to` is exclusive and `worklogDate <=` is not, so the last *included* instant is
535
+ // what names the day. Taking the endpoint's own day asked Jira for the next day's issues as
536
+ // well — harmless in the tally, which filters by instant, but every extra issue is another
537
+ // worklog request, and one failure there fails the whole run by design.
538
+ const toDay = localDay(new Date(Math.max(period.from.getTime(), period.to.getTime() - 1)));
175
539
  // Find issues the user logged work on in the window.
176
- const search = yield* jira.searchIssuesUsingJql({
540
+ // Every page, not just the first. A ticket that fell off page one tallies as zero Jira time,
541
+ // and since every caller subtracts this from what a session accounts for, that reads as
542
+ // "Jira is short by the whole day" — which `watch` would then post on top of hours Jira
543
+ // already holds. A window of more than a hundred distinct issues is an ordinary week.
544
+ const jql = `worklogAuthor = currentUser() AND worklogDate >= "${fromDay}" AND worklogDate <= "${toDay}"`;
545
+ const searchPage = (pageToken) => client.searchIssuesUsingJql({
177
546
  params: {
178
- jql: `worklogAuthor = currentUser() AND worklogDate >= "${fromDay}" AND worklogDate <= "${toDay}"`,
547
+ jql,
179
548
  maxResults: 100,
180
- fields: ["key"]
549
+ fields: ["key"],
550
+ ...((pageToken !== undefined) && { nextPageToken: pageToken })
181
551
  }
182
552
  }).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
183
- const issueKeys = (search.issues ?? []).flatMap((issue) => {
553
+ const searched = [];
554
+ let pageToken = undefined;
555
+ // Bounded so a server that keeps handing back a token cannot spin here for ever; hitting the
556
+ // bound fails the run rather than proceeding on a partial tally.
557
+ for (let page = 0; page < 50; page++) {
558
+ const result = yield* searchPage(pageToken);
559
+ const issues = Predicate.isObject(result) ? result["issues"] : undefined;
560
+ // A page with no `issues` at all is not an empty page. The generated success schema makes
561
+ // the field optional, so a response Jira truncated or a shape that changed under us would
562
+ // otherwise read as "this user logged no work" — and every caller subtracts that from what
563
+ // a session accounts for before writing the difference.
564
+ if (!Array.isArray(issues)) {
565
+ return yield* new ReconcileError({
566
+ message: "Jira returned a worklog search page with no issues field; not tallying a partial result."
567
+ });
568
+ }
569
+ for (const issue of issues)
570
+ searched.push(issue);
571
+ const next = Predicate.isObject(result) && Predicate.isString(result["nextPageToken"])
572
+ ? result["nextPageToken"]
573
+ : undefined;
574
+ if (Predicate.isObject(result) && result["isLast"] === false && (next === undefined || next.trim() === "")) {
575
+ return yield* new ReconcileError({ message: "Jira returned an incomplete Jira search page" });
576
+ }
577
+ pageToken = next;
578
+ if (next === undefined)
579
+ break;
580
+ }
581
+ if (pageToken !== undefined) {
582
+ return yield* new ReconcileError({
583
+ message: "Jira returned more worklog pages than this run will read; narrow the window."
584
+ });
585
+ }
586
+ // An issue whose key cannot be read is an issue whose worklogs go unread, which is the same
587
+ // under-count as a dropped page — so it fails rather than being skipped.
588
+ const issueKeys = [];
589
+ for (const issue of searched) {
184
590
  const key = issueKey(issue);
185
- return key ? [key] : [];
186
- });
187
- const fromMs = period.from.getTime();
591
+ if (key === null) {
592
+ return yield* new ReconcileError({
593
+ message: "Jira returned a worklog search result with no issue key; not tallying a partial result."
594
+ });
595
+ }
596
+ issueKeys.push(key);
597
+ }
598
+ for (const key of requiredIssueKeys) {
599
+ if (!issueKeys.includes(key))
600
+ issueKeys.push(key);
601
+ }
188
602
  const toMs = period.to.getTime();
189
603
  const tally = [];
190
604
  for (const issueKey of issueKeys) {
191
- const worklogs = yield* jira.getIssueWorklog(issueKey, {
192
- params: {
193
- startedAfter: fromMs,
194
- startedBefore: toMs
195
- }
196
- }).pipe(Effect.map(toRawWorklogs), Effect.catch(() => Effect.succeed([])));
605
+ const worklogs = yield* readJiraWorklogs(client, issueKey, lookupFromMs - 1, toMs);
197
606
  for (const wl of worklogs) {
198
- if (!wl.started || !Predicate.isNumber(wl.timeSpentSeconds))
199
- continue;
200
607
  // Only this user's worklogs (the JQL narrows issues, not individual worklog authors).
201
- if (accountId && wl.author?.accountId && wl.author.accountId !== accountId)
608
+ const author = wl.author?.accountId;
609
+ if (author !== accountId)
202
610
  continue;
203
611
  const startedMs = new Date(wl.started).getTime();
204
- if (startedMs < fromMs || startedMs >= toMs)
612
+ const endedMs = startedMs + wl.timeSpentSeconds * 1000;
613
+ if (endedMs <= fromMs || startedMs >= toMs)
205
614
  continue;
206
- tally.push({ ticketKey: issueKey, day: localDay(new Date(wl.started)), seconds: wl.timeSpentSeconds });
615
+ const entry = wl.id === undefined
616
+ ? undefined
617
+ : {
618
+ id: wl.id,
619
+ source: "jira",
620
+ ticketKey: issueKey,
621
+ startMs: startedMs,
622
+ endMs: endedMs,
623
+ description: yield* Schema.decodeUnknownEffect(Schema.UndefinedOr(Schema.Json))(wl.comment).pipe(Effect.flatMap(jiraWorklogDescription), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })))
624
+ };
625
+ // Clockify and session evidence use local calendar slices. Apply the same accounting
626
+ // policy to Jira's duration even though Jira stores it as one start-day worklog, or a
627
+ // matching overnight pair becomes two opposite discrepancies.
628
+ for (const bucket of splitIntervalByLocalDay(Math.max(startedMs, fromMs), Math.min(endedMs, toMs))) {
629
+ tally.push({
630
+ ticketKey: issueKey,
631
+ ...(entry !== undefined && { entry }),
632
+ ...bucket
633
+ });
634
+ }
207
635
  }
208
636
  }
209
637
  return tally;
210
638
  });
211
- const compare = (period) => Effect.gen(function* () {
212
- const [clockifySide, jiraSide] = yield* Effect.all([clockifyTally(period), jiraTally(period)], {
213
- concurrency: 2
639
+ const readRecorded = (period, options) => Effect.gen(function* () {
640
+ const sides = options?.sides ?? bothSides;
641
+ // A side that is out is not called at all. That is the point of the option for someone who
642
+ // tracks in one system: no Clockify workspace to configure, no Jira login to keep alive, and
643
+ // no request whose failure could stop a run that never needed it.
644
+ const jiraSideRead = sides.jira
645
+ ? jiraTally(period, options?.jiraIssueKeys, options?.jiraSnapshot)
646
+ : Effect.succeed([]);
647
+ const [clockifySide, jiraSide] = yield* Effect.all([
648
+ sides.clockify
649
+ ? clockifyTally(period, options?.clockifySnapshot)
650
+ : Effect.succeed({ tally: [], unlinked: [] }),
651
+ options?.tolerateUnavailableJira === true
652
+ ? jiraSideRead.pipe(Effect.catch(() => Effect.succeed([])))
653
+ : jiraSideRead
654
+ ], { concurrency: 2 });
655
+ return { recorded: buildReconcileRows(clockifySide.tally, jiraSide), unlinkedClockify: clockifySide.unlinked };
656
+ });
657
+ const compare = (period, options) => readRecorded(period, options).pipe(Effect.map((result) => result.recorded));
658
+ /**
659
+ * Read one bound Clockify entry without the generated client's lossy unexpected-status fallback.
660
+ *
661
+ * Clockify's pinned OpenAPI contract declares no typed not-found response for this operation.
662
+ * A non-2xx response therefore remains uncertain even when its numeric status is 404.
663
+ */
664
+ const readClockifyBinding = (binding, snapshot) => snapshot.client.httpClient.execute(HttpClientRequest.get(`/v1/workspaces/${snapshot.auth.workspaceId}/time-entries/${binding.entryId}`).pipe(HttpClientRequest.setUrlParam("hydrated", "false"))).pipe(Effect.flatMap((response) => response.status >= 200 && response.status < 300
665
+ ? HttpClientResponse.schemaBodyJson(ClockifyApi.GetTimeEntry200)(response).pipe(Effect.mapError((cause) => new ReconcileError({ message: "Clockify returned incomplete bound-entry evidence", cause })))
666
+ : Effect.fail(new ReconcileError({ message: "Clockify did not provide a verifiable bound-entry response" }))), Effect.mapError((cause) => Predicate.isTagged(cause, "ReconcileError")
667
+ ? cause
668
+ : new ReconcileError({ message: "Could not verify the current Clockify bound entry", cause })));
669
+ /** A targeted read can verify presence; ambiguous misses keep the bound credit held. */
670
+ const readBindingEvidence = (binding, currentEntries, clockifySnapshot, jiraSnapshot) => {
671
+ const current = currentEntries.find((entry) => entry.source === binding.provider && entry.id === binding.entryId);
672
+ if (current !== undefined)
673
+ return Effect.succeed({ _tag: "Present", entry: current });
674
+ const read = binding.provider === "clockify"
675
+ ? readClockifyBinding(binding, clockifySnapshot).pipe(Effect.flatMap((entry) => {
676
+ const start = entry.timeInterval.start;
677
+ const end = entry.timeInterval.end;
678
+ const startMs = new Date(start ?? "").getTime();
679
+ const endMs = new Date(end ?? "").getTime();
680
+ if (entry.id !== binding.entryId || entry.userId !== clockifySnapshot.auth.userId ||
681
+ entry.workspaceId !== clockifySnapshot.auth.workspaceId || end === undefined ||
682
+ !Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
683
+ return Effect.fail(new ReconcileError({
684
+ message: "Clockify returned incomplete bound-entry evidence"
685
+ }));
686
+ }
687
+ return Effect.succeed({
688
+ _tag: "Present",
689
+ entry: {
690
+ id: entry.id,
691
+ source: "clockify",
692
+ ticketKey: parseTicketKey(entry.description),
693
+ startMs,
694
+ endMs,
695
+ description: entry.description
696
+ }
697
+ });
698
+ }))
699
+ : jiraSnapshot.client.getWorklog(binding.ticketKey, binding.entryId, undefined).pipe(Effect.flatMap((value) => {
700
+ const worklog = toRawWorklog(value);
701
+ if (worklog === null || !isReadableWorklog(worklog) || worklog.id !== binding.entryId ||
702
+ worklog.author.accountId !== jiraSnapshot.accountId) {
703
+ return Effect.fail(new ReconcileError({ message: "Jira returned incomplete bound-worklog evidence" }));
704
+ }
705
+ const startMs = new Date(worklog.started).getTime();
706
+ const endMs = startMs + worklog.timeSpentSeconds * 1000;
707
+ if (!Number.isFinite(startMs) || endMs <= startMs) {
708
+ return Effect.fail(new ReconcileError({ message: "Jira returned incomplete bound-worklog evidence" }));
709
+ }
710
+ return Effect.succeed({
711
+ _tag: "Present",
712
+ entry: {
713
+ id: worklog.id,
714
+ source: "jira",
715
+ ticketKey: binding.ticketKey,
716
+ startMs,
717
+ endMs,
718
+ description: null
719
+ }
720
+ });
721
+ }), Effect.catchTag("GetWorklog404", () => Effect.gen(function* () {
722
+ if (binding.jiraCreatedAtMs === undefined) {
723
+ return yield* new ReconcileError({
724
+ message: "Jira bound entry has no provider-created checkpoint; deletion remains unproved"
725
+ });
726
+ }
727
+ const evidence = yield* readJiraDeletionEvidence(jiraSnapshot.client, binding.entryId, binding.jiraCreatedAtMs).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message })));
728
+ if (evidence._tag === "Unproved") {
729
+ return yield* new ReconcileError({ message: "Jira bound entry has no verified deletion event" });
730
+ }
731
+ const current = yield* jiraWriteState;
732
+ if (current.snapshot === null || current.snapshot.heldScope !== jiraSnapshot.heldScope) {
733
+ return yield* new ReconcileError({ message: "Jira account changed during deletion verification" });
734
+ }
735
+ // A deletion event is historical. Jira's targeted 404 also covers lost visibility,
736
+ // so neither fact proves the entry is still absent after an out-of-band restore.
737
+ return yield* new ReconcileError({
738
+ message: "Jira deleted-worklog event is verified, but current absence is not"
739
+ });
740
+ })));
741
+ return read.pipe(Effect.mapError((cause) => new ReconcileError({ message: `Could not verify the current ${binding.provider} bound entry`, cause })));
742
+ };
743
+ const directionSource = (recorded, unlinkedClockify, clockifySnapshot, jiraSnapshot) => (row, direction, seconds) => Effect.gen(function* () {
744
+ const sourceProvider = direction === "clockify-to-jira" ? "clockify" : "jira";
745
+ const targetProvider = sourceProvider === "clockify" ? "jira" : "clockify";
746
+ const sourceEntries = row.intervals
747
+ .flatMap(({ entry }) => entry?.source === sourceProvider ? [entry] : [])
748
+ .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.id === entry.id) === index);
749
+ const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
750
+ const hasMarker = sourceEntries.some((entry) => entry.description !== null && SourceConsumption.markers(entry.description).length > 0);
751
+ const mayBeBound = sourceEntries.some((entry) => stored.bindings.some((binding) => binding.provider === sourceProvider && binding.entryId === entry.id));
752
+ if (!hasMarker && !mayBeBound)
753
+ return { _tag: "Ordinary" };
754
+ if (jiraSnapshot === null) {
755
+ return {
756
+ _tag: "Refused",
757
+ message: "Direction provenance could not be verified for the current provider accounts"
758
+ };
759
+ }
760
+ const sourceScope = sourceProvider === "clockify" ? clockifySnapshot.scope : jiraSnapshot.ledgerScope;
761
+ const targetScope = targetProvider === "clockify" ? clockifySnapshot.scope : jiraSnapshot.ledgerScope;
762
+ const expectedTargetScope = targetProvider === "clockify" ? clockifySnapshot.scope : jiraSnapshot.heldScope;
763
+ const expectedSourceScope = sourceProvider === "clockify" ? clockifySnapshot.scope : jiraSnapshot.heldScope;
764
+ const sourceIds = new Set(sourceEntries.map((entry) => entry.id));
765
+ const verifiedSource = stored.bindings.filter((binding) => binding.provider === sourceProvider && binding.scope === sourceScope && sourceIds.has(binding.entryId));
766
+ if (verifiedSource.length === 0 ||
767
+ sourceEntries.some((entry) => entry.description !== null && SourceConsumption.markers(entry.description).length > 0 &&
768
+ !verifiedSource.some((binding) => binding.entryId === entry.id))) {
769
+ return { _tag: "Refused", message: "Direction source marker is not verified by the private ledger" };
770
+ }
771
+ const sourceKeys = new Map(verifiedSource.map((binding) => [
772
+ SourceConsumption.blockKey(binding.rowId, binding.sourceStartMs),
773
+ { rowId: binding.rowId, sourceStartMs: binding.sourceStartMs }
774
+ ]));
775
+ if (sourceKeys.size !== 1) {
776
+ return {
777
+ _tag: "Refused",
778
+ message: "Direction row combines more than one verified source block; split it manually before writing"
779
+ };
780
+ }
781
+ const sourceKey = sourceKeys.values().next().value;
782
+ if (sourceKey === undefined) {
783
+ return { _tag: "Refused", message: "Direction source binding is unavailable" };
784
+ }
785
+ const currentEntries = [
786
+ ...recorded.flatMap((candidate) => candidate.intervals.flatMap(({ entry }) => entry === undefined ? [] : [entry])),
787
+ ...unlinkedClockify.flatMap(({ entry }) => entry === undefined ? [] : [entry])
788
+ ].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.source === entry.source && candidate.id === entry.id) === index);
789
+ const sourceEvidence = yield* Effect.forEach(verifiedSource, (binding) => readBindingEvidence(binding, currentEntries, clockifySnapshot, jiraSnapshot), { concurrency: 2 });
790
+ const sourceIntervals = verifiedSource.flatMap((binding, index) => {
791
+ const evidence = sourceEvidence[index];
792
+ if (evidence?._tag !== "Present")
793
+ return [];
794
+ const startMs = Math.max(binding.startMs, evidence.entry.startMs);
795
+ const endMs = Math.min(binding.endMs, evidence.entry.endMs);
796
+ return endMs > startMs ? [{ startMs, endMs }] : [];
214
797
  });
215
- return buildReconcileRows(clockifySide, jiraSide);
798
+ const sourceAuthority = mergeIntervals(sourceIntervals);
799
+ if (sourceAuthority.length !== 1 || sourceIntervals.length !== verifiedSource.length) {
800
+ return {
801
+ _tag: "Refused",
802
+ message: "Direction source intervals are no longer one contiguous verified block"
803
+ };
804
+ }
805
+ const sourceInterval = sourceAuthority[0];
806
+ if (sourceInterval === undefined) {
807
+ return { _tag: "Refused", message: "Direction source interval is unavailable" };
808
+ }
809
+ const targetBindings = stored.bindings.filter((binding) => binding.provider === targetProvider && binding.scope === targetScope &&
810
+ binding.rowId === sourceKey.rowId && binding.sourceStartMs === sourceKey.sourceStartMs);
811
+ const targetEvidence = yield* Effect.forEach(targetBindings, (binding) => readBindingEvidence(binding, currentEntries, clockifySnapshot, jiraSnapshot), { concurrency: 2 });
812
+ const consumedIntervals = targetBindings.flatMap((_binding, index) => {
813
+ const evidence = targetEvidence[index];
814
+ return evidence?._tag === "Present"
815
+ ? [{ startMs: evidence.entry.startMs, endMs: evidence.entry.endMs }]
816
+ : [];
817
+ });
818
+ if (consumedIntervals.some((interval) => interval.startMs < sourceInterval.startMs || interval.endMs > sourceInterval.endMs)) {
819
+ return {
820
+ _tag: "Refused",
821
+ message: "A bound target moved outside its verified source interval"
822
+ };
823
+ }
824
+ const remaining = subtractIntervals(sourceInterval, consumedIntervals);
825
+ if (remaining.length === 0 || seconds <= 0) {
826
+ return { _tag: "Refused", message: "Direction delta exceeds the remaining verified source time" };
827
+ }
828
+ if (remaining.length !== 1) {
829
+ return {
830
+ _tag: "Refused",
831
+ message: "Verified target time leaves disconnected source intervals; split it manually before writing"
832
+ };
833
+ }
834
+ const remainingInterval = remaining[0];
835
+ if (remainingInterval === undefined) {
836
+ return { _tag: "Refused", message: "Direction source interval is unavailable" };
837
+ }
838
+ const remainingSeconds = (remainingInterval.endMs - remainingInterval.startMs) / 1000;
839
+ const writableSeconds = Math.min(seconds, remainingSeconds);
840
+ if (!Number.isSafeInteger(writableSeconds) || writableSeconds <= 0) {
841
+ return { _tag: "Refused", message: "Direction remainder is not representable as whole seconds" };
842
+ }
843
+ if (targetProvider === "jira" && writableSeconds < MINIMUM_PROPOSAL_SECONDS) {
844
+ return { _tag: "Refused", message: "Direction remainder is less than Jira's one-minute minimum" };
845
+ }
846
+ const startedAtMs = remainingInterval.startMs;
847
+ return {
848
+ _tag: "Bound",
849
+ startedAt: new Date(startedAtMs),
850
+ source: {
851
+ rowId: sourceKey.rowId,
852
+ sourceStartMs: sourceKey.sourceStartMs,
853
+ startMs: startedAtMs,
854
+ endMs: startedAtMs + writableSeconds * 1000,
855
+ seconds: writableSeconds,
856
+ expectedScope: expectedTargetScope,
857
+ expectedSource: { provider: sourceProvider, scope: expectedSourceScope }
858
+ }
859
+ };
216
860
  });
217
- // Noon-local on the bucket's day — keeps the worklog/entry firmly on that calendar day.
861
+ const compareDirection = (period) => Effect.gen(function* () {
862
+ const clockifySnapshot = yield* clockifyWriteSnapshot;
863
+ const jiraSnapshot = (yield* jiraWriteState).snapshot;
864
+ const result = yield* readRecorded(period, { clockifySnapshot, jiraSnapshot });
865
+ return {
866
+ rows: result.recorded,
867
+ resolveSource: directionSource(result.recorded, result.unlinkedClockify, clockifySnapshot, jiraSnapshot)
868
+ };
869
+ });
870
+ // Noon-local on the bucket's day — the fallback when a caller knows only the day. It keeps the
871
+ // worklog or entry firmly on the right calendar day whatever the reader's timezone.
218
872
  const dayStart = (day) => new Date(`${day}T12:00:00`);
219
- const applyToJira = (ticketKey, day, seconds, comment) => timer.logWorklog({
220
- ticketKey,
221
- startedAt: dayStart(day),
222
- durationSeconds: seconds,
223
- // Carry the Clockify description across; only fall back to a generic note when blank.
224
- comment: comment && comment.trim() ? comment.trim() : "Reconciled from Clockify"
873
+ /**
874
+ * When the work began: the caller's instant if it belongs to this day, else local noon.
875
+ *
876
+ * The day check matters because the bucket's day is authoritative — a caller must not be able to
877
+ * push an entry onto a neighbouring day by passing an instant from it.
878
+ */
879
+ const startOf = (day, startedAt) => startedAt !== undefined && localDay(startedAt) === day ? startedAt : dayStart(day);
880
+ const sourceScopeIsCurrent = (source) => {
881
+ if (source?.expectedSource === undefined)
882
+ return Effect.succeed(true);
883
+ if (source.expectedSource.provider === "clockify") {
884
+ return Effect.result(clockifyWriteSnapshot).pipe(Effect.map((result) => result._tag === "Success" && result.success.scope === source.expectedSource?.scope));
885
+ }
886
+ return jiraWriteState.pipe(Effect.map((state) => state.snapshot?.heldScope === source.expectedSource?.scope));
887
+ };
888
+ const applyToJiraUnGuarded = (ticketKey, day, seconds, comment, startedAt, source) => Effect.gen(function* () {
889
+ if (source !== undefined &&
890
+ (seconds !== source.seconds || startedAt?.getTime() !== source.startMs)) {
891
+ return { _tag: "Failed", message: "The approved Jira interval changed before confirmation" };
892
+ }
893
+ if (!(yield* sourceScopeIsCurrent(source))) {
894
+ return { _tag: "Failed", message: "The source provider account changed since this direction was read" };
895
+ }
896
+ const state = source === undefined ? undefined : yield* jiraWriteState;
897
+ const snapshot = state?.snapshot ?? null;
898
+ const identity = source === undefined || snapshot === null ? undefined : {
899
+ rowId: source.rowId,
900
+ sourceStartMs: source.sourceStartMs,
901
+ startMs: source.startMs,
902
+ endMs: source.endMs,
903
+ seconds: source.seconds,
904
+ ticketKey,
905
+ provider: "jira",
906
+ scope: snapshot.ledgerScope
907
+ };
908
+ if (source !== undefined && identity === undefined) {
909
+ return state?.availability === "not-logged-in"
910
+ ? { _tag: "NotLoggedIn" }
911
+ : { _tag: "VerificationUnavailable" };
912
+ }
913
+ if (source?.expectedScope !== undefined && snapshot?.heldScope !== source.expectedScope) {
914
+ return { _tag: "Failed", message: "The Jira account changed since this plan was read" };
915
+ }
916
+ if (identity !== undefined) {
917
+ const reserved = yield* Effect.result(sourceLedger.reserve(identity));
918
+ if (reserved._tag === "Failure")
919
+ return { _tag: "Failed", message: reserved.failure.message };
920
+ }
921
+ let writeSnapshot = snapshot;
922
+ if (identity !== undefined) {
923
+ const sourceIsCurrent = yield* sourceScopeIsCurrent(source);
924
+ const currentState = yield* jiraWriteState;
925
+ const current = currentState.snapshot;
926
+ if (!sourceIsCurrent || current === null || current.ledgerScope !== identity.scope ||
927
+ source?.expectedScope !== undefined && current.heldScope !== source.expectedScope) {
928
+ const released = yield* Effect.result(sourceLedger.release(identity));
929
+ if (released._tag === "Failure") {
930
+ return {
931
+ _tag: "Failed",
932
+ message: "Provider identity changed and the private write intent needs manual review"
933
+ };
934
+ }
935
+ if (!sourceIsCurrent) {
936
+ return { _tag: "Failed", message: "The source provider account changed before the Jira worklog write" };
937
+ }
938
+ if (currentState.availability === "not-logged-in")
939
+ return { _tag: "NotLoggedIn" };
940
+ if (currentState.availability === "unverified")
941
+ return { _tag: "VerificationUnavailable" };
942
+ return { _tag: "Failed", message: "The Jira account changed before the worklog write" };
943
+ }
944
+ writeSnapshot = current;
945
+ }
946
+ const params = {
947
+ ticketKey,
948
+ startedAt: startOf(day, startedAt),
949
+ durationSeconds: seconds,
950
+ comment: comment !== undefined && comment.trim() !== "" ? comment.trim() : "Reconciled from Clockify"
951
+ };
952
+ const posted = writeSnapshot === null
953
+ ? yield* timer.logWorklog(params)
954
+ : yield* postJiraWorklog(writeSnapshot.client, params);
955
+ if (identity === undefined)
956
+ return posted;
957
+ if (posted._tag === "NotLoggedIn") {
958
+ const released = yield* Effect.result(sourceLedger.release(identity));
959
+ return released._tag === "Failure"
960
+ ? { _tag: "Failed", message: "Jira was not called but the private write intent could not be released" }
961
+ : posted;
962
+ }
963
+ if (posted._tag !== "Posted")
964
+ return posted;
965
+ if (posted.entryId === undefined || posted.entryId === "") {
966
+ return {
967
+ _tag: "Failed",
968
+ message: "Jira may have written time without returning an entry ID; manual recovery required"
969
+ };
970
+ }
971
+ // An exact provider read may add a Jira-clock checkpoint; its failure cannot undo a confirmed POST.
972
+ const createdAtMs = writeSnapshot === null
973
+ ? undefined
974
+ : yield* writeSnapshot.client.getWorklog(ticketKey, posted.entryId, undefined).pipe(Effect.flatMap((worklog) => {
975
+ const value = worklog.created === undefined ? NaN : new Date(worklog.created).getTime();
976
+ const valid = worklog.id === posted.entryId &&
977
+ worklog.author?.accountId === writeSnapshot.accountId &&
978
+ Number.isSafeInteger(value) && value >= 0;
979
+ return valid
980
+ ? Effect.succeed(value)
981
+ : Effect.logWarning("Jira creation checkpoint is incomplete; deletion reconciliation stays held")
982
+ .pipe(Effect.as(undefined));
983
+ }), Effect.catch(() => Effect.logWarning("Jira creation checkpoint is unavailable; deletion reconciliation stays held")
984
+ .pipe(Effect.as(undefined))));
985
+ const bound = yield* Effect.result(sourceLedger.bind(identity, posted.entryId, createdAtMs));
986
+ return bound._tag === "Failure"
987
+ ? { _tag: "Failed", message: "Jira wrote time but its private binding failed; manual recovery required" }
988
+ : posted;
225
989
  });
226
- const applyToClockify = (ticketKey, day, seconds) => Effect.gen(function* () {
227
- const auth = yield* getAuth;
990
+ const applyToJira = (ticketKey, day, seconds, comment, startedAt, source) => provideGuard(WriterGuard.mutate(applyToJiraUnGuarded(ticketKey, day, seconds, comment, startedAt, source))).pipe(Effect.catchTag("WriterGuardError", (error) => Effect.succeed({ _tag: "Failed", message: error.message })));
991
+ const applyToClockifyUnGuarded = (ticketKey, day, seconds, note, startedAt, source) => Effect.gen(function* () {
992
+ if (source !== undefined &&
993
+ (seconds !== source.seconds || startedAt?.getTime() !== source.startMs)) {
994
+ return yield* new ReconcileError({ message: "The approved Clockify interval changed before confirmation" });
995
+ }
996
+ if (!(yield* sourceScopeIsCurrent(source))) {
997
+ return yield* new ReconcileError({
998
+ message: "The source provider account changed since this direction was read"
999
+ });
1000
+ }
1001
+ const snapshot = yield* clockifyWriteSnapshot;
228
1002
  const cfg = yield* config.get;
229
- const start = dayStart(day);
1003
+ const start = startOf(day, startedAt);
230
1004
  const end = new Date(start.getTime() + seconds * 1000);
231
- yield* clockify.createTimeEntry(auth.workspaceId, {
232
- description: `[${ticketKey}] Reconciled from Jira`,
233
- start: start.toISOString(),
234
- end: end.toISOString(),
235
- ...((cfg.defaultProjectId) && { projectId: cfg.defaultProjectId })
1005
+ const identity = source === undefined ? undefined : {
1006
+ rowId: source.rowId,
1007
+ sourceStartMs: source.sourceStartMs,
1008
+ startMs: source.startMs,
1009
+ endMs: source.endMs,
1010
+ seconds: source.seconds,
1011
+ provider: "clockify",
1012
+ scope: snapshot.scope,
1013
+ ticketKey
1014
+ };
1015
+ if (source?.expectedScope !== undefined && identity?.scope !== source.expectedScope) {
1016
+ return yield* new ReconcileError({ message: "The Clockify account changed since this plan was read" });
1017
+ }
1018
+ if (identity !== undefined) {
1019
+ yield* sourceLedger.reserve(identity, snapshot.legacyScope).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1020
+ }
1021
+ const sourceIsCurrent = yield* sourceScopeIsCurrent(source);
1022
+ const verified = yield* Effect.result(clockifyWriteSnapshot);
1023
+ if (!sourceIsCurrent || verified._tag === "Failure") {
1024
+ if (identity !== undefined) {
1025
+ const released = yield* Effect.result(sourceLedger.release(identity));
1026
+ if (released._tag === "Failure") {
1027
+ return yield* new ReconcileError({
1028
+ message: "Clockify was not called but its private write intent needs manual recovery",
1029
+ cause: released.failure
1030
+ });
1031
+ }
1032
+ }
1033
+ return yield* verified._tag === "Failure"
1034
+ ? verified.failure
1035
+ : new ReconcileError({ message: "The source provider account changed before the Clockify write" });
1036
+ }
1037
+ const current = verified.success;
1038
+ if (current.scope !== snapshot.scope ||
1039
+ (source?.expectedScope !== undefined && current.scope !== source.expectedScope)) {
1040
+ if (identity !== undefined) {
1041
+ yield* sourceLedger.release(identity).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1042
+ }
1043
+ return yield* new ReconcileError({ message: "The Clockify account changed before the write" });
1044
+ }
1045
+ const created = yield* current.client.createTimeEntry(current.auth.workspaceId, {
1046
+ payload: {
1047
+ description: `[${ticketKey}] ${note !== undefined && note.trim() !== "" ? note.trim() : "Reconciled from Jira"}`,
1048
+ start: start.toISOString(),
1049
+ end: end.toISOString(),
1050
+ // Stated, not left to Clockify's own default. `jcf timer start` already sends it, so
1051
+ // omitting it here made a reconciled entry's billable flag differ from a timed one's for
1052
+ // the same ticket — visible only later, on an invoice.
1053
+ billable: cfg.defaultBillable,
1054
+ ...((cfg.defaultProjectId) && { projectId: cfg.defaultProjectId })
1055
+ }
236
1056
  }).pipe(Effect.mapError((e) => new ReconcileError({ message: `Clockify create failed: ${e.message}`, cause: e })));
1057
+ if (identity !== undefined) {
1058
+ yield* sourceLedger.bind(identity, created.id).pipe(Effect.mapError((cause) => new ReconcileError({
1059
+ message: "Clockify wrote time but its private binding failed; manual recovery required",
1060
+ cause
1061
+ })));
1062
+ }
237
1063
  return true;
238
1064
  });
239
- return { compare, applyToJira, applyToClockify };
1065
+ const applyToClockify = (ticketKey, day, seconds, note, startedAt, source) => provideGuard(WriterGuard.mutate(applyToClockifyUnGuarded(ticketKey, day, seconds, note, startedAt, source))).pipe(Effect.mapError((error) => error._tag === "WriterGuardError"
1066
+ ? new ReconcileError({ message: error.message, cause: error })
1067
+ : error));
1068
+ /**
1069
+ * Days that must not be proposed because a Timer is still running across them.
1070
+ *
1071
+ * Load-bearing, not politeness: a running Clockify entry has no end time, and the Clockify
1072
+ * tally skips entries without one. Its time is therefore invisible to the subtraction, so
1073
+ * proposing such a day would log hours a second time the moment the Timer is stopped.
1074
+ *
1075
+ * Every day the Timer spans is excluded, not just the one it started on. A Timer left running
1076
+ * overnight hides time on each day it crosses, and the day it happened to start is no safer than
1077
+ * the others — it is simply the one a shorter rule noticed.
1078
+ */
1079
+ const runningTimerExclusions = (period, snapshot) => Effect.gen(function* () {
1080
+ // Fails the run rather than logging and carrying on. An unknown running-timer state is not
1081
+ // an absent one, and every caller of this subtracts before writing the difference.
1082
+ const running = yield* snapshot.client.getTimeEntries(snapshot.auth.workspaceId, snapshot.auth.userId, {
1083
+ params: { "in-progress": "true", "page-size": 1 }
1084
+ }).pipe(Effect.mapError((cause) => new ReconcileError({ message: "Could not rule out a running timer", cause })));
1085
+ if (running.length === 0)
1086
+ return [];
1087
+ const started = running[0]?.timeInterval.start;
1088
+ const startedMs = started === undefined ? Number.NaN : new Date(started).getTime();
1089
+ if (!Number.isFinite(startedMs)) {
1090
+ return yield* new ReconcileError({ message: "Running Clockify timer has no valid start" });
1091
+ }
1092
+ // Clamped to the window on both sides: a Timer running since last week makes nothing outside
1093
+ // this period any less proposable, and the period's own end is as far as the run can look.
1094
+ const fromMs = Math.max(startedMs, period.from.getTime());
1095
+ const toMs = period.to.getTime() - 1;
1096
+ if (fromMs > toMs)
1097
+ return [];
1098
+ const excluded = [];
1099
+ for (let cursor = fromMs; cursor <= toMs; cursor = nextLocalMidnight(cursor)) {
1100
+ excluded.push({
1101
+ day: localDay(new Date(cursor)),
1102
+ reason: "a Clockify timer is still running on this day — stop it and re-run"
1103
+ });
1104
+ }
1105
+ return excluded;
1106
+ });
1107
+ /**
1108
+ * Attribute every session, spending a Coding Agent call only where the deterministic signals
1109
+ * came up empty.
1110
+ *
1111
+ * The calls run concurrently. They are the slowest thing this command does — a CLI process
1112
+ * each, tens of seconds apiece — and doing them one after another is what makes a week's run
1113
+ * look like it has hung. A failure marks the Coding Agent unavailable so the remaining
1114
+ * sessions are honestly reported as Unattributed Sessions rather than retried.
1115
+ */
1116
+ const attributeSessions = (sessions, options) => Effect.gen(function* () {
1117
+ // `deterministicAttribution` is asked here purely to decide whether a call is warranted;
1118
+ // `attributeSession` remains the single place the precedence is actually applied.
1119
+ const needsAgent = options.mode === "deterministic" ?
1120
+ [] :
1121
+ sessions.filter((session) => deterministicAttribution(session, { standingMap: options.standingMap }) === null &&
1122
+ session.candidateKeys.length > 0);
1123
+ const batches = batchRequests(needsAgent);
1124
+ if (needsAgent.length > 0) {
1125
+ yield* options.onProgress({
1126
+ _tag: "AttributingSessions",
1127
+ count: needsAgent.length,
1128
+ calls: batches.length
1129
+ });
1130
+ }
1131
+ // Counted as batches finish rather than in list order, so the reported number always
1132
+ // reflects work actually completed. Safe as a plain counter: Effect's scheduler runs these
1133
+ // fibers on one thread, so the increment cannot interleave.
1134
+ let done = 0;
1135
+ const batchResults = yield* Effect.all(batches.map((batch, batchIndex) => attributor.attribute(batch.map((session) => ({
1136
+ sessionId: session.sessionId,
1137
+ candidateKeys: session.candidateKeys,
1138
+ digest: session.digest
1139
+ })), (activity) => options.onProgress({
1140
+ _tag: "AgentActivity",
1141
+ batch: batchIndex + 1,
1142
+ batches: batches.length,
1143
+ ...activity
1144
+ })).pipe(Effect.map((answers) => {
1145
+ const bySession = new Map(answers.map((answer) => [answer.sessionId, answer.choice]));
1146
+ return batch.map((session) => {
1147
+ const declined = { _tag: "None" };
1148
+ const choice = bySession.get(session.sessionId) ?? declined;
1149
+ return {
1150
+ session,
1151
+ choice,
1152
+ outcome: (choice._tag === "Chosen"
1153
+ ? { _tag: "Placed", ticketKey: choice.ticketKey, confidence: choice.confidence }
1154
+ : { _tag: "Declined" })
1155
+ };
1156
+ });
1157
+ }),
1158
+ // No log here: the failure is reported to the progress listener below, which puts it
1159
+ // on stderr with the sessions it belongs to. Logging as well printed it twice.
1160
+ //
1161
+ // A failed call costs every session in its batch, which is why batches are bounded.
1162
+ Effect.catch((error) => Effect.succeed(batch.map((session) => ({
1163
+ session,
1164
+ choice: null,
1165
+ outcome: { _tag: "Unavailable", message: error.message }
1166
+ })))), Effect.tap((results) => Effect.forEach(results, (result) => {
1167
+ done += 1;
1168
+ return options.onProgress({
1169
+ _tag: "SessionAttributed",
1170
+ done,
1171
+ total: needsAgent.length,
1172
+ gitBranch: result.session.gitBranch,
1173
+ cwd: result.session.cwd,
1174
+ outcome: result.outcome
1175
+ });
1176
+ }, { discard: true })))), { concurrency: ATTRIBUTOR_CONCURRENCY });
1177
+ const answers = batchResults.flat();
1178
+ if (needsAgent.length > 0) {
1179
+ yield* options.onProgress({
1180
+ _tag: "SessionsAttributed",
1181
+ placed: answers.filter((answer) => answer.outcome._tag === "Placed").length,
1182
+ declined: answers.filter((answer) => answer.outcome._tag === "Declined").length,
1183
+ unavailable: answers.filter((answer) => answer.outcome._tag === "Unavailable").length
1184
+ });
1185
+ }
1186
+ const chosen = new Map();
1187
+ let attributorAvailable = true;
1188
+ for (const answer of answers) {
1189
+ if (answer.choice === null) {
1190
+ attributorAvailable = false;
1191
+ continue;
1192
+ }
1193
+ if (answer.choice._tag === "Chosen") {
1194
+ chosen.set(answer.session.sessionId, {
1195
+ ticketKey: answer.choice.ticketKey,
1196
+ confidence: answer.choice.confidence
1197
+ });
1198
+ }
1199
+ }
1200
+ const attributions = sessions.map((session) => attributeSession(session, {
1201
+ standingMap: options.standingMap,
1202
+ agentChoice: chosen.get(session.sessionId) ?? null,
1203
+ confidenceFloor: options.confidenceFloor
1204
+ }));
1205
+ return { attributions, attributorAvailable, attributorCalls: batches.length };
1206
+ });
1207
+ const describeProposals = (options) => Effect.gen(function* () {
1208
+ if (options.proposals.length === 0)
1209
+ return [];
1210
+ const requests = options.proposals.map((proposal, index) => ({
1211
+ id: String(index),
1212
+ ticketKey: proposal.ticketKey,
1213
+ summary: options.summaries?.get(proposal.ticketKey) ?? null,
1214
+ digest: bucketDigest(proposal, options.digests)
1215
+ }));
1216
+ // Batched on the same budget as attribution: the prompt carries whole digests, so the bound
1217
+ // that matters is characters, not rows.
1218
+ const batches = batchRequests(requests);
1219
+ const answers = yield* Effect.forEach(batches, (batch) => attributor.describe(batch).pipe(
1220
+ // A batch that fails costs its own notes and nothing else. Reported as a warning
1221
+ // rather than a failure: the worklog it belongs to is still correct without a sentence.
1222
+ Effect.catch((error) => (options.onUnavailable?.(error.message) ?? Effect.void).pipe(Effect.andThen(Effect.logWarning(`Could not describe ${batch.length} row(s): ${error.message}`)), Effect.as(batch.map((request) => ({ id: request.id, note: null })))))), { concurrency: ATTRIBUTOR_CONCURRENCY });
1223
+ const byId = new Map(answers.flat().map((answer) => [answer.id, answer.note]));
1224
+ return requests.map((request) => byId.get(request.id) ?? null);
1225
+ });
1226
+ /** Both first reads and post-write refreshes subtract live totals through the same path. */
1227
+ const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period, previous, options) {
1228
+ const { sides } = previous;
1229
+ const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1230
+ const clockifySnapshot = sides.clockify ? yield* clockifyWriteSnapshot : null;
1231
+ if (clockifySnapshot !== null) {
1232
+ yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1233
+ }
1234
+ const clockifyScope = clockifySnapshot?.scope ?? null;
1235
+ const jiraState = sides.jira ? yield* jiraWriteState : undefined;
1236
+ const jiraSnapshot = jiraState?.snapshot ?? null;
1237
+ // Compatibility reads may still render Jira totals, but only one verified snapshot may grant
1238
+ // durable source authority. Independent Clockify work remains executable while Jira is held.
1239
+ const jiraScope = jiraSnapshot?.ledgerScope ?? null;
1240
+ const jiraHeldScope = jiraSnapshot?.heldScope ?? null;
1241
+ const sourceScopes = { clockify: clockifyScope, jira: jiraHeldScope };
1242
+ if (previous.sourceScopes !== undefined &&
1243
+ ((sides.clockify && previous.sourceScopes.clockify !== null &&
1244
+ clockifyScope !== null &&
1245
+ previous.sourceScopes.clockify !== clockifyScope) ||
1246
+ (sides.jira && previous.sourceScopes.jira !== null && jiraHeldScope !== null &&
1247
+ previous.sourceScopes.jira !== jiraHeldScope))) {
1248
+ return yield* new ReconcileError({ message: "The provider account changed since this plan was read" });
1249
+ }
1250
+ const scoped = stored.bindings.filter((binding) => binding.provider === "clockify" ? binding.scope === clockifyScope : binding.scope === jiraScope);
1251
+ if (previous.attributed.length > 0 &&
1252
+ stored.pending.some((pending) => pending.provider === "clockify" ? pending.scope === clockifyScope : pending.scope === jiraScope)) {
1253
+ return yield* new ReconcileError({ message: "An unresolved source write needs manual reconciliation" });
1254
+ }
1255
+ const jiraIssueKeys = [
1256
+ ...new Set([
1257
+ ...previous.attributed.map((row) => row.ticketKey),
1258
+ ...scoped.filter((binding) => binding.provider === "jira").map((binding) => binding.ticketKey),
1259
+ ...(options?.jiraIssueKeys ?? [])
1260
+ ])
1261
+ ];
1262
+ const [{ recorded, unlinkedClockify }, excludedDays] = yield* Effect.all([
1263
+ readRecorded(period, {
1264
+ sides,
1265
+ jiraIssueKeys,
1266
+ clockifySnapshot: clockifySnapshot ?? undefined,
1267
+ jiraSnapshot,
1268
+ tolerateUnavailableJira: jiraState?.availability !== "verified"
1269
+ }),
1270
+ clockifySnapshot === null ? Effect.succeed([]) : runningTimerExclusions(period, clockifySnapshot)
1271
+ ], { concurrency: 2 });
1272
+ if (previous.attributed.length > 0 &&
1273
+ recorded.some((row) => row.intervals.some((interval) => interval.source === "jira" && interval.entry === undefined))) {
1274
+ return yield* new ReconcileError({
1275
+ message: "Jira returned a worklog without an ID; session consumption needs manual review"
1276
+ });
1277
+ }
1278
+ const entries = [
1279
+ ...recorded.flatMap((row) => row.intervals.flatMap((interval) => interval.entry === undefined ? [] : [interval.entry])),
1280
+ ...unlinkedClockify.flatMap((slice) => slice.entry === undefined ? [] : [slice.entry])
1281
+ ];
1282
+ const providerScopes = [
1283
+ ["clockify", clockifyScope],
1284
+ ["jira", jiraScope]
1285
+ ];
1286
+ for (const [provider, scope] of providerScopes) {
1287
+ if (previous.attributed.length === 0)
1288
+ break;
1289
+ if (scope === null)
1290
+ continue;
1291
+ const known = stored.reviewedWindows.some((window) => window.provider === provider &&
1292
+ window.scope === scope && window.fromMs <= period.from.getTime() && window.toMs >= period.to.getTime());
1293
+ if (known)
1294
+ continue;
1295
+ const observed = [...new Map(entries.filter((entry) => entry.source === provider)
1296
+ .map((entry) => [entry.id, entry])).values()];
1297
+ const markerBindings = observed.flatMap((entry) => {
1298
+ const references = entry.description === null ? [] : SourceConsumption.sourceReferences(entry.description);
1299
+ if (references.length !== 1 || entry.ticketKey === null)
1300
+ return [];
1301
+ const reference = references[0];
1302
+ if (reference === undefined)
1303
+ return [];
1304
+ const sourceCredit = previous.attributed.find((credit) => `${credit.day}:${credit.ticketKey}` === reference.rowId);
1305
+ if (sourceCredit === undefined ||
1306
+ !sourceCredit.blocks.some((block) => reference.sourceStartMs >= (block.sourceStartMs ?? block.startMs) &&
1307
+ reference.sourceStartMs < block.endMs))
1308
+ return [];
1309
+ return [{
1310
+ provider,
1311
+ scope,
1312
+ entryId: entry.id,
1313
+ rowId: reference.rowId,
1314
+ sourceStartMs: reference.sourceStartMs,
1315
+ startMs: entry.startMs,
1316
+ endMs: entry.endMs,
1317
+ seconds: (entry.endMs - entry.startMs) / 1000,
1318
+ ticketKey: entry.ticketKey
1319
+ }];
1320
+ });
1321
+ const verified = [
1322
+ ...stored.bindings.filter((binding) => binding.provider === provider && binding.scope === scope &&
1323
+ observed.some((entry) => entry.id === binding.entryId)),
1324
+ ...markerBindings.filter((binding) => !stored.bindings.some((knownBinding) => knownBinding.provider === provider && knownBinding.scope === scope &&
1325
+ knownBinding.entryId === binding.entryId))
1326
+ ];
1327
+ yield* sourceLedger.ensureWindow({
1328
+ provider,
1329
+ scope,
1330
+ fromMs: period.from.getTime(),
1331
+ toMs: period.to.getTime()
1332
+ }, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1333
+ }
1334
+ const verifiedLedger = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1335
+ if (previous.attributed.length > 0 &&
1336
+ entries.some((entry) => {
1337
+ const scope = entry.source === "clockify" ? clockifyScope : jiraScope;
1338
+ return scope !== null && entry.description !== null &&
1339
+ SourceConsumption.markers(entry.description).length > 0 &&
1340
+ !verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope);
1341
+ })) {
1342
+ return yield* new ReconcileError({
1343
+ message: "An unlinked source marker needs private manual review before session writes"
1344
+ });
1345
+ }
1346
+ const sourceEntries = verifiedLedger.bindings.filter((binding) => binding.provider === "clockify" ? binding.scope === clockifyScope : binding.scope === jiraScope).flatMap((binding) => {
1347
+ const entry = entries.find((candidate) => candidate.source === binding.provider && candidate.id === binding.entryId);
1348
+ return entry === undefined ? [] : [{
1349
+ source: binding.provider,
1350
+ id: binding.entryId,
1351
+ rowId: binding.rowId,
1352
+ sourceStartMs: binding.sourceStartMs,
1353
+ startMs: entry.startMs,
1354
+ endMs: entry.endMs
1355
+ }];
1356
+ });
1357
+ const executableRecorded = recordedForExecution(recorded, jiraState?.availability);
1358
+ return {
1359
+ ...previous,
1360
+ proposals: buildSessionProposals(previous.attributed, executableRecorded, {
1361
+ minimumSeconds: MINIMUM_PROPOSAL_SECONDS,
1362
+ excludedDays: excludedDays.map((excluded) => excluded.day),
1363
+ sides,
1364
+ sourceEntries,
1365
+ consumptionRows: unlinkedClockify.map((slice) => ({
1366
+ day: slice.day,
1367
+ intervals: slice.entry === undefined ? [] : [{ entry: slice.entry }]
1368
+ }))
1369
+ }),
1370
+ recorded,
1371
+ unlinkedClockify,
1372
+ sourceEntries,
1373
+ sourceScopes,
1374
+ jiraAvailability: jiraState?.availability,
1375
+ excludedDays
1376
+ };
1377
+ });
1378
+ const proposeFromSessions = (period, options) => Effect.gen(function* () {
1379
+ const onProgress = options?.onProgress ?? (() => Effect.void);
1380
+ const cfg = yield* config.get;
1381
+ const sessions = yield* sessionReader.read(period).pipe(Effect.mapError((error) => new ReconcileError({ message: error.message, cause: error })));
1382
+ yield* onProgress({ _tag: "SessionsRead", count: sessions.length });
1383
+ const { attributions, attributorAvailable, attributorCalls } = yield* attributeSessions(sessions, {
1384
+ standingMap: expandedStandingMap(cfg.sessionTicketMap, home),
1385
+ confidenceFloor: cfg.sessionConfidenceFloor,
1386
+ mode: options?.attribution ?? "full",
1387
+ onProgress
1388
+ });
1389
+ // Windows first, then attribution, then sharing — in that order, because time is divided
1390
+ // between distinct Issue Keys, not between sessions. Two sessions on one ticket must union
1391
+ // their windows rather than halve each other.
1392
+ const nowMs = yield* Clock.currentTimeMillis;
1393
+ const windows = activeWindows(sessions.flatMap((session) => session.activity), {
1394
+ idleCapSeconds: cfg.sessionIdleCapSeconds,
1395
+ // The end of the window being reconciled — which for a watch is now. Presence is never
1396
+ // credited past what this run can see.
1397
+ observedAtMs: Math.min(period.to.getTime(), nowMs),
1398
+ boundsBySession: new Map(sessions.flatMap((session) => session.boundedAtMs === null ? [] : [[session.sessionId, session.boundedAtMs]]))
1399
+ });
1400
+ const split = splitCredits(windows, attributions, {
1401
+ cwdBySession: new Map(sessions.map((session) => [session.sessionId, session.cwd])),
1402
+ dwellSeconds: cfg.sessionDwellSeconds
1403
+ });
1404
+ const sides = options?.sides ?? bothSides;
1405
+ yield* onProgress({ _tag: "ReadingRecordedTime", sides });
1406
+ return yield* refreshRecordedTime(period, {
1407
+ attributed: split.attributed,
1408
+ sessionEvidence: windows.map((window) => ({
1409
+ ...window,
1410
+ ticketKey: attributions.find((attribution) => attribution.sessionId === window.sessionId)?.ticketKey ?? null
1411
+ })),
1412
+ sides,
1413
+ withheld: split.withheld,
1414
+ unattributed: split.unattributed,
1415
+ attributorAvailable,
1416
+ sessionCount: sessions.length,
1417
+ sessionRootCount: cfg.sessionRoots.length,
1418
+ attributorCalls,
1419
+ digests: new Map(sessions.map((session) => [session.sessionId, session.digest]))
1420
+ });
1421
+ });
1422
+ return {
1423
+ compare,
1424
+ compareDirection,
1425
+ applyToJira,
1426
+ applyToClockify,
1427
+ describeProposals,
1428
+ proposeFromSessions,
1429
+ refreshRecordedTime
1430
+ };
240
1431
  }));
241
1432
  //# sourceMappingURL=ReconcileService.js.map