@forloop-cc/forloop-opencode-plugin-planner 2.2.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/CODE_OF_CONDUCT.md +77 -0
  3. package/CONTRIBUTING.md +176 -0
  4. package/LICENSE +21 -0
  5. package/README.md +211 -0
  6. package/SECURITY.md +69 -0
  7. package/docs/CONFIGURATION.md +283 -0
  8. package/docs/DEVELOPER-VS-USER.md +129 -0
  9. package/docs/IMPROVED-AUTHENTICATION.md +317 -0
  10. package/docs/INSTALLATION-SCRIPTS.md +304 -0
  11. package/docs/PLANNER-AGENT-IMPROVEMENTS.md +88 -0
  12. package/docs/PLUGIN-ARCHITECTURE.md +308 -0
  13. package/docs/QUICKSTART-NON-DEV.md +94 -0
  14. package/docs/README-FOR-HUMANS-AND-AI.md +109 -0
  15. package/docs/README.md +70 -0
  16. package/docs/SKILLS-GAP-ANALYSIS.md +157 -0
  17. package/docs/USER-INTERACTION-MODEL.md +106 -0
  18. package/docs/file-management/2026-04-12-forloop-aivy-documents-sync.md +395 -0
  19. package/docs/file-management/FILE_UPLOAD_ENHANCEMENT.md +431 -0
  20. package/docs/file-management/FILE_UPLOAD_FLOW.md +300 -0
  21. package/docs/images/forloop-opencode-flow.png +0 -0
  22. package/docs/images/forloop-opencode-header.png +0 -0
  23. package/docs/superpowers/plans/2026-05-03-sprint-isolated-directory-structure.md +1450 -0
  24. package/docs/superpowers/plans/2026-05-29-install-system.md +850 -0
  25. package/docs/superpowers/specs/2026-05-03-sprint-isolated-directory-structure-design.md +149 -0
  26. package/docs/superpowers/specs/2026-05-19-opencode-message-recording-design.md +468 -0
  27. package/docs/superpowers/specs/2026-05-29-install-system-design.md +135 -0
  28. package/install.sh +317 -0
  29. package/package.json +87 -0
  30. package/plugins/capabilities/api-client.ts +556 -0
  31. package/plugins/capabilities/auth.ts +120 -0
  32. package/plugins/capabilities/config.ts +195 -0
  33. package/plugins/capabilities/context-resolver.ts +106 -0
  34. package/plugins/capabilities/token-storage.ts +98 -0
  35. package/plugins/forloop-plugin.ts +186 -0
  36. package/plugins/hooks/message-recorder.ts +258 -0
  37. package/plugins/hooks/shell-env.ts +62 -0
  38. package/plugins/tools/agent-sprint-tools.ts +100 -0
  39. package/plugins/tools/agent-suggestion-tools.ts +275 -0
  40. package/plugins/tools/agent-tools.ts +114 -0
  41. package/plugins/tools/ai-trigger-tools.ts +79 -0
  42. package/plugins/tools/developer-status-tool.ts +97 -0
  43. package/plugins/tools/file-tools.ts +240 -0
  44. package/plugins/tools/folder-tools.ts +75 -0
  45. package/plugins/tools/organization-tools.ts +213 -0
  46. package/plugins/tools/schedule-tools.ts +173 -0
  47. package/plugins/tools/sprint-tools.ts +287 -0
  48. package/plugins/tools/story-tools.ts +320 -0
  49. package/plugins/tools/sync-tools.ts +509 -0
  50. package/plugins/tools/template-tools.ts +170 -0
  51. package/plugins/tools/user-tools.ts +146 -0
  52. package/scripts/migrate-tokens.js +90 -0
  53. package/scripts/use-prod.sh +11 -0
  54. package/tsconfig.json +17 -0
  55. package/update.sh +172 -0
@@ -0,0 +1,149 @@
1
+ # Sprint-Isolated Directory Structure Design
2
+
3
+ ## Problem
4
+
5
+ The ForLoop planner agent runs on both local machines and AWS Lambda. Currently, all sprint files (knowledge, plans, tasks) are stored in a flat `~/.forloop/` directory without sprint isolation. This causes file collisions when switching sprints and makes it impossible to have clean per-sprint working directories.
6
+
7
+ ## Requirement
8
+
9
+ All sprint-specific files must be stored under `~/.forloop/sprint-{sprint_id}/`. The manifest stays at `~/.forloop/manifest.json` (shared across sprints) to track which sprint is currently active.
10
+
11
+ ## Target Directory Structure
12
+
13
+ ```
14
+ /tmp/home/.forloop/
15
+ ├── manifest.json # Shared, tracks activeSprintId
16
+ ├── sync/
17
+ │ └── manifest.json # Sync tracking metadata
18
+ └── sprint-14/ # Sprint-specific root
19
+ ├── knowledge/
20
+ │ └── knowledge-auth-jwt-20260410-093015.md
21
+ ├── plan/
22
+ │ └── plan-14-20260410-093015.md
23
+ └── task/
24
+ └── task-14-20260410-100000.md
25
+ ```
26
+
27
+ ## Manifest Format (Version 2)
28
+
29
+ ```json
30
+ {
31
+ "version": 2,
32
+ "activeSprintId": 14,
33
+ "sprints": {
34
+ "14": {
35
+ "sprintDir": "sprint-14",
36
+ "plan": {
37
+ "file": "sprint-14/plan/plan-14-20260410-093015.md",
38
+ "createdAt": "2026-04-10T09:30:15Z"
39
+ },
40
+ "tasks": {
41
+ "file": "sprint-14/task/task-14-20260410-100000.md",
42
+ "storyIds": ["123", "124", "125"]
43
+ }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## Path Resolution Logic
50
+
51
+ ### Core Function: `resolveForloopRoot(contextDir, sprintId?)`
52
+
53
+ ```
54
+ 1. If sprintId provided or resolvable (env/branch/manifest):
55
+ → $HOME/.forloop/sprint-{sprintId}/
56
+
57
+ 2. If no sprintId available:
58
+ → $HOME/.forloop/ (root only, for manifest access)
59
+
60
+ 3. Manifest always at:
61
+ → $HOME/.forloop/manifest.json
62
+ ```
63
+
64
+ ## Files to Update
65
+
66
+ ### 1. `plugins/capabilities/context-resolver.ts`
67
+ - Add `getSprintDir(sprintId)` → returns `"sprint-{id}"`
68
+ - Add `resolveSprintRoot(sprintId?)` → returns `$HOME/.forloop/sprint-{id}/` or `$HOME/.forloop/`
69
+ - `getActiveSprintIdFromManifest()` stays unchanged (reads root manifest)
70
+ - Export new functions for use by sync-tools and skills
71
+
72
+ ### 2. `plugins/tools/sync-tools.ts`
73
+ - `resolveForloopRoot()`: Use sprint-aware resolution. Falls back to `$HOME/.forloop/` if no sprint
74
+ - `ensureForloopStructure()`: Create `sprint-{id}/knowledge/plan/task/` instead of flat dirs
75
+ - `getSyncManifestPath()`: Move to `$HOME/.forloop/sync/manifest.json` (shared, not per-sprint)
76
+ - File mapping: `knowledge/X.md` → `sprint-{id}/knowledge/X.md`
77
+
78
+ ### 3. `plugins/hooks/shell-env.ts`
79
+ - No changes needed (doesn't reference .forloop paths directly)
80
+
81
+ ### 4. `agent/forLoopPlanner.md`
82
+ - Update all path references:
83
+ - `~/.forloop/plan/` → `~/.forloop/sprint-{id}/plan/`
84
+ - `~/.forloop/knowledge/` → `~/.forloop/sprint-{id}/knowledge/`
85
+ - `~/.forloop/task/` → `~/.forloop/sprint-{id}/task/`
86
+ - `~/.forloop/manifest.json` → stays (shared)
87
+ - Update permission block: `/tmp/home/.forloop/sprint-*/**`
88
+ - Update examples and workflows to use sprint subdir
89
+
90
+ ### 5. `agent/forLoopStoryEvaluator.md`
91
+ - Update permission block to include sprint subdirs
92
+ - No functional changes (evaluator doesn't write files)
93
+
94
+ ### 6. `skills/forloop-context/SKILL.md`
95
+ - Step 1: Resolve to `~/.forloop/sprint-{id}/` after sprint is known
96
+ - Step 2: Create `sprint-{id}/` structure instead of flat `.forloop/knowledge/plan/task/`
97
+ - Update all example paths and commands
98
+ - Update manifest examples to v2 format
99
+
100
+ ### 7. `skills/knowledge-management/SKILL.md`
101
+ - `.forloop/knowledge/` → `.forloop/sprint-{id}/knowledge/`
102
+ - Update file creation paths and S3 upload examples
103
+ - Update quality gates and anti-patterns
104
+
105
+ ### 8. `skills/plan-documentation/SKILL.md`
106
+ - `.forloop/plan/` → `.forloop/sprint-{id}/plan/`
107
+ - Update mkdir, file write, manifest examples
108
+ - Update all workflow examples
109
+
110
+ ### 9. `skills/task-tracking/SKILL.md`
111
+ - `.forloop/task/` → `.forloop/sprint-{id}/task/`
112
+ - Update file write, manifest examples
113
+ - Update S3 folder mapping section (local paths change, S3 paths stay the same)
114
+
115
+ ### 10. `skills/aivy-documents-sync/SKILL.md`
116
+ - Update all path references to sprint subdirs
117
+ - Update sync examples
118
+
119
+ ### 11. `skills/sprint-planning/SKILL.md`
120
+ - Update context loading paths
121
+ - Update manifest and file path references
122
+
123
+ ### 12. Tests
124
+ - All unit tests with path assertions need updating
125
+ - Mock file paths should use sprint subdir format
126
+ - Integration tests verify correct directory creation
127
+
128
+ ## Backward Compatibility
129
+
130
+ - On first access, check if old flat structure exists (`~/.forloop/knowledge/`, `~/.forloop/plan/`, etc.)
131
+ - If old structure exists:
132
+ - If manifest has an active sprint, create `sprint-{id}/` and move old files into it
133
+ - If no active sprint, leave old files in place; they will be orphaned when new sprint is selected
134
+ - Manifest version bump from 1 → 2 signals new structure
135
+ - The `sprintDir` field is added to manifest sprint entries; missing `sprintDir` implies v1 (flat structure) and triggers migration
136
+
137
+ ## Error Handling
138
+
139
+ - No sprint ID available: agent works at `~/.forloop/` root only (manifest access)
140
+ - Sprint directory missing: auto-create on first file operation
141
+ - Old v1 manifest: upgrade to v2 by adding `sprintDir` field, preserve existing data, migrate files if present
142
+ - Corrupted sprint dir name in manifest: fall back to `sprint-{activeSprintId}` convention
143
+
144
+ ## Testing Strategy
145
+
146
+ 1. Unit tests: Verify `resolveSprintRoot()` with various inputs
147
+ 2. Unit tests: Verify `ensureForloopStructure()` creates correct dirs
148
+ 3. Integration: Create sprint, verify directory structure
149
+ 4. Integration: Switch sprints, verify isolation
@@ -0,0 +1,468 @@
1
+ # OpenCode Message Recording Feature
2
+
3
+ **Date:** 2026-05-19
4
+ **Status:** Design
5
+ **Version:** 1.0
6
+
7
+ ## Overview
8
+
9
+ Record the full opencode conversation (user messages + AI responses) to the ForLoop database. User messages are captured via the `chat.message` hook, assistant responses are assembled from streaming `message.part.updated` events, and deletions are handled via `message.removed`. Recording is active only when a working sprint is confirmed via `~/.forloop/manifest.json`.
10
+
11
+ ## Motivation
12
+
13
+ The ForLoop web client already records messages from the `planner.chat` workflow. However, conversations happening inside the opencode IDE plugin are not captured. This feature bridges that gap, ensuring all sprint-related conversations — whether from the web client or the opencode IDE — are stored in one place for future features like conversation summarization.
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ opencode runtime
19
+ │
20
+ ├── "chat.message" hook ──────────┐ (user messages with full text via parts)
21
+ │ │
22
+ ├── event: "message.part.updated" ─┤ (assistant text parts stream)
23
+ ├── event: "message.updated" ──────┤ (signal: assistant message complete)
24
+ ├── event: "message.removed" ──────┤ (message deletion)
25
+ │ ▼
26
+ │ plugins/hooks/message-recorder.ts
27
+ │ │
28
+ │ reads ~/.forloop/manifest.json
29
+ │ │
30
+ │ if activeSprintId exists:
31
+ │ │
32
+ │ ForLoopAPIClient.recordMessage()
33
+ │ ForLoopAPIClient.removeMessage()
34
+ │ │
35
+ │ ▼
36
+ │ POST /api/opencode/messages
37
+ │ DELETE /api/opencode/messages/:messageId
38
+ │ │
39
+ └──────────────────────────────┘
40
+ ▼
41
+ server_lambda (ForLoop API)
42
+ │
43
+ Conversation table (Prisma)
44
+ ```
45
+
46
+ ### Hook Responsibilities
47
+
48
+ | Hook | Event | Action |
49
+ |------|-------|--------|
50
+ | `chat.message` | User sends a message | Extract text from `output.parts`, POST user message row |
51
+ | `event` | `message.part.updated` (type="text") | Buffer text by messageID into `textBuffer` map |
52
+ | `event` | `message.updated` (role="assistant") | Flush buffered text, POST assistant response update |
53
+ | `event` | `message.removed` | DELETE row from Conversation table |
54
+
55
+ ### Why Not `message.updated` Alone for Text?
56
+
57
+ The `EventMessageUpdated` event provides a `Message` info object (UserMessage | AssistantMessage), which contains metadata (id, role, sessionID, agent, model, time) but does **not** include the message text body. Text content flows through `TextPart` objects delivered via `message.part.updated` events. For user messages, the `chat.message` hook provides parts directly. For assistant responses, we buffer streaming text parts until the message is complete.
58
+
59
+ ## Data Model
60
+
61
+ ### Reusing the Conversation Table
62
+
63
+ The existing `Conversation` table stores paired user message + agent response:
64
+
65
+ | Column | Type | Description |
66
+ |--------|------|-------------|
67
+ | `id` | Int (PK, auto) | Primary key |
68
+ | `sprintId` | Int | Sprint context |
69
+ | `conversationId` | String | Composite ID for grouping |
70
+ | `timestamp` | DateTime | When recorded |
71
+ | `userMessage` | String | User's message |
72
+ | `agentResponse` | String | Agent's response |
73
+ | `metadata` | String? | JSON blob |
74
+
75
+ Message flow mapped to the paired model:
76
+
77
+ 1. **User message** (`chat.message` hook) → INSERT new row with `userMessage` filled, `agentResponse` empty
78
+ 2. **Assistant parts** (`message.part.updated` events) → buffered in-memory by messageID
79
+ 3. **Assistant complete** (`message.updated` with role "assistant") → UPDATE last row for `conversationId`, setting `agentResponse` from buffer
80
+ 4. **Message removed** (`message.removed`) → DELETE the row for that messageID
81
+
82
+ ### conversationId Format
83
+
84
+ Consistent `sprint:{id}:agent:{agent}:` prefix with source-identifying suffix:
85
+
86
+ | Source | Format | Example |
87
+ |--------|--------|---------|
88
+ | Web client (existing) | `sprint:{id}:agent:{agent}:web:user:{userId}` | `sprint:14:agent:forLoopPlanner:web:user:3` |
89
+ | opencode plugin (new) | `sprint:{id}:agent:{agent}:opencode:{sessionId}` | `sprint:14:agent:forLoopPlanner:opencode:ses_abc123` |
90
+
91
+ Agent keys are normalized to lowercase-kebab casing (e.g., `"forLoopPlanner"` → `"forLoopPlanner"`) to match the web client convention and enable cross-source queries.
92
+
93
+ ### metadata Structure
94
+
95
+ ```json
96
+ {
97
+ "source": "opencode",
98
+ "model": { "providerID": "anthropic", "modelID": "claude-sonnet-4" },
99
+ "messageId": "msg_xyz",
100
+ "sessionId": "ses_abc123",
101
+ "recordedAt": 1712345678000
102
+ }
103
+ ```
104
+
105
+ ## API Contract
106
+
107
+ ### POST `/api/opencode/messages`
108
+
109
+ Auth: `x-api-token` header with `agent:query` scope.
110
+
111
+ **Request:**
112
+ ```json
113
+ {
114
+ "sprintId": 14,
115
+ "conversationId": "sprint:14:agent:forLoopPlanner:opencode:ses_abc123",
116
+ "messageId": "msg_xyz",
117
+ "role": "user",
118
+ "content": "Help me plan sprint 14",
119
+ "agent": "forLoopPlanner",
120
+ "model": { "providerID": "anthropic", "modelID": "claude-sonnet-4" },
121
+ "timestamp": 1712345678000
122
+ }
123
+ ```
124
+
125
+ **Fields:**
126
+
127
+ | Field | Type | Required | Description |
128
+ |-------|------|----------|-------------|
129
+ | `sprintId` | number | Yes | Sprint context |
130
+ | `conversationId` | string | Yes | Composite conversation identifier |
131
+ | `messageId` | string | Yes | opencode message ID |
132
+ | `role` | `"user"` \| `"assistant"` | Yes | Message role |
133
+ | `content` | string | Yes | Full message text |
134
+ | `agent` | string | No | opencode agent name (lowercase-kebab) |
135
+ | `model` | object | No | `{ providerID, modelID }` |
136
+ | `timestamp` | number | No | Unix ms timestamp |
137
+
138
+ **Response (200):**
139
+ ```json
140
+ { "status": "ok", "id": 42 }
141
+ ```
142
+
143
+ **Behavior by role:**
144
+
145
+ | Role | Action |
146
+ |------|--------|
147
+ | `"user"` | INSERT new row: `conversationId`, `sprintId`, `userMessage`, `metadata` |
148
+ | `"assistant"` | UPDATE last row for `conversationId`: set `agentResponse`, update `metadata` |
149
+
150
+ ### DELETE `/api/opencode/messages/:messageId`
151
+
152
+ Auth: `x-api-token` header with `agent:query` scope.
153
+
154
+ **Request body:**
155
+ ```json
156
+ {
157
+ "sprintId": 14,
158
+ "sessionId": "ses_abc123"
159
+ }
160
+ ```
161
+
162
+ **Response (200):**
163
+ ```json
164
+ { "status": "ok" }
165
+ ```
166
+
167
+ ## Plugin Implementation
168
+
169
+ ### New File: `plugins/hooks/message-recorder.ts`
170
+
171
+ Two exports:
172
+
173
+ 1. `createChatMessageHook(client)` — the `chat.message` hook for user messages
174
+ 2. `createEventHook(client)` — the `event` hook for parts/removals
175
+
176
+ ```typescript
177
+ import { ForLoopAPIClient } from '../capabilities/api-client';
178
+ import fs from 'fs';
179
+ import path from 'path';
180
+
181
+ // Normalize agent key: "forLoopPlanner" -> "forLoopPlanner"
182
+ function normalizeAgentKey(agent: string): string {
183
+ return agent
184
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
185
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
186
+ .toLowerCase();
187
+ }
188
+
189
+ function buildConversationId(sprintId: number, agent: string, sessionId: string): string {
190
+ return `sprint:${sprintId}:agent:${normalizeAgentKey(agent)}:opencode:${sessionId}`;
191
+ }
192
+
193
+ function readActiveSprintId(): number | null {
194
+ try {
195
+ const home = process.env.HOME || '/tmp/home';
196
+ const manifestPath = path.join(home, '.forloop', 'manifest.json');
197
+ if (!fs.existsSync(manifestPath)) return null;
198
+ const raw = fs.readFileSync(manifestPath, 'utf8');
199
+ const parsed = JSON.parse(raw);
200
+ const id = parsed?.activeSprintId;
201
+ return Number.isFinite(id) ? Number(id) : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ // Extract text from parts array (from chat.message hook)
208
+ function extractTextFromParts(parts: any[]): string {
209
+ return parts
210
+ .filter((p: any) => p.type === 'text')
211
+ .map((p: any) => p.text || '')
212
+ .join('');
213
+ }
214
+
215
+ // ─── chat.message hook: captures user messages ───
216
+
217
+ export function createChatMessageHook(client: ForLoopAPIClient) {
218
+ return async (input: any, output: any) => {
219
+ const sprintId = readActiveSprintId();
220
+ if (!sprintId) return;
221
+
222
+ const { sessionID, agent, model, messageID } = input;
223
+ const { message, parts } = output;
224
+ const content = extractTextFromParts(parts);
225
+ if (!content) return;
226
+
227
+ const conversationId = buildConversationId(sprintId, agent || 'unknown', sessionID);
228
+
229
+ try {
230
+ await client.recordMessage({
231
+ sprintId,
232
+ sessionId: sessionID,
233
+ messageId: messageID || message.id,
234
+ conversationId,
235
+ role: 'user',
236
+ content,
237
+ agent: agent ? normalizeAgentKey(agent) : undefined,
238
+ model,
239
+ timestamp: message.time?.created ?? Date.now(),
240
+ });
241
+ } catch (err) {
242
+ console.warn('[ForLoop] Failed to record user message:', (err as Error).message);
243
+ }
244
+ };
245
+ }
246
+
247
+ // ─── event hook: captures assistant text + removals ───
248
+
249
+ export function createEventHook(client: ForLoopAPIClient) {
250
+ const textBuffer = new Map<string, string>(); // messageID -> accumulated text
251
+
252
+ return async ({ event }: { event: { type: string; properties: any } }) => {
253
+ const sprintId = readActiveSprintId();
254
+ if (!sprintId) return;
255
+
256
+ switch (event.type) {
257
+
258
+ case 'message.part.updated': {
259
+ const part = event.properties?.part;
260
+ if (!part || part.type !== 'text') return;
261
+
262
+ const current = textBuffer.get(part.messageID) || '';
263
+ // part.text is the full accumulated text for streaming parts
264
+ textBuffer.set(part.messageID, part.text || current);
265
+ break;
266
+ }
267
+
268
+ case 'message.updated': {
269
+ const info = event.properties?.info;
270
+ if (!info || info.role !== 'assistant') return;
271
+
272
+ const bufferedText = textBuffer.get(info.id);
273
+ if (!bufferedText) return;
274
+ // NOTE: do NOT delete from textBuffer — parts may still arrive after
275
+ // this event. Each subsequent message.updated will flush again with
276
+ // the latest text. Buffer is cleaned up on message.removed only.
277
+
278
+ const conversationId = buildConversationId(
279
+ sprintId,
280
+ info.agent || 'unknown',
281
+ info.sessionID
282
+ );
283
+
284
+ try {
285
+ await client.recordMessage({
286
+ sprintId,
287
+ sessionId: info.sessionID,
288
+ messageId: info.id,
289
+ conversationId,
290
+ role: 'assistant',
291
+ content: bufferedText,
292
+ agent: info.agent ? normalizeAgentKey(info.agent) : undefined,
293
+ model: info.model,
294
+ timestamp: info.time?.created ?? Date.now(),
295
+ });
296
+ } catch (err) {
297
+ console.warn('[ForLoop] Failed to record assistant message:', (err as Error).message);
298
+ }
299
+ break;
300
+ }
301
+
302
+ case 'message.removed': {
303
+ const { sessionID, messageID } = event.properties || {};
304
+ if (!sessionID || !messageID) return;
305
+
306
+ textBuffer.delete(messageID); // clean up buffer too
307
+
308
+ try {
309
+ await client.removeMessage(messageID, sprintId, sessionID);
310
+ } catch (err) {
311
+ console.warn('[ForLoop] Failed to remove message:', (err as Error).message);
312
+ }
313
+ break;
314
+ }
315
+ }
316
+ };
317
+ }
318
+ ```
319
+
320
+ ### Modified File: `plugins/forloop-plugin.ts`
321
+
322
+ Wire both hooks:
323
+
324
+ ```diff
325
+ + import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
326
+
327
+ export default async (ctx: any) => {
328
+ // ... existing init ...
329
+
330
+ return {
331
+ tool: { ... },
332
+ + event: createEventHook(client),
333
+ + 'chat.message': createChatMessageHook(client),
334
+ 'shell.env': injectEnvironment,
335
+ };
336
+ };
337
+ ```
338
+
339
+ ### Modified File: `plugins/capabilities/api-client.ts`
340
+
341
+ Two new methods on `ForLoopAPIClient`:
342
+
343
+ ```typescript
344
+ async recordMessage(data: {
345
+ sprintId: number;
346
+ sessionId: string;
347
+ messageId: string;
348
+ conversationId: string;
349
+ role: 'user' | 'assistant';
350
+ content: string;
351
+ agent?: string;
352
+ model?: { providerID: string; modelID: string };
353
+ timestamp: number;
354
+ }): Promise<any> {
355
+ return this.request('/api/opencode/messages', {
356
+ method: 'POST',
357
+ body: JSON.stringify(data),
358
+ });
359
+ }
360
+
361
+ async removeMessage(messageId: string, sprintId: number, sessionId: string): Promise<void> {
362
+ await this.request(`/api/opencode/messages/${encodeURIComponent(messageId)}`, {
363
+ method: 'DELETE',
364
+ body: JSON.stringify({ sprintId, sessionId }),
365
+ });
366
+ }
367
+ ```
368
+
369
+ ## Server Implementation (server_lambda)
370
+
371
+ ### New Routes
372
+
373
+ Add to `opencodeRoutes.ts`:
374
+
375
+ ```typescript
376
+ router.post('/messages', authenticateApiToken, requireScopes(['agent:query']), recordMessage);
377
+ router.delete('/messages/:messageId', authenticateApiToken, requireScopes(['agent:query']), removeMessage);
378
+ ```
379
+
380
+ ### New Controller Methods (opencodeController.ts)
381
+
382
+ **`recordMessage`:**
383
+ 1. Validate required fields (`sprintId`, `messageId`, `conversationId`, `role`, `content`)
384
+ 2. If `role === "user"`:
385
+ - INSERT into `Conversation` with `userMessage = content`, `agentResponse = ''`, `metadata = {...}`
386
+ 3. If `role === "assistant"`:
387
+ - UPDATE `Conversation SET agentResponse = content, metadata = metadata || {...}` WHERE `conversationId = :id` AND `id = (SELECT MAX(id) FROM Conversation WHERE conversationId = :id)`
388
+ 4. Return `{ status: "ok", id: <row id> }`
389
+
390
+ **`removeMessage`:**
391
+ 1. Parse `sprintId` and `sessionId` from body
392
+ 2. Query for row matching the messageId in metadata JSON
393
+ 3. `DELETE FROM Conversation WHERE id = :id`
394
+ 4. Return `{ status: "ok" }`
395
+
396
+ ## Error Handling
397
+
398
+ | Scenario | Plugin Behavior |
399
+ |----------|----------------|
400
+ | No manifest / no active sprint | Silent skip (return immediately) |
401
+ | Non-text part type | No-op |
402
+ | Non-assistant message.updated | No-op |
403
+ | No buffered text for assistant | Skip (return immediately) |
404
+ | API unreachable / network error | Log warning, drop (no retry) |
405
+ | Server returns 4xx | Log warning, drop |
406
+ | Server returns 5xx | Log warning, drop |
407
+
408
+ **Key principle:** All hooks are fire-and-forget. They must never throw — a failing message recorder must not disrupt the opencode session.
409
+
410
+ ## Text Buffer Lifecycle
411
+
412
+ The in-memory `textBuffer` map accumulates text by messageID. Each `message.part.updated` with type "text" updates the buffer with the full accumulated `part.text`. Each `message.updated` with role "assistant" flushes the current buffer value to the API (overwriting the `agentResponse` column). The buffer is NOT deleted after flush — parts may arrive after `message.updated`, and each subsequent flush simply overwrites with the latest text. The last write always wins, ensuring the complete text is persisted.
413
+
414
+ Cleanup happens only on:
415
+ - `message.removed`: buffer entry deleted
416
+ - Plugin unload / process exit: buffer is garbage collected
417
+
418
+ ## Feature Toggle
419
+
420
+ Always active when `activeSprintId` is present in `~/.forloop/manifest.json`. No explicit toggle — if there's no sprint, there's nothing to record.
421
+
422
+ ## Testing
423
+
424
+ ### Unit Tests: `test/suites/unit/message-recorder.test.ts`
425
+
426
+ | Test | Description |
427
+ |------|-------------|
428
+ | `chat.message` hook | Extracts text from parts, calls `recordMessage` with user role |
429
+ | `chat.message` hook — no sprint | No API calls |
430
+ | `chat.message` hook — no text | No API calls |
431
+ | `event` — `message.part.updated` (text) | Buffers text by messageID |
432
+ | `event` — `message.part.updated` (non-text) | No-op |
433
+ | `event` — `message.updated` (assistant) | Flushes buffer, calls `recordMessage` |
434
+ | `event` — `message.updated` (user) | No-op (user handled by `chat.message`) |
435
+ | `event` — `message.updated` (no buffer) | No-op |
436
+ | `event` — `message.removed` | Calls `removeMessage`, cleans buffer |
437
+ | No manifest | No API calls for any event |
438
+ | API error | Does not throw, logs warning |
439
+ | Agent key normalization | `"forLoopPlanner"` → `"forLoopPlanner"` |
440
+ | conversationId format | Matches `sprint:{id}:agent:{agent}:opencode:{sessionId}` |
441
+ | Buffer persists after flush | Subsequent `message.updated` flushes again with latest text |
442
+ | textBuffer cleanup after remove | Buffer entry is deleted |
443
+
444
+ ### Integration Tests: `test/suites/integration/message-api.test.ts`
445
+
446
+ | Test | Description |
447
+ |------|-------------|
448
+ | POST user message | INSERT row, returns id |
449
+ | POST assistant message | UPDATE existing row |
450
+ | DELETE message | Removes row |
451
+ | Invalid sprintId | Returns 400 |
452
+ | No token | Returns 401 |
453
+
454
+ ## Conversation Summarization Compatibility
455
+
456
+ The `conversationId` format difference (`web` vs `opencode`) does not affect summarization. The summarizer queries by `sprintId + conversationId` — the ID is a grouping key. The format difference is actually useful metadata for identifying the source. The shared `sprint:{id}:agent:{agent}:` prefix enables cross-source queries (e.g., "all conversations with forLoopPlanner").
457
+
458
+ ## Files Changed
459
+
460
+ | File | Change |
461
+ |------|--------|
462
+ | `plugins/hooks/message-recorder.ts` | **New** — `createChatMessageHook` + `createEventHook` |
463
+ | `plugins/forloop-plugin.ts` | **Modified** — wire `event` and `chat.message` hooks |
464
+ | `plugins/capabilities/api-client.ts` | **Modified** — add `recordMessage`, `removeMessage` methods |
465
+ | `server_lambda/src/routes/opencodeRoutes.ts` | **Modified** — add message routes |
466
+ | `server_lambda/src/controllers/opencodeController.ts` | **Modified** — add controller methods |
467
+ | `test/suites/unit/message-recorder.test.ts` | **New** — unit tests |
468
+ | `test/suites/integration/message-api.test.ts` | **New** — integration tests |