@kodrunhq/opencode-autopilot 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/assets/commands/brainstorm.md +7 -0
  2. package/assets/commands/stocktake.md +7 -0
  3. package/assets/commands/tdd.md +7 -0
  4. package/assets/commands/update-docs.md +7 -0
  5. package/assets/commands/write-plan.md +7 -0
  6. package/assets/skills/brainstorming/SKILL.md +295 -0
  7. package/assets/skills/code-review/SKILL.md +241 -0
  8. package/assets/skills/e2e-testing/SKILL.md +266 -0
  9. package/assets/skills/git-worktrees/SKILL.md +296 -0
  10. package/assets/skills/go-patterns/SKILL.md +240 -0
  11. package/assets/skills/plan-executing/SKILL.md +258 -0
  12. package/assets/skills/plan-writing/SKILL.md +278 -0
  13. package/assets/skills/python-patterns/SKILL.md +255 -0
  14. package/assets/skills/rust-patterns/SKILL.md +293 -0
  15. package/assets/skills/strategic-compaction/SKILL.md +217 -0
  16. package/assets/skills/systematic-debugging/SKILL.md +299 -0
  17. package/assets/skills/tdd-workflow/SKILL.md +311 -0
  18. package/assets/skills/typescript-patterns/SKILL.md +278 -0
  19. package/assets/skills/verification/SKILL.md +240 -0
  20. package/package.json +1 -1
  21. package/src/index.ts +72 -1
  22. package/src/observability/context-monitor.ts +102 -0
  23. package/src/observability/event-emitter.ts +136 -0
  24. package/src/observability/event-handlers.ts +322 -0
  25. package/src/observability/event-store.ts +226 -0
  26. package/src/observability/index.ts +53 -0
  27. package/src/observability/log-reader.ts +152 -0
  28. package/src/observability/log-writer.ts +93 -0
  29. package/src/observability/mock/mock-provider.ts +72 -0
  30. package/src/observability/mock/types.ts +31 -0
  31. package/src/observability/retention.ts +57 -0
  32. package/src/observability/schemas.ts +83 -0
  33. package/src/observability/session-logger.ts +63 -0
  34. package/src/observability/summary-generator.ts +209 -0
  35. package/src/observability/token-tracker.ts +97 -0
  36. package/src/observability/types.ts +24 -0
  37. package/src/orchestrator/skill-injection.ts +38 -0
  38. package/src/review/sanitize.ts +1 -1
  39. package/src/skills/adaptive-injector.ts +122 -0
  40. package/src/skills/dependency-resolver.ts +88 -0
  41. package/src/skills/linter.ts +113 -0
  42. package/src/skills/loader.ts +88 -0
  43. package/src/templates/skill-template.ts +4 -0
  44. package/src/tools/create-skill.ts +12 -0
  45. package/src/tools/logs.ts +178 -0
  46. package/src/tools/mock-fallback.ts +100 -0
  47. package/src/tools/pipeline-report.ts +148 -0
  48. package/src/tools/session-stats.ts +185 -0
  49. package/src/tools/stocktake.ts +170 -0
  50. package/src/tools/update-docs.ts +116 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Context utilization tracking with one-time warning per session.
3
+ *
4
+ * Pure function `checkContextUtilization` computes utilization ratio and warning signal.
5
+ * `ContextMonitor` class tracks per-session warned state and context limits.
6
+ *
7
+ * The toast itself is NOT fired here -- that happens in the event handler
8
+ * (separation of concerns). This module only computes whether to warn.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** Threshold at which context utilization triggers a warning (80%). */
14
+ const CONTEXT_WARNING_THRESHOLD = 0.8;
15
+
16
+ /**
17
+ * Result of a context utilization check.
18
+ */
19
+ export interface ContextUtilizationResult {
20
+ readonly utilization: number;
21
+ readonly shouldWarn: boolean;
22
+ }
23
+
24
+ /**
25
+ * Pure function that computes context utilization and whether to warn.
26
+ *
27
+ * - Returns utilization as a ratio (0.0 - 1.0)
28
+ * - Returns shouldWarn=true when utilization >= 0.80 and not already warned
29
+ * - Returns shouldWarn=false when already warned (fires once per D-36)
30
+ * - Handles zero contextLimit gracefully (returns 0 utilization)
31
+ *
32
+ * @param latestInputTokens - Current cumulative input tokens for the session
33
+ * @param contextLimit - The model's context window size in tokens
34
+ * @param alreadyWarned - Whether this session has already been warned
35
+ */
36
+ export function checkContextUtilization(
37
+ latestInputTokens: number,
38
+ contextLimit: number,
39
+ alreadyWarned: boolean,
40
+ ): ContextUtilizationResult {
41
+ if (contextLimit <= 0) {
42
+ return { utilization: 0, shouldWarn: false };
43
+ }
44
+
45
+ const utilization = latestInputTokens / contextLimit;
46
+ const shouldWarn = !alreadyWarned && utilization >= CONTEXT_WARNING_THRESHOLD;
47
+
48
+ return { utilization, shouldWarn };
49
+ }
50
+
51
+ /**
52
+ * Per-session state for context monitoring.
53
+ */
54
+ interface SessionContextState {
55
+ readonly contextLimit: number;
56
+ warned: boolean;
57
+ }
58
+
59
+ /**
60
+ * Tracks context utilization per session with one-time warning.
61
+ *
62
+ * - `initSession` sets the context limit for a session
63
+ * - `processMessage` checks utilization and updates warned state
64
+ * - `cleanup` removes session tracking data
65
+ */
66
+ export class ContextMonitor {
67
+ private readonly sessions: Map<string, SessionContextState> = new Map();
68
+
69
+ /**
70
+ * Initializes tracking for a session with its model's context limit.
71
+ */
72
+ initSession(sessionID: string, contextLimit: number): void {
73
+ this.sessions.set(sessionID, { contextLimit, warned: false });
74
+ }
75
+
76
+ /**
77
+ * Checks context utilization for a session and updates warned state.
78
+ * Returns utilization 0 for unknown sessions.
79
+ */
80
+ processMessage(sessionID: string, inputTokens: number): ContextUtilizationResult {
81
+ const state = this.sessions.get(sessionID);
82
+ if (!state) {
83
+ return { utilization: 0, shouldWarn: false };
84
+ }
85
+
86
+ const result = checkContextUtilization(inputTokens, state.contextLimit, state.warned);
87
+
88
+ // Update warned flag if warning triggered (one-time per session)
89
+ if (result.shouldWarn) {
90
+ this.sessions.set(sessionID, { ...state, warned: true });
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Removes session tracking data.
98
+ */
99
+ cleanup(sessionID: string): void {
100
+ this.sessions.delete(sessionID);
101
+ }
102
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Typed event emitter helper functions for observability events.
3
+ *
4
+ * Each function constructs a frozen ObservabilityEvent with a timestamp.
5
+ * Pure functions: take args, return frozen event object.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { ObservabilityEvent } from "./event-store";
11
+
12
+ /**
13
+ * Constructs a fallback event.
14
+ */
15
+ export function emitFallbackEvent(
16
+ sessionId: string,
17
+ failedModel: string,
18
+ nextModel: string,
19
+ reason: string,
20
+ success: boolean,
21
+ ): ObservabilityEvent {
22
+ return Object.freeze({
23
+ type: "fallback" as const,
24
+ timestamp: new Date().toISOString(),
25
+ sessionId,
26
+ failedModel,
27
+ nextModel,
28
+ reason,
29
+ success,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Constructs an error event.
35
+ */
36
+ export function emitErrorEvent(
37
+ sessionId: string,
38
+ errorType:
39
+ | "rate_limit"
40
+ | "quota_exceeded"
41
+ | "service_unavailable"
42
+ | "missing_api_key"
43
+ | "model_not_found"
44
+ | "content_filter"
45
+ | "context_length"
46
+ | "unknown",
47
+ message: string,
48
+ model = "unknown",
49
+ statusCode?: number,
50
+ ): ObservabilityEvent {
51
+ return Object.freeze({
52
+ type: "error" as const,
53
+ timestamp: new Date().toISOString(),
54
+ sessionId,
55
+ errorType,
56
+ message,
57
+ model,
58
+ ...(statusCode !== undefined ? { statusCode } : {}),
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Constructs a decision event (per D-27, D-28).
64
+ */
65
+ export function emitDecisionEvent(
66
+ sessionId: string,
67
+ phase: string,
68
+ agent: string,
69
+ decision: string,
70
+ rationale: string,
71
+ ): ObservabilityEvent {
72
+ return Object.freeze({
73
+ type: "decision" as const,
74
+ timestamp: new Date().toISOString(),
75
+ sessionId,
76
+ phase,
77
+ agent,
78
+ decision,
79
+ rationale,
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Constructs a model_switch event.
85
+ */
86
+ export function emitModelSwitchEvent(
87
+ sessionId: string,
88
+ fromModel: string,
89
+ toModel: string,
90
+ trigger: "fallback" | "config" | "user",
91
+ ): ObservabilityEvent {
92
+ return Object.freeze({
93
+ type: "model_switch" as const,
94
+ timestamp: new Date().toISOString(),
95
+ sessionId,
96
+ fromModel,
97
+ toModel,
98
+ trigger,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Constructs a tool_complete event.
104
+ */
105
+ export function emitToolCompleteEvent(
106
+ sessionId: string,
107
+ tool: string,
108
+ durationMs: number,
109
+ success: boolean,
110
+ ): ObservabilityEvent {
111
+ return Object.freeze({
112
+ type: "tool_complete" as const,
113
+ timestamp: new Date().toISOString(),
114
+ sessionId,
115
+ tool,
116
+ durationMs,
117
+ success,
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Constructs a phase_transition event.
123
+ */
124
+ export function emitPhaseTransition(
125
+ sessionId: string,
126
+ fromPhase: string,
127
+ toPhase: string,
128
+ ): ObservabilityEvent {
129
+ return Object.freeze({
130
+ type: "phase_transition" as const,
131
+ timestamp: new Date().toISOString(),
132
+ sessionId,
133
+ fromPhase,
134
+ toPhase,
135
+ });
136
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Hook handler factories for OpenCode event system integration.
3
+ *
4
+ * Creates handlers for:
5
+ * - event hook: session.created/error/idle/deleted/compacted, message.updated
6
+ * - tool.execute.before: records start timestamp
7
+ * - tool.execute.after: computes duration, records metrics
8
+ *
9
+ * All handlers are pure observers: they read event data, append to store,
10
+ * and never modify session state or output objects (Pitfall 5).
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { classifyErrorType, getErrorMessage } from "../orchestrator/fallback/error-classifier";
16
+ import type { ContextMonitor } from "./context-monitor";
17
+ import { emitErrorEvent, emitToolCompleteEvent } from "./event-emitter";
18
+ import type { ObservabilityEvent, SessionEventStore, SessionEvents } from "./event-store";
19
+ import { accumulateTokensFromMessage, createEmptyTokenAggregate } from "./token-tracker";
20
+
21
+ /**
22
+ * Dependencies for the observability event handler.
23
+ */
24
+ export interface ObservabilityHandlerDeps {
25
+ readonly eventStore: SessionEventStore;
26
+ readonly contextMonitor: ContextMonitor;
27
+ readonly showToast: (
28
+ title: string,
29
+ message: string,
30
+ variant: "info" | "warning" | "error",
31
+ ) => Promise<void>;
32
+ readonly writeSessionLog: (sessionData: SessionEvents | undefined) => Promise<void>;
33
+ }
34
+
35
+ /**
36
+ * Extracts a session ID from event properties.
37
+ * Supports properties.sessionID, properties.info.sessionID, and properties.info.id.
38
+ */
39
+ function extractSessionId(properties: Record<string, unknown>): string | undefined {
40
+ if (typeof properties.sessionID === "string") return properties.sessionID;
41
+ if (properties.info !== null && typeof properties.info === "object") {
42
+ const info = properties.info as Record<string, unknown>;
43
+ if (typeof info.sessionID === "string") return info.sessionID;
44
+ if (typeof info.id === "string") return info.id;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /**
50
+ * Checks if an object has AssistantMessage token shape.
51
+ */
52
+ function hasTokenShape(obj: unknown): obj is {
53
+ tokens: {
54
+ input: number;
55
+ output: number;
56
+ reasoning: number;
57
+ cache: { read: number; write: number };
58
+ };
59
+ cost: number;
60
+ } {
61
+ if (obj === null || typeof obj !== "object") return false;
62
+ const o = obj as Record<string, unknown>;
63
+ if (typeof o.cost !== "number") return false;
64
+ if (o.tokens === null || typeof o.tokens !== "object") return false;
65
+ const tokens = o.tokens as Record<string, unknown>;
66
+ if (typeof tokens.input !== "number") return false;
67
+ if (typeof tokens.output !== "number") return false;
68
+ if (typeof tokens.reasoning !== "number") return false;
69
+ if (tokens.cache === null || typeof tokens.cache !== "object") return false;
70
+ const cache = tokens.cache as Record<string, unknown>;
71
+ return typeof cache.read === "number" && typeof cache.write === "number";
72
+ }
73
+
74
+ /**
75
+ * Creates the main observability event handler.
76
+ *
77
+ * Routes OpenCode events to the session event store:
78
+ * - session.created: init session, append session_start
79
+ * - session.error: classify error, append error event (D-37, D-38)
80
+ * - message.updated: accumulate tokens, check context utilization
81
+ * - session.idle: flush to disk (fire-and-forget, Pitfall 2)
82
+ * - session.deleted: final flush + cleanup
83
+ * - session.compacted: append event, intermediate flush
84
+ */
85
+ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps) {
86
+ const { eventStore, contextMonitor, showToast, writeSessionLog } = deps;
87
+
88
+ return async (input: {
89
+ readonly event: { readonly type: string; readonly [key: string]: unknown };
90
+ }): Promise<void> => {
91
+ const { event } = input;
92
+ const properties = (event.properties ?? {}) as Record<string, unknown>;
93
+
94
+ switch (event.type) {
95
+ case "session.created": {
96
+ const info = properties.info as { id?: string; model?: string } | undefined;
97
+ if (!info?.id) return;
98
+
99
+ eventStore.initSession(info.id);
100
+
101
+ // Init context monitor with a default context limit.
102
+ // The actual limit would come from provider metadata if available.
103
+ contextMonitor.initSession(info.id, 200000);
104
+
105
+ // Append session_start event
106
+ const startEvent: ObservabilityEvent = Object.freeze({
107
+ type: "session_start" as const,
108
+ timestamp: new Date().toISOString(),
109
+ sessionId: info.id,
110
+ });
111
+ eventStore.appendEvent(info.id, startEvent);
112
+ return;
113
+ }
114
+
115
+ case "session.error": {
116
+ const sessionId = extractSessionId(properties);
117
+ if (!sessionId) return;
118
+
119
+ const error = properties.error;
120
+ const rawErrorType = classifyErrorType(error);
121
+ const errorType = (
122
+ [
123
+ "rate_limit",
124
+ "quota_exceeded",
125
+ "service_unavailable",
126
+ "missing_api_key",
127
+ "model_not_found",
128
+ "content_filter",
129
+ "context_length",
130
+ ] as const
131
+ ).includes(rawErrorType as never)
132
+ ? (rawErrorType as
133
+ | "rate_limit"
134
+ | "quota_exceeded"
135
+ | "service_unavailable"
136
+ | "missing_api_key"
137
+ | "model_not_found"
138
+ | "content_filter"
139
+ | "context_length")
140
+ : ("unknown" as const);
141
+ const message = getErrorMessage(error);
142
+
143
+ const errorEvent = emitErrorEvent(sessionId, errorType, message);
144
+ eventStore.appendEvent(sessionId, errorEvent);
145
+ return;
146
+ }
147
+
148
+ case "message.updated": {
149
+ const info = properties.info as Record<string, unknown> | undefined;
150
+ if (!info) return;
151
+
152
+ const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined;
153
+ if (!sessionId) return;
154
+
155
+ // Accumulate tokens if message has AssistantMessage shape
156
+ if (hasTokenShape(info)) {
157
+ const empty = createEmptyTokenAggregate();
158
+ const accumulated = accumulateTokensFromMessage(empty, info);
159
+ eventStore.accumulateTokens(sessionId, accumulated);
160
+
161
+ // Check context utilization
162
+ const utilResult = contextMonitor.processMessage(sessionId, info.tokens.input);
163
+ if (utilResult.shouldWarn) {
164
+ const pct = Math.round(utilResult.utilization * 100);
165
+ // Append context_warning event
166
+ const warningEvent: ObservabilityEvent = Object.freeze({
167
+ type: "context_warning" as const,
168
+ timestamp: new Date().toISOString(),
169
+ sessionId,
170
+ utilization: utilResult.utilization,
171
+ contextLimit: 200000,
172
+ inputTokens: info.tokens.input,
173
+ });
174
+ eventStore.appendEvent(sessionId, warningEvent);
175
+
176
+ // Fire toast (per D-35)
177
+ showToast(
178
+ "Context Warning",
179
+ `Context at ${pct}% -- consider compacting`,
180
+ "warning",
181
+ ).catch((err) => {
182
+ console.error("[opencode-autopilot]", err);
183
+ });
184
+ }
185
+ }
186
+ return;
187
+ }
188
+
189
+ case "session.idle": {
190
+ const sessionId = extractSessionId(properties);
191
+ if (!sessionId) return;
192
+
193
+ // Snapshot to disk (fire-and-forget per Pitfall 2) — session continues
194
+ const sessionData = eventStore.getSession(sessionId);
195
+ writeSessionLog(sessionData).catch((err) => {
196
+ console.error("[opencode-autopilot]", err);
197
+ });
198
+ return;
199
+ }
200
+
201
+ case "session.deleted": {
202
+ const sessionId = extractSessionId(properties);
203
+ if (!sessionId) return;
204
+
205
+ // Final flush — session is done, remove from store
206
+ const sessionData = eventStore.flush(sessionId);
207
+ writeSessionLog(sessionData).catch((err) => {
208
+ console.error("[opencode-autopilot]", err);
209
+ });
210
+
211
+ // Clean up context monitor
212
+ contextMonitor.cleanup(sessionId);
213
+ return;
214
+ }
215
+
216
+ case "session.compacted": {
217
+ const sessionId = extractSessionId(properties);
218
+ if (!sessionId) return;
219
+
220
+ // Append compaction decision event (not session_start)
221
+ const compactEvent: ObservabilityEvent = Object.freeze({
222
+ type: "decision" as const,
223
+ timestamp: new Date().toISOString(),
224
+ sessionId,
225
+ phase: "COMPACT",
226
+ agent: "system",
227
+ decision: "Session compacted",
228
+ rationale: "Context window compaction triggered",
229
+ });
230
+ eventStore.appendEvent(sessionId, compactEvent);
231
+
232
+ // Snapshot to disk — session continues after compaction
233
+ const sessionData = eventStore.getSession(sessionId);
234
+ writeSessionLog(sessionData).catch((err) => {
235
+ console.error("[opencode-autopilot]", err);
236
+ });
237
+ return;
238
+ }
239
+
240
+ default:
241
+ return;
242
+ }
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Creates a tool.execute.before handler that records start timestamps.
248
+ *
249
+ * @param startTimes - Map to store callID -> start timestamp
250
+ */
251
+ export function createToolExecuteBeforeHandler(startTimes: Map<string, number>) {
252
+ return (hookInput: {
253
+ readonly tool: string;
254
+ readonly sessionID: string;
255
+ readonly callID: string;
256
+ readonly args: unknown;
257
+ }): void => {
258
+ startTimes.set(hookInput.callID, Date.now());
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Determines if a tool execution succeeded based on the output.
264
+ */
265
+ function isToolSuccess(output: {
266
+ readonly title: string;
267
+ readonly output: string;
268
+ readonly metadata: unknown;
269
+ }): boolean {
270
+ // Check metadata for explicit error flag
271
+ if (output.metadata !== null && typeof output.metadata === "object") {
272
+ const meta = output.metadata as Record<string, unknown>;
273
+ if (meta.error === true) return false;
274
+ }
275
+ // Check title for error indicator
276
+ if (output.title.toLowerCase().startsWith("error")) return false;
277
+ // Check output for error prefix
278
+ if (output.output.startsWith("Error:")) return false;
279
+ return true;
280
+ }
281
+
282
+ /**
283
+ * Creates a tool.execute.after handler that computes duration and records metrics.
284
+ *
285
+ * Never modifies the output object (pure observer per Pitfall 5).
286
+ *
287
+ * @param eventStore - The session event store
288
+ * @param startTimes - Map of callID -> start timestamp (shared with before handler)
289
+ */
290
+ export function createToolExecuteAfterHandler(
291
+ eventStore: SessionEventStore,
292
+ startTimes: Map<string, number>,
293
+ ) {
294
+ return (
295
+ hookInput: {
296
+ readonly tool: string;
297
+ readonly sessionID: string;
298
+ readonly callID: string;
299
+ readonly args: unknown;
300
+ },
301
+ output: { readonly title: string; readonly output: string; readonly metadata: unknown },
302
+ ): void => {
303
+ const startTime = startTimes.get(hookInput.callID);
304
+ const durationMs = startTime !== undefined ? Date.now() - startTime : 0;
305
+ const success = isToolSuccess(output);
306
+
307
+ // Clean up start time entry
308
+ startTimes.delete(hookInput.callID);
309
+
310
+ // Record tool execution metric (per D-39, D-40)
311
+ eventStore.recordToolExecution(hookInput.sessionID, hookInput.tool, durationMs, success);
312
+
313
+ // Append tool_complete event
314
+ const toolEvent = emitToolCompleteEvent(
315
+ hookInput.sessionID,
316
+ hookInput.tool,
317
+ durationMs,
318
+ success,
319
+ );
320
+ eventStore.appendEvent(hookInput.sessionID, toolEvent);
321
+ };
322
+ }