@comfanion/workflow 4.38.3-dev.2 → 4.38.4-dev.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.
@@ -1,1080 +0,0 @@
1
- import type { Plugin } from "@opencode-ai/plugin"
2
- import { readFile, access, readdir, appendFile } from "fs/promises"
3
- import { join } from "path"
4
-
5
- // Debug logging to file
6
- async function log(directory: string, message: string): Promise<void> {
7
- const logPath = join(directory, ".opencode", "compaction.log")
8
- const timestamp = new Date().toISOString()
9
- try {
10
- await appendFile(logPath, `[${timestamp}] ${message}\n`)
11
- } catch {
12
- // ignore logging errors
13
- }
14
- }
15
-
16
- // Service agents that should be ignored
17
- const SERVICE_AGENTS = ["title", "compaction", "summary", "system"]
18
-
19
- function isRealAgent(agent: string | null): boolean {
20
- if (!agent) return false
21
- return !SERVICE_AGENTS.includes(agent.toLowerCase())
22
- }
23
-
24
- interface TaskStatus {
25
- id: string
26
- content: string
27
- status: "pending" | "in_progress" | "completed" | "cancelled"
28
- priority: string
29
- }
30
-
31
- interface StoryContext {
32
- path: string
33
- title: string
34
- status: string
35
- currentTask: string | null
36
- completedTasks: string[]
37
- pendingTasks: string[]
38
- acceptanceCriteria: string[]
39
- fullContent: string
40
- }
41
-
42
- interface SessionState {
43
- command: string | null // /dev-sprint, /dev-epic, /dev-story
44
- agent: string | null
45
- sprint: { number: number; status: string } | null
46
- epic: { id: string; title: string; file: string; progress: string } | null
47
- story: { id: string; title: string; file: string; current_task: string | null; completed_tasks: string[]; pending_tasks: string[] } | null
48
- next_action: string | null
49
- key_decisions: string[]
50
- }
51
-
52
- interface SessionContext {
53
- todos: TaskStatus[]
54
- story: StoryContext | null
55
- sessionState: SessionState | null
56
- relevantFiles: string[]
57
- activeAgent: string | null
58
- activeCommand: string | null // /dev-story, /dev-epic, /dev-sprint
59
- }
60
-
61
- // Base files ALL agents need after compaction (to remember who they are)
62
- const BASE_FILES = [
63
- "CLAUDE.md", // Project rules, coding standards
64
- "AGENTS.md", // Agent personas and how they work
65
- ]
66
-
67
- // Agent-specific file priorities (added to BASE_FILES)
68
- // These are OPTIONAL files for context, not mandatory
69
- const AGENT_FILES: Record<string, string[]> = {
70
- dev: [
71
- ...BASE_FILES,
72
- "docs/coding-standards/README.md",
73
- "docs/coding-standards/patterns.md",
74
- // NO prd.md, NO architecture.md - too large, story has context
75
- // story path added dynamically
76
- ],
77
- coder: [
78
- ...BASE_FILES,
79
- "docs/coding-standards/patterns.md",
80
- ],
81
- architect: [
82
- ...BASE_FILES,
83
- "docs/architecture.md",
84
- "docs/prd.md",
85
- "docs/coding-standards/README.md",
86
- "docs/architecture/adr", // directory
87
- ],
88
- pm: [
89
- ...BASE_FILES,
90
- "docs/prd.md",
91
- "docs/architecture.md",
92
- "docs/sprint-artifacts/sprint-status.yaml",
93
- "docs/sprint-artifacts/backlog", // directory
94
- ],
95
- analyst: [
96
- ...BASE_FILES,
97
- "docs/requirements/requirements.md",
98
- "docs/prd.md",
99
- ],
100
- researcher: [
101
- ...BASE_FILES,
102
- "docs/prd.md",
103
- "docs/research", // directory
104
- ],
105
- crawler: [
106
- ...BASE_FILES,
107
- ],
108
- "change-manager": [
109
- ...BASE_FILES,
110
- "docs/prd.md",
111
- "docs/architecture.md",
112
- ],
113
- }
114
-
115
- // Default files for unknown agents
116
- const DEFAULT_FILES = [
117
- ...BASE_FILES,
118
- "docs/prd.md",
119
- "docs/architecture.md",
120
- ]
121
-
122
- // Files agent MUST Read after compaction (commands generated)
123
- // MINIMAL CONTEXT: ~70KB for dev, not 200KB+
124
- // Note: coding-standards/README.md is standard path created by /coding-standards command
125
- const MUST_READ_FILES: Record<string, string[]> = {
126
- dev: [
127
- "AGENTS.md",
128
- "CLAUDE.md",
129
- "docs/coding-standards/README.md", // if exists
130
- // story/epic state path added dynamically
131
- ],
132
- coder: [
133
- "AGENTS.md",
134
- "CLAUDE.md",
135
- "docs/coding-standards/README.md",
136
- ],
137
- architect: [
138
- "AGENTS.md",
139
- "CLAUDE.md",
140
- "docs/prd.md",
141
- "docs/architecture.md",
142
- ],
143
- pm: [
144
- "AGENTS.md",
145
- "CLAUDE.md",
146
- "docs/prd.md",
147
- ],
148
- analyst: [
149
- "AGENTS.md",
150
- "CLAUDE.md",
151
- "docs/prd.md",
152
- ],
153
- researcher: [
154
- "AGENTS.md",
155
- "CLAUDE.md",
156
- ],
157
- default: [
158
- "AGENTS.md",
159
- "CLAUDE.md",
160
- ],
161
- }
162
-
163
- /**
164
- * Custom Compaction Plugin
165
- *
166
- * Agent-aware context preservation during session compaction:
167
- * - Tracks active agent via chat.message hook
168
- * - Generates MANDATORY Read commands for critical files
169
- * - Preserves agent-specific documentation files
170
- * - Provides detailed story/task info for dev agent
171
- * - Generates targeted continuation prompts
172
- */
173
- export const CustomCompactionPlugin: Plugin = async (ctx) => {
174
- const { directory } = ctx
175
-
176
- // Track the last active agent
177
- let lastActiveAgent: string | null = null
178
- let lastSessionId: string | null = null
179
-
180
- /**
181
- * Generate Read commands that agent MUST execute after compaction
182
- */
183
- async function generateReadCommands(agent: string | null, story: StoryContext | null, activeCommand: string | null, sessionState: SessionState | null): Promise<string> {
184
- const agentKey = (typeof agent === 'string' ? agent.toLowerCase() : null) || "default"
185
- const filesToRead = [...(MUST_READ_FILES[agentKey] || MUST_READ_FILES.default)]
186
-
187
- // For dev/coder: add command file first
188
- if ((agentKey === "dev" || agentKey === "coder") && activeCommand) {
189
- const commandFile = activeCommand.replace("/", "") + ".md"
190
- filesToRead.unshift(`.opencode/commands/${commandFile}`)
191
- }
192
-
193
- // For dev/coder: add session-state.yaml as priority read
194
- if ((agentKey === "dev" || agentKey === "coder")) {
195
- // Session state is most important — has everything
196
- filesToRead.unshift(".opencode/session-state.yaml")
197
-
198
- // Then story file (from session state or StoryContext)
199
- const storyFile = sessionState?.story?.file || story?.path
200
- if (storyFile) {
201
- filesToRead.unshift(storyFile) // Story first!
202
- }
203
-
204
- // Epic state as backup (only if no session state)
205
- if (!sessionState) {
206
- const epicState = await getActiveEpicState()
207
- if (epicState) {
208
- filesToRead.unshift(epicState.statePath.replace(directory + "/", ""))
209
- }
210
- }
211
- }
212
-
213
- const commands = filesToRead.map((f, i) => `${i + 1}. Read("${f}")`).join("\n")
214
-
215
- return `## ⚠️ MANDATORY: Execute these Read commands FIRST
216
-
217
- Before doing ANYTHING else, you MUST read these files to restore context:
218
-
219
- ${commands}
220
-
221
- DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
222
- }
223
-
224
- async function getTodoList(): Promise<TaskStatus[]> {
225
- try {
226
- const todoPath = join(directory, ".opencode", "state", "todos.json")
227
- const content = await readFile(todoPath, "utf-8")
228
- return JSON.parse(content)
229
- } catch {
230
- return []
231
- }
232
- }
233
-
234
- interface EpicState {
235
- statePath: string
236
- epicId: string
237
- epicTitle: string
238
- status: string
239
- currentStoryIndex: number
240
- totalStories: number
241
- nextAction: string | null
242
- nextStoryPath: string | null
243
- completedCount: number
244
- pendingCount: number
245
- }
246
-
247
- async function getActiveEpicState(): Promise<EpicState | null> {
248
- try {
249
- // Search for epic state files in all sprint folders
250
- const sprintArtifactsPath = join(directory, "docs", "sprint-artifacts")
251
- const entries = await readdir(sprintArtifactsPath)
252
-
253
- for (const entry of entries) {
254
- if (entry.startsWith("sprint-")) {
255
- const statePath = join(sprintArtifactsPath, entry, ".sprint-state")
256
- try {
257
- const stateFiles = await readdir(statePath)
258
- for (const stateFile of stateFiles) {
259
- if (stateFile.endsWith("-state.yaml")) {
260
- const fullPath = join(statePath, stateFile)
261
- const content = await readFile(fullPath, "utf-8")
262
-
263
- // Check if this epic is in-progress (normalize variants)
264
- const contentLower = content.toLowerCase()
265
- if (contentLower.includes("status: \"in-progress\"") || contentLower.includes("status: in-progress") || contentLower.includes("status: \"in_progress\"") || contentLower.includes("status: in_progress")) {
266
- // Parse epic state
267
- const epicIdMatch = content.match(/epic_id:\s*["']?([^"'\n]+)["']?/i)
268
- const epicTitleMatch = content.match(/epic_title:\s*["']?([^"'\n]+)["']?/i)
269
- const statusMatch = content.match(/status:\s*["']?([^"'\n]+)["']?/i)
270
- const currentIndexMatch = content.match(/current_story_index:\s*(\d+)/i)
271
- const totalStoriesMatch = content.match(/total_stories:\s*(\d+)/i)
272
- const nextActionMatch = content.match(/next_action:\s*["']?([^"'\n]+)["']?/i)
273
-
274
- // Count completed/pending stories
275
- const completedSection = content.match(/completed_stories:([\s\S]*?)(?=pending_stories:|$)/i)
276
- const pendingSection = content.match(/pending_stories:([\s\S]*?)(?=\n\w+:|$)/i)
277
-
278
- const completedCount = completedSection
279
- ? (completedSection[1].match(/- path:/g) || []).length
280
- : 0
281
- const pendingCount = pendingSection
282
- ? (pendingSection[1].match(/- path:/g) || []).length
283
- : 0
284
-
285
- // Extract next story path from next_action
286
- let nextStoryPath: string | null = null
287
- if (nextActionMatch) {
288
- const actionText = nextActionMatch[1]
289
- const storyFileMatch = actionText.match(/story-[\w-]+\.md/i)
290
- if (storyFileMatch) {
291
- // Find full path in pending_stories
292
- const pathMatch = content.match(new RegExp(`path:\\s*["']?([^"'\\n]*${storyFileMatch[0]}[^"'\\n]*)["']?`, 'i'))
293
- if (pathMatch) {
294
- nextStoryPath = pathMatch[1]
295
- }
296
- }
297
- }
298
-
299
- return {
300
- statePath: fullPath.replace(directory + "/", ""),
301
- epicId: epicIdMatch?.[1] || "unknown",
302
- epicTitle: epicTitleMatch?.[1] || "Unknown Epic",
303
- status: statusMatch?.[1] || "in-progress",
304
- currentStoryIndex: currentIndexMatch ? parseInt(currentIndexMatch[1]) : 0,
305
- totalStories: totalStoriesMatch ? parseInt(totalStoriesMatch[1]) : 0,
306
- nextAction: nextActionMatch?.[1] || null,
307
- nextStoryPath,
308
- completedCount,
309
- pendingCount
310
- }
311
- }
312
- }
313
- }
314
- } catch {
315
- // No .sprint-state folder in this sprint
316
- }
317
- }
318
- }
319
- return null
320
- } catch {
321
- return null
322
- }
323
- }
324
-
325
- /**
326
- * PRIMARY source: session-state.yaml written by AI agent.
327
- * Falls back to getActiveEpicState() + getActiveStory() if missing.
328
- */
329
- async function getSessionState(): Promise<SessionState | null> {
330
- try {
331
- const statePath = join(directory, ".opencode", "session-state.yaml")
332
- const content = await readFile(statePath, "utf-8")
333
- await log(directory, ` session-state.yaml found, parsing...`)
334
-
335
- // Simple regex YAML parser for flat/nested fields
336
- const str = (key: string): string | null => {
337
- const m = content.match(new RegExp(`^${key}:\\s*["']?(.+?)["']?\\s*$`, 'm'))
338
- return m ? m[1].trim() : null
339
- }
340
- const nested = (parent: string, key: string): string | null => {
341
- const section = content.match(new RegExp(`^${parent}:\\s*\\n((?: .+\\n?)*)`, 'm'))
342
- if (!section) return null
343
- const m = section[1].match(new RegExp(`^\\s+${key}:\\s*["']?(.+?)["']?\\s*$`, 'm'))
344
- return m ? m[1].trim() : null
345
- }
346
- const list = (parent: string, key: string): string[] => {
347
- // First try inline format: key: [T1, T2] or key: T1, T2
348
- const val = nested(parent, key)
349
- if (val) {
350
- const clean = val.replace(/^\[/, '').replace(/\]$/, '')
351
- return clean.split(',').map(s => s.trim()).filter(Boolean)
352
- }
353
- // Fallback: YAML block list under parent.key
354
- const section = content.match(new RegExp(`^${parent}:\\s*\\n([\\s\\S]*?)(?=^\\w+:|$)`, 'm'))
355
- if (!section) return []
356
- const keyBlock = section[1].match(new RegExp(`^\\s+${key}:\\s*\\n((?:\\s+-\\s+.+\\n?)*)`, 'm'))
357
- if (!keyBlock) return []
358
- return [...keyBlock[1].matchAll(/^\s+-\s+(.+)$/gm)].map(m => m[1].trim())
359
- }
360
-
361
- // Parse key_decisions as list
362
- const decisions: string[] = []
363
- const decMatch = content.match(/^key_decisions:\s*\n((?:\s+-\s*.+\n?)*)/m)
364
- if (decMatch) {
365
- const items = decMatch[1].matchAll(/^\s+-\s*["']?(.+?)["']?\s*$/gm)
366
- for (const item of items) {
367
- decisions.push(item[1])
368
- }
369
- }
370
-
371
- const state: SessionState = {
372
- command: str('command'),
373
- agent: str('agent'),
374
- sprint: nested('sprint', 'number') ? {
375
- number: parseInt(nested('sprint', 'number') || '0'),
376
- status: nested('sprint', 'status') || 'unknown'
377
- } : null,
378
- epic: nested('epic', 'id') ? {
379
- id: nested('epic', 'id') || '',
380
- title: nested('epic', 'title') || '',
381
- file: nested('epic', 'file') || '',
382
- progress: nested('epic', 'progress') || ''
383
- } : null,
384
- story: nested('story', 'id') ? {
385
- id: nested('story', 'id') || '',
386
- title: nested('story', 'title') || '',
387
- file: nested('story', 'file') || '',
388
- current_task: nested('story', 'current_task'),
389
- completed_tasks: list('story', 'completed_tasks'),
390
- pending_tasks: list('story', 'pending_tasks')
391
- } : null,
392
- next_action: str('next_action'),
393
- key_decisions: decisions
394
- }
395
-
396
- await log(directory, ` parsed: command=${state.command}, epic=${state.epic?.id}, story=${state.story?.id}`)
397
- return state
398
- } catch {
399
- await log(directory, ` session-state.yaml not found, using fallback`)
400
- return null
401
- }
402
- }
403
-
404
- async function getActiveStory(): Promise<StoryContext | null> {
405
- try {
406
- let storyPath: string | null = null
407
-
408
- // First, try epic state file for story path
409
- const epicState = await getActiveEpicState()
410
- if (epicState?.nextStoryPath) {
411
- storyPath = epicState.nextStoryPath
412
- }
413
-
414
- // Fallback: try sprint-status.yaml
415
- if (!storyPath) {
416
- try {
417
- const sprintStatusPath = join(directory, "docs", "sprint-artifacts", "sprint-status.yaml")
418
- const content = await readFile(sprintStatusPath, "utf-8")
419
- const inProgressMatch = content.match(/status:\s*in-progress[\s\S]*?path:\s*["']?([^"'\n]+)["']?/i)
420
- if (inProgressMatch) {
421
- storyPath = inProgressMatch[1]
422
- }
423
- } catch {
424
- // No sprint-status.yaml
425
- }
426
- }
427
-
428
- if (!storyPath) return null
429
-
430
- // Parse story file
431
- const storyContent = await readFile(join(directory, storyPath), "utf-8")
432
- const titleMatch = storyContent.match(/^#\s+(.+)/m)
433
- const statusMatch = storyContent.match(/\*\*Status:\*\*\s*([\w-]+)/i)
434
-
435
- const completedTasks: string[] = []
436
- const pendingTasks: string[] = []
437
- let currentTask: string | null = null
438
-
439
- const taskRegex = /- \[([ x])\]\s+\*\*T(\d+)\*\*[:\s]+(.+?)(?=\n|$)/g
440
- let match
441
- while ((match = taskRegex.exec(storyContent)) !== null) {
442
- const [, checked, taskId, taskName] = match
443
- const taskInfo = `T${taskId}: ${taskName.trim()}`
444
- if (checked === "x") {
445
- completedTasks.push(taskInfo)
446
- } else {
447
- if (!currentTask) currentTask = taskInfo
448
- pendingTasks.push(taskInfo)
449
- }
450
- }
451
-
452
- const acceptanceCriteria: string[] = []
453
- const acSection = storyContent.match(/## Acceptance Criteria[\s\S]*?(?=##|$)/i)
454
- if (acSection) {
455
- const acRegex = /- \[([ x])\]\s+(.+?)(?=\n|$)/g
456
- while ((match = acRegex.exec(acSection[0])) !== null) {
457
- const [, checked, criteria] = match
458
- acceptanceCriteria.push(`${checked === "x" ? "✅" : "⬜"} ${criteria.trim()}`)
459
- }
460
- }
461
-
462
- return {
463
- path: storyPath,
464
- title: titleMatch?.[1] || "Unknown Story",
465
- status: statusMatch?.[1] || "unknown",
466
- currentTask,
467
- completedTasks,
468
- pendingTasks,
469
- acceptanceCriteria,
470
- fullContent: storyContent
471
- }
472
- } catch {
473
- return null
474
- }
475
- }
476
-
477
- async function getRelevantFiles(agent: string | null, story: StoryContext | null): Promise<string[]> {
478
- const relevantPaths: string[] = []
479
- const agentKey = (typeof agent === 'string' ? agent.toLowerCase() : null) || "default"
480
- const filesToCheck = AGENT_FILES[agentKey] || DEFAULT_FILES
481
-
482
- for (const filePath of filesToCheck) {
483
- try {
484
- const fullPath = join(directory, filePath)
485
- const stat = await access(fullPath).then(() => true).catch(() => false)
486
- if (stat) {
487
- // Check if it's a directory
488
- try {
489
- const entries = await readdir(fullPath)
490
- // Add first 5 files from directory
491
- for (const entry of entries.slice(0, 5)) {
492
- if (entry.endsWith('.md') || entry.endsWith('.yaml')) {
493
- relevantPaths.push(join(filePath, entry))
494
- }
495
- }
496
- } catch {
497
- // It's a file, add it
498
- relevantPaths.push(filePath)
499
- }
500
- }
501
- } catch {
502
- // File/dir doesn't exist, skip
503
- }
504
- }
505
-
506
- // Always add story path for dev/coder
507
- if (story && (agentKey === "dev" || agentKey === "coder")) {
508
- if (!relevantPaths.includes(story.path)) {
509
- relevantPaths.unshift(story.path) // Add at beginning
510
- }
511
- }
512
-
513
- return relevantPaths
514
- }
515
-
516
- async function detectActiveCommand(todos: TaskStatus[], epicState: EpicState | null): Promise<string | null> {
517
- // Detect command from TODO structure
518
- if (todos.length === 0) return null
519
-
520
- // Check if TODOs are epics (sprint mode)
521
- const hasEpicTodos = todos.some(t => t.content.toLowerCase().includes("epic"))
522
- if (hasEpicTodos) return "/dev-sprint"
523
-
524
- // Check if TODOs are stories (epic mode)
525
- const hasStoryTodos = todos.some(t => t.content.toLowerCase().includes("story"))
526
- if (hasStoryTodos || epicState) return "/dev-epic"
527
-
528
- // Regular story mode
529
- return "/dev-story"
530
- }
531
-
532
- async function buildContext(agent: string | null): Promise<SessionContext> {
533
- // PRIMARY: try session-state.yaml (written by AI agent)
534
- const sessionState = await getSessionState()
535
-
536
- const [todos, story] = await Promise.all([
537
- getTodoList(),
538
- // If session state has story path, use it; otherwise parse files
539
- sessionState?.story?.file
540
- ? readStoryFromPath(sessionState.story.file)
541
- : getActiveStory()
542
- ])
543
-
544
- const epicState = await getActiveEpicState()
545
- const relevantFiles = await getRelevantFiles(agent, story)
546
-
547
- // Command: from session state or detected from TODOs
548
- const activeCommand = sessionState?.command || await detectActiveCommand(todos, epicState)
549
-
550
- return { todos, story, sessionState, relevantFiles, activeAgent: agent, activeCommand }
551
- }
552
-
553
- /** Read and parse a story file by path */
554
- async function readStoryFromPath(storyPath: string): Promise<StoryContext | null> {
555
- try {
556
- const storyContent = await readFile(join(directory, storyPath), "utf-8")
557
- const titleMatch = storyContent.match(/^#\s+(.+)/m)
558
- const statusMatch = storyContent.match(/\*\*Status:\*\*\s*([\w-]+)/i)
559
-
560
- const completedTasks: string[] = []
561
- const pendingTasks: string[] = []
562
- let currentTask: string | null = null
563
-
564
- const taskRegex = /- \[([ x])\]\s+\*\*T(\d+)\*\*[:\s]+(.+?)(?=\n|$)/g
565
- let match
566
- while ((match = taskRegex.exec(storyContent)) !== null) {
567
- const [, checked, taskId, taskName] = match
568
- const taskInfo = `T${taskId}: ${taskName.trim()}`
569
- if (checked === "x") {
570
- completedTasks.push(taskInfo)
571
- } else {
572
- if (!currentTask) currentTask = taskInfo
573
- pendingTasks.push(taskInfo)
574
- }
575
- }
576
-
577
- const acceptanceCriteria: string[] = []
578
- const acSection = storyContent.match(/## Acceptance Criteria[\s\S]*?(?=##|$)/i)
579
- if (acSection) {
580
- const acRegex = /- \[([ x])\]\s+(.+?)(?=\n|$)/g
581
- while ((match = acRegex.exec(acSection[0])) !== null) {
582
- const [, checked, criteria] = match
583
- acceptanceCriteria.push(`${checked === "x" ? "✅" : "⬜"} ${criteria.trim()}`)
584
- }
585
- }
586
-
587
- return {
588
- path: storyPath,
589
- title: titleMatch?.[1] || "Unknown Story",
590
- status: statusMatch?.[1] || "unknown",
591
- currentTask,
592
- completedTasks,
593
- pendingTasks,
594
- acceptanceCriteria,
595
- fullContent: storyContent
596
- }
597
- } catch {
598
- return null
599
- }
600
- }
601
-
602
- async function formatDevContext(ctx: SessionContext): Promise<string> {
603
- const sections: string[] = []
604
- const ss = ctx.sessionState
605
-
606
- // SESSION STATE available — use it (primary source)
607
- if (ss) {
608
- let header = `## 🎯 Session State (from session-state.yaml)\n`
609
- header += `**Command:** ${ss.command || "unknown"}\n`
610
-
611
- if (ss.sprint) {
612
- header += `**Sprint:** #${ss.sprint.number} (${ss.sprint.status})\n`
613
- }
614
- if (ss.epic) {
615
- header += `**Epic:** ${ss.epic.id} — ${ss.epic.title}\n`
616
- header += `**Epic File:** \`${ss.epic.file}\`\n`
617
- header += `**Epic Progress:** ${ss.epic.progress}\n`
618
- }
619
- if (ss.story) {
620
- header += `\n**Story:** ${ss.story.id} — ${ss.story.title}\n`
621
- header += `**Story File:** \`${ss.story.file}\` ← READ THIS FIRST\n`
622
- header += `**Current Task:** ${ss.story.current_task || "all done"}\n`
623
- header += `**Completed:** ${ss.story.completed_tasks.join(", ") || "none"}\n`
624
- header += `**Pending:** ${ss.story.pending_tasks.join(", ") || "none"}\n`
625
- }
626
-
627
- header += `\n### Next Action (DO THIS NOW)\n\`\`\`\n${ss.next_action || "Check TODO list"}\n\`\`\`\n`
628
-
629
- if (ss.key_decisions.length > 0) {
630
- header += `\n### Key Technical Decisions\n`
631
- header += ss.key_decisions.map(d => `- ${d}`).join("\n")
632
- }
633
-
634
- sections.push(header)
635
- }
636
- // FALLBACK: parse epic state file
637
- else {
638
- const epicState = await getActiveEpicState()
639
- if (epicState) {
640
- const progress = epicState.totalStories > 0
641
- ? ((epicState.completedCount / epicState.totalStories) * 100).toFixed(0)
642
- : 0
643
-
644
- sections.push(`## 🎯 Epic Workflow: ${epicState.epicTitle}
645
-
646
- **Epic ID:** ${epicState.epicId}
647
- **Epic State:** \`${epicState.statePath}\` ← READ THIS FIRST
648
- **Progress:** ${progress}% (${epicState.completedCount}/${epicState.totalStories} stories)
649
-
650
- ### Next Action (DO THIS NOW)
651
- \`\`\`
652
- ${epicState.nextAction || "All stories complete - run epic integration tests"}
653
- \`\`\`
654
-
655
- ${epicState.nextStoryPath ? `**Next Story:** \`${epicState.nextStoryPath}\` ← READ THIS SECOND` : ""}
656
-
657
- ### Epic Progress
658
- **Completed Stories:** ${epicState.completedCount}
659
- **Pending Stories:** ${epicState.pendingCount}
660
- **Current Index:** ${epicState.currentStoryIndex}
661
-
662
- ---
663
-
664
- 💡 **Note:** If this is part of /dev-sprint, after epic completes:
665
- 1. Update sprint-status.yaml (mark epic done)
666
- 2. Continue to next epic automatically`)
667
- }
668
- }
669
-
670
- if (!ss && ctx.story) {
671
- // Regular story mode
672
- const s = ctx.story
673
- const total = s.completedTasks.length + s.pendingTasks.length
674
- const progress = total > 0 ? (s.completedTasks.length / total * 100).toFixed(0) : 0
675
-
676
- sections.push(`## 🎯 Active Story: ${s.title}
677
-
678
- **Path:** \`${s.path}\` ← READ THIS FIRST
679
- **Status:** ${s.status}
680
- **Progress:** ${progress}% (${s.completedTasks.length}/${total} tasks)
681
-
682
- ### Current Task (DO THIS NOW)
683
- \`\`\`
684
- ${s.currentTask || "All tasks complete - run final tests"}
685
- \`\`\`
686
-
687
- ### Task Breakdown
688
- **Completed:**
689
- ${s.completedTasks.length > 0 ? s.completedTasks.map(t => `✅ ${t}`).join("\n") : "None yet"}
690
-
691
- **Remaining:**
692
- ${s.pendingTasks.length > 0 ? s.pendingTasks.map(t => `⬜ ${t}`).join("\n") : "All done!"}
693
-
694
- ### Acceptance Criteria
695
- ${s.acceptanceCriteria.length > 0 ? s.acceptanceCriteria.join("\n") : "Check story file"}`)
696
- }
697
-
698
- if (ctx.todos.length > 0) {
699
- const inProgress = ctx.todos.filter(t => t.status === "in_progress")
700
- const pending = ctx.todos.filter(t => t.status === "pending")
701
-
702
- if (inProgress.length > 0 || pending.length > 0) {
703
- sections.push(`## 📋 Session Tasks
704
- **In Progress:** ${inProgress.map(t => t.content).join(", ") || "None"}
705
- **Pending:** ${pending.map(t => t.content).join(", ") || "None"}`)
706
- }
707
- }
708
-
709
- return sections.join("\n\n---\n\n")
710
- }
711
-
712
- function formatArchitectContext(ctx: SessionContext): string {
713
- return `## 🏗️ Architecture Session
714
-
715
- **Focus:** System design, ADRs, technical decisions
716
-
717
- ### Critical Files (MUST re-read)
718
- ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}
719
-
720
- ### Resume Actions
721
- 1. Review docs/architecture.md for current state
722
- 2. Check docs/architecture/adr/ for recent decisions
723
- 3. Continue from last architectural discussion`
724
- }
725
-
726
- function formatPmContext(ctx: SessionContext): string {
727
- return `## 📋 PM Session
728
-
729
- **Focus:** PRD, epics, stories, sprint planning
730
-
731
- ### Critical Files (MUST re-read)
732
- ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}
733
-
734
- ### Resume Actions
735
- 1. Check docs/sprint-artifacts/sprint-status.yaml
736
- 2. Review current sprint progress
737
- 3. Continue from last planning activity`
738
- }
739
-
740
- function formatAnalystContext(ctx: SessionContext): string {
741
- return `## 📊 Analyst Session
742
-
743
- **Focus:** Requirements gathering, validation
744
-
745
- ### Critical Files (MUST re-read)
746
- ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}
747
-
748
- ### Resume Actions
749
- 1. Review docs/requirements/requirements.md
750
- 2. Check for pending stakeholder questions
751
- 3. Continue requirements elicitation`
752
- }
753
-
754
- function formatResearcherContext(ctx: SessionContext): string {
755
- return `## 🔍 Research Session
756
-
757
- **Focus:** Technical, market, or domain research
758
-
759
- ### Critical Files (MUST re-read)
760
- ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}
761
-
762
- ### Resume Actions
763
- 1. Review docs/research/ folder
764
- 2. Check research objectives
765
- 3. Continue investigation`
766
- }
767
-
768
- function formatGenericContext(ctx: SessionContext): string {
769
- const sections: string[] = []
770
-
771
- if (ctx.todos.length > 0) {
772
- const inProgress = ctx.todos.filter(t => t.status === "in_progress")
773
- const completed = ctx.todos.filter(t => t.status === "completed")
774
- const pending = ctx.todos.filter(t => t.status === "pending")
775
-
776
- sections.push(`## Task Status
777
- **In Progress:** ${inProgress.length > 0 ? inProgress.map(t => t.content).join(", ") : "None"}
778
- **Completed:** ${completed.length > 0 ? completed.map(t => `✅ ${t.content}`).join("\n") : "None"}
779
- **Pending:** ${pending.length > 0 ? pending.map(t => `⬜ ${t.content}`).join("\n") : "None"}`)
780
- }
781
-
782
- if (ctx.relevantFiles.length > 0) {
783
- sections.push(`## Critical Files (MUST re-read)
784
- ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}`)
785
- }
786
-
787
- return sections.join("\n\n---\n\n")
788
- }
789
-
790
- async function formatContext(ctx: SessionContext): Promise<string> {
791
- const agent = ctx.activeAgent?.toLowerCase()
792
-
793
- switch (agent) {
794
- case "dev":
795
- case "coder":
796
- return await formatDevContext(ctx)
797
- case "architect":
798
- return formatArchitectContext(ctx)
799
- case "pm":
800
- return formatPmContext(ctx)
801
- case "analyst":
802
- return formatAnalystContext(ctx)
803
- case "researcher":
804
- return formatResearcherContext(ctx)
805
- default:
806
- return formatGenericContext(ctx)
807
- }
808
- }
809
-
810
- async function formatInstructions(ctx: SessionContext): Promise<string> {
811
- const agent = ctx.activeAgent?.toLowerCase()
812
- const hasInProgressTasks = ctx.todos.some(t => t.status === "in_progress")
813
- const storyStatus = ctx.story?.status?.toLowerCase().replace(/_/g, '-') || ''
814
- const hasInProgressStory = storyStatus === "in-progress"
815
-
816
- // Check if we're in epic workflow
817
- const epicState = await getActiveEpicState()
818
-
819
- if (!hasInProgressTasks && !hasInProgressStory && !epicState) {
820
- return `## Status: COMPLETED ✅
821
-
822
- Previous task was completed successfully.
823
-
824
- **Next:**
825
- 1. Review completed work
826
- 2. Run validation/tests if applicable
827
- 3. Ask user for next task`
828
- }
829
-
830
- // Sprint workflow instructions
831
- if (ctx.activeCommand === "/dev-sprint" && (agent === "dev" || agent === "coder")) {
832
- const nextEpicTodo = ctx.todos.find(t => t.status === "in_progress" && t.content.toLowerCase().includes("epic"))
833
- return `## Status: SPRINT IN PROGRESS 🔄
834
-
835
- **Active Command:** ${ctx.activeCommand}
836
- **Active Agent:** @${agent}
837
- **Next Epic:** ${nextEpicTodo?.content || "check TODO"}
838
-
839
- ### Resume Protocol (AUTOMATIC - DO NOT ASK USER)
840
- 1. **Read command:** \`.opencode/commands/dev-sprint.md\`
841
- 2. **Read sprint-status.yaml**
842
- 3. **Find next epic** from TODO or sprint-status.yaml
843
- 4. **Execute epic** via /dev-epic workflow
844
- 5. **After epic done:**
845
- - Update sprint-status.yaml (mark epic done)
846
- - Update TODO (mark epic completed, next epic in_progress)
847
- - Continue next epic automatically
848
-
849
- ### DO NOT
850
- - Ask user what to do (TODO + sprint-status.yaml tell you)
851
- - Re-read completed epics
852
- - Wait for confirmation between epics (auto-continue)
853
-
854
- ### IMPORTANT
855
- This is /dev-sprint autopilot mode. Execute epics sequentially until sprint done.`
856
- }
857
-
858
- // Epic workflow instructions
859
- if ((ctx.activeCommand === "/dev-epic" || epicState) && (agent === "dev" || agent === "coder")) {
860
- return `## Status: EPIC IN PROGRESS 🔄
861
-
862
- **Active Command:** ${ctx.activeCommand || "/dev-epic"}
863
- **Active Agent:** @${agent}
864
- **Epic:** ${epicState?.epicTitle || "check epic state"}
865
- **Next Action:** ${epicState?.nextAction || "check TODO"}
866
-
867
- ### Resume Protocol (AUTOMATIC - DO NOT ASK USER)
868
- 1. **Read command:** \`.opencode/commands/dev-epic.md\`
869
- 2. **Read epic state:** \`${epicState?.statePath || "find in .sprint-state/"}\`
870
- 3. **Read next story:** \`${epicState?.nextStoryPath || "check epic state"}\`
871
- 4. **Load skill:** \`.opencode/skills/dev-story/SKILL.md\`
872
- 5. **Execute story** following /dev-story workflow
873
- 6. **After story done:**
874
- - Update epic state file (move story to completed)
875
- - Update TODO (mark story completed, next story in_progress)
876
- - Increment current_story_index
877
- - Set next_action to next story
878
- - Continue next story automatically
879
-
880
- ### DO NOT
881
- - Ask user what to do (epic state + TODO tell you)
882
- - Re-read completed stories
883
- - Re-read epic file (info in state)
884
- - Wait for confirmation between stories (auto-continue)
885
-
886
- ### IMPORTANT
887
- This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
888
- }
889
-
890
- // Dev-specific instructions (regular story)
891
- if ((agent === "dev" || agent === "coder") && ctx.story) {
892
- return `## Status: IN PROGRESS 🔄
893
-
894
- **Active Agent:** @${agent}
895
- **Story:** ${ctx.story.title}
896
- **Current Task:** ${ctx.story.currentTask}
897
-
898
- ### Resume Protocol
899
- 1. **Read story file:** \`${ctx.story.path}\`
900
- 2. **Load skill:** \`.opencode/skills/dev-story/SKILL.md\`
901
- 3. **Run tests first** to see current state
902
- 4. **Continue task:** ${ctx.story.currentTask}
903
- 5. **Follow TDD:** Red → Green → Refactor
904
-
905
- ### DO NOT
906
- - Start over from scratch
907
- - Skip reading the story file
908
- - Ignore existing tests`
909
- }
910
-
911
- // Generic instructions
912
- return `## Status: IN PROGRESS 🔄
913
-
914
- **Active Agent:** @${ctx.activeAgent || "unknown"}
915
-
916
- ### Resume Protocol
917
- 1. Read critical files listed above
918
- 2. Check previous messages for context
919
- 3. Continue from last action
920
- 4. Update todo/story when task complete`
921
- }
922
-
923
- function buildBriefing(agent: string | null, ss: SessionState | null, ctx: SessionContext, readCommands: string): string {
924
- const lines: string[] = []
925
-
926
- // 1. WHO you are
927
- if (agent) {
928
- lines.push(`You are @${agent} (→ .opencode/agents/${agent}.md).`)
929
- }
930
-
931
- // 2. WHAT you are doing
932
- if (ss) {
933
- const cmd = ss.command || "unknown command"
934
- if (ss.epic) {
935
- lines.push(`You are executing ${cmd} ${ss.epic.id}: ${ss.epic.title}.`)
936
- } else if (ss.story) {
937
- lines.push(`You are executing ${cmd} ${ss.story.id}: ${ss.story.title}.`)
938
- } else {
939
- lines.push(`You are executing ${cmd}.`)
940
- }
941
- } else if (ctx.activeCommand) {
942
- lines.push(`You are executing ${ctx.activeCommand}.`)
943
- }
944
-
945
- // 3. WHERE you stopped
946
- if (ss?.story) {
947
- const task = ss.story.current_task || "review"
948
- lines.push(`You were on story ${ss.story.id}: ${ss.story.title}, task ${task}.`)
949
- if (ss.story.completed_tasks.length > 0) {
950
- lines.push(`Completed: ${ss.story.completed_tasks.join(", ")}.`)
951
- }
952
- if (ss.story.pending_tasks.length > 0) {
953
- lines.push(`Remaining: ${ss.story.pending_tasks.join(", ")}.`)
954
- }
955
- } else if (ss?.epic) {
956
- lines.push(`Epic progress: ${ss.epic.progress}.`)
957
- } else if (ctx.story) {
958
- lines.push(`You were on story: ${ctx.story.title}, task ${ctx.story.currentTask || "review"}.`)
959
- }
960
-
961
- // 4. WHAT to do next
962
- if (ss?.next_action) {
963
- lines.push(`\nNext action: ${ss.next_action}`)
964
- }
965
-
966
- // 5. READ these files
967
- lines.push(`\n${readCommands}`)
968
-
969
- // 6. KEY DECISIONS (if any)
970
- if (ss?.key_decisions && ss.key_decisions.length > 0) {
971
- lines.push(`\nKey decisions from your session:`)
972
- for (const d of ss.key_decisions) {
973
- lines.push(`- ${d}`)
974
- }
975
- }
976
-
977
- // 7. TODO status (brief)
978
- if (ctx.todos.length > 0) {
979
- const inProgress = ctx.todos.filter(t => t.status === "in_progress")
980
- const pending = ctx.todos.filter(t => t.status === "pending")
981
- const completed = ctx.todos.filter(t => t.status === "completed")
982
- lines.push(`\nTODO: ${completed.length} done, ${inProgress.length} in progress, ${pending.length} pending.`)
983
- }
984
-
985
- // 8. RULES
986
- lines.push(`\nDO NOT ask user what to do. Read files above, then resume automatically.`)
987
-
988
- return lines.join("\n")
989
- }
990
-
991
- return {
992
- // Track active agent from chat messages
993
- "chat.message": async (input, output) => {
994
- await log(directory, `chat.message: agent=${input.agent}, sessionID=${input.sessionID}`)
995
- if (input.agent) {
996
- // Handle both string and object agent (e.g., { name: "dev" })
997
- const agent = typeof input.agent === 'string'
998
- ? input.agent
999
- : (input.agent as any)?.name || null
1000
-
1001
- // Only track real agents, not service agents
1002
- if (isRealAgent(agent)) {
1003
- lastActiveAgent = agent
1004
- lastSessionId = input.sessionID
1005
- await log(directory, ` -> tracked agent: ${lastActiveAgent}`)
1006
- } else {
1007
- await log(directory, ` -> ignored service agent: ${agent}`)
1008
- }
1009
- }
1010
- },
1011
-
1012
- // Also track from chat params (backup)
1013
- "chat.params": async (input, output) => {
1014
- await log(directory, `chat.params: agent=${input.agent}`)
1015
- if (input.agent) {
1016
- const agent = typeof input.agent === 'string'
1017
- ? input.agent
1018
- : (input.agent as any)?.name || null
1019
-
1020
- // Only track real agents, not service agents
1021
- if (isRealAgent(agent)) {
1022
- lastActiveAgent = agent
1023
- await log(directory, ` -> tracked agent: ${lastActiveAgent}`)
1024
- } else {
1025
- await log(directory, ` -> ignored service agent: ${agent}`)
1026
- }
1027
- }
1028
- },
1029
-
1030
- "experimental.session.compacting": async (input, output) => {
1031
- await log(directory, `=== COMPACTION STARTED ===`)
1032
- await log(directory, ` lastActiveAgent: ${lastActiveAgent}`)
1033
-
1034
- // Guard: ensure output.context is an array
1035
- if (!output.context) output.context = []
1036
-
1037
- // Use tracked agent or try to detect from session
1038
- const agent = lastActiveAgent
1039
- const ctx = await buildContext(agent)
1040
- ctx.activeAgent = agent
1041
-
1042
- await log(directory, ` story: ${ctx.story?.path || 'none'}`)
1043
- await log(directory, ` todos: ${ctx.todos.length}`)
1044
- await log(directory, ` relevantFiles: ${ctx.relevantFiles.length}`)
1045
-
1046
- const context = await formatContext(ctx)
1047
- const instructions = await formatInstructions(ctx)
1048
- const readCommands = await generateReadCommands(agent, ctx.story, ctx.activeCommand, ctx.sessionState)
1049
-
1050
- // Build agentic briefing
1051
- const ss = ctx.sessionState
1052
- const briefing = buildBriefing(agent, ss, ctx, readCommands)
1053
- output.context.push(briefing)
1054
-
1055
- // Push agent-specific context (story details, epic progress, AC) and resume instructions
1056
- if (context) {
1057
- output.context.push(context)
1058
- }
1059
- if (instructions) {
1060
- output.context.push(instructions)
1061
- }
1062
-
1063
- await log(directory, ` -> output.context pushed (${output.context.length} items)`)
1064
- await log(directory, `=== COMPACTION DONE ===`)
1065
- },
1066
-
1067
- event: async ({ event }) => {
1068
- await log(directory, `event: ${event.type}`)
1069
- if (event.type === "session.idle") {
1070
- const story = await getActiveStory()
1071
- if (story && story.pendingTasks.length === 0) {
1072
- await log(directory, ` -> Story complete: ${story.title}`)
1073
- console.log(`[compaction] Story complete: ${story.title}`)
1074
- }
1075
- }
1076
- }
1077
- }
1078
- }
1079
-
1080
- export default CustomCompactionPlugin