@techdivision/opencode-time-tracking 0.3.2 → 0.5.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/package.json +1 -1
- package/src/Plugin.ts +3 -1
- package/src/hooks/EventHook.ts +43 -9
- package/src/services/CsvWriter.ts +1 -1
- package/src/services/SessionManager.ts +21 -0
- package/src/services/TicketResolver.ts +112 -0
- package/src/types/AgentDefaultConfig.ts +29 -0
- package/src/types/CsvEntryData.ts +11 -0
- package/src/types/GlobalDefaultConfig.ts +28 -0
- package/src/types/ResolvedTicketInfo.ts +35 -0
- package/src/types/TimeTrackingConfig.ts +30 -0
- package/src/utils/DescriptionGenerator.ts +1 -1
package/package.json
CHANGED
package/src/Plugin.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { ConfigLoader } from "./services/ConfigLoader"
|
|
|
13
13
|
import { CsvWriter } from "./services/CsvWriter"
|
|
14
14
|
import { SessionManager } from "./services/SessionManager"
|
|
15
15
|
import { TicketExtractor } from "./services/TicketExtractor"
|
|
16
|
+
import { TicketResolver } from "./services/TicketResolver"
|
|
16
17
|
import { createEventHook } from "./hooks/EventHook"
|
|
17
18
|
import { createToolExecuteAfterHook } from "./hooks/ToolExecuteAfterHook"
|
|
18
19
|
|
|
@@ -61,13 +62,14 @@ export const plugin: Plugin = async ({
|
|
|
61
62
|
const sessionManager = new SessionManager()
|
|
62
63
|
const csvWriter = new CsvWriter(config, directory)
|
|
63
64
|
const ticketExtractor = new TicketExtractor(client)
|
|
65
|
+
const ticketResolver = new TicketResolver(config, ticketExtractor)
|
|
64
66
|
|
|
65
67
|
const hooks: Hooks = {
|
|
66
68
|
"tool.execute.after": createToolExecuteAfterHook(
|
|
67
69
|
sessionManager,
|
|
68
70
|
ticketExtractor
|
|
69
71
|
),
|
|
70
|
-
event: createEventHook(sessionManager, csvWriter, client),
|
|
72
|
+
event: createEventHook(sessionManager, csvWriter, client, ticketResolver, config),
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
return hooks
|
package/src/hooks/EventHook.ts
CHANGED
|
@@ -6,9 +6,11 @@ import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk"
|
|
|
6
6
|
|
|
7
7
|
import type { CsvWriter } from "../services/CsvWriter"
|
|
8
8
|
import type { SessionManager } from "../services/SessionManager"
|
|
9
|
+
import type { TicketResolver } from "../services/TicketResolver"
|
|
9
10
|
import type { MessagePartUpdatedProperties } from "../types/MessagePartUpdatedProperties"
|
|
10
11
|
import type { MessageWithParts } from "../types/MessageWithParts"
|
|
11
12
|
import type { OpencodeClient } from "../types/OpencodeClient"
|
|
13
|
+
import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
|
|
12
14
|
|
|
13
15
|
import { DescriptionGenerator } from "../utils/DescriptionGenerator"
|
|
14
16
|
|
|
@@ -83,7 +85,9 @@ async function extractSummaryTitle(
|
|
|
83
85
|
export function createEventHook(
|
|
84
86
|
sessionManager: SessionManager,
|
|
85
87
|
csvWriter: CsvWriter,
|
|
86
|
-
client: OpencodeClient
|
|
88
|
+
client: OpencodeClient,
|
|
89
|
+
ticketResolver: TicketResolver,
|
|
90
|
+
config: TimeTrackingConfig
|
|
87
91
|
) {
|
|
88
92
|
return async ({ event }: { event: Event }): Promise<void> => {
|
|
89
93
|
// Track model and agent from assistant messages
|
|
@@ -94,6 +98,11 @@ export function createEventHook(
|
|
|
94
98
|
if (message.role === "assistant") {
|
|
95
99
|
const assistantMsg = message as AssistantMessage
|
|
96
100
|
|
|
101
|
+
// Ensure session exists for tracking
|
|
102
|
+
if (!sessionManager.has(assistantMsg.sessionID)) {
|
|
103
|
+
sessionManager.create(assistantMsg.sessionID, null)
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
// Track model
|
|
98
107
|
if (assistantMsg.modelID && assistantMsg.providerID) {
|
|
99
108
|
sessionManager.setModel(assistantMsg.sessionID, {
|
|
@@ -118,6 +127,11 @@ export function createEventHook(
|
|
|
118
127
|
|
|
119
128
|
// Track token usage from step-finish events
|
|
120
129
|
if (part.type === "step-finish" && part.sessionID && part.tokens) {
|
|
130
|
+
// Ensure session exists for token tracking
|
|
131
|
+
if (!sessionManager.has(part.sessionID)) {
|
|
132
|
+
sessionManager.create(part.sessionID, null)
|
|
133
|
+
}
|
|
134
|
+
|
|
121
135
|
sessionManager.addTokenUsage(part.sessionID, {
|
|
122
136
|
input: part.tokens.input,
|
|
123
137
|
output: part.tokens.output,
|
|
@@ -139,10 +153,17 @@ export function createEventHook(
|
|
|
139
153
|
return
|
|
140
154
|
}
|
|
141
155
|
|
|
142
|
-
|
|
156
|
+
// Atomically get and delete to prevent race conditions
|
|
157
|
+
const session = sessionManager.getAndDelete(sessionID)
|
|
143
158
|
|
|
144
|
-
if
|
|
145
|
-
|
|
159
|
+
// Check if session has any trackable data
|
|
160
|
+
const hasActivity = (session?.activities.length ?? 0) > 0
|
|
161
|
+
const hasTokens =
|
|
162
|
+
(session?.tokenUsage.input ?? 0) +
|
|
163
|
+
(session?.tokenUsage.output ?? 0) >
|
|
164
|
+
0
|
|
165
|
+
|
|
166
|
+
if (!session || (!hasActivity && !hasTokens)) {
|
|
146
167
|
return
|
|
147
168
|
}
|
|
148
169
|
|
|
@@ -171,9 +192,24 @@ export function createEventHook(
|
|
|
171
192
|
// Get agent name if available
|
|
172
193
|
const agentString = session.agent?.name ?? null
|
|
173
194
|
|
|
195
|
+
// Check if agent should be ignored
|
|
196
|
+
if (agentString && config.ignored_agents?.includes(agentString)) {
|
|
197
|
+
await client.tui.showToast({
|
|
198
|
+
body: {
|
|
199
|
+
message: `Time tracking skipped for ${agentString} (ignored agent)`,
|
|
200
|
+
variant: "info",
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Resolve ticket and account key with fallback hierarchy
|
|
207
|
+
const resolved = await ticketResolver.resolve(sessionID, agentString)
|
|
208
|
+
|
|
174
209
|
try {
|
|
175
210
|
await csvWriter.write({
|
|
176
|
-
ticket:
|
|
211
|
+
ticket: resolved.ticket,
|
|
212
|
+
accountKey: resolved.accountKey,
|
|
177
213
|
startTime: session.startTime,
|
|
178
214
|
endTime,
|
|
179
215
|
durationSeconds,
|
|
@@ -188,7 +224,7 @@ export function createEventHook(
|
|
|
188
224
|
|
|
189
225
|
await client.tui.showToast({
|
|
190
226
|
body: {
|
|
191
|
-
message: `Time tracked: ${minutes} min, ${totalTokens} tokens${
|
|
227
|
+
message: `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}`,
|
|
192
228
|
variant: "success",
|
|
193
229
|
},
|
|
194
230
|
})
|
|
@@ -199,9 +235,7 @@ export function createEventHook(
|
|
|
199
235
|
variant: "error",
|
|
200
236
|
},
|
|
201
237
|
})
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
sessionManager.delete(sessionID)
|
|
238
|
+
}
|
|
205
239
|
}
|
|
206
240
|
}
|
|
207
241
|
}
|
|
@@ -113,7 +113,7 @@ export class CsvWriter {
|
|
|
113
113
|
this.config.user_email,
|
|
114
114
|
"",
|
|
115
115
|
data.ticket ?? "",
|
|
116
|
-
|
|
116
|
+
data.accountKey,
|
|
117
117
|
CsvFormatter.formatTime(data.startTime),
|
|
118
118
|
CsvFormatter.formatTime(data.endTime),
|
|
119
119
|
data.durationSeconds.toString(),
|
|
@@ -81,6 +81,27 @@ export class SessionManager {
|
|
|
81
81
|
this.sessions.delete(sessionID)
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Retrieves and deletes a session atomically.
|
|
86
|
+
*
|
|
87
|
+
* @param sessionID - The OpenCode session identifier
|
|
88
|
+
* @returns The session data, or `undefined` if not found
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* Prevents race conditions when multiple idle events fire
|
|
92
|
+
* for the same session. The session is removed immediately
|
|
93
|
+
* after retrieval to ensure it can only be processed once.
|
|
94
|
+
*/
|
|
95
|
+
getAndDelete(sessionID: string): SessionData | undefined {
|
|
96
|
+
const session = this.sessions.get(sessionID)
|
|
97
|
+
|
|
98
|
+
if (session) {
|
|
99
|
+
this.sessions.delete(sessionID)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return session
|
|
103
|
+
}
|
|
104
|
+
|
|
84
105
|
/**
|
|
85
106
|
* Adds a tool activity to a session.
|
|
86
107
|
*
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Resolves tickets and account keys with fallback hierarchy.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ResolvedTicketInfo } from "../types/ResolvedTicketInfo"
|
|
6
|
+
import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
|
|
7
|
+
import type { TicketExtractor } from "./TicketExtractor"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves tickets and account keys using fallback hierarchy.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Ticket fallback hierarchy:
|
|
14
|
+
* 1. Context ticket (from messages/todos)
|
|
15
|
+
* 2. Agent default (from config)
|
|
16
|
+
* 3. Global default (from config)
|
|
17
|
+
* 4. `null` (no ticket found)
|
|
18
|
+
*
|
|
19
|
+
* Account key fallback hierarchy:
|
|
20
|
+
* 1. Agent-specific account_key
|
|
21
|
+
* 2. Global default account_key
|
|
22
|
+
* 3. default_account_key from config
|
|
23
|
+
*/
|
|
24
|
+
export class TicketResolver {
|
|
25
|
+
/** Plugin configuration */
|
|
26
|
+
private config: TimeTrackingConfig
|
|
27
|
+
|
|
28
|
+
/** Ticket extractor for context-based lookup */
|
|
29
|
+
private ticketExtractor: TicketExtractor
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new ticket resolver instance.
|
|
33
|
+
*
|
|
34
|
+
* @param config - The plugin configuration
|
|
35
|
+
* @param ticketExtractor - The ticket extractor instance
|
|
36
|
+
*/
|
|
37
|
+
constructor(config: TimeTrackingConfig, ticketExtractor: TicketExtractor) {
|
|
38
|
+
this.config = config
|
|
39
|
+
this.ticketExtractor = ticketExtractor
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolves ticket and account key for a session.
|
|
44
|
+
*
|
|
45
|
+
* @param sessionID - The OpenCode session identifier
|
|
46
|
+
* @param agentName - The agent name (e.g., "@developer"), or `null`
|
|
47
|
+
* @returns Resolved ticket info with ticket and accountKey
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* const resolved = await ticketResolver.resolve("session-123", "@developer")
|
|
52
|
+
* // Returns { ticket: "PROJ-123", accountKey: "TD_DEV" }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
async resolve(
|
|
56
|
+
sessionID: string,
|
|
57
|
+
agentName: string | null
|
|
58
|
+
): Promise<ResolvedTicketInfo> {
|
|
59
|
+
// 1. Try context ticket
|
|
60
|
+
const contextTicket = await this.ticketExtractor.extract(sessionID)
|
|
61
|
+
|
|
62
|
+
if (contextTicket) {
|
|
63
|
+
return {
|
|
64
|
+
ticket: contextTicket,
|
|
65
|
+
accountKey: this.resolveAccountKey(agentName),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Try agent default
|
|
70
|
+
if (agentName && this.config.agent_defaults?.[agentName]) {
|
|
71
|
+
return {
|
|
72
|
+
ticket: this.config.agent_defaults[agentName].issue_key,
|
|
73
|
+
accountKey: this.resolveAccountKey(agentName),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Try global default
|
|
78
|
+
if (this.config.global_default) {
|
|
79
|
+
return {
|
|
80
|
+
ticket: this.config.global_default.issue_key,
|
|
81
|
+
accountKey: this.resolveAccountKey(agentName),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. No ticket found
|
|
86
|
+
return {
|
|
87
|
+
ticket: null,
|
|
88
|
+
accountKey: this.resolveAccountKey(agentName),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolves account key using fallback hierarchy.
|
|
94
|
+
*
|
|
95
|
+
* @param agentName - The agent name, or `null`
|
|
96
|
+
* @returns Resolved Tempo account key
|
|
97
|
+
*/
|
|
98
|
+
private resolveAccountKey(agentName: string | null): string {
|
|
99
|
+
// 1. Agent-specific account_key
|
|
100
|
+
if (agentName && this.config.agent_defaults?.[agentName]?.account_key) {
|
|
101
|
+
return this.config.agent_defaults[agentName].account_key!
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Global default account_key
|
|
105
|
+
if (this.config.global_default?.account_key) {
|
|
106
|
+
return this.config.global_default.account_key
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 3. Config default
|
|
110
|
+
return this.config.default_account_key
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Agent-specific default ticket configuration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration for agent-specific default tickets.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Used as fallback when no ticket is found in session context.
|
|
10
|
+
* Each agent (e.g., "@developer", "@reviewer") can have its own default.
|
|
11
|
+
*/
|
|
12
|
+
export interface AgentDefaultConfig {
|
|
13
|
+
/**
|
|
14
|
+
* Default JIRA Issue Key for this agent.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Must match pattern `^[A-Z][A-Z0-9]+-[0-9]+$` (e.g., "PROJ-123")
|
|
18
|
+
*/
|
|
19
|
+
issue_key: string
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Optional Tempo Account Key override.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* If not set, falls back to `global_default.account_key`
|
|
26
|
+
* or `default_account_key`.
|
|
27
|
+
*/
|
|
28
|
+
account_key?: string
|
|
29
|
+
}
|
|
@@ -45,4 +45,15 @@ export interface CsvEntryData {
|
|
|
45
45
|
* Only the first/primary agent is tracked.
|
|
46
46
|
*/
|
|
47
47
|
agent: string | null
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tempo account key for the worklog entry.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Resolved from (in order of priority):
|
|
54
|
+
* 1. Agent-specific account_key
|
|
55
|
+
* 2. Global default account_key
|
|
56
|
+
* 3. default_account_key from config
|
|
57
|
+
*/
|
|
58
|
+
accountKey: string
|
|
48
59
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Global fallback ticket configuration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration for global default ticket fallback.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Used when no ticket is found in session context and no
|
|
10
|
+
* agent-specific default is configured.
|
|
11
|
+
*/
|
|
12
|
+
export interface GlobalDefaultConfig {
|
|
13
|
+
/**
|
|
14
|
+
* Global default JIRA Issue Key.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Must match pattern `^[A-Z][A-Z0-9]+-[0-9]+$` (e.g., "PROJ-MISC-001")
|
|
18
|
+
*/
|
|
19
|
+
issue_key: string
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Optional Tempo Account Key.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* If not set, falls back to `default_account_key`.
|
|
26
|
+
*/
|
|
27
|
+
account_key?: string
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Result type for ticket resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result of ticket resolution containing ticket and account key.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Returned by `TicketResolver.resolve()` after applying the
|
|
10
|
+
* fallback hierarchy.
|
|
11
|
+
*/
|
|
12
|
+
export interface ResolvedTicketInfo {
|
|
13
|
+
/**
|
|
14
|
+
* Resolved JIRA ticket, or `null` if not found.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Resolution priority:
|
|
18
|
+
* 1. Context ticket (from messages/todos)
|
|
19
|
+
* 2. Agent default (from config)
|
|
20
|
+
* 3. Global default (from config)
|
|
21
|
+
* 4. `null` (no ticket found)
|
|
22
|
+
*/
|
|
23
|
+
ticket: string | null
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolved Tempo account key.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Resolution priority:
|
|
30
|
+
* 1. Agent-specific account_key
|
|
31
|
+
* 2. Global default account_key
|
|
32
|
+
* 3. default_account_key from config
|
|
33
|
+
*/
|
|
34
|
+
accountKey: string
|
|
35
|
+
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* @fileoverview Configuration type for the time tracking plugin.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { AgentDefaultConfig } from "./AgentDefaultConfig"
|
|
6
|
+
import type { GlobalDefaultConfig } from "./GlobalDefaultConfig"
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
9
|
* Time tracking configuration as stored in `.opencode/opencode-project.json`.
|
|
7
10
|
*
|
|
@@ -24,6 +27,33 @@ export interface TimeTrackingJsonConfig {
|
|
|
24
27
|
|
|
25
28
|
/** Default Jira account key for time entries */
|
|
26
29
|
default_account_key: string
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Agent-specific default tickets.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* Map of agent names (e.g., "@developer", "@reviewer") to their
|
|
36
|
+
* default ticket configuration. Used when no ticket is found in context.
|
|
37
|
+
*/
|
|
38
|
+
agent_defaults?: Record<string, AgentDefaultConfig>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Global fallback ticket configuration.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Used when no ticket is found in context and no agent-specific
|
|
45
|
+
* default is configured.
|
|
46
|
+
*/
|
|
47
|
+
global_default?: GlobalDefaultConfig
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* List of agent names to ignore for time tracking.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Sessions triggered by these agents will not be exported to CSV.
|
|
54
|
+
* Agent names should include the "@" prefix (e.g., "@internal").
|
|
55
|
+
*/
|
|
56
|
+
ignored_agents?: string[]
|
|
27
57
|
}
|
|
28
58
|
|
|
29
59
|
/**
|