@techdivision/opencode-time-tracking 0.3.1 → 0.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/package.json +1 -1
- package/src/Plugin.ts +3 -1
- package/src/hooks/EventHook.ts +38 -16
- 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 +21 -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),
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
return hooks
|
package/src/hooks/EventHook.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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"
|
|
@@ -83,10 +84,11 @@ async function extractSummaryTitle(
|
|
|
83
84
|
export function createEventHook(
|
|
84
85
|
sessionManager: SessionManager,
|
|
85
86
|
csvWriter: CsvWriter,
|
|
86
|
-
client: OpencodeClient
|
|
87
|
+
client: OpencodeClient,
|
|
88
|
+
ticketResolver: TicketResolver
|
|
87
89
|
) {
|
|
88
90
|
return async ({ event }: { event: Event }): Promise<void> => {
|
|
89
|
-
// Track model from assistant messages
|
|
91
|
+
// Track model and agent from assistant messages
|
|
90
92
|
if (event.type === "message.updated") {
|
|
91
93
|
const props = event.properties as MessageUpdatedProperties
|
|
92
94
|
const message = props.info
|
|
@@ -94,29 +96,40 @@ export function createEventHook(
|
|
|
94
96
|
if (message.role === "assistant") {
|
|
95
97
|
const assistantMsg = message as AssistantMessage
|
|
96
98
|
|
|
99
|
+
// Ensure session exists for tracking
|
|
100
|
+
if (!sessionManager.has(assistantMsg.sessionID)) {
|
|
101
|
+
sessionManager.create(assistantMsg.sessionID, null)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Track model
|
|
97
105
|
if (assistantMsg.modelID && assistantMsg.providerID) {
|
|
98
106
|
sessionManager.setModel(assistantMsg.sessionID, {
|
|
99
107
|
modelID: assistantMsg.modelID,
|
|
100
108
|
providerID: assistantMsg.providerID,
|
|
101
109
|
})
|
|
102
110
|
}
|
|
111
|
+
|
|
112
|
+
// Track agent from mode field
|
|
113
|
+
if (assistantMsg.mode) {
|
|
114
|
+
sessionManager.setAgent(assistantMsg.sessionID, assistantMsg.mode)
|
|
115
|
+
}
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
return
|
|
106
119
|
}
|
|
107
120
|
|
|
108
|
-
// Track token usage
|
|
121
|
+
// Track token usage from message part events
|
|
109
122
|
if (event.type === "message.part.updated") {
|
|
110
123
|
const props = event.properties as MessagePartUpdatedProperties
|
|
111
124
|
const part = props.part
|
|
112
125
|
|
|
113
|
-
// Track agent from agent parts (only first agent is stored)
|
|
114
|
-
if (part.type === "agent" && part.sessionID && part.name) {
|
|
115
|
-
sessionManager.setAgent(part.sessionID, part.name)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
126
|
// Track token usage from step-finish events
|
|
119
127
|
if (part.type === "step-finish" && part.sessionID && part.tokens) {
|
|
128
|
+
// Ensure session exists for token tracking
|
|
129
|
+
if (!sessionManager.has(part.sessionID)) {
|
|
130
|
+
sessionManager.create(part.sessionID, null)
|
|
131
|
+
}
|
|
132
|
+
|
|
120
133
|
sessionManager.addTokenUsage(part.sessionID, {
|
|
121
134
|
input: part.tokens.input,
|
|
122
135
|
output: part.tokens.output,
|
|
@@ -138,10 +151,17 @@ export function createEventHook(
|
|
|
138
151
|
return
|
|
139
152
|
}
|
|
140
153
|
|
|
141
|
-
|
|
154
|
+
// Atomically get and delete to prevent race conditions
|
|
155
|
+
const session = sessionManager.getAndDelete(sessionID)
|
|
142
156
|
|
|
143
|
-
if
|
|
144
|
-
|
|
157
|
+
// Check if session has any trackable data
|
|
158
|
+
const hasActivity = (session?.activities.length ?? 0) > 0
|
|
159
|
+
const hasTokens =
|
|
160
|
+
(session?.tokenUsage.input ?? 0) +
|
|
161
|
+
(session?.tokenUsage.output ?? 0) >
|
|
162
|
+
0
|
|
163
|
+
|
|
164
|
+
if (!session || (!hasActivity && !hasTokens)) {
|
|
145
165
|
return
|
|
146
166
|
}
|
|
147
167
|
|
|
@@ -170,9 +190,13 @@ export function createEventHook(
|
|
|
170
190
|
// Get agent name if available
|
|
171
191
|
const agentString = session.agent?.name ?? null
|
|
172
192
|
|
|
193
|
+
// Resolve ticket and account key with fallback hierarchy
|
|
194
|
+
const resolved = await ticketResolver.resolve(sessionID, agentString)
|
|
195
|
+
|
|
173
196
|
try {
|
|
174
197
|
await csvWriter.write({
|
|
175
|
-
ticket:
|
|
198
|
+
ticket: resolved.ticket,
|
|
199
|
+
accountKey: resolved.accountKey,
|
|
176
200
|
startTime: session.startTime,
|
|
177
201
|
endTime,
|
|
178
202
|
durationSeconds,
|
|
@@ -187,7 +211,7 @@ export function createEventHook(
|
|
|
187
211
|
|
|
188
212
|
await client.tui.showToast({
|
|
189
213
|
body: {
|
|
190
|
-
message: `Time tracked: ${minutes} min, ${totalTokens} tokens${
|
|
214
|
+
message: `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}`,
|
|
191
215
|
variant: "success",
|
|
192
216
|
},
|
|
193
217
|
})
|
|
@@ -198,9 +222,7 @@ export function createEventHook(
|
|
|
198
222
|
variant: "error",
|
|
199
223
|
},
|
|
200
224
|
})
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
sessionManager.delete(sessionID)
|
|
225
|
+
}
|
|
204
226
|
}
|
|
205
227
|
}
|
|
206
228
|
}
|
|
@@ -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,24 @@ 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
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
/**
|