@comfanion/workflow 4.21.0 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/workflow",
3
- "version": "4.21.0",
3
+ "version": "4.22.0",
4
4
  "description": "Initialize OpenCode Workflow system for AI-assisted development with semantic code search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "3.0.0",
3
- "buildDate": "2026-01-24T11:19:45.550Z",
3
+ "buildDate": "2026-01-24T11:24:58.085Z",
4
4
  "files": [
5
5
  "config.yaml",
6
6
  "FLOW.yaml",
@@ -12,6 +12,8 @@
12
12
  "checklists",
13
13
  "commands",
14
14
  "tools",
15
+ "plugins",
16
+ "package.json",
15
17
  "opencode.json"
16
18
  ]
17
19
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "@opencode-ai/plugin": "1.1.34"
4
+ }
5
+ }
@@ -0,0 +1,133 @@
1
+ # Plugins
2
+
3
+ Plugins that extend OpenCode with hooks, events, and custom tools.
4
+
5
+ ## Available Plugins
6
+
7
+ ### file-indexer.ts
8
+
9
+ Automatically reindexes changed files for semantic search.
10
+
11
+ **Features:**
12
+ - Detects file type and updates appropriate index (code, docs, config)
13
+ - Debounces rapid changes (2s) to avoid excessive indexing
14
+ - Hooks into Edit/Write tool execution
15
+ - Logs indexing activity for transparency
16
+
17
+ **Events:** `file.edited`, `document.updated`, `tool.execute.after`
18
+
19
+ **How it works:**
20
+
21
+ | File Extension | Index Updated |
22
+ |----------------|---------------|
23
+ | `.js`, `.ts`, `.py`, etc. | `code` |
24
+ | `.md`, `.txt`, `.rst` | `docs` |
25
+ | `.yaml`, `.json`, `.toml` | `config` |
26
+
27
+ **Prerequisites:**
28
+ ```bash
29
+ npx opencode-workflow vectorizer install
30
+ npx opencode-workflow index --index code
31
+ ```
32
+
33
+ ---
34
+
35
+ ### custom-compaction.ts
36
+
37
+ Intelligent session compaction that preserves flow context.
38
+
39
+ **Features:**
40
+ - Tracks todo list and story task status
41
+ - Identifies critical documentation files for context
42
+ - Generates smart continuation prompts
43
+ - Differentiates between completed and interrupted tasks
44
+
45
+ **Hook:** `experimental.session.compacting`
46
+
47
+ **What it does:**
48
+
49
+ | Scenario | Compaction Output |
50
+ |----------|-------------------|
51
+ | **Task Completed** | Summary of completed work, next steps, validation reminders |
52
+ | **Task Interrupted** | Current task, what was done, resume instructions, file list |
53
+
54
+ **Critical Files Passed to Context:**
55
+ - `CLAUDE.md` - Project coding standards
56
+ - `AGENTS.md` - Agent definitions
57
+ - `project-context.md` - Project overview
58
+ - `.opencode/config.yaml` - Flow config
59
+ - `docs/prd.md` - Product requirements
60
+ - `docs/architecture.md` - System architecture
61
+ - `docs/coding-standards/*.md` - Coding patterns
62
+ - Active story file (if in progress)
63
+
64
+ ## Installation
65
+
66
+ Plugins in `.opencode/plugins/` are automatically loaded by OpenCode.
67
+
68
+ ```bash
69
+ # No installation needed - just place files in .opencode/plugins/
70
+ ```
71
+
72
+ ## Creating Custom Plugins
73
+
74
+ ### Hook Types
75
+
76
+ ```typescript
77
+ // Compaction - customize context preservation
78
+ "experimental.session.compacting": async (input, output) => {
79
+ output.context.push("Custom context...")
80
+ // or replace entirely:
81
+ output.prompt = "Custom prompt..."
82
+ }
83
+
84
+ // Events - react to flow changes
85
+ event: async ({ event }) => {
86
+ if (event.type === "session.idle") { /* ... */ }
87
+ if (event.type === "todo.updated") { /* ... */ }
88
+ }
89
+
90
+ // Tool hooks - intercept tool execution
91
+ "tool.execute.before": async (input, output) => { /* ... */ }
92
+ "tool.execute.after": async (input, output) => { /* ... */ }
93
+ ```
94
+
95
+ ### Session Events
96
+
97
+ | Event | When | Use Case |
98
+ |-------|------|----------|
99
+ | `session.idle` | Agent finished responding | Check task completion, send notifications |
100
+ | `todo.updated` | Todo list changed | Track progress, update external systems |
101
+ | `file.edited` | File was modified | Track active files for context |
102
+ | `session.compacted` | Session was compacted | Log, analytics |
103
+
104
+ ### Example: Story Progress Tracker
105
+
106
+ ```typescript
107
+ export const StoryTrackerPlugin: Plugin = async (ctx) => {
108
+ return {
109
+ event: async ({ event }) => {
110
+ if (event.type === "todo.updated") {
111
+ // Sync with Jira, send Slack notification, etc.
112
+ }
113
+ }
114
+ }
115
+ }
116
+ ```
117
+
118
+ ### Example: Auto-Read Documentation
119
+
120
+ ```typescript
121
+ export const AutoContextPlugin: Plugin = async (ctx) => {
122
+ return {
123
+ "experimental.session.compacting": async (input, output) => {
124
+ // Always include coding standards in compaction
125
+ output.context.push(`
126
+ ## Coding Standards (MUST follow)
127
+ - Read docs/coding-standards/README.md on resume
128
+ - Follow patterns from CLAUDE.md
129
+ `)
130
+ }
131
+ }
132
+ }
133
+ ```
@@ -0,0 +1,256 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { readFile, access } from "fs/promises"
3
+ import { join } from "path"
4
+
5
+ interface TaskStatus {
6
+ id: string
7
+ content: string
8
+ status: "pending" | "in_progress" | "completed" | "cancelled"
9
+ priority: string
10
+ }
11
+
12
+ interface StoryContext {
13
+ path: string
14
+ title: string
15
+ status: string
16
+ currentTask: string | null
17
+ completedTasks: string[]
18
+ pendingTasks: string[]
19
+ }
20
+
21
+ interface SessionContext {
22
+ todos: TaskStatus[]
23
+ story: StoryContext | null
24
+ relevantFiles: string[]
25
+ activeAgent: string | null
26
+ }
27
+
28
+ /**
29
+ * Custom Compaction Plugin
30
+ *
31
+ * Intelligent context preservation during session compaction:
32
+ * - Tracks task/story completion status
33
+ * - Preserves relevant documentation files
34
+ * - Generates continuation prompts for seamless resumption
35
+ */
36
+ export const CustomCompactionPlugin: Plugin = async (ctx) => {
37
+ const { directory } = ctx
38
+
39
+ async function getTodoList(): Promise<TaskStatus[]> {
40
+ try {
41
+ const todoPath = join(directory, ".opencode", "state", "todos.json")
42
+ const content = await readFile(todoPath, "utf-8")
43
+ return JSON.parse(content)
44
+ } catch {
45
+ return []
46
+ }
47
+ }
48
+
49
+ async function getActiveStory(): Promise<StoryContext | null> {
50
+ try {
51
+ const sprintStatusPath = join(directory, "docs", "sprint-artifacts", "sprint-status.yaml")
52
+ const content = await readFile(sprintStatusPath, "utf-8")
53
+
54
+ const inProgressMatch = content.match(/status:\s*in-progress[\s\S]*?path:\s*["']?([^"'\n]+)["']?/i)
55
+ if (!inProgressMatch) return null
56
+
57
+ const storyPath = inProgressMatch[1]
58
+ const storyContent = await readFile(join(directory, storyPath), "utf-8")
59
+
60
+ const titleMatch = storyContent.match(/^#\s+(.+)/m)
61
+ const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
62
+
63
+ const completedTasks: string[] = []
64
+ const pendingTasks: string[] = []
65
+ let currentTask: string | null = null
66
+
67
+ const taskRegex = /- \[([ x])\]\s+\*\*T(\d+)\*\*[:\s]+(.+)/g
68
+ let match
69
+ while ((match = taskRegex.exec(storyContent)) !== null) {
70
+ const [, checked, taskId, taskName] = match
71
+ if (checked === "x") {
72
+ completedTasks.push(`T${taskId}: ${taskName}`)
73
+ } else {
74
+ if (!currentTask) currentTask = `T${taskId}: ${taskName}`
75
+ pendingTasks.push(`T${taskId}: ${taskName}`)
76
+ }
77
+ }
78
+
79
+ return {
80
+ path: storyPath,
81
+ title: titleMatch?.[1] || "Unknown Story",
82
+ status: statusMatch?.[1] || "unknown",
83
+ currentTask,
84
+ completedTasks,
85
+ pendingTasks
86
+ }
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ async function getRelevantFiles(): Promise<string[]> {
93
+ const relevantPaths: string[] = []
94
+
95
+ const criticalFiles = [
96
+ "CLAUDE.md",
97
+ "AGENTS.md",
98
+ "project-context.md",
99
+ ".opencode/config.yaml",
100
+ "docs/prd.md",
101
+ "docs/architecture.md",
102
+ "docs/coding-standards/README.md",
103
+ "docs/coding-standards/patterns.md"
104
+ ]
105
+
106
+ for (const filePath of criticalFiles) {
107
+ try {
108
+ await access(join(directory, filePath))
109
+ relevantPaths.push(filePath)
110
+ } catch {
111
+ // File doesn't exist, skip
112
+ }
113
+ }
114
+
115
+ const story = await getActiveStory()
116
+ if (story) {
117
+ relevantPaths.push(story.path)
118
+ }
119
+
120
+ return relevantPaths
121
+ }
122
+
123
+ async function buildContext(): Promise<SessionContext> {
124
+ const [todos, story, relevantFiles] = await Promise.all([
125
+ getTodoList(),
126
+ getActiveStory(),
127
+ getRelevantFiles()
128
+ ])
129
+
130
+ return { todos, story, relevantFiles, activeAgent: null }
131
+ }
132
+
133
+ function formatContext(ctx: SessionContext): string {
134
+ const sections: string[] = []
135
+
136
+ if (ctx.todos.length > 0) {
137
+ const inProgress = ctx.todos.filter(t => t.status === "in_progress")
138
+ const completed = ctx.todos.filter(t => t.status === "completed")
139
+ const pending = ctx.todos.filter(t => t.status === "pending")
140
+
141
+ sections.push(`## Task Status
142
+
143
+ **In Progress:** ${inProgress.length > 0 ? inProgress.map(t => t.content).join(", ") : "None"}
144
+ **Completed:** ${completed.length > 0 ? completed.map(t => `✅ ${t.content}`).join("\n") : "None"}
145
+ **Pending:** ${pending.length > 0 ? pending.map(t => `⬜ ${t.content}`).join("\n") : "None"}`)
146
+ }
147
+
148
+ if (ctx.story) {
149
+ const s = ctx.story
150
+ const total = s.completedTasks.length + s.pendingTasks.length
151
+ const progress = total > 0 ? (s.completedTasks.length / total * 100).toFixed(0) : 0
152
+
153
+ sections.push(`## Active Story
154
+
155
+ **Story:** ${s.title}
156
+ **Path:** ${s.path}
157
+ **Status:** ${s.status}
158
+ **Progress:** ${progress}% (${s.completedTasks.length}/${total} tasks)
159
+
160
+ ### Current Task
161
+ ${s.currentTask || "All tasks complete"}
162
+
163
+ ### Completed
164
+ ${s.completedTasks.length > 0 ? s.completedTasks.map(t => `✅ ${t}`).join("\n") : "None"}
165
+
166
+ ### Remaining
167
+ ${s.pendingTasks.length > 0 ? s.pendingTasks.map(t => `⬜ ${t}`).join("\n") : "All done!"}`)
168
+ }
169
+
170
+ if (ctx.relevantFiles.length > 0) {
171
+ sections.push(`## Critical Files (MUST re-read)
172
+
173
+ ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}`)
174
+ }
175
+
176
+ return sections.join("\n\n---\n\n")
177
+ }
178
+
179
+ function formatInstructions(ctx: SessionContext): string {
180
+ const hasInProgressTasks = ctx.todos.some(t => t.status === "in_progress")
181
+ const hasInProgressStory = ctx.story?.status === "in-progress"
182
+
183
+ if (!hasInProgressTasks && !hasInProgressStory) {
184
+ return `## Status: COMPLETED
185
+
186
+ Previous task was completed successfully.
187
+
188
+ **Next:**
189
+ 1. Review completed work
190
+ 2. Run validation/tests if applicable
191
+ 3. Ask user for next task`
192
+ }
193
+
194
+ const instructions = [`## Status: INTERRUPTED
195
+
196
+ Session compacted while work in progress.
197
+
198
+ **Resume:**`]
199
+
200
+ if (ctx.story?.currentTask) {
201
+ instructions.push(`
202
+ 1. Read story: \`${ctx.story.path}\`
203
+ 2. Current task: ${ctx.story.currentTask}
204
+ 3. Load skill: \`.opencode/skills/dev-story/SKILL.md\`
205
+ 4. Continue red-green-refactor
206
+ 5. Run tests first`)
207
+ }
208
+
209
+ if (hasInProgressTasks) {
210
+ const task = ctx.todos.find(t => t.status === "in_progress")
211
+ instructions.push(`
212
+ 1. Resume: ${task?.content}
213
+ 2. Check previous messages
214
+ 3. Continue from last action
215
+ 4. Update todo when complete`)
216
+ }
217
+
218
+ return instructions.join("\n")
219
+ }
220
+
221
+ return {
222
+ "experimental.session.compacting": async (input, output) => {
223
+ const ctx = await buildContext()
224
+ const context = formatContext(ctx)
225
+ const instructions = formatInstructions(ctx)
226
+
227
+ output.context.push(`# Session Continuation
228
+
229
+ ${context}
230
+
231
+ ---
232
+
233
+ ${instructions}
234
+
235
+ ---
236
+
237
+ ## On Resume
238
+
239
+ 1. **Read critical files** listed above
240
+ 2. **Check task/story status**
241
+ 3. **Continue from last point** - never start over
242
+ 4. **Run tests first** if implementing code`)
243
+ },
244
+
245
+ event: async ({ event }) => {
246
+ if (event.type === "session.idle") {
247
+ const story = await getActiveStory()
248
+ if (story && story.pendingTasks.length === 0) {
249
+ console.log(`[compaction] Story complete: ${story.title}`)
250
+ }
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ export default CustomCompactionPlugin
@@ -0,0 +1,169 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import path from "path"
3
+ import fs from "fs/promises"
4
+
5
+ /**
6
+ * File Indexer Plugin
7
+ *
8
+ * Automatically reindexes changed files for semantic search.
9
+ *
10
+ * Listens to:
11
+ * - file.edited - when agent edits a file
12
+ * - file.watcher.updated - when file changes on disk
13
+ * - tool.execute.after - after Edit/Write tool executes
14
+ */
15
+
16
+ // File extensions for each index type
17
+ const INDEX_EXTENSIONS: Record<string, string[]> = {
18
+ code: ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.scala', '.clj'],
19
+ docs: ['.md', '.mdx', '.txt', '.rst', '.adoc'],
20
+ config: ['.yaml', '.yml', '.json', '.toml', '.ini', '.xml'],
21
+ }
22
+
23
+ // Debounce map to batch rapid changes
24
+ const pendingFiles: Map<string, { indexName: string; timestamp: number }> = new Map()
25
+ const DEBOUNCE_MS = 2000
26
+
27
+ function getIndexForFile(filePath: string): string | null {
28
+ const ext = path.extname(filePath).toLowerCase()
29
+ for (const [indexName, extensions] of Object.entries(INDEX_EXTENSIONS)) {
30
+ if (extensions.includes(ext)) {
31
+ return indexName
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ async function isVectorizerInstalled(projectRoot: string): Promise<boolean> {
38
+ try {
39
+ await fs.access(path.join(projectRoot, ".opencode", "vectorizer", "node_modules"))
40
+ return true
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ async function processPendingFiles(projectRoot: string): Promise<void> {
47
+ if (pendingFiles.size === 0) return
48
+
49
+ const now = Date.now()
50
+ const filesToProcess: Map<string, string[]> = new Map()
51
+
52
+ for (const [filePath, info] of pendingFiles.entries()) {
53
+ if (now - info.timestamp >= DEBOUNCE_MS) {
54
+ const files = filesToProcess.get(info.indexName) || []
55
+ files.push(filePath)
56
+ filesToProcess.set(info.indexName, files)
57
+ pendingFiles.delete(filePath)
58
+ }
59
+ }
60
+
61
+ if (filesToProcess.size === 0) return
62
+
63
+ try {
64
+ const vectorizerModule = path.join(projectRoot, ".opencode", "vectorizer", "index.js")
65
+ const { CodebaseIndexer } = await import(`file://${vectorizerModule}`)
66
+
67
+ for (const [indexName, files] of filesToProcess.entries()) {
68
+ const indexer = await new CodebaseIndexer(projectRoot, indexName).init()
69
+
70
+ for (const filePath of files) {
71
+ try {
72
+ const wasIndexed = await indexer.indexSingleFile(filePath)
73
+ if (wasIndexed) {
74
+ console.log(`[file-indexer] ✅ Reindexed: ${path.relative(projectRoot, filePath)} -> ${indexName}`)
75
+ }
76
+ } catch (e) {
77
+ console.warn(`[file-indexer] ❌ Failed: ${filePath}: ${(e as Error).message}`)
78
+ }
79
+ }
80
+
81
+ await indexer.unloadModel()
82
+ }
83
+ } catch (e) {
84
+ console.warn(`[file-indexer] ❌ Error: ${(e as Error).message}`)
85
+ }
86
+ }
87
+
88
+ export const FileIndexerPlugin: Plugin = async ({ directory }) => {
89
+ let processingTimeout: NodeJS.Timeout | null = null
90
+
91
+ // Log plugin initialization
92
+ console.log(`[file-indexer] 🚀 Plugin loaded for: ${directory}`)
93
+
94
+ function queueFileForIndexing(filePath: string): void {
95
+ const relativePath = path.relative(directory, filePath)
96
+
97
+ // Skip ignored directories
98
+ if (
99
+ relativePath.startsWith('node_modules') ||
100
+ relativePath.startsWith('.git') ||
101
+ relativePath.startsWith('.opencode/vectors') ||
102
+ relativePath.startsWith('.opencode/vectorizer') ||
103
+ relativePath.startsWith('dist') ||
104
+ relativePath.startsWith('build')
105
+ ) {
106
+ return
107
+ }
108
+
109
+ const indexName = getIndexForFile(filePath)
110
+ if (!indexName) return
111
+
112
+ console.log(`[file-indexer] 📝 Queued: ${relativePath} -> ${indexName}`)
113
+
114
+ pendingFiles.set(filePath, { indexName, timestamp: Date.now() })
115
+
116
+ if (processingTimeout) {
117
+ clearTimeout(processingTimeout)
118
+ }
119
+ processingTimeout = setTimeout(async () => {
120
+ if (await isVectorizerInstalled(directory)) {
121
+ await processPendingFiles(directory)
122
+ }
123
+ }, DEBOUNCE_MS + 100)
124
+ }
125
+
126
+ return {
127
+ /**
128
+ * Event handler for file events
129
+ */
130
+ event: async ({ event }) => {
131
+ console.log(`[file-indexer] 📨 Event: ${event.type}`)
132
+
133
+ // file.edited - when agent edits a file via Edit tool
134
+ if (event.type === "file.edited") {
135
+ const filePath = event.data?.path || event.data?.filePath
136
+ if (filePath) {
137
+ console.log(`[file-indexer] 📝 file.edited: ${filePath}`)
138
+ queueFileForIndexing(filePath)
139
+ }
140
+ }
141
+
142
+ // file.watcher.updated - when file changes on disk (external or internal)
143
+ if (event.type === "file.watcher.updated") {
144
+ const filePath = event.data?.path || event.data?.filePath
145
+ if (filePath) {
146
+ console.log(`[file-indexer] 👁️ file.watcher.updated: ${filePath}`)
147
+ queueFileForIndexing(filePath)
148
+ }
149
+ }
150
+ },
151
+
152
+ /**
153
+ * Hook: After any tool executes
154
+ */
155
+ "tool.execute.after": async (input, output) => {
156
+ const toolName = input.tool?.toLowerCase()
157
+ const filePath = input.args?.filePath
158
+
159
+ console.log(`[file-indexer] 🔧 tool.execute.after: ${toolName}`)
160
+
161
+ if ((toolName === "edit" || toolName === "write" || toolName === "patch") && filePath) {
162
+ console.log(`[file-indexer] 📝 Tool edited file: ${filePath}`)
163
+ queueFileForIndexing(filePath)
164
+ }
165
+ },
166
+ }
167
+ }
168
+
169
+ export default FileIndexerPlugin