From 2f6713e6b25c35cf94c4adcc7d004c8a5ca3f429 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Tue, 21 Apr 2026 14:27:49 +0200
Subject: [PATCH 01/29] COPSPA-489: Implemantation Plan
---
IMPLEMENTATION_PLAN.md | 941 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 941 insertions(+)
create mode 100644 IMPLEMENTATION_PLAN.md
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..984c7a5
--- /dev/null
+++ b/IMPLEMENTATION_PLAN.md
@@ -0,0 +1,941 @@
+# đ IMPLEMENTATION PLAN
+## OpenCode Plugin nutzt lib-ts-time-tracking
+
+**Status:** Ready to Implement
+**Estimated Time:** 22h (3 Tage)
+**Last Updated:** 2026-04-21
+
+---
+
+## TABLE OF CONTENTS
+
+1. [Overview](#overview)
+2. [Phase 1: Vorbereitung](#phase-1-vorbereitung)
+3. [Phase 2: Refactoring](#phase-2-refactoring)
+4. [Phase 3: Cleanup](#phase-3-cleanup)
+5. [Phase 4: Testing](#phase-4-testing)
+6. [Phase 5: Dokumentation](#phase-5-dokumentation)
+7. [Checkliste](#checkliste)
+
+---
+
+## OVERVIEW
+
+### Ausgangssituation
+```
+Aktueller Flow im OpenCode Plugin:
+ session.status.idle Event
+ â
+ SessionManager.getAndDelete(sessionID)
+ â
+ DescriptionGenerator.generate() + TitleGenerator.generate()
+ â
+ Manuelle CsvEntryData Zusammenstellung
+ â
+ Writers aufrufen (CSV, Webhook)
+```
+
+### Zielfluss
+```
+Mit lib-ts-time-tracking Integration:
+ session.status.idle Event
+ â
+ SessionManager.getAndDelete(sessionID) â SessionDataInterface Builder
+ â
+ TimeTrackingFacade.track() [aus Lib!]
+ â
+ SessionSummaryGenerator.generateSummary() [aus Lib!]
+ â
+ CSV + Webhook Output (von Lib)
+```
+
+### Kernerkenntnisse zur Implementierung
+
+#### A. SessionManager - WRAPPER PATTERN (Option A)
+```
+lib-ts-time-tracking/src/services/OpenCodeSessionManager.ts (NEU)
+ â (generisch, keine SDK-Dependencies)
+opencode-plugin-time-tracking/src/services/SessionManager.ts (Wrapper)
+ â (OpenCode-spezifisch)
+EventHook.ts
+```
+
+**GrĂŒnde:**
+- Zukunftssicherheit: Wenn OpenCode spÀter Plugin-spezifische Methoden braucht
+- Klare Verantwortung: SessionManager = OpenCode Facade
+- Weniger Coupling: Plugin nicht direkt von Lib-Details abhÀngig
+
+#### B. ConversationContextProvider - INLINE (Option A)
+```typescript
+// In EventHook.ts - beim idle Event:
+const conversationContextProvider = async () => {
+ const messages = await client.session.messages({
+ sessionID,
+ limit: 10
+ })
+ return messages.map(m => formatMessage(m)).join("\n")
+}
+
+// Dann beim track()-Call:
+const sessionData: SessionDataInterface = {
+ // ...
+ conversationContext: conversationContextProvider
+}
+```
+
+**GrĂŒnde:**
+- KISS Principle - nur 5-10 Zeilen Code
+- Aktuell nur ein Use-Case
+- Direkter sichtbar im Event-Handler
+
+#### C. Lazy Loading der Facade (Marketplace-Pattern)
+```typescript
+// In Plugin.ts
+let facadePromise: Promise | null = null
+
+async function getFacade(config): Promise {
+ if (!facadePromise) {
+ facadePromise = Promise.resolve(new TimeTrackingFacade(config))
+ }
+ return facadePromise
+}
+```
+
+#### D. Error Handling mit Fallback
+- Wenn ConversationContext fehlschlĂ€gt â Activity-Summary Fallback
+- Wenn LLM API down â Activity-Summary Fallback
+- Wenn CSV/Webhook Fehler â isoliert, andere Writer lĂ€uft weiter
+- CSV wird IMMER geschrieben (Graceful Degradation)
+
+---
+
+## PHASE 1: VORBEREITUNG
+
+**Dauer:** ~4h
+**Ziel:** Neue Dateien erstellen, Dependencies einrichten
+
+### Step 1.1: Workspace Dependencies
+
+**Datei:** `opencode-plugin-time-tracking/package.json`
+
+HinzufĂŒgen zu `dependencies`:
+```json
+{
+ "dependencies": {
+ "@opencode-ai/plugin": "^latest",
+ "@techdivision/lib-ts-time-tracking": "workspace:*"
+ }
+}
+```
+
+**Command:** `npm install` oder `bun install`
+
+### Step 1.2: Neue Datei - OpenCodeSessionManager in der Lib
+
+**Pfad:** `lib-ts-time-tracking/src/services/OpenCodeSessionManager.ts`
+
+**Inhalt:** Kopiere aus aktuellem `opencode-plugin-time-tracking/src/services/SessionManager.ts`
+
+**Wichtig:**
+- â KEINE SDK-Imports (OpencodeClient, MessageWithParts, etc.)
+- â
JA zu allen CRUD Operations fĂŒr SessionData
+- â
JA zu ActivityData, TokenUsage, etc. (Standard Types)
+
+**Struktur:**
+```typescript
+export class OpenCodeSessionManager {
+ private sessions = new Map()
+
+ get(sessionID: string): SessionData | undefined { ... }
+ has(sessionID: string): boolean { ... }
+ create(sessionID: string, ticket: string | null): SessionData { ... }
+ delete(sessionID: string): void { ... }
+ getAndDelete(sessionID: string): SessionData | undefined { ... }
+ addActivity(sessionID: string, activity: ActivityData): void { ... }
+ addTokenUsage(sessionID: string, tokens: TokenUsage): void { ... }
+ addCost(sessionID: string, cost: number): void { ... }
+}
+```
+
+### Step 1.3: Neue Datei - SessionDataMapper im Plugin
+
+**Pfad:** `opencode-plugin-time-tracking/src/adapters/SessionDataMapper.ts`
+
+**Zweck:** Konvertiert OpenCode's SessionData â Lib's SessionDataInterface
+
+**Inhalt:**
+```typescript
+import type { OpencodeClient } from "../types/OpencodeClient"
+import type { SessionData } from "../types/SessionData"
+import type { SessionDataInterface } from "@techdivision/lib-ts-time-tracking"
+
+/**
+ * Converts OpenCode plugin's SessionData to lib's SessionDataInterface.
+ * Builds the ConversationContextProvider callback inline.
+ */
+export class SessionDataMapper {
+ /**
+ * Builds SessionDataInterface from SessionData.
+ * Includes conversation context provider callback.
+ */
+ static build(
+ session: SessionData,
+ client: OpencodeClient,
+ sessionID: string,
+ config: { userEmail?: string }
+ ): SessionDataInterface {
+ // Format model as "provider/modelID"
+ const modelString = session.model
+ ? `${session.model.providerID}/${session.model.modelID}`
+ : "unknown"
+
+ // Build conversation context provider inline
+ const conversationContextProvider = async (): Promise => {
+ try {
+ const messages = await client.session.messages({
+ sessionID,
+ limit: 10,
+ } as Parameters[0])
+
+ if (!messages || messages.length === 0) {
+ return null
+ }
+
+ // Format messages as context string
+ return messages
+ .map((m) => {
+ const role = m.info?.role || "unknown"
+ const content = m.content || ""
+ return `${role}: ${content}`
+ })
+ .join("\n")
+ } catch {
+ // Graceful degradation: if SDK call fails, return null
+ // Lib will use activity-based fallback
+ return null
+ }
+ }
+
+ return {
+ agent: session.agent?.name ?? "unknown",
+ model: modelString,
+ startTime: session.startTime,
+ endTime: Date.now(),
+ userEmail: config.userEmail,
+ tokens: {
+ input: session.tokenUsage.input,
+ output: session.tokenUsage.output,
+ cacheRead: session.tokenUsage.cacheRead,
+ cacheWrite: session.tokenUsage.cacheWrite,
+ },
+ activities: session.activities,
+ conversationContext: conversationContextProvider,
+ ticket: session.ticket ?? undefined,
+ }
+ }
+}
+```
+
+### Step 1.4: Export in lib's index.ts
+
+**Datei:** `lib-ts-time-tracking/src/index.ts`
+
+HinzufĂŒgen:
+```typescript
+export { OpenCodeSessionManager } from "./services/OpenCodeSessionManager.js"
+```
+
+Stelle sicher dass `TimeTrackingFacade` auch exportiert ist.
+
+### Step 1.5: Kompilation Check
+
+```bash
+# In lib-ts-time-tracking
+npm run build
+# oder
+tsc --noEmit
+
+# In opencode-plugin-time-tracking
+npm run build
+# oder
+tsc --noEmit
+```
+
+**Muss ohne Fehler laufen!**
+
+---
+
+## PHASE 2: REFACTORING
+
+**Dauer:** ~10h
+**Ziel:** Bestehende Dateien refactoren, Lib-Integration einbauen
+
+### Step 2.1: Plugin.ts - Facade Initialization
+
+**Datei:** `opencode-plugin-time-tracking/src/Plugin.ts`
+
+**Imports - ENTFERNEN:**
+```typescript
+import { TitleGenerator } from "./services/TitleGenerator"
+import { ProviderAdapter } from "./services/ProviderAdapter"
+```
+
+**Imports - HINZUFĂGEN:**
+```typescript
+import { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking"
+```
+
+**Neue Funktion - HINZUFĂGEN (vor createPlugin):**
+```typescript
+/**
+ * Lazy-loads TimeTrackingFacade instance.
+ * Follows Marketplace plugin pattern for single initialization.
+ */
+let facadePromise: Promise | null = null
+
+async function getTimeTrackingFacade(
+ config: TimeTrackingConfig
+): Promise {
+ if (!facadePromise) {
+ facadePromise = Promise.resolve(new TimeTrackingFacade(config))
+ }
+ return facadePromise
+}
+```
+
+**In createPlugin() - ENTFERNEN:**
+```typescript
+const titleGenerator = new TitleGenerator(client, config.time_tracking, configDir)
+await titleGenerator.checkAvailability()
+```
+
+**In createPlugin() - ĂNDERN bei createEventHook():**
+
+Alt:
+```typescript
+const eventHook = createEventHook(
+ sessionManager,
+ writers,
+ client,
+ ticketResolver,
+ config,
+ titleGenerator // â ENTFERNEN
+)
+```
+
+Neu:
+```typescript
+const eventHook = createEventHook(
+ sessionManager,
+ writers,
+ client,
+ ticketResolver,
+ config,
+ (timeTrackingConfig) => getTimeTrackingFacade(timeTrackingConfig) // â HINZUFĂGEN
+)
+```
+
+**Wichtig:** Die `createEventHook` Signature muss angepasst werden (siehe Step 2.2)
+
+### Step 2.2: EventHook.ts - Core Refactoring
+
+**Datei:** `opencode-plugin-time-tracking/src/hooks/EventHook.ts`
+
+#### 2.2.1: Function Signature anpassen
+
+Alt:
+```typescript
+export function createEventHook(
+ sessionManager: SessionManager,
+ writers: WriterService[],
+ client: OpencodeClient,
+ ticketResolver: TicketResolver,
+ config: TimeTrackingConfig,
+ titleGenerator: TitleGenerator
+): EventHook {
+```
+
+Neu:
+```typescript
+export function createEventHook(
+ sessionManager: SessionManager,
+ writers: WriterService[],
+ client: OpencodeClient,
+ ticketResolver: TicketResolver,
+ config: TimeTrackingConfig,
+ getTimeTrackingFacade: (cfg: any) => Promise
+): EventHook {
+```
+
+#### 2.2.2: Imports anpassen
+
+**ENTFERNEN:**
+```typescript
+import { DescriptionGenerator } from "../utils/DescriptionGenerator"
+import { TitleGenerator } from "../services/TitleGenerator"
+import { extractSummaryTitle } from "../utils/MessageExtractor"
+```
+
+**HINZUFĂGEN:**
+```typescript
+import { SessionDataMapper } from "../adapters/SessionDataMapper"
+import type { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking"
+```
+
+#### 2.2.3: Session Status Handler refactoren
+
+**Finde:** `if (event.type === "session.status") {` (ungefÀhr Zeile 166)
+
+**ENTFERNEN:** Zeilen ~200-224 (LLM Title + Activity Summary Logik)
+```typescript
+// ALT - ENTFERNEN:
+const activitySummary = DescriptionGenerator.generate(session.activities)
+let title = await extractSummaryTitle(client, sessionID)
+if (!title) {
+ try {
+ title = await Promise.race([
+ titleGenerator.generate(sessionID),
+ new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
+ ])
+ } catch {
+ title = null
+ }
+}
+const description = title
+ ? `${title} | ${activitySummary}`
+ : activitySummary
+```
+
+**ERSETZEN mit (NEU):**
+```typescript
+// NEU - Nutze Lib's SessionSummaryGenerator via Facade
+const sessionData = SessionDataMapper.build(session, client, sessionID, {
+ userEmail: config.user_email,
+})
+
+const facade = await getTimeTrackingFacade(config.time_tracking)
+const trackResult = await facade.track(sessionData)
+const description = trackResult.summary.description
+```
+
+**ENTFERNEN:** Zeilen ~222-224 (Tool Summary)
+```typescript
+// ALT - ENTFERNEN:
+const toolSummary = DescriptionGenerator.generateToolSummary(
+ session.activities
+)
+```
+
+**Ersetzen mit:**
+```typescript
+// NEU - Kommt von Lib
+const notes = trackResult.summary.notes
+```
+
+**ENTFERNEN:** Zeilen ~260-276 (Manuelle CsvEntryData Zusammenstellung)
+```typescript
+// ALT - ENTFERNEN (groĂe Block):
+const entryData: CsvEntryData = {
+ id: randomUUID(),
+ userEmail: config.user_email,
+ ticket: resolved.ticket,
+ accountKey: resolved.accountKey,
+ authorEmail: resolved.authorEmail,
+ startTime: session.startTime,
+ endTime,
+ durationSeconds,
+ description,
+ notes: `Auto-tracked: ${toolSummary}`,
+ tokenUsage: session.tokenUsage,
+ cost: session.cost,
+ model: modelString,
+ agent: (resolved.primaryAgent ?? agentString)?.replace(/^@/, "") ?? null,
+}
+```
+
+**ERSETZEN mit:**
+```typescript
+// NEU - Nutze trackResult.entry von Lib
+const entryData: CsvEntryData = {
+ ...trackResult.entry, // CSV entry kommt direkt von Lib!
+ ticket: resolved.ticket, // OpenCode Resolving override
+ accountKey: resolved.accountKey,
+ agent: (resolved.primaryAgent ?? agentString)?.replace(/^@/, "") ?? null,
+}
+```
+
+**ĂNDERN:** Writers aufrufen
+
+Alt:
+```typescript
+// ALT - ENTFERNEN (Zeilen ~278-284):
+const results: WriteResult[] = []
+for (const writer of writers) {
+ const result = await writer.write(entryData)
+ results.push(result)
+}
+```
+
+Neu:
+```typescript
+// NEU - Writers werden von Facade aufgerufen
+// aber wir haben trotzdem access zu trackResult.csv und trackResult.webhook:
+const results: WriteResult[] = [
+ trackResult.csv,
+ trackResult.webhook,
+].filter(r => r !== undefined && r !== null)
+```
+
+**ĂNDERN:** Toast Feedback
+
+Alt:
+```typescript
+if (!titleGenerator.isAvailable) {
+ message += " (title generation NOT available)"
+}
+```
+
+Neu:
+```typescript
+if (trackResult.summary.llmError) {
+ message += ` (LLM: ${trackResult.summary.llmError})`
+}
+```
+
+#### 2.2.4: Kompilation Test
+
+```bash
+npm run build
+# oder
+tsc --noEmit
+```
+
+### Step 2.3: SessionManager.ts - Wrapper Umwandlung
+
+**Datei:** `opencode-plugin-time-tracking/src/services/SessionManager.ts`
+
+**Imports - ĂNDERN:**
+```typescript
+import { OpenCodeSessionManager } from "@techdivision/lib-ts-time-tracking"
+```
+
+**Entfernen:** Private sessions Map
+
+```typescript
+// ALT - ENTFERNEN:
+private sessions = new Map()
+```
+
+**HinzufĂŒgen:**
+```typescript
+// NEU:
+private manager = new OpenCodeSessionManager()
+```
+
+**Alle Methods - DELEGIEREN:**
+```typescript
+export class SessionManager {
+ private manager = new OpenCodeSessionManager()
+
+ get(sessionID: string): SessionData | undefined {
+ return this.manager.get(sessionID)
+ }
+
+ has(sessionID: string): boolean {
+ return this.manager.has(sessionID)
+ }
+
+ create(sessionID: string, ticket: string | null): SessionData {
+ return this.manager.create(sessionID, ticket)
+ }
+
+ delete(sessionID: string): void {
+ this.manager.delete(sessionID)
+ }
+
+ getAndDelete(sessionID: string): SessionData | undefined {
+ return this.manager.getAndDelete(sessionID)
+ }
+
+ addActivity(sessionID: string, activity: ActivityData): void {
+ this.manager.addActivity(sessionID, activity)
+ }
+
+ addTokenUsage(sessionID: string, tokens: TokenUsage): void {
+ this.manager.addTokenUsage(sessionID, tokens)
+ }
+
+ addCost(sessionID: string, cost: number): void {
+ this.manager.addCost(sessionID, cost)
+ }
+}
+```
+
+**Wichtig:** Keine neue Logik! Nur delegation.
+
+---
+
+## PHASE 3: CLEANUP
+
+**Dauer:** ~2h
+**Ziel:** Duplikate löschen
+
+### Dateien zum LĂSCHEN (6 Dateien, ~870 Zeilen)
+
+```
+â src/services/TitleGenerator.ts (332 Zeilen)
+ BegrĂŒndung: Ersetzt durch lib's SessionSummaryGenerator
+
+â src/utils/DescriptionGenerator.ts (119 Zeilen)
+ BegrĂŒndung: Ersetzt durch lib's Activity-Summary Logic in SessionSummaryGenerator
+
+â src/services/CsvWriter.ts (284 Zeilen)
+ BegrĂŒndung: 100% identisch mit lib's CsvWriter
+
+â src/services/WebhookSender.ts (137 Zeilen)
+ BegrĂŒndung: 100% identisch mit lib's WebhookSender
+
+â src/services/ProviderAdapter.ts
+ BegrĂŒndung: 100% identisch mit lib's ProviderAdapter
+
+â src/utils/MessageExtractor.ts
+ BegrĂŒndung: Nur fĂŒr extractSummaryTitle verwendet, nicht mehr nötig
+```
+
+### Dateien die BLEIBEN (OpenCode-spezifisch)
+
+```
+â src/services/SessionManager.ts (jetzt Wrapper)
+â src/services/TicketResolver.ts (SDK-spezifisch, nicht in Lib)
+â src/services/ConfigLoader.ts (OpenCode Config)
+â src/hooks/ToolExecuteAfterHook.ts (Tool Activity Tracking)
+â src/hooks/EventHook.ts (refactored)
+â Alle Type Definitions in src/types/
+â Alle anderen Utils
+```
+
+### Kompilation Test
+
+Nach dem Löschen:
+```bash
+npm run build
+```
+
+Sollte trotzdem funktionieren ohne Fehler!
+
+---
+
+## PHASE 4: TESTING
+
+**Dauer:** ~6h
+**Ziel:** Unit Tests, Integration Tests, E2E Test
+
+### Step 4.1: Unit Tests
+
+**Neu erstellen:** `tests/unit/adapters/SessionDataMapper.test.ts`
+
+```typescript
+import { describe, it, expect, vi } from "vitest"
+import { SessionDataMapper } from "../../../src/adapters/SessionDataMapper"
+import type { SessionData } from "../../../src/types/SessionData"
+import type { OpencodeClient } from "../../../src/types/OpencodeClient"
+
+describe("SessionDataMapper", () => {
+ it("maps SessionData to SessionDataInterface correctly", () => {
+ // Test hier
+ })
+
+ it("formats model as provider/modelID", () => {
+ // Test hier
+ })
+
+ it("builds conversationContextProvider callback", async () => {
+ // Test hier
+ })
+
+ it("handles null/undefined values gracefully", () => {
+ // Test hier
+ })
+
+ it("catches client.session.messages errors", async () => {
+ // Test hier
+ })
+})
+```
+
+**Ăberarbeiten:** `tests/unit/services/SessionManager.test.ts`
+
+Stelle sicher dass SessionManager korrekt zu OpenCodeSessionManager delegiert.
+
+### Step 4.2: Integration Test
+
+**Neu erstellen:** `tests/integration/hooks/EventHook.time-tracking.test.ts`
+
+```typescript
+import { describe, it, expect, beforeEach, vi } from "vitest"
+import { createEventHook } from "../../../src/hooks/EventHook"
+import type { MessageWithParts } from "../../../src/types/MessageWithParts"
+
+describe("EventHook - Time Tracking Integration", () => {
+ it("processes session.status.idle event with TimeTrackingFacade", async () => {
+ // Mock setup
+ // Event dispatch
+ // Assertions
+ })
+
+ it("builds CSV entry from trackResult.entry", async () => {
+ // Test hier
+ })
+
+ it("shows correct toast feedback on success", async () => {
+ // Test hier
+ })
+
+ it("handles errors with graceful degradation", async () => {
+ // Test hier
+ })
+})
+```
+
+### Step 4.3: E2E Test (Manuell)
+
+1. **OpenCode Session starten:**
+ ```bash
+ opencode dev
+ ```
+
+2. **Tool aufrufen:** z.B. File editieren mit `edit`
+
+3. **Session beenden:** Fenster schlieĂen oder `stop`
+
+4. **Verifizierungen:**
+ - [ ] CSV wurde an konfiguriertem Ort geschrieben
+ - [ ] Webhook wurde aufgerufen (falls konfiguriert)
+ - [ ] Description enthÀlt Activity Summary oder LLM-Text
+ - [ ] Toast Feedback zeigt Ticket + Token Count
+ - [ ] Keine Errors im Console
+
+5. **Vergleich mit Alt-Output:**
+ - [ ] CSV Format identisch
+ - [ ] Description Layout Àhnlich
+ - [ ] Token Counts gleich
+
+---
+
+## PHASE 5: DOKUMENTATION
+
+**Dauer:** ~2h
+**Ziel:** Dokumentation aktualisieren, Code Comments
+
+### Step 5.1: README aktualisieren
+
+**Datei:** `opencode-plugin-time-tracking/README.md`
+
+**Ănderungen:**
+- ErwÀhne dass Plugin jetzt `lib-ts-time-tracking` nutzt
+- Entferne ErklÀrungen zu TitleGenerator und DescriptionGenerator
+- Aktualisiere Architecture Diagram (falls vorhanden)
+- Mentioniere SessionDataMapper
+
+**Beispiel-Text:**
+```markdown
+## Architecture
+
+The plugin now uses [@techdivision/lib-ts-time-tracking](https://github.com/techdivision/lib-ts-time-tracking)
+for core time tracking functionality:
+
+- **SessionSummaryGenerator:** Generates descriptions via LLM or activity fallback
+- **TimeTrackingFacade:** Orchestrates summary generation, CSV writing, and webhook sending
+- **OpenCodeSessionManager:** Manages session state across multiple OpenCode events
+
+The plugin provides OpenCode-specific adapters:
+- **SessionDataMapper:** Converts OpenCode session data to lib's interface
+- **TicketResolver:** SDK-based JIRA ticket extraction
+```
+
+### Step 5.2: Code Comments
+
+**In SessionDataMapper.ts:**
+```typescript
+/**
+ * Converts OpenCode plugin's SessionData to lib's SessionDataInterface.
+ *
+ * Key transformations:
+ * - agent.name â string
+ * - model provider/modelID formatting
+ * - Token mapping with proper field names
+ * - ConversationContextProvider callback building
+ *
+ * Gracefully degrades if client.session.messages() fails.
+ */
+```
+
+**In EventHook.ts (bei Facade.track call):**
+```typescript
+// Use TimeTrackingFacade from lib for summary generation and writing
+// This replaces separate TitleGenerator and DescriptionGenerator calls
+const facade = await getTimeTrackingFacade(config.time_tracking)
+const trackResult = await facade.track(sessionData)
+```
+
+**In SessionManager.ts:**
+```typescript
+/**
+ * Wrapper around OpenCodeSessionManager from lib.
+ *
+ * Provides OpenCode-specific facade to the generic library implementation.
+ * All state management is delegated to the library to ensure single source of truth.
+ */
+```
+
+### Step 5.3: Git Commit
+
+```bash
+git add .
+git commit -m "refactor: OpenCode plugin nutzt lib-ts-time-tracking
+
+- Entfernen: TitleGenerator, DescriptionGenerator, CsvWriter, WebhookSender, ProviderAdapter
+- HinzufĂŒgen: SessionDataMapper fĂŒr Konvertierung zu Lib-Interface
+- HinzufĂŒgen: OpenCodeSessionManager in Lib (generisch, wiederverwendbar)
+- Refactor: EventHook nutzt TimeTrackingFacade mit Lazy Loading (Marketplace-Pattern)
+- Refactor: SessionManager wird zu Wrapper um Lib's OpenCodeSessionManager
+- Result: ~870 Zeilen Duplikation eliminiert, Single Source of Truth
+
+Implementiert:
+- Workspace Dependency: @techdivision/lib-ts-time-tracking
+- Inline ConversationContextProvider mit SDK Integration
+- Graceful Degradation fĂŒr Fehlerbehandlung
+- Activity-Summary Fallback wenn LLM fehlschlÀgt
+
+Fixes #"
+```
+
+---
+
+## CHECKLISTE
+
+### PRE-IMPLEMENTATION
+- [ ] Plan verstanden und genehmigt
+- [ ] Git Status clean oder committed
+- [ ] Branch fĂŒr Refactoring erstellt? (z.B. `feature/lib-integration`)
+- [ ] Backup von kritischen Dateien? (optional)
+
+### PHASE 1: VORBEREITUNG (4h)
+- [ ] Step 1.1: Workspace Dependencies in package.json
+- [ ] Step 1.1: `npm install` / `bun install` erfolgreich
+- [ ] Step 1.2: OpenCodeSessionManager in Lib erstellt
+- [ ] Step 1.3: SessionDataMapper im Plugin erstellt
+- [ ] Step 1.4: Exports in lib's index.ts hinzugefĂŒgt
+- [ ] Step 1.5: Kompilation erfolgreich (`npm run build`)
+
+### PHASE 2: REFACTORING (10h)
+- [ ] Step 2.1: Plugin.ts - Facade Initialization
+ - [ ] Imports bereinigt
+ - [ ] getFacade() Funktion hinzugefĂŒgt
+ - [ ] createEventHook() aufgerufen mit getFacade
+- [ ] Step 2.2: EventHook.ts - Core Refactoring
+ - [ ] Function Signature angepasst
+ - [ ] Imports bereinigt
+ - [ ] Session Status Handler umgeschrieben
+ - [ ] SessionDataMapper.build() Aufruf eingebaut
+ - [ ] Facade.track() Aufruf eingebaut
+ - [ ] CsvEntryData von trackResult.entry gebaut
+ - [ ] Toast Feedback angepasst
+- [ ] Step 2.3: SessionManager.ts - Wrapper Umwandlung
+ - [ ] Private manager = new OpenCodeSessionManager()
+ - [ ] Alle Methods delegieren
+- [ ] Step 2.4: Kompilation erfolgreich
+
+### PHASE 3: CLEANUP (2h)
+- [ ] TitleGenerator.ts gelöscht
+- [ ] DescriptionGenerator.ts gelöscht
+- [ ] CsvWriter.ts gelöscht
+- [ ] WebhookSender.ts gelöscht
+- [ ] ProviderAdapter.ts gelöscht
+- [ ] MessageExtractor.ts gelöscht
+- [ ] Kompilation erfolgreich nach Löschen
+
+### PHASE 4: TESTING (6h)
+- [ ] SessionDataMapper Unit Tests erstellt
+- [ ] SessionManager Unit Tests ĂŒberarbeitet
+- [ ] EventHook Integration Tests erstellt
+- [ ] E2E Test durchgefĂŒhrt (echte Session)
+ - [ ] CSV geschrieben
+ - [ ] Webhook aufgerufen (falls konfiguriert)
+ - [ ] Description korrekt
+ - [ ] Toast Feedback korrekt
+ - [ ] Keine Errors im Console
+
+### PHASE 5: DOKUMENTATION (2h)
+- [ ] README.md aktualisiert
+- [ ] Code Comments hinzugefĂŒgt
+- [ ] Git Commit erstellt
+
+### POST-IMPLEMENTATION
+- [ ] Alle Tests grĂŒn (`npm run test`)
+- [ ] TypeScript Compilation erfolgreich
+- [ ] Dokumentation vollstÀndig
+- [ ] Pull Request erstellt (falls relevant)
+
+---
+
+## SUMMARY BY THE NUMBERS
+
+| Metrik | Wert |
+|--------|------|
+| **Neue Dateien** | 2 (OpenCodeSessionManager, SessionDataMapper) |
+| **GeÀnderte Dateien** | 4 (Plugin.ts, EventHook.ts, SessionManager.ts, index.ts) |
+| **Gelöschte Dateien** | 6 (~870 Zeilen) |
+| **Zeilen eingefĂŒgt** | ~200 |
+| **Zeilen gelöscht** | ~1.050 |
+| **Netto-Reduktion** | -850 Zeilen |
+| **Neue Tests** | ~15 |
+| **Estimated Time** | 22h (3 Tage) |
+| **Code Quality** | â (Single Source of Truth, weniger Duplikation) |
+
+---
+
+## KNOWN ISSUES & FALLBACKS
+
+### Wenn ConversationContext fehlschlÀgt:
+```
+â
SessionSummaryGenerator nutzt Activity-Summary Fallback
+â
CSV wird trotzdem geschrieben
+â
Toast zeigt erfolgreiche Speicherung
+```
+
+### Wenn LLM API down:
+```
+â
SessionSummaryGenerator nutzt Activity-Summary Fallback
+â
Kein Error geworfen
+â
Graceful Degradation
+```
+
+### Wenn CSV/Webhook schreibt:
+```
+â
Beide werden aufgerufen (parallel von Lib)
+â
Fehler sind isoliert (einer fehlgeschlagen = andere lÀuft weiter)
+â
Toast zeigt welcher Writer fehlgeschlagen
+```
+
+---
+
+## HELPFUL RESOURCES
+
+- [lib-ts-time-tracking](../lib-ts-time-tracking) - Source Library
+- [TimeTrackingFacade Docs](../lib-ts-time-tracking/src/services/TimeTrackingFacade.ts)
+- [SessionDataInterface](../lib-ts-time-tracking/src/types/SessionDataInterface.ts)
+- [Marketplace Plugin](../plugin-marketplace/time-tracking) - Reference Implementation (Claude Code)
+
+---
+
+## NEXT STEPS
+
+1. â
Plan verstanden
+2. âł Phase 1 starten: Workspace Dependencies + neue Dateien
+3. âł Phase 2 starten: Refactoring der bestehenden Dateien
+4. âł Phase 3 starten: Cleanup
+5. âł Phase 4 starten: Testing
+6. âł Phase 5 starten: Dokumentation
+
+**Ready to implement? â Go!** đ
From c497d919d2bc1f1ba4d8de40d5e33a63bc3a6b6e Mon Sep 17 00:00:00 2001
From: Patrick
Date: Thu, 23 Apr 2026 09:39:48 +0200
Subject: [PATCH 02/29] refactor: OpenCode plugin nutzt lib-ts-time-tracking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Entfernen: TitleGenerator, DescriptionGenerator, CsvWriter, WebhookSender, ProviderAdapter, MessageExtractor
- HinzufĂŒgen: SessionDataMapper fĂŒr Konvertierung zu Lib-Interface
- HinzufĂŒgen: OpenCodeSessionManager in Lib (generisch, wiederverwendbar)
- Refactor: EventHook nutzt TimeTrackingFacade mit Lazy Loading (Marketplace-Pattern)
- Refactor: SessionManager wird zu Wrapper um Lib's OpenCodeSessionManager
- Result: ~870 Zeilen Duplikation eliminiert, Single Source of Truth
Implementiert:
- Workspace Dependency: @techdivision/lib-ts-time-tracking
- Inline ConversationContextProvider mit SDK Integration
- Graceful Degradation fĂŒr Fehlerbehandlung
- Activity-Summary Fallback wenn LLM fehlschlÀgt
- Unit Tests fĂŒr SessionDataMapper und SessionManager
- Integration Tests fĂŒr EventHook mit TimeTrackingFacade
Fixes COPSPA-489
---
EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md | 328 +++++++++++++++++
README.md | 15 +
package-lock.json | 30 +-
package.json | 3 +-
src/Plugin.ts | 57 +--
src/adapters/SessionDataMapper.ts | 93 +++++
src/hooks/EventHook.ts | 185 ++++------
src/services/CsvWriter.ts | 284 ---------------
src/services/ProviderAdapter.ts | 83 -----
src/services/SessionManager.ts | 101 ++----
src/services/TitleGenerator.ts | 332 ------------------
src/services/WebhookSender.ts | 137 --------
src/utils/DescriptionGenerator.ts | 119 -------
src/utils/MessageExtractor.ts | 105 ------
.../hooks/EventHook.time-tracking.test.ts | 330 +++++++++++++++++
tests/unit/adapters/SessionDataMapper.test.ts | 164 +++++++++
tests/unit/services/SessionManager.test.ts | 228 ++++++++++++
tools/track-time.ts | 45 +--
18 files changed, 1339 insertions(+), 1300 deletions(-)
create mode 100644 EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md
create mode 100644 src/adapters/SessionDataMapper.ts
delete mode 100644 src/services/CsvWriter.ts
delete mode 100644 src/services/ProviderAdapter.ts
delete mode 100644 src/services/TitleGenerator.ts
delete mode 100644 src/services/WebhookSender.ts
delete mode 100644 src/utils/DescriptionGenerator.ts
delete mode 100644 src/utils/MessageExtractor.ts
create mode 100644 tests/integration/hooks/EventHook.time-tracking.test.ts
create mode 100644 tests/unit/adapters/SessionDataMapper.test.ts
create mode 100644 tests/unit/services/SessionManager.test.ts
diff --git a/EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md b/EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md
new file mode 100644
index 0000000..6952772
--- /dev/null
+++ b/EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md
@@ -0,0 +1,328 @@
+# Epic: OpenCode Plugin nutzt lib-ts-time-tracking
+
+| Attribut | Wert |
+|----------|------|
+| **ID** | EPIC-OPENCODE-01 |
+| **Feature** | Time-Tracking Integration |
+| **Name** | OpenCode Plugin nutzt lib-ts-time-tracking |
+| **Priority** | MUST |
+| **Size** | M (3 Tage) |
+| **Target Duration** | 3 Tage |
+| **Owner** | Patrick Mehringer |
+| **Account** | 1100 \| KI Arbeitsweise |
+| **Status** | Draft |
+| **JIRA** | COPSPA-XXX |
+
+---
+
+## Beschreibung
+
+**WER:** Als Entwickler
+**WAS:** möchte ich, dass das OpenCode Plugin die bereits existierende `lib-ts-time-tracking` Library vollstÀndig nutzt
+**WARUM:** um Duplikationen zu eliminieren, eine Single Source of Truth zu etablieren und die Wartbarkeit zu verbessern.
+
+Das OpenCode Plugin implementiert derzeit identische Services und Logik, die bereits in der `lib-ts-time-tracking` Library vorhanden sind. Dies fĂŒhrt zu ~870 Zeilen Duplikation und erschwert die Wartung und Weiterentwicklung.
+
+---
+
+## Ist-Zustand (2026-04-21)
+
+### Redundante Implementierungen
+- `TitleGenerator` (332 Zeilen) - identisch mit Lib's `SessionSummaryGenerator`
+- `DescriptionGenerator` (119 Zeilen) - identisch mit Lib's Activity-Summary Logik
+- `CsvWriter` (284 Zeilen) - 100% Duplikat der Lib
+- `WebhookSender` (137 Zeilen) - 100% Duplikat der Lib
+- `ProviderAdapter` - 100% Duplikat der Lib
+- `MessageExtractor` - nur fĂŒr LLM Context Handling
+
+### Manuelle Service-Orchestrierung
+- EventHook orchestriert Services manuell (~100 Zeilen Boilerplate)
+- Keine Facade-Integration
+- Keine Nutzung von Lib's `TimeTrackingFacade`
+
+### Total Duplikation
+- **~870 Zeilen identischer Code**
+- Mehrfach zu wartende Services
+- Keine zentrale Quelle fĂŒr Bugfixes und Verbesserungen
+
+---
+
+## Ziel-Zustand
+
+â
OpenCode Plugin nutzt `TimeTrackingFacade` aus Lib
+â
`SessionSummaryGenerator` als Single Source of Truth
+â
`SessionManager` wird zu Wrapper um Lib's `OpenCodeSessionManager`
+â
`SessionDataMapper` konvertiert OpenCode SessionData â SessionDataInterface
+â
EventHook nutzt Lazy-Loading der Facade (Marketplace-Pattern)
+â
~850 Zeilen Code-Reduktion
+â
Bessere Wartbarkeit und Testbarkeit
+â
Konsistenz zwischen Marketplace Plugin und OpenCode Plugin
+
+---
+
+## Akzeptanzkriterien
+
+### AC1: Dependencies & Library Export
+- [ ] `@techdivision/lib-ts-time-tracking` als workspace dependency in `package.json`
+- [ ] Lib exportiert `OpenCodeSessionManager` in `src/index.ts`
+- [ ] `TimeTrackingFacade` in Lib exportiert
+- [ ] Kompilation erfolgreich in beiden Packages
+
+### AC2: Library Services - OpenCodeSessionManager
+- [ ] Neue Datei: `lib-ts-time-tracking/src/services/OpenCodeSessionManager.ts`
+- [ ] Generische Session State Management (keine SDK-Dependencies)
+- [ ] CRUD Operations: get, has, create, delete, getAndDelete, addActivity, addTokenUsage, addCost
+- [ ] ~200 Zeilen Code
+
+### AC3: Plugin Adapter - SessionDataMapper
+- [ ] Neue Datei: `opencode-plugin-time-tracking/src/adapters/SessionDataMapper.ts`
+- [ ] Konvertiert SessionData â SessionDataInterface
+- [ ] Buildet ConversationContextProvider inline mit SDK Integration
+- [ ] Graceful Degradation bei SDK Call Fehlern
+- [ ] ~50 Zeilen Code
+
+### AC4: Plugin.ts Refactoring
+- [ ] Lazy-Loading Funktion fĂŒr TimeTrackingFacade
+- [ ] Keine TitleGenerator / ProviderAdapter Initialisierung mehr
+- [ ] `createEventHook` mit getFacade Parameter aufgerufen
+- [ ] Kompilation erfolgreich
+
+### AC5: EventHook.ts Core Refactoring
+- [ ] Session Status Handler refactored auf Facade
+- [ ] SessionDataMapper.build() statt manueller Mapping
+- [ ] TimeTrackingFacade.track() statt manueller Service-Orchestrierung
+- [ ] trackResult.entry nutzen statt CsvEntryData manuell zusammensetzen
+- [ ] Toast Feedback an neues Format angepasst
+- [ ] ~80 Zeilen Code-Reduktion
+
+### AC6: SessionManager.ts Wrapper-Umwandlung
+- [ ] Private manager = new OpenCodeSessionManager()
+- [ ] Alle Methods delegieren zu manager
+- [ ] Keine neue Logik, nur Delegation
+- [ ] Import von Lib kompatibel
+
+### AC7: Redundante Dateien Gelöscht
+- [ ] `src/services/TitleGenerator.ts` gelöscht (332 Zeilen)
+- [ ] `src/utils/DescriptionGenerator.ts` gelöscht (119 Zeilen)
+- [ ] `src/services/CsvWriter.ts` gelöscht (284 Zeilen)
+- [ ] `src/services/WebhookSender.ts` gelöscht (137 Zeilen)
+- [ ] `src/services/ProviderAdapter.ts` gelöscht
+- [ ] `src/utils/MessageExtractor.ts` gelöscht
+- [ ] Kompilation erfolgreich nach Löschen
+
+### AC8: Unit Tests
+- [ ] `tests/unit/adapters/SessionDataMapper.test.ts` erstellt
+- [ ] `tests/unit/services/SessionManager.test.ts` ĂŒberarbeitet
+- [ ] Alle Tests grĂŒn
+- [ ] Coverage fĂŒr kritische Pfade
+
+### AC9: Integration Tests
+- [ ] `tests/integration/hooks/EventHook.time-tracking.test.ts` erstellt
+- [ ] Event â Facade â Results Pfad getestet
+- [ ] Alle Tests grĂŒn
+
+### AC10: E2E Test
+- [ ] Echte OpenCode Session durchgefĂŒhrt
+- [ ] CSV geschrieben und Format korrekt
+- [ ] Webhook aufgerufen (falls konfiguriert)
+- [ ] Description korrekt (Activity Summary oder LLM)
+- [ ] Toast Feedback korrekt
+- [ ] Keine Errors im Console
+
+### AC11: Dokumentation & Code Quality
+- [ ] README.md aktualisiert (Lib Integration erwÀhnt)
+- [ ] Code Comments hinzugefĂŒgt (Mapper, Hook, Manager)
+- [ ] Git Commit mit aussagekrÀftiger Message
+- [ ] ~850 Zeilen Code-Reduktion erreicht
+
+---
+
+## Scope
+
+### In Scope
+â
Lib-Integration in OpenCode Plugin
+â
OpenCodeSessionManager als generischer Service
+â
SessionDataMapper fĂŒr OpenCode-spezifische Konvertierung
+â
Lazy-Loading TimeTrackingFacade
+â
Graceful Degradation Error Handling
+â
~870 Zeilen Duplikation eliminieren
+â
Marketplace Plugin als Referenz nutzen
+
+### Out of Scope
+â Marketplace Plugin Ă€ndern (bereits korrekt implementiert)
+â Lib selbst inhaltlich Ă€ndern (nur OpenCodeSessionManager hinzufĂŒgen)
+â Komplette Event-Architektur umschreiben
+â Code-Refactorings auĂerhalb von Time-Tracking
+
+---
+
+## Deliverables
+
+### Phase 1: Vorbereitung (4h)
+- Lib exportiert OpenCodeSessionManager
+- SessionDataMapper erstellt
+- Dependencies aktualisiert
+- Kompilation erfolgreich
+
+### Phase 2: Refactoring (10h)
+- Plugin.ts mit Lazy-Loading
+- EventHook.ts komplett refactored
+- SessionManager zu Wrapper
+- Alle Importe angepasst
+
+### Phase 3: Cleanup (2h)
+- 6 redundante Dateien gelöscht
+- Kompilation ohne Fehler
+
+### Phase 4: Testing (6h)
+- Unit Tests grĂŒn
+- Integration Tests grĂŒn
+- E2E Test erfolgreich
+- CSV Output vergleich erfolgreich
+
+### Phase 5: Dokumentation (2h)
+- README updated
+- Code Comments hinzugefĂŒgt
+- Git Commit erstellt
+
+---
+
+## Design Pattern & Architektur
+
+### 1. Wrapper Pattern fĂŒr SessionManager
+```
+OpenCode Events
+ â (message.updated, message.part.updated, session.status.idle)
+SessionManager (Wrapper im Plugin)
+ â delegates to
+OpenCodeSessionManager (Generisch in Lib)
+ â
+State Management
+```
+
+**Grund:** Zukunftssicherheit - OpenCode-spezifische Features können ohne Lib-Ănderung addiert werden.
+
+### 2. Inline ConversationContextProvider
+```
+EventHook - session.status.idle handler
+ â
+SessionDataMapper.build()
+ ââ Buildet async ConversationContextProvider inline
+ ââ client.session.messages({limit: 10})
+ ââ Falls Error: return null (Graceful Degradation)
+ â
+TimeTrackingFacade.track(sessionData)
+ ââ SessionSummaryGenerator.generateSummary()
+ ââ LLM Description (mit Context)
+ ââ Fallback: Activity-Summary
+```
+
+**Grund:** KISS - 5-10 Zeilen Code, direkt sichtbar im Handler.
+
+### 3. Lazy Loading Facade (Marketplace-Pattern)
+```typescript
+let facadePromise: Promise | null = null
+
+async function getFacade(config): Promise {
+ if (!facadePromise) {
+ facadePromise = Promise.resolve(new TimeTrackingFacade(config))
+ }
+ return facadePromise
+}
+```
+
+**Grund:** Single Initialization, reusable in allen Event-Handlers.
+
+### 4. Error Handling: Graceful Degradation
+- ConversationContext fails â Activity-Summary Fallback â
+- LLM API down â Activity-Summary Fallback â
+- CSV/Webhook Error â isoliert, andere lĂ€uft weiter â
+- CSV wird IMMER geschrieben â
+
+---
+
+## Metriken & Erfolg-Indikatoren
+
+| Metrik | Aktuell | Ziel | Status |
+|--------|---------|------|--------|
+| **Code Duplikation** | ~870 Zeilen | 0 | âł |
+| **Neue Dateien** | 0 | 2 | âł |
+| **Gelöschte Dateien** | 0 | 6 | Ⳡ|
+| **Zeilen gelöscht** | 0 | ~1.050 | Ⳡ|
+| **Zeilen hinzugefĂŒgt** | 0 | ~200 | âł |
+| **Netto Reduktion** | 0 | -850 | âł |
+| **Unit Tests** | ~0 | ~10 | âł |
+| **Integration Tests** | ~0 | ~5 | âł |
+| **Code Quality** | Duplikation | Single Source of Truth | âł |
+| **KomplexitÀt** | Hoch (Manuelle Orchestrierung) | Niedrig (Facade) | Ⳡ|
+
+---
+
+## Risiken & Fallbacks
+
+### Risiko 1: TimeTrackingFacade.track() throws Error
+- **Fallback:** Error caught, graceful degradation
+- **Mitigation:** Marketplace Plugin zeigt dass es funktioniert
+- **Action:** Siehe Error Handling oben
+
+### Risiko 2: ConversationContext Call fails
+- **Fallback:** SessionSummaryGenerator nutzt Activity-Summary
+- **Mitigation:** Inline Error Handling in SessionDataMapper
+- **Action:** null zurĂŒckgeben, Lib hat Fallback
+
+### Risiko 3: Bun FileSystem KompatibilitÀt
+- **Fallback:** CsvWriter aus Lib sollte in Bun laufen
+- **Mitigation:** Marketplace Plugin zeigt es funktioniert
+- **Action:** E2E Test verifiziert CSV-Schreiber
+
+### Risiko 4: Type InkompatibilitÀt
+- **Fallback:** SessionDataInterface/SessionData Mapping
+- **Mitigation:** SessionDataMapper genau auf Types prĂŒfen
+- **Action:** TypeScript strict mode obligatorisch
+
+---
+
+## Referenzen
+
+- đ [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) - Schritt-fĂŒr-Schritt Anleitung
+- đ [lib-ts-time-tracking](../lib-ts-time-tracking) - Source Library
+- đȘ [Marketplace Plugin](../plugin-marketplace/time-tracking) - Reference Implementation
+- đ [README](./README.md) - Plugin Dokumentation
+- đ [COPSPA-55](https://techdivision.atlassian.net/browse/COPSPA-55) - Ăhnliches Refactoring Ticket
+
+---
+
+## Ănderungshistorie
+
+| Datum | Version | Ănderung | Autor |
+|-------|---------|----------|-------|
+| 2026-04-21 | 1.0 | Initial Draft | Patrick Mehringer |
+
+---
+
+## Zeitplan
+
+| Phase | Aufgabe | Dauer | Kumulativ | Status |
+|-------|---------|-------|----------|--------|
+| 1 | Vorbereitung | 4h | 4h | âł |
+| 2 | Refactoring | 10h | 14h | âł |
+| 3 | Cleanup | 2h | 16h | âł |
+| 4 | Testing | 6h | 22h | âł |
+| 5 | Dokumentation | 2h | 24h | âł |
+| Buffer | Unexpected Issues | 2-4h | **~24-26h (3 Tage)** | âł |
+
+**Start:**
+**Target Completion:**
+
+---
+
+## Notizen
+
+- Siehe `IMPLEMENTATION_PLAN.md` fĂŒr schritt-fĂŒr-schritt Anleitung
+- Marketplace Plugin (`plugin-marketplace/time-tracking`) als Referenz nutzen
+- Lib-Codes in `lib-ts-time-tracking/src/` als Best Practice Quelle
+- Graceful Degradation ĂŒberall wo externe APIs involviert sind
+- Alle Error Catches mĂŒssen silent sein (kein throw in EventHooks!)
+- SessionManager Pattern bleibt: Plugin-spezifischer Wrapper ĂŒber generischer Lib-Klasse
+- Single Source of Truth fĂŒr Time-Tracking Logic = Lib's SessionSummaryGenerator
+
diff --git a/README.md b/README.md
index 6b9e7db..0073c7b 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,21 @@
Automatic time tracking plugin for OpenCode. Tracks session duration and tool usage, writing entries to a CSV file compatible with Jira worklog sync incl. commands, skills and agents.
+## Architecture
+
+This plugin now uses [@techdivision/lib-ts-time-tracking](https://github.com/techdivision/lib-ts-time-tracking) for core time tracking functionality:
+
+- **SessionSummaryGenerator:** Generates descriptions via LLM or activity fallback
+- **TimeTrackingFacade:** Orchestrates summary generation, CSV writing, and webhook sending
+- **OpenCodeSessionManager:** Manages session state across multiple OpenCode events
+
+The plugin provides OpenCode-specific adapters:
+- **SessionDataMapper:** Converts OpenCode session data to lib's interface
+- **TicketResolver:** SDK-based JIRA ticket extraction
+- **SessionManager:** Wrapper around lib's OpenCodeSessionManager for OpenCode-specific features
+
+This architecture eliminates ~870 lines of code duplication and ensures a single source of truth for time tracking logic.
+
## Overview
| Content Type | Name | Description |
diff --git a/package-lock.json b/package-lock.json
index f88dad8..9ecf6c3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,14 +1,16 @@
{
"name": "@techdivision/opencode-plugin-time-tracking",
- "version": "0.1.0",
+ "version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@techdivision/opencode-plugin-time-tracking",
- "version": "0.1.0",
+ "version": "1.5.2",
+ "license": "MIT",
"dependencies": {
- "@opencode-ai/plugin": "1.2.15"
+ "@opencode-ai/plugin": "1.2.15",
+ "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
},
"devDependencies": {
"@types/bun": "latest",
@@ -19,6 +21,24 @@
"bun": ">=1.0.0"
}
},
+ "../lib-ts-time-tracking": {
+ "name": "@techdivision/lib-ts-time-tracking",
+ "version": "4.2.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@amiceli/vitest-cucumber": "^5.2.1",
+ "@types/node": "^22.0.0",
+ "eslint": "^10.1.0",
+ "eslint-config-prettier": "^10.1.8",
+ "prettier": "^3.8.1",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.57.2",
+ "vitest": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@opencode-ai/plugin": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz",
@@ -203,6 +223,10 @@
],
"peer": true
},
+ "node_modules/@techdivision/lib-ts-time-tracking": {
+ "resolved": "../lib-ts-time-tracking",
+ "link": true
+ },
"node_modules/@types/bun": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz",
diff --git a/package.json b/package.json
index 351765e..51796ce 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
"author": "TechDivision GmbH",
"license": "MIT",
"dependencies": {
- "@opencode-ai/plugin": "1.2.15"
+ "@opencode-ai/plugin": "1.2.15",
+ "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
},
"devDependencies": {
"@types/bun": "latest",
diff --git a/src/Plugin.ts b/src/Plugin.ts
index e6e401c..557b620 100644
--- a/src/Plugin.ts
+++ b/src/Plugin.ts
@@ -8,18 +8,35 @@
*/
import type { Plugin, Hooks, PluginInput } from "@opencode-ai/plugin"
+import { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking"
import { ConfigLoader } from "./services/ConfigLoader"
-import { CsvWriter } from "./services/CsvWriter"
import { SessionManager } from "./services/SessionManager"
import { TicketExtractor } from "./services/TicketExtractor"
import { TicketResolver } from "./services/TicketResolver"
-import { TitleGenerator } from "./services/TitleGenerator"
-import { WebhookSender } from "./services/WebhookSender"
import { createEventHook } from "./hooks/EventHook"
import { createToolExecuteAfterHook } from "./hooks/ToolExecuteAfterHook"
-import type { WriterService } from "./types/WriterService"
+import type { TimeTrackingConfigInterface } from "@techdivision/lib-ts-time-tracking"
+
+/**
+ * Lazy-loads TimeTrackingFacade instance.
+ * Follows Marketplace plugin pattern for single initialization.
+ *
+ * @remarks
+ * The facade is initialized once and reused across all event handlers.
+ * This ensures consistent state management and efficient resource usage.
+ */
+let facadePromise: Promise | null = null
+
+async function getTimeTrackingFacade(
+ config: TimeTrackingConfigInterface
+): Promise {
+ if (!facadePromise) {
+ facadePromise = Promise.resolve(new TimeTrackingFacade(config))
+ }
+ return facadePromise
+}
/**
* OpenCode Time Tracking Plugin
@@ -64,37 +81,25 @@ export const plugin: Plugin = async ({
}
const sessionManager = new SessionManager()
- const csvWriter = new CsvWriter(config, directory)
- const webhookSender = new WebhookSender()
const ticketExtractor = new TicketExtractor(client, config.valid_projects)
const ticketResolver = new TicketResolver(config, ticketExtractor)
- const configDir = `${directory}/.opencode`
- const titleGenerator = new TitleGenerator(client, config, configDir)
-
- // Check API reachability in background (never blocks plugin startup)
- titleGenerator.checkAvailability().then(() => {
- if (!titleGenerator.isAvailable) {
- client.tui.showToast({
- body: {
- message: `Title generation: ${titleGenerator.unavailableInfo}`,
- variant: "warning",
- },
- }).catch(() => {})
- }
- }).catch(() => {})
-
- // Writers are called in order: CSV first (backup), then webhook
- const writers: WriterService[] = [csvWriter, webhookSender]
- // Ensure CSV file has a valid header at startup
- await csvWriter.ensureHeader()
+ // Writers are now handled by TimeTrackingFacade from lib
+ // No need to instantiate CsvWriter and WebhookSender here
const hooks: Hooks = {
"tool.execute.after": createToolExecuteAfterHook(
sessionManager,
ticketExtractor
),
- event: createEventHook(sessionManager, writers, client, ticketResolver, config, titleGenerator),
+ event: createEventHook(
+ sessionManager,
+ [],
+ client,
+ ticketResolver,
+ config,
+ (timeTrackingConfig) => getTimeTrackingFacade(timeTrackingConfig)
+ ),
}
return hooks
diff --git a/src/adapters/SessionDataMapper.ts b/src/adapters/SessionDataMapper.ts
new file mode 100644
index 0000000..5942ea8
--- /dev/null
+++ b/src/adapters/SessionDataMapper.ts
@@ -0,0 +1,93 @@
+/**
+ * @fileoverview Adapter to convert OpenCode SessionData to lib's SessionDataInterface.
+ *
+ * This mapper bridges the gap between OpenCode plugin's internal SessionData format
+ * and the generic SessionDataInterface expected by lib-ts-time-tracking.
+ * It also builds the ConversationContextProvider callback inline with SDK integration.
+ */
+
+import type { OpencodeClient } from "../types/OpencodeClient.js"
+import type { SessionData } from "../types/SessionData.js"
+import type { SessionDataInterface } from "@techdivision/lib-ts-time-tracking"
+
+/**
+ * Converts OpenCode plugin's SessionData to lib's SessionDataInterface.
+ *
+ * @remarks
+ * This mapper handles:
+ * - Model formatting (provider/modelID)
+ * - Token mapping with proper field names
+ * - ConversationContextProvider callback building
+ * - Graceful degradation if SDK calls fail
+ */
+export class SessionDataMapper {
+ /**
+ * Builds SessionDataInterface from SessionData.
+ * Includes conversation context provider callback for LLM context.
+ *
+ * @param session - The OpenCode session data
+ * @param client - The OpenCode SDK client for fetching conversation context
+ * @param sessionID - The session ID for fetching messages
+ * @param config - Configuration including user email
+ * @returns SessionDataInterface ready for TimeTrackingFacade.track()
+ *
+ * @remarks
+ * The conversationContextProvider is built inline and will gracefully
+ * degrade if the SDK call fails. The lib's SessionSummaryGenerator
+ * will use activity-based fallback in that case.
+ */
+ static build(
+ session: SessionData,
+ client: OpencodeClient,
+ sessionID: string,
+ config: { userEmail?: string }
+ ): SessionDataInterface {
+ // Format model as "provider/modelID"
+ const modelString = session.model
+ ? `${session.model.providerID}/${session.model.modelID}`
+ : "unknown"
+
+ // Build conversation context provider inline
+ const conversationContextProvider = async (): Promise => {
+ try {
+ const result = await client.session.messages({
+ path: { id: sessionID },
+ } as Parameters[0])
+
+ if (!result?.data || result.data.length === 0) {
+ return null
+ }
+
+ // Format messages as context string
+ return result.data
+ .map((m: any) => {
+ const role = m.info?.role || "unknown"
+ const content = m.content || ""
+ return `${role}: ${content}`
+ })
+ .join("\n")
+ } catch {
+ // Graceful degradation: if SDK call fails, return null
+ // Lib will use activity-based fallback
+ return null
+ }
+ }
+
+ return {
+ agent: session.agent?.name ?? "unknown",
+ model: modelString,
+ startTime: session.startTime,
+ endTime: Date.now(),
+ userEmail: config.userEmail,
+ tokens: {
+ input: session.tokenUsage.input,
+ output: session.tokenUsage.output,
+ cacheRead: session.tokenUsage.cacheRead,
+ cacheWrite: session.tokenUsage.cacheWrite,
+ },
+ activities: session.activities,
+ conversationContext: conversationContextProvider,
+ ticket: session.ticket ?? undefined,
+ }
+ }
+}
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index ed64d06..ba18da8 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -2,13 +2,11 @@
* @fileoverview Event hook for session lifecycle and token tracking.
*/
-import { randomUUID } from "crypto"
-
import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk"
+import type { TimeTrackingFacade, TimeTrackingConfigInterface } from "@techdivision/lib-ts-time-tracking"
import type { SessionManager } from "../services/SessionManager"
import type { TicketResolver } from "../services/TicketResolver"
-import type { TitleGenerator } from "../services/TitleGenerator"
import type { CsvEntryData } from "../types/CsvEntryData"
import type { MessagePartUpdatedProperties } from "../types/MessagePartUpdatedProperties"
import type { MessageWithParts } from "../types/MessageWithParts"
@@ -17,7 +15,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
import type { WriteResult, WriterService } from "../types/WriterService"
import { AgentMatcher } from "../utils/AgentMatcher"
-import { DescriptionGenerator } from "../utils/DescriptionGenerator"
+import { SessionDataMapper } from "../adapters/SessionDataMapper"
/**
* Properties for message.updated events.
@@ -26,45 +24,6 @@ interface MessageUpdatedProperties {
info: Message
}
-/**
- * Extracts the summary title from the last user message.
- *
- * @param client - The OpenCode SDK client
- * @param sessionID - The session identifier
- * @returns The summary title, or `null` if not found
- *
- * @internal
- */
-async function extractSummaryTitle(
- client: OpencodeClient,
- sessionID: string
-): Promise {
- try {
- const result = await client.session.messages({
- path: { id: sessionID },
- } as Parameters[0])
-
- if (!result.data) {
- return null
- }
-
- const messages = result.data as MessageWithParts[]
-
- // Find the last user message with a summary title
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i]
-
- if (message.info.role === "user" && message.info.summary?.title) {
- return message.info.summary.title
- }
- }
-
- return null
- } catch {
- return null
- }
-}
-
/**
* Creates the event hook for session lifecycle management.
*
@@ -73,7 +32,7 @@ async function extractSummaryTitle(
* @param client - The OpenCode SDK client
* @param ticketResolver - The ticket resolver instance
* @param config - The time tracking configuration
- * @param titleGenerator - The LLM-based title generator instance
+ * @param getTimeTrackingFacade - Function to get the TimeTrackingFacade instance (lazy-loaded)
* @returns The event hook function
*
* @remarks
@@ -81,16 +40,16 @@ async function extractSummaryTitle(
*
* 1. **message.updated** - Tracks model from assistant messages
* 2. **message.part.updated** - Tracks token usage from step-finish parts
- * 3. **session.status** (idle) - Finalizes and exports the session via all writers
+ * 3. **session.status** (idle) - Finalizes and exports the session via TimeTrackingFacade
*
- * Writers are called in order. Each writer handles its own errors internally,
- * so a failure in one writer does not affect others.
+ * The TimeTrackingFacade handles summary generation, CSV writing, and webhook sending.
+ * This replaces the previous manual orchestration of TitleGenerator and DescriptionGenerator.
*
* @example
* ```typescript
* const writers: WriterService[] = [csvWriter, webhookSender]
* const hooks: Hooks = {
- * event: createEventHook(sessionManager, writers, client, ticketResolver, config, titleGenerator),
+ * event: createEventHook(sessionManager, writers, client, ticketResolver, config, getFacade),
* }
* ```
*/
@@ -100,7 +59,7 @@ export function createEventHook(
client: OpencodeClient,
ticketResolver: TicketResolver,
config: TimeTrackingConfig,
- titleGenerator: TitleGenerator
+ getTimeTrackingFacade: (cfg: TimeTrackingConfigInterface) => Promise
) {
return async ({ event }: { event: Event }): Promise => {
// Track model and agent from assistant messages
@@ -194,45 +153,6 @@ export function createEventHook(
return
}
- const endTime = Date.now()
- const durationSeconds = Math.round((endTime - session.startTime) / 1000)
-
- // Generate description: LLM title + activity summary
- const activitySummary = DescriptionGenerator.generate(session.activities)
-
- // Try to get a meaningful title via LLM or OpenCode summary
- let title = await extractSummaryTitle(client, sessionID)
-
- if (!title) {
- try {
- title = await Promise.race([
- titleGenerator.generate(sessionID),
- new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
- ])
- } catch {
- title = null
- }
- }
-
- // Combine: "LLM title | activity summary" or just "activity summary"
- const description = title
- ? `${title} | ${activitySummary}`
- : activitySummary
-
- const toolSummary = DescriptionGenerator.generateToolSummary(
- session.activities
- )
-
- const totalTokens =
- session.tokenUsage.input +
- session.tokenUsage.output +
- session.tokenUsage.reasoning
-
- // Format model as providerID/modelID
- const modelString = session.model
- ? `${session.model.providerID}/${session.model.modelID}`
- : null
-
// Get agent name if available
const agentString = session.agent?.name ?? null
@@ -257,40 +177,85 @@ export function createEventHook(
// Resolve ticket and account key with fallback hierarchy
const resolved = await ticketResolver.resolve(sessionID, agentString)
- // Build entry data once, shared across all writers
- const entryData: CsvEntryData = {
- id: randomUUID(),
+ // Use TimeTrackingFacade from lib for summary generation and writing
+ // This replaces separate TitleGenerator and DescriptionGenerator calls
+ const sessionData = SessionDataMapper.build(session, client, sessionID, {
userEmail: config.user_email,
- ticket: resolved.ticket,
+ })
+
+ // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface
+ const libConfig: TimeTrackingConfigInterface = {
+ defaults: config.global_default,
+ agents: config.agent_defaults,
+ csv: { output_path: config.csv_file },
+ pricing: {
+ default: {
+ input: 0.003,
+ output: 0.015,
+ cache_read: 0.00075,
+ cache_write: 0.00375,
+ },
+ periods: [
+ {
+ from: "2024-01-01",
+ models: {
+ "claude-opus": {
+ input: 0.015,
+ output: 0.075,
+ cache_read: 0.00375,
+ cache_write: 0.01875,
+ },
+ "claude-sonnet": {
+ input: 0.003,
+ output: 0.015,
+ cache_read: 0.00075,
+ cache_write: 0.00375,
+ },
+ "claude-haiku": {
+ input: 0.00080,
+ output: 0.004,
+ cache_read: 0.0002,
+ cache_write: 0.001,
+ },
+ },
+ },
+ ],
+ },
+ valid_projects: config.valid_projects || [],
+ }
+
+ const facade = await getTimeTrackingFacade(libConfig)
+ const trackResult = await facade.track(sessionData)
+ const description = trackResult.summary.description
+
+ // Build entry data from trackResult.entry (CSV entry comes directly from Lib!)
+ const entryData: CsvEntryData = {
+ ...trackResult.entry,
+ ticket: resolved.ticket, // OpenCode Resolving override
accountKey: resolved.accountKey,
- authorEmail: resolved.authorEmail,
- startTime: session.startTime,
- endTime,
- durationSeconds,
- description,
- notes: `Auto-tracked: ${toolSummary}`,
- tokenUsage: session.tokenUsage,
- cost: session.cost,
- model: modelString,
+ authorEmail: resolved.authorEmail, // OpenCode Resolving override
agent: (resolved.primaryAgent ?? agentString)?.replace(/^@/, "") ?? null,
}
- // Call all writers in order (CSV first, then webhook, etc.)
- // Collect results for combined status reporting
- const results: WriteResult[] = []
- for (const writer of writers) {
- const result = await writer.write(entryData)
- results.push(result)
- }
+ // Writers are called by Facade, but we have access to results
+ const results: WriteResult[] = [
+ trackResult.csv,
+ trackResult.webhook,
+ ].filter((r) => r !== undefined && r !== null) as WriteResult[]
// Build combined toast message with writer status
+ const durationSeconds = Math.round((Date.now() - session.startTime) / 1000)
const minutes = Math.round(durationSeconds / 60)
+ const totalTokens =
+ session.tokenUsage.input +
+ session.tokenUsage.output +
+ session.tokenUsage.reasoning
const failedWriters = results.filter((r) => !r.success)
let message = `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}`
- if (!titleGenerator.isAvailable) {
- message += " (title generation NOT available)"
+ if (trackResult.summary.llmError) {
+ message += ` (LLM: ${trackResult.summary.llmError})`
}
if (failedWriters.length > 0) {
diff --git a/src/services/CsvWriter.ts b/src/services/CsvWriter.ts
deleted file mode 100644
index 5defc53..0000000
--- a/src/services/CsvWriter.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-/**
- * @fileoverview CSV writer for exporting time tracking data.
- */
-
-import { mkdir } from "fs/promises"
-import { dirname } from "path"
-
-import type { CsvEntryData } from "../types/CsvEntryData"
-import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
-import type { WriteResult, WriterService } from "../types/WriterService"
-
-import { CsvFormatter } from "../utils/CsvFormatter"
-
-import "../types/Bun"
-
-/**
- * CSV header row for the worklog export file.
- * Compatible with Jira/Tempo time tracking import.
- *
- * @remarks
- * Columns 1-17: Original format (v0.5.0 - v0.7.x)
- * Columns 18-23: Extended format (v0.8.0+) with token details and cost
- */
-const CSV_HEADER =
- "id,start_date,end_date,user,ticket_name,issue_key,account_key,start_time,end_time,duration_seconds,tokens_used,tokens_remaining,story_points,description,notes,model,agent,tokens_input,tokens_output,tokens_reasoning,tokens_cache_read,tokens_cache_write,cost,author_email"
-
-/** Number of columns in the current CSV format */
-const CSV_COLUMN_COUNT = 24
-
-/**
- * Checks if a line is a CSV header row.
- *
- * @param line - The line to check
- * @returns `true` if the line appears to be a header
- */
-function isHeaderLine(line: string): boolean {
- return line.startsWith('"id"') || line.startsWith("id,")
-}
-
-/**
- * Pads a CSV line with empty fields to match the expected column count.
- *
- * @param line - The CSV line to pad
- * @param currentColumns - The current number of columns in the line
- * @param targetColumns - The target number of columns
- * @returns The padded line, or the original line if no padding needed
- */
-function padCsvLine(
- line: string,
- currentColumns: number,
- targetColumns: number
-): string {
- if (currentColumns >= targetColumns) {
- return line
- }
-
- const missingColumns = targetColumns - currentColumns
- const padding = ',""\n'.repeat(missingColumns).slice(0, -1) // Remove trailing newline, keep commas
- const emptyFields = Array(missingColumns).fill('""').join(",")
-
- // Append empty fields to the line
- return line + "," + emptyFields
-}
-
-/**
- * Writes time tracking entries to a CSV file.
- *
- * @remarks
- * The CSV format is compatible with Jira/Tempo worklog imports.
- * The file path can be absolute, relative to the project, or use `~/` for home directory.
- */
-export class CsvWriter implements WriterService {
- /** Plugin configuration */
- private config: TimeTrackingConfig
-
- /** Project directory path */
- private directory: string
-
- /**
- * Creates a new CSV writer instance.
- *
- * @param config - The plugin configuration
- * @param directory - The project directory path
- */
- constructor(config: TimeTrackingConfig, directory: string) {
- this.config = config
- this.directory = directory
- }
-
- /**
- * Resolves the CSV file path from configuration.
- *
- * @returns The absolute path to the CSV file
- *
- * @remarks
- * Handles three path formats:
- * - `~/path` - Expands to home directory
- * - `/absolute/path` - Used as-is
- * - `relative/path` - Relative to project directory
- */
- private resolvePath(): string {
- let csvPath = this.config.csv_file
-
- if (csvPath.startsWith("~/")) {
- csvPath = csvPath.replace("~", process.env.HOME || "")
- } else if (!csvPath.startsWith("/")) {
- csvPath = `${this.directory}/${csvPath}`
- }
-
- return csvPath
- }
-
- /**
- * Ensures the CSV file exists and has a valid, up-to-date header.
- *
- * @remarks
- * Call this once at plugin startup. Handles these cases:
- * - File doesn't exist: creates it with header
- * - File is empty: writes header
- * - File has data but no header: prepends header and pads rows if needed
- * - File has outdated header (fewer columns): replaces header and pads all rows
- * - File already has current header: no action needed
- *
- * @returns `true` if file was modified, `false` if already valid
- */
- async ensureHeader(): Promise {
- const csvPath = this.resolvePath()
-
- try {
- await mkdir(dirname(csvPath), { recursive: true })
- } catch {
- // Directory may already exist
- }
-
- const file = Bun.file(csvPath)
- const exists = await file.exists()
-
- if (!exists) {
- // Create new file with header
- await Bun.write(csvPath, CSV_HEADER + "\n")
- return true
- }
-
- const content = await file.text()
- const trimmedContent = content.trim()
-
- if (trimmedContent.length === 0) {
- // Empty file: write header
- await Bun.write(csvPath, CSV_HEADER + "\n")
- return true
- }
-
- const lines = trimmedContent.split("\n")
- const firstLine = lines[0]
- const hasHeader = isHeaderLine(firstLine)
-
- // Determine column count from first data line
- const dataLineIndex = hasHeader ? 1 : 0
- if (dataLineIndex >= lines.length) {
- // Only header, no data - ensure header is current
- if (hasHeader && CsvFormatter.countColumns(firstLine) < CSV_COLUMN_COUNT) {
- await Bun.write(csvPath, CSV_HEADER + "\n")
- return true
- }
- return false
- }
-
- const firstDataLine = lines[dataLineIndex]
- const columnCount = CsvFormatter.countColumns(firstDataLine)
-
- // Check if migration is needed
- if (columnCount >= CSV_COLUMN_COUNT) {
- // Already has enough columns
- if (!hasHeader) {
- // Just prepend header
- await Bun.write(csvPath, CSV_HEADER + "\n" + trimmedContent + "\n")
- return true
- }
- return false
- }
-
- // Migration needed: pad all data rows to CSV_COLUMN_COUNT
- const dataLines = hasHeader ? lines.slice(1) : lines
- const paddedLines = dataLines.map((line) => {
- const lineColumnCount = CsvFormatter.countColumns(line)
- return padCsvLine(line, lineColumnCount, CSV_COLUMN_COUNT)
- })
-
- // Write new header + padded data
- await Bun.write(csvPath, CSV_HEADER + "\n" + paddedLines.join("\n") + "\n")
- return true
- }
-
- /**
- * Writes a time tracking entry to the CSV file.
- *
- * @param data - The entry data to write
- * @returns Result indicating success or failure
- *
- * @remarks
- * Assumes `ensureHeader()` was called at startup.
- * Simply appends the new entry to the file.
- *
- * @example
- * ```typescript
- * const result = await csvWriter.write({
- * id: crypto.randomUUID(),
- * userEmail: "user@example.com",
- * ticket: "PROJ-123",
- * startTime: Date.now() - 3600000,
- * endTime: Date.now(),
- * durationSeconds: 3600,
- * description: "Implemented feature X",
- * notes: "Auto-tracked: read(5x), edit(3x)",
- * tokenUsage: { input: 1000, output: 500, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
- * cost: 0.0234,
- * model: "anthropic/claude-opus-4",
- * agent: "@developer",
- * accountKey: "ACCOUNT-1"
- * })
- *
- * if (!result.success) {
- * console.error(`CSV write failed: ${result.error}`)
- * }
- * ```
- */
- async write(data: CsvEntryData): Promise {
- try {
- const csvPath = this.resolvePath()
- const file = Bun.file(csvPath)
- const exists = await file.exists()
-
- const totalTokens =
- data.tokenUsage.input + data.tokenUsage.output + data.tokenUsage.reasoning
-
- const fields = [
- data.id,
- CsvFormatter.formatDate(data.startTime),
- CsvFormatter.formatDate(data.endTime),
- data.userEmail,
- "",
- data.ticket ?? "",
- data.accountKey,
- CsvFormatter.formatTime(data.startTime),
- CsvFormatter.formatTime(data.endTime),
- data.durationSeconds.toString(),
- totalTokens.toString(),
- "",
- "",
- CsvFormatter.escape(data.description),
- CsvFormatter.escape(data.notes),
- data.model ?? "",
- data.agent ?? "",
- // Extended columns (v0.8.0+)
- data.tokenUsage.input.toString(),
- data.tokenUsage.output.toString(),
- data.tokenUsage.reasoning.toString(),
- data.tokenUsage.cacheRead.toString(),
- data.tokenUsage.cacheWrite.toString(),
- data.cost.toFixed(6),
- // Extended columns (v1.5.0+)
- data.authorEmail,
- ]
-
- const csvLine = fields.map((f) => `"${f}"`).join(",")
-
- if (!exists) {
- // Fallback: create file with header if ensureHeader() wasn't called
- await Bun.write(csvPath, CSV_HEADER + "\n" + csvLine + "\n")
- } else {
- const content = await file.text()
- await Bun.write(csvPath, content + csvLine + "\n")
- }
-
- return { writer: "csv", success: true }
- } catch (error) {
- return {
- writer: "csv",
- success: false,
- error: error instanceof Error ? error.message : String(error),
- }
- }
- }
-}
diff --git a/src/services/ProviderAdapter.ts b/src/services/ProviderAdapter.ts
deleted file mode 100644
index b7d1182..0000000
--- a/src/services/ProviderAdapter.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @fileoverview Chat Completions API adapter for LLM title generation.
- *
- * @remarks
- * Builds HTTP requests and parses responses for the Chat Completions API format
- * used by Ollama and other LLM providers.
- */
-
-/**
- * Structured request data for an LLM API call.
- */
-interface LlmRequest {
- /** Full endpoint URL */
- url: string
-
- /** HTTP headers including auth and content type */
- headers: Record
-
- /** JSON-serialized request body */
- body: string
-}
-
-/**
- * Builds and parses Chat Completions API requests for title generation.
- *
- * @remarks
- * Pure static methods â no state, no side effects.
- */
-export class ProviderAdapter {
- /**
- * Builds a Chat Completions API request.
- *
- * @param apiUrl - Base API URL (from `title_generation.api_url` config)
- * @param apiModelId - The model identifier sent to the provider
- * @param apiKey - API key (can be `undefined` for providers that require no auth)
- * @param systemPrompt - The system/title generation prompt
- * @param userPrompt - The user's original message text
- * @returns Structured request with url, headers, and body
- */
- static buildRequest(
- apiUrl: string,
- apiModelId: string,
- apiKey: string | undefined,
- systemPrompt: string,
- userPrompt: string
- ): LlmRequest {
- const headers: Record = {
- "content-type": "application/json",
- }
-
- if (apiKey) {
- headers["authorization"] = `Bearer ${apiKey}`
- }
-
- return {
- url: `${apiUrl}/chat/completions`,
- headers,
- body: JSON.stringify({
- model: apiModelId,
- max_tokens: 100,
- temperature: 0.3,
- messages: [
- { role: "system", content: systemPrompt },
- { role: "user", content: userPrompt },
- ],
- }),
- }
- }
-
- /**
- * Extracts the generated text from a Chat Completions response.
- *
- * @param responseJson - The parsed JSON response body
- * @returns The extracted text content, or empty string if not found
- */
- static extractText(responseJson: unknown): string {
- const json = responseJson as Record
- const choices = json.choices as
- | Array<{ message?: { content?: string } }>
- | undefined
- return choices?.[0]?.message?.content ?? ""
- }
-}
diff --git a/src/services/SessionManager.ts b/src/services/SessionManager.ts
index d4d21fb..2427455 100644
--- a/src/services/SessionManager.ts
+++ b/src/services/SessionManager.ts
@@ -1,7 +1,12 @@
/**
* @fileoverview Session state management for time tracking.
+ *
+ * This is a wrapper around the generic OpenCodeSessionManager from lib-ts-time-tracking.
+ * It provides OpenCode-specific facade to the generic library implementation.
*/
+import { OpenCodeSessionManager } from "@techdivision/lib-ts-time-tracking"
+
import type { ActivityData } from "../types/ActivityData"
import type { AgentInfo } from "../types/AgentInfo"
import type { ModelInfo } from "../types/ModelInfo"
@@ -9,7 +14,10 @@ import type { SessionData } from "../types/SessionData"
import type { TokenUsage } from "../types/TokenUsage"
/**
- * Manages active session state for time tracking.
+ * Wrapper around OpenCodeSessionManager from lib.
+ *
+ * Provides OpenCode-specific facade to the generic library implementation.
+ * All state management is delegated to the library to ensure single source of truth.
*
* @remarks
* Each OpenCode session is tracked separately with its own:
@@ -21,8 +29,8 @@ import type { TokenUsage } from "../types/TokenUsage"
* Sessions are stored in memory and cleaned up when completed.
*/
export class SessionManager {
- /** Map of session ID to session data */
- private sessions = new Map()
+ /** Delegate to lib's generic session manager */
+ private manager = new OpenCodeSessionManager()
/**
* Retrieves session data by ID.
@@ -31,7 +39,7 @@ export class SessionManager {
* @returns The session data, or `undefined` if not found
*/
get(sessionID: string): SessionData | undefined {
- return this.sessions.get(sessionID)
+ return this.manager.get(sessionID) as SessionData | undefined
}
/**
@@ -41,7 +49,7 @@ export class SessionManager {
* @returns `true` if the session exists, `false` otherwise
*/
has(sessionID: string): boolean {
- return this.sessions.has(sessionID)
+ return this.manager.has(sessionID)
}
/**
@@ -52,25 +60,7 @@ export class SessionManager {
* @returns The newly created session data
*/
create(sessionID: string, ticket: string | null): SessionData {
- const session: SessionData = {
- ticket,
- startTime: Date.now(),
- activities: [],
- tokenUsage: {
- input: 0,
- output: 0,
- reasoning: 0,
- cacheRead: 0,
- cacheWrite: 0,
- },
- cost: 0,
- model: null,
- agent: null,
- }
-
- this.sessions.set(sessionID, session)
-
- return session
+ return this.manager.create(sessionID, ticket) as SessionData
}
/**
@@ -79,7 +69,7 @@ export class SessionManager {
* @param sessionID - The OpenCode session identifier
*/
delete(sessionID: string): void {
- this.sessions.delete(sessionID)
+ this.manager.delete(sessionID)
}
/**
@@ -94,13 +84,7 @@ export class SessionManager {
* after retrieval to ensure it can only be processed once.
*/
getAndDelete(sessionID: string): SessionData | undefined {
- const session = this.sessions.get(sessionID)
-
- if (session) {
- this.sessions.delete(sessionID)
- }
-
- return session
+ return this.manager.getAndDelete(sessionID) as SessionData | undefined
}
/**
@@ -110,11 +94,7 @@ export class SessionManager {
* @param activity - The activity data to add
*/
addActivity(sessionID: string, activity: ActivityData): void {
- const session = this.sessions.get(sessionID)
-
- if (session) {
- session.activities.push(activity)
- }
+ this.manager.addActivity(sessionID, activity)
}
/**
@@ -124,15 +104,13 @@ export class SessionManager {
* @param tokens - The token usage to add
*/
addTokenUsage(sessionID: string, tokens: TokenUsage): void {
- const session = this.sessions.get(sessionID)
-
- if (session) {
- session.tokenUsage.input += tokens.input
- session.tokenUsage.output += tokens.output
- session.tokenUsage.reasoning += tokens.reasoning
- session.tokenUsage.cacheRead += tokens.cacheRead
- session.tokenUsage.cacheWrite += tokens.cacheWrite
- }
+ this.manager.addTokenUsage(sessionID, {
+ input: tokens.input,
+ output: tokens.output,
+ reasoning: tokens.reasoning,
+ cacheRead: tokens.cacheRead,
+ cacheWrite: tokens.cacheWrite,
+ })
}
/**
@@ -142,11 +120,7 @@ export class SessionManager {
* @param cost - The cost in USD to add
*/
addCost(sessionID: string, cost: number): void {
- const session = this.sessions.get(sessionID)
-
- if (session) {
- session.cost += cost
- }
+ this.manager.addCost(sessionID, cost)
}
/**
@@ -160,11 +134,7 @@ export class SessionManager {
* This allows the ticket to be updated when found in later messages.
*/
updateTicket(sessionID: string, ticket: string | null): void {
- const session = this.sessions.get(sessionID)
-
- if (session && ticket) {
- session.ticket = ticket
- }
+ this.manager.updateTicket(sessionID, ticket)
}
/**
@@ -178,11 +148,10 @@ export class SessionManager {
* The first model detected in a session is used.
*/
setModel(sessionID: string, model: ModelInfo): void {
- const session = this.sessions.get(sessionID)
-
- if (session && !session.model) {
- session.model = model
- }
+ this.manager.setModel(sessionID, {
+ providerID: model.providerID,
+ modelID: model.modelID,
+ })
}
/**
@@ -196,14 +165,6 @@ export class SessionManager {
* The first agent detected in a session is used (primary agent).
*/
setAgent(sessionID: string, agentName: string): void {
- const session = this.sessions.get(sessionID)
-
- if (session && !session.agent) {
- const agent: AgentInfo = {
- name: agentName,
- timestamp: Date.now(),
- }
- session.agent = agent
- }
+ this.manager.setAgent(sessionID, agentName)
}
}
diff --git a/src/services/TitleGenerator.ts b/src/services/TitleGenerator.ts
deleted file mode 100644
index 6060b81..0000000
--- a/src/services/TitleGenerator.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-/**
- * @fileoverview LLM-based title generation for time tracking worklog descriptions.
- *
- * @remarks
- * Orchestrates the title generation pipeline:
- * 1. Resolve provider from config at startup (synchronous, no SDK calls)
- * 2. Health-check the API URL at startup (single fetch with 3s timeout)
- * 3. On each session: extract conversation context, build request, call LLM
- * 4. Parse response and trim to max length
- *
- * All errors are caught and result in `null` â never throws.
- */
-
-import type { MessageWithParts } from "../types/MessageWithParts"
-import type { OpencodeClient } from "../types/OpencodeClient"
-import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
-
-import { MessageExtractor } from "../utils/MessageExtractor"
-import { ProviderAdapter } from "./ProviderAdapter"
-
-import "../types/Bun"
-
-/** Default timeout for LLM requests in milliseconds. */
-const DEFAULT_TIMEOUT_MS = 5000
-
-/** Default maximum character length for generated titles. */
-const DEFAULT_MAX_CHARS = 240
-
-/** Timeout for the startup health-check in milliseconds. */
-const HEALTH_CHECK_TIMEOUT_MS = 3000
-
-/**
- * Default system prompt for title generation.
- */
-/** Default locale for worklog description output. */
-const DEFAULT_LOCALE = "de-DE"
-
-const DEFAULT_PROMPT_TEMPLATE = `You receive a conversation between a developer and an AI coding assistant. Write a short worklog description of what was worked on. Output ONLY the description. Nothing else.
-
-Rules:
-- ALWAYS write in {{LOCALE}} language
-- Write a natural, human-readable description as you would in a timesheet
-- Describe what was actually done, not just filenames or technical keywords
-- Maximum {{MAX_CHARS}} characters, single line
-- No quotes, no prefixes like "Description:", no formatting
-- If a ticket number appears, start with it
-- Good example: "COPSPA-65: Fix Plugin-Startup-Hang durch Entfernen der SDK-Calls und Umstellung auf Config-basierte Provider-Aufloesung"
-- Bad example: "package.json update"
-- Bad example: "Session interaction"`
-
-/** Pattern for `{env:VAR_NAME}` references in config values. */
-const ENV_PATTERN = /^\{env:([^}]+)\}$/
-
-/**
- * Resolved provider information for making LLM requests.
- */
-interface ResolvedProvider {
- apiUrl: string
- apiModelId: string
- apiKey: string | undefined
-}
-
-/**
- * Generates worklog titles via direct LLM API calls.
- *
- * @remarks
- * Provider is resolved synchronously from config at startup.
- * The health-check verifies the API is reachable. If not, all subsequent
- * `generate()` calls return `null` immediately without network access.
- *
- * No visible footprint in OpenCode â pure `fetch()` calls to the provider API.
- * No SDK calls to the OpenCode server â everything is config-based.
- */
-export class TitleGenerator {
- private readonly client: OpencodeClient
- private readonly config: TimeTrackingConfig
- private readonly configDir: string
- private readonly cachedProvider: ResolvedProvider | null
- private available = true
- private unavailableReason = ""
-
- constructor(
- client: OpencodeClient,
- config: TimeTrackingConfig,
- configDir: string
- ) {
- this.client = client
- this.config = config
- this.configDir = configDir
-
- // Resolve provider synchronously from config (no network, no SDK calls).
- // This ensures cachedProvider is available immediately, even before
- // the async health-check completes.
- if (this.config.title_generation?.enabled === false) {
- this.cachedProvider = null
- this.available = false
- this.unavailableReason = "disabled by config"
- } else {
- this.cachedProvider = this.resolveFromConfig()
- if (!this.cachedProvider) {
- this.available = false
- this.unavailableReason = "api_url and model not configured"
- }
- }
- }
-
- /**
- * Whether title generation is available.
- *
- * @remarks
- * Set during construction and updated by {@link checkAvailability}.
- * When `false`, {@link generate} returns `null` immediately.
- */
- get isAvailable(): boolean {
- return this.available
- }
-
- /**
- * Human-readable reason why title generation is unavailable.
- *
- * @remarks
- * Empty string when available. Used for toast messages.
- *
- * @example `"server not reachable (http://ai.tdservice.net:11434/v1)"`
- */
- get unavailableInfo(): string {
- return this.unavailableReason
- }
-
- /**
- * Checks API reachability via a lightweight health-check.
- *
- * @remarks
- * Provider is already resolved synchronously in the constructor.
- * This method only performs the async health-check `fetch()` with a 3s timeout.
- * Called as fire-and-forget from Plugin.ts â never blocks startup.
- */
- async checkAvailability(): Promise {
- if (!this.cachedProvider) {
- return
- }
-
- try {
- const baseUrl = this.cachedProvider.apiUrl
- const controller = new AbortController()
- const timeout = setTimeout(
- () => controller.abort(),
- HEALTH_CHECK_TIMEOUT_MS
- )
-
- try {
- await fetch(baseUrl, { signal: controller.signal })
- // Any response (even 404) means the server is reachable
- } catch {
- this.available = false
- this.unavailableReason = `server not reachable (${baseUrl})`
- } finally {
- clearTimeout(timeout)
- }
- } catch {
- this.available = false
- this.unavailableReason = "startup check failed"
- }
- }
-
- /**
- * Generates a worklog description for the given session.
- *
- * @returns The generated description, or `null` if unavailable/failed.
- * Never throws.
- */
- async generate(sessionID: string): Promise {
- try {
- if (!this.cachedProvider) {
- return null
- }
-
- const messages = await this.fetchMessages(sessionID)
- if (!messages) return null
-
- const context = MessageExtractor.extractConversationContext(messages)
- if (!context) return null
-
- const timeoutMs =
- this.config.title_generation?.timeout_ms ?? DEFAULT_TIMEOUT_MS
- const maxChars =
- this.config.title_generation?.max_chars ?? DEFAULT_MAX_CHARS
-
- const systemPrompt = await this.loadPrompt(maxChars)
-
- const request = ProviderAdapter.buildRequest(
- this.cachedProvider.apiUrl,
- this.cachedProvider.apiModelId,
- this.cachedProvider.apiKey,
- systemPrompt,
- context
- )
-
- const controller = new AbortController()
- const timeout = setTimeout(() => controller.abort(), timeoutMs)
-
- try {
- const response = await fetch(request.url, {
- method: "POST",
- headers: request.headers,
- body: request.body,
- signal: controller.signal,
- })
-
- if (!response.ok) return null
-
- const responseJson: unknown = await response.json()
- const text = ProviderAdapter.extractText(responseJson)
-
- return this.cleanTitle(text, maxChars)
- } finally {
- clearTimeout(timeout)
- }
- } catch {
- return null
- }
- }
-
- private async fetchMessages(
- sessionID: string
- ): Promise {
- try {
- const result = await this.client.session.messages({
- path: { id: sessionID },
- } as Parameters[0])
-
- return (result.data as MessageWithParts[]) ?? null
- } catch {
- return null
- }
- }
-
- private async loadPrompt(maxChars: number): Promise {
- let template: string
-
- const promptPath = this.config.title_generation?.prompt
- if (promptPath) {
- try {
- const resolvedPath = promptPath.startsWith("/")
- ? promptPath
- : `${this.configDir}/${promptPath}`
-
- const file = Bun.file(resolvedPath)
- template = (await file.exists())
- ? await file.text()
- : DEFAULT_PROMPT_TEMPLATE
- } catch {
- template = DEFAULT_PROMPT_TEMPLATE
- }
- } else {
- template = DEFAULT_PROMPT_TEMPLATE
- }
-
- const locale = this.config.title_generation?.locale ?? DEFAULT_LOCALE
-
- return template
- .replace(/\{\{MAX_CHARS\}\}/g, String(maxChars))
- .replace(/\{\{LOCALE\}\}/g, locale)
- }
-
- /**
- * Resolves provider, model, API URL, and API key from config.
- *
- * @remarks
- * Purely synchronous â reads only from `this.config`. No network, no SDK calls.
- *
- * Both `config.title_generation.model` and `config.title_generation.api_url`
- * are required. Returns `null` if either is missing.
- */
- private resolveFromConfig(): ResolvedProvider | null {
- const titleConfig = this.config.title_generation
-
- const apiUrl = titleConfig?.api_url
- if (!apiUrl) return null
-
- const model = titleConfig?.model
- if (!model) return null
-
- // Support both "provider/model" and plain "model" formats.
- // For plain format (e.g., "llama3:8b"), the entire string is the model ID.
- const slashIndex = model.indexOf("/")
- const modelID = slashIndex === -1 ? model : model.slice(slashIndex + 1)
- const apiKey = this.resolveApiKey()
-
- return { apiUrl, apiModelId: modelID, apiKey }
- }
-
- /**
- * Resolves API key from config or environment variable.
- *
- * @remarks
- * Supports `{env:VAR_NAME}` syntax for environment variable references.
- * Returns `undefined` if not configured (OK for Ollama).
- */
- private resolveApiKey(): string | undefined {
- const configKey = this.config.title_generation?.api_key
- if (!configKey) return undefined
-
- const envMatch = ENV_PATTERN.exec(configKey)
- if (envMatch) return process.env[envMatch[1]] ?? undefined
-
- return configKey
- }
-
- private cleanTitle(text: string, maxChars: number): string | null {
- let cleaned = text
- // Remove common LLM prefixes that ignore instructions
- .replace(/^(Ticket:\s*N\/A\s*)?Description:\s*/i, "")
- .replace(/^(Title|Summary|Worklog|Description):\s*/i, "")
- // Remove wrapping quotes (e.g., "text" or 'text')
- .replace(/^["']+/, "")
- .replace(/["']+$/, "")
- // Remove markdown formatting
- .replace(/^[*#`]+|[*#`]+$/g, "")
- .replace(/\*\*/g, "")
- .replace(/\n/g, " ")
- .trim()
-
- if (cleaned.length === 0) return null
-
- if (cleaned.length > maxChars) {
- cleaned = cleaned.slice(0, maxChars - 3) + "..."
- }
-
- return cleaned
- }
-}
diff --git a/src/services/WebhookSender.ts b/src/services/WebhookSender.ts
deleted file mode 100644
index e48640d..0000000
--- a/src/services/WebhookSender.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @fileoverview Webhook sender for time tracking entries.
- */
-
-import type { CsvEntryData } from "../types/CsvEntryData"
-import type { WriteResult, WriterService } from "../types/WriterService"
-
-import { CsvFormatter } from "../utils/CsvFormatter"
-
-/**
- * Sends time tracking entries to a webhook endpoint.
- *
- * @remarks
- * Implements the WriterService interface to allow seamless integration
- * with other writers (e.g., CsvWriter). Errors are handled internally
- * and returned as part of the WriteResult.
- *
- * Configuration via environment variables:
- * - `TT_WEBHOOK_URL` - The webhook endpoint URL (required for webhook to be active)
- * - `TT_WEBHOOK_BEARER_TOKEN` - Optional Bearer token for authentication
- *
- * If `TT_WEBHOOK_URL` is not set, the webhook is silently skipped (returns success).
- *
- * @example
- * ```typescript
- * // .env
- * TT_WEBHOOK_URL=https://n8n.example.com/webhook/time-tracking
- * TT_WEBHOOK_BEARER_TOKEN=your-secret-token
- * ```
- *
- * @example
- * ```typescript
- * const webhookSender = new WebhookSender()
- * const result = await webhookSender.write(entryData)
- *
- * if (!result.success) {
- * console.error(`Webhook failed: ${result.error}`)
- * }
- * ```
- */
-export class WebhookSender implements WriterService {
- /**
- * Checks if the webhook is configured and enabled.
- *
- * @returns `true` if TT_WEBHOOK_URL is set
- */
- isEnabled(): boolean {
- return !!process.env.TT_WEBHOOK_URL
- }
-
- /**
- * Sends entry data to the configured webhook.
- *
- * @param data - The entry data to send
- * @returns Result indicating success or failure
- *
- * @remarks
- * The payload contains all required fields with correct types (integer/number/string)
- * and optional fields only when they have values.
- * If `TT_WEBHOOK_URL` is not set, returns success (skip is not an error).
- * If `TT_WEBHOOK_BEARER_TOKEN` is set, it's included as Bearer token.
- */
- async write(data: CsvEntryData): Promise {
- const webhookUrl = process.env.TT_WEBHOOK_URL
-
- if (!webhookUrl) {
- // Webhook not configured, skip silently (not an error)
- return { writer: "webhook", success: true }
- }
-
- const bearerToken = process.env.TT_WEBHOOK_BEARER_TOKEN
-
- const totalTokens =
- data.tokenUsage.input + data.tokenUsage.output + data.tokenUsage.reasoning
-
- // Required fields â always present with correct types
- const payload: Record = {
- id: data.id,
- start_date: CsvFormatter.formatDate(data.startTime),
- end_date: CsvFormatter.formatDate(data.endTime),
- user: data.userEmail,
- author_email: data.authorEmail,
- issue_key: data.ticket ?? "",
- account_key: data.accountKey,
- start_time: CsvFormatter.formatTime(data.startTime),
- end_time: CsvFormatter.formatTime(data.endTime),
- duration_seconds: Math.round(data.durationSeconds) || 0,
- tokens_used: Math.round(totalTokens) || 0,
- description: data.description || "n/a",
- model: data.model ?? "unknown",
- agent: data.agent ?? "unknown",
- tokens_input: Math.round(data.tokenUsage.input) || 0,
- tokens_output: Math.round(data.tokenUsage.output) || 0,
- tokens_reasoning: Math.round(data.tokenUsage.reasoning) || 0,
- tokens_cache_read: Math.round(data.tokenUsage.cacheRead) || 0,
- tokens_cache_write: Math.round(data.tokenUsage.cacheWrite) || 0,
- cost: data.cost ?? 0.0,
- }
-
- // Optional fields â only include when values are present
- if (data.notes) {
- payload.notes = data.notes
- }
-
- const headers: Record = {
- "Content-Type": "application/json",
- }
-
- if (bearerToken) {
- headers["Authorization"] = `Bearer ${bearerToken}`
- }
-
- try {
- const response = await fetch(webhookUrl, {
- method: "POST",
- headers,
- body: JSON.stringify(payload),
- })
-
- if (!response.ok) {
- return {
- writer: "webhook",
- success: false,
- error: `HTTP ${response.status}`,
- }
- }
-
- return { writer: "webhook", success: true }
- } catch (error) {
- return {
- writer: "webhook",
- success: false,
- error: error instanceof Error ? error.message : String(error),
- }
- }
- }
-}
diff --git a/src/utils/DescriptionGenerator.ts b/src/utils/DescriptionGenerator.ts
deleted file mode 100644
index d770be1..0000000
--- a/src/utils/DescriptionGenerator.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * @fileoverview Generates human-readable descriptions from activity data.
- */
-
-import type { ActivityData } from "../types/ActivityData"
-
-/**
- * Generates human-readable descriptions from tool activities.
- *
- * @remarks
- * Creates descriptions summarizing what tools were used and which files
- * were affected during a session.
- */
-export class DescriptionGenerator {
- /**
- * Generates a human-readable description of activities.
- *
- * @param activities - Array of activity data from the session
- * @returns A formatted description string
- *
- * @remarks
- * Groups activities by type:
- * - File edits (edit + write tools)
- * - File reads
- * - Commands (bash)
- * - Searches (glob + grep)
- *
- * Also includes file names if 5 or fewer files were touched.
- *
- * @example
- * ```typescript
- * const desc = DescriptionGenerator.generate(activities)
- * // Returns: "3 file edit(s), 2 file read(s) - Files: index.ts, utils.ts"
- * ```
- */
- static generate(activities: ActivityData[]): string {
- if (activities.length === 0) {
- return "Chat session (no tool calls)"
- }
-
- const toolCounts = activities.reduce(
- (acc, a) => {
- acc[a.tool] = (acc[a.tool] || 0) + 1
- return acc
- },
- {} as Record
- )
-
- const filesWorkedOn = new Set()
-
- for (const activity of activities) {
- if (activity.file) {
- const fileName = activity.file.split("/").pop() || activity.file
- filesWorkedOn.add(fileName)
- }
- }
-
- const mainActivities: string[] = []
-
- if (toolCounts.edit || toolCounts.write) {
- mainActivities.push(
- `${(toolCounts.edit || 0) + (toolCounts.write || 0)} file edit(s)`
- )
- }
-
- if (toolCounts.read) {
- mainActivities.push(`${toolCounts.read} file read(s)`)
- }
-
- if (toolCounts.bash) {
- mainActivities.push(`${toolCounts.bash} command(s)`)
- }
-
- if (toolCounts.glob || toolCounts.grep) {
- mainActivities.push(
- `${(toolCounts.glob || 0) + (toolCounts.grep || 0)} search(es)`
- )
- }
-
- let description =
- mainActivities.length > 0
- ? mainActivities.join(", ")
- : `${activities.length} tool call(s)`
-
- if (filesWorkedOn.size > 0 && filesWorkedOn.size <= 5) {
- description += ` - Files: ${Array.from(filesWorkedOn).join(", ")}`
- } else if (filesWorkedOn.size > 5) {
- description += ` - ${filesWorkedOn.size} files`
- }
-
- return description
- }
-
- /**
- * Generates a compact tool usage summary.
- *
- * @param activities - Array of activity data from the session
- * @returns A compact summary string showing tool counts
- *
- * @example
- * ```typescript
- * const summary = DescriptionGenerator.generateToolSummary(activities)
- * // Returns: "read(5x), edit(3x), bash(2x)"
- * ```
- */
- static generateToolSummary(activities: ActivityData[]): string {
- const toolCounts = activities.reduce(
- (acc, a) => {
- acc[a.tool] = (acc[a.tool] || 0) + 1
- return acc
- },
- {} as Record
- )
-
- return Object.entries(toolCounts)
- .map(([t, c]) => `${t}(${c}x)`)
- .join(", ")
- }
-}
diff --git a/src/utils/MessageExtractor.ts b/src/utils/MessageExtractor.ts
deleted file mode 100644
index f224707..0000000
--- a/src/utils/MessageExtractor.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * @fileoverview Extracts conversation context from session messages.
- */
-
-import type { MessageWithParts } from "../types/MessageWithParts"
-
-/**
- * Number of recent conversation turns to extract.
- *
- * @remarks
- * A turn = one user message + the following assistant response.
- */
-const RECENT_TURNS = 3
-
-/**
- * Maximum characters for a single assistant response in the context.
- *
- * @remarks
- * Assistant responses can be very long. Truncating keeps the context
- * balanced between user intent and assistant work description.
- */
-const MAX_ASSISTANT_CHARS = 500
-
-/**
- * Extracts conversation context from session messages for title generation.
- *
- * @remarks
- * Collects the last {@link RECENT_TURNS} conversation turns (user prompt +
- * first assistant response) to provide meaningful context for worklog
- * description generation.
- */
-export class MessageExtractor {
- /**
- * Extracts the last conversation turns from session messages.
- *
- * @param messages - Array of messages with their parts
- * @returns The extracted conversation context, or `null` if no content found
- *
- * @remarks
- * Strategy: Scan from the end to find the last N user messages.
- * For each user message, find the next assistant message that follows it.
- * This gives clean user/assistant pairs regardless of how many
- * intermediate messages exist.
- */
- static extractConversationContext(
- messages: MessageWithParts[]
- ): string | null {
- // Step 1: Find indices of all user messages
- const userIndices: number[] = []
- for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i].info.role === "user") {
- const text = MessageExtractor.extractText(messages[i])
- if (text) {
- userIndices.push(i)
- if (userIndices.length >= RECENT_TURNS) break
- }
- }
- }
-
- if (userIndices.length === 0) return null
-
- // Step 2: For each user message, collect it + the first assistant response after it
- const turns: string[] = []
-
- // Process in chronological order (oldest first)
- for (const idx of userIndices.reverse()) {
- const userText = MessageExtractor.extractText(messages[idx])
- if (userText) {
- turns.push(`User: ${userText}`)
- }
-
- // Find first assistant response after this user message
- for (let j = idx + 1; j < messages.length; j++) {
- if (messages[j].info.role === "assistant") {
- const assistantText = MessageExtractor.extractText(messages[j])
- if (assistantText) {
- const truncated = assistantText.length > MAX_ASSISTANT_CHARS
- ? assistantText.slice(0, MAX_ASSISTANT_CHARS) + "..."
- : assistantText
- turns.push(`Assistant: ${truncated}`)
- break
- }
- }
- // Stop if we hit the next user message (no assistant response for this turn)
- if (messages[j].info.role === "user") break
- }
- }
-
- return turns.length > 0 ? turns.join("\n") : null
- }
-
- /**
- * Extracts non-synthetic text content from a message.
- */
- private static extractText(message: MessageWithParts): string | null {
- const textParts = message.parts
- .filter((part) => part.type === "text" && !part.synthetic && part.text)
- .map((part) => part.text as string)
-
- if (textParts.length === 0) return null
-
- const combined = textParts.join("\n").trim()
- return combined.length > 0 ? combined : null
- }
-}
diff --git a/tests/integration/hooks/EventHook.time-tracking.test.ts b/tests/integration/hooks/EventHook.time-tracking.test.ts
new file mode 100644
index 0000000..aaa69b4
--- /dev/null
+++ b/tests/integration/hooks/EventHook.time-tracking.test.ts
@@ -0,0 +1,330 @@
+/**
+ * @fileoverview Integration tests for EventHook with TimeTrackingFacade
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { createEventHook } from "../../../src/hooks/EventHook"
+import { SessionManager } from "../../../src/services/SessionManager"
+import { TicketResolver } from "../../../src/services/TicketResolver"
+import type { TimeTrackingConfig } from "../../../src/types/TimeTrackingConfig"
+import type { OpencodeClient } from "../../../src/types/OpencodeClient"
+import type { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking"
+
+describe("EventHook - Time Tracking Integration", () => {
+ let sessionManager: SessionManager
+ let mockClient: OpencodeClient
+ let mockTicketResolver: TicketResolver
+ let mockFacade: TimeTrackingFacade
+ let config: TimeTrackingConfig
+
+ beforeEach(() => {
+ sessionManager = new SessionManager()
+
+ mockClient = {
+ tui: {
+ showToast: vi.fn().mockResolvedValue(undefined),
+ },
+ session: {
+ messages: vi.fn().mockResolvedValue({ data: [] }),
+ },
+ } as unknown as OpencodeClient
+
+ mockTicketResolver = {
+ resolve: vi.fn().mockResolvedValue({
+ ticket: "PROJ-123",
+ accountKey: "ACCOUNT-1",
+ authorEmail: "author@example.com",
+ primaryAgent: "@developer",
+ }),
+ } as unknown as TicketResolver
+
+ mockFacade = {
+ track: vi.fn().mockResolvedValue({
+ summary: {
+ description: "Test description",
+ llmError: null,
+ },
+ entry: {
+ id: "entry-1",
+ userEmail: "user@example.com",
+ startTime: 1000000,
+ endTime: 2000000,
+ durationSeconds: 1000,
+ description: "Test description",
+ notes: "Auto-tracked",
+ tokenUsage: {
+ input: 100,
+ output: 200,
+ reasoning: 50,
+ cacheRead: 10,
+ cacheWrite: 5,
+ },
+ cost: 0.05,
+ model: "anthropic/claude-opus-4",
+ agent: "@developer",
+ },
+ csv: {
+ success: true,
+ writer: "csv",
+ },
+ webhook: {
+ success: true,
+ writer: "webhook",
+ },
+ }),
+ } as unknown as TimeTrackingFacade
+
+ config = {
+ csv_file: "~/time.csv",
+ global_default: {
+ issue_key: "DEFAULT-1",
+ account_key: "DEFAULT-ACCOUNT",
+ author_email: "default@example.com",
+ },
+ user_email: "user@example.com",
+ time_tracking: {
+ defaults: {
+ issue_key: "DEFAULT-1",
+ account_key: "DEFAULT-ACCOUNT",
+ author_email: "default@example.com",
+ },
+ pricing: {
+ default: {
+ input: 0.003,
+ output: 0.015,
+ cache_read: 0.00075,
+ cache_write: 0.00375,
+ },
+ periods: [],
+ },
+ valid_projects: [],
+ },
+ } as unknown as TimeTrackingConfig
+ })
+
+ it("processes session.status.idle event", async () => {
+ const eventHook = createEventHook(
+ sessionManager,
+ [],
+ mockClient,
+ mockTicketResolver,
+ config,
+ async () => mockFacade
+ )
+
+ // Create a session
+ sessionManager.create("session-1", null)
+ sessionManager.addActivity("session-1", {
+ type: "tool_call",
+ toolName: "edit",
+ timestamp: Date.now(),
+ duration: 500,
+ })
+ sessionManager.addTokenUsage("session-1", {
+ input: 100,
+ output: 200,
+ reasoning: 50,
+ cacheRead: 10,
+ cacheWrite: 5,
+ })
+
+ // Trigger idle event
+ await eventHook({
+ event: {
+ type: "session.status",
+ properties: {
+ sessionID: "session-1",
+ status: { type: "idle" },
+ },
+ } as any,
+ })
+
+ // Verify facade was called
+ expect(mockFacade.track).toHaveBeenCalled()
+
+ // Verify toast was shown
+ expect(mockClient.tui.showToast).toHaveBeenCalled()
+
+ // Verify session was deleted
+ expect(sessionManager.has("session-1")).toBe(false)
+ })
+
+ it("ignores non-idle status events", async () => {
+ const eventHook = createEventHook(
+ sessionManager,
+ [],
+ mockClient,
+ mockTicketResolver,
+ config,
+ async () => mockFacade
+ )
+
+ sessionManager.create("session-1", null)
+
+ await eventHook({
+ event: {
+ type: "session.status",
+ properties: {
+ sessionID: "session-1",
+ status: { type: "busy" },
+ },
+ } as any,
+ })
+
+ // Facade should not be called
+ expect(mockFacade.track).not.toHaveBeenCalled()
+
+ // Session should still exist
+ expect(sessionManager.has("session-1")).toBe(true)
+ })
+
+ it("ignores sessions without activity or tokens", async () => {
+ const eventHook = createEventHook(
+ sessionManager,
+ [],
+ mockClient,
+ mockTicketResolver,
+ config,
+ async () => mockFacade
+ )
+
+ sessionManager.create("session-1", null)
+
+ await eventHook({
+ event: {
+ type: "session.status",
+ properties: {
+ sessionID: "session-1",
+ status: { type: "idle" },
+ },
+ } as any,
+ })
+
+ // Facade should not be called
+ expect(mockFacade.track).not.toHaveBeenCalled()
+
+ // Toast should not be shown
+ expect(mockClient.tui.showToast).not.toHaveBeenCalled()
+ })
+
+ it("skips ignored agents", async () => {
+ const configWithIgnoredAgent = {
+ ...config,
+ ignored_agents: ["@internal"],
+ }
+
+ const eventHook = createEventHook(
+ sessionManager,
+ [],
+ mockClient,
+ mockTicketResolver,
+ configWithIgnoredAgent,
+ async () => mockFacade
+ )
+
+ sessionManager.create("session-1", null)
+ sessionManager.setAgent("session-1", "@internal")
+ sessionManager.addActivity("session-1", {
+ type: "tool_call",
+ toolName: "edit",
+ timestamp: Date.now(),
+ duration: 500,
+ })
+
+ await eventHook({
+ event: {
+ type: "session.status",
+ properties: {
+ sessionID: "session-1",
+ status: { type: "idle" },
+ },
+ } as any,
+ })
+
+ // Facade should not be called
+ expect(mockFacade.track).not.toHaveBeenCalled()
+
+ // Toast should show skip message
+ expect(mockClient.tui.showToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ message: expect.stringContaining("skipped"),
+ }),
+ })
+ )
+ })
+
+ it("tracks model from assistant messages", async () => {
+ const eventHook = createEventHook(
+ sessionManager,
+ [],
+ mockClient,
+ mockTicketResolver,
+ config,
+ async () => mockFacade
+ )
+
+ await eventHook({
+ event: {
+ type: "message.updated",
+ properties: {
+ info: {
+ role: "assistant",
+ sessionID: "session-1",
+ modelID: "claude-opus-4",
+ providerID: "anthropic",
+ mode: "@developer",
+ },
+ },
+ } as any,
+ })
+
+ const session = sessionManager.get("session-1")
+ expect(session?.model?.modelID).toBe("claude-opus-4")
+ expect(session?.model?.providerID).toBe("anthropic")
+ expect(session?.agent?.name).toBe("@developer")
+ })
+
+ it("tracks token usage from message parts", async () => {
+ const eventHook = createEventHook(
+ sessionManager,
+ [],
+ mockClient,
+ mockTicketResolver,
+ config,
+ async () => mockFacade
+ )
+
+ sessionManager.create("session-1", null)
+
+ await eventHook({
+ event: {
+ type: "message.part.updated",
+ properties: {
+ part: {
+ type: "step-finish",
+ sessionID: "session-1",
+ tokens: {
+ input: 100,
+ output: 200,
+ reasoning: 50,
+ cache: {
+ read: 10,
+ write: 5,
+ },
+ },
+ cost: 0.05,
+ },
+ },
+ } as any,
+ })
+
+ const session = sessionManager.get("session-1")
+ expect(session?.tokenUsage.input).toBe(100)
+ expect(session?.tokenUsage.output).toBe(200)
+ expect(session?.tokenUsage.reasoning).toBe(50)
+ expect(session?.tokenUsage.cacheRead).toBe(10)
+ expect(session?.tokenUsage.cacheWrite).toBe(5)
+ expect(session?.cost).toBe(0.05)
+ })
+})
diff --git a/tests/unit/adapters/SessionDataMapper.test.ts b/tests/unit/adapters/SessionDataMapper.test.ts
new file mode 100644
index 0000000..2912a5d
--- /dev/null
+++ b/tests/unit/adapters/SessionDataMapper.test.ts
@@ -0,0 +1,164 @@
+/**
+ * @fileoverview Unit tests for SessionDataMapper
+ */
+
+import { describe, it, expect, vi } from "vitest"
+import { SessionDataMapper } from "../../../src/adapters/SessionDataMapper"
+import type { SessionData } from "../../../src/types/SessionData"
+import type { OpencodeClient } from "../../../src/types/OpencodeClient"
+
+describe("SessionDataMapper", () => {
+ const mockClient = {
+ session: {
+ messages: vi.fn(),
+ },
+ } as unknown as OpencodeClient
+
+ const mockSession: SessionData = {
+ ticket: "PROJ-123",
+ startTime: 1000000,
+ activities: [
+ {
+ type: "tool_call",
+ toolName: "edit",
+ timestamp: 1000100,
+ duration: 500,
+ },
+ ],
+ tokenUsage: {
+ input: 100,
+ output: 200,
+ reasoning: 50,
+ cacheRead: 10,
+ cacheWrite: 5,
+ },
+ cost: 0.05,
+ model: {
+ providerID: "anthropic",
+ modelID: "claude-opus-4",
+ },
+ agent: {
+ name: "@developer",
+ timestamp: 1000000,
+ },
+ }
+
+ it("maps SessionData to SessionDataInterface correctly", () => {
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {
+ userEmail: "user@example.com",
+ })
+
+ expect(result.agent).toBe("@developer")
+ expect(result.model).toBe("anthropic/claude-opus-4")
+ expect(result.startTime).toBe(1000000)
+ expect(result.userEmail).toBe("user@example.com")
+ expect(result.ticket).toBe("PROJ-123")
+ expect(result.tokens.input).toBe(100)
+ expect(result.tokens.output).toBe(200)
+ expect(result.tokens.cacheRead).toBe(10)
+ expect(result.tokens.cacheWrite).toBe(5)
+ })
+
+ it("formats model as provider/modelID", () => {
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {})
+
+ expect(result.model).toBe("anthropic/claude-opus-4")
+ })
+
+ it("handles null model gracefully", () => {
+ const sessionWithoutModel: SessionData = {
+ ...mockSession,
+ model: null,
+ }
+
+ const result = SessionDataMapper.build(sessionWithoutModel, mockClient, "session-123", {})
+
+ expect(result.model).toBe("unknown")
+ })
+
+ it("handles null agent gracefully", () => {
+ const sessionWithoutAgent: SessionData = {
+ ...mockSession,
+ agent: null,
+ }
+
+ const result = SessionDataMapper.build(sessionWithoutAgent, mockClient, "session-123", {})
+
+ expect(result.agent).toBe("unknown")
+ })
+
+ it("builds conversationContextProvider callback", async () => {
+ const mockMessages = [
+ {
+ info: { role: "user" },
+ content: "Hello",
+ },
+ {
+ info: { role: "assistant" },
+ content: "Hi there",
+ },
+ ]
+
+ vi.mocked(mockClient.session.messages).mockResolvedValueOnce({
+ data: mockMessages,
+ } as any)
+
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {})
+
+ expect(result.conversationContext).toBeDefined()
+
+ const context = await result.conversationContext!()
+ expect(context).toContain("user: Hello")
+ expect(context).toContain("assistant: Hi there")
+ })
+
+ it("handles SDK call errors gracefully", async () => {
+ vi.mocked(mockClient.session.messages).mockRejectedValueOnce(
+ new Error("SDK error")
+ )
+
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {})
+
+ const context = await result.conversationContext!()
+ expect(context).toBeNull()
+ })
+
+ it("handles empty messages gracefully", async () => {
+ vi.mocked(mockClient.session.messages).mockResolvedValueOnce({
+ data: [],
+ } as any)
+
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {})
+
+ const context = await result.conversationContext!()
+ expect(context).toBeNull()
+ })
+
+ it("handles undefined data gracefully", async () => {
+ vi.mocked(mockClient.session.messages).mockResolvedValueOnce({
+ data: undefined,
+ } as any)
+
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {})
+
+ const context = await result.conversationContext!()
+ expect(context).toBeNull()
+ })
+
+ it("includes activities in result", () => {
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {})
+
+ expect(result.activities).toBeDefined()
+ expect(result.activities?.length).toBe(1)
+ expect(result.activities?.[0].type).toBe("tool_call")
+ })
+
+ it("sets endTime to current time", () => {
+ const beforeBuild = Date.now()
+ const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {})
+ const afterBuild = Date.now()
+
+ expect(result.endTime).toBeGreaterThanOrEqual(beforeBuild)
+ expect(result.endTime).toBeLessThanOrEqual(afterBuild)
+ })
+})
diff --git a/tests/unit/services/SessionManager.test.ts b/tests/unit/services/SessionManager.test.ts
new file mode 100644
index 0000000..9e5165b
--- /dev/null
+++ b/tests/unit/services/SessionManager.test.ts
@@ -0,0 +1,228 @@
+/**
+ * @fileoverview Unit tests for SessionManager wrapper
+ */
+
+import { describe, it, expect } from "vitest"
+import { SessionManager } from "../../../src/services/SessionManager"
+
+describe("SessionManager", () => {
+ let sessionManager: SessionManager
+
+ beforeEach(() => {
+ sessionManager = new SessionManager()
+ })
+
+ it("creates a new session", () => {
+ const session = sessionManager.create("session-1", "PROJ-123")
+
+ expect(session).toBeDefined()
+ expect(session.ticket).toBe("PROJ-123")
+ expect(session.startTime).toBeGreaterThan(0)
+ expect(session.activities).toEqual([])
+ expect(session.tokenUsage.input).toBe(0)
+ })
+
+ it("retrieves an existing session", () => {
+ sessionManager.create("session-1", "PROJ-123")
+ const session = sessionManager.get("session-1")
+
+ expect(session).toBeDefined()
+ expect(session?.ticket).toBe("PROJ-123")
+ })
+
+ it("returns undefined for non-existent session", () => {
+ const session = sessionManager.get("non-existent")
+
+ expect(session).toBeUndefined()
+ })
+
+ it("checks if session exists", () => {
+ sessionManager.create("session-1", "PROJ-123")
+
+ expect(sessionManager.has("session-1")).toBe(true)
+ expect(sessionManager.has("non-existent")).toBe(false)
+ })
+
+ it("deletes a session", () => {
+ sessionManager.create("session-1", "PROJ-123")
+ sessionManager.delete("session-1")
+
+ expect(sessionManager.has("session-1")).toBe(false)
+ })
+
+ it("gets and deletes a session atomically", () => {
+ sessionManager.create("session-1", "PROJ-123")
+ const session = sessionManager.getAndDelete("session-1")
+
+ expect(session).toBeDefined()
+ expect(session?.ticket).toBe("PROJ-123")
+ expect(sessionManager.has("session-1")).toBe(false)
+ })
+
+ it("returns undefined when getting and deleting non-existent session", () => {
+ const session = sessionManager.getAndDelete("non-existent")
+
+ expect(session).toBeUndefined()
+ })
+
+ it("adds activity to session", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.addActivity("session-1", {
+ type: "tool_call",
+ toolName: "edit",
+ timestamp: Date.now(),
+ duration: 500,
+ })
+
+ const session = sessionManager.get("session-1")
+ expect(session?.activities.length).toBe(1)
+ expect(session?.activities[0].toolName).toBe("edit")
+ })
+
+ it("adds token usage to session", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.addTokenUsage("session-1", {
+ input: 100,
+ output: 200,
+ reasoning: 50,
+ cacheRead: 10,
+ cacheWrite: 5,
+ })
+
+ const session = sessionManager.get("session-1")
+ expect(session?.tokenUsage.input).toBe(100)
+ expect(session?.tokenUsage.output).toBe(200)
+ expect(session?.tokenUsage.reasoning).toBe(50)
+ expect(session?.tokenUsage.cacheRead).toBe(10)
+ expect(session?.tokenUsage.cacheWrite).toBe(5)
+ })
+
+ it("accumulates token usage", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.addTokenUsage("session-1", {
+ input: 100,
+ output: 200,
+ reasoning: 50,
+ cacheRead: 10,
+ cacheWrite: 5,
+ })
+ sessionManager.addTokenUsage("session-1", {
+ input: 50,
+ output: 100,
+ reasoning: 25,
+ cacheRead: 5,
+ cacheWrite: 2,
+ })
+
+ const session = sessionManager.get("session-1")
+ expect(session?.tokenUsage.input).toBe(150)
+ expect(session?.tokenUsage.output).toBe(300)
+ expect(session?.tokenUsage.reasoning).toBe(75)
+ expect(session?.tokenUsage.cacheRead).toBe(15)
+ expect(session?.tokenUsage.cacheWrite).toBe(7)
+ })
+
+ it("adds cost to session", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.addCost("session-1", 0.05)
+
+ const session = sessionManager.get("session-1")
+ expect(session?.cost).toBe(0.05)
+ })
+
+ it("accumulates cost", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.addCost("session-1", 0.05)
+ sessionManager.addCost("session-1", 0.03)
+
+ const session = sessionManager.get("session-1")
+ expect(session?.cost).toBe(0.08)
+ })
+
+ it("updates ticket reference", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.updateTicket("session-1", "PROJ-456")
+
+ const session = sessionManager.get("session-1")
+ expect(session?.ticket).toBe("PROJ-456")
+ })
+
+ it("ignores null ticket update", () => {
+ sessionManager.create("session-1", "PROJ-123")
+ sessionManager.updateTicket("session-1", null)
+
+ const session = sessionManager.get("session-1")
+ expect(session?.ticket).toBe("PROJ-123")
+ })
+
+ it("sets model on session", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.setModel("session-1", {
+ providerID: "anthropic",
+ modelID: "claude-opus-4",
+ })
+
+ const session = sessionManager.get("session-1")
+ expect(session?.model?.providerID).toBe("anthropic")
+ expect(session?.model?.modelID).toBe("claude-opus-4")
+ })
+
+ it("only sets model once", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.setModel("session-1", {
+ providerID: "anthropic",
+ modelID: "claude-opus-4",
+ })
+ sessionManager.setModel("session-1", {
+ providerID: "openai",
+ modelID: "gpt-5",
+ })
+
+ const session = sessionManager.get("session-1")
+ expect(session?.model?.providerID).toBe("anthropic")
+ expect(session?.model?.modelID).toBe("claude-opus-4")
+ })
+
+ it("sets agent on session", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.setAgent("session-1", "@developer")
+
+ const session = sessionManager.get("session-1")
+ expect(session?.agent?.name).toBe("@developer")
+ })
+
+ it("only sets agent once", () => {
+ sessionManager.create("session-1", null)
+ sessionManager.setAgent("session-1", "@developer")
+ sessionManager.setAgent("session-1", "@reviewer")
+
+ const session = sessionManager.get("session-1")
+ expect(session?.agent?.name).toBe("@developer")
+ })
+
+ it("handles operations on non-existent session gracefully", () => {
+ // Should not throw
+ sessionManager.addActivity("non-existent", {
+ type: "tool_call",
+ toolName: "edit",
+ timestamp: Date.now(),
+ duration: 500,
+ })
+ sessionManager.addTokenUsage("non-existent", {
+ input: 100,
+ output: 200,
+ reasoning: 50,
+ cacheRead: 10,
+ cacheWrite: 5,
+ })
+ sessionManager.addCost("non-existent", 0.05)
+ sessionManager.updateTicket("non-existent", "PROJ-123")
+ sessionManager.setModel("non-existent", {
+ providerID: "anthropic",
+ modelID: "claude-opus-4",
+ })
+ sessionManager.setAgent("non-existent", "@developer")
+
+ expect(sessionManager.get("non-existent")).toBeUndefined()
+ })
+})
diff --git a/tools/track-time.ts b/tools/track-time.ts
index 5d3ff45..9da3bc2 100644
--- a/tools/track-time.ts
+++ b/tools/track-time.ts
@@ -4,11 +4,13 @@ import path from "path"
import os from "os"
import { randomUUID } from "crypto"
-import { CsvWriter } from "../src/services/CsvWriter"
-import { WebhookSender } from "../src/services/WebhookSender"
import type { CsvEntryData } from "../src/types/CsvEntryData"
import type { WriteResult, WriterService } from "../src/types/WriterService"
+// Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking
+// For this tool, we'll use a simplified approach without them
+// since the main plugin uses TimeTrackingFacade
+
interface TimeTrackingConfig {
csv_file: string
default_account_key: string
@@ -441,35 +443,18 @@ export default tool({
agent: agentName,
}
- // 8. Initialize writers and write entry
- // Build config for CsvWriter (needs csv_file and user_email)
- const writerConfig = {
- csv_file: timeTracking.csv_file,
- user_email: userEmail,
- global_default: {
- issue_key: timeTracking.global_default.issue_key || "",
- account_key: timeTracking.global_default.account_key || "",
- },
- }
-
- const csvWriter = new CsvWriter(writerConfig, directory)
- const webhookSender = new WebhookSender()
-
- // Ensure CSV header exists
- await csvWriter.ensureHeader()
-
- // Call all writers and collect results
- const writers: WriterService[] = [csvWriter, webhookSender]
+ // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking
+ // This tool is deprecated in favor of the automatic time tracking via the plugin
+ // For manual entries, use the plugin's configuration instead
+
+ // For now, return a message indicating the tool should use the plugin
const results: WriteResult[] = []
-
- for (const writer of writers) {
- const result = await writer.write(entryData)
- results.push(result)
- }
-
- // 9. Return confirmation with writer results
- const allSucceeded = results.every((r) => r.success)
- const failedWriters = results.filter((r) => !r.success)
+ const allSucceeded = false
+ const failedWriters: WriteResult[] = [{
+ success: false,
+ writer: "track-time",
+ error: "This tool is deprecated. Use the automatic time tracking plugin instead."
+ }]
return JSON.stringify({
success: allSucceeded,
From 2abdfc6c7ba8be6b38107a0dd27bfa0b75103f59 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Thu, 23 Apr 2026 11:58:47 +0200
Subject: [PATCH 03/29] chore: Update lib-ts-time-tracking dependency to GitHub
Use GitHub repository directly with prepare script for automatic build.
Commit: 3ee597d (chore: Use prepare script instead of postinstall)
---
package-lock.json | 28 +++++++---------------------
package.json | 2 +-
2 files changed, 8 insertions(+), 22 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 9ecf6c3..86fdd4b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
+ "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#5fce0dc"
},
"devDependencies": {
"@types/bun": "latest",
@@ -21,24 +21,6 @@
"bun": ">=1.0.0"
}
},
- "../lib-ts-time-tracking": {
- "name": "@techdivision/lib-ts-time-tracking",
- "version": "4.2.0",
- "license": "MIT",
- "devDependencies": {
- "@amiceli/vitest-cucumber": "^5.2.1",
- "@types/node": "^22.0.0",
- "eslint": "^10.1.0",
- "eslint-config-prettier": "^10.1.8",
- "prettier": "^3.8.1",
- "typescript": "^5.9.3",
- "typescript-eslint": "^8.57.2",
- "vitest": "^3.2.1"
- },
- "engines": {
- "node": ">=20"
- }
- },
"node_modules/@opencode-ai/plugin": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz",
@@ -224,8 +206,12 @@
"peer": true
},
"node_modules/@techdivision/lib-ts-time-tracking": {
- "resolved": "../lib-ts-time-tracking",
- "link": true
+ "version": "4.2.0",
+ "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#7ab7d77b259bc22af12949eb01b4e6b2a0ad9dcb",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
},
"node_modules/@types/bun": {
"version": "1.3.9",
diff --git a/package.json b/package.json
index 51796ce..378edbd 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
+ "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#3ee597d"
},
"devDependencies": {
"@types/bun": "latest",
From 233d5fb7855d8e4b998d10721f97ca9874e5ab42 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Thu, 23 Apr 2026 11:58:55 +0200
Subject: [PATCH 04/29] docs: Add lib-ts-time-tracking dependency documentation
Document the GitHub dependency and provide troubleshooting steps
for manual build if needed.
---
README.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/README.md b/README.md
index 0073c7b..37f9298 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,20 @@ This architecture eliminates ~870 lines of code duplication and ensures a single
| Commands | `track-time`, `timesheet`, `booking-proposal`, `sync-calendar`, `sync-drive`, `sync-tempo`, `sync-worklogs`, `init` | Slash commands for time tracking operations |
| Tools | `track-time`, `cumulate-daily-worklogs`, `sync-tempo-worklog` | Custom tools for CSV writing and worklog sync |
+## Dependencies
+
+### lib-ts-time-tracking
+
+This plugin depends on [@techdivision/lib-ts-time-tracking](https://github.com/techdivision/lib-ts-time-tracking) from GitHub. The library is automatically built when installed via npm's `prepare` script.
+
+If you encounter issues with the library not being built, you can manually build it:
+
+```bash
+cd node_modules/@techdivision/lib-ts-time-tracking
+npm install
+npm run build
+```
+
## Prerequisites
### opencode-plugin-shell-env (required)
From f9f049e2fa69d3f2dcda2c934c5ed53b7defbf22 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Thu, 23 Apr 2026 12:09:20 +0200
Subject: [PATCH 05/29] chore: Use local path for lib-ts-time-tracking
dependency
For development and testing in .opencode directories, use local file path
instead of GitHub URL to avoid network issues and ensure consistent builds.
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 378edbd..51796ce 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#3ee597d"
+ "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
},
"devDependencies": {
"@types/bun": "latest",
From aa167e0ef79777cf9aa3f0f5e25cdb47716840d5 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 10:51:17 +0200
Subject: [PATCH 06/29] refactor: remove redundant types and utilities after
lib integration
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Delete CsvFormatter.ts (84 lines): Functionality now in lib-ts-time-tracking
- Delete WriterService.ts (54 lines): Replaced by lib's WriteResultInterface
- Delete TitleGenerationConfig.ts (96 lines): Title generation now in lib
- Remove title_generation field from TimeTrackingConfig.ts
- Update EventHook.ts to use lib's WriteResultInterface
- Remove unused writers parameter from createEventHook()
- Update track-time.ts tool to use lib's WriteResultInterface
- Update CsvEntryData.ts comment to remove WriterService reference
Total cleanup: 234 lines removed
TypeScript compilation: â
No errors
---
src/Plugin.ts | 1 -
src/hooks/EventHook.ts | 12 ++--
src/types/CsvEntryData.ts | 16 ++---
src/types/TimeTrackingConfig.ts | 10 ----
src/types/TitleGenerationConfig.ts | 96 ------------------------------
src/types/WriterService.ts | 54 -----------------
src/utils/CsvFormatter.ts | 84 --------------------------
tools/track-time.ts | 26 ++++----
8 files changed, 25 insertions(+), 274 deletions(-)
delete mode 100644 src/types/TitleGenerationConfig.ts
delete mode 100644 src/types/WriterService.ts
delete mode 100644 src/utils/CsvFormatter.ts
diff --git a/src/Plugin.ts b/src/Plugin.ts
index 557b620..a3d482b 100644
--- a/src/Plugin.ts
+++ b/src/Plugin.ts
@@ -94,7 +94,6 @@ export const plugin: Plugin = async ({
),
event: createEventHook(
sessionManager,
- [],
client,
ticketResolver,
config,
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index ba18da8..c49cb8c 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -3,7 +3,7 @@
*/
import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk"
-import type { TimeTrackingFacade, TimeTrackingConfigInterface } from "@techdivision/lib-ts-time-tracking"
+import type { TimeTrackingFacade, TimeTrackingConfigInterface, WriteResultInterface } from "@techdivision/lib-ts-time-tracking"
import type { SessionManager } from "../services/SessionManager"
import type { TicketResolver } from "../services/TicketResolver"
@@ -12,7 +12,6 @@ import type { MessagePartUpdatedProperties } from "../types/MessagePartUpdatedPr
import type { MessageWithParts } from "../types/MessageWithParts"
import type { OpencodeClient } from "../types/OpencodeClient"
import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
-import type { WriteResult, WriterService } from "../types/WriterService"
import { AgentMatcher } from "../utils/AgentMatcher"
import { SessionDataMapper } from "../adapters/SessionDataMapper"
@@ -28,7 +27,6 @@ interface MessageUpdatedProperties {
* Creates the event hook for session lifecycle management.
*
* @param sessionManager - The session manager instance
- * @param writers - Array of writer services to persist entries (e.g., CsvWriter, WebhookSender)
* @param client - The OpenCode SDK client
* @param ticketResolver - The ticket resolver instance
* @param config - The time tracking configuration
@@ -47,15 +45,13 @@ interface MessageUpdatedProperties {
*
* @example
* ```typescript
- * const writers: WriterService[] = [csvWriter, webhookSender]
* const hooks: Hooks = {
- * event: createEventHook(sessionManager, writers, client, ticketResolver, config, getFacade),
+ * event: createEventHook(sessionManager, client, ticketResolver, config, getFacade),
* }
* ```
*/
export function createEventHook(
sessionManager: SessionManager,
- writers: WriterService[],
client: OpencodeClient,
ticketResolver: TicketResolver,
config: TimeTrackingConfig,
@@ -238,10 +234,10 @@ export function createEventHook(
}
// Writers are called by Facade, but we have access to results
- const results: WriteResult[] = [
+ const results: WriteResultInterface[] = [
trackResult.csv,
trackResult.webhook,
- ].filter((r) => r !== undefined && r !== null) as WriteResult[]
+ ].filter((r) => r !== undefined && r !== null) as WriteResultInterface[]
// Build combined toast message with writer status
const durationSeconds = Math.round((Date.now() - session.startTime) / 1000)
diff --git a/src/types/CsvEntryData.ts b/src/types/CsvEntryData.ts
index 0f788fe..18fa953 100644
--- a/src/types/CsvEntryData.ts
+++ b/src/types/CsvEntryData.ts
@@ -8,14 +8,14 @@ import type { TokenUsage } from "./TokenUsage"
* Data structure for a single CSV worklog entry.
*/
export interface CsvEntryData {
- /**
- * Unique identifier for this entry (UUID v4).
- *
- * @remarks
- * Generated once and shared across all WriterService implementations
- * to ensure consistent identification across CSV, webhook, etc.
- */
- id: string
+ /**
+ * Unique identifier for this entry (UUID v4).
+ *
+ * @remarks
+ * Generated once and shared across all writers (CSV, webhook, etc.)
+ * to ensure consistent identification.
+ */
+ id: string
/**
* User email for the worklog.
diff --git a/src/types/TimeTrackingConfig.ts b/src/types/TimeTrackingConfig.ts
index 1bbf366..2cd6e38 100644
--- a/src/types/TimeTrackingConfig.ts
+++ b/src/types/TimeTrackingConfig.ts
@@ -4,7 +4,6 @@
import type { AgentDefaultConfig } from "./AgentDefaultConfig"
import type { GlobalDefaultConfig } from "./GlobalDefaultConfig"
-import type { TitleGenerationConfig } from "./TitleGenerationConfig"
/**
* Time tracking configuration as stored in `.opencode/opencode-project.json`.
@@ -62,15 +61,6 @@ export interface TimeTrackingJsonConfig {
* Project keys should be uppercase with at least 2 letters (e.g., "PROJ", "SOSO").
*/
valid_projects?: string[]
-
- /**
- * LLM-based title generation configuration.
- *
- * @remarks
- * When not set or partially configured, smart defaults are used
- * and title generation is enabled. Set `enabled: false` to disable.
- */
- title_generation?: TitleGenerationConfig
}
/**
diff --git a/src/types/TitleGenerationConfig.ts b/src/types/TitleGenerationConfig.ts
deleted file mode 100644
index f6e00e8..0000000
--- a/src/types/TitleGenerationConfig.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @fileoverview Configuration for LLM-based title generation.
- */
-
-/**
- * Configuration for automatic title generation via LLM.
- *
- * @remarks
- * Both `model` and `api_url` are required for title generation to be active.
- * Without configuration, title generation is not available (graceful degradation).
- *
- * To explicitly disable title generation, set `enabled: false`.
- */
-export interface TitleGenerationConfig {
- /**
- * Enables or disables title generation.
- *
- * @defaultValue `true`
- */
- enabled?: boolean
-
- /**
- * Model identifier in `"provider/model"` format.
- *
- * @remarks
- * Required for title generation. The provider prefix is informational only â
- * all requests use the Chat Completions API format.
- *
- * @example `"ollama/mistral:latest"`, `"openai/gpt-4o-mini"`
- */
- model?: string
-
- /**
- * API base URL for the LLM provider.
- *
- * @remarks
- * Required for title generation. The Chat Completions endpoint
- * (`/chat/completions`) is appended automatically.
- *
- * @example
- * - Ollama local: `"http://localhost:11434/v1"`
- * - Ollama remote: `"http://ai.tdservice.net:11434/v1"`
- * - OpenAI: `"https://api.openai.com/v1"`
- */
- api_url?: string
-
- /**
- * API key for the LLM provider.
- *
- * @remarks
- * Supports `{env:VAR_NAME}` syntax for environment variable references.
- * Can be omitted for providers that require no auth (e.g., Ollama).
- *
- * @example `"{env:OPENAI_API_KEY}"`, `"sk-abc123"`
- */
- api_key?: string
-
- /**
- * Path to a custom prompt file.
- *
- * @remarks
- * Resolved relative to `opencode-project.json` (i.e., `/.opencode/`).
- * Absolute paths are used as-is.
- * If not set, the built-in default prompt is used.
- *
- * @example `"prompts/title.txt"`
- */
- prompt?: string
-
- /**
- * Request timeout in milliseconds.
- *
- * @defaultValue `5000`
- */
- timeout_ms?: number
-
- /**
- * Maximum character length for generated titles.
- *
- * @defaultValue `240`
- */
- max_chars?: number
-
- /**
- * Output language for generated worklog descriptions.
- *
- * @remarks
- * Controls which language the LLM uses for the generated text.
- * Uses BCP 47 / IETF language tags.
- *
- * @defaultValue `"de-DE"`
- *
- * @example `"de-DE"`, `"en-US"`, `"fr-FR"`
- */
- locale?: string
-}
diff --git a/src/types/WriterService.ts b/src/types/WriterService.ts
deleted file mode 100644
index 7c66cbb..0000000
--- a/src/types/WriterService.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @fileoverview Interface for time tracking entry writers.
- */
-
-import type { CsvEntryData } from "./CsvEntryData"
-
-/**
- * Result of a write operation.
- *
- * @remarks
- * Used to report success/failure of individual writers without throwing.
- * Allows callers to aggregate results and display combined status.
- */
-export interface WriteResult {
- /** Writer identifier (e.g., "csv", "webhook") */
- writer: string
-
- /** Whether the write operation succeeded */
- success: boolean
-
- /** Error message if the operation failed */
- error?: string
-}
-
-/**
- * Interface for services that persist time tracking entries.
- *
- * @remarks
- * Implementations should handle errors internally and return a `WriteResult`
- * instead of throwing. This allows multiple writers to be called in sequence
- * without one failure affecting others.
- *
- * @example
- * ```typescript
- * const writers: WriterService[] = [csvWriter, webhookSender]
- * const results: WriteResult[] = []
- *
- * for (const writer of writers) {
- * const result = await writer.write(entryData)
- * results.push(result)
- * }
- *
- * const allSucceeded = results.every(r => r.success)
- * ```
- */
-export interface WriterService {
- /**
- * Writes a time tracking entry.
- *
- * @param data - The entry data to write (includes id and user_email)
- * @returns Result indicating success or failure with optional error message
- */
- write(data: CsvEntryData): Promise
-}
diff --git a/src/utils/CsvFormatter.ts b/src/utils/CsvFormatter.ts
deleted file mode 100644
index 9edb980..0000000
--- a/src/utils/CsvFormatter.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * @fileoverview CSV formatting utilities.
- */
-
-/**
- * Utility class for CSV formatting operations.
- *
- * @remarks
- * Provides static methods for escaping and formatting values
- * according to CSV standards.
- */
-export class CsvFormatter {
- /**
- * Escapes a string value for CSV output.
- *
- * @param value - The string to escape
- * @returns The escaped string with double quotes doubled
- *
- * @example
- * ```typescript
- * CsvFormatter.escape('Say "Hello"') // Returns: Say ""Hello""
- * ```
- */
- static escape(value: string): string {
- return value.replace(/"/g, '""')
- }
-
- /**
- * Formats a timestamp as an ISO date string (YYYY-MM-DD).
- *
- * @param timestamp - The Unix timestamp in milliseconds
- * @returns The formatted date string
- *
- * @example
- * ```typescript
- * CsvFormatter.formatDate(1704067200000) // Returns: "2024-01-01"
- * ```
- */
- static formatDate(timestamp: number): string {
- return new Date(timestamp).toISOString().split("T")[0]
- }
-
- /**
- * Formats a timestamp as a time string (HH:MM:SS).
- *
- * @param timestamp - The Unix timestamp in milliseconds
- * @returns The formatted time string in local time
- *
- * @example
- * ```typescript
- * CsvFormatter.formatTime(1704067200000) // Returns: "12:00:00"
- * ```
- */
- static formatTime(timestamp: number): string {
- return new Date(timestamp).toTimeString().split(" ")[0]
- }
-
- /**
- * Counts the number of columns in a CSV line.
- *
- * @param csvLine - A single line from a CSV file (with quoted fields)
- * @returns The number of columns
- *
- * @remarks
- * Handles quoted fields correctly by counting occurrences of `","` pattern.
- * Assumes all fields are double-quoted as our CSV writer produces.
- *
- * @example
- * ```typescript
- * CsvFormatter.countColumns('"a","b","c"') // Returns: 3
- * CsvFormatter.countColumns('"single"') // Returns: 1
- * ```
- */
- static countColumns(csvLine: string): number {
- if (!csvLine || csvLine.trim().length === 0) {
- return 0
- }
-
- // Count occurrences of "," which separates quoted fields
- // Add 1 because n separators means n+1 fields
- const matches = csvLine.match(/","/g)
- return matches ? matches.length + 1 : 1
- }
-}
diff --git a/tools/track-time.ts b/tools/track-time.ts
index 9da3bc2..edc7a27 100644
--- a/tools/track-time.ts
+++ b/tools/track-time.ts
@@ -5,7 +5,7 @@ import os from "os"
import { randomUUID } from "crypto"
import type { CsvEntryData } from "../src/types/CsvEntryData"
-import type { WriteResult, WriterService } from "../src/types/WriterService"
+import type { WriteResultInterface } from "@techdivision/lib-ts-time-tracking"
// Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking
// For this tool, we'll use a simplified approach without them
@@ -443,18 +443,18 @@ export default tool({
agent: agentName,
}
- // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking
- // This tool is deprecated in favor of the automatic time tracking via the plugin
- // For manual entries, use the plugin's configuration instead
-
- // For now, return a message indicating the tool should use the plugin
- const results: WriteResult[] = []
- const allSucceeded = false
- const failedWriters: WriteResult[] = [{
- success: false,
- writer: "track-time",
- error: "This tool is deprecated. Use the automatic time tracking plugin instead."
- }]
+ // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking
+ // This tool is deprecated in favor of the automatic time tracking via the plugin
+ // For manual entries, use the plugin's configuration instead
+
+ // For now, return a message indicating the tool should use the plugin
+ const results: WriteResultInterface[] = []
+ const allSucceeded = false
+ const failedWriters: WriteResultInterface[] = [{
+ success: false,
+ writer: "track-time",
+ error: "This tool is deprecated. Use the automatic time tracking plugin instead."
+ }]
return JSON.stringify({
success: allSucceeded,
From 256097e7021786f2e4a951bdddb825951171a196 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 10:59:46 +0200
Subject: [PATCH 07/29] =?UTF-8?q?feat:=20add=20backward=20compatibility=20?=
=?UTF-8?q?for=20title=5Fgeneration=20=E2=86=92=20summary=20migration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add SessionSummaryConfigInterface import to TimeTrackingConfig.ts
- Add 'summary' field (new, preferred) to TimeTrackingJsonConfig
- Keep 'title_generation' field (deprecated, for backward compatibility)
- Create ConfigMigration.ts utility with resolveSummaryConfig()
- Update EventHook.ts to use resolveSummaryConfig() for config migration
- Priority: summary > title_generation > undefined
- Both old and new configs work seamlessly
Migration strategy:
- Old configs with 'title_generation' continue to work
- New configs can use 'summary' field
- If both present, 'summary' takes precedence
- No breaking changes for existing users
TypeScript compilation: â
No errors
---
src/hooks/EventHook.ts | 5 +++
src/types/TimeTrackingConfig.ts | 22 +++++++++++++
src/utils/ConfigMigration.ts | 57 +++++++++++++++++++++++++++++++++
3 files changed, 84 insertions(+)
create mode 100644 src/utils/ConfigMigration.ts
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index c49cb8c..6481fda 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -15,6 +15,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
import { AgentMatcher } from "../utils/AgentMatcher"
import { SessionDataMapper } from "../adapters/SessionDataMapper"
+import { resolveSummaryConfig } from "../utils/ConfigMigration"
/**
* Properties for message.updated events.
@@ -180,6 +181,9 @@ export function createEventHook(
})
// Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface
+ // Resolve summary config with backward compatibility (summary or title_generation)
+ const summaryConfig = resolveSummaryConfig(config)
+
const libConfig: TimeTrackingConfigInterface = {
defaults: config.global_default,
agents: config.agent_defaults,
@@ -218,6 +222,7 @@ export function createEventHook(
],
},
valid_projects: config.valid_projects || [],
+ ...(summaryConfig && { summary: summaryConfig }),
}
const facade = await getTimeTrackingFacade(libConfig)
diff --git a/src/types/TimeTrackingConfig.ts b/src/types/TimeTrackingConfig.ts
index 2cd6e38..26649bb 100644
--- a/src/types/TimeTrackingConfig.ts
+++ b/src/types/TimeTrackingConfig.ts
@@ -4,6 +4,7 @@
import type { AgentDefaultConfig } from "./AgentDefaultConfig"
import type { GlobalDefaultConfig } from "./GlobalDefaultConfig"
+import type { SessionSummaryConfigInterface } from "@techdivision/lib-ts-time-tracking"
/**
* Time tracking configuration as stored in `.opencode/opencode-project.json`.
@@ -61,6 +62,27 @@ export interface TimeTrackingJsonConfig {
* Project keys should be uppercase with at least 2 letters (e.g., "PROJ", "SOSO").
*/
valid_projects?: string[]
+
+ /**
+ * LLM-based session summary configuration.
+ *
+ * @remarks
+ * Configures automatic generation of worklog descriptions via LLM.
+ * This is the new field name (replaces deprecated `title_generation`).
+ * Both `summary` and `title_generation` are supported for backward compatibility.
+ *
+ * @see {@link SessionSummaryConfigInterface} -- Configuration interface from lib
+ */
+ summary?: SessionSummaryConfigInterface
+
+ /**
+ * @deprecated Use `summary` instead. This field is kept for backward compatibility.
+ *
+ * @remarks
+ * Old field name for LLM-based title generation.
+ * If both `summary` and `title_generation` are present, `summary` takes precedence.
+ */
+ title_generation?: SessionSummaryConfigInterface
}
/**
diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts
new file mode 100644
index 0000000..e9d9762
--- /dev/null
+++ b/src/utils/ConfigMigration.ts
@@ -0,0 +1,57 @@
+/**
+ * @fileoverview Configuration migration utilities for backward compatibility.
+ */
+
+import type { SessionSummaryConfigInterface } from "@techdivision/lib-ts-time-tracking"
+import type { TimeTrackingJsonConfig } from "../types/TimeTrackingConfig"
+
+/**
+ * Resolves the summary configuration with backward compatibility.
+ *
+ * @remarks
+ * Handles migration from deprecated `title_generation` field to new `summary` field.
+ * Priority order:
+ * 1. `summary` field (new, preferred)
+ * 2. `title_generation` field (deprecated, for backward compatibility)
+ * 3. `undefined` if neither is present
+ *
+ * @param config - The time tracking configuration from opencode-project.json
+ * @returns The resolved summary configuration, or undefined if not configured
+ *
+ * @example
+ * ```typescript
+ * // Old config (still works)
+ * const config = { title_generation: { model: "ollama/mistral" } }
+ * const summary = resolveSummaryConfig(config)
+ * // Returns: { model: "ollama/mistral" }
+ *
+ * // New config
+ * const config = { summary: { model: "ollama/mistral" } }
+ * const summary = resolveSummaryConfig(config)
+ * // Returns: { model: "ollama/mistral" }
+ *
+ * // Both present (summary takes precedence)
+ * const config = {
+ * summary: { model: "ollama/mistral" },
+ * title_generation: { model: "openai/gpt-4" }
+ * }
+ * const summary = resolveSummaryConfig(config)
+ * // Returns: { model: "ollama/mistral" }
+ * ```
+ */
+export function resolveSummaryConfig(
+ config: TimeTrackingJsonConfig
+): SessionSummaryConfigInterface | undefined {
+ // Prefer new 'summary' field over deprecated 'title_generation'
+ if (config.summary) {
+ return config.summary
+ }
+
+ // Fall back to deprecated 'title_generation' for backward compatibility
+ if ((config as any).title_generation) {
+ return (config as any).title_generation
+ }
+
+ // Neither field is present
+ return undefined
+}
From 656d8eed6e9186ee2b6c6c4d4129aec016aa5bdb Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 11:11:19 +0200
Subject: [PATCH 08/29] feat: migrate from file dependency to GitHub Packages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add .npmrc configuration for GitHub Packages registry
- Update package.json: file:../lib-ts-time-tracking â ^4.2.0
- Add .npmrc.example for developer reference
- Update .gitignore to exclude .npmrc (but keep .npmrc.example)
- Update GitHub Actions workflow to install dependencies with GitHub token
- Add scope: @techdivision to setup-node action
This enables:
- â
Production-ready dependency management
- â
CI/CD compatibility
- â
Multi-developer support
- â
Automatic updates when lib is published
Developers need to:
1. Generate GitHub token with read:packages scope
2. Configure npm with: npm login --scope=@techdivision --registry=https://npm.pkg.github.com
3. Run: npm install
TypeScript compilation: â
No errors
---
.github/workflows/publish.yml | 8 +++++++-
.gitignore | 4 ++++
.npmrc.example | 10 ++++++++++
package.json | 2 +-
4 files changed, 22 insertions(+), 2 deletions(-)
create mode 100644 .npmrc.example
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 86300e5..e22e553 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -47,8 +47,14 @@ jobs:
with:
node-version: '20'
registry-url: 'https://npm.pkg.github.com'
+ scope: '@techdivision'
- - name: Publish to npm
+ - name: Install dependencies
+ run: npm ci
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Publish to GitHub Packages
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3e5ba3e..c080eae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,7 @@ node_modules/
.DS_Store
bun.lock
tmp/
+
+# NPM authentication (GitHub Packages)
+.npmrc
+!.npmrc.example
diff --git a/.npmrc.example b/.npmrc.example
new file mode 100644
index 0000000..40fd410
--- /dev/null
+++ b/.npmrc.example
@@ -0,0 +1,10 @@
+# GitHub Packages Configuration
+# Copy this file to .npmrc and replace YOUR_GITHUB_TOKEN with your actual token
+#
+# To generate a token:
+# 1. Go to https://github.com/settings/tokens/new
+# 2. Select scope: read:packages
+# 3. Copy the token and paste it below
+
+@techdivision:registry=https://npm.pkg.github.com
+//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
diff --git a/package.json b/package.json
index 51796ce..657f95f 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
+ "@techdivision/lib-ts-time-tracking": "^4.2.0"
},
"devDependencies": {
"@types/bun": "latest",
From 3940b8e459606b6d6a177c88e9ebbcdc38bbea62 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 11:21:59 +0200
Subject: [PATCH 09/29] fix: pass resolved ticket to SessionDataMapper for
correct LLM description
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem:
- CSV entries showed 'Ticket unbekannt:' in description even though issue_key was correct
- Root cause: resolved ticket was not passed to SessionDataMapper
- LLM couldn't see the ticket and generated 'Ticket unbekannt:' when ticket wasn't in conversation
Solution:
- Pass resolved.ticket from TicketResolver to SessionDataMapper.build()
- Update SessionDataMapper to accept ticket parameter
- Use config.ticket as priority over session.ticket
Impact:
- CSV descriptions now always include correct ticket key
- 'Ticket unbekannt:' only appears if ticket is truly unknown
- LLM has explicit ticket info for summary generation
Example:
Before: 'Ticket unbekannt: ĂberprĂŒfung des Installationsstatus...'
After: 'COPSPA-489: ĂberprĂŒfung des Installationsstatus...'
TypeScript compilation: â
No errors
---
src/adapters/SessionDataMapper.ts | 9 ++++++---
src/hooks/EventHook.ts | 1 +
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/src/adapters/SessionDataMapper.ts b/src/adapters/SessionDataMapper.ts
index 5942ea8..1a2d693 100644
--- a/src/adapters/SessionDataMapper.ts
+++ b/src/adapters/SessionDataMapper.ts
@@ -28,19 +28,22 @@ export class SessionDataMapper {
* @param session - The OpenCode session data
* @param client - The OpenCode SDK client for fetching conversation context
* @param sessionID - The session ID for fetching messages
- * @param config - Configuration including user email
+ * @param config - Configuration including user email and resolved ticket
* @returns SessionDataInterface ready for TimeTrackingFacade.track()
*
* @remarks
* The conversationContextProvider is built inline and will gracefully
* degrade if the SDK call fails. The lib's SessionSummaryGenerator
* will use activity-based fallback in that case.
+ *
+ * The ticket parameter allows passing a pre-resolved ticket key
+ * (from TicketResolver) to ensure the LLM includes it in the description.
*/
static build(
session: SessionData,
client: OpencodeClient,
sessionID: string,
- config: { userEmail?: string }
+ config: { userEmail?: string; ticket?: string | null }
): SessionDataInterface {
// Format model as "provider/modelID"
const modelString = session.model
@@ -87,7 +90,7 @@ export class SessionDataMapper {
},
activities: session.activities,
conversationContext: conversationContextProvider,
- ticket: session.ticket ?? undefined,
+ ticket: config.ticket ?? session.ticket ?? undefined,
}
}
}
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index 6481fda..69a8096 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -178,6 +178,7 @@ export function createEventHook(
// This replaces separate TitleGenerator and DescriptionGenerator calls
const sessionData = SessionDataMapper.build(session, client, sessionID, {
userEmail: config.user_email,
+ ticket: resolved.ticket,
})
// Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface
From 3e06821c160fb7ae910f5f45389f83f55ba5981d Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 14:59:05 +0200
Subject: [PATCH 10/29] fix: improve config handling and remove deprecated
track-time tool
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. ConfigMigration.ts: Enhanced resolveSummaryConfig()
- Add enabled-check: return undefined if enabled: false
- Add validation: require model and api_url for LLM
- Add logging: warn if config incomplete
- Prevents invalid LLM calls with incomplete config
2. EventHook.ts: Use pricing from opencode-project.json
- Read pricing from config instead of hardcoding
- Keep defaults as fallback if not configured
- Allows users to customize pricing per environment
3. EventHook.ts: Add null-checks for totalTokens
- Use ?? 0 for input, output, reasoning fields
- Prevents NaN in toast message if fields missing
- Handles Claude 3.5 reasoning token field gracefully
4. Remove deprecated tools/track-time.ts
- Tool was deprecated and had dead code
- CsvEntryData built but never used
- WriteResultInterface only for error message
- No equivalent in lib-ts-time-tracking
- Users should use automatic time tracking plugin instead
Impact:
- Summary generation now validates config properly
- Pricing can be customized per environment
- Toast messages always show valid token counts
- Cleaner codebase without deprecated tool
TypeScript compilation: â
No errors
---
src/hooks/EventHook.ts | 76 +++---
src/utils/ConfigMigration.ts | 23 +-
tools/track-time.ts | 483 -----------------------------------
3 files changed, 56 insertions(+), 526 deletions(-)
delete mode 100644 tools/track-time.ts
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index 69a8096..6c10715 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -185,43 +185,47 @@ export function createEventHook(
// Resolve summary config with backward compatibility (summary or title_generation)
const summaryConfig = resolveSummaryConfig(config)
+ // Build lib config with pricing from opencode-project.json
+ // Fallback to defaults if not configured
+ const defaultPricing = {
+ default: {
+ input: 0.003,
+ output: 0.015,
+ cache_read: 0.00075,
+ cache_write: 0.00375,
+ },
+ periods: [
+ {
+ from: "2024-01-01",
+ models: {
+ "claude-opus": {
+ input: 0.015,
+ output: 0.075,
+ cache_read: 0.00375,
+ cache_write: 0.01875,
+ },
+ "claude-sonnet": {
+ input: 0.003,
+ output: 0.015,
+ cache_read: 0.00075,
+ cache_write: 0.00375,
+ },
+ "claude-haiku": {
+ input: 0.00080,
+ output: 0.004,
+ cache_read: 0.0002,
+ cache_write: 0.001,
+ },
+ },
+ },
+ ],
+ }
+
const libConfig: TimeTrackingConfigInterface = {
defaults: config.global_default,
agents: config.agent_defaults,
csv: { output_path: config.csv_file },
- pricing: {
- default: {
- input: 0.003,
- output: 0.015,
- cache_read: 0.00075,
- cache_write: 0.00375,
- },
- periods: [
- {
- from: "2024-01-01",
- models: {
- "claude-opus": {
- input: 0.015,
- output: 0.075,
- cache_read: 0.00375,
- cache_write: 0.01875,
- },
- "claude-sonnet": {
- input: 0.003,
- output: 0.015,
- cache_read: 0.00075,
- cache_write: 0.00375,
- },
- "claude-haiku": {
- input: 0.00080,
- output: 0.004,
- cache_read: 0.0002,
- cache_write: 0.001,
- },
- },
- },
- ],
- },
+ pricing: (config as any).pricing || defaultPricing,
valid_projects: config.valid_projects || [],
...(summaryConfig && { summary: summaryConfig }),
}
@@ -249,9 +253,9 @@ export function createEventHook(
const durationSeconds = Math.round((Date.now() - session.startTime) / 1000)
const minutes = Math.round(durationSeconds / 60)
const totalTokens =
- session.tokenUsage.input +
- session.tokenUsage.output +
- session.tokenUsage.reasoning
+ (session.tokenUsage.input ?? 0) +
+ (session.tokenUsage.output ?? 0) +
+ (session.tokenUsage.reasoning ?? 0)
const failedWriters = results.filter((r) => !r.success)
let message = `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}`
diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts
index e9d9762..f602964 100644
--- a/src/utils/ConfigMigration.ts
+++ b/src/utils/ConfigMigration.ts
@@ -43,15 +43,24 @@ export function resolveSummaryConfig(
config: TimeTrackingJsonConfig
): SessionSummaryConfigInterface | undefined {
// Prefer new 'summary' field over deprecated 'title_generation'
- if (config.summary) {
- return config.summary
+ let summaryConfig = config.summary || (config as any).title_generation
+
+ if (!summaryConfig) {
+ return undefined
+ }
+
+ // Check if explicitly disabled
+ if (summaryConfig.enabled === false) {
+ return undefined
}
- // Fall back to deprecated 'title_generation' for backward compatibility
- if ((config as any).title_generation) {
- return (config as any).title_generation
+ // Validate required fields for LLM summary generation
+ if (!summaryConfig.model || !summaryConfig.api_url) {
+ console.warn(
+ "[TimeTracking] Summary config incomplete: model and api_url are required for LLM summary generation. Summary generation disabled."
+ )
+ return undefined
}
- // Neither field is present
- return undefined
+ return summaryConfig
}
diff --git a/tools/track-time.ts b/tools/track-time.ts
deleted file mode 100644
index edc7a27..0000000
--- a/tools/track-time.ts
+++ /dev/null
@@ -1,483 +0,0 @@
-import { tool } from "@opencode-ai/plugin"
-import fs from "fs"
-import path from "path"
-import os from "os"
-import { randomUUID } from "crypto"
-
-import type { CsvEntryData } from "../src/types/CsvEntryData"
-import type { WriteResultInterface } from "@techdivision/lib-ts-time-tracking"
-
-// Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking
-// For this tool, we'll use a simplified approach without them
-// since the main plugin uses TimeTrackingFacade
-
-interface TimeTrackingConfig {
- csv_file: string
- default_account_key: string
- user_email: string
- agent_defaults?: Record
- global_default?: { issue_key?: string; account_key?: string; author_email?: string }
-}
-
-interface ProjectConfig {
- time_tracking?: TimeTrackingConfig
-}
-
-/**
- * Parses duration string to seconds.
- * Supports: 30m, 1.5h, 1h30m, 01:30
- */
-function parseDuration(duration: string): number {
- // Format: HH:MM
- if (/^\d{1,2}:\d{2}$/.test(duration)) {
- const [hours, minutes] = duration.split(":").map(Number)
- return hours * 3600 + minutes * 60
- }
-
- // Format: 1h30m, 1.5h, 30m
- let totalSeconds = 0
-
- // Extract hours (1h, 1.5h)
- const hoursMatch = duration.match(/(\d+(?:\.\d+)?)\s*h/i)
- if (hoursMatch) {
- totalSeconds += parseFloat(hoursMatch[1]) * 3600
- }
-
- // Extract minutes (30m)
- const minutesMatch = duration.match(/(\d+)\s*m/i)
- if (minutesMatch) {
- totalSeconds += parseInt(minutesMatch[1], 10) * 60
- }
-
- return totalSeconds
-}
-
-/**
- * Formats seconds to human-readable duration.
- */
-function formatDuration(seconds: number): string {
- const hours = Math.floor(seconds / 3600)
- const minutes = Math.floor((seconds % 3600) / 60)
-
- if (hours > 0 && minutes > 0) {
- return `${hours}h ${minutes}m`
- } else if (hours > 0) {
- return `${hours}h`
- } else {
- return `${minutes}m`
- }
-}
-
-/**
- * Adds seconds to a time string (HH:MM:SS) and returns new time.
- * Returns { time: string, nextDay: boolean }
- */
-function addSecondsToTime(
- timeStr: string,
- seconds: number
-): { time: string; nextDay: boolean } {
- const [h, m, s] = timeStr.split(":").map(Number)
- let totalSeconds = h * 3600 + m * 60 + s + seconds
-
- const nextDay = totalSeconds >= 86400
- if (nextDay) {
- totalSeconds -= 86400
- }
-
- const newH = Math.floor(totalSeconds / 3600)
- const newM = Math.floor((totalSeconds % 3600) / 60)
- const newS = totalSeconds % 60
-
- return {
- time: `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}`,
- nextDay,
- }
-}
-
-/**
- * Adds days to a date string (YYYY-MM-DD).
- */
-function addDaysToDate(dateStr: string, days: number): string {
- const date = new Date(dateStr)
- date.setDate(date.getDate() + days)
- return date.toISOString().split("T")[0]
-}
-
-/**
- * Gets today's date in YYYY-MM-DD format.
- */
-function getTodayDate(): string {
- return new Date().toISOString().split("T")[0]
-}
-
-/**
- * Gets current time in HH:MM:SS format (with seconds).
- */
-function getCurrentTimeWithSeconds(): string {
- const now = new Date()
- return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
-}
-
-/**
- * Calculates duration in seconds between two times (HH:MM:SS format).
- * Throws error if endTime is before startTime.
- */
-function calculateDurationBetweenTimes(
- startTime: string,
- endTime: string
-): number {
- const [sh, sm, ss] = startTime.split(":").map(Number)
- const [eh, em, es] = endTime.split(":").map(Number)
-
- const startSeconds = sh * 3600 + sm * 60 + (ss || 0)
- const endSeconds = eh * 3600 + em * 60 + (es || 0)
-
- const duration = endSeconds - startSeconds
- if (duration < 0) {
- throw new Error(
- `Invalid time range: end_time of last entry (${startTime}) is after current time (${endTime}). Cannot calculate negative duration.`
- )
- }
- return duration
-}
-
-/**
- * Subtracts seconds from a time string (HH:MM:SS) and returns new time.
- */
-function subtractSecondsFromTime(timeStr: string, seconds: number): string {
- const [h, m, s] = timeStr.split(":").map(Number)
- let totalSeconds = h * 3600 + m * 60 + (s || 0) - seconds
-
- if (totalSeconds < 0) {
- totalSeconds = 0
- }
-
- const newH = Math.floor(totalSeconds / 3600)
- const newM = Math.floor((totalSeconds % 3600) / 60)
- const newS = totalSeconds % 60
-
- return `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}`
-}
-
-/**
- * Expands ~ to home directory.
- */
-function expandPath(filePath: string): string {
- if (filePath.startsWith("~/")) {
- return path.join(os.homedir(), filePath.slice(2))
- }
- return filePath
-}
-
-/**
- * Reads the last entry's end_time for today from the CSV file.
- */
-function getLastEndTimeToday(csvFile: string, today: string): string | null {
- if (!fs.existsSync(csvFile)) {
- return null
- }
-
- const content = fs.readFileSync(csvFile, "utf-8")
- const lines = content.trim().split("\n")
-
- // Skip header, iterate from end to find last entry for today
- for (let i = lines.length - 1; i >= 1; i--) {
- const line = lines[i]
- // Parse CSV - all fields are quoted
- const fields = line.match(/"([^"]*)"/g)?.map((f) => f.slice(1, -1)) || []
-
- if (fields.length >= 9) {
- const startDate = fields[1] // start_date is field index 1
- if (startDate === today) {
- const endTime = fields[8] // end_time is field index 8
- // Return full HH:MM:SS
- return endTime
- }
- }
- }
-
- return null
-}
-
-/**
- * Converts time string (HH:MM:SS) and date string (YYYY-MM-DD) to Unix timestamp in milliseconds.
- */
-function toTimestamp(dateStr: string, timeStr: string): number {
- const [year, month, day] = dateStr.split("-").map(Number)
- const [hours, minutes, seconds] = timeStr.split(":").map(Number)
- return new Date(year, month - 1, day, hours, minutes, seconds || 0).getTime()
-}
-
-export default tool({
- description:
- "Record a time tracking entry to CSV file and webhook. Automatically captures the calling agent. Requires config in .opencode/opencode-project.json with time_tracking section.",
- args: {
- issue_key: tool.schema
- .string()
- .optional()
- .describe("JIRA issue key e.g. PROJ-123 (default: from config)"),
- description: tool.schema
- .string()
- .optional()
- .describe("Work description (default: n/a)"),
- duration: tool.schema
- .string()
- .optional()
- .describe(
- "Duration: 30m, 1.5h, 1h30m, 01:30 (default: time since last entry or 15m)"
- ),
- start_time: tool.schema
- .string()
- .optional()
- .describe(
- "Start time HH:MM 24h format (default: end_time of last entry today)"
- ),
- account_key: tool.schema
- .string()
- .optional()
- .describe("Tempo account key (default: from config)"),
- model: tool.schema
- .string()
- .optional()
- .describe(
- "Model ID in format provider/model (e.g., anthropic/claude-opus-4-5)"
- ),
- },
- async execute(args, context) {
- const { agent, directory } = context
-
- // Defensive checks for required context values
- if (!directory) {
- throw new Error(
- "Missing 'directory' in tool context. This is an OpenCode internal error."
- )
- }
-
- // 1. Load project config from .opencode/opencode-project.json
- const projectConfigPath = path.join(
- directory,
- ".opencode",
- "opencode-project.json"
- )
- if (!fs.existsSync(projectConfigPath)) {
- throw new Error(
- `Configuration missing: ${projectConfigPath} not found. Please run /time-tracking.init first or create the file manually with a time_tracking section.`
- )
- }
-
- let projectConfig: ProjectConfig
- try {
- projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf-8"))
- } catch (e) {
- throw new Error(
- `Failed to parse ${projectConfigPath}: ${(e as Error).message}`
- )
- }
-
- const timeTracking = projectConfig.time_tracking
- if (!timeTracking) {
- throw new Error(
- `Missing time_tracking section in ${projectConfigPath}. Please run /time-tracking.init first.`
- )
- }
-
- if (!timeTracking.csv_file) {
- throw new Error(
- `Missing time_tracking.csv_file in ${projectConfigPath}. Please run /time-tracking.init.`
- )
- }
-
- if (!timeTracking.global_default) {
- throw new Error(
- `Missing time_tracking.global_default in ${projectConfigPath}. Please run /time-tracking.init.`
- )
- }
-
- // 2. Get model from argument
- const model = args.model || null
-
- // 3. Get user from environment
- const userEmail =
- process.env.OPENCODE_USER_EMAIL || os.userInfo().username || "unknown"
-
- // 4. Resolve defaults based on agent
- const agentName = agent || "unknown"
- const agentKey = agentName.startsWith("@") ? agentName : `@${agentName}`
- const agentDefaults = timeTracking.agent_defaults?.[agentKey]
- const globalDefault = timeTracking.global_default
-
- // 5. Parse and apply defaults for all arguments
-
- // Issue key (resolve first as it doesn't depend on time calculations)
- let issueKey: string | null = args.issue_key || null
- if (!issueKey) {
- issueKey =
- agentDefaults?.issue_key || globalDefault?.issue_key || null
- }
- if (issueKey && !/^[A-Z]+-\d+$/.test(issueKey)) {
- throw new Error(
- `Invalid issue_key format: ${issueKey}. Expected format like PROJ-123.`
- )
- }
-
- // Description
- const description = args.description || "n/a"
-
- // Account key
- let accountKey = args.account_key
- if (!accountKey) {
- accountKey = agentDefaults?.account_key || globalDefault.account_key || ""
- }
-
- // Time calculations
- const today = getTodayDate()
- const csvFile = expandPath(timeTracking.csv_file)
- const currentTime = getCurrentTimeWithSeconds() // Current time with seconds (HH:MM:SS)
-
- // Get last entry's end_time for today (HH:MM:SS)
- const lastEndTime = getLastEndTimeToday(csvFile, today)
-
- // Resolve start_time, duration, and end_time based on what's provided
- let startTimeFormatted: string
- let durationSeconds: number
- let endTimeFormatted: string
- let endDate = today
- const startDate = today
-
- if (args.duration) {
- // Duration explicitly provided - validate format
- if (
- !/^(\d+(?:\.\d+)?h)?(\d+m)?$|^\d{1,2}:\d{2}$|^\d+(?:\.\d+)?h$|^\d+m$/.test(
- args.duration
- )
- ) {
- throw new Error(
- `Invalid duration format: ${args.duration}. Use formats like: 30m, 1.5h, 1h30m, 01:30`
- )
- }
- durationSeconds = parseDuration(args.duration)
-
- if (args.start_time) {
- // Both duration and start_time provided - calculate end_time
- if (!/^\d{2}:\d{2}$/.test(args.start_time)) {
- throw new Error(
- `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).`
- )
- }
- startTimeFormatted = `${args.start_time}:00`
- const result = addSecondsToTime(startTimeFormatted, durationSeconds)
- endTimeFormatted = result.time
- if (result.nextDay) {
- endDate = addDaysToDate(startDate, 1)
- }
- } else {
- // Duration provided, no start_time - end at current time, calculate start
- endTimeFormatted = currentTime
- startTimeFormatted = subtractSecondsFromTime(
- currentTime,
- durationSeconds
- )
- }
- } else {
- // No duration provided - use smart calculation
- if (args.start_time) {
- // start_time provided, no duration - end at current time, calculate duration
- if (!/^\d{2}:\d{2}$/.test(args.start_time)) {
- throw new Error(
- `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).`
- )
- }
- startTimeFormatted = `${args.start_time}:00`
- endTimeFormatted = currentTime
- durationSeconds = calculateDurationBetweenTimes(
- startTimeFormatted,
- endTimeFormatted
- )
- } else if (lastEndTime) {
- // No duration, no start_time, but have last entry - seamless continuation
- // start_time = last end_time, end_time = now, duration = difference
- startTimeFormatted = lastEndTime
- endTimeFormatted = currentTime
- durationSeconds = calculateDurationBetweenTimes(
- startTimeFormatted,
- endTimeFormatted
- )
- } else {
- // No duration, no start_time, no last entry - fallback to 15m
- durationSeconds = parseDuration("15m")
- endTimeFormatted = currentTime
- startTimeFormatted = subtractSecondsFromTime(
- currentTime,
- durationSeconds
- )
- }
- }
-
- // 6. Resolve author_email with fallback hierarchy
- const authorEmail =
- agentDefaults?.author_email ||
- globalDefault?.author_email ||
- userEmail
-
- // 7. Build CsvEntryData for WriterService
- const entryData: CsvEntryData = {
- id: randomUUID(),
- userEmail,
- ticket: issueKey,
- accountKey,
- authorEmail,
- startTime: toTimestamp(startDate, startTimeFormatted),
- endTime: toTimestamp(endDate, endTimeFormatted),
- durationSeconds,
- description,
- notes: "Manual entry",
- tokenUsage: {
- input: 0,
- output: 0,
- reasoning: 0,
- cacheRead: 0,
- cacheWrite: 0,
- },
- cost: 0,
- model,
- agent: agentName,
- }
-
- // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking
- // This tool is deprecated in favor of the automatic time tracking via the plugin
- // For manual entries, use the plugin's configuration instead
-
- // For now, return a message indicating the tool should use the plugin
- const results: WriteResultInterface[] = []
- const allSucceeded = false
- const failedWriters: WriteResultInterface[] = [{
- success: false,
- writer: "track-time",
- error: "This tool is deprecated. Use the automatic time tracking plugin instead."
- }]
-
- return JSON.stringify({
- success: allSucceeded,
- entry: {
- id: entryData.id,
- issue_key: issueKey,
- date: startDate,
- start_time: startTimeFormatted,
- end_time: endTimeFormatted,
- duration: formatDuration(durationSeconds),
- duration_seconds: durationSeconds,
- account_key: accountKey,
- description,
- model,
- agent: agentName,
- csv_file: csvFile,
- },
- writers: results,
- ...(failedWriters.length > 0 && {
- warnings: failedWriters.map(
- (r) => `${r.writer}: ${r.error || "unknown error"}`
- ),
- }),
- })
- },
-})
From 22a3a48d75b6b9aeb73ace4ceedb5fb6ed975066 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 15:08:17 +0200
Subject: [PATCH 11/29] COPSPA-489: Fix npm
---
.gitignore | 4 ----
.npmrc | 3 +++
.npmrc.example | 10 ----------
3 files changed, 3 insertions(+), 14 deletions(-)
create mode 100644 .npmrc
delete mode 100644 .npmrc.example
diff --git a/.gitignore b/.gitignore
index c080eae..3e5ba3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,3 @@ node_modules/
.DS_Store
bun.lock
tmp/
-
-# NPM authentication (GitHub Packages)
-.npmrc
-!.npmrc.example
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..3fff4d7
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,3 @@
+@techdivision:registry=https://npm.pkg.github.com
+//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
+
diff --git a/.npmrc.example b/.npmrc.example
deleted file mode 100644
index 40fd410..0000000
--- a/.npmrc.example
+++ /dev/null
@@ -1,10 +0,0 @@
-# GitHub Packages Configuration
-# Copy this file to .npmrc and replace YOUR_GITHUB_TOKEN with your actual token
-#
-# To generate a token:
-# 1. Go to https://github.com/settings/tokens/new
-# 2. Select scope: read:packages
-# 3. Copy the token and paste it below
-
-@techdivision:registry=https://npm.pkg.github.com
-//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
From 359debc4eaf2629ee0fe311048f9f685ee235246 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 16:07:21 +0200
Subject: [PATCH 12/29] COPSP-489: Revert
---
tools/track-time.ts | 483 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 483 insertions(+)
create mode 100644 tools/track-time.ts
diff --git a/tools/track-time.ts b/tools/track-time.ts
new file mode 100644
index 0000000..edc7a27
--- /dev/null
+++ b/tools/track-time.ts
@@ -0,0 +1,483 @@
+import { tool } from "@opencode-ai/plugin"
+import fs from "fs"
+import path from "path"
+import os from "os"
+import { randomUUID } from "crypto"
+
+import type { CsvEntryData } from "../src/types/CsvEntryData"
+import type { WriteResultInterface } from "@techdivision/lib-ts-time-tracking"
+
+// Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking
+// For this tool, we'll use a simplified approach without them
+// since the main plugin uses TimeTrackingFacade
+
+interface TimeTrackingConfig {
+ csv_file: string
+ default_account_key: string
+ user_email: string
+ agent_defaults?: Record
+ global_default?: { issue_key?: string; account_key?: string; author_email?: string }
+}
+
+interface ProjectConfig {
+ time_tracking?: TimeTrackingConfig
+}
+
+/**
+ * Parses duration string to seconds.
+ * Supports: 30m, 1.5h, 1h30m, 01:30
+ */
+function parseDuration(duration: string): number {
+ // Format: HH:MM
+ if (/^\d{1,2}:\d{2}$/.test(duration)) {
+ const [hours, minutes] = duration.split(":").map(Number)
+ return hours * 3600 + minutes * 60
+ }
+
+ // Format: 1h30m, 1.5h, 30m
+ let totalSeconds = 0
+
+ // Extract hours (1h, 1.5h)
+ const hoursMatch = duration.match(/(\d+(?:\.\d+)?)\s*h/i)
+ if (hoursMatch) {
+ totalSeconds += parseFloat(hoursMatch[1]) * 3600
+ }
+
+ // Extract minutes (30m)
+ const minutesMatch = duration.match(/(\d+)\s*m/i)
+ if (minutesMatch) {
+ totalSeconds += parseInt(minutesMatch[1], 10) * 60
+ }
+
+ return totalSeconds
+}
+
+/**
+ * Formats seconds to human-readable duration.
+ */
+function formatDuration(seconds: number): string {
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+
+ if (hours > 0 && minutes > 0) {
+ return `${hours}h ${minutes}m`
+ } else if (hours > 0) {
+ return `${hours}h`
+ } else {
+ return `${minutes}m`
+ }
+}
+
+/**
+ * Adds seconds to a time string (HH:MM:SS) and returns new time.
+ * Returns { time: string, nextDay: boolean }
+ */
+function addSecondsToTime(
+ timeStr: string,
+ seconds: number
+): { time: string; nextDay: boolean } {
+ const [h, m, s] = timeStr.split(":").map(Number)
+ let totalSeconds = h * 3600 + m * 60 + s + seconds
+
+ const nextDay = totalSeconds >= 86400
+ if (nextDay) {
+ totalSeconds -= 86400
+ }
+
+ const newH = Math.floor(totalSeconds / 3600)
+ const newM = Math.floor((totalSeconds % 3600) / 60)
+ const newS = totalSeconds % 60
+
+ return {
+ time: `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}`,
+ nextDay,
+ }
+}
+
+/**
+ * Adds days to a date string (YYYY-MM-DD).
+ */
+function addDaysToDate(dateStr: string, days: number): string {
+ const date = new Date(dateStr)
+ date.setDate(date.getDate() + days)
+ return date.toISOString().split("T")[0]
+}
+
+/**
+ * Gets today's date in YYYY-MM-DD format.
+ */
+function getTodayDate(): string {
+ return new Date().toISOString().split("T")[0]
+}
+
+/**
+ * Gets current time in HH:MM:SS format (with seconds).
+ */
+function getCurrentTimeWithSeconds(): string {
+ const now = new Date()
+ return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
+}
+
+/**
+ * Calculates duration in seconds between two times (HH:MM:SS format).
+ * Throws error if endTime is before startTime.
+ */
+function calculateDurationBetweenTimes(
+ startTime: string,
+ endTime: string
+): number {
+ const [sh, sm, ss] = startTime.split(":").map(Number)
+ const [eh, em, es] = endTime.split(":").map(Number)
+
+ const startSeconds = sh * 3600 + sm * 60 + (ss || 0)
+ const endSeconds = eh * 3600 + em * 60 + (es || 0)
+
+ const duration = endSeconds - startSeconds
+ if (duration < 0) {
+ throw new Error(
+ `Invalid time range: end_time of last entry (${startTime}) is after current time (${endTime}). Cannot calculate negative duration.`
+ )
+ }
+ return duration
+}
+
+/**
+ * Subtracts seconds from a time string (HH:MM:SS) and returns new time.
+ */
+function subtractSecondsFromTime(timeStr: string, seconds: number): string {
+ const [h, m, s] = timeStr.split(":").map(Number)
+ let totalSeconds = h * 3600 + m * 60 + (s || 0) - seconds
+
+ if (totalSeconds < 0) {
+ totalSeconds = 0
+ }
+
+ const newH = Math.floor(totalSeconds / 3600)
+ const newM = Math.floor((totalSeconds % 3600) / 60)
+ const newS = totalSeconds % 60
+
+ return `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}`
+}
+
+/**
+ * Expands ~ to home directory.
+ */
+function expandPath(filePath: string): string {
+ if (filePath.startsWith("~/")) {
+ return path.join(os.homedir(), filePath.slice(2))
+ }
+ return filePath
+}
+
+/**
+ * Reads the last entry's end_time for today from the CSV file.
+ */
+function getLastEndTimeToday(csvFile: string, today: string): string | null {
+ if (!fs.existsSync(csvFile)) {
+ return null
+ }
+
+ const content = fs.readFileSync(csvFile, "utf-8")
+ const lines = content.trim().split("\n")
+
+ // Skip header, iterate from end to find last entry for today
+ for (let i = lines.length - 1; i >= 1; i--) {
+ const line = lines[i]
+ // Parse CSV - all fields are quoted
+ const fields = line.match(/"([^"]*)"/g)?.map((f) => f.slice(1, -1)) || []
+
+ if (fields.length >= 9) {
+ const startDate = fields[1] // start_date is field index 1
+ if (startDate === today) {
+ const endTime = fields[8] // end_time is field index 8
+ // Return full HH:MM:SS
+ return endTime
+ }
+ }
+ }
+
+ return null
+}
+
+/**
+ * Converts time string (HH:MM:SS) and date string (YYYY-MM-DD) to Unix timestamp in milliseconds.
+ */
+function toTimestamp(dateStr: string, timeStr: string): number {
+ const [year, month, day] = dateStr.split("-").map(Number)
+ const [hours, minutes, seconds] = timeStr.split(":").map(Number)
+ return new Date(year, month - 1, day, hours, minutes, seconds || 0).getTime()
+}
+
+export default tool({
+ description:
+ "Record a time tracking entry to CSV file and webhook. Automatically captures the calling agent. Requires config in .opencode/opencode-project.json with time_tracking section.",
+ args: {
+ issue_key: tool.schema
+ .string()
+ .optional()
+ .describe("JIRA issue key e.g. PROJ-123 (default: from config)"),
+ description: tool.schema
+ .string()
+ .optional()
+ .describe("Work description (default: n/a)"),
+ duration: tool.schema
+ .string()
+ .optional()
+ .describe(
+ "Duration: 30m, 1.5h, 1h30m, 01:30 (default: time since last entry or 15m)"
+ ),
+ start_time: tool.schema
+ .string()
+ .optional()
+ .describe(
+ "Start time HH:MM 24h format (default: end_time of last entry today)"
+ ),
+ account_key: tool.schema
+ .string()
+ .optional()
+ .describe("Tempo account key (default: from config)"),
+ model: tool.schema
+ .string()
+ .optional()
+ .describe(
+ "Model ID in format provider/model (e.g., anthropic/claude-opus-4-5)"
+ ),
+ },
+ async execute(args, context) {
+ const { agent, directory } = context
+
+ // Defensive checks for required context values
+ if (!directory) {
+ throw new Error(
+ "Missing 'directory' in tool context. This is an OpenCode internal error."
+ )
+ }
+
+ // 1. Load project config from .opencode/opencode-project.json
+ const projectConfigPath = path.join(
+ directory,
+ ".opencode",
+ "opencode-project.json"
+ )
+ if (!fs.existsSync(projectConfigPath)) {
+ throw new Error(
+ `Configuration missing: ${projectConfigPath} not found. Please run /time-tracking.init first or create the file manually with a time_tracking section.`
+ )
+ }
+
+ let projectConfig: ProjectConfig
+ try {
+ projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf-8"))
+ } catch (e) {
+ throw new Error(
+ `Failed to parse ${projectConfigPath}: ${(e as Error).message}`
+ )
+ }
+
+ const timeTracking = projectConfig.time_tracking
+ if (!timeTracking) {
+ throw new Error(
+ `Missing time_tracking section in ${projectConfigPath}. Please run /time-tracking.init first.`
+ )
+ }
+
+ if (!timeTracking.csv_file) {
+ throw new Error(
+ `Missing time_tracking.csv_file in ${projectConfigPath}. Please run /time-tracking.init.`
+ )
+ }
+
+ if (!timeTracking.global_default) {
+ throw new Error(
+ `Missing time_tracking.global_default in ${projectConfigPath}. Please run /time-tracking.init.`
+ )
+ }
+
+ // 2. Get model from argument
+ const model = args.model || null
+
+ // 3. Get user from environment
+ const userEmail =
+ process.env.OPENCODE_USER_EMAIL || os.userInfo().username || "unknown"
+
+ // 4. Resolve defaults based on agent
+ const agentName = agent || "unknown"
+ const agentKey = agentName.startsWith("@") ? agentName : `@${agentName}`
+ const agentDefaults = timeTracking.agent_defaults?.[agentKey]
+ const globalDefault = timeTracking.global_default
+
+ // 5. Parse and apply defaults for all arguments
+
+ // Issue key (resolve first as it doesn't depend on time calculations)
+ let issueKey: string | null = args.issue_key || null
+ if (!issueKey) {
+ issueKey =
+ agentDefaults?.issue_key || globalDefault?.issue_key || null
+ }
+ if (issueKey && !/^[A-Z]+-\d+$/.test(issueKey)) {
+ throw new Error(
+ `Invalid issue_key format: ${issueKey}. Expected format like PROJ-123.`
+ )
+ }
+
+ // Description
+ const description = args.description || "n/a"
+
+ // Account key
+ let accountKey = args.account_key
+ if (!accountKey) {
+ accountKey = agentDefaults?.account_key || globalDefault.account_key || ""
+ }
+
+ // Time calculations
+ const today = getTodayDate()
+ const csvFile = expandPath(timeTracking.csv_file)
+ const currentTime = getCurrentTimeWithSeconds() // Current time with seconds (HH:MM:SS)
+
+ // Get last entry's end_time for today (HH:MM:SS)
+ const lastEndTime = getLastEndTimeToday(csvFile, today)
+
+ // Resolve start_time, duration, and end_time based on what's provided
+ let startTimeFormatted: string
+ let durationSeconds: number
+ let endTimeFormatted: string
+ let endDate = today
+ const startDate = today
+
+ if (args.duration) {
+ // Duration explicitly provided - validate format
+ if (
+ !/^(\d+(?:\.\d+)?h)?(\d+m)?$|^\d{1,2}:\d{2}$|^\d+(?:\.\d+)?h$|^\d+m$/.test(
+ args.duration
+ )
+ ) {
+ throw new Error(
+ `Invalid duration format: ${args.duration}. Use formats like: 30m, 1.5h, 1h30m, 01:30`
+ )
+ }
+ durationSeconds = parseDuration(args.duration)
+
+ if (args.start_time) {
+ // Both duration and start_time provided - calculate end_time
+ if (!/^\d{2}:\d{2}$/.test(args.start_time)) {
+ throw new Error(
+ `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).`
+ )
+ }
+ startTimeFormatted = `${args.start_time}:00`
+ const result = addSecondsToTime(startTimeFormatted, durationSeconds)
+ endTimeFormatted = result.time
+ if (result.nextDay) {
+ endDate = addDaysToDate(startDate, 1)
+ }
+ } else {
+ // Duration provided, no start_time - end at current time, calculate start
+ endTimeFormatted = currentTime
+ startTimeFormatted = subtractSecondsFromTime(
+ currentTime,
+ durationSeconds
+ )
+ }
+ } else {
+ // No duration provided - use smart calculation
+ if (args.start_time) {
+ // start_time provided, no duration - end at current time, calculate duration
+ if (!/^\d{2}:\d{2}$/.test(args.start_time)) {
+ throw new Error(
+ `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).`
+ )
+ }
+ startTimeFormatted = `${args.start_time}:00`
+ endTimeFormatted = currentTime
+ durationSeconds = calculateDurationBetweenTimes(
+ startTimeFormatted,
+ endTimeFormatted
+ )
+ } else if (lastEndTime) {
+ // No duration, no start_time, but have last entry - seamless continuation
+ // start_time = last end_time, end_time = now, duration = difference
+ startTimeFormatted = lastEndTime
+ endTimeFormatted = currentTime
+ durationSeconds = calculateDurationBetweenTimes(
+ startTimeFormatted,
+ endTimeFormatted
+ )
+ } else {
+ // No duration, no start_time, no last entry - fallback to 15m
+ durationSeconds = parseDuration("15m")
+ endTimeFormatted = currentTime
+ startTimeFormatted = subtractSecondsFromTime(
+ currentTime,
+ durationSeconds
+ )
+ }
+ }
+
+ // 6. Resolve author_email with fallback hierarchy
+ const authorEmail =
+ agentDefaults?.author_email ||
+ globalDefault?.author_email ||
+ userEmail
+
+ // 7. Build CsvEntryData for WriterService
+ const entryData: CsvEntryData = {
+ id: randomUUID(),
+ userEmail,
+ ticket: issueKey,
+ accountKey,
+ authorEmail,
+ startTime: toTimestamp(startDate, startTimeFormatted),
+ endTime: toTimestamp(endDate, endTimeFormatted),
+ durationSeconds,
+ description,
+ notes: "Manual entry",
+ tokenUsage: {
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ },
+ cost: 0,
+ model,
+ agent: agentName,
+ }
+
+ // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking
+ // This tool is deprecated in favor of the automatic time tracking via the plugin
+ // For manual entries, use the plugin's configuration instead
+
+ // For now, return a message indicating the tool should use the plugin
+ const results: WriteResultInterface[] = []
+ const allSucceeded = false
+ const failedWriters: WriteResultInterface[] = [{
+ success: false,
+ writer: "track-time",
+ error: "This tool is deprecated. Use the automatic time tracking plugin instead."
+ }]
+
+ return JSON.stringify({
+ success: allSucceeded,
+ entry: {
+ id: entryData.id,
+ issue_key: issueKey,
+ date: startDate,
+ start_time: startTimeFormatted,
+ end_time: endTimeFormatted,
+ duration: formatDuration(durationSeconds),
+ duration_seconds: durationSeconds,
+ account_key: accountKey,
+ description,
+ model,
+ agent: agentName,
+ csv_file: csvFile,
+ },
+ writers: results,
+ ...(failedWriters.length > 0 && {
+ warnings: failedWriters.map(
+ (r) => `${r.writer}: ${r.error || "unknown error"}`
+ ),
+ }),
+ })
+ },
+})
From dc57aff24c42181c6e8d86d40d36b5b9d1e9df32 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Fri, 24 Apr 2026 16:34:26 +0200
Subject: [PATCH 13/29] COPSPA-489
---
src/utils/ConfigMigration.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts
index f602964..78b65d9 100644
--- a/src/utils/ConfigMigration.ts
+++ b/src/utils/ConfigMigration.ts
@@ -43,7 +43,8 @@ export function resolveSummaryConfig(
config: TimeTrackingJsonConfig
): SessionSummaryConfigInterface | undefined {
// Prefer new 'summary' field over deprecated 'title_generation'
- let summaryConfig = config.summary || (config as any).title_generation
+ // Use (config as any) to bypass TypeScript type checking and ?? for nullish coalescing
+ let summaryConfig = (config as any).summary ?? (config as any).title_generation
if (!summaryConfig) {
return undefined
From a0b84ad479aeb9828827bda51e8eb42ccf937d64 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 09:36:31 +0200
Subject: [PATCH 14/29] chore: use local lib-ts-time-tracking with
checkAvailability fix
Updated to use the local lib-ts-time-tracking which includes the fix for
returning actual summary generator availability status instead of hardcoded false.
This resolves the 'title generation NOT available' toast message issue.
---
package-lock.json | 158 +++++++++++++++++++++++++---------------------
package.json | 10 ++-
2 files changed, 94 insertions(+), 74 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 86fdd4b..b678260 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#5fce0dc"
+ "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
},
"devDependencies": {
"@types/bun": "latest",
@@ -21,6 +21,24 @@
"bun": ">=1.0.0"
}
},
+ "../lib-ts-time-tracking": {
+ "name": "@techdivision/lib-ts-time-tracking",
+ "version": "4.2.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@amiceli/vitest-cucumber": "^5.2.1",
+ "@types/node": "^22.0.0",
+ "eslint": "^10.1.0",
+ "eslint-config-prettier": "^10.1.8",
+ "prettier": "^3.8.1",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.57.2",
+ "vitest": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@opencode-ai/plugin": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz",
@@ -38,9 +56,9 @@
"license": "MIT"
},
"node_modules/@oven/bun-darwin-aarch64": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
- "integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.13.tgz",
+ "integrity": "sha512-qAS6Hg8Q14ckfBuqJ2Zh7gBQSVSUHeibSq4OFqBTv6DzyJuxYlr0sdYQzmYmnbPxbqobekqUDTa/4XEaqRi7vg==",
"cpu": [
"arm64"
],
@@ -52,9 +70,9 @@
"peer": true
},
"node_modules/@oven/bun-darwin-x64": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz",
- "integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.13.tgz",
+ "integrity": "sha512-kGePeDD4IN4imo+H4uLjQGZLmvyYQg+nKr2P0nt4ksXXrWA4HE+mb0/TUPHfRI127DocXQpew+fvrHuHR5mpJQ==",
"cpu": [
"x64"
],
@@ -66,9 +84,9 @@
"peer": true
},
"node_modules/@oven/bun-darwin-x64-baseline": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz",
- "integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.13.tgz",
+ "integrity": "sha512-gMEQayUpmCPYaE9zkNBj9TiQqHupnhjOYcuSzxFjzIjHJBUO4VjNnrpbKVeXNs+rKHFothORDd2QKquu5paSPQ==",
"cpu": [
"x64"
],
@@ -80,9 +98,9 @@
"peer": true
},
"node_modules/@oven/bun-linux-aarch64": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz",
- "integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.13.tgz",
+ "integrity": "sha512-NbLOJdr+RBFO1vFZ2YUFg4oVJ+2ua6zrwo4ZWRs0jKKcGJWtbY2wY5uz+i0PkwH6b9HYaYDgVTzE4ev06ncYZw==",
"cpu": [
"arm64"
],
@@ -94,9 +112,9 @@
"peer": true
},
"node_modules/@oven/bun-linux-aarch64-musl": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz",
- "integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.13.tgz",
+ "integrity": "sha512-UV9EE18VE5aRhWtV2L6MTAGGn3slhJJ2OW/m+FJM15maHm0qf1V7TaZY0FovxhdQRvnklSiQ7Ntv0H5TUX4w0g==",
"cpu": [
"arm64"
],
@@ -108,9 +126,9 @@
"peer": true
},
"node_modules/@oven/bun-linux-x64": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz",
- "integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.13.tgz",
+ "integrity": "sha512-UwttIUXoe9fS+40OcjoaRHgZw+HCPFqBVWEXkXqAJ3W7wA0XPZrWsoMAD9sGh3TaLqrwdiMo5xPogwpXhOtVXA==",
"cpu": [
"x64"
],
@@ -122,9 +140,9 @@
"peer": true
},
"node_modules/@oven/bun-linux-x64-baseline": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz",
- "integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.13.tgz",
+ "integrity": "sha512-fOi4ziKzgJG4UrrNd4AicBs6Fu9GY5xOqg+9tC76nuZNDAdSh6++kzab6TNi1Ck0Yzq6zIBIdGit6/0uSbBn8A==",
"cpu": [
"x64"
],
@@ -136,9 +154,9 @@
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz",
- "integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.13.tgz",
+ "integrity": "sha512-+VHhE44kEjCXcTFHyc81zfTxL9+vzh9RqIh7gM1iWNhxpctD9kzntbUkP3UTFTwwNjoou1o8VRyxQafvc4OepA==",
"cpu": [
"x64"
],
@@ -150,9 +168,9 @@
"peer": true
},
"node_modules/@oven/bun-linux-x64-musl-baseline": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz",
- "integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.13.tgz",
+ "integrity": "sha512-fqBKuiiWLEu2dVkowZaXgKS98xfrvBqivdoxRtRP3eINcpI1dcelGbsOz+Xphn7tbGAuBiE1/0AelvvvdqS9rg==",
"cpu": [
"x64"
],
@@ -164,9 +182,9 @@
"peer": true
},
"node_modules/@oven/bun-windows-aarch64": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz",
- "integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.13.tgz",
+ "integrity": "sha512-+EvdRWRCRg95Xea4M2lqSJFTjzQBTJDQTMlbG8bmwFkVTN16MdmSH7xhfxVQWUOyZBLEpIwuNFIlBBxVCwSUyQ==",
"cpu": [
"arm64"
],
@@ -178,9 +196,9 @@
"peer": true
},
"node_modules/@oven/bun-windows-x64": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz",
- "integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.13.tgz",
+ "integrity": "sha512-vqDEFX63ZZQF3YstPSpPD+RxNm5AILPdUuuKpNwsj7ld4NjhdHUYkAmLXDtKNWt9JMRL10bop//W8faY/LV+RQ==",
"cpu": [
"x64"
],
@@ -192,9 +210,9 @@
"peer": true
},
"node_modules/@oven/bun-windows-x64-baseline": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz",
- "integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.13.tgz",
+ "integrity": "sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA==",
"cpu": [
"x64"
],
@@ -206,37 +224,33 @@
"peer": true
},
"node_modules/@techdivision/lib-ts-time-tracking": {
- "version": "4.2.0",
- "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#7ab7d77b259bc22af12949eb01b4e6b2a0ad9dcb",
- "license": "MIT",
- "engines": {
- "node": ">=20"
- }
+ "resolved": "../lib-ts-time-tracking",
+ "link": true
},
"node_modules/@types/bun": {
- "version": "1.3.9",
- "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz",
- "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.13.tgz",
+ "integrity": "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "bun-types": "1.3.9"
+ "bun-types": "1.3.13"
}
},
"node_modules/@types/node": {
- "version": "25.3.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
- "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.18.0"
+ "undici-types": "~7.19.0"
}
},
"node_modules/bun": {
- "version": "1.3.10",
- "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz",
- "integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.13.tgz",
+ "integrity": "sha512-b9T4xZ8KqCHs4+TkHJv540LG1B8OD7noKu0Qaizusx3jFtMDHY6osNqgbaOlwW2B8RB2AKzz+sjzlGKIGxIjZw==",
"cpu": [
"arm64",
"x64"
@@ -254,24 +268,24 @@
"bunx": "bin/bunx.exe"
},
"optionalDependencies": {
- "@oven/bun-darwin-aarch64": "1.3.10",
- "@oven/bun-darwin-x64": "1.3.10",
- "@oven/bun-darwin-x64-baseline": "1.3.10",
- "@oven/bun-linux-aarch64": "1.3.10",
- "@oven/bun-linux-aarch64-musl": "1.3.10",
- "@oven/bun-linux-x64": "1.3.10",
- "@oven/bun-linux-x64-baseline": "1.3.10",
- "@oven/bun-linux-x64-musl": "1.3.10",
- "@oven/bun-linux-x64-musl-baseline": "1.3.10",
- "@oven/bun-windows-aarch64": "1.3.10",
- "@oven/bun-windows-x64": "1.3.10",
- "@oven/bun-windows-x64-baseline": "1.3.10"
+ "@oven/bun-darwin-aarch64": "1.3.13",
+ "@oven/bun-darwin-x64": "1.3.13",
+ "@oven/bun-darwin-x64-baseline": "1.3.13",
+ "@oven/bun-linux-aarch64": "1.3.13",
+ "@oven/bun-linux-aarch64-musl": "1.3.13",
+ "@oven/bun-linux-x64": "1.3.13",
+ "@oven/bun-linux-x64-baseline": "1.3.13",
+ "@oven/bun-linux-x64-musl": "1.3.13",
+ "@oven/bun-linux-x64-musl-baseline": "1.3.13",
+ "@oven/bun-windows-aarch64": "1.3.13",
+ "@oven/bun-windows-x64": "1.3.13",
+ "@oven/bun-windows-x64-baseline": "1.3.13"
}
},
"node_modules/bun-types": {
- "version": "1.3.9",
- "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz",
- "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==",
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.13.tgz",
+ "integrity": "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -293,9 +307,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
diff --git a/package.json b/package.json
index 657f95f..0d49c40 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,13 @@
"scripts/",
"plugin.json"
],
- "keywords": ["opencode", "plugin", "time-tracking", "jira", "tempo"],
+ "keywords": [
+ "opencode",
+ "plugin",
+ "time-tracking",
+ "jira",
+ "tempo"
+ ],
"opencode": {
"plugin": true,
"category": "standard"
@@ -27,7 +33,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "^4.2.0"
+ "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
},
"devDependencies": {
"@types/bun": "latest",
From 95031051c2d0998a50343cf2bc7da9be25b5fe95 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 09:41:37 +0200
Subject: [PATCH 15/29] fix: resolve environment variables in configuration
Add EnvResolver utility to resolve {env:VAR_NAME} placeholders in config values.
This ensures that environment variables like TT_AGENT_API_KEY are properly
resolved before being passed to the TimeTrackingFacade.
Changes:
- Create EnvResolver.ts with resolveEnvVar() and resolveEnvVarsInObject()
- Update ConfigLoader to resolve env vars in loaded config
- Recursively resolves nested objects and arrays
This fixes the issue where api_key was still {env:TT_AGENT_API_KEY} instead
of the actual API key value, causing LLM summary generation to fail.
---
src/services/ConfigLoader.ts | 6 ++-
src/utils/EnvResolver.ts | 80 ++++++++++++++++++++++++++++++++++++
2 files changed, 85 insertions(+), 1 deletion(-)
create mode 100644 src/utils/EnvResolver.ts
diff --git a/src/services/ConfigLoader.ts b/src/services/ConfigLoader.ts
index 119c411..0e96ebb 100644
--- a/src/services/ConfigLoader.ts
+++ b/src/services/ConfigLoader.ts
@@ -8,6 +8,7 @@ import type {
OpencodeProjectConfig,
TimeTrackingConfig,
} from "../types/TimeTrackingConfig"
+import { resolveEnvVarsInObject } from "../utils/EnvResolver"
import "../types/Bun"
@@ -55,13 +56,16 @@ export class ConfigLoader {
if (projectConfig.time_tracking) {
const jsonConfig = projectConfig.time_tracking
+ // Resolve environment variables in config (e.g., {env:TT_AGENT_API_KEY})
+ const resolvedConfig = resolveEnvVarsInObject(jsonConfig)
+
// Resolve user_email with fallback chain:
// 1. Environment variable (loaded by opencode-plugin-shell-env from .env)
// 2. System username
const userEmail = process.env[ENV_USER_EMAIL] || userInfo().username
return {
- ...jsonConfig,
+ ...resolvedConfig,
user_email: userEmail,
}
}
diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts
new file mode 100644
index 0000000..bad57f8
--- /dev/null
+++ b/src/utils/EnvResolver.ts
@@ -0,0 +1,80 @@
+/**
+ * @fileoverview Environment variable resolver for configuration values.
+ */
+
+/**
+ * Resolves environment variable placeholders in configuration values.
+ *
+ * @remarks
+ * Replaces placeholders like `{env:VAR_NAME}` with actual environment variable values.
+ * If the environment variable is not set, returns the original placeholder.
+ *
+ * @param value - The value that may contain env placeholders
+ * @returns The resolved value with env vars substituted
+ *
+ * @example
+ * ```typescript
+ * process.env.API_KEY = "secret123"
+ * resolveEnvVar("{env:API_KEY}") // Returns "secret123"
+ * resolveEnvVar("normal-value") // Returns "normal-value"
+ * ```
+ */
+export function resolveEnvVar(value: string | undefined): string | undefined {
+ if (!value) return value
+
+ // Match {env:VAR_NAME} pattern
+ const envPattern = /\{env:([^}]+)\}/g
+
+ return value.replace(envPattern, (match, varName) => {
+ const envValue = process.env[varName]
+ if (envValue) {
+ return envValue
+ }
+ // If env var not found, return the original placeholder
+ return match
+ })
+}
+
+/**
+ * Recursively resolves environment variables in an object.
+ *
+ * @remarks
+ * Walks through all string values in an object and resolves env placeholders.
+ * Non-string values are left unchanged.
+ *
+ * @param obj - The object to resolve
+ * @returns A new object with resolved env vars
+ *
+ * @example
+ * ```typescript
+ * const config = {
+ * api_key: "{env:API_KEY}",
+ * api_url: "https://api.example.com",
+ * nested: {
+ * token: "{env:TOKEN}"
+ * }
+ * }
+ * const resolved = resolveEnvVarsInObject(config)
+ * // resolved.api_key === "secret123"
+ * // resolved.nested.token === "my-token"
+ * ```
+ */
+export function resolveEnvVarsInObject>(obj: T): T {
+ const resolved: any = {}
+
+ for (const [key, value] of Object.entries(obj)) {
+ if (typeof value === 'string') {
+ resolved[key] = resolveEnvVar(value)
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+ resolved[key] = resolveEnvVarsInObject(value)
+ } else if (Array.isArray(value)) {
+ resolved[key] = value.map(item =>
+ typeof item === 'string' ? resolveEnvVar(item) : item
+ )
+ } else {
+ resolved[key] = value
+ }
+ }
+
+ return resolved
+}
From a286c14155b13b2345053d567794a2595798caa9 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 09:45:01 +0200
Subject: [PATCH 16/29] Revert "fix: resolve environment variables in
configuration"
This reverts commit 95031051c2d0998a50343cf2bc7da9be25b5fe95.
---
src/services/ConfigLoader.ts | 6 +--
src/utils/EnvResolver.ts | 80 ------------------------------------
2 files changed, 1 insertion(+), 85 deletions(-)
delete mode 100644 src/utils/EnvResolver.ts
diff --git a/src/services/ConfigLoader.ts b/src/services/ConfigLoader.ts
index 0e96ebb..119c411 100644
--- a/src/services/ConfigLoader.ts
+++ b/src/services/ConfigLoader.ts
@@ -8,7 +8,6 @@ import type {
OpencodeProjectConfig,
TimeTrackingConfig,
} from "../types/TimeTrackingConfig"
-import { resolveEnvVarsInObject } from "../utils/EnvResolver"
import "../types/Bun"
@@ -56,16 +55,13 @@ export class ConfigLoader {
if (projectConfig.time_tracking) {
const jsonConfig = projectConfig.time_tracking
- // Resolve environment variables in config (e.g., {env:TT_AGENT_API_KEY})
- const resolvedConfig = resolveEnvVarsInObject(jsonConfig)
-
// Resolve user_email with fallback chain:
// 1. Environment variable (loaded by opencode-plugin-shell-env from .env)
// 2. System username
const userEmail = process.env[ENV_USER_EMAIL] || userInfo().username
return {
- ...resolvedConfig,
+ ...jsonConfig,
user_email: userEmail,
}
}
diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts
deleted file mode 100644
index bad57f8..0000000
--- a/src/utils/EnvResolver.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @fileoverview Environment variable resolver for configuration values.
- */
-
-/**
- * Resolves environment variable placeholders in configuration values.
- *
- * @remarks
- * Replaces placeholders like `{env:VAR_NAME}` with actual environment variable values.
- * If the environment variable is not set, returns the original placeholder.
- *
- * @param value - The value that may contain env placeholders
- * @returns The resolved value with env vars substituted
- *
- * @example
- * ```typescript
- * process.env.API_KEY = "secret123"
- * resolveEnvVar("{env:API_KEY}") // Returns "secret123"
- * resolveEnvVar("normal-value") // Returns "normal-value"
- * ```
- */
-export function resolveEnvVar(value: string | undefined): string | undefined {
- if (!value) return value
-
- // Match {env:VAR_NAME} pattern
- const envPattern = /\{env:([^}]+)\}/g
-
- return value.replace(envPattern, (match, varName) => {
- const envValue = process.env[varName]
- if (envValue) {
- return envValue
- }
- // If env var not found, return the original placeholder
- return match
- })
-}
-
-/**
- * Recursively resolves environment variables in an object.
- *
- * @remarks
- * Walks through all string values in an object and resolves env placeholders.
- * Non-string values are left unchanged.
- *
- * @param obj - The object to resolve
- * @returns A new object with resolved env vars
- *
- * @example
- * ```typescript
- * const config = {
- * api_key: "{env:API_KEY}",
- * api_url: "https://api.example.com",
- * nested: {
- * token: "{env:TOKEN}"
- * }
- * }
- * const resolved = resolveEnvVarsInObject(config)
- * // resolved.api_key === "secret123"
- * // resolved.nested.token === "my-token"
- * ```
- */
-export function resolveEnvVarsInObject>(obj: T): T {
- const resolved: any = {}
-
- for (const [key, value] of Object.entries(obj)) {
- if (typeof value === 'string') {
- resolved[key] = resolveEnvVar(value)
- } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
- resolved[key] = resolveEnvVarsInObject(value)
- } else if (Array.isArray(value)) {
- resolved[key] = value.map(item =>
- typeof item === 'string' ? resolveEnvVar(item) : item
- )
- } else {
- resolved[key] = value
- }
- }
-
- return resolved
-}
From 156bcdc05f692271526ab6a4966a51d4436e9882 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 09:46:06 +0200
Subject: [PATCH 17/29] fix: simplify resolveSummaryConfig to match
node_modules version
Synchronize local ConfigMigration.ts with node_modules version to ensure
consistent behavior. The simpler ?? (nullish coalescing) operator is more
reliable than hasOwnProperty checks.
This ensures 'summary' field is properly resolved just like 'title_generation'.
---
src/hooks/EventHook.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index 6c10715..f9888da 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -183,7 +183,9 @@ export function createEventHook(
// Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface
// Resolve summary config with backward compatibility (summary or title_generation)
+ console.log("[EventHook] config object:", JSON.stringify(config, null, 2))
const summaryConfig = resolveSummaryConfig(config)
+ console.log("[EventHook] summaryConfig result:", summaryConfig)
// Build lib config with pricing from opencode-project.json
// Fallback to defaults if not configured
From 7b8654d767d27537e57f4b7050751f2eaa902988 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 09:48:15 +0200
Subject: [PATCH 18/29] debug: add logging to resolveSummaryConfig to verify
config resolution
Add console logs to show:
- Which config keys are present
- Whether 'summary' field is found
- Whether 'title_generation' field is found
- Whether summaryConfig was resolved
This helps verify that 'summary' field is being properly resolved.
---
src/utils/ConfigMigration.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts
index 78b65d9..f5b6641 100644
--- a/src/utils/ConfigMigration.ts
+++ b/src/utils/ConfigMigration.ts
@@ -46,6 +46,11 @@ export function resolveSummaryConfig(
// Use (config as any) to bypass TypeScript type checking and ?? for nullish coalescing
let summaryConfig = (config as any).summary ?? (config as any).title_generation
+ console.log("[ConfigMigration] resolveSummaryConfig called with config keys:", Object.keys(config as any))
+ console.log("[ConfigMigration] summary field:", (config as any).summary ? "present" : "missing")
+ console.log("[ConfigMigration] title_generation field:", (config as any).title_generation ? "present" : "missing")
+ console.log("[ConfigMigration] resolved summaryConfig:", summaryConfig ? "found" : "not found")
+
if (!summaryConfig) {
return undefined
}
From d75f6785c3e4e93981c8f5c2b4ee2c2493b7dbdc Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 10:30:12 +0200
Subject: [PATCH 19/29] COPSPA-489: Version fix
---
package-lock.json | 383 ++++++++++++++++++++++++++++++++++++++++++----
package.json | 4 +-
2 files changed, 357 insertions(+), 30 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index b678260..e9abcec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,8 @@
"version": "1.5.2",
"license": "MIT",
"dependencies": {
- "@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
+ "@opencode-ai/plugin": "^1.14",
+ "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main"
},
"devDependencies": {
"@types/bun": "latest",
@@ -21,39 +21,115 @@
"bun": ">=1.0.0"
}
},
- "../lib-ts-time-tracking": {
- "name": "@techdivision/lib-ts-time-tracking",
- "version": "4.2.0",
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
+ "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
+ "cpu": [
+ "arm64"
+ ],
"license": "MIT",
- "devDependencies": {
- "@amiceli/vitest-cucumber": "^5.2.1",
- "@types/node": "^22.0.0",
- "eslint": "^10.1.0",
- "eslint-config-prettier": "^10.1.8",
- "prettier": "^3.8.1",
- "typescript": "^5.9.3",
- "typescript-eslint": "^8.57.2",
- "vitest": "^3.2.1"
- },
- "engines": {
- "node": ">=20"
- }
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
+ "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
+ "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
+ "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
+ "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
+ "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
"node_modules/@opencode-ai/plugin": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz",
- "integrity": "sha512-mh9S05W+CZZmo6q3uIEBubS66QVgiev7fRafX7vemrCfz+3pEIkSwipLjU/sxIewC9yLiDWLqS73DH/iEQzVDw==",
+ "version": "1.14.28",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.28.tgz",
+ "integrity": "sha512-cHJo7t1jwrzbkIVmNgggdWh4cyOVGw5fnbSpuYeL6qwfmH3g/6YLWtw5ZYEP6detUkEebT08mHXDGmsMUpQa+A==",
"license": "MIT",
"dependencies": {
- "@opencode-ai/sdk": "1.2.15",
+ "@opencode-ai/sdk": "1.14.28",
+ "effect": "4.0.0-beta.48",
"zod": "4.1.8"
+ },
+ "peerDependencies": {
+ "@opentui/core": ">=0.1.105",
+ "@opentui/solid": ">=0.1.105"
+ },
+ "peerDependenciesMeta": {
+ "@opentui/core": {
+ "optional": true
+ },
+ "@opentui/solid": {
+ "optional": true
+ }
}
},
"node_modules/@opencode-ai/sdk": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.15.tgz",
- "integrity": "sha512-NUJNlyBCdZ4R0EBLjJziEQOp2XbRPJosaMcTcWSWO5XJPKGUpz0u8ql+5cR8K+v2RJ+hp2NobtNwpjEYfe6BRQ==",
- "license": "MIT"
+ "version": "1.14.28",
+ "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.28.tgz",
+ "integrity": "sha512-qRFJfD+Zdz3jHHSupW4F6Io1ZFrQ6gCRFlG50O6kEU9xRxrBpK0wGvP+Y5VwwvD/gH9WKMHYinlQpDVI9/lgJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "7.0.6"
+ }
},
"node_modules/@oven/bun-darwin-aarch64": {
"version": "1.3.13",
@@ -223,9 +299,19 @@
],
"peer": true
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
"node_modules/@techdivision/lib-ts-time-tracking": {
- "resolved": "../lib-ts-time-tracking",
- "link": true
+ "version": "4.2.0",
+ "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#efff5a88eaab91a26084cc69ed29e2ee7a677819",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
},
"node_modules/@types/bun": {
"version": "1.3.13",
@@ -292,6 +378,204 @@
"@types/node": "*"
}
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/effect": {
+ "version": "4.0.0-beta.48",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
+ "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "fast-check": "^4.6.0",
+ "find-my-way-ts": "^0.1.6",
+ "ini": "^6.0.0",
+ "kubernetes-types": "^1.30.0",
+ "msgpackr": "^1.11.9",
+ "multipasta": "^0.2.7",
+ "toml": "^4.1.1",
+ "uuid": "^13.0.0",
+ "yaml": "^2.8.3"
+ }
+ },
+ "node_modules/fast-check": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
+ "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=12.17.0"
+ }
+ },
+ "node_modules/find-my-way-ts": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
+ "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
+ "license": "MIT"
+ },
+ "node_modules/ini": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
+ "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/kubernetes-types": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
+ "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/msgpackr": {
+ "version": "1.11.10",
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
+ "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.2"
+ }
+ },
+ "node_modules/msgpackr-extract": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
+ "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
+ }
+ },
+ "node_modules/multipasta": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
+ "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
+ "license": "MIT"
+ },
+ "node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pure-rand": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
+ "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/toml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
+ "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -313,6 +597,49 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
diff --git a/package.json b/package.json
index 0d49c40..164a19c 100644
--- a/package.json
+++ b/package.json
@@ -32,8 +32,8 @@
"author": "TechDivision GmbH",
"license": "MIT",
"dependencies": {
- "@opencode-ai/plugin": "1.2.15",
- "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
+ "@opencode-ai/plugin": "^1.14",
+ "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main"
},
"devDependencies": {
"@types/bun": "latest",
From 85a4c31204433491293eeea74b7939e8187318e1 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 10:32:10 +0200
Subject: [PATCH 20/29] fix: resolve environment variables in summary config
before passing to lib
Add EnvResolver utility to resolve {env:VAR_NAME} placeholders in config values.
This ensures that environment variables like TT_AGENT_API_KEY are properly
resolved before being passed to the TimeTrackingFacade.
Changes:
- Create EnvResolver.ts with resolveEnvVar() and resolveEnvVarsInObject()
- Update EventHook to resolve env vars in summaryConfig after migration
- Add debug logging to show before/after env resolution
This fixes the issue where api_key was still {env:TT_AGENT_API_KEY} instead
of the actual API key value, causing LLM summary generation to fail.
---
src/hooks/EventHook.ts | 11 ++++-
src/utils/EnvResolver.ts | 86 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 95 insertions(+), 2 deletions(-)
create mode 100644 src/utils/EnvResolver.ts
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index f9888da..838664e 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -16,6 +16,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
import { AgentMatcher } from "../utils/AgentMatcher"
import { SessionDataMapper } from "../adapters/SessionDataMapper"
import { resolveSummaryConfig } from "../utils/ConfigMigration"
+import { resolveEnvVarsInObject } from "../utils/EnvResolver"
/**
* Properties for message.updated events.
@@ -184,8 +185,14 @@ export function createEventHook(
// Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface
// Resolve summary config with backward compatibility (summary or title_generation)
console.log("[EventHook] config object:", JSON.stringify(config, null, 2))
- const summaryConfig = resolveSummaryConfig(config)
- console.log("[EventHook] summaryConfig result:", summaryConfig)
+ let summaryConfig = resolveSummaryConfig(config)
+ console.log("[EventHook] summaryConfig result (before env resolution):", summaryConfig)
+
+ // Resolve environment variables in summaryConfig (e.g., {env:TT_AGENT_API_KEY})
+ if (summaryConfig) {
+ summaryConfig = resolveEnvVarsInObject(summaryConfig)
+ console.log("[EventHook] summaryConfig result (after env resolution):", summaryConfig)
+ }
// Build lib config with pricing from opencode-project.json
// Fallback to defaults if not configured
diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts
new file mode 100644
index 0000000..f330305
--- /dev/null
+++ b/src/utils/EnvResolver.ts
@@ -0,0 +1,86 @@
+/**
+ * @fileoverview Environment variable resolver for configuration values.
+ */
+
+/**
+ * Resolves environment variable placeholders in a string value.
+ *
+ * @remarks
+ * Replaces `{env:VAR_NAME}` placeholders with actual environment variable values.
+ * If the environment variable is not set, the placeholder is left unchanged.
+ *
+ * @param value - The string value that may contain `{env:VAR_NAME}` placeholders
+ * @returns The resolved string with environment variables substituted
+ *
+ * @example
+ * ```typescript
+ * process.env.API_KEY = "sk-123456"
+ * resolveEnvVar("{env:API_KEY}") // Returns: "sk-123456"
+ * resolveEnvVar("prefix-{env:API_KEY}-suffix") // Returns: "prefix-sk-123456-suffix"
+ * resolveEnvVar("{env:MISSING}") // Returns: "{env:MISSING}" (unchanged)
+ * ```
+ */
+export function resolveEnvVar(value: string): string {
+ if (typeof value !== "string") {
+ return value
+ }
+
+ return value.replace(/{env:([^}]+)}/g, (match, varName) => {
+ const envValue = process.env[varName]
+ return envValue !== undefined ? envValue : match
+ })
+}
+
+/**
+ * Recursively resolves environment variable placeholders in an object.
+ *
+ * @remarks
+ * Traverses the entire object tree and resolves `{env:VAR_NAME}` placeholders
+ * in all string values. Works with nested objects and arrays.
+ *
+ * @param obj - The object that may contain `{env:VAR_NAME}` placeholders in string values
+ * @returns A new object with all environment variables resolved
+ *
+ * @example
+ * ```typescript
+ * process.env.API_KEY = "sk-123456"
+ * const config = {
+ * api_key: "{env:API_KEY}",
+ * nested: {
+ * url: "https://api.example.com",
+ * token: "{env:API_TOKEN}"
+ * }
+ * }
+ * resolveEnvVarsInObject(config)
+ * // Returns: {
+ * // api_key: "sk-123456",
+ * // nested: {
+ * // url: "https://api.example.com",
+ * // token: "{env:API_TOKEN}" (unchanged if not set)
+ * // }
+ * // }
+ * ```
+ */
+export function resolveEnvVarsInObject(obj: any): any {
+ if (obj === null || obj === undefined) {
+ return obj
+ }
+
+ if (typeof obj === "string") {
+ return resolveEnvVar(obj)
+ }
+
+ if (Array.isArray(obj)) {
+ return obj.map((item) => resolveEnvVarsInObject(item))
+ }
+
+ if (typeof obj === "object") {
+ const resolved: any = {}
+ for (const [key, value] of Object.entries(obj)) {
+ resolved[key] = resolveEnvVarsInObject(value)
+ }
+ return resolved
+ }
+
+ return obj
+}
From 766aa16a8ccc7ff487152f447cbc303df0103e24 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 10:32:49 +0200
Subject: [PATCH 21/29] Revert "fix: resolve environment variables in summary
config before passing to lib"
This reverts commit 85a4c31204433491293eeea74b7939e8187318e1.
---
src/hooks/EventHook.ts | 11 +----
src/utils/EnvResolver.ts | 86 ----------------------------------------
2 files changed, 2 insertions(+), 95 deletions(-)
delete mode 100644 src/utils/EnvResolver.ts
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index 838664e..f9888da 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -16,7 +16,6 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
import { AgentMatcher } from "../utils/AgentMatcher"
import { SessionDataMapper } from "../adapters/SessionDataMapper"
import { resolveSummaryConfig } from "../utils/ConfigMigration"
-import { resolveEnvVarsInObject } from "../utils/EnvResolver"
/**
* Properties for message.updated events.
@@ -185,14 +184,8 @@ export function createEventHook(
// Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface
// Resolve summary config with backward compatibility (summary or title_generation)
console.log("[EventHook] config object:", JSON.stringify(config, null, 2))
- let summaryConfig = resolveSummaryConfig(config)
- console.log("[EventHook] summaryConfig result (before env resolution):", summaryConfig)
-
- // Resolve environment variables in summaryConfig (e.g., {env:TT_AGENT_API_KEY})
- if (summaryConfig) {
- summaryConfig = resolveEnvVarsInObject(summaryConfig)
- console.log("[EventHook] summaryConfig result (after env resolution):", summaryConfig)
- }
+ const summaryConfig = resolveSummaryConfig(config)
+ console.log("[EventHook] summaryConfig result:", summaryConfig)
// Build lib config with pricing from opencode-project.json
// Fallback to defaults if not configured
diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts
deleted file mode 100644
index f330305..0000000
--- a/src/utils/EnvResolver.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * @fileoverview Environment variable resolver for configuration values.
- */
-
-/**
- * Resolves environment variable placeholders in a string value.
- *
- * @remarks
- * Replaces `{env:VAR_NAME}` placeholders with actual environment variable values.
- * If the environment variable is not set, the placeholder is left unchanged.
- *
- * @param value - The string value that may contain `{env:VAR_NAME}` placeholders
- * @returns The resolved string with environment variables substituted
- *
- * @example
- * ```typescript
- * process.env.API_KEY = "sk-123456"
- * resolveEnvVar("{env:API_KEY}") // Returns: "sk-123456"
- * resolveEnvVar("prefix-{env:API_KEY}-suffix") // Returns: "prefix-sk-123456-suffix"
- * resolveEnvVar("{env:MISSING}") // Returns: "{env:MISSING}" (unchanged)
- * ```
- */
-export function resolveEnvVar(value: string): string {
- if (typeof value !== "string") {
- return value
- }
-
- return value.replace(/{env:([^}]+)}/g, (match, varName) => {
- const envValue = process.env[varName]
- return envValue !== undefined ? envValue : match
- })
-}
-
-/**
- * Recursively resolves environment variable placeholders in an object.
- *
- * @remarks
- * Traverses the entire object tree and resolves `{env:VAR_NAME}` placeholders
- * in all string values. Works with nested objects and arrays.
- *
- * @param obj - The object that may contain `{env:VAR_NAME}` placeholders in string values
- * @returns A new object with all environment variables resolved
- *
- * @example
- * ```typescript
- * process.env.API_KEY = "sk-123456"
- * const config = {
- * api_key: "{env:API_KEY}",
- * nested: {
- * url: "https://api.example.com",
- * token: "{env:API_TOKEN}"
- * }
- * }
- * resolveEnvVarsInObject(config)
- * // Returns: {
- * // api_key: "sk-123456",
- * // nested: {
- * // url: "https://api.example.com",
- * // token: "{env:API_TOKEN}" (unchanged if not set)
- * // }
- * // }
- * ```
- */
-export function resolveEnvVarsInObject(obj: any): any {
- if (obj === null || obj === undefined) {
- return obj
- }
-
- if (typeof obj === "string") {
- return resolveEnvVar(obj)
- }
-
- if (Array.isArray(obj)) {
- return obj.map((item) => resolveEnvVarsInObject(item))
- }
-
- if (typeof obj === "object") {
- const resolved: any = {}
- for (const [key, value] of Object.entries(obj)) {
- resolved[key] = resolveEnvVarsInObject(value)
- }
- return resolved
- }
-
- return obj
-}
From e7b1e0e0c0dda7ffec743d9019ea3e697de814d4 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 10:41:58 +0200
Subject: [PATCH 22/29] Fix version
---
package-lock.json | 4 ++--
package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index e9abcec..c5dd8dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "^1.14",
- "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main"
+ "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489"
},
"devDependencies": {
"@types/bun": "latest",
@@ -307,7 +307,7 @@
},
"node_modules/@techdivision/lib-ts-time-tracking": {
"version": "4.2.0",
- "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#efff5a88eaab91a26084cc69ed29e2ee7a677819",
+ "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#b51d2c4c6ab7bfa66bada11dd2c9346aeda1a755",
"license": "MIT",
"engines": {
"node": ">=20"
diff --git a/package.json b/package.json
index 164a19c..5ed3e73 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "^1.14",
- "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main"
+ "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489"
},
"devDependencies": {
"@types/bun": "latest",
From 5dc0994e7ab79e846131c992c95e91017def0622 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 10:46:50 +0200
Subject: [PATCH 23/29] fix: pass both summary and title_generation to lib for
mapping
Remove the plugin's resolveSummaryConfig() call and instead pass both
'summary' and 'title_generation' fields to the lib. The lib's ConfigMapper
will handle the migration and normalization.
Changes:
- Remove resolveSummaryConfig() call from EventHook
- Pass both config.summary and config.title_generation to lib
- Let lib handle the mapping via ConfigMapper
This ensures the lib's mapping logic is used instead of duplicating
the migration logic in the plugin.
---
src/hooks/EventHook.ts | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index f9888da..8a40681 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -15,7 +15,6 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
import { AgentMatcher } from "../utils/AgentMatcher"
import { SessionDataMapper } from "../adapters/SessionDataMapper"
-import { resolveSummaryConfig } from "../utils/ConfigMigration"
/**
* Properties for message.updated events.
@@ -182,10 +181,8 @@ export function createEventHook(
})
// Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface
- // Resolve summary config with backward compatibility (summary or title_generation)
+ // Note: Don't resolve summary config here - let the lib handle both summary and title_generation
console.log("[EventHook] config object:", JSON.stringify(config, null, 2))
- const summaryConfig = resolveSummaryConfig(config)
- console.log("[EventHook] summaryConfig result:", summaryConfig)
// Build lib config with pricing from opencode-project.json
// Fallback to defaults if not configured
@@ -223,13 +220,15 @@ export function createEventHook(
],
}
- const libConfig: TimeTrackingConfigInterface = {
+ const libConfig: TimeTrackingConfigInterface & { title_generation?: any } = {
defaults: config.global_default,
agents: config.agent_defaults,
csv: { output_path: config.csv_file },
pricing: (config as any).pricing || defaultPricing,
valid_projects: config.valid_projects || [],
- ...(summaryConfig && { summary: summaryConfig }),
+ // Pass both summary and title_generation to lib - lib will handle the mapping
+ ...(config.summary && { summary: config.summary }),
+ ...((config as any).title_generation && { title_generation: (config as any).title_generation }),
}
const facade = await getTimeTrackingFacade(libConfig)
From 3ed45b352249f18cb8611b0b49fa05e20c33eec9 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 11:22:35 +0200
Subject: [PATCH 24/29] Fix version
---
package-lock.json | 28 +++++++++++++++++++++-------
package.json | 2 +-
2 files changed, 22 insertions(+), 8 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index c5dd8dd..fdf0140 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "^1.14",
- "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489"
+ "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
},
"devDependencies": {
"@types/bun": "latest",
@@ -21,6 +21,24 @@
"bun": ">=1.0.0"
}
},
+ "../lib-ts-time-tracking": {
+ "name": "@techdivision/lib-ts-time-tracking",
+ "version": "4.2.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@amiceli/vitest-cucumber": "^5.2.1",
+ "@types/node": "^22.0.0",
+ "eslint": "^10.1.0",
+ "eslint-config-prettier": "^10.1.8",
+ "prettier": "^3.8.1",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.57.2",
+ "vitest": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
@@ -306,12 +324,8 @@
"license": "MIT"
},
"node_modules/@techdivision/lib-ts-time-tracking": {
- "version": "4.2.0",
- "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#b51d2c4c6ab7bfa66bada11dd2c9346aeda1a755",
- "license": "MIT",
- "engines": {
- "node": ">=20"
- }
+ "resolved": "../lib-ts-time-tracking",
+ "link": true
},
"node_modules/@types/bun": {
"version": "1.3.13",
diff --git a/package.json b/package.json
index 5ed3e73..6d9e20e 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "^1.14",
- "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489"
+ "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
},
"devDependencies": {
"@types/bun": "latest",
From 5db2fac496451b005567426e1a11c450165f4532 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 11:48:32 +0200
Subject: [PATCH 25/29] fix: resolve environment variables in config before
passing to lib
Add EnvResolver utility to resolve {env:VAR_NAME} placeholders in config values.
This ensures that environment variables like TT_AGENT_API_KEY are properly
resolved before being passed to the TimeTrackingFacade.
Changes:
- Create EnvResolver.ts with resolveEnvVar() and resolveEnvVarsInObject()
- EventHook: Resolve env vars in summary and title_generation config
- Recursively resolves nested objects and arrays
This fixes the issue where api_key was still {env:TT_AGENT_API_KEY} instead
of the actual API key value, causing LLM summary generation to fail.
---
src/hooks/EventHook.ts | 6 ++-
src/utils/EnvResolver.ts | 86 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 90 insertions(+), 2 deletions(-)
create mode 100644 src/utils/EnvResolver.ts
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index 8a40681..c5bed7f 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -15,6 +15,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
import { AgentMatcher } from "../utils/AgentMatcher"
import { SessionDataMapper } from "../adapters/SessionDataMapper"
+import { resolveEnvVarsInObject } from "../utils/EnvResolver"
/**
* Properties for message.updated events.
@@ -227,8 +228,9 @@ export function createEventHook(
pricing: (config as any).pricing || defaultPricing,
valid_projects: config.valid_projects || [],
// Pass both summary and title_generation to lib - lib will handle the mapping
- ...(config.summary && { summary: config.summary }),
- ...((config as any).title_generation && { title_generation: (config as any).title_generation }),
+ // Resolve environment variables in config values (e.g., {env:TT_AGENT_API_KEY})
+ ...(config.summary && { summary: resolveEnvVarsInObject(config.summary) }),
+ ...((config as any).title_generation && { title_generation: resolveEnvVarsInObject((config as any).title_generation) }),
}
const facade = await getTimeTrackingFacade(libConfig)
diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts
new file mode 100644
index 0000000..f330305
--- /dev/null
+++ b/src/utils/EnvResolver.ts
@@ -0,0 +1,86 @@
+/**
+ * @fileoverview Environment variable resolver for configuration values.
+ */
+
+/**
+ * Resolves environment variable placeholders in a string value.
+ *
+ * @remarks
+ * Replaces `{env:VAR_NAME}` placeholders with actual environment variable values.
+ * If the environment variable is not set, the placeholder is left unchanged.
+ *
+ * @param value - The string value that may contain `{env:VAR_NAME}` placeholders
+ * @returns The resolved string with environment variables substituted
+ *
+ * @example
+ * ```typescript
+ * process.env.API_KEY = "sk-123456"
+ * resolveEnvVar("{env:API_KEY}") // Returns: "sk-123456"
+ * resolveEnvVar("prefix-{env:API_KEY}-suffix") // Returns: "prefix-sk-123456-suffix"
+ * resolveEnvVar("{env:MISSING}") // Returns: "{env:MISSING}" (unchanged)
+ * ```
+ */
+export function resolveEnvVar(value: string): string {
+ if (typeof value !== "string") {
+ return value
+ }
+
+ return value.replace(/{env:([^}]+)}/g, (match, varName) => {
+ const envValue = process.env[varName]
+ return envValue !== undefined ? envValue : match
+ })
+}
+
+/**
+ * Recursively resolves environment variable placeholders in an object.
+ *
+ * @remarks
+ * Traverses the entire object tree and resolves `{env:VAR_NAME}` placeholders
+ * in all string values. Works with nested objects and arrays.
+ *
+ * @param obj - The object that may contain `{env:VAR_NAME}` placeholders in string values
+ * @returns A new object with all environment variables resolved
+ *
+ * @example
+ * ```typescript
+ * process.env.API_KEY = "sk-123456"
+ * const config = {
+ * api_key: "{env:API_KEY}",
+ * nested: {
+ * url: "https://api.example.com",
+ * token: "{env:API_TOKEN}"
+ * }
+ * }
+ * resolveEnvVarsInObject(config)
+ * // Returns: {
+ * // api_key: "sk-123456",
+ * // nested: {
+ * // url: "https://api.example.com",
+ * // token: "{env:API_TOKEN}" (unchanged if not set)
+ * // }
+ * // }
+ * ```
+ */
+export function resolveEnvVarsInObject(obj: any): any {
+ if (obj === null || obj === undefined) {
+ return obj
+ }
+
+ if (typeof obj === "string") {
+ return resolveEnvVar(obj)
+ }
+
+ if (Array.isArray(obj)) {
+ return obj.map((item) => resolveEnvVarsInObject(item))
+ }
+
+ if (typeof obj === "object") {
+ const resolved: any = {}
+ for (const [key, value] of Object.entries(obj)) {
+ resolved[key] = resolveEnvVarsInObject(value)
+ }
+ return resolved
+ }
+
+ return obj
+}
From d2fe298a191e58d631420bb4ff6da0f806c950c0 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 11:49:41 +0200
Subject: [PATCH 26/29] debug: add logging to EventHook for config and track
result
Add console logs to show:
- libConfig.summary and libConfig.title_generation (after env resolution)
- trackResult.summary (what the lib returns)
This helps debug the flow and identify where the issue is.
---
src/hooks/EventHook.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts
index c5bed7f..0bdd03b 100644
--- a/src/hooks/EventHook.ts
+++ b/src/hooks/EventHook.ts
@@ -233,8 +233,13 @@ export function createEventHook(
...((config as any).title_generation && { title_generation: resolveEnvVarsInObject((config as any).title_generation) }),
}
+ console.log("[EventHook] libConfig.summary:", JSON.stringify(libConfig.summary, null, 2))
+ console.log("[EventHook] libConfig.title_generation:", JSON.stringify(libConfig.title_generation, null, 2))
+
const facade = await getTimeTrackingFacade(libConfig)
+ console.log("[EventHook] facade created, calling track()")
const trackResult = await facade.track(sessionData)
+ console.log("[EventHook] trackResult.summary:", JSON.stringify(trackResult.summary, null, 2))
const description = trackResult.summary.description
// Build entry data from trackResult.entry (CSV entry comes directly from Lib!)
From bd457f9c9a22be52fc1f9279d4a9f1c2740efb80 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Mon, 27 Apr 2026 16:56:28 +0200
Subject: [PATCH 27/29] COPSPA-489: Fix
---
package.json | 2 +-
plugins/time-tracking.ts | 20 ++++++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
create mode 100644 plugins/time-tracking.ts
diff --git a/package.json b/package.json
index 6d9e20e..5ed3e73 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "^1.14",
- "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking"
+ "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489"
},
"devDependencies": {
"@types/bun": "latest",
diff --git a/plugins/time-tracking.ts b/plugins/time-tracking.ts
new file mode 100644
index 0000000..34d319d
--- /dev/null
+++ b/plugins/time-tracking.ts
@@ -0,0 +1,20 @@
+/**
+ * OpenCode Time Tracking Plugin
+ *
+ * @package @techdivision/opencode-plugin-time-tracking
+ * @author TechDivision GmbH
+ * @license MIT
+ * @version 1.0.0
+ *
+ * @description
+ * Automatically tracks session duration, tool usage, and token consumption,
+ * exporting data to CSV for time tracking integration (e.g., Jira/Tempo).
+ *
+ * @usage
+ * The plugin automatically tracks all sessions and exports data to CSV.
+ * No manual configuration required beyond setting up the time_tracking
+ * section in .opencode/opencode-project.json.
+ */
+
+// Re-export the plugin from the main package
+export { plugin } from "../src/Plugin"
From cb60196c109cbe1f6f1e0d14fe925a9acf33d189 Mon Sep 17 00:00:00 2001
From: Patrick
Date: Tue, 28 Apr 2026 15:53:58 +0200
Subject: [PATCH 28/29] Fix version
---
plugin.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plugin.json b/plugin.json
index 329c8f0..ae1bad4 100644
--- a/plugin.json
+++ b/plugin.json
@@ -2,7 +2,7 @@
"name": "time-tracking",
"description": "Automatic time tracking plugin for OpenCode. Tracks session duration and tool usage, writing entries to a CSV file compatible with Jira worklog sync incl. commands, skills and agents.",
"category": "standard",
- "version": "1.5.1",
+ "version": "2.0.0",
"hooks": {
"postlink": "scripts/postlink.js"
}
From 612c7233c36e67013f1daceac72084077f1d6d6f Mon Sep 17 00:00:00 2001
From: Patrick
Date: Tue, 28 Apr 2026 16:46:20 +0200
Subject: [PATCH 29/29] Fix version
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 5ed3e73..8efbd55 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@techdivision/opencode-plugin-time-tracking",
- "version": "1.5.2",
+ "version": "2.0.0",
"description": "Automatic time tracking plugin for OpenCode. Tracks session duration and tool usage, writing entries to a CSV file compatible with Jira worklog sync incl. commands, skills and agents.",
"type": "module",
"main": "src/Plugin.ts",