@comfanion/workflow 4.38.1 → 4.38.3-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/workflow",
3
- "version": "4.38.1",
3
+ "version": "4.38.3-dev.0",
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",
3
- "buildDate": "2026-01-27T22:14:35.343Z",
2
+ "version": "4.38.3-dev.0",
3
+ "buildDate": "2026-01-28T10:28:17.932Z",
4
4
  "files": [
5
5
  ".gitignore",
6
6
  "config.yaml",
@@ -382,198 +382,6 @@ pipeline:
382
382
  output:
383
383
  - docs/sprint-artifacts/sprint-N/retrospective.md
384
384
 
385
- # =============================================================================
386
- # AGENTS (Personas - WHO does work)
387
- # =============================================================================
388
- #
389
- # Model Strategy (based on Jan 2026 research):
390
- # - Architecture/Planning: Claude 4.5 Opus, Gemini 3 Pro (wisdom, context)
391
- # - Development: DeepSeek-V3.2 (cheapest, fast), GLM-4.7 (preserved thinking)
392
- # - Testing: GPT-5.2 Codex (best at finding bugs)
393
- # - UI/Frontend: MiniMax-M2.1 (fast, aesthetic)
394
- # - Research: Gemini 3 Pro (10M context, grounding)
395
- #
396
- agents:
397
- analyst:
398
- name: Sara
399
- title: Business Analyst
400
- icon: "📊"
401
- description: Requirements Analyst - extracts FR/NFR through stakeholder interviews
402
- mode: subagent
403
- model: anthropic/claude-sonnet-4-20250514
404
- temperature: 0.3
405
- file: agents/analyst.md
406
- expertise:
407
- - Requirements engineering
408
- - Business analysis
409
- - Stakeholder interviews
410
- personality: Methodical, thorough, asks clarifying questions
411
- skills_used:
412
- - requirements-gathering
413
- - requirements-validation
414
- - acceptance-criteria
415
- - unit-writing
416
- - methodologies
417
- - doc-todo
418
- - archiving
419
-
420
- pm:
421
- name: Dima
422
- title: Product Manager
423
- icon: "📋"
424
- description: Product Manager - creates PRDs, epics, stories, sprint planning, Jira sync
425
- mode: subagent
426
- model: anthropic/claude-sonnet-4-20250514
427
- temperature: 0.3
428
- file: agents/pm.md
429
- expertise:
430
- - Product management
431
- - B2B SaaS
432
- - Agile/Sprint planning
433
- personality: Business-focused, user-centric, data-driven
434
- skills_used:
435
- - prd-writing
436
- - prd-validation
437
- - acceptance-criteria
438
- - epic-writing
439
- - story-writing
440
- - sprint-planning
441
- - jira-integration
442
- - unit-writing
443
- - translation
444
- - methodologies
445
- - doc-todo
446
- - archiving
447
-
448
- architect:
449
- name: Winston
450
- title: Solution Architect
451
- icon: "🏗️"
452
- description: Solution Architect - designs system architecture, makes technical decisions
453
- mode: subagent
454
- model: anthropic/claude-sonnet-4-20250514
455
- temperature: 0.2
456
- file: agents/architect.md
457
- expertise:
458
- - Distributed systems
459
- - Architecture patterns (chooses based on context)
460
- - System design
461
- personality: Technical, precise, patterns-focused
462
- skills_used:
463
- - architecture-design
464
- - architecture-validation
465
- - adr-writing
466
- - coding-standards
467
- - unit-writing
468
- - methodologies
469
- - api-design
470
- - database-design
471
- - diagram-creation
472
- - module-documentation
473
- - doc-todo
474
- - archiving
475
-
476
- dev:
477
- name: Rick
478
- title: Senior Developer
479
- icon: "💻"
480
- description: Developer - implements stories following red-green-refactor cycle
481
- mode: subagent
482
- model: anthropic/claude-sonnet-4-20250514
483
- temperature: 0.2
484
- file: agents/dev.md
485
- expertise:
486
- - Software development
487
- - TDD/BDD
488
- - Code review
489
- personality: Precise, test-driven, follows story specifications exactly
490
- skills_used:
491
- - dev-story
492
- - code-review
493
- - test-design
494
- - changelog
495
- - doc-todo
496
-
497
- coder:
498
- name: Morty
499
- title: Fast Coder
500
- icon: "⚡"
501
- description: Fast Coder - quick implementation, bug fixes, code following patterns
502
- mode: subagent
503
- hidden: true # Internal subagent, invoked by @dev
504
- model: anthropic/claude-sonnet-4-20250514
505
- temperature: 0.1
506
- file: agents/coder.md
507
- expertise:
508
- - Quick implementation
509
- - Bug fixes
510
- - Following existing patterns
511
- personality: Fast, no questions, executes or fails
512
- skills_used:
513
- - test-design
514
- - changelog
515
- - doc-todo
516
-
517
- reviewer:
518
- name: Marcus
519
- title: Code Reviewer
520
- icon: "🔍"
521
- description: Code Reviewer - security-focused review, bug finding, test coverage
522
- mode: subagent
523
- model: openai/gpt-5.2-codex # Best at finding bugs and security issues
524
- temperature: 0.1
525
- file: agents/reviewer.md
526
- expertise:
527
- - Security review
528
- - Bug finding
529
- - Test coverage analysis
530
- - Code quality
531
- personality: Thorough, security-paranoid, always suggests fixes
532
- skills_used:
533
- - code-review
534
- auto_invoke:
535
- trigger: story_tasks_complete # Called automatically when all story tasks done
536
- before: story_marked_done
537
-
538
- # Supporting Agents (not in main pipeline)
539
- researcher:
540
- name: Kristina
541
- title: Researcher
542
- icon: "🔍"
543
- description: Researcher - conducts technical, market, and domain research
544
- mode: subagent
545
- model: google/gemini-2.5-pro # Best for research - 1M context, grounding
546
- # Alternatives: gemini-2.5-flash (faster), gemini-3-pro (preview)
547
- temperature: 0.4
548
- file: agents/researcher.md
549
- expertise:
550
- - Technical research
551
- - Market analysis
552
- - Domain expertise
553
- - Deep research with web grounding
554
- personality: Curious, thorough, evidence-based
555
- skills_used:
556
- - research-methodology
557
- - methodologies
558
-
559
- change-manager:
560
- name: Bruce
561
- title: Change Manager
562
- icon: "🔄"
563
- description: Change Manager - manages documentation change proposals
564
- mode: subagent
565
- model: anthropic/claude-sonnet-4-20250514
566
- temperature: 0.2
567
- file: agents/change-manager.md
568
- expertise:
569
- - Change management
570
- - Impact analysis
571
- - Version control
572
- personality: Careful, systematic, risk-aware
573
- skills_used:
574
- - doc-todo
575
- - archiving
576
-
577
385
  # =============================================================================
578
386
  # ARTIFACTS
579
387
  # =============================================================================
@@ -701,95 +509,3 @@ quickstart:
701
509
  - step: 18
702
510
  command: /retrospective
703
511
  description: Sprint retrospective
704
-
705
- # =============================================================================
706
- # HOOKS & EVENTS (Plugin Integration)
707
- # =============================================================================
708
- #
709
- # Workflow emits events that plugins can subscribe to.
710
- # Plugins are in .opencode/plugins/ directory.
711
- #
712
- hooks:
713
- # Compaction hook - preserve context during session compaction
714
- compaction:
715
- plugin: custom-compaction.ts
716
- description: Preserves task/story status, critical files, continuation instructions
717
- context_files:
718
- always:
719
- - CLAUDE.md
720
- - AGENTS.md
721
- - project-context.md
722
- - .opencode/config.yaml
723
- if_exists:
724
- - docs/prd.md
725
- - docs/architecture.md
726
- - docs/coding-standards/README.md
727
- - docs/coding-standards/patterns.md
728
- dynamic:
729
- - active_story_file # From sprint-status.yaml
730
- - modified_files # Files edited in session
731
-
732
- events:
733
- # Session lifecycle
734
- session:
735
- - session.started # New session began
736
- - session.idle # Agent finished responding
737
- - session.compacted # Session was compacted
738
- - session.error # Error occurred
739
-
740
- # Agent lifecycle
741
- agent:
742
- - agent.activated # Agent persona loaded
743
- - agent.skill.loaded # Skill file was read
744
- - agent.task.started # Agent started a task
745
- - agent.task.completed # Agent completed a task
746
-
747
- # Workflow pipeline
748
- workflow:
749
- - workflow.stage.started # Pipeline stage began
750
- - workflow.stage.completed # Pipeline stage finished
751
- - workflow.stage.skipped # Optional stage skipped
752
- - workflow.validation.passed # Validation succeeded
753
- - workflow.validation.failed # Validation failed
754
-
755
- # Document lifecycle
756
- document:
757
- - document.created # New doc created
758
- - document.updated # Doc modified
759
- - document.validated # Doc passed validation
760
- - document.archived # Doc moved to archive
761
-
762
- # Sprint/Story lifecycle
763
- sprint:
764
- - epic.created # Epic document created
765
- - story.created # Story document created
766
- - story.started # Story status → in-progress
767
- - story.task.completed # Task marked [x]
768
- - story.completed # All tasks done, status → review
769
- - story.approved # Code review passed, status → done
770
- - sprint.planned # Sprint planning completed
771
- - sprint.completed # All stories done
772
-
773
- # Jira integration
774
- jira:
775
- - jira.sync.started # Sync began
776
- - jira.sync.completed # Sync finished
777
- - jira.issue.created # New issue in Jira
778
- - jira.issue.updated # Issue updated
779
- - jira.transition # Status changed
780
-
781
- # Example plugin subscriptions
782
- #
783
- # .opencode/plugins/notifications.ts:
784
- # event: async ({ event }) => {
785
- # if (event.type === "story.completed") {
786
- # await sendSlackNotification(event.data)
787
- # }
788
- # }
789
- #
790
- # .opencode/plugins/jira-auto-sync.ts:
791
- # event: async ({ event }) => {
792
- # if (event.type === "story.task.completed") {
793
- # await updateJiraProgress(event.data)
794
- # }
795
- # }
@@ -123,7 +123,7 @@ permission:
123
123
  </size-awareness>
124
124
 
125
125
  <phase name="2. Planning">
126
- <action>Create tasklist with todowrite()</action>
126
+ <action>Create tasklist with todowrite() (TODOv2)</action>
127
127
  <action>Present plan to user with specific files/changes</action>
128
128
  <action>Ask for confirmation with question() tool</action>
129
129
  <action>WAIT for user approval before proceeding</action>
@@ -4,7 +4,7 @@ mode: all # Can be primary agent or invoked via @dev
4
4
  temperature: 0.2
5
5
 
6
6
  model: anthropic/claude-opus-4-5 # Strong
7
- #model: z.ai/glm-4.7 # Can break
7
+ #model: zai-coding-plan/glm-4.7 # Can break
8
8
  #model: openai/gpt-5.2-codex
9
9
 
10
10
  # Tools - FULL ACCESS for implementation
@@ -40,6 +40,7 @@ permission:
40
40
  <step n="2">IMMEDIATE: store {user_name}, {communication_language} from .opencode/config.yaml</step>
41
41
  <step n="3">Greet user by {user_name}, communicate in {communication_language}</step>
42
42
  <step n="4">Understand user request and select appropriate skill</step>
43
+ <step n="5">Create tasklist with todowrite() (TODOv2)</step>
43
44
 
44
45
  <search-first critical="MANDATORY - DO THIS BEFORE GLOB/GREP">
45
46
  BEFORE using glob or grep, you MUST call search() first:
@@ -68,23 +69,6 @@ permission:
68
69
  <r critical="MANDATORY">🔍 SEARCH FIRST: Call search() BEFORE glob when exploring codebase.
69
70
  search({ query: "feature pattern", index: "code" }) → THEN glob if needed</r>
70
71
  </rules>
71
-
72
- <todo-usage hint="How to use TODO for tracking">
73
- <create>
74
- todowrite([
75
- { id: "story-task-1", content: "Task 1: Create entity", status: "pending", priority: "high" },
76
- { id: "story-task-2", content: "Task 2: Add repository", status: "pending", priority: "medium" },
77
- ...
78
- ])
79
- </create>
80
- <update-progress>
81
- todoread() → get current list
82
- todowrite([...list with task.status = "in_progress"])
83
- </update-progress>
84
- <mark-complete>
85
- todowrite([...list with task.status = "completed"])
86
- </mark-complete>
87
- </todo-usage>
88
72
  </activation>
89
73
 
90
74
  <persona>
@@ -105,7 +105,7 @@ permission:
105
105
  </phase>
106
106
 
107
107
  <phase name="2. Planning">
108
- <action>Create tasklist with todowrite()</action>
108
+ <action>Create tasklist with todowrite() (TODOv2)</action>
109
109
  <action>Present plan to user with specific deliverables</action>
110
110
  <action>Ask for confirmation with question() tool</action>
111
111
  <action>WAIT for user approval before proceeding</action>
@@ -27,9 +27,6 @@ documentation:
27
27
  enforced: true # Cannot be changed
28
28
  paths:
29
29
  - "docs/"
30
- - "CLAUDE.md"
31
- - "README.md"
32
- - ".opencode/"
33
30
 
34
31
  # User-facing docs (translations, Confluence export)
35
32
  user_facing:
@@ -83,13 +80,6 @@ workflow:
83
80
  # Default validation level: strict | normal | minimal
84
81
  validation_level: normal
85
82
 
86
- # =============================================================================
87
- # AGENT DEFAULTS
88
- # =============================================================================
89
- agents:
90
- default_model: "anthropic/claude-sonnet-4-20250514"
91
- default_temperature: 0.3
92
-
93
83
  # =============================================================================
94
84
  # JIRA INTEGRATION
95
85
  # =============================================================================
@@ -221,7 +211,7 @@ epic_workflow:
221
211
  # Run integration tests after each story
222
212
  # When true: story done → run integration tests → continue
223
213
  # When false: skip per-story integration tests
224
- test_after_each_story: false
214
+ test_after_each_story: true
225
215
 
226
216
  # Run integration tests after epic complete
227
217
  # When true: all stories done → run epic integration tests
@@ -21,7 +21,8 @@
21
21
  "tools": {
22
22
  "lsp": true,
23
23
  "search": true,
24
- "codeindex": true
24
+ "codeindex": true,
25
+ "usethis-todo": true
25
26
  },
26
27
 
27
28
  "permission": {
@@ -4,6 +4,6 @@
4
4
  "test:leak": "bun test --smol plugins/__tests__/ --test-name-pattern 'memory safety'"
5
5
  },
6
6
  "dependencies": {
7
- "@opencode-ai/plugin": "1.1.36"
7
+ "@opencode-ai/plugin": "1.1.39"
8
8
  }
9
9
  }
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
+ import { readFile } from "fs/promises"
3
+ import { join } from "path"
4
+ import { createTempDir, cleanupTempDir } from "./helpers/mock-ctx"
5
+ import { write, update, read, get_by_id } from "../../tools/usethis_todo"
6
+
7
+ describe("usethis_todo tool", () => {
8
+ let tempDir: string
9
+ let originalXdg: string | undefined
10
+
11
+ beforeEach(async () => {
12
+ tempDir = await createTempDir()
13
+ originalXdg = process.env.XDG_DATA_HOME
14
+ process.env.XDG_DATA_HOME = join(tempDir, "xdg-data")
15
+ })
16
+
17
+ afterEach(async () => {
18
+ if (originalXdg === undefined) {
19
+ delete process.env.XDG_DATA_HOME
20
+ } else {
21
+ process.env.XDG_DATA_HOME = originalXdg
22
+ }
23
+ await cleanupTempDir(tempDir)
24
+ })
25
+
26
+ it("writes enhanced and native todo files", async () => {
27
+ const ctx = { sessionID: "sess-test", directory: tempDir } as any
28
+ const output = await write.execute(
29
+ {
30
+ todos: [
31
+ { id: "A1", content: "First task", description: "Longer details", status: "ready", priority: "HIGH" },
32
+ { id: "A2", content: "Second task", status: "pending", priority: "LOW" },
33
+ ],
34
+ },
35
+ ctx
36
+ )
37
+
38
+ expect(output).toContain("TODO Graph")
39
+
40
+ const enhancedPath = join(tempDir, ".opencode", "session-todos", "sess-test.json")
41
+ const enhanced = JSON.parse(await readFile(enhancedPath, "utf-8"))
42
+ expect(enhanced.length).toBe(2)
43
+ expect(enhanced[0].content).toBe("First task")
44
+ expect(enhanced[0].description).toBe("Longer details")
45
+
46
+ const nativePath = join(
47
+ process.env.XDG_DATA_HOME!,
48
+ "opencode",
49
+ "storage",
50
+ "todo",
51
+ "sess-test.json"
52
+ )
53
+ const native = JSON.parse(await readFile(nativePath, "utf-8"))
54
+ expect(native.length).toBe(2)
55
+ expect(native[0].content).toContain("First task")
56
+ expect(native[0].content).toContain("Longer details")
57
+ })
58
+
59
+ it("update merges by id and can add new tasks", async () => {
60
+ const ctx = { sessionID: "sess-merge", directory: tempDir } as any
61
+
62
+ await write.execute(
63
+ {
64
+ todos: [
65
+ { id: "A1", content: "First task", description: "v1", status: "pending", priority: "MED" },
66
+ ],
67
+ },
68
+ ctx
69
+ )
70
+
71
+ const result = await update.execute(
72
+ {
73
+ todos: [
74
+ { id: "A1", content: "First task", description: "v2", status: "done", priority: "MED" },
75
+ { id: "A2", content: "Second task", status: "ready", priority: "LOW" },
76
+ ],
77
+ },
78
+ ctx
79
+ )
80
+
81
+ expect(result).toContain("Updated 2 task(s)")
82
+
83
+ const enhancedPath = join(tempDir, ".opencode", "session-todos", "sess-merge.json")
84
+ const enhanced = JSON.parse(await readFile(enhancedPath, "utf-8"))
85
+ expect(enhanced.length).toBe(2)
86
+ expect(enhanced.find((t: any) => t.id === "A1")?.status).toBe("done")
87
+ expect(enhanced.find((t: any) => t.id === "A1")?.description).toBe("v2")
88
+ })
89
+
90
+ it("read returns graph with content", async () => {
91
+ const ctx = { sessionID: "sess-read", directory: tempDir } as any
92
+ await write.execute(
93
+ {
94
+ todos: [
95
+ { id: "A1", content: "Task content", status: "ready", priority: "HIGH" },
96
+ ],
97
+ },
98
+ ctx
99
+ )
100
+
101
+ const output = await read.execute({}, ctx)
102
+ expect(output).toContain("Task content")
103
+ expect(output).toContain("Available Now")
104
+ })
105
+
106
+ it("get_by_id returns single task", async () => {
107
+ const ctx = { sessionID: "sess-get", directory: tempDir } as any
108
+ await write.execute(
109
+ {
110
+ todos: [
111
+ {
112
+ id: "A1",
113
+ content: "Task content",
114
+ description: "More details",
115
+ status: "ready",
116
+ priority: "HIGH",
117
+ blockedBy: ["B1"],
118
+ },
119
+ ],
120
+ },
121
+ ctx
122
+ )
123
+
124
+ const out = await get_by_id.execute({ id: "A1" }, ctx)
125
+ expect(out).toContain("id: A1")
126
+ expect(out).toContain("content:")
127
+ expect(out).toContain("Task content")
128
+ expect(out).toContain("blockedBy: B1")
129
+ expect(out).toContain("description:")
130
+ expect(out).toContain("More details")
131
+ })
132
+
133
+ it("get_by_id returns not found", async () => {
134
+ const ctx = { sessionID: "sess-miss", directory: tempDir } as any
135
+ const out = await get_by_id.execute({ id: "NOPE" }, ctx)
136
+ expect(out).toContain("not found")
137
+ })
138
+ })
@@ -0,0 +1,36 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ /**
4
+ * Publishes a TODO snapshot into the session chat.
5
+ *
6
+ * Why: the TODO sidebar may not live-refresh when we write native todo files
7
+ * (no todo.updated Bus event). This makes changes visible in the main dialog.
8
+ */
9
+ export const UsethisTodoPublish: Plugin = async ({ client }) => {
10
+ return {
11
+ "tool.execute.after": async (input, output) => {
12
+ if (input.tool !== "usethis_todo_write" && input.tool !== "usethis_todo_update") return
13
+
14
+ const text = [
15
+ `## TODO`,
16
+ // `session: ${input.sessionID}`,
17
+ "",
18
+ output.output
19
+ ].join("\n")
20
+
21
+ try {
22
+ await client.session.prompt({
23
+ path: { id: input.sessionID },
24
+ body: {
25
+ noReply: true,
26
+ parts: [{ type: "text", text }],
27
+ },
28
+ })
29
+ } catch {}
30
+
31
+ // Debug toasts removed
32
+ },
33
+ }
34
+ }
35
+
36
+ export default UsethisTodoPublish
@@ -0,0 +1,37 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ /**
4
+ * UseThis TODO UI Plugin — zero-state
5
+ *
6
+ * ONLY sets output.title for usethis_todo_* tools in TUI.
7
+ * No caching, no env vars, no before hook, no HTTP calls.
8
+ * Parses tool output string directly in after hook.
9
+ */
10
+
11
+ export const UsethisTodoUIPlugin: Plugin = async () => {
12
+ return {
13
+ "tool.execute.after": async (input, output) => {
14
+ if (!input.tool.startsWith("usethis_todo_")) return
15
+
16
+ const out = output.output || ""
17
+
18
+ if (input.tool === "usethis_todo_write") {
19
+ const match = out.match(/\[(\d+)\/(\d+) done/)
20
+ output.title = match ? `📋 TODO: ${match[2]} tasks` : "📋 TODO updated"
21
+
22
+ } else if (input.tool === "usethis_todo_update") {
23
+ const match = out.match(/^✅ (.+)$/m)
24
+ output.title = match ? `📝 ${match[1]}` : "📝 Task updated"
25
+
26
+ } else if (input.tool === "usethis_todo_read") {
27
+ const match = out.match(/\[(\d+)\/(\d+) done, (\d+) in progress\]/)
28
+ output.title = match ? `📋 TODO [${match[1]}/${match[2]} done]` : "📋 TODO list"
29
+
30
+ } else if (input.tool === "usethis_todo_read_next_five") {
31
+ output.title = "📋 Next 5 tasks"
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ export default UsethisTodoUIPlugin
@@ -0,0 +1,375 @@
1
+ /**
2
+ * TODO Tool with Dependencies & Priority — v3 (dual storage)
3
+ *
4
+ * 4 commands:
5
+ * usethis_todo_write({ todos: [...] }) - create/update TODO list
6
+ * usethis_todo_read() - read TODO with graph analysis
7
+ * usethis_todo_read_next_five() - get next 5 available tasks
8
+ * usethis_todo_update(id, field, value) - update any task field
9
+ *
10
+ * Storage:
11
+ * Enhanced: .opencode/session-todos/{sid}.json (title, blockedBy, graph)
12
+ * Native: ~/.local/share/opencode/storage/todo/{sid}.json (TUI display)
13
+ *
14
+ * Features:
15
+ * - Hierarchical IDs: E01-S01-T01
16
+ * - Dependencies: blockedBy field
17
+ * - Priority: CRIT | HIGH | MED | LOW (auto-sorted)
18
+ * - Graph: shows available, blocked, parallel tasks
19
+ * - Dual write: native OpenCode storage for TUI integration
20
+ */
21
+
22
+ import { tool } from "@opencode-ai/plugin"
23
+ import path from "path"
24
+ import os from "os"
25
+ import fs from "fs/promises"
26
+
27
+ // ============================================================================
28
+ // Types
29
+ // ============================================================================
30
+
31
+ interface Todo {
32
+ id: string // E01-S01-T01
33
+ content: string // Short task summary
34
+ description?: string // Full task description (optional)
35
+ status: string // pending | ready | in_progress | waiting_review | done | cancelled
36
+ priority: string // CRIT | HIGH | MED | LOW
37
+ blockedBy?: string[] // IDs of blocking tasks
38
+ createdAt?: number
39
+ updatedAt?: number
40
+ }
41
+
42
+ interface NativeTodo {
43
+ id: string
44
+ content: string // "title: content" combined
45
+ status: string // pending | in_progress | completed | cancelled
46
+ priority: string // high | medium | low
47
+ }
48
+
49
+ interface TodoGraph {
50
+ todos: Todo[]
51
+ available: string[]
52
+ parallel: string[][]
53
+ blocked: Record<string, string[]>
54
+ }
55
+
56
+ // ============================================================================
57
+ // Storage — dual write
58
+ // ============================================================================
59
+
60
+ // Resolve project directory (context.directory may be undefined via MCP)
61
+ function dir(directory?: string): string {
62
+ return directory || process.env.OPENCODE_PROJECT_DIR || process.cwd()
63
+ }
64
+
65
+ // Enhanced storage path (project-local)
66
+ function getEnhancedPath(sid: string, directory?: string): string {
67
+ return path.join(dir(directory), ".opencode", "session-todos", `${sid || "current"}.json`)
68
+ }
69
+
70
+ async function getNativeDataDirs(): Promise<string[]> {
71
+ const dirs = new Set<string>()
72
+
73
+ // 1) xdg-basedir (what OpenCode itself uses)
74
+ try {
75
+ const mod: any = await import("xdg-basedir")
76
+ if (mod?.xdgData && typeof mod.xdgData === "string") {
77
+ dirs.add(mod.xdgData)
78
+ }
79
+ } catch {
80
+ // ignore
81
+ }
82
+
83
+ // 2) explicit XDG override
84
+ if (process.env.XDG_DATA_HOME) {
85
+ dirs.add(process.env.XDG_DATA_HOME)
86
+ }
87
+
88
+ // 3) common fallbacks
89
+ dirs.add(path.join(os.homedir(), ".local", "share"))
90
+ dirs.add(path.join(os.homedir(), "Library", "Application Support"))
91
+
92
+ return [...dirs]
93
+ }
94
+
95
+ async function getNativePaths(sid: string): Promise<string[]> {
96
+ const baseDirs = await getNativeDataDirs()
97
+ const file = `${sid || "current"}.json`
98
+ return baseDirs.map((base) => path.join(base, "opencode", "storage", "todo", file))
99
+ }
100
+
101
+ // Map our format → native format
102
+ function toNative(todo: Todo): NativeTodo {
103
+ // Status mapping: our → native
104
+ const statusMap: Record<string, string> = {
105
+ pending: "pending",
106
+ ready: "pending", // native has no "ready"
107
+ in_progress: "in_progress",
108
+ waiting_review: "in_progress", // native has no "waiting_review"
109
+ done: "completed", // native uses "completed" not "done"
110
+ cancelled: "cancelled",
111
+ }
112
+ // Priority mapping: CRIT/HIGH/MED/LOW → high/medium/low
113
+ const prioMap: Record<string, string> = {
114
+ CRIT: "high",
115
+ HIGH: "high",
116
+ MED: "medium",
117
+ LOW: "low",
118
+ }
119
+
120
+ const deps = todo.blockedBy?.length ? ` [← ${todo.blockedBy.join(", ")}]` : ""
121
+ const desc = todo.description?.trim() ? ` — ${todo.description.trim()}` : ""
122
+
123
+ return {
124
+ id: todo.id,
125
+ content: `${todo.content}${desc}${deps}`,
126
+ status: statusMap[todo.status] || "pending",
127
+ priority: prioMap[todo.priority] || "medium",
128
+ }
129
+ }
130
+
131
+ async function readTodos(sid: string, directory?: string): Promise<Todo[]> {
132
+ try {
133
+ return JSON.parse(await fs.readFile(getEnhancedPath(sid, directory), "utf-8"))
134
+ } catch {
135
+ return []
136
+ }
137
+ }
138
+
139
+ async function writeTodos(todos: Todo[], sid: string, directory?: string): Promise<void> {
140
+ // 1. Enhanced storage (our full format)
141
+ const enhancedPath = getEnhancedPath(sid, directory)
142
+ await fs.mkdir(path.dirname(enhancedPath), { recursive: true })
143
+ await fs.writeFile(enhancedPath, JSON.stringify(todos, null, 2), "utf-8")
144
+
145
+ // 2. Native storage (for TUI display)
146
+ const nativeTodos = todos.map(toNative)
147
+ try {
148
+ const nativePaths = await getNativePaths(sid)
149
+ await Promise.allSettled(
150
+ nativePaths.map(async (nativePath) => {
151
+ await fs.mkdir(path.dirname(nativePath), { recursive: true })
152
+ await fs.writeFile(nativePath, JSON.stringify(nativeTodos, null, 2), "utf-8")
153
+ }),
154
+ )
155
+ } catch {
156
+ // Native write failure is non-fatal
157
+ }
158
+ }
159
+
160
+ // ============================================================================
161
+ // Graph analysis
162
+ // ============================================================================
163
+
164
+ function analyzeGraph(todos: Todo[]): TodoGraph {
165
+ const blocked: Record<string, string[]> = {}
166
+ const availableTodos: Todo[] = []
167
+
168
+ for (const todo of todos) {
169
+ if (todo.status !== "ready") continue
170
+ const activeBlockers = (todo.blockedBy || []).filter(id => {
171
+ const b = todos.find(t => t.id === id)
172
+ return b && b.status !== "done"
173
+ })
174
+ if (activeBlockers.length === 0) {
175
+ availableTodos.push(todo)
176
+ } else {
177
+ blocked[todo.id] = activeBlockers
178
+ }
179
+ }
180
+
181
+ const P: Record<string, number> = { CRIT: 0, HIGH: 1, MED: 2, LOW: 3 }
182
+ availableTodos.sort((a, b) => (P[a.priority] ?? 2) - (P[b.priority] ?? 2))
183
+ const available = availableTodos.map(t => t.id)
184
+
185
+ // Parallel groups
186
+ const parallel: string[][] = []
187
+ const seen = new Set<string>()
188
+ for (const id of available) {
189
+ if (seen.has(id)) continue
190
+ const group = [id]
191
+ seen.add(id)
192
+ for (const other of available) {
193
+ if (seen.has(other)) continue
194
+ const a = todos.find(t => t.id === id)
195
+ const b = todos.find(t => t.id === other)
196
+ if (!b?.blockedBy?.includes(id) && !a?.blockedBy?.includes(other)) {
197
+ group.push(other)
198
+ seen.add(other)
199
+ }
200
+ }
201
+ if (group.length > 0) parallel.push(group)
202
+ }
203
+
204
+ return { todos, available, parallel, blocked }
205
+ }
206
+
207
+ // ============================================================================
208
+ // Formatting
209
+ // ============================================================================
210
+
211
+ const PE = (p?: string) => p === "CRIT" ? "🔴" : p === "HIGH" ? "🟠" : p === "LOW" ? "🟢" : "🟡"
212
+ const SI = (s: string) => s === "done" ? "✓" : s === "in_progress" ? "⚙" : s === "ready" ? "○" : s === "cancelled" ? "✗" : s === "waiting_review" ? "⏳" : "·"
213
+
214
+ function formatGraph(graph: TodoGraph): string {
215
+ const { todos } = graph
216
+ const total = todos.length
217
+ const done = todos.filter(t => t.status === "done").length
218
+ const wip = todos.filter(t => t.status === "in_progress").length
219
+
220
+ const lines: string[] = [`═══ TODO Graph [${done}/${total} done, ${wip} in progress] ═══`, ""]
221
+
222
+ lines.push("All Tasks:")
223
+ for (const t of todos) {
224
+ const deps = t.blockedBy?.length ? ` ← ${t.blockedBy.join(", ")}` : ""
225
+ const desc = t.description?.trim() ? ` — ${t.description.trim()}` : ""
226
+ lines.push(` ${SI(t.status)} ${PE(t.priority)} ${t.id}: ${t.content}${desc}${deps}`)
227
+ }
228
+ lines.push("")
229
+
230
+ if (graph.available.length > 0) {
231
+ lines.push("Available Now:")
232
+ for (const id of graph.available) {
233
+ const t = todos.find(x => x.id === id)
234
+ const desc = t?.description?.trim() ? ` — ${t.description.trim()}` : ""
235
+ lines.push(` → ${PE(t?.priority)} ${id}: ${t?.content}${desc}`)
236
+ }
237
+ } else {
238
+ lines.push("Available Now: none")
239
+ }
240
+ lines.push("")
241
+
242
+ const multi = graph.parallel.filter(g => g.length > 1)
243
+ if (multi.length > 0) {
244
+ lines.push("Parallel Groups:")
245
+ multi.forEach((g, i) => lines.push(` Group ${i + 1}: ${g.join(", ")}`))
246
+ lines.push("")
247
+ }
248
+
249
+ if (Object.keys(graph.blocked).length > 0) {
250
+ lines.push("Blocked:")
251
+ for (const [id, blockers] of Object.entries(graph.blocked)) {
252
+ const t = todos.find(x => x.id === id)
253
+ const desc = t?.description?.trim() ? ` — ${t.description.trim()}` : ""
254
+ lines.push(` ⊗ ${id}: ${t?.content}${desc} ← waiting: ${blockers.join(", ")}`)
255
+ }
256
+ }
257
+
258
+ return lines.join("\n")
259
+ }
260
+
261
+ // ============================================================================
262
+ // Tools
263
+ // ============================================================================
264
+
265
+ export const write = tool({
266
+ description: "Create or update TODO list. TODOv2 (Prefer this instead of TODO)",
267
+ args: {
268
+ todos: tool.schema.array(
269
+ tool.schema.object({
270
+ id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
271
+ content: tool.schema.string().describe("Short task summary"),
272
+ description: tool.schema.string().optional().describe("Full task description"),
273
+ status: tool.schema.string().describe("pending | ready | in_progress | waiting_review | done | cancelled"),
274
+ priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
275
+ blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks"),
276
+ })
277
+ ).describe("Array of todos"),
278
+ },
279
+ async execute(args, context) {
280
+ const now = Date.now()
281
+ const todos = args.todos.map((t: any) => ({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
282
+ await writeTodos(todos, context.sessionID, context.directory)
283
+ return formatGraph(analyzeGraph(todos))
284
+ },
285
+ })
286
+
287
+ export const read_next_five = tool({
288
+ description: "Read current TODO list. Shows Next 5 tasks.",
289
+ args: {},
290
+ async execute(_args, context) {
291
+ const todos = await readTodos(context.sessionID, context.directory)
292
+ const graph = analyzeGraph(todos)
293
+ if (graph.available.length === 0) return "No tasks available. All blocked or not ready."
294
+ const next5 = graph.available.slice(0, 5)
295
+ const lines: string[] = ["Next 5 available tasks:", ""]
296
+ for (const id of next5) {
297
+ const t = graph.todos.find(x => x.id === id)
298
+ if (t) {
299
+ const desc = t.description?.trim() ? `\n ${t.description.trim()}` : ""
300
+ lines.push(`${PE(t.priority)} ${id}: ${t.content}${desc}`)
301
+ lines.push("")
302
+ }
303
+ }
304
+ if (graph.available.length > 5) lines.push(`... +${graph.available.length - 5} more`)
305
+ return lines.join("\n")
306
+ },
307
+ })
308
+
309
+ export const read = tool({
310
+ description: "Read current TODO list. Shows all tasks.",
311
+ args: {},
312
+ async execute(_args, context) {
313
+ const todos = await readTodos(context.sessionID, context.directory)
314
+ if (todos.length === 0) return "No todos. Use usethis_todo_write to create."
315
+ return formatGraph(analyzeGraph(todos))
316
+ },
317
+ })
318
+
319
+ export const get_by_id = tool({
320
+ description: "Get one task by id.",
321
+ args: {
322
+ id: tool.schema.string().describe("Task ID"),
323
+ },
324
+ async execute(args, context) {
325
+ const todos = await readTodos(context.sessionID, context.directory)
326
+ const todo = todos.find(t => t.id === args.id)
327
+ if (!todo) return `❌ Task ${args.id} not found`
328
+
329
+ const deps = todo.blockedBy?.length ? `\nblockedBy: ${todo.blockedBy.join(", ")}` : ""
330
+ const desc = todo.description?.trim() ? `\n\ndescription:\n${todo.description.trim()}` : ""
331
+ return [
332
+ `id: ${todo.id}`,
333
+ `priority: ${todo.priority}`,
334
+ `status: ${todo.status}`,
335
+ deps,
336
+ `\ncontent:\n${todo.content}`,
337
+ desc,
338
+ ].filter(Boolean).join("\n")
339
+ },
340
+ })
341
+
342
+ export const update = tool({
343
+ description: "Update tasks. Same interface as write, but merges by id.",
344
+ args: {
345
+ todos: tool.schema.array(
346
+ tool.schema.object({
347
+ id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
348
+ content: tool.schema.string().describe("Short task summary"),
349
+ description: tool.schema.string().optional().describe("Full task description"),
350
+ status: tool.schema.string().describe("pending | ready | in_progress | waiting_review | done | cancelled"),
351
+ priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
352
+ blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks"),
353
+ })
354
+ ).describe("Array of todos to update"),
355
+ },
356
+ async execute(args, context) {
357
+ const todos = await readTodos(context.sessionID, context.directory)
358
+ const now = Date.now()
359
+ const byId = new Map(todos.map(t => [t.id, t]))
360
+
361
+ for (const incoming of args.todos) {
362
+ const existing = byId.get(incoming.id)
363
+ if (existing) {
364
+ Object.assign(existing, incoming)
365
+ existing.updatedAt = now
366
+ } else {
367
+ byId.set(incoming.id, { ...incoming, createdAt: now, updatedAt: now })
368
+ }
369
+ }
370
+
371
+ const merged = [...byId.values()]
372
+ await writeTodos(merged, context.sessionID, context.directory)
373
+ return `✅ Updated ${args.todos.length} task(s)\n\n${formatGraph(analyzeGraph(merged))}`
374
+ },
375
+ })