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 +119 -0
- package/opencode-auto-review-completed-todos.js +106 -0
- package/opencode-auto-review-completed-todos.ts +151 -0
- package/package.json +31 -0
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
|
+
}
|