@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.
- package/README.md +254 -3
- package/dist/package.json +36 -12
- package/dist/src/agent/agentSettings.d.ts +18 -0
- package/dist/src/agent/agentSettings.d.ts.map +1 -0
- package/dist/src/agent/agentSettings.js +23 -0
- package/dist/src/agent/agentSettings.js.map +1 -0
- package/dist/src/agent/schedule.d.ts +20 -0
- package/dist/src/agent/schedule.d.ts.map +1 -0
- package/dist/src/agent/schedule.js +189 -0
- package/dist/src/agent/schedule.js.map +1 -0
- package/dist/src/agent/sessions.d.ts +427 -0
- package/dist/src/agent/sessions.d.ts.map +1 -0
- package/dist/src/agent/sessions.js +868 -0
- package/dist/src/agent/sessions.js.map +1 -0
- package/dist/src/agent/sourceConsumption.d.ts +79 -0
- package/dist/src/agent/sourceConsumption.d.ts.map +1 -0
- package/dist/src/agent/sourceConsumption.js +191 -0
- package/dist/src/agent/sourceConsumption.js.map +1 -0
- package/dist/src/agent/watch.d.ts +76 -0
- package/dist/src/agent/watch.d.ts.map +1 -0
- package/dist/src/agent/watch.js +45 -0
- package/dist/src/agent/watch.js.map +1 -0
- package/dist/src/agent/writePlanning.d.ts +154 -0
- package/dist/src/agent/writePlanning.d.ts.map +1 -0
- package/dist/src/agent/writePlanning.js +210 -0
- package/dist/src/agent/writePlanning.js.map +1 -0
- package/dist/src/bin.d.ts +3 -0
- package/dist/src/bin.d.ts.map +1 -0
- package/dist/src/bin.js +9 -3
- package/dist/src/bin.js.map +1 -1
- package/dist/src/cli/agentWrite.d.ts +164 -0
- package/dist/src/cli/agentWrite.d.ts.map +1 -0
- package/dist/src/cli/agentWrite.js +362 -0
- package/dist/src/cli/agentWrite.js.map +1 -0
- package/dist/src/cli/auth.d.ts +8 -0
- package/dist/src/cli/auth.d.ts.map +1 -0
- package/dist/src/cli/auth.js +10 -10
- package/dist/src/cli/auth.js.map +1 -1
- package/dist/src/cli/calendar.d.ts +42 -0
- package/dist/src/cli/calendar.d.ts.map +1 -0
- package/dist/src/cli/calendar.js +189 -0
- package/dist/src/cli/calendar.js.map +1 -0
- package/dist/src/cli/config.d.ts +12 -0
- package/dist/src/cli/config.d.ts.map +1 -0
- package/dist/src/cli/config.js +163 -21
- package/dist/src/cli/config.js.map +1 -1
- package/dist/src/cli/fetchTicket.d.ts +51 -0
- package/dist/src/cli/fetchTicket.d.ts.map +1 -0
- package/dist/src/cli/fetchTicket.js +18 -2
- package/dist/src/cli/fetchTicket.js.map +1 -1
- package/dist/src/cli/fuzzySelect.d.ts +24 -0
- package/dist/src/cli/fuzzySelect.d.ts.map +1 -0
- package/dist/src/cli/layers.d.ts +40 -0
- package/dist/src/cli/layers.d.ts.map +1 -0
- package/dist/src/cli/layers.js +56 -12
- package/dist/src/cli/layers.js.map +1 -1
- package/dist/src/cli/list.d.ts +8 -0
- package/dist/src/cli/list.d.ts.map +1 -0
- package/dist/src/cli/list.js +2 -2
- package/dist/src/cli/list.js.map +1 -1
- package/dist/src/cli/reconcile.d.ts +129 -0
- package/dist/src/cli/reconcile.d.ts.map +1 -0
- package/dist/src/cli/reconcile.js +635 -46
- package/dist/src/cli/reconcile.js.map +1 -1
- package/dist/src/cli/root.d.ts +15 -0
- package/dist/src/cli/root.d.ts.map +1 -0
- package/dist/src/cli/root.js +4 -3
- package/dist/src/cli/root.js.map +1 -1
- package/dist/src/cli/runtimeFailure.d.ts +10 -0
- package/dist/src/cli/runtimeFailure.d.ts.map +1 -0
- package/dist/src/cli/runtimeFailure.js +17 -0
- package/dist/src/cli/runtimeFailure.js.map +1 -0
- package/dist/src/cli/setup.d.ts +10 -0
- package/dist/src/cli/setup.d.ts.map +1 -0
- package/dist/src/cli/setup.js +7 -7
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/timer/discard.d.ts +4 -0
- package/dist/src/cli/timer/discard.d.ts.map +1 -0
- package/dist/src/cli/timer/discard.js +2 -2
- package/dist/src/cli/timer/discard.js.map +1 -1
- package/dist/src/cli/timer/edit.d.ts +22 -0
- package/dist/src/cli/timer/edit.d.ts.map +1 -0
- package/dist/src/cli/timer/edit.js +62 -42
- package/dist/src/cli/timer/edit.js.map +1 -1
- package/dist/src/cli/timer/index.d.ts +12 -0
- package/dist/src/cli/timer/index.d.ts.map +1 -0
- package/dist/src/cli/timer/log.d.ts +16 -0
- package/dist/src/cli/timer/log.d.ts.map +1 -0
- package/dist/src/cli/timer/log.js +6 -6
- package/dist/src/cli/timer/log.js.map +1 -1
- package/dist/src/cli/timer/start.d.ts +21 -0
- package/dist/src/cli/timer/start.d.ts.map +1 -0
- package/dist/src/cli/timer/start.js +9 -9
- package/dist/src/cli/timer/start.js.map +1 -1
- package/dist/src/cli/timer/status.d.ts +22 -0
- package/dist/src/cli/timer/status.d.ts.map +1 -0
- package/dist/src/cli/timer/status.js +110 -71
- package/dist/src/cli/timer/status.js.map +1 -1
- package/dist/src/cli/timer/stop.d.ts +48 -0
- package/dist/src/cli/timer/stop.d.ts.map +1 -0
- package/dist/src/cli/timer/stop.js +19 -19
- package/dist/src/cli/timer/stop.js.map +1 -1
- package/dist/src/cli/timer.d.ts +11 -0
- package/dist/src/cli/timer.d.ts.map +1 -0
- package/dist/src/cli/timer.js +1 -1
- package/dist/src/cli/timer.js.map +1 -1
- package/dist/src/cli/watch.d.ts +54 -0
- package/dist/src/cli/watch.d.ts.map +1 -0
- package/dist/src/cli/watch.js +412 -0
- package/dist/src/cli/watch.js.map +1 -0
- package/dist/src/cli/watchLease.d.ts +99 -0
- package/dist/src/cli/watchLease.d.ts.map +1 -0
- package/dist/src/cli/watchLease.js +204 -0
- package/dist/src/cli/watchLease.js.map +1 -0
- package/dist/src/cli/writerGuard.d.ts +37 -0
- package/dist/src/cli/writerGuard.d.ts.map +1 -0
- package/dist/src/cli/writerGuard.js +92 -0
- package/dist/src/cli/writerGuard.js.map +1 -0
- package/dist/src/index.d.ts +46 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +46 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/main.d.ts +4 -0
- package/dist/src/main.d.ts.map +1 -0
- package/dist/src/services/AgentSessionReader.d.ts +121 -0
- package/dist/src/services/AgentSessionReader.d.ts.map +1 -0
- package/dist/src/services/AgentSessionReader.js +392 -0
- package/dist/src/services/AgentSessionReader.js.map +1 -0
- package/dist/src/services/ClockifyAuth.d.ts +47 -0
- package/dist/src/services/ClockifyAuth.d.ts.map +1 -0
- package/dist/src/services/ClockifyAuth.js +2 -2
- package/dist/src/services/ClockifyAuth.js.map +1 -1
- package/dist/src/services/CodexTranscript.d.ts +112 -0
- package/dist/src/services/CodexTranscript.d.ts.map +1 -0
- package/dist/src/services/CodexTranscript.js +96 -0
- package/dist/src/services/CodexTranscript.js.map +1 -0
- package/dist/src/services/ConfigService.d.ts +99 -0
- package/dist/src/services/ConfigService.d.ts.map +1 -0
- package/dist/src/services/ConfigService.js +79 -7
- package/dist/src/services/ConfigService.js.map +1 -1
- package/dist/src/services/HomeDirectory.d.ts +17 -0
- package/dist/src/services/HomeDirectory.d.ts.map +1 -0
- package/dist/src/services/HomeDirectory.js +1 -1
- package/dist/src/services/IssueFacts.d.ts +127 -0
- package/dist/src/services/IssueFacts.d.ts.map +1 -0
- package/dist/src/services/IssueFacts.js +277 -0
- package/dist/src/services/IssueFacts.js.map +1 -0
- package/dist/src/services/ProviderDeletion.d.ts +19 -0
- package/dist/src/services/ProviderDeletion.d.ts.map +1 -0
- package/dist/src/services/ProviderDeletion.js +37 -0
- package/dist/src/services/ProviderDeletion.js.map +1 -0
- package/dist/src/services/ReconcileService.d.ts +378 -0
- package/dist/src/services/ReconcileService.d.ts.map +1 -0
- package/dist/src/services/ReconcileService.js +1270 -79
- package/dist/src/services/ReconcileService.js.map +1 -1
- package/dist/src/services/SavedEntries.d.ts +40 -0
- package/dist/src/services/SavedEntries.d.ts.map +1 -0
- package/dist/src/services/SavedEntries.js +209 -0
- package/dist/src/services/SavedEntries.js.map +1 -0
- package/dist/src/services/SessionAttributor.d.ts +114 -0
- package/dist/src/services/SessionAttributor.d.ts.map +1 -0
- package/dist/src/services/SessionAttributor.js +240 -0
- package/dist/src/services/SessionAttributor.js.map +1 -0
- package/dist/src/services/SourceLedger.d.ts +104 -0
- package/dist/src/services/SourceLedger.d.ts.map +1 -0
- package/dist/src/services/SourceLedger.js +302 -0
- package/dist/src/services/SourceLedger.js.map +1 -0
- package/dist/src/services/StateWriter.d.ts +39 -0
- package/dist/src/services/StateWriter.d.ts.map +1 -0
- package/dist/src/services/TicketService.d.ts +63 -0
- package/dist/src/services/TicketService.d.ts.map +1 -0
- package/dist/src/services/TimerService.d.ts +145 -0
- package/dist/src/services/TimerService.d.ts.map +1 -0
- package/dist/src/services/TimerService.js +113 -80
- package/dist/src/services/TimerService.js.map +1 -1
- package/dist/src/services/internal/JiraWorklogPost.d.ts +7 -0
- package/dist/src/services/internal/JiraWorklogPost.d.ts.map +1 -0
- package/dist/src/services/internal/JiraWorklogPost.js +32 -0
- package/dist/src/services/internal/JiraWorklogPost.js.map +1 -0
- package/dist/src/testing/fakeHeadless.d.ts +318 -0
- package/dist/src/testing/fakeHeadless.d.ts.map +1 -0
- package/dist/src/testing/fakeHeadless.js +1149 -0
- package/dist/src/testing/fakeHeadless.js.map +1 -0
- package/dist/src/tui/App.d.ts +6 -0
- package/dist/src/tui/App.d.ts.map +1 -0
- package/dist/src/tui/App.js +1 -1
- package/dist/src/tui/App.js.map +1 -1
- package/dist/src/tui/atoms/runtime.d.ts +8 -0
- package/dist/src/tui/atoms/runtime.d.ts.map +1 -0
- package/dist/src/tui/atoms/runtime.js +1 -1
- package/dist/src/tui/atoms/runtime.js.map +1 -1
- package/dist/src/tui/atoms/tickets.d.ts +3 -0
- package/dist/src/tui/atoms/tickets.d.ts.map +1 -0
- package/dist/src/tui/atoms/timer.d.ts +11 -0
- package/dist/src/tui/atoms/timer.d.ts.map +1 -0
- package/dist/src/tui/atoms/timer.js +1 -1
- package/dist/src/tui/atoms/timer.js.map +1 -1
- package/dist/src/tui/atoms/ui.d.ts +11 -0
- package/dist/src/tui/atoms/ui.d.ts.map +1 -0
- package/dist/src/tui/atoms/ui.js +1 -1
- package/dist/src/tui/atoms/ui.js.map +1 -1
- package/dist/src/tui/components/BigTimer.d.ts +2 -0
- package/dist/src/tui/components/BigTimer.d.ts.map +1 -0
- package/dist/src/tui/components/Footer.d.ts +2 -0
- package/dist/src/tui/components/Footer.d.ts.map +1 -0
- package/dist/src/tui/components/Header.d.ts +2 -0
- package/dist/src/tui/components/Header.d.ts.map +1 -0
- package/dist/src/tui/components/Header.js +1 -1
- package/dist/src/tui/components/Header.js.map +1 -1
- package/dist/src/tui/components/PopupInput.d.ts +18 -0
- package/dist/src/tui/components/PopupInput.d.ts.map +1 -0
- package/dist/src/tui/components/PopupMessage.d.ts +20 -0
- package/dist/src/tui/components/PopupMessage.d.ts.map +1 -0
- package/dist/src/tui/components/TicketList.d.ts +2 -0
- package/dist/src/tui/components/TicketList.d.ts.map +1 -0
- package/dist/src/tui/components/TicketList.js +1 -1
- package/dist/src/tui/components/TicketList.js.map +1 -1
- package/dist/src/tui/components/TicketRow.d.ts +8 -0
- package/dist/src/tui/components/TicketRow.d.ts.map +1 -0
- package/dist/src/tui/components/TimerDisplay.d.ts +2 -0
- package/dist/src/tui/components/TimerDisplay.d.ts.map +1 -0
- package/dist/src/tui/components/index.d.ts +12 -0
- package/dist/src/tui/components/index.d.ts.map +1 -0
- package/dist/src/tui/context/theme.d.ts +15 -0
- package/dist/src/tui/context/theme.d.ts.map +1 -0
- package/dist/src/tui/hooks/useElapsedTimer.d.ts +6 -0
- package/dist/src/tui/hooks/useElapsedTimer.d.ts.map +1 -0
- package/dist/src/tui/hooks/useElapsedTimer.js +1 -1
- package/dist/src/tui/hooks/useElapsedTimer.js.map +1 -1
- package/dist/src/tui/hooks/useTerminalSize.d.ts +5 -0
- package/dist/src/tui/hooks/useTerminalSize.d.ts.map +1 -0
- package/dist/src/utils/hints.d.ts +12 -0
- package/dist/src/utils/hints.d.ts.map +1 -0
- package/dist/src/utils/hints.js +12 -0
- package/dist/src/utils/hints.js.map +1 -0
- package/dist/src/utils/time.d.ts +104 -0
- package/dist/src/utils/time.d.ts.map +1 -0
- package/dist/src/utils/time.js +74 -8
- package/dist/src/utils/time.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/nvim/lua/jcf/state.lua +191 -13
- package/package.json +36 -18
- package/skills/jcf/SKILL.md +93 -1
package/nvim/lua/jcf/state.lua
CHANGED
|
@@ -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
|
-
--
|
|
96
|
-
--
|
|
97
|
-
--
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
22
|
-
"@effect/platform-node": "4.0.0
|
|
23
|
-
"@opentui/core": "0.5.
|
|
24
|
-
"@opentui/react": "0.5.
|
|
25
|
-
"effect": "4.0.0
|
|
26
|
-
"react": "^19.
|
|
27
|
-
"@knpkv/agent-skills": "^0.3.
|
|
28
|
-
"@knpkv/
|
|
29
|
-
"@knpkv/
|
|
30
|
-
"@knpkv/
|
|
31
|
-
"@knpkv/
|
|
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
|
|
51
|
+
"@effect/vitest": "4.0.0",
|
|
35
52
|
"@types/node": "latest",
|
|
36
|
-
"@types/react": "^19.
|
|
37
|
-
"tsx": "^4.23.
|
|
38
|
-
"vitest": "^
|
|
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
|
}
|
package/skills/jcf/SKILL.md
CHANGED
|
@@ -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.
|