@knpkv/jira-clockify 1.2.1 → 1.4.0

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 (243) hide show
  1. package/README.md +254 -3
  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 +56 -12
  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 +110 -71
  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/nvim/lua/jcf/state.lua +191 -13
  242. package/package.json +36 -18
  243. package/skills/jcf/SKILL.md +93 -1
@@ -2,8 +2,17 @@ local M = {}
2
2
  local uv = vim.loop or vim.uv
3
3
  local cached = { active = false }
4
4
  local last_key = nil
5
+ local last_path = nil
5
6
  local poll_timer = nil
6
7
  local poll_job = nil
8
+ -- Config captured by `start_poll`, so the command and its lock/stamp paths stay
9
+ -- stable for the lifetime of the timer.
10
+ local poll_config = nil
11
+ -- The interval `start_poll` was given. Needed away from that function because it
12
+ -- is also the machine's poll budget, not just this editor's tick spacing.
13
+ local poll_interval_ms = nil
14
+ local poll_once
15
+ local arm_poll
7
16
 
8
17
  -- The cache key has to change on every write. `mtime.sec` alone does not: a
9
18
  -- write landing in the same filesystem second as the previous read is
@@ -30,12 +39,14 @@ function M.read(state_path)
30
39
  -- spawning `jcf timer status` every tick against a timer that isn't there.
31
40
  cached = { active = false }
32
41
  last_key = nil
42
+ last_path = nil
33
43
  return cached
34
44
  end
35
45
  local key = stat_key(stat)
36
- if key == last_key then
46
+ if path == last_path and key == last_key then
37
47
  return cached
38
48
  end
49
+ last_path = path
39
50
  last_key = key
40
51
  local f = io.open(path, "r")
41
52
  if f then
@@ -57,7 +68,9 @@ end
57
68
  -- multiplied by the number of open editors. Hence: at most one job in flight,
58
69
  -- owned by nvim (never detached, so VimLeave and jobstop can actually kill it),
59
70
  -- under a watchdog. When no timer is running locally there is nothing to
60
- -- reconcile, so the spawn is skipped entirely.
71
+ -- reconcile, so the spawn is skipped entirely. That bounds one editor to one
72
+ -- poll; the lock below bounds the whole machine to one, which is the part that
73
+ -- actually decides the cost when many editors are open.
61
74
  -- Backstop only. `jcf timer status` bounds every network call it can make, but
62
75
  -- not all at the same value: an expired Jira token costs up to
63
76
  -- `REFRESH_TIMEOUT` (30s, in @knpkv/jira-cli's JiraAuth) before any command
@@ -88,19 +101,139 @@ local POLL_TIMEOUT_MS = 120000
88
101
  -- `invalid_grant`. No client-side design can close that window.
89
102
  local POLL_STOP_GRACE_MS = 35000
90
103
 
104
+ -- ---------------------------------------------------------------------------
105
+ -- Cross-editor lock: one `jcf timer status` per machine, not per editor.
106
+ -- ---------------------------------------------------------------------------
107
+ --
108
+ -- The single-flight guard above is process-local. It bounds one nvim to one
109
+ -- poll and says nothing about the others, so with an editor open per project it
110
+ -- does not bound anything that matters: 19 editors on a 30s timer is 19 cold
111
+ -- Node starts every 30s, each making the same four Clockify calls to reconcile
112
+ -- the same single global timer.
113
+ --
114
+ -- The work is inherently shared — one `~/.jcf/state.json`, one running timer —
115
+ -- so exactly one editor should do it and the rest should read the file it
116
+ -- refreshes, which `M.read` already does off an mtime cache. Coordination uses
117
+ -- util-linux `flock`: every editor starts a non-blocking contender, the kernel
118
+ -- lets exactly one exec `jcf`, and every loser exits without starting Node.
119
+ -- `--no-fork` leaves the lock attached to the `jcf` process itself. If nvim is
120
+ -- SIGKILLed, its child therefore keeps the lock until the poll really exits.
121
+ --
122
+ -- The lock is half of it. It bounds how many polls run *at once*, and because
123
+ -- it is released as soon as the CLI exits, it says nothing about how often they
124
+ -- run — 19 editors would still spawn ~19 times per interval, just never two at
125
+ -- the same moment. The poll stamp below is what bounds the rate, and the two
126
+ -- together are what make the "per machine" claim above true.
127
+ --
128
+ -- No directory is created for it. The lock sits beside the CLI's fixed state
129
+ -- authority. Neovim's configurable `state_path` only changes which file the UI
130
+ -- reads; `jcf timer status` still mutates `~/.jcf/state.json`, so every editor
131
+ -- must coordinate on that one path regardless of its display configuration.
132
+ local function cli_state_path()
133
+ return vim.fn.expand("~/.jcf/state.json")
134
+ end
135
+
136
+ local function lock_path()
137
+ return vim.fn.fnamemodify(cli_state_path(), ":h") .. "/poll.lock"
138
+ end
139
+
140
+ -- ---------------------------------------------------------------------------
141
+ -- Poll stamp: the lock bounds overlap, this bounds rate.
142
+ -- ---------------------------------------------------------------------------
143
+ --
144
+ -- The lock is held only while `jcf timer status` runs — a second or two — and
145
+ -- released the moment it exits. That makes two polls never overlap, which is not
146
+ -- the same as making them rare: 19 editors on a 30s timer tick every ~1.6s
147
+ -- between them, and by then the lock is free again, so nearly every tick would
148
+ -- still spawn. The lock alone therefore delivers ~19 polls per interval, the
149
+ -- number it exists to avoid.
150
+ --
151
+ -- What actually bounds the machine is a record that survives the release. Each
152
+ -- finished attempt writes a stamp file before releasing the lock, and a tick
153
+ -- that finds the stamp younger than one interval skips. Failed attempts count:
154
+ -- otherwise every de-phased editor would retry the same persistent failure in
155
+ -- turn. Readers keep the last state file until the next attempt. That is the
156
+ -- whole rate limit, and it is what lets the editors stay de-phased without
157
+ -- multiplying the work.
158
+ --
159
+ -- The CLI owns the write so no successor can acquire the lock between process
160
+ -- exit and the stamp becoming visible.
161
+ local function stamp_path()
162
+ return vim.fn.fnamemodify(cli_state_path(), ":h") .. "/poll.stamp"
163
+ end
164
+
165
+ local function wall_clock_ms()
166
+ local sec, usec = uv.gettimeofday()
167
+ return (sec * 1000) + math.floor(usec / 1000)
168
+ end
169
+
170
+ -- A watchdog escalation bypasses the CLI's Effect finalizers. Write the failed
171
+ -- attempt synchronously while the child still owns `poll.lock`, so another
172
+ -- editor cannot acquire the lock between SIGKILL and the shared rate bound.
173
+ local function stamp_failed_attempt()
174
+ local fd = uv.fs_open(stamp_path(), "w", 384) -- 0600
175
+ if not fd then
176
+ return
177
+ end
178
+ uv.fs_write(fd, tostring(wall_clock_ms()), -1)
179
+ uv.fs_close(fd)
180
+ end
181
+
182
+ -- Whether some editor on this machine already reconciled within the interval.
183
+ -- Read from the stamp's own mtime, so a torn or truncated write still carries a
184
+ -- usable time and no parsing can fail here.
185
+ local function polled_recently()
186
+ if not poll_interval_ms then
187
+ return false
188
+ end
189
+ local stat = uv.fs_stat(stamp_path())
190
+ if not stat then
191
+ return false
192
+ end
193
+ local stamped_at_ms = (stat.mtime.sec * 1000) + math.floor((stat.mtime.nsec or 0) / 1000000)
194
+ local age_ms = wall_clock_ms() - stamped_at_ms
195
+ -- A stamp dated in the future is a clock that moved backwards, not a poll that
196
+ -- has not happened yet; treating it as recent would wedge polling until the
197
+ -- clock caught up.
198
+ if age_ms < 0 then
199
+ return false
200
+ end
201
+ return age_ms < poll_interval_ms
202
+ end
203
+
204
+ local function own_pid()
205
+ if uv.os_getpid then
206
+ return uv.os_getpid()
207
+ end
208
+ return vim.fn.getpid()
209
+ end
210
+
211
+ -- One-shot scheduling keeps this editor's cadence anchored to completion, not
212
+ -- start. A repeating timer would tick one interval after start, see the fresh
213
+ -- completion stamp, skip, and wait a second whole interval before trying again.
214
+ arm_poll = function(delay_ms)
215
+ if not poll_timer or not poll_config then
216
+ return
217
+ end
218
+ poll_timer:stop()
219
+ poll_timer:start(delay_ms, 0, vim.schedule_wrap(function()
220
+ poll_once(poll_config)
221
+ end))
222
+ end
223
+
91
224
  -- Ask `job` to stop, then escalate if it does not. Signals the pid directly
92
225
  -- rather than calling `jobstop`, which starts nvim's own ~2s kill timer and
93
226
  -- would SIGKILL the CLI long before an in-flight OAuth rotation could finish.
94
227
  --
95
- -- The guard is never released here: only proof of death releases it, because
96
- -- letting a tick spawn a second poll beside a dying one is the stacking leak
97
- -- this whole mechanism exists to prevent. When in doubt we keep holding, at the
98
- -- cost of no further polling until nvim restarts.
228
+ -- `flock --no-fork` execs the CLI, so the pid we signal is also the process
229
+ -- holding the kernel lock. The lock cannot be released before that process is
230
+ -- dead.
99
231
  local function terminate(job)
100
232
  local signalled, pid = pcall(vim.fn.jobpid, job)
101
233
  if signalled and pid > 0 then
102
234
  uv.kill(pid, "sigterm")
103
235
  else
236
+ stamp_failed_attempt()
104
237
  vim.fn.jobstop(job) -- no pid to signal; fall back to nvim's own teardown
105
238
  end
106
239
 
@@ -110,47 +243,73 @@ local function terminate(job)
110
243
  end
111
244
  if vim.fn.jobwait({ job }, 0)[1] ~= -1 then
112
245
  poll_job = nil -- already exited without `on_exit` reaching us
246
+ arm_poll(poll_interval_ms)
113
247
  return
114
248
  end
115
249
  -- SIGTERM was not enough. Escalate, and let `on_exit` do the releasing.
116
250
  local ok, live_pid = pcall(vim.fn.jobpid, job)
117
251
  if ok and live_pid > 0 then
252
+ stamp_failed_attempt()
118
253
  uv.kill(live_pid, "sigkill")
119
254
  return
120
255
  end
121
256
  -- No pid to escalate against, so nothing further will make this job report.
122
257
  -- `jobstop` once more, then release on the next proof rather than wedging
123
258
  -- the poll for the rest of the session.
259
+ stamp_failed_attempt()
124
260
  vim.fn.jobstop(job)
125
261
  vim.defer_fn(function()
126
262
  if poll_job == job and vim.fn.jobwait({ job }, 0)[1] ~= -1 then
127
263
  poll_job = nil
264
+ arm_poll(poll_interval_ms)
128
265
  end
129
266
  end, POLL_STOP_GRACE_MS)
130
267
  end, POLL_STOP_GRACE_MS)
131
268
  end
132
269
 
133
- local function poll_once(config)
270
+ poll_once = function(config)
134
271
  if poll_job then
272
+ arm_poll(poll_interval_ms)
135
273
  return -- previous poll still in flight; do not stack
136
274
  end
137
- if not M.read(config.state_path).active then
275
+ if not M.read(cli_state_path()).active then
276
+ arm_poll(poll_interval_ms)
138
277
  return -- no local timer, nothing to reconcile
139
278
  end
279
+ if polled_recently() then
280
+ arm_poll(poll_interval_ms)
281
+ return -- someone already reconciled this interval; read their result instead
282
+ end
140
283
 
141
284
  -- `job` is declared first so on_exit closes over it, not a global.
142
285
  local job
143
- job = vim.fn.jobstart({ config.binary or "jcf", "timer", "status" }, {
286
+ job = vim.fn.jobstart({
287
+ "flock",
288
+ "--no-fork",
289
+ "--nonblock",
290
+ "--conflict-exit-code",
291
+ "75",
292
+ lock_path(),
293
+ config.binary or "jcf",
294
+ "timer",
295
+ "status",
296
+ "--nvim-poll-stamp",
297
+ stamp_path(),
298
+ "--nvim-poll-interval-ms",
299
+ tostring(poll_interval_ms),
300
+ }, {
144
301
  on_stdout = function() end,
145
302
  on_stderr = function() end,
146
303
  on_exit = function()
147
304
  if poll_job == job then
148
305
  poll_job = nil
306
+ arm_poll(poll_interval_ms)
149
307
  end
150
308
  end,
151
309
  })
152
310
  if job <= 0 then
153
- return -- failed to spawn (missing binary); try again next tick
311
+ arm_poll(poll_interval_ms)
312
+ return -- failed to spawn (`flock` missing); try again next tick
154
313
  end
155
314
  poll_job = job
156
315
 
@@ -167,11 +326,26 @@ function M.start_poll(config, interval_ms)
167
326
  return
168
327
  end
169
328
  interval_ms = interval_ms or 30000 -- 30s default
329
+ if type(interval_ms) ~= "number" or interval_ms <= 0 or interval_ms % 1 ~= 0 then
330
+ return -- non-positive and non-integral values disable polling
331
+ end
332
+ poll_config = config
333
+ poll_interval_ms = interval_ms
334
+
335
+ -- Spread the first tick across a whole interval, keyed off the pid, so editors
336
+ -- opened in a batch (a session restore, a fleet of worktrees) do not line up on
337
+ -- the same millisecond forever after. Derived from the pid rather than
338
+ -- `math.random`, which is unseeded per process and would hand every editor the
339
+ -- same offset.
340
+ --
341
+ -- De-phasing is only safe because the stamp bounds the rate. Spreading the
342
+ -- ticks removes the collisions the lock would otherwise resolve, so with the
343
+ -- lock alone this would make things worse, not better: every tick would find
344
+ -- a free lock and spawn.
345
+ local first_ms = own_pid() % interval_ms
170
346
 
171
347
  poll_timer = uv.new_timer()
172
- poll_timer:start(interval_ms, interval_ms, vim.schedule_wrap(function()
173
- poll_once(config)
174
- end))
348
+ arm_poll(first_ms)
175
349
  end
176
350
 
177
351
  function M.stop_poll()
@@ -180,6 +354,9 @@ function M.stop_poll()
180
354
  poll_timer:close()
181
355
  poll_timer = nil
182
356
  end
357
+ if not poll_job then
358
+ return
359
+ end
183
360
  if poll_job then
184
361
  -- Exactly the watchdog's sequence, for the same reason: `jobstop` here
185
362
  -- would hand the CLI nvim's ~2s kill timer and could SIGKILL it between the
@@ -187,6 +364,7 @@ function M.stop_poll()
187
364
  -- reconfiguration deserve the same grace as a timeout. (On `VimLeave` nvim
188
365
  -- exits before any of it elapses — the CLI's own retry-not-delete rule is
189
366
  -- what covers that case.)
367
+ stamp_failed_attempt()
190
368
  terminate(poll_job)
191
369
  end
192
370
  end
package/package.json CHANGED
@@ -1,10 +1,25 @@
1
1
  {
2
2
  "name": "@knpkv/jira-clockify",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "TUI for Jira-Clockify time tracking, attachable to neovim",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
7
7
  "type": "module",
8
+ "main": "dist/src/index.js",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/src/index.d.ts",
12
+ "default": "./dist/src/index.js"
13
+ },
14
+ "./testing.js": {
15
+ "types": "./dist/src/testing/fakeHeadless.d.ts",
16
+ "default": "./dist/src/testing/fakeHeadless.js"
17
+ },
18
+ "./*.js": {
19
+ "types": "./dist/src/*.d.ts",
20
+ "default": "./dist/src/*.js"
21
+ }
22
+ },
8
23
  "bin": {
9
24
  "jcf": "dist/src/bin.js"
10
25
  },
@@ -18,30 +33,33 @@
18
33
  "skills"
19
34
  ],
20
35
  "dependencies": {
21
- "@effect/atom-react": "4.0.0-rc.109",
22
- "@effect/platform-node": "4.0.0-rc.109",
23
- "@opentui/core": "0.5.1",
24
- "@opentui/react": "0.5.1",
25
- "effect": "4.0.0-rc.109",
26
- "react": "^19.2.8",
27
- "@knpkv/agent-skills": "^0.3.1",
28
- "@knpkv/atlassian-common": "^1.4.1",
29
- "@knpkv/clockify-api-client": "^1.1.1",
30
- "@knpkv/jira-api-client": "^1.1.1",
31
- "@knpkv/jira-cli": "^1.3.1"
36
+ "@effect/atom-react": "4.0.0",
37
+ "@effect/platform-node": "4.0.0",
38
+ "@opentui/core": "0.5.14",
39
+ "@opentui/react": "0.5.14",
40
+ "effect": "4.0.0",
41
+ "react": "^19.3.0",
42
+ "@knpkv/agent-skills": "^0.3.2",
43
+ "@knpkv/ai-claude": "^0.4.0",
44
+ "@knpkv/ai-codex": "^0.5.0",
45
+ "@knpkv/atlassian-common": "^1.5.0",
46
+ "@knpkv/clockify-api-client": "^2.0.0",
47
+ "@knpkv/jira-api-client": "^2.0.0",
48
+ "@knpkv/jira-cli": "^1.4.0"
32
49
  },
33
50
  "devDependencies": {
34
- "@effect/vitest": "4.0.0-rc.109",
51
+ "@effect/vitest": "4.0.0",
35
52
  "@types/node": "latest",
36
- "@types/react": "^19.2.18",
37
- "tsx": "^4.23.12",
38
- "vitest": "^4.1.10"
53
+ "@types/react": "^19.3.0",
54
+ "tsx": "^4.23.15",
55
+ "vitest": "^5.0.3"
39
56
  },
40
57
  "scripts": {
41
58
  "start": "tsx src/bin.ts",
42
59
  "build": "tsc -b && chmod +x dist/src/bin.js",
43
- "check": "tsc -b tsconfig.json",
60
+ "check": "tsc -b tsconfig.json && tsc --noEmit -p test/tsconfig.json",
44
61
  "test": "vitest",
45
62
  "lint": "eslint \"{src,test}/**/*.{ts,tsx}\""
46
- }
63
+ },
64
+ "types": "dist/src/index.d.ts"
47
65
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: jcf
3
- description: Use the @knpkv/jira-clockify CLI to track work across Jira and Clockify. Trigger when the user asks an agent to start, stop, discard, edit, inspect, or manually log time for Jira tickets; configure Jira OAuth or Clockify API access; list current Jira tickets; set default Clockify project, billable flag, or JQL; or launch the jcf TUI.
3
+ description: Use the @knpkv/jira-clockify CLI to track work across Jira and Clockify. Trigger when the user asks an agent to start, stop, discard, edit, inspect, or manually log time for Jira tickets; reconcile Clockify against Jira or recover forgotten time from local Claude Code and Codex sessions; configure Jira OAuth or Clockify API access; list current Jira tickets; set default Clockify project, billable flag, or JQL; or launch the jcf TUI.
4
4
  ---
5
5
 
6
6
  # Jcf
@@ -38,6 +38,15 @@ jcf config set jql 'assignee = currentUser() AND status != Done ORDER BY updated
38
38
  jcf config reset
39
39
  ```
40
40
 
41
+ Configure which directories' Claude Code and Codex sessions may become proposed worklogs:
42
+
43
+ ```bash
44
+ jcf config set session-root ~/dev/work
45
+ jcf config set session-root ~/dev/work --remove
46
+ jcf config set session-ticket ~/dev/work/docs PROJ-42
47
+ jcf config set idle-cap 300
48
+ ```
49
+
41
50
  ## Timer Commands
42
51
 
43
52
  Launch the TUI:
@@ -85,6 +94,87 @@ Edit the running timer:
85
94
  jcf timer edit
86
95
  ```
87
96
 
97
+ ## Reconcile Commands
98
+
99
+ Both forms are remote write commands: they create Clockify entries and Jira worklogs. Never run
100
+ either unattended. Confirm the window and the direction (or the agent) with the user first, and
101
+ prefer the read-only forms below when gathering information.
102
+
103
+ Compare the two sides and fill whichever is short:
104
+
105
+ ```bash
106
+ jcf sync reconcile clockify-to-jira --day
107
+ jcf sync reconcile jira-to-clockify --week
108
+ jcf sync reconcile clockify-to-jira --since 2026-07-01 --until 2026-07-07
109
+ ```
110
+
111
+ Recover time neither side recorded, using local Claude Code and Codex sessions as evidence:
112
+
113
+ ```bash
114
+ jcf sync reconcile --agent claude --day
115
+ jcf sync reconcile --agent claude --week
116
+ jcf sync reconcile --agent claude --json
117
+ jcf sync reconcile --agent claude --day --calendar
118
+ ```
119
+
120
+ - `--agent` is a mode switch, not a direction. Passing both is a usage error.
121
+ - `claude` is the only supported agent mode; `--agent codex` fails. That mode reads in-scope Claude Code
122
+ and Codex sessions as evidence.
123
+ - `--agent claude --json` is the read-only form: it writes exactly one JSON value to stdout, sends
124
+ everything human-facing to stderr, and creates no Clockify entry or Jira worklog. Use it to
125
+ inspect proposals before asking the user which to accept.
126
+ - Without `--json`, every row needs an interactive confirmation, so this form is unsuitable for
127
+ unattended use.
128
+ - Nothing is proposed for a directory outside the configured session roots, and a day with a timer
129
+ still running is reported but never proposed. Stop the timer and re-run to log that day.
130
+ - Every picker row names the issue summary and assignee; `--json` carries both as `summary` and
131
+ `assignee`. Rows are not listed above the picker — the picker rows _are_ the report.
132
+ - Written entries carry the issue title and an agent-written sentence saying what was done, read off
133
+ the session prompts. Notes are asked for only about confirmed rows, and only when writing, so
134
+ `--json` never spends a call on one. A row still writes when no note can be produced.
135
+ - `--calendar` adds an ASCII hour-by-hour grid of when the time was credited. `--json` carries the
136
+ same information as a `blocks` array per proposal, with `startMs`, `endMs` and credited `seconds`,
137
+ plus the issue `summary`. Sum `seconds`, not wall-clock bounds, for shared work.
138
+ - In `--json`, `ownershipWithheld` keeps rows assigned to someone else or unassigned, with a
139
+ `reason`; `withheld` remains the separate confidence-floor list. Neither list is offered to write.
140
+ - `--json` and `--calendar` are agent-mode flags; passing either without `--agent` is a usage error.
141
+ - Only messages the user typed count towards time; the agent's own output, its tool results, and
142
+ prompts it sends its own subagents do not, so an unattended agent run credits at most the idle cap
143
+ rather than the hour it ran for.
144
+ - If Jira cannot be read, the run fails rather than proposing time that may already be logged.
145
+ - Time worked on several issues at once is split equally between them, so a row's credited total can
146
+ be lower than the clock ranges beneath it; the report names the active total and the shared amount.
147
+ - Each proposal shows the attribution signal behind it (`branch`, `path`, `standing`, `agent`).
148
+ A branch name states intent, not fact: time spent on an unrelated fix while on a ticket branch is
149
+ credited to that branch's ticket, so review the signal before confirming.
150
+
151
+ ## Watch Command
152
+
153
+ ```bash
154
+ jcf watch claude # Runs until interrupted; writes as work settles
155
+ jcf watch claude --dry-run # Prints what it would write, writes nothing
156
+ jcf watch claude --interval 60
157
+ ```
158
+
159
+ - A long-running remote write command. Only start it when the user explicitly asks for live
160
+ tracking, and say that it will keep writing until they stop it. Do not start it to answer a
161
+ question — it never terminates on its own, so it cannot be used to gather information.
162
+ - `--dry-run` is the read-only form, but it still runs forever. To inspect unlogged work, use
163
+ `jcf sync reconcile --agent claude --json` instead.
164
+ - It writes only branch-, path-, and standing-attributed blocks. Time only a coding agent could
165
+ place is reported and left for `jcf sync reconcile --agent claude`.
166
+ - It writes a block once it has been quiet for one idle cap, so work in progress is never written.
167
+ Nothing is written twice _on this machine_ — a proposal is always the gap the two sides still have,
168
+ and the lease keeps a second local watch out. Two machines watching one Clockify account can still
169
+ duplicate: see ADR-0007.
170
+ - It covers time since it started, plus any stretch a previous watch reached but had not written —
171
+ it leaves a cursor, so a restart resumes rather than dropping the block. A first run reaches back
172
+ for nothing. Earlier work in the same day needs `jcf sync reconcile`.
173
+ - Only one watch writes at a time, per machine. Starting a second one prints who holds the lease and
174
+ exits; do not start one to "check" on a running watch. After an ungraceful process death, verify no
175
+ watch remains before manually removing `~/.jcf/watch.lease`; the file is never auto-taken-over.
176
+ - It stops itself if Jira rejects the login. Re-authenticate with `jcf auth jira login` and restart.
177
+
88
178
  ## Agent Workflow
89
179
 
90
180
  1. Run `jcf auth status` and `jcf timer status` before changing timer state.
@@ -92,3 +182,5 @@ jcf timer edit
92
182
  3. Verify the active Jira profile when the user names a Jira site/account or before posting worklogs; `atlassian profiles doctor` shows Jira Clockify as a consumer of the `jira-cli` auth store.
93
183
  4. Prefer explicit flags for non-interactive work: issue key, duration, date, time, project id, billable flag, and comment.
94
184
  5. If no timer is running, `jcf timer stop` may offer an interactive correction interval; use `jcf timer log` for deterministic manual logging.
185
+ 6. To find unlogged work, read `jcf sync reconcile --agent claude --json` first and report the
186
+ proposals to the user; only they should decide which rows to write.