@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.
- package/package.json +1 -1
- package/src/build-info.json +4 -5
- package/src/opencode/config.yaml +0 -69
- package/src/opencode/gitignore +2 -0
- package/src/opencode/opencode.json +3 -5
- package/src/opencode/vectorizer.yaml +45 -0
- package/src/opencode/plugins/README.md +0 -182
- package/src/opencode/plugins/__tests__/custom-compaction.test.ts +0 -829
- package/src/opencode/plugins/__tests__/file-indexer.test.ts +0 -425
- package/src/opencode/plugins/__tests__/helpers/mock-ctx.ts +0 -171
- package/src/opencode/plugins/__tests__/leak-stress.test.ts +0 -315
- package/src/opencode/plugins/__tests__/usethis-todo.test.ts +0 -205
- package/src/opencode/plugins/__tests__/version-check.test.ts +0 -223
- package/src/opencode/plugins/custom-compaction.ts +0 -1080
- package/src/opencode/plugins/file-indexer.ts +0 -516
- package/src/opencode/plugins/usethis-todo-publish.ts +0 -44
- package/src/opencode/plugins/usethis-todo-ui.ts +0 -37
- package/src/opencode/plugins/version-check.ts +0 -230
- package/src/opencode/tools/codeindex.ts +0 -264
- package/src/opencode/tools/search.ts +0 -149
- package/src/opencode/tools/usethis_todo.ts +0 -538
- package/src/vectorizer/index.js +0 -573
- package/src/vectorizer/package.json +0 -16
|
@@ -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
|