@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
package/src/build-info.json
CHANGED
|
@@ -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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">
|
|
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">
|