@geekbeer/minion 3.22.0 → 3.23.1

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/core/db.js CHANGED
@@ -259,12 +259,15 @@ function initSchema(db) {
259
259
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
260
260
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
261
261
  completed_at TEXT,
262
- data TEXT
262
+ data TEXT,
263
+ session_id TEXT,
264
+ injection_count INTEGER NOT NULL DEFAULT 0
263
265
  );
264
266
 
265
267
  CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
266
268
  CREATE INDEX IF NOT EXISTS idx_todos_project ON todos(project_id);
267
269
  CREATE INDEX IF NOT EXISTS idx_todos_priority ON todos(priority);
270
+ CREATE INDEX IF NOT EXISTS idx_todos_session ON todos(session_id);
268
271
 
269
272
  -- ==================== emails ====================
270
273
  CREATE TABLE IF NOT EXISTS emails (
@@ -448,9 +451,12 @@ function migrateSchema(db) {
448
451
 
449
452
  console.log('[DB] Migration 3 complete: memories.project_id added')
450
453
  } catch (err) {
451
- // Column may already exist (duplicate column error)
452
- console.warn(`[DB] Migration 3 skipped: ${err.message}`)
453
- try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (3)") } catch {}
454
+ if (hasColumn(db, 'memories', 'project_id')) {
455
+ console.warn(`[DB] Migration 3 skipped (column already exists): ${err.message}`)
456
+ try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (3)") } catch {}
457
+ } else {
458
+ console.error(`[DB] Migration 3 FAILED — memories.project_id not added: ${err.message}`)
459
+ }
454
460
  }
455
461
  }
456
462
 
@@ -469,10 +475,90 @@ function migrateSchema(db) {
469
475
 
470
476
  console.log('[DB] Migration 4 complete: chat_sessions.workspace_id added')
471
477
  } catch (err) {
472
- console.warn(`[DB] Migration 4 skipped: ${err.message}`)
473
- try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (4)") } catch {}
478
+ if (hasColumn(db, 'chat_sessions', 'workspace_id')) {
479
+ console.warn(`[DB] Migration 4 skipped (column already exists): ${err.message}`)
480
+ try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (4)") } catch {}
481
+ } else {
482
+ console.error(`[DB] Migration 4 FAILED — chat_sessions.workspace_id not added: ${err.message}`)
483
+ }
484
+ }
485
+ }
486
+
487
+ if (currentVersion < 5) {
488
+ try {
489
+ console.log('[DB] Migration 5: Adding session_id / injection_count to todos...')
490
+
491
+ db.exec(`
492
+ ALTER TABLE todos ADD COLUMN session_id TEXT;
493
+ ALTER TABLE todos ADD COLUMN injection_count INTEGER NOT NULL DEFAULT 0;
494
+
495
+ CREATE INDEX IF NOT EXISTS idx_todos_session ON todos(session_id);
496
+
497
+ INSERT INTO schema_version (version) VALUES (5);
498
+ `)
499
+
500
+ console.log('[DB] Migration 5 complete: todos.session_id / injection_count added')
501
+ } catch (err) {
502
+ if (hasColumn(db, 'todos', 'session_id') && hasColumn(db, 'todos', 'injection_count')) {
503
+ console.warn(`[DB] Migration 5 skipped (columns already exist): ${err.message}`)
504
+ try { db.exec("INSERT OR IGNORE INTO schema_version (version) VALUES (5)") } catch {}
505
+ } else {
506
+ console.error(`[DB] Migration 5 FAILED — todos columns not added: ${err.message}`)
507
+ }
474
508
  }
475
509
  }
510
+
511
+ // Repair step: fix DBs where migrations were incorrectly marked as done
512
+ // but columns were never actually added (caused by the old catch-all error handling)
513
+ repairMissingColumns(db)
514
+ }
515
+
516
+ /**
517
+ * Check if a table has a specific column.
518
+ * @param {import('better-sqlite3').Database} db
519
+ * @param {string} table
520
+ * @param {string} column
521
+ * @returns {boolean}
522
+ */
523
+ function hasColumn(db, table, column) {
524
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all()
525
+ return cols.some(c => c.name === column)
526
+ }
527
+
528
+ /**
529
+ * Repair missing columns that should have been added by migrations.
530
+ * Handles DBs where migrations were marked as done but ALTER TABLE actually failed.
531
+ */
532
+ function repairMissingColumns(db) {
533
+ const repairs = [
534
+ { table: 'memories', column: 'project_id', sql: 'ALTER TABLE memories ADD COLUMN project_id TEXT DEFAULT NULL' },
535
+ { table: 'chat_sessions', column: 'workspace_id', sql: 'ALTER TABLE chat_sessions ADD COLUMN workspace_id TEXT DEFAULT NULL' },
536
+ { table: 'todos', column: 'session_id', sql: 'ALTER TABLE todos ADD COLUMN session_id TEXT' },
537
+ { table: 'todos', column: 'injection_count', sql: 'ALTER TABLE todos ADD COLUMN injection_count INTEGER NOT NULL DEFAULT 0' },
538
+ ]
539
+
540
+ for (const { table, column, sql } of repairs) {
541
+ if (!hasColumn(db, table, column)) {
542
+ try {
543
+ console.warn(`[DB] Repair: adding missing column ${table}.${column}`)
544
+ db.exec(sql)
545
+ console.log(`[DB] Repair: ${table}.${column} added successfully`)
546
+ } catch (err) {
547
+ console.error(`[DB] Repair FAILED for ${table}.${column}: ${err.message}`)
548
+ }
549
+ }
550
+ }
551
+
552
+ // Ensure indexes exist after repair
553
+ try {
554
+ db.exec(`
555
+ CREATE INDEX IF NOT EXISTS idx_memories_project_id ON memories(project_id);
556
+ CREATE INDEX IF NOT EXISTS idx_chat_sessions_workspace ON chat_sessions(workspace_id, updated_at DESC);
557
+ CREATE INDEX IF NOT EXISTS idx_todos_session ON todos(session_id);
558
+ `)
559
+ } catch (err) {
560
+ console.warn(`[DB] Repair index creation skipped: ${err.message}`)
561
+ }
476
562
  }
477
563
 
478
564
  /**
@@ -40,12 +40,13 @@ async function todoRoutes(fastify) {
40
40
  return { success: false, error: 'Unauthorized' }
41
41
  }
42
42
 
43
- const { status, priority, project_id, source_type, limit } = request.query || {}
43
+ const { status, priority, project_id, source_type, session_id, limit } = request.query || {}
44
44
  const todos = todoStore.list({
45
45
  status,
46
46
  priority,
47
47
  project_id,
48
48
  source_type,
49
+ session_id,
49
50
  limit: limit ? parseInt(limit, 10) : undefined,
50
51
  })
51
52
 
@@ -75,14 +76,14 @@ async function todoRoutes(fastify) {
75
76
  return { success: false, error: 'Unauthorized' }
76
77
  }
77
78
 
78
- const { title, description, priority, source_type, source_id, project_id, due_at, data } = request.body || {}
79
+ const { title, description, priority, source_type, source_id, project_id, due_at, data, session_id } = request.body || {}
79
80
  if (!title) {
80
81
  reply.code(400)
81
82
  return { success: false, error: 'title is required' }
82
83
  }
83
84
 
84
85
  try {
85
- const todo = todoStore.add({ title, description, priority, source_type, source_id, project_id, due_at, data })
86
+ const todo = todoStore.add({ title, description, priority, source_type, source_id, project_id, due_at, data, session_id })
86
87
  console.log(`[Todos] Created: ${todo.id} "${todo.title}"`)
87
88
  reply.code(201)
88
89
  return { success: true, todo }
@@ -19,6 +19,11 @@ const VALID_SOURCE_TYPES = ['thread', 'workflow', 'directive', 'user', 'self']
19
19
  // Auto-prune completed todos older than this
20
20
  const PRUNE_DAYS = 30
21
21
 
22
+ // Max number of times an incomplete todo gets auto-injected into the chat
23
+ // prompt before we give up and stop injecting it (to avoid infinite loops
24
+ // when Claude cannot finish a task).
25
+ const MAX_INJECTION_COUNT = 5
26
+
22
27
  /**
23
28
  * Create a new TODO.
24
29
  * @param {object} todo - { title, description?, priority?, source_type?, source_id?, project_id?, due_at?, data? }
@@ -50,12 +55,14 @@ function add(todo) {
50
55
  created_at: now,
51
56
  updated_at: now,
52
57
  completed_at: null,
58
+ session_id: todo.session_id || null,
59
+ injection_count: 0,
53
60
  ...(todo.data ? { data: todo.data } : {}),
54
61
  }
55
62
 
56
63
  db.prepare(`
57
- INSERT INTO todos (id, title, description, status, priority, source_type, source_id, project_id, due_at, created_at, updated_at, completed_at, data)
58
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
64
+ INSERT INTO todos (id, title, description, status, priority, source_type, source_id, project_id, due_at, created_at, updated_at, completed_at, data, session_id, injection_count)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59
66
  `).run(
60
67
  record.id,
61
68
  record.title,
@@ -69,7 +76,9 @@ function add(todo) {
69
76
  record.created_at,
70
77
  record.updated_at,
71
78
  record.completed_at,
72
- record.data ? JSON.stringify(record.data) : null
79
+ record.data ? JSON.stringify(record.data) : null,
80
+ record.session_id,
81
+ record.injection_count
73
82
  )
74
83
 
75
84
  console.log(`[TodoStore] Added: "${record.title}" (${record.priority}, source=${record.source_type || 'none'})`)
@@ -115,7 +124,8 @@ function update(id, updates) {
115
124
  db.prepare(`
116
125
  UPDATE todos SET title = ?, description = ?, status = ?, priority = ?,
117
126
  source_type = ?, source_id = ?, project_id = ?, due_at = ?,
118
- updated_at = ?, completed_at = ?, data = ?
127
+ updated_at = ?, completed_at = ?, data = ?,
128
+ session_id = ?, injection_count = ?
119
129
  WHERE id = ?
120
130
  `).run(
121
131
  merged.title,
@@ -129,6 +139,8 @@ function update(id, updates) {
129
139
  merged.updated_at,
130
140
  merged.completed_at,
131
141
  merged.data ? JSON.stringify(merged.data) : null,
142
+ merged.session_id ?? null,
143
+ merged.injection_count ?? 0,
132
144
  id
133
145
  )
134
146
 
@@ -160,9 +172,50 @@ function getById(id) {
160
172
  return row ? parseRow(row) : null
161
173
  }
162
174
 
175
+ /**
176
+ * List incomplete (pending/in_progress) todos linked to a chat session that
177
+ * still have remaining injection budget. Used by the chat route to remind
178
+ * Claude of unfinished work even after context compaction.
179
+ * @param {string} sessionId
180
+ * @returns {Array}
181
+ */
182
+ function listActiveForSession(sessionId) {
183
+ if (!sessionId) return []
184
+ const db = getDb()
185
+ const rows = db.prepare(`
186
+ SELECT * FROM todos
187
+ WHERE session_id = ?
188
+ AND status IN ('pending', 'in_progress')
189
+ AND injection_count < ?
190
+ ORDER BY
191
+ CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END,
192
+ created_at ASC
193
+ `).all(sessionId, MAX_INJECTION_COUNT)
194
+ return rows.map(parseRow)
195
+ }
196
+
197
+ /**
198
+ * Increment injection_count for the given todo IDs. Should be called once
199
+ * the reminder has been actually written into the outgoing prompt.
200
+ * @param {string[]} ids
201
+ */
202
+ function markInjected(ids) {
203
+ if (!Array.isArray(ids) || ids.length === 0) return
204
+ const db = getDb()
205
+ const now = new Date().toISOString()
206
+ const stmt = db.prepare(`
207
+ UPDATE todos
208
+ SET injection_count = injection_count + 1, updated_at = ?
209
+ WHERE id = ?
210
+ `)
211
+ for (const id of ids) {
212
+ stmt.run(now, id)
213
+ }
214
+ }
215
+
163
216
  /**
164
217
  * List TODOs with optional filters.
165
- * @param {object} opts - { status?, priority?, project_id?, source_type?, limit? }
218
+ * @param {object} opts - { status?, priority?, project_id?, source_type?, session_id?, limit? }
166
219
  * @returns {Array}
167
220
  */
168
221
  function list(opts = {}) {
@@ -186,6 +239,10 @@ function list(opts = {}) {
186
239
  conditions.push('source_type = ?')
187
240
  params.push(opts.source_type)
188
241
  }
242
+ if (opts.session_id) {
243
+ conditions.push('session_id = ?')
244
+ params.push(opts.session_id)
245
+ }
189
246
 
190
247
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
191
248
  const limit = opts.limit || 100
@@ -260,6 +317,9 @@ module.exports = {
260
317
  remove,
261
318
  getById,
262
319
  list,
320
+ listActiveForSession,
321
+ markInjected,
263
322
  getSummary,
264
323
  pruneCompleted,
324
+ MAX_INJECTION_COUNT,
265
325
  }
@@ -229,6 +229,38 @@ curl -X PUT /api/config/env \
229
229
  The scheduler starts automatically on server boot if `REFLECTION_TIME` is configured.
230
230
  Changes via the config API take effect immediately (no restart required).
231
231
 
232
+ ### Todos
233
+
234
+ ミニオンローカルのToDoリスト。SQLiteに永続化され、HQにも同期される。
235
+
236
+ | Method | Endpoint | Description |
237
+ |--------|----------|-------------|
238
+ | GET | `/api/todos` | List todos. Query: `status`, `priority`, `project_id`, `source_type`, `session_id`, `limit` |
239
+ | GET | `/api/todos/summary` | Status counts |
240
+ | GET | `/api/todos/:id` | Get single todo |
241
+ | POST | `/api/todos` | Create. Body: `{title, description?, priority?, source_type?, source_id?, project_id?, due_at?, session_id?, data?}` |
242
+ | PUT | `/api/todos/:id` | Update any field including `status` |
243
+ | DELETE | `/api/todos/:id` | Delete |
244
+
245
+ **フィールド**:
246
+ - `status`: `pending` / `in_progress` / `done` / `cancelled`
247
+ - `priority`: `low` / `normal` / `high` / `urgent`
248
+ - `session_id`: チャットセッションID(任意)。**設定すると圧縮を跨いだ自動再掲の対象になる** — 次ターン以降のプロンプト冒頭で未完了Todoが自動表示される。
249
+ - `injection_count`: 自動再掲された回数(読み取り専用)。一定回数を超えたTodoは再掲が停止する。
250
+
251
+ **セッション紐づけ例**:
252
+ ```bash
253
+ # 作成時にsession_idを指定すると、このセッションのチャットに自動で再掲される
254
+ curl -X POST -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
255
+ http://localhost:8080/api/todos \
256
+ -d '{"title": "レポートを保存", "session_id": "'$SESSION_ID'", "priority": "high"}'
257
+
258
+ # 完了マーク(即座に更新すること)
259
+ curl -X PUT -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
260
+ http://localhost:8080/api/todos/$TODO_ID \
261
+ -d '{"status": "done"}'
262
+ ```
263
+
232
264
  ### Config
233
265
 
234
266
  | Method | Endpoint | Description |
@@ -1187,16 +1219,16 @@ DAG ワークフローの graph は以下の構造で保存される(`dag_work
1187
1219
  "label": "Per item",
1188
1220
  "fan_out_source": ".items",
1189
1221
  "template": { "nodes": [ ... ], "edges": [ ... ] },
1190
- "join_node": "n4",
1222
+ "join_mode": "all",
1223
+ "on_failure": "collect",
1191
1224
  "max_concurrency": 3
1192
1225
  },
1193
1226
  {
1194
1227
  "id": "n4",
1195
1228
  "type": "join",
1196
1229
  "label": "Collect",
1197
- "fan_out_node": "n3",
1198
1230
  "join_mode": "all",
1199
- "on_failure": "collect"
1231
+ "aggregation": "array"
1200
1232
  },
1201
1233
  { "id": "n5", "type": "end", "label": "End" }
1202
1234
  ],
@@ -1218,8 +1250,8 @@ DAG ワークフローの graph は以下の構造で保存される(`dag_work
1218
1250
  | `skill` | スキル実行。`skill_version_id` と `assigned_role` が必須 | ✅ |
1219
1251
  | `transform` | LLM によるデータ変換。`transform_instruction` が必須 | ✅ |
1220
1252
  | `review` | レビューゲート。`approved` / `revision_requested` で分岐 | ❌ (内部) |
1221
- | `fan_out` | 配列入力をテンプレートsub-graphに展開して並列実行 | ❌ (内部) |
1222
- | `join` | fan_out の結果を集約 | ❌ (内部) |
1253
+ | `fan_out` | 配列入力をテンプレートsub-graphに展開して並列実行。子が全て settle すると自ノードが completed に遷移 | ❌ (内部) |
1254
+ | `join` | N本の上流エッジを待ち合わせる汎用バリア。fan_out とは独立 | ❌ (内部) |
1223
1255
  | `conditional` | 条件分岐(`llm` / `regex` / `jq`) | ❌ (内部) |
1224
1256
 
1225
1257
  #### DagNode 主要フィールド
@@ -1233,11 +1265,10 @@ DAG ワークフローの graph は以下の構造で保存される(`dag_work
1233
1265
  | `assigned_role` | `pm`\|`engineer`\|`accountant` | 実行ロール |
1234
1266
  | `fan_out_source` | string? | fan_out: 入力から配列を取り出すドット記法(例 `.items`) |
1235
1267
  | `template` | DagGraph? | fan_out: 各要素ごとに展開するsub-graph |
1236
- | `join_node` | string? | fan_out: 対応する join ノードID |
1237
1268
  | `max_concurrency` | number? | fan_out: 並列インスタンス上限 |
1238
- | `fan_out_node` | string? | join: 対応する fan_out ノードID |
1239
- | `join_mode` | `all`\|`any`\|`majority` | join: 完了判定 |
1240
- | `on_failure` | `fail_all`\|`ignore`\|`collect` | join: 失敗時の挙動 |
1269
+ | `join_mode` | `all`\|`any`\|`majority` | fan_out / join: 完了判定 |
1270
+ | `on_failure` | `fail_all`\|`ignore`\|`collect` | fan_out / join: 失敗時の挙動 |
1271
+ | `aggregation` | `array`\|`merge` | join: 上流出力の束ね方(デフォルト `array`) |
1241
1272
  | `condition_type` | `llm`\|`regex`\|`jq` | conditional: 条件評価方式 |
1242
1273
  | `condition_expression` | string | conditional: 条件式 |
1243
1274
  | `branches` | Record<string,string> | conditional: 条件出力→遷移先ノードID |
@@ -22,6 +22,7 @@ const path = require('path')
22
22
  const { verifyToken } = require('../../core/lib/auth')
23
23
  const { config } = require('../../core/config')
24
24
  const chatStore = require('../../core/stores/chat-store')
25
+ const todoStore = require('../../core/stores/todo-store')
25
26
  const { runEndOfDay } = require('../../core/lib/end-of-day')
26
27
  const { DATA_DIR } = require('../../core/lib/platform')
27
28
  const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
@@ -255,6 +256,33 @@ ${indexed}`
255
256
  async function buildContextPrefix(message, context, sessionId) {
256
257
  const parts = []
257
258
 
259
+ // Re-inject unfinished todos tied to this session. This is how we survive
260
+ // context compaction: even if Claude forgot the plan, the outstanding
261
+ // todos are re-shown on the next turn. Todos past MAX_INJECTION_COUNT are
262
+ // skipped by the store to prevent infinite loops.
263
+ if (sessionId) {
264
+ parts.push(
265
+ `[現在のチャットセッションID] ${sessionId}`,
266
+ '新規Todoを作成する際は `session_id` にこの値を含めてください(圧縮を跨いだ自動再掲の対象になります)。',
267
+ ''
268
+ )
269
+ const activeTodos = todoStore.listActiveForSession(sessionId)
270
+ if (activeTodos.length > 0) {
271
+ parts.push(
272
+ '[未完了のToDo(このセッション起点)]',
273
+ '以下のToDoが未完了のまま残っています。着手前に「既に完了していないか」を確認し、',
274
+ '完了済みなら `PUT /api/todos/:id` で status=done に更新、未完なら続行してください。',
275
+ ''
276
+ )
277
+ for (const t of activeTodos) {
278
+ const desc = t.description ? ` — ${t.description}` : ''
279
+ parts.push(`- [${t.id}] (${t.status}/${t.priority}) ${t.title}${desc}`)
280
+ }
281
+ parts.push('')
282
+ todoStore.markInjected(activeTodos.map(t => t.id))
283
+ }
284
+ }
285
+
258
286
  // Tell the LLM how to access memory and daily logs via API
259
287
  if (!sessionId) {
260
288
  const port = require('../../core/config').config.AGENT_PORT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.22.0",
3
+ "version": "3.23.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -203,6 +203,18 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
203
203
  - `pm.md` — PM (Project Manager) のガイドライン
204
204
  - `engineer.md` — Engineer のガイドライン
205
205
 
206
+ ## Todo運用ルール
207
+
208
+ チャット中のタスクは `/api/todos` に登録して進捗管理すること。圧縮(context compaction)を跨いでも作業を完遂するための仕組みがある。
209
+
210
+ - **Todoは「1往復で完了する粒度」に分解して登録する。** 大きいタスクは複数のTodoに分ける。粒度を小さく保てば、完了マークを圧縮に奪われにくい。
211
+ - **完了したら即座に done にマークする。** まとめて更新しない。`PUT /api/todos/:id` で `status=done`。
212
+ - **チャットセッション内で作成するTodoには必ず `session_id` を含める。** プロンプト冒頭の `[現在のチャットセッションID]` の値を使う。紐づいた未完了Todoは次ターン以降に自動で再掲される(圧縮を跨いでも失われない)。
213
+ - **再掲されたTodoを見たら、着手前に「既に完了していないか」を確認する。** 完了済みなら done に更新、未完なら続行。
214
+ - 同一Todoが規定回数以上再掲されても未完了のままの場合、再掲は自動停止する。進展しないTodoはブロッカーとして起票するか手動で `cancelled` にすること。
215
+
216
+ API詳細は `~/.minion/docs/api-reference.md` の「Todos」セクションを参照。
217
+
206
218
  ## Blocker Handling (ブロッカー対処)
207
219
 
208
220
  タスク実行中にブロッカー(自力で解決できない問題)に遭遇した場合、以下のフローに従うこと。
@@ -19,6 +19,7 @@ const http = require('http')
19
19
  const { verifyToken } = require('../../core/lib/auth')
20
20
  const { config } = require('../../core/config')
21
21
  const chatStore = require('../../core/stores/chat-store')
22
+ const todoStore = require('../../core/stores/todo-store')
22
23
  const { DATA_DIR } = require('../../core/lib/platform')
23
24
  const { runEndOfDay } = require('../../core/lib/end-of-day')
24
25
  const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
@@ -319,6 +320,32 @@ ${indexed}`
319
320
  async function buildContextPrefix(message, context, sessionId) {
320
321
  const parts = []
321
322
 
323
+ // Re-inject unfinished todos tied to this session. Survives context
324
+ // compaction — Claude sees outstanding todos again on the next turn.
325
+ // Skipped past MAX_INJECTION_COUNT to prevent infinite loops.
326
+ if (sessionId) {
327
+ parts.push(
328
+ `[現在のチャットセッションID] ${sessionId}`,
329
+ '新規Todoを作成する際は `session_id` にこの値を含めてください(圧縮を跨いだ自動再掲の対象になります)。',
330
+ ''
331
+ )
332
+ const activeTodos = todoStore.listActiveForSession(sessionId)
333
+ if (activeTodos.length > 0) {
334
+ parts.push(
335
+ '[未完了のToDo(このセッション起点)]',
336
+ '以下のToDoが未完了のまま残っています。着手前に「既に完了していないか」を確認し、',
337
+ '完了済みなら `PUT /api/todos/:id` で status=done に更新、未完なら続行してください。',
338
+ ''
339
+ )
340
+ for (const t of activeTodos) {
341
+ const desc = t.description ? ` — ${t.description}` : ''
342
+ parts.push(`- [${t.id}] (${t.status}/${t.priority}) ${t.title}${desc}`)
343
+ }
344
+ parts.push('')
345
+ todoStore.markInjected(activeTodos.map(t => t.id))
346
+ }
347
+ }
348
+
322
349
  // Tell the LLM how to access memory and daily logs via API
323
350
  if (!sessionId) {
324
351
  const port = require('../../core/config').config.AGENT_PORT