@comfanion/workflow 4.38.1-dev.2 → 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.2",
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.2",
3
- "buildDate": "2026-01-27T01:09:08.937Z",
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,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
- // First, try to find epic state file
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
- // Parse epic state to get current story
313
- const storyPathMatch = epicState.content.match(/next_action:\s*["']?Execute\s+(.+?)["']?$/m)
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
- return {
355
- path: storyPath,
356
- title: titleMatch?.[1] || "Unknown Story",
357
- status: statusMatch?.[1] || "unknown",
358
- currentTask,
359
- completedTasks,
360
- pendingTasks,
361
- acceptanceCriteria,
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
- const storyPath = inProgressMatch[1]
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
- getActiveStory()
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
- 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)
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
- // Check if we're in epic workflow
497
- const epicState = await getActiveEpicState()
498
-
499
- if (epicState) {
500
- // Epic/Sprint workflow mode - show epic progress
501
- const progress = epicState.totalStories > 0
502
- ? ((epicState.completedCount / epicState.totalStories) * 100).toFixed(0)
503
- : 0
596
+ const ss = ctx.sessionState
504
597
 
505
- 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}
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
- } else if (ctx.story) {
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
@@ -831,7 +965,7 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
831
965
 
832
966
  const context = await formatContext(ctx)
833
967
  const instructions = await formatInstructions(ctx)
834
- const readCommands = await generateReadCommands(agent, ctx.story, ctx.activeCommand)
968
+ const readCommands = await generateReadCommands(agent, ctx.story, ctx.activeCommand, ctx.sessionState)
835
969
 
836
970
  // Agent identity reminder
837
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">