opencode-auto-review-completed-todos 1.0.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/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # opencode-auto-review-completed-todos
2
+
3
+ Auto-detect when all session todos are completed and send a review message. Fires once per session.
4
+
5
+ ## What it does
6
+
7
+ Listens for OpenCode's internal `todo.updated` events — whenever the todowrite tool creates, updates, or completes todos. When all todos have status `completed` or `cancelled`, it sends a review message to the chat. If new pending todos appear before the debounce fires, the review is cancelled.
8
+
9
+ This works with **any** todo source: the AI creating/checking todos via the todowrite tool, the user checking boxes in the UI, or the internal todo-reminder plugin.
10
+
11
+ **Design philosophy:** Pairs with `opencode-todo-reminder` as its complement. Todo-reminder nudges when tasks remain incomplete; this plugin triggers a review when all tasks are done.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ cp opencode-auto-review-completed-todos.ts ~/.config/opencode/plugins/
17
+ # or the compiled version:
18
+ cp opencode-auto-review-completed-todos.js ~/.config/opencode/plugins/
19
+ ```
20
+
21
+ Register in `opencode.json`:
22
+
23
+ ```json
24
+ "plugin": [
25
+ "opencode-auto-review-completed-todos"
26
+ ]
27
+ ```
28
+
29
+ Restart OpenCode.
30
+
31
+ ## Configuration
32
+
33
+ ```json
34
+ {
35
+ "plugin": [
36
+ ["opencode-auto-review-completed-todos", {
37
+ "debounceMs": 500
38
+ }]
39
+ ]
40
+ }
41
+ ```
42
+
43
+ | Option | Type | Default | Description |
44
+ |--------|------|---------|-------------|
45
+ | `debounceMs` | `number` | `500` | Wait after the last completed todo before sending message |
46
+
47
+ ## How it works
48
+
49
+ ### Architecture
50
+
51
+ Per-session state is minimal:
52
+
53
+ ```
54
+ SessionState
55
+ ├── reviewFired: boolean ← prevent double-fire
56
+ └── debounceTimer: Timer | null ← debounce before sending
57
+ ```
58
+
59
+ ### Event handling
60
+
61
+ | Event | Action |
62
+ |-------|--------|
63
+ | `todo.updated` | Check all todos. If all completed/cancelled → schedule message. If any pending → cancel scheduled message. |
64
+ | `session.deleted` / `session.error` / `session.compacted` | Clean up session state |
65
+
66
+ ### Review trigger flow
67
+
68
+ 1. User/AI completes todos via OpenCode's todowrite tool
69
+ 2. `todo.updated` event fires with updated todo list
70
+ 3. Plugin checks `todos.every(t => t.status === "completed" || t.status === "cancelled")`
71
+ 4. If all done → 500ms debounce timer starts
72
+ 5. Timer fires → sends message to chat: "All tasks in this session have been completed. Please perform a final review..."
73
+ 6. AI responds with a session review summary
74
+
75
+ ### How the message appears
76
+
77
+ Uses `client.session.prompt()` with `synthetic: false` — the same approach as `opencode-todo-reminder`. The message appears as a **normal chat message** that the AI responds to naturally.
78
+
79
+ ## Yin and yang
80
+
81
+ | Plugin | When it triggers | What it does |
82
+ |--------|-----------------|--------------|
83
+ | `opencode-todo-reminder` | Todos remain incomplete | Nudges to complete tasks |
84
+ | `opencode-auto-review-completed-todos` | All todos complete | Triggers review |
85
+
86
+ ## Status
87
+
88
+ **BETA — confirmed working (5 May 2026).**
89
+
90
+ Plugin:
91
+ - Detects `todo.updated` events from OpenCode's internal todowrite tool
92
+ - Sends visible chat message when all todos are completed
93
+ - AI responds with a session review summary
94
+ - Debounces to avoid premature triggering
95
+ - Fires only once per session
96
+
97
+ ## Files
98
+
99
+ | Path | Description |
100
+ |------|-------------|
101
+ | `~/.config/opencode/plugins/opencode-auto-review-completed-todos.js` | Main plugin (loaded by OpenCode) |
102
+ | `~/.config/opencode/plugins/opencode-auto-review-completed-todos.ts` | TypeScript source |
103
+ | `~/Dev/opencode-auto-review-completed-todos/` | Git-tracked source |
104
+
105
+ ## Troubleshooting
106
+
107
+ **Plugin not loading:**
108
+ - Verify `opencode.json` has `"opencode-auto-review-completed-todos"` in the `plugin` array
109
+ - Ensure file is at `~/.config/opencode/plugins/opencode-auto-review-completed-todos.js`
110
+
111
+ **Message not appearing:**
112
+ - Todos must be created via OpenCode's todowrite tool (not raw text like `- [ ]`)
113
+ - All todos must have status `"completed"` or `"cancelled"`
114
+ - Plugin fires only once per session (new session needed after firing)
115
+
116
+ ## Requirements
117
+
118
+ - OpenCode with plugin support
119
+ - No additional npm dependencies
@@ -0,0 +1,106 @@
1
+ // @bun
2
+ // opencode-auto-review-completed-todos.ts
3
+ var DEFAULT_OPTIONS = {
4
+ debounceMs: 500
5
+ };
6
+ function mergeOptions(raw) {
7
+ if (!raw || typeof raw !== "object")
8
+ return { ...DEFAULT_OPTIONS };
9
+ return {
10
+ debounceMs: typeof raw.debounceMs === "number" && raw.debounceMs > 0 ? raw.debounceMs : DEFAULT_OPTIONS.debounceMs
11
+ };
12
+ }
13
+ export default async function AutoReviewCompletedTodosPlugin(input, rawOptions) {
14
+ const config = mergeOptions(rawOptions);
15
+ const sessions = /* @__PURE__ */ new Map();
16
+ function getSession(sessionId) {
17
+ return sessions.get(sessionId) || null;
18
+ }
19
+ function ensureSession(sessionId) {
20
+ let state = sessions.get(sessionId);
21
+ if (!state) {
22
+ state = { reviewFired: false, debounceTimer: null };
23
+ sessions.set(sessionId, state);
24
+ }
25
+ return state;
26
+ }
27
+ function cleanupSession(sessionId) {
28
+ const state = sessions.get(sessionId);
29
+ if (state?.debounceTimer) {
30
+ clearTimeout(state.debounceTimer);
31
+ }
32
+ sessions.delete(sessionId);
33
+ }
34
+ function allTodosCompleted(todos) {
35
+ return Array.isArray(todos) && todos.length > 0 && todos.every((t) => t.status === "completed" || t.status === "cancelled");
36
+ }
37
+ async function triggerReview(sessionId) {
38
+ const state = sessions.get(sessionId);
39
+ if (!state || state.reviewFired)
40
+ return;
41
+ state.reviewFired = true;
42
+ if (state.debounceTimer) {
43
+ clearTimeout(state.debounceTimer);
44
+ state.debounceTimer = null;
45
+ }
46
+ try {
47
+ await input.client.session.prompt({
48
+ path: { id: sessionId },
49
+ query: { directory: input.directory },
50
+ body: {
51
+ parts: [
52
+ {
53
+ type: "text",
54
+ text: "All tasks in this session have been completed. Please perform a final review: summarize what was accomplished, note any technical decisions or trade-offs made, flag anything that should be documented, and list any follow-up tasks or improvements for next time.",
55
+ synthetic: false
56
+ }
57
+ ]
58
+ }
59
+ });
60
+ } catch (e) {
61
+ process.stderr.write("[auto-review] REVIEW TRIGGERED (prompt failed)\n");
62
+ }
63
+ }
64
+ function scheduleReview(sessionId) {
65
+ const state = sessions.get(sessionId);
66
+ if (!state)
67
+ return;
68
+ if (state.debounceTimer)
69
+ clearTimeout(state.debounceTimer);
70
+ state.debounceTimer = setTimeout(() => {
71
+ state.debounceTimer = null;
72
+ triggerReview(sessionId);
73
+ }, config.debounceMs);
74
+ }
75
+ function cancelScheduledReview(sessionId) {
76
+ const state = sessions.get(sessionId);
77
+ if (state?.debounceTimer) {
78
+ clearTimeout(state.debounceTimer);
79
+ state.debounceTimer = null;
80
+ }
81
+ }
82
+ return {
83
+ event: async ({ event }) => {
84
+ const e = event;
85
+ const sessionId = e?.properties?.sessionID ?? e?.properties?.info?.id ?? e?.properties?.path?.id;
86
+ if (!sessionId)
87
+ return;
88
+ if (e.type === "session.deleted" || e.type === "session.error" || e.type === "session.compacted") {
89
+ cleanupSession(sessionId);
90
+ return;
91
+ }
92
+ if (e.type === "todo.updated") {
93
+ const todos = e?.properties?.todos;
94
+ if (!Array.isArray(todos))
95
+ return;
96
+ const state = ensureSession(sessionId);
97
+ if (allTodosCompleted(todos)) {
98
+ scheduleReview(sessionId);
99
+ } else {
100
+ cancelScheduledReview(sessionId);
101
+ }
102
+ return;
103
+ }
104
+ }
105
+ };
106
+ }
@@ -0,0 +1,151 @@
1
+ // @bun
2
+ // opencode-auto-review-completed-todos.ts
3
+ import { type Plugin, type PluginOptions } from "@opencode-ai/plugin";
4
+ import type { EventTodoUpdated, Todo } from "@opencode-ai/sdk";
5
+
6
+ interface SessionState {
7
+ reviewFired: boolean;
8
+ debounceTimer: ReturnType<typeof setTimeout> | null;
9
+ }
10
+
11
+ interface Options {
12
+ debounceMs: number;
13
+ }
14
+
15
+ const DEFAULT_OPTIONS: Options = {
16
+ debounceMs: 500,
17
+ };
18
+
19
+ function mergeOptions(raw: unknown): Options {
20
+ if (!raw || typeof raw !== "object") return { ...DEFAULT_OPTIONS };
21
+ const o = raw as Record<string, unknown>;
22
+ return {
23
+ debounceMs:
24
+ typeof o.debounceMs === "number" && o.debounceMs > 0
25
+ ? o.debounceMs
26
+ : DEFAULT_OPTIONS.debounceMs,
27
+ };
28
+ }
29
+
30
+ function allTodosCompleted(todos: Todo[]): boolean {
31
+ return (
32
+ Array.isArray(todos) &&
33
+ todos.length > 0 &&
34
+ todos.every(
35
+ (t: Todo) => t.status === "completed" || t.status === "cancelled",
36
+ )
37
+ );
38
+ }
39
+
40
+ const AutoReviewCompletedTodosPlugin: Plugin = async (input, rawOptions) => {
41
+ const config = mergeOptions(rawOptions);
42
+ const sessions = new Map<string, SessionState>();
43
+
44
+ function getSession(sessionId: string): SessionState | null {
45
+ return sessions.get(sessionId) ?? null;
46
+ }
47
+
48
+ function ensureSession(sessionId: string): SessionState {
49
+ let state = sessions.get(sessionId);
50
+ if (!state) {
51
+ state = { reviewFired: false, debounceTimer: null };
52
+ sessions.set(sessionId, state);
53
+ }
54
+ return state;
55
+ }
56
+
57
+ function cleanupSession(sessionId: string): void {
58
+ const state = sessions.get(sessionId);
59
+ if (state?.debounceTimer) {
60
+ clearTimeout(state.debounceTimer);
61
+ }
62
+ sessions.delete(sessionId);
63
+ }
64
+
65
+ async function triggerReview(sessionId: string): Promise<void> {
66
+ const state = sessions.get(sessionId);
67
+ if (!state || state.reviewFired) return;
68
+
69
+ state.reviewFired = true;
70
+ if (state.debounceTimer) {
71
+ clearTimeout(state.debounceTimer);
72
+ state.debounceTimer = null;
73
+ }
74
+
75
+ try {
76
+ await input.client.session.prompt({
77
+ path: { id: sessionId },
78
+ query: { directory: input.directory },
79
+ body: {
80
+ parts: [
81
+ {
82
+ type: "text" as const,
83
+ text: "All tasks in this session have been completed. Please perform a final review: summarize what was accomplished, note any technical decisions or trade-offs made, flag anything that should be documented, and list any follow-up tasks or improvements for next time.",
84
+ synthetic: false,
85
+ },
86
+ ],
87
+ },
88
+ });
89
+ } catch {
90
+ process.stderr.write("[auto-review] REVIEW TRIGGERED (prompt failed)\n");
91
+ }
92
+ }
93
+
94
+ function scheduleReview(sessionId: string): void {
95
+ const state = sessions.get(sessionId);
96
+ if (!state) return;
97
+
98
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
99
+ state.debounceTimer = setTimeout(() => {
100
+ state.debounceTimer = null;
101
+ triggerReview(sessionId);
102
+ }, config.debounceMs);
103
+ }
104
+
105
+ function cancelScheduledReview(sessionId: string): void {
106
+ const state = sessions.get(sessionId);
107
+ if (state?.debounceTimer) {
108
+ clearTimeout(state.debounceTimer);
109
+ state.debounceTimer = null;
110
+ }
111
+ }
112
+
113
+ return {
114
+ event: async ({ event }) => {
115
+ const e = event as Record<string, unknown>;
116
+ const props = e?.properties as Record<string, unknown> | undefined;
117
+ const sessionId: string | undefined =
118
+ (props?.sessionID as string) ??
119
+ ((props?.info as Record<string, unknown>)?.id as string) ??
120
+ ((props?.path as Record<string, unknown>)?.id as string);
121
+
122
+ if (!sessionId) return;
123
+
124
+ if (
125
+ e.type === "session.deleted" ||
126
+ e.type === "session.error" ||
127
+ e.type === "session.compacted"
128
+ ) {
129
+ cleanupSession(sessionId);
130
+ return;
131
+ }
132
+
133
+ if (e.type === "todo.updated") {
134
+ const todos = (e as unknown as EventTodoUpdated).properties.todos;
135
+ if (!Array.isArray(todos)) return;
136
+
137
+ const state = ensureSession(sessionId);
138
+
139
+ if (allTodosCompleted(todos)) {
140
+ scheduleReview(sessionId);
141
+ } else {
142
+ cancelScheduledReview(sessionId);
143
+ }
144
+ return;
145
+ }
146
+ },
147
+ };
148
+ };
149
+
150
+ export { AutoReviewCompletedTodosPlugin };
151
+ export default AutoReviewCompletedTodosPlugin;
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "opencode-auto-review-completed-todos",
3
+ "version": "1.0.0",
4
+ "description": "Auto-detect when all session todos are completed and trigger a review. Yin to opencode-todo-reminder's yang.",
5
+ "type": "module",
6
+ "main": "opencode-auto-review-completed-todos.js",
7
+ "types": "opencode-auto-review-completed-todos.ts",
8
+ "files": [
9
+ "opencode-auto-review-completed-todos.js",
10
+ "opencode-auto-review-completed-todos.ts",
11
+ "README.md"
12
+ ],
13
+ "peerDependencies": {
14
+ "@opencode-ai/plugin": ">=1.0.0",
15
+ "@opencode-ai/sdk": ">=1.0.0"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "todo",
21
+ "review",
22
+ "productivity"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/DraconDev/opencode-auto-review-completed-todos"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ }
31
+ }