@forloop-cc/forloop-opencode-plugin-planner 2.2.11 → 2.2.13

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,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@forloop-cc/forloop-opencode-plugin-planner",
4
- "version": "2.2.11",
4
+ "version": "2.2.13",
5
5
  "description": "ForLoop integration plugin for opencode — Sprint, story, and agent tools for the ForLoop platform",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -23,6 +23,9 @@ interface ClientConfig {
23
23
  export interface SprintOptions {
24
24
  includeStories?: boolean;
25
25
  includeFiles?: boolean;
26
+ allStories?: boolean;
27
+ subSprintId?: number;
28
+ includeSubSprints?: boolean;
26
29
  }
27
30
 
28
31
  export interface ListSprintsParams {
@@ -259,6 +262,9 @@ export class ForLoopAPIClient {
259
262
  includeStories: options?.includeStories !== false ? 'true' : 'false',
260
263
  includeFiles: options?.includeFiles !== false ? 'true' : 'false',
261
264
  });
265
+ if (options?.allStories) query.set('allStories', 'true');
266
+ if (options?.subSprintId) query.set('subSprintId', String(options.subSprintId));
267
+ if (options?.includeSubSprints) query.set('includeSubSprints', 'true');
262
268
  return this.request(`/api/opencode/sprints/${id}?${query}`);
263
269
  }
264
270
 
@@ -309,6 +315,39 @@ export class ForLoopAPIClient {
309
315
  });
310
316
  }
311
317
 
318
+ async getSubSprints(sprintId: number): Promise<any[]> {
319
+ return this.request(`/api/opencode/sprints/${sprintId}/sub-sprints`);
320
+ }
321
+
322
+ async createSubSprint(sprintId: number, data: {
323
+ title?: string;
324
+ startDate: string;
325
+ endDate: string;
326
+ }): Promise<any> {
327
+ return this.request(`/api/opencode/sprints/${sprintId}/sub-sprints`, {
328
+ method: 'POST',
329
+ body: JSON.stringify(data),
330
+ });
331
+ }
332
+
333
+ async updateSubSprint(subSprintId: number, data: Partial<{
334
+ title: string;
335
+ startDate: string;
336
+ endDate: string;
337
+ status: 'planned' | 'in_progress' | 'completed';
338
+ }>): Promise<any> {
339
+ return this.request(`/api/opencode/sub-sprints/${subSprintId}`, {
340
+ method: 'PUT',
341
+ body: JSON.stringify(data),
342
+ });
343
+ }
344
+
345
+ async deleteSubSprint(subSprintId: number): Promise<void> {
346
+ await this.request(`/api/opencode/sub-sprints/${subSprintId}`, {
347
+ method: 'DELETE',
348
+ });
349
+ }
350
+
312
351
  async addStoryComment(storyId: number, body: string, authorAgentKey?: string): Promise<any> {
313
352
  const payload: Record<string, unknown> = { body };
314
353
  if (authorAgentKey) payload.authorAgentKey = authorAgentKey;
@@ -521,6 +560,10 @@ export class ForLoopAPIClient {
521
560
  });
522
561
  }
523
562
 
563
+ /**
564
+ * @deprecated Use writeConversationEvent() for new integrations.
565
+ * Kept for backward compatibility with existing plugin hooks.
566
+ */
524
567
  async recordMessage(data: MessageRecord): Promise<any> {
525
568
  return this.request('/api/opencode/messages', {
526
569
  method: 'POST',
@@ -535,6 +578,13 @@ export class ForLoopAPIClient {
535
578
  });
536
579
  }
537
580
 
581
+ async writeConversationEvent(data: WriteConversationEventInput): Promise<any> {
582
+ return this.request('/api/opencode/conversations/events', {
583
+ method: 'POST',
584
+ body: JSON.stringify(data),
585
+ });
586
+ }
587
+
538
588
  async sendStreamChunk(data: {
539
589
  taskId: string;
540
590
  trackingId: string;
@@ -556,6 +606,21 @@ export interface AgentSuggestionRequest {
556
606
  query?: string;
557
607
  }
558
608
 
609
+ export interface WriteConversationEventInput {
610
+ operation: 'append_turn' | 'start_turn' | 'complete_turn' | 'delete_turn';
611
+ sprintId: number;
612
+ targetAgent: string;
613
+ actor: { senderType: string; senderId: string };
614
+ conversationId?: string;
615
+ sessionId?: string;
616
+ turnId?: string;
617
+ messageId?: string;
618
+ requestId?: string;
619
+ userMessage?: string;
620
+ agentResponse?: string;
621
+ metadata?: Record<string, unknown>;
622
+ }
623
+
559
624
  export interface ConversationHistoryParams {
560
625
  sprintId?: number;
561
626
  limit?: number;
@@ -7,6 +7,10 @@ import {
7
7
  createSprintCreateTool,
8
8
  createSprintUpdateTool,
9
9
  createSprintDeleteTool,
10
+ createSubSprintListTool,
11
+ createSubSprintCreateTool,
12
+ createSubSprintUpdateTool,
13
+ createSubSprintDeleteTool,
10
14
  } from './tools/sprint-tools';
11
15
  import {
12
16
  createStoryCreateTool,
@@ -102,6 +106,12 @@ export default async (ctx: any) => {
102
106
  'forloopSprintUpdate': createSprintUpdateTool(client),
103
107
  'forloopSprintDelete': createSprintDeleteTool(client),
104
108
 
109
+ // Sub-Sprint (Iteration) Operations
110
+ 'forloopSubSprintList' : createSubSprintListTool(client),
111
+ 'forloopSubSprintCreate' : createSubSprintCreateTool(client),
112
+ 'forloopSubSprintUpdate' : createSubSprintUpdateTool(client),
113
+ 'forloopSubSprintDelete' : createSubSprintDeleteTool(client),
114
+
105
115
  // Story Operations
106
116
  'forloopStoryCreate': createStoryCreateTool(client),
107
117
  'forloopStoryGet': createStoryGetTool(client),
@@ -1,29 +1,31 @@
1
1
  import { ForLoopAPIClient } from '../capabilities/api-client';
2
- import type { MessageRecord } from '../capabilities/api-client';
2
+ import type { WriteConversationEventInput } from '../capabilities/api-client';
3
3
  import { isLambdaExecution } from '../capabilities/config';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
6
 
7
+ interface PendingUserMessage {
8
+ messageId: string;
9
+ content: string;
10
+ agent: string;
11
+ model: any;
12
+ timestamp: number;
13
+ }
14
+
7
15
  function normalizeAgentKey(agent: string): string {
8
- if (/^forLoop[A-Z]/.test(agent)) {
9
- return (
10
- 'forloop-' +
11
- agent
12
- .slice(7)
13
- .replace(/([a-z])([A-Z])/g, '$1-$2')
14
- .toLowerCase()
15
- );
16
- }
17
- return agent
18
- .replace(/([a-z])([A-Z])/g, '$1-$2')
19
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
20
- .toLowerCase();
16
+ return agent;
17
+ }
18
+
19
+ function getActor(): { senderType: string; senderId: string } {
20
+ return {
21
+ senderType: process.env.FORLOOP_SENDER_TYPE || 'user',
22
+ senderId: process.env.FORLOOP_SENDER_ID || 'unknown',
23
+ };
21
24
  }
22
25
 
23
26
  function buildConversationId(sprintId: number, agent: string, sessionId: string): string {
24
- const senderType = process.env.FORLOOP_SENDER_TYPE || 'user';
25
- const senderId = process.env.FORLOOP_SENDER_ID || 'unknown';
26
- return `sprint:${sprintId}:agent:${agent}:${senderType}:${senderId}`;
27
+ const actor = getActor();
28
+ return `sprint:${sprintId}:agent:${agent}:${actor.senderType}:${actor.senderId}`;
27
29
  }
28
30
 
29
31
  function readActiveSprintId(): number | null {
@@ -40,9 +42,11 @@ function readActiveSprintId(): number | null {
40
42
  }
41
43
  }
42
44
 
43
- async function recordAssistantMessage(
45
+ const userMessageBuffer = new Map<string, PendingUserMessage>();
46
+
47
+ async function writeConversationTurn(
44
48
  client: ForLoopAPIClient,
45
- data: MessageRecord,
49
+ input: WriteConversationEventInput,
46
50
  attempts: number
47
51
  ): Promise<void> {
48
52
  for (let i = 0; i < attempts; i++) {
@@ -50,20 +54,15 @@ async function recordAssistantMessage(
50
54
  if (i > 0) {
51
55
  await new Promise((resolve) => setTimeout(resolve, 600 * i));
52
56
  }
53
- const result = await client.recordMessage(data);
54
- if (result?.message === 'no matching user message to update') {
55
- console.log(`[ForLoop] No matching user message — user message may not be recorded yet`);
56
- return;
57
+ const result = await client.writeConversationEvent(input);
58
+ if (!result?.ok) {
59
+ console.warn(`[ForLoop] Write event returned not ok: ${result?.error || 'unknown'}`);
57
60
  }
58
- // After recording, try summarization (no-op if < 30 rounds)
59
- client.summarizeConversation(data.sprintId).catch((err: Error) => {
60
- console.warn('[ForLoop] Summarization trigger failed:', err.message);
61
- });
62
61
  return;
63
62
  } catch (err: any) {
64
- console.warn(`[ForLoop] Attempt ${i + 1}/${attempts} failed:`, err.message);
63
+ console.warn(`[ForLoop] Write event attempt ${i + 1}/${attempts} failed:`, err.message);
65
64
  if (i === attempts - 1) {
66
- console.warn('[ForLoop] Failed to record assistant message after all retries');
65
+ console.warn('[ForLoop] Failed to write conversation event after all retries');
67
66
  }
68
67
  }
69
68
  }
@@ -85,7 +84,6 @@ export function createChatMessageHook(client: ForLoopAPIClient) {
85
84
  const { message, parts } = output;
86
85
  const content = extractTextFromParts(parts);
87
86
 
88
- // Log incoming user message for debug visibility (Lambda)
89
87
  if (content && messageID) {
90
88
  console.log(`[ForLoop] REQUEST — user message (${content.length} chars)`, {
91
89
  sprintId,
@@ -95,25 +93,15 @@ export function createChatMessageHook(client: ForLoopAPIClient) {
95
93
  });
96
94
  }
97
95
 
98
- // Only record messages with a model (indicates actual LLM processing,
99
- // not system events like context compaction or summary generation)
100
96
  if (!model || !model.providerID) return;
97
+ if (!content) return;
101
98
 
102
- const conversationId = buildConversationId(sprintId, agent || 'unknown', sessionID);
103
-
104
- // Fire-and-forget: don't await - must not block the LLM from receiving the message
105
- client.recordMessage({
106
- sprintId,
107
- sessionId: sessionID,
99
+ userMessageBuffer.set(sessionID, {
108
100
  messageId: messageID || message.id,
109
- conversationId,
110
- role: 'user',
111
101
  content,
112
- agent: agent ? normalizeAgentKey(agent) : undefined,
102
+ agent: agent || 'unknown',
113
103
  model,
114
104
  timestamp: message.time?.created ?? Date.now(),
115
- }).catch((err: Error) => {
116
- console.warn('[ForLoop] Failed to record user message:', err.message);
117
105
  });
118
106
  };
119
107
  }
@@ -198,21 +186,36 @@ export function createEventHook(client: ForLoopAPIClient) {
198
186
  preview: bufferedText.substring(0, 500),
199
187
  });
200
188
 
189
+ const pendingUserMsg = userMessageBuffer.get(info.sessionID);
190
+ userMessageBuffer.delete(info.sessionID);
191
+
192
+ if (!pendingUserMsg) {
193
+ console.log('[ForLoop] Skipping write — no buffered user message for this session (stream handler handles the save)');
194
+ break;
195
+ }
196
+
201
197
  const conversationId = buildConversationId(
202
198
  sprintId,
203
199
  info.agent || 'unknown',
204
200
  info.sessionID
205
201
  );
206
- recordAssistantMessage(client, {
202
+ writeConversationTurn(client, {
203
+ operation: 'append_turn',
207
204
  sprintId,
205
+ targetAgent: info.agent ? normalizeAgentKey(info.agent) : 'unknown',
206
+ actor: getActor(),
207
+ conversationId,
208
208
  sessionId: info.sessionID,
209
209
  messageId: info.id,
210
- conversationId,
211
- role: 'assistant',
212
- content: bufferedText,
213
- agent: info.agent ? normalizeAgentKey(info.agent) : undefined,
214
- model: info.model,
215
- timestamp: info.time?.created ?? Date.now(),
210
+ userMessage: pendingUserMsg.content,
211
+ agentResponse: bufferedText,
212
+ metadata: {
213
+ source: 'opencode-plugin',
214
+ agent: info.agent ? normalizeAgentKey(info.agent) : null,
215
+ model: info.model || null,
216
+ userMessageId: pendingUserMsg.messageId || null,
217
+ recordedAt: info.time?.created ?? Date.now(),
218
+ },
216
219
  }, 3);
217
220
  break;
218
221
  }
@@ -220,8 +223,17 @@ export function createEventHook(client: ForLoopAPIClient) {
220
223
  const { sessionID, messageID } = event.properties || {};
221
224
  if (!sessionID || !messageID) break;
222
225
  textBuffer.delete(messageID);
223
- client.removeMessage(messageID, sprintId, sessionID).catch((err: Error) => {
224
- console.warn('[ForLoop] Failed to remove message:', err.message);
226
+ const conversationId = buildConversationId(sprintId, 'unknown', sessionID);
227
+ client.writeConversationEvent({
228
+ operation: 'delete_turn',
229
+ sprintId,
230
+ targetAgent: 'forLoopPlanner',
231
+ actor: getActor(),
232
+ conversationId,
233
+ messageId: messageID,
234
+ metadata: { source: 'opencode-plugin' },
235
+ }).catch((err: Error) => {
236
+ console.warn('[ForLoop] Failed to delete conversation turn:', err.message);
225
237
  });
226
238
  break;
227
239
  }
@@ -243,21 +255,36 @@ export function createEventHook(client: ForLoopAPIClient) {
243
255
  if (!info || info.role !== 'assistant') return;
244
256
  const bufferedText = textBuffer.get(info.id);
245
257
  if (!bufferedText) return;
258
+ const pendingUserMsg = userMessageBuffer.get(info.sessionID);
259
+ userMessageBuffer.delete(info.sessionID);
260
+
261
+ if (!pendingUserMsg) {
262
+ console.log('[ForLoop] Skipping write — no buffered user message for this session');
263
+ break;
264
+ }
265
+
246
266
  const conversationId = buildConversationId(
247
267
  sprintId,
248
268
  info.agent || 'unknown',
249
269
  info.sessionID
250
270
  );
251
- recordAssistantMessage(client, {
271
+ writeConversationTurn(client, {
272
+ operation: 'append_turn',
252
273
  sprintId,
274
+ targetAgent: info.agent ? normalizeAgentKey(info.agent) : 'unknown',
275
+ actor: getActor(),
276
+ conversationId,
253
277
  sessionId: info.sessionID,
254
278
  messageId: info.id,
255
- conversationId,
256
- role: 'assistant',
257
- content: bufferedText,
258
- agent: info.agent ? normalizeAgentKey(info.agent) : undefined,
259
- model: info.model,
260
- timestamp: info.time?.created ?? Date.now(),
279
+ userMessage: pendingUserMsg.content,
280
+ agentResponse: bufferedText,
281
+ metadata: {
282
+ source: 'opencode-plugin',
283
+ agent: info.agent ? normalizeAgentKey(info.agent) : null,
284
+ model: info.model || null,
285
+ userMessageId: pendingUserMsg.messageId || null,
286
+ recordedAt: info.time?.created ?? Date.now(),
287
+ },
261
288
  }, 3);
262
289
  break;
263
290
  }
@@ -265,8 +292,17 @@ export function createEventHook(client: ForLoopAPIClient) {
265
292
  const { sessionID, messageID } = event.properties || {};
266
293
  if (!sessionID || !messageID) return;
267
294
  textBuffer.delete(messageID);
268
- client.removeMessage(messageID, sprintId, sessionID).catch((err: Error) => {
269
- console.warn('[ForLoop] Failed to remove message:', err.message);
295
+ const conversationId = buildConversationId(sprintId, 'unknown', sessionID);
296
+ client.writeConversationEvent({
297
+ operation: 'delete_turn',
298
+ sprintId,
299
+ targetAgent: 'forLoopPlanner',
300
+ actor: getActor(),
301
+ conversationId,
302
+ messageId: messageID,
303
+ metadata: { source: 'opencode-plugin' },
304
+ }).catch((err: Error) => {
305
+ console.warn('[ForLoop] Failed to delete conversation turn:', err.message);
270
306
  });
271
307
  break;
272
308
  }
@@ -17,6 +17,9 @@ export function createSprintGetTool(client: ForLoopAPIClient) {
17
17
  includeFiles: tool.schema.boolean()
18
18
  .default(true)
19
19
  .describe('Include files in response'),
20
+ includeSubSprints: tool.schema.boolean()
21
+ .default(true)
22
+ .describe('Include sub-sprints array in response'),
20
23
  },
21
24
  async execute(args, context) {
22
25
  const tokenResult = await validateToken();
@@ -42,6 +45,7 @@ export function createSprintGetTool(client: ForLoopAPIClient) {
42
45
  const sprint = await client.getSprint(resolution.sprintId, {
43
46
  includeStories: args.includeStories,
44
47
  includeFiles: args.includeFiles,
48
+ includeSubSprints: args.includeSubSprints,
45
49
  });
46
50
 
47
51
  const lines = [
@@ -288,3 +292,145 @@ export function createSprintDeleteTool(client: ForLoopAPIClient) {
288
292
  },
289
293
  });
290
294
  }
295
+
296
+ export function createSubSprintListTool(client: ForLoopAPIClient) {
297
+ return tool({
298
+ description: 'List all iterations (sub-sprints) within a sprint',
299
+ args: {
300
+ sprintId: tool.schema.number()
301
+ .optional()
302
+ .describe('Sprint ID (defaults to FORLOOP_SPRINT_ID env or current branch)'),
303
+ },
304
+ async execute(args, context) {
305
+ const tokenResult = await validateToken()
306
+ if (!tokenResult.valid) return tokenResult.error
307
+ client.setToken?.(tokenResult.token)
308
+
309
+ const resolution = await resolveSprintId(args.sprintId, context.directory)
310
+ if (!resolution.sprintId) {
311
+ return 'No sprint ID resolved. Use --sprintId, set FORLOOP_SPRINT_ID, or use a sprint-XXX branch.'
312
+ }
313
+ try {
314
+ const subSprints = await client.getSubSprints(resolution.sprintId)
315
+ if (subSprints.length === 0) {
316
+ return `Sprint #${resolution.sprintId} has no iterations yet.`
317
+ }
318
+ const lines = [`Iterations for sprint #${resolution.sprintId}:`, '']
319
+ for (const s of subSprints) {
320
+ const active = s.status === 'in_progress' ? ' ▶ ACTIVE' : ''
321
+ lines.push(
322
+ ` #${s.id}: ${s.title} | ${formatDate(s.startDate)} – ${formatDate(s.endDate)} | ${s.status}${active}`
323
+ )
324
+ }
325
+ return lines.join('\n')
326
+ } catch (error: any) {
327
+ return `Failed to list iterations: ${error.message}`
328
+ }
329
+ },
330
+ })
331
+ }
332
+
333
+ export function createSubSprintCreateTool(client: ForLoopAPIClient) {
334
+ return tool({
335
+ description: 'Create a new iteration (sub-sprint). The previously active iteration is auto-completed.',
336
+ args: {
337
+ sprintId: tool.schema.number()
338
+ .optional()
339
+ .describe('Sprint ID (defaults to FORLOOP_SPRINT_ID env or current branch)'),
340
+ title: tool.schema.string()
341
+ .optional()
342
+ .describe('Iteration title (auto-generates as "Sprint Title — Iteration N" if not provided)'),
343
+ startDate: tool.schema.string()
344
+ .describe('Start date (YYYY-MM-DD)'),
345
+ endDate: tool.schema.string()
346
+ .describe('End date (YYYY-MM-DD)'),
347
+ },
348
+ async execute(args, context) {
349
+ const tokenResult = await validateToken()
350
+ if (!tokenResult.valid) return tokenResult.error
351
+ client.setToken?.(tokenResult.token)
352
+
353
+ const resolution = await resolveSprintId(args.sprintId, context.directory)
354
+ if (!resolution.sprintId) {
355
+ return 'No sprint ID resolved. Use --sprintId, set FORLOOP_SPRINT_ID, or use a sprint-XXX branch.'
356
+ }
357
+ if (!args.startDate || !args.endDate) {
358
+ return 'startDate and endDate are required (YYYY-MM-DD format).'
359
+ }
360
+ try {
361
+ const subSprint = await client.createSubSprint(resolution.sprintId, {
362
+ title: args.title,
363
+ startDate: args.startDate,
364
+ endDate: args.endDate,
365
+ })
366
+ return `Iteration created: #${subSprint.id} "${subSprint.title}" (${formatDate(subSprint.startDate)} – ${formatDate(subSprint.endDate)}) status: ${subSprint.status}`
367
+ } catch (error: any) {
368
+ return `Failed to create iteration: ${error.message}`
369
+ }
370
+ },
371
+ })
372
+ }
373
+
374
+ export function createSubSprintUpdateTool(client: ForLoopAPIClient) {
375
+ return tool({
376
+ description: 'Update an iteration (sub-sprint) title, dates, or status. Activating an iteration (status: in_progress) auto-completes the previous one.',
377
+ args: {
378
+ subSprintId: tool.schema.number()
379
+ .describe('Sub-sprint ID to update'),
380
+ title: tool.schema.string()
381
+ .optional()
382
+ .describe('New iteration title'),
383
+ startDate: tool.schema.string()
384
+ .optional()
385
+ .describe('New start date (YYYY-MM-DD)'),
386
+ endDate: tool.schema.string()
387
+ .optional()
388
+ .describe('New end date (YYYY-MM-DD)'),
389
+ status: tool.schema.string()
390
+ .optional()
391
+ .describe('New status: planned, in_progress, or completed'),
392
+ },
393
+ async execute(args, context) {
394
+ const tokenResult = await validateToken()
395
+ if (!tokenResult.valid) return tokenResult.error
396
+ client.setToken?.(tokenResult.token)
397
+
398
+ if (!args.subSprintId) return 'subSprintId is required.'
399
+ try {
400
+ const updateData: Record<string, unknown> = {}
401
+ if (args.title !== undefined) updateData.title = args.title
402
+ if (args.startDate !== undefined) updateData.startDate = args.startDate
403
+ if (args.endDate !== undefined) updateData.endDate = args.endDate
404
+ if (args.status !== undefined) updateData.status = args.status
405
+ if (Object.keys(updateData).length === 0) return 'No update fields provided.'
406
+ const updated = await client.updateSubSprint(args.subSprintId, updateData)
407
+ return `Iteration updated: #${updated.id} "${updated.title}" status: ${updated.status}`
408
+ } catch (error: any) {
409
+ return `Failed to update iteration: ${error.message}`
410
+ }
411
+ },
412
+ })
413
+ }
414
+
415
+ export function createSubSprintDeleteTool(client: ForLoopAPIClient) {
416
+ return tool({
417
+ description: 'Soft-delete an iteration (sub-sprint). Linked stories retain their subSprintId and will not appear in default sprint views.',
418
+ args: {
419
+ subSprintId: tool.schema.number()
420
+ .describe('Sub-sprint ID to delete'),
421
+ },
422
+ async execute(args, context) {
423
+ const tokenResult = await validateToken()
424
+ if (!tokenResult.valid) return tokenResult.error
425
+ client.setToken?.(tokenResult.token)
426
+
427
+ if (!args.subSprintId) return 'subSprintId is required.'
428
+ try {
429
+ await client.deleteSubSprint(args.subSprintId)
430
+ return `Iteration #${args.subSprintId} deleted.`
431
+ } catch (error: any) {
432
+ return `Failed to delete iteration: ${error.message}`
433
+ }
434
+ },
435
+ })
436
+ }