@comfanion/workflow 4.38.1-dev.3 → 4.38.1-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/workflow",
3
- "version": "4.38.1-dev.3",
3
+ "version": "4.38.1-dev.4",
4
4
  "description": "Initialize OpenCode Workflow system for AI-assisted development with semantic code search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "4.38.1-dev.3",
3
- "buildDate": "2026-01-27T10:14:50.120Z",
2
+ "version": "4.38.1-dev.4",
3
+ "buildDate": "2026-01-27T10:26:28.340Z",
4
4
  "files": [
5
5
  "config.yaml",
6
6
  "FLOW.yaml",
@@ -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 epic state file if in epic workflow
193
+ // For dev/coder: add session-state.yaml as priority read
183
194
  if ((agentKey === "dev" || agentKey === "coder")) {
184
- const epicState = await getActiveEpicState()
185
- if (epicState) {
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 if active
191
- if (story) {
192
- filesToRead.unshift(story.path) // Story first!
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,6 +321,78 @@ 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
@@ -433,31 +522,118 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
433
522
  }
434
523
 
435
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
+
436
528
  const [todos, story] = await Promise.all([
437
529
  getTodoList(),
438
- getActiveStory()
530
+ // If session state has story path, use it; otherwise parse files
531
+ sessionState?.story?.file
532
+ ? readStoryFromPath(sessionState.story.file)
533
+ : getActiveStory()
439
534
  ])
440
535
 
441
536
  const epicState = await getActiveEpicState()
442
537
  const relevantFiles = await getRelevantFiles(agent, story)
443
- const activeCommand = await detectActiveCommand(todos, epicState)
538
+
539
+ // Command: from session state or detected from TODOs
540
+ const activeCommand = sessionState?.command || await detectActiveCommand(todos, epicState)
444
541
 
445
- 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
+ }
446
592
  }
447
593
 
448
594
  async function formatDevContext(ctx: SessionContext): Promise<string> {
449
595
  const sections: string[] = []
450
-
451
- // Check if we're in epic workflow
452
- const epicState = await getActiveEpicState()
453
-
454
- if (epicState) {
455
- // Epic/Sprint workflow mode - show epic progress
456
- const progress = epicState.totalStories > 0
457
- ? ((epicState.completedCount / epicState.totalStories) * 100).toFixed(0)
458
- : 0
596
+ const ss = ctx.sessionState
459
597
 
460
- sections.push(`## 🎯 Epic Workflow: ${epicState.epicTitle}
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
635
+
636
+ sections.push(`## 🎯 Epic Workflow: ${epicState.epicTitle}
461
637
 
462
638
  **Epic ID:** ${epicState.epicId}
463
639
  **Epic State:** \`${epicState.statePath}\` ← READ THIS FIRST
@@ -480,7 +656,10 @@ ${epicState.nextStoryPath ? `**Next Story:** \`${epicState.nextStoryPath}\` ←
480
656
  💡 **Note:** If this is part of /dev-sprint, after epic completes:
481
657
  1. Update sprint-status.yaml (mark epic done)
482
658
  2. Continue to next epic automatically`)
483
- } else if (ctx.story) {
659
+ }
660
+ }
661
+
662
+ if (!ss && ctx.story) {
484
663
  // Regular story mode
485
664
  const s = ctx.story
486
665
  const total = s.completedTasks.length + s.pendingTasks.length
@@ -786,7 +965,7 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
786
965
 
787
966
  const context = await formatContext(ctx)
788
967
  const instructions = await formatInstructions(ctx)
789
- const readCommands = await generateReadCommands(agent, ctx.story, ctx.activeCommand)
968
+ const readCommands = await generateReadCommands(agent, ctx.story, ctx.activeCommand, ctx.sessionState)
790
969
 
791
970
  // Agent identity reminder
792
971
  const agentIdentity = agent
@@ -103,14 +103,47 @@ metadata:
103
103
  <step n="2">Verify all AC from epic file (mark in TODO)</step>
104
104
  <step n="3">Set state: status="done"</step>
105
105
  <step n="4">Clear epic TODO list</step>
106
- <step n="5">Report completion with summary</step>
106
+ <step n="5">Update .opencode/session-state.yaml (next epic or done)</step>
107
+ <step n="6">Report completion with summary</step>
107
108
  </phase>
108
109
 
109
110
  </workflow>
110
111
 
112
+ ## Session State (MANDATORY)
113
+
114
+ After each story/task completion, update `.opencode/session-state.yaml`:
115
+
116
+ ```yaml
117
+ # .opencode/session-state.yaml — AI writes, compaction plugin reads
118
+ command: /dev-epic
119
+ agent: dev
120
+
121
+ epic:
122
+ id: PROJ-E01
123
+ title: Epic Title
124
+ file: docs/sprint-artifacts/sprint-1/epic-01-desc.md
125
+ progress: "3/5 stories"
126
+
127
+ story:
128
+ id: PROJ-S01-03
129
+ title: Current Story Title
130
+ file: docs/sprint-artifacts/sprint-1/stories/story-01-03-desc.md
131
+ current_task: T2
132
+ completed_tasks: [T1]
133
+ pending_tasks: [T2, T3]
134
+
135
+ next_action: "Continue T2 of story S01-03"
136
+
137
+ key_decisions:
138
+ - "Decision 1"
139
+ ```
140
+
141
+ This file survives compaction and tells the agent where to resume.
142
+
111
143
  <outputs>
112
144
  - Implementation code for all stories
113
145
  - Updated epic-XX-state.yaml
146
+ - Updated .opencode/session-state.yaml
114
147
  - Clean TODO list (all completed)
115
148
  </outputs>
116
149
 
@@ -101,14 +101,51 @@ metadata:
101
101
  <step n="1">Run sprint integration tests (mark in TODO)</step>
102
102
  <step n="2">Set sprint status="done" in sprint-status.yaml</step>
103
103
  <step n="3">Clear sprint TODO list</step>
104
- <step n="4">Report completion with summary + metrics</step>
104
+ <step n="4">Update .opencode/session-state.yaml (done)</step>
105
+ <step n="5">Report completion with summary + metrics</step>
105
106
  </phase>
106
107
 
107
108
  </workflow>
108
109
 
110
+ ## Session State (MANDATORY)
111
+
112
+ After each epic/story/task completion, update `.opencode/session-state.yaml`:
113
+
114
+ ```yaml
115
+ # .opencode/session-state.yaml — AI writes, compaction plugin reads
116
+ command: /dev-sprint
117
+ agent: dev
118
+
119
+ sprint:
120
+ number: 2
121
+ status: in-progress
122
+
123
+ epic:
124
+ id: PROJ-E04
125
+ title: Current Epic Title
126
+ file: docs/sprint-artifacts/sprint-2/epic-04-desc.md
127
+ progress: "3/5 stories"
128
+
129
+ story:
130
+ id: PROJ-S04-03
131
+ title: Current Story Title
132
+ file: docs/sprint-artifacts/sprint-2/stories/story-04-03-desc.md
133
+ current_task: T2
134
+ completed_tasks: [T1]
135
+ pending_tasks: [T2, T3]
136
+
137
+ next_action: "Continue T2 of story S04-03"
138
+
139
+ key_decisions:
140
+ - "Decision 1"
141
+ ```
142
+
143
+ This file survives compaction and tells the agent where to resume.
144
+
109
145
  <outputs>
110
146
  - Implementation code for all stories in sprint
111
147
  - Updated sprint-status.yaml
148
+ - Updated .opencode/session-state.yaml
112
149
  - Clean TODO list (all completed)
113
150
  </outputs>
114
151
 
@@ -78,11 +78,38 @@ metadata:
78
78
  <action>Run tests</action>
79
79
  <action>Check "Done when" criteria</action>
80
80
  <action>Mark task ✅ in story file</action>
81
+ <action>Update .opencode/session-state.yaml (see format below)</action>
81
82
  <next>Next task or story complete</next>
82
83
  </phase>
83
84
 
84
85
  </workflow>
85
86
 
87
+ ## Session State (MANDATORY)
88
+
89
+ After each task completion, update `.opencode/session-state.yaml`:
90
+
91
+ ```yaml
92
+ # .opencode/session-state.yaml — AI writes, compaction plugin reads
93
+ command: /dev-story
94
+ agent: dev
95
+
96
+ story:
97
+ id: PROJ-S01-01
98
+ title: Story Title
99
+ file: docs/sprint-artifacts/sprint-1/stories/story-01-01-desc.md
100
+ current_task: T3
101
+ completed_tasks: [T1, T2]
102
+ pending_tasks: [T3, T4]
103
+
104
+ next_action: "Continue T3: Implement handler"
105
+
106
+ key_decisions:
107
+ - "Decision 1"
108
+ - "Decision 2"
109
+ ```
110
+
111
+ This file survives compaction and tells the agent where to resume.
112
+
86
113
  ## Task Template for @coder
87
114
 
88
115
  <template name="coder-task">