@comfanion/workflow 4.38.1-dev.1 → 4.38.1-dev.12
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 +2 -2
- package/src/opencode/agents/reviewer.md +48 -41
- package/src/opencode/plugins/custom-compaction.ts +298 -110
- package/src/opencode/skills/code-review/SKILL.md +66 -47
- package/src/opencode/skills/dev-epic/SKILL.md +111 -32
- package/src/opencode/skills/dev-sprint/SKILL.md +122 -29
- package/src/opencode/skills/dev-story/SKILL.md +104 -14
- package/src/opencode/skills/epic-writing/template.md +30 -6
- package/src/opencode/skills/story-writing/SKILL.md +20 -0
- package/src/opencode/skills/story-writing/template.md +8 -0
package/package.json
CHANGED
package/src/build-info.json
CHANGED
|
@@ -6,7 +6,7 @@ temperature: 0.1 # Low temperature for precise analysis
|
|
|
6
6
|
#model: openai/gpt-5.2-codex # Best at finding bugs and security issues
|
|
7
7
|
model: anthropic/claude-sonnet-4-5 # Best at finding bugs and security issues
|
|
8
8
|
|
|
9
|
-
# Tools - Read-only for review
|
|
9
|
+
# Tools - Read-only for code, but CAN write review findings to story/epic files
|
|
10
10
|
tools:
|
|
11
11
|
read: true
|
|
12
12
|
glob: true
|
|
@@ -18,12 +18,14 @@ tools:
|
|
|
18
18
|
bash: true # For running tests
|
|
19
19
|
todowrite: false # Reviewer doesn't manage todos
|
|
20
20
|
todoread: true
|
|
21
|
-
edit:
|
|
22
|
-
write: false # Reviewer doesn't write files
|
|
21
|
+
edit: true # To append ## Review section to story/epic files
|
|
22
|
+
write: false # Reviewer doesn't write new files
|
|
23
23
|
|
|
24
|
-
# Permissions - read-only
|
|
24
|
+
# Permissions - read-only for code, write ONLY to story/epic docs
|
|
25
25
|
permission:
|
|
26
|
-
edit:
|
|
26
|
+
edit:
|
|
27
|
+
"docs/sprint-artifacts/**/*.md": allow # Story and epic files
|
|
28
|
+
"*": deny # Everything else read-only
|
|
27
29
|
bash:
|
|
28
30
|
"*": deny
|
|
29
31
|
# Tests
|
|
@@ -116,10 +118,17 @@ permission:
|
|
|
116
118
|
<action>If failures → include in review report as HIGH priority</action>
|
|
117
119
|
</phase>
|
|
118
120
|
|
|
119
|
-
<phase name="6.
|
|
120
|
-
<action>
|
|
121
|
-
<action>
|
|
122
|
-
<action>
|
|
121
|
+
<phase name="6. Write to Story File">
|
|
122
|
+
<action>Append `### Review #N` block to the story file's `## Review` section (see code-review skill for format)</action>
|
|
123
|
+
<action>Determine N by counting existing `### Review #` blocks + 1</action>
|
|
124
|
+
<action>Include: verdict, summary, test/lint results, action items with file:line</action>
|
|
125
|
+
<critical>NEVER overwrite previous reviews — always APPEND. History is preserved for analytics.</critical>
|
|
126
|
+
</phase>
|
|
127
|
+
|
|
128
|
+
<phase name="7. Return Summary to Caller">
|
|
129
|
+
<action>Return SHORT summary so calling agent does NOT re-read the story file</action>
|
|
130
|
+
<action>Format: verdict + action items list (caller uses this directly)</action>
|
|
131
|
+
<critical>Caller (@dev) uses YOUR output, not the file. Keep it actionable.</critical>
|
|
123
132
|
</phase>
|
|
124
133
|
</workflow>
|
|
125
134
|
|
|
@@ -220,38 +229,33 @@ permission:
|
|
|
220
229
|
</category>
|
|
221
230
|
</review_checklist>
|
|
222
231
|
|
|
223
|
-
<output_format>
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
**
|
|
228
|
-
**
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
{{
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
### Action Items
|
|
252
|
-
- [ ] [HIGH] Fix {{issue}}
|
|
253
|
-
- [ ] [MED] Add {{test/improvement}}
|
|
254
|
-
</output_format>
|
|
232
|
+
<output_format hint="TWO outputs: file + return summary">
|
|
233
|
+
|
|
234
|
+
<file_output hint="Appended to story file ## Review section — full details for analytics">
|
|
235
|
+
### Review #{{N}} — {{YYYY-MM-DD}}
|
|
236
|
+
**Verdict:** {{APPROVE | CHANGES_REQUESTED | BLOCKED}}
|
|
237
|
+
**Reviewer:** @reviewer (Marcus)
|
|
238
|
+
**Summary:** {{1-2 sentences}}
|
|
239
|
+
**Tests:** {{PASS | FAIL — details}}
|
|
240
|
+
**Lint:** {{PASS | FAIL — details}}
|
|
241
|
+
#### Action Items (if CHANGES_REQUESTED/BLOCKED)
|
|
242
|
+
- [ ] [HIGH] `path/file.ts:42` — {{issue}} → Fix: {{fix}}
|
|
243
|
+
- [ ] [MED] `path/file.ts:100` — {{issue}} → Fix: {{fix}}
|
|
244
|
+
#### What's Good (if APPROVE)
|
|
245
|
+
- {{positive feedback}}
|
|
246
|
+
</file_output>
|
|
247
|
+
|
|
248
|
+
<return_summary hint="Returned to calling agent — short, actionable, NO re-read needed">
|
|
249
|
+
**VERDICT: {{APPROVE | CHANGES_REQUESTED | BLOCKED}}**
|
|
250
|
+
{{IF CHANGES_REQUESTED or BLOCKED:}}
|
|
251
|
+
Action items:
|
|
252
|
+
- [HIGH] `path/file.ts:42` — {{issue}} → {{fix}}
|
|
253
|
+
- [MED] `path/file.ts:100` — {{issue}} → {{fix}}
|
|
254
|
+
{{IF APPROVE:}}
|
|
255
|
+
All good. No issues found.
|
|
256
|
+
</return_summary>
|
|
257
|
+
|
|
258
|
+
</output_format>
|
|
255
259
|
|
|
256
260
|
</agent>
|
|
257
261
|
|
|
@@ -269,4 +273,7 @@ permission:
|
|
|
269
273
|
- Make architecture decisions (→ @architect)
|
|
270
274
|
- Write documentation (→ @pm)
|
|
271
275
|
|
|
276
|
+
**What I Write:**
|
|
277
|
+
- `## Review` section in story files (append history: Review #1, #2, ...)
|
|
278
|
+
|
|
272
279
|
**My Model:** GPT-5.2 Codex (best at finding bugs)
|
|
@@ -39,9 +39,20 @@ interface StoryContext {
|
|
|
39
39
|
fullContent: string
|
|
40
40
|
}
|
|
41
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
|
+
|
|
42
52
|
interface SessionContext {
|
|
43
53
|
todos: TaskStatus[]
|
|
44
54
|
story: StoryContext | null
|
|
55
|
+
sessionState: SessionState | null
|
|
45
56
|
relevantFiles: string[]
|
|
46
57
|
activeAgent: string | null
|
|
47
58
|
activeCommand: string | null // /dev-story, /dev-epic, /dev-sprint
|
|
@@ -169,7 +180,7 @@ export const CustomCompactionPlugin: Plugin = async (ctx) => {
|
|
|
169
180
|
/**
|
|
170
181
|
* Generate Read commands that agent MUST execute after compaction
|
|
171
182
|
*/
|
|
172
|
-
async function generateReadCommands(agent: string | null, story: StoryContext | null, activeCommand: string | null): Promise<string> {
|
|
183
|
+
async function generateReadCommands(agent: string | null, story: StoryContext | null, activeCommand: string | null, sessionState: SessionState | null): Promise<string> {
|
|
173
184
|
const agentKey = (typeof agent === 'string' ? agent.toLowerCase() : null) || "default"
|
|
174
185
|
const filesToRead = [...(MUST_READ_FILES[agentKey] || MUST_READ_FILES.default)]
|
|
175
186
|
|
|
@@ -179,17 +190,23 @@ export const CustomCompactionPlugin: Plugin = async (ctx) => {
|
|
|
179
190
|
filesToRead.unshift(`.opencode/commands/${commandFile}`)
|
|
180
191
|
}
|
|
181
192
|
|
|
182
|
-
// For dev/coder: add
|
|
193
|
+
// For dev/coder: add session-state.yaml as priority read
|
|
183
194
|
if ((agentKey === "dev" || agentKey === "coder")) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// Epic state file (has all context)
|
|
187
|
-
filesToRead.unshift(epicState.statePath.replace(directory + "/", ""))
|
|
188
|
-
}
|
|
195
|
+
// Session state is most important — has everything
|
|
196
|
+
filesToRead.unshift(".opencode/session-state.yaml")
|
|
189
197
|
|
|
190
|
-
// Then story file
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
193
210
|
}
|
|
194
211
|
}
|
|
195
212
|
|
|
@@ -304,85 +321,113 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
|
|
|
304
321
|
}
|
|
305
322
|
}
|
|
306
323
|
|
|
324
|
+
/**
|
|
325
|
+
* PRIMARY source: session-state.yaml written by AI agent.
|
|
326
|
+
* Falls back to getActiveEpicState() + getActiveStory() if missing.
|
|
327
|
+
*/
|
|
328
|
+
async function getSessionState(): Promise<SessionState | null> {
|
|
329
|
+
try {
|
|
330
|
+
const statePath = join(directory, ".opencode", "session-state.yaml")
|
|
331
|
+
const content = await readFile(statePath, "utf-8")
|
|
332
|
+
await log(directory, ` session-state.yaml found, parsing...`)
|
|
333
|
+
|
|
334
|
+
// Simple regex YAML parser for flat/nested fields
|
|
335
|
+
const str = (key: string): string | null => {
|
|
336
|
+
const m = content.match(new RegExp(`^${key}:\\s*["']?(.+?)["']?\\s*$`, 'm'))
|
|
337
|
+
return m ? m[1].trim() : null
|
|
338
|
+
}
|
|
339
|
+
const nested = (parent: string, key: string): string | null => {
|
|
340
|
+
const section = content.match(new RegExp(`^${parent}:\\s*\\n((?: .+\\n?)*)`, 'm'))
|
|
341
|
+
if (!section) return null
|
|
342
|
+
const m = section[1].match(new RegExp(`^\\s+${key}:\\s*["']?(.+?)["']?\\s*$`, 'm'))
|
|
343
|
+
return m ? m[1].trim() : null
|
|
344
|
+
}
|
|
345
|
+
const list = (parent: string, key: string): string[] => {
|
|
346
|
+
const val = nested(parent, key)
|
|
347
|
+
if (!val) return []
|
|
348
|
+
// Handle [T1, T2] or T1, T2
|
|
349
|
+
const clean = val.replace(/^\[/, '').replace(/\]$/, '')
|
|
350
|
+
return clean.split(',').map(s => s.trim()).filter(Boolean)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Parse key_decisions as list
|
|
354
|
+
const decisions: string[] = []
|
|
355
|
+
const decMatch = content.match(/^key_decisions:\s*\n((?:\s+-\s*.+\n?)*)/m)
|
|
356
|
+
if (decMatch) {
|
|
357
|
+
const items = decMatch[1].matchAll(/^\s+-\s*["']?(.+?)["']?\s*$/gm)
|
|
358
|
+
for (const item of items) {
|
|
359
|
+
decisions.push(item[1])
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const state: SessionState = {
|
|
364
|
+
command: str('command'),
|
|
365
|
+
agent: str('agent'),
|
|
366
|
+
sprint: nested('sprint', 'number') ? {
|
|
367
|
+
number: parseInt(nested('sprint', 'number') || '0'),
|
|
368
|
+
status: nested('sprint', 'status') || 'unknown'
|
|
369
|
+
} : null,
|
|
370
|
+
epic: nested('epic', 'id') ? {
|
|
371
|
+
id: nested('epic', 'id') || '',
|
|
372
|
+
title: nested('epic', 'title') || '',
|
|
373
|
+
file: nested('epic', 'file') || '',
|
|
374
|
+
progress: nested('epic', 'progress') || ''
|
|
375
|
+
} : null,
|
|
376
|
+
story: nested('story', 'id') ? {
|
|
377
|
+
id: nested('story', 'id') || '',
|
|
378
|
+
title: nested('story', 'title') || '',
|
|
379
|
+
file: nested('story', 'file') || '',
|
|
380
|
+
current_task: nested('story', 'current_task'),
|
|
381
|
+
completed_tasks: list('story', 'completed_tasks'),
|
|
382
|
+
pending_tasks: list('story', 'pending_tasks')
|
|
383
|
+
} : null,
|
|
384
|
+
next_action: str('next_action'),
|
|
385
|
+
key_decisions: decisions
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await log(directory, ` parsed: command=${state.command}, epic=${state.epic?.id}, story=${state.story?.id}`)
|
|
389
|
+
return state
|
|
390
|
+
} catch {
|
|
391
|
+
await log(directory, ` session-state.yaml not found, using fallback`)
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
307
396
|
async function getActiveStory(): Promise<StoryContext | null> {
|
|
308
397
|
try {
|
|
309
|
-
|
|
398
|
+
let storyPath: string | null = null
|
|
399
|
+
|
|
400
|
+
// First, try epic state file for story path
|
|
310
401
|
const epicState = await getActiveEpicState()
|
|
311
|
-
if (epicState) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (storyPathMatch) {
|
|
315
|
-
const storyFileName = storyPathMatch[1]
|
|
316
|
-
// Find story file
|
|
317
|
-
const sprintMatch = epicState.statePath.match(/sprint-(\d+)/)
|
|
318
|
-
if (sprintMatch) {
|
|
319
|
-
const storyPath = `docs/sprint-artifacts/sprint-${sprintMatch[1]}/stories/${storyFileName}`
|
|
320
|
-
const storyContent = await readFile(join(directory, storyPath), "utf-8")
|
|
321
|
-
|
|
322
|
-
const titleMatch = storyContent.match(/^#\s+(.+)/m)
|
|
323
|
-
const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
|
|
324
|
-
|
|
325
|
-
const completedTasks: string[] = []
|
|
326
|
-
const pendingTasks: string[] = []
|
|
327
|
-
let currentTask: string | null = null
|
|
328
|
-
|
|
329
|
-
// Parse tasks with more detail
|
|
330
|
-
const taskRegex = /- \[([ x])\]\s+\*\*T(\d+)\*\*[:\s]+(.+?)(?=\n|$)/g
|
|
331
|
-
let match
|
|
332
|
-
while ((match = taskRegex.exec(storyContent)) !== null) {
|
|
333
|
-
const [, checked, taskId, taskName] = match
|
|
334
|
-
const taskInfo = `T${taskId}: ${taskName.trim()}`
|
|
335
|
-
if (checked === "x") {
|
|
336
|
-
completedTasks.push(taskInfo)
|
|
337
|
-
} else {
|
|
338
|
-
if (!currentTask) currentTask = taskInfo
|
|
339
|
-
pendingTasks.push(taskInfo)
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Parse acceptance criteria
|
|
344
|
-
const acceptanceCriteria: string[] = []
|
|
345
|
-
const acSection = storyContent.match(/## Acceptance Criteria[\s\S]*?(?=##|$)/i)
|
|
346
|
-
if (acSection) {
|
|
347
|
-
const acRegex = /- \[([ x])\]\s+(.+?)(?=\n|$)/g
|
|
348
|
-
while ((match = acRegex.exec(acSection[0])) !== null) {
|
|
349
|
-
const [, checked, criteria] = match
|
|
350
|
-
acceptanceCriteria.push(`${checked === "x" ? "✅" : "⬜"} ${criteria.trim()}`)
|
|
351
|
-
}
|
|
352
|
-
}
|
|
402
|
+
if (epicState?.nextStoryPath) {
|
|
403
|
+
storyPath = epicState.nextStoryPath
|
|
404
|
+
}
|
|
353
405
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
fullContent: storyContent
|
|
363
|
-
}
|
|
406
|
+
// Fallback: try sprint-status.yaml
|
|
407
|
+
if (!storyPath) {
|
|
408
|
+
try {
|
|
409
|
+
const sprintStatusPath = join(directory, "docs", "sprint-artifacts", "sprint-status.yaml")
|
|
410
|
+
const content = await readFile(sprintStatusPath, "utf-8")
|
|
411
|
+
const inProgressMatch = content.match(/status:\s*in-progress[\s\S]*?path:\s*["']?([^"'\n]+)["']?/i)
|
|
412
|
+
if (inProgressMatch) {
|
|
413
|
+
storyPath = inProgressMatch[1]
|
|
364
414
|
}
|
|
415
|
+
} catch {
|
|
416
|
+
// No sprint-status.yaml
|
|
365
417
|
}
|
|
366
418
|
}
|
|
367
|
-
|
|
368
|
-
// Fallback: try old sprint-status.yaml format
|
|
369
|
-
const sprintStatusPath = join(directory, "docs", "sprint-artifacts", "sprint-status.yaml")
|
|
370
|
-
const content = await readFile(sprintStatusPath, "utf-8")
|
|
371
|
-
|
|
372
|
-
const inProgressMatch = content.match(/status:\s*in-progress[\s\S]*?path:\s*["']?([^"'\n]+)["']?/i)
|
|
373
|
-
if (!inProgressMatch) return null
|
|
374
419
|
|
|
375
|
-
|
|
420
|
+
if (!storyPath) return null
|
|
421
|
+
|
|
422
|
+
// Parse story file
|
|
376
423
|
const storyContent = await readFile(join(directory, storyPath), "utf-8")
|
|
377
|
-
|
|
378
424
|
const titleMatch = storyContent.match(/^#\s+(.+)/m)
|
|
379
425
|
const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
|
|
380
|
-
|
|
426
|
+
|
|
381
427
|
const completedTasks: string[] = []
|
|
382
428
|
const pendingTasks: string[] = []
|
|
383
429
|
let currentTask: string | null = null
|
|
384
|
-
|
|
385
|
-
// Parse tasks with more detail
|
|
430
|
+
|
|
386
431
|
const taskRegex = /- \[([ x])\]\s+\*\*T(\d+)\*\*[:\s]+(.+?)(?=\n|$)/g
|
|
387
432
|
let match
|
|
388
433
|
while ((match = taskRegex.exec(storyContent)) !== null) {
|
|
@@ -395,8 +440,7 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
|
|
|
395
440
|
pendingTasks.push(taskInfo)
|
|
396
441
|
}
|
|
397
442
|
}
|
|
398
|
-
|
|
399
|
-
// Parse acceptance criteria
|
|
443
|
+
|
|
400
444
|
const acceptanceCriteria: string[] = []
|
|
401
445
|
const acSection = storyContent.match(/## Acceptance Criteria[\s\S]*?(?=##|$)/i)
|
|
402
446
|
if (acSection) {
|
|
@@ -478,31 +522,118 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
|
|
|
478
522
|
}
|
|
479
523
|
|
|
480
524
|
async function buildContext(agent: string | null): Promise<SessionContext> {
|
|
525
|
+
// PRIMARY: try session-state.yaml (written by AI agent)
|
|
526
|
+
const sessionState = await getSessionState()
|
|
527
|
+
|
|
481
528
|
const [todos, story] = await Promise.all([
|
|
482
529
|
getTodoList(),
|
|
483
|
-
|
|
530
|
+
// If session state has story path, use it; otherwise parse files
|
|
531
|
+
sessionState?.story?.file
|
|
532
|
+
? readStoryFromPath(sessionState.story.file)
|
|
533
|
+
: getActiveStory()
|
|
484
534
|
])
|
|
485
535
|
|
|
486
536
|
const epicState = await getActiveEpicState()
|
|
487
537
|
const relevantFiles = await getRelevantFiles(agent, story)
|
|
488
|
-
|
|
538
|
+
|
|
539
|
+
// Command: from session state or detected from TODOs
|
|
540
|
+
const activeCommand = sessionState?.command || await detectActiveCommand(todos, epicState)
|
|
489
541
|
|
|
490
|
-
return { todos, story, relevantFiles, activeAgent: agent, activeCommand }
|
|
542
|
+
return { todos, story, sessionState, relevantFiles, activeAgent: agent, activeCommand }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Read and parse a story file by path */
|
|
546
|
+
async function readStoryFromPath(storyPath: string): Promise<StoryContext | null> {
|
|
547
|
+
try {
|
|
548
|
+
const storyContent = await readFile(join(directory, storyPath), "utf-8")
|
|
549
|
+
const titleMatch = storyContent.match(/^#\s+(.+)/m)
|
|
550
|
+
const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
|
|
551
|
+
|
|
552
|
+
const completedTasks: string[] = []
|
|
553
|
+
const pendingTasks: string[] = []
|
|
554
|
+
let currentTask: string | null = null
|
|
555
|
+
|
|
556
|
+
const taskRegex = /- \[([ x])\]\s+\*\*T(\d+)\*\*[:\s]+(.+?)(?=\n|$)/g
|
|
557
|
+
let match
|
|
558
|
+
while ((match = taskRegex.exec(storyContent)) !== null) {
|
|
559
|
+
const [, checked, taskId, taskName] = match
|
|
560
|
+
const taskInfo = `T${taskId}: ${taskName.trim()}`
|
|
561
|
+
if (checked === "x") {
|
|
562
|
+
completedTasks.push(taskInfo)
|
|
563
|
+
} else {
|
|
564
|
+
if (!currentTask) currentTask = taskInfo
|
|
565
|
+
pendingTasks.push(taskInfo)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const acceptanceCriteria: string[] = []
|
|
570
|
+
const acSection = storyContent.match(/## Acceptance Criteria[\s\S]*?(?=##|$)/i)
|
|
571
|
+
if (acSection) {
|
|
572
|
+
const acRegex = /- \[([ x])\]\s+(.+?)(?=\n|$)/g
|
|
573
|
+
while ((match = acRegex.exec(acSection[0])) !== null) {
|
|
574
|
+
const [, checked, criteria] = match
|
|
575
|
+
acceptanceCriteria.push(`${checked === "x" ? "✅" : "⬜"} ${criteria.trim()}`)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
path: storyPath,
|
|
581
|
+
title: titleMatch?.[1] || "Unknown Story",
|
|
582
|
+
status: statusMatch?.[1] || "unknown",
|
|
583
|
+
currentTask,
|
|
584
|
+
completedTasks,
|
|
585
|
+
pendingTasks,
|
|
586
|
+
acceptanceCriteria,
|
|
587
|
+
fullContent: storyContent
|
|
588
|
+
}
|
|
589
|
+
} catch {
|
|
590
|
+
return null
|
|
591
|
+
}
|
|
491
592
|
}
|
|
492
593
|
|
|
493
594
|
async function formatDevContext(ctx: SessionContext): Promise<string> {
|
|
494
595
|
const sections: string[] = []
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
596
|
+
const ss = ctx.sessionState
|
|
597
|
+
|
|
598
|
+
// SESSION STATE available — use it (primary source)
|
|
599
|
+
if (ss) {
|
|
600
|
+
let header = `## 🎯 Session State (from session-state.yaml)\n`
|
|
601
|
+
header += `**Command:** ${ss.command || "unknown"}\n`
|
|
602
|
+
|
|
603
|
+
if (ss.sprint) {
|
|
604
|
+
header += `**Sprint:** #${ss.sprint.number} (${ss.sprint.status})\n`
|
|
605
|
+
}
|
|
606
|
+
if (ss.epic) {
|
|
607
|
+
header += `**Epic:** ${ss.epic.id} — ${ss.epic.title}\n`
|
|
608
|
+
header += `**Epic File:** \`${ss.epic.file}\`\n`
|
|
609
|
+
header += `**Epic Progress:** ${ss.epic.progress}\n`
|
|
610
|
+
}
|
|
611
|
+
if (ss.story) {
|
|
612
|
+
header += `\n**Story:** ${ss.story.id} — ${ss.story.title}\n`
|
|
613
|
+
header += `**Story File:** \`${ss.story.file}\` ← READ THIS FIRST\n`
|
|
614
|
+
header += `**Current Task:** ${ss.story.current_task || "all done"}\n`
|
|
615
|
+
header += `**Completed:** ${ss.story.completed_tasks.join(", ") || "none"}\n`
|
|
616
|
+
header += `**Pending:** ${ss.story.pending_tasks.join(", ") || "none"}\n`
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
header += `\n### Next Action (DO THIS NOW)\n\`\`\`\n${ss.next_action || "Check TODO list"}\n\`\`\`\n`
|
|
620
|
+
|
|
621
|
+
if (ss.key_decisions.length > 0) {
|
|
622
|
+
header += `\n### Key Technical Decisions\n`
|
|
623
|
+
header += ss.key_decisions.map(d => `- ${d}`).join("\n")
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
sections.push(header)
|
|
627
|
+
}
|
|
628
|
+
// FALLBACK: parse epic state file
|
|
629
|
+
else {
|
|
630
|
+
const epicState = await getActiveEpicState()
|
|
631
|
+
if (epicState) {
|
|
632
|
+
const progress = epicState.totalStories > 0
|
|
633
|
+
? ((epicState.completedCount / epicState.totalStories) * 100).toFixed(0)
|
|
634
|
+
: 0
|
|
504
635
|
|
|
505
|
-
|
|
636
|
+
sections.push(`## 🎯 Epic Workflow: ${epicState.epicTitle}
|
|
506
637
|
|
|
507
638
|
**Epic ID:** ${epicState.epicId}
|
|
508
639
|
**Epic State:** \`${epicState.statePath}\` ← READ THIS FIRST
|
|
@@ -525,7 +656,10 @@ ${epicState.nextStoryPath ? `**Next Story:** \`${epicState.nextStoryPath}\` ←
|
|
|
525
656
|
💡 **Note:** If this is part of /dev-sprint, after epic completes:
|
|
526
657
|
1. Update sprint-status.yaml (mark epic done)
|
|
527
658
|
2. Continue to next epic automatically`)
|
|
528
|
-
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (!ss && ctx.story) {
|
|
529
663
|
// Regular story mode
|
|
530
664
|
const s = ctx.story
|
|
531
665
|
const total = s.completedTasks.length + s.pendingTasks.length
|
|
@@ -777,6 +911,74 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
|
|
|
777
911
|
4. Update todo/story when task complete`
|
|
778
912
|
}
|
|
779
913
|
|
|
914
|
+
function buildBriefing(agent: string | null, ss: SessionState | null, ctx: SessionContext, readCommands: string): string {
|
|
915
|
+
const lines: string[] = []
|
|
916
|
+
|
|
917
|
+
// 1. WHO you are
|
|
918
|
+
if (agent) {
|
|
919
|
+
lines.push(`You are @${agent} (→ .opencode/agents/${agent}.md).`)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// 2. WHAT you are doing
|
|
923
|
+
if (ss) {
|
|
924
|
+
const cmd = ss.command || "unknown command"
|
|
925
|
+
if (ss.epic) {
|
|
926
|
+
lines.push(`You are executing ${cmd} ${ss.epic.id}: ${ss.epic.title}.`)
|
|
927
|
+
} else if (ss.story) {
|
|
928
|
+
lines.push(`You are executing ${cmd} ${ss.story.id}: ${ss.story.title}.`)
|
|
929
|
+
} else {
|
|
930
|
+
lines.push(`You are executing ${cmd}.`)
|
|
931
|
+
}
|
|
932
|
+
} else if (ctx.activeCommand) {
|
|
933
|
+
lines.push(`You are executing ${ctx.activeCommand}.`)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// 3. WHERE you stopped
|
|
937
|
+
if (ss?.story) {
|
|
938
|
+
const task = ss.story.current_task || "review"
|
|
939
|
+
lines.push(`You were on story ${ss.story.id}: ${ss.story.title}, task ${task}.`)
|
|
940
|
+
if (ss.story.completed_tasks.length > 0) {
|
|
941
|
+
lines.push(`Completed: ${ss.story.completed_tasks.join(", ")}.`)
|
|
942
|
+
}
|
|
943
|
+
if (ss.story.pending_tasks.length > 0) {
|
|
944
|
+
lines.push(`Remaining: ${ss.story.pending_tasks.join(", ")}.`)
|
|
945
|
+
}
|
|
946
|
+
} else if (ss?.epic) {
|
|
947
|
+
lines.push(`Epic progress: ${ss.epic.progress}.`)
|
|
948
|
+
} else if (ctx.story) {
|
|
949
|
+
lines.push(`You were on story: ${ctx.story.title}, task ${ctx.story.currentTask || "review"}.`)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// 4. WHAT to do next
|
|
953
|
+
if (ss?.next_action) {
|
|
954
|
+
lines.push(`\nNext action: ${ss.next_action}`)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// 5. READ these files
|
|
958
|
+
lines.push(`\n${readCommands}`)
|
|
959
|
+
|
|
960
|
+
// 6. KEY DECISIONS (if any)
|
|
961
|
+
if (ss?.key_decisions && ss.key_decisions.length > 0) {
|
|
962
|
+
lines.push(`\nKey decisions from your session:`)
|
|
963
|
+
for (const d of ss.key_decisions) {
|
|
964
|
+
lines.push(`- ${d}`)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// 7. TODO status (brief)
|
|
969
|
+
if (ctx.todos.length > 0) {
|
|
970
|
+
const inProgress = ctx.todos.filter(t => t.status === "in_progress")
|
|
971
|
+
const pending = ctx.todos.filter(t => t.status === "pending")
|
|
972
|
+
const completed = ctx.todos.filter(t => t.status === "completed")
|
|
973
|
+
lines.push(`\nTODO: ${completed.length} done, ${inProgress.length} in progress, ${pending.length} pending.`)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// 8. RULES
|
|
977
|
+
lines.push(`\nDO NOT ask user what to do. Read files above, then resume automatically.`)
|
|
978
|
+
|
|
979
|
+
return lines.join("\n")
|
|
980
|
+
}
|
|
981
|
+
|
|
780
982
|
return {
|
|
781
983
|
// Track active agent from chat messages
|
|
782
984
|
"chat.message": async (input, output) => {
|
|
@@ -831,26 +1033,12 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
|
|
|
831
1033
|
|
|
832
1034
|
const context = await formatContext(ctx)
|
|
833
1035
|
const instructions = await formatInstructions(ctx)
|
|
834
|
-
const readCommands = await generateReadCommands(agent, ctx.story, ctx.activeCommand)
|
|
835
|
-
|
|
836
|
-
// Agent identity reminder
|
|
837
|
-
const agentIdentity = agent
|
|
838
|
-
? `You are @${agent} (.opencode/agents/${agent}.md). Load your persona and continue.`
|
|
839
|
-
: "You are an AI assistant helping with this project."
|
|
840
|
-
|
|
841
|
-
output.context.push(`# Session Continuation
|
|
842
|
-
|
|
843
|
-
${agentIdentity}
|
|
844
|
-
|
|
845
|
-
${readCommands}
|
|
846
|
-
|
|
847
|
-
---
|
|
848
|
-
|
|
849
|
-
${context}
|
|
850
|
-
|
|
851
|
-
---
|
|
1036
|
+
const readCommands = await generateReadCommands(agent, ctx.story, ctx.activeCommand, ctx.sessionState)
|
|
852
1037
|
|
|
853
|
-
|
|
1038
|
+
// Build agentic briefing
|
|
1039
|
+
const ss = ctx.sessionState
|
|
1040
|
+
const briefing = buildBriefing(agent, ss, ctx, readCommands)
|
|
1041
|
+
output.context.push(briefing)
|
|
854
1042
|
|
|
855
1043
|
await log(directory, ` -> output.context pushed (${output.context.length} items)`)
|
|
856
1044
|
await log(directory, `=== COMPACTION DONE ===`)
|