@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,258 @@
1
+ import { ForLoopAPIClient } from '../capabilities/api-client';
2
+ import type { MessageRecord } from '../capabilities/api-client';
3
+ import { isLambdaExecution } from '../capabilities/config';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ 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();
21
+ }
22
+
23
+ function buildConversationId(sprintId: number, agent: string, sessionId: string): string {
24
+ return `sprint:${sprintId}:agent:${normalizeAgentKey(agent)}:opencode:${sessionId}`;
25
+ }
26
+
27
+ function readActiveSprintId(): number | null {
28
+ try {
29
+ const home = process.env.HOME || '/tmp/home';
30
+ const manifestPath = path.join(home, '.forloop', 'manifest.json');
31
+ if (!fs.existsSync(manifestPath)) return null;
32
+ const raw = fs.readFileSync(manifestPath, 'utf8');
33
+ const parsed = JSON.parse(raw);
34
+ const id = parsed?.activeSprintId;
35
+ return Number.isFinite(id) ? Number(id) : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function recordAssistantMessage(
42
+ client: ForLoopAPIClient,
43
+ data: MessageRecord,
44
+ attempts: number
45
+ ): Promise<void> {
46
+ for (let i = 0; i < attempts; i++) {
47
+ try {
48
+ if (i > 0) {
49
+ await new Promise((resolve) => setTimeout(resolve, 600 * i));
50
+ }
51
+ const result = await client.recordMessage(data);
52
+ if (result?.message !== 'no matching user message to update') return;
53
+ console.log(`[ForLoop] Retry ${i + 1}/${attempts}: user row not found yet`);
54
+ } catch (err: any) {
55
+ console.warn(`[ForLoop] Attempt ${i + 1}/${attempts} failed:`, err.message);
56
+ if (i === attempts - 1) {
57
+ console.warn('[ForLoop] Failed to record assistant message after all retries');
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ function extractTextFromParts(parts: any[]): string {
64
+ return parts
65
+ .filter((p: any) => p.type === 'text')
66
+ .map((p: any) => p.text || '')
67
+ .join('');
68
+ }
69
+
70
+ export function createChatMessageHook(client: ForLoopAPIClient) {
71
+ return async (input: any, output: any) => {
72
+ const sprintId = readActiveSprintId();
73
+ if (!sprintId) return;
74
+
75
+ // Only record on user's local machine; Lambda records via its own system
76
+ if (isLambdaExecution()) return;
77
+
78
+ const { sessionID, agent, model, messageID } = input;
79
+
80
+ // Only record messages with a model (indicates actual LLM processing,
81
+ // not system events like context compaction or summary generation)
82
+ if (!model || !model.providerID) return;
83
+
84
+ const { message, parts } = output;
85
+ const content = extractTextFromParts(parts);
86
+ if (!content) return;
87
+
88
+ const conversationId = buildConversationId(sprintId, agent || 'unknown', sessionID);
89
+
90
+ // Fire-and-forget: don't await - must not block the LLM from receiving the message
91
+ client.recordMessage({
92
+ sprintId,
93
+ sessionId: sessionID,
94
+ messageId: messageID || message.id,
95
+ conversationId,
96
+ role: 'user',
97
+ content,
98
+ agent: agent ? normalizeAgentKey(agent) : undefined,
99
+ model,
100
+ timestamp: message.time?.created ?? Date.now(),
101
+ }).catch((err: Error) => {
102
+ console.warn('[ForLoop] Failed to record user message:', err.message);
103
+ });
104
+ };
105
+ }
106
+
107
+ export function createEventHook(client: ForLoopAPIClient) {
108
+ const textBuffer = new Map<string, string>();
109
+ const readyToStream = new Set<string>(); // messageIDs that have passed the context phase
110
+
111
+ const writeStreamChunk = (sprintId: number, trackingId: string, taskId: string, text: string, done: boolean) => {
112
+ try {
113
+ const dir = '/tmp/forloop-stream';
114
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
115
+ const filePath = path.join(dir, `sprint_${sprintId}.json`);
116
+ let data: Record<string, any> = {};
117
+ try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch {}
118
+ const existing = data[trackingId] || { index: 0 };
119
+ data[trackingId] = {
120
+ trackingId,
121
+ taskId: taskId || '',
122
+ sprintId,
123
+ text,
124
+ index: existing.index + 1,
125
+ done,
126
+ lastUpdated: Date.now(),
127
+ };
128
+ fs.writeFileSync(filePath, JSON.stringify(data));
129
+ } catch (err: any) {
130
+ console.warn('[ForLoop] Failed to write stream chunk:', err.message);
131
+ }
132
+ };
133
+
134
+ return async ({ event }: { event: { type: string; properties: any } }) => {
135
+ const sprintId = readActiveSprintId();
136
+ if (!sprintId) return;
137
+
138
+ const trackingId = process.env.FORLOOP_TRACKING_ID;
139
+ const taskId = process.env.FORLOOP_TASK_ID;
140
+ const isLambda = isLambdaExecution();
141
+
142
+ // Lambda with streaming: write chunks to file for parent process to poll
143
+ if (isLambda && trackingId && process.env.FORLOOP_STREAM_ENABLED === 'true') {
144
+ switch (event.type) {
145
+ case 'message.part.updated': {
146
+ const part = event.properties?.part;
147
+ const messageID = part?.messageID || '';
148
+ console.error('[stream-event] message.part.updated', { type: part?.type, trackingId, sprintId, messageID });
149
+
150
+ // Skip non-text parts — but track that this messageID is now in "generation" phase
151
+ if (!part || part.type !== 'text') {
152
+ if (messageID && (part?.type === 'reasoning' || part?.type === 'tool' || part?.type === 'step-start')) {
153
+ readyToStream.add(messageID);
154
+ }
155
+ break;
156
+ }
157
+
158
+ // Skip the initial context text (the conversation history loaded by the agent).
159
+ // Context text is massive (30K+ chars), assistant responses are small (<5K).
160
+ // Also track that we've seen the context and subsequent text is streamable.
161
+ const textLen = (part.text || '').length;
162
+ if (!readyToStream.has(messageID) && textLen > 5000) {
163
+ console.error('[stream-event] skipping context text', { trackingId, messageID, textLen });
164
+ readyToStream.add(messageID); // mark as past context
165
+ break;
166
+ }
167
+
168
+ readyToStream.add(messageID);
169
+ const current = textBuffer.get(part.messageID) || '';
170
+ textBuffer.set(part.messageID, part.text || current);
171
+ console.error('[stream-event] writing chunk', { trackingId, textLen });
172
+ writeStreamChunk(sprintId, trackingId, taskId || '', part.text || '', false);
173
+ break;
174
+ }
175
+ case 'message.updated': {
176
+ const info = event.properties?.info;
177
+ if (!info || info.role !== 'assistant') break;
178
+ const bufferedText = textBuffer.get(info.id);
179
+ if (!bufferedText) break;
180
+ writeStreamChunk(sprintId, trackingId, taskId || '', bufferedText, true);
181
+
182
+ const conversationId = buildConversationId(
183
+ sprintId,
184
+ info.agent || 'unknown',
185
+ info.sessionID
186
+ );
187
+ recordAssistantMessage(client, {
188
+ sprintId,
189
+ sessionId: info.sessionID,
190
+ messageId: info.id,
191
+ conversationId,
192
+ role: 'assistant',
193
+ content: bufferedText,
194
+ agent: info.agent ? normalizeAgentKey(info.agent) : undefined,
195
+ model: info.model,
196
+ timestamp: info.time?.created ?? Date.now(),
197
+ }, 3);
198
+ break;
199
+ }
200
+ case 'message.removed': {
201
+ const { sessionID, messageID } = event.properties || {};
202
+ if (!sessionID || !messageID) break;
203
+ textBuffer.delete(messageID);
204
+ client.removeMessage(messageID, sprintId, sessionID).catch((err: Error) => {
205
+ console.warn('[ForLoop] Failed to remove message:', err.message);
206
+ });
207
+ break;
208
+ }
209
+ }
210
+ return;
211
+ }
212
+
213
+ // Local machine: no streaming, just record messages
214
+ if (isLambda) return;
215
+
216
+ switch (event.type) {
217
+ case 'message.part.updated': {
218
+ const part = event.properties?.part;
219
+ if (!part || part.type !== 'text') return;
220
+ const current = textBuffer.get(part.messageID) || '';
221
+ textBuffer.set(part.messageID, part.text || current);
222
+ break;
223
+ }
224
+ case 'message.updated': {
225
+ const info = event.properties?.info;
226
+ if (!info || info.role !== 'assistant') return;
227
+ const bufferedText = textBuffer.get(info.id);
228
+ if (!bufferedText) return;
229
+ const conversationId = buildConversationId(
230
+ sprintId,
231
+ info.agent || 'unknown',
232
+ info.sessionID
233
+ );
234
+ recordAssistantMessage(client, {
235
+ sprintId,
236
+ sessionId: info.sessionID,
237
+ messageId: info.id,
238
+ conversationId,
239
+ role: 'assistant',
240
+ content: bufferedText,
241
+ agent: info.agent ? normalizeAgentKey(info.agent) : undefined,
242
+ model: info.model,
243
+ timestamp: info.time?.created ?? Date.now(),
244
+ }, 3);
245
+ break;
246
+ }
247
+ case 'message.removed': {
248
+ const { sessionID, messageID } = event.properties || {};
249
+ if (!sessionID || !messageID) return;
250
+ textBuffer.delete(messageID);
251
+ client.removeMessage(messageID, sprintId, sessionID).catch((err: Error) => {
252
+ console.warn('[ForLoop] Failed to remove message:', err.message);
253
+ });
254
+ break;
255
+ }
256
+ }
257
+ };
258
+ }
@@ -0,0 +1,62 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import { getConfig } from '../capabilities/config';
6
+
7
+ const execAsync = promisify(exec);
8
+
9
+ interface ShellEnvInput {
10
+ directory?: string;
11
+ }
12
+
13
+ interface ShellEnvOutput {
14
+ env: Record<string, string>;
15
+ }
16
+
17
+ export async function injectEnvironment(
18
+ input: ShellEnvInput,
19
+ output: ShellEnvOutput
20
+ ): Promise<void> {
21
+ const tokenPath = path.join(
22
+ process.env.HOME || process.env.USERPROFILE || '.',
23
+ '.config',
24
+ 'forloop',
25
+ 'tokens.json'
26
+ );
27
+
28
+ let token: string | null = null;
29
+ try {
30
+ const content = await fs.readFile(tokenPath, 'utf-8');
31
+ const tokens = JSON.parse(content);
32
+ const profile = process.env.FORLOOP_TOKEN_PROFILE;
33
+ token = (profile && tokens.profiles?.[profile]) || tokens.default || null;
34
+ } catch (error: any) {
35
+ if (error.code !== 'ENOENT') {
36
+ console.warn('[ForLoop] Error reading token file:', error.message);
37
+ }
38
+ }
39
+
40
+ if (!token) {
41
+ console.warn('[ForLoop] Token not configured');
42
+ return;
43
+ }
44
+
45
+ const config = getConfig();
46
+ output.env.FORLOOP_API_URL = config.apiUrl;
47
+ output.env.FORLOOP_TOKEN_SET = 'true';
48
+
49
+ try {
50
+ const result = await execAsync('git rev-parse --abbrev-ref HEAD', {
51
+ cwd: input.directory,
52
+ });
53
+ const branch = result.stdout.trim();
54
+ const match = branch.match(/sprint-(\d+)/);
55
+
56
+ if (match) {
57
+ output.env.FORLOOP_SPRINT_ID = match[1];
58
+ console.log(`[ForLoop] Detected sprint ${match[1]} from branch ${branch}`);
59
+ }
60
+ } catch (error) {
61
+ }
62
+ }
@@ -0,0 +1,100 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { resolveSprintId } from '../capabilities/context-resolver';
4
+ import { validateToken } from '../capabilities/auth';
5
+
6
+ export function createAgentListTool(client: ForLoopAPIClient) {
7
+ return tool({
8
+ description: 'List all available AI agents in the catalog',
9
+ args: {},
10
+ async execute(_args, _context) {
11
+ const tokenResult = await validateToken();
12
+ if (!tokenResult.valid) {
13
+ return tokenResult.error;
14
+ }
15
+ client.setToken?.(tokenResult.token);
16
+
17
+ try {
18
+ const agents = await client.listAiAgents();
19
+
20
+ if (agents.length === 0) {
21
+ return 'No AI agents found in the catalog.';
22
+ }
23
+
24
+ const lines = [
25
+ '🤖 Available AI Agents:',
26
+ '',
27
+ ];
28
+
29
+ for (const agent of agents) {
30
+ const defaultBadge = agent.defaultEnabled ? ' (default enabled)' : '';
31
+ lines.push(`**${agent.key}** - ${agent.name}${defaultBadge}`);
32
+ lines.push(` ${agent.description}`);
33
+ lines.push(` Category: ${agent.category || 'N/A'}`);
34
+ lines.push('');
35
+ }
36
+
37
+ return lines.join('\n');
38
+ } catch (error: any) {
39
+ return `❌ Error: ${error.message}`;
40
+ }
41
+ },
42
+ });
43
+ }
44
+
45
+ export function createSprintAiAgentsUpdateTool(client: ForLoopAPIClient) {
46
+ return tool({
47
+ description: 'Enable or disable AI agents for a sprint',
48
+ args: {
49
+ sprintId: tool.schema.number()
50
+ .optional()
51
+ .describe('Target sprint ID (uses active sprint if not provided)'),
52
+ enabledAgentKeys: tool.schema.array(tool.schema.string())
53
+ .describe('Array of agent keys to enable for this sprint'),
54
+ },
55
+ async execute(args, context) {
56
+ const tokenResult = await validateToken();
57
+ if (!tokenResult.valid) {
58
+ return tokenResult.error;
59
+ }
60
+ client.setToken?.(tokenResult.token);
61
+
62
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
63
+
64
+ if (!resolution.sprintId) {
65
+ return [
66
+ '❌ No sprint ID provided.',
67
+ '',
68
+ 'Please specify a sprint ID using one of these methods:',
69
+ ' 1. Pass --sprintId flag',
70
+ ' 2. Set FORLOOP_SPRINT_ID environment variable',
71
+ ' 3. Use a git branch named sprint-XXX',
72
+ ].join('\n');
73
+ }
74
+
75
+ try {
76
+ const agentKeys = args.enabledAgentKeys.filter(Boolean);
77
+
78
+ if (agentKeys.length === 0) {
79
+ return '❌ No agent keys provided. Please specify at least one agent key to enable.';
80
+ }
81
+
82
+ const sprintAiAgents = await client.updateSprintAiAgents(resolution.sprintId, agentKeys);
83
+
84
+ const lines = [
85
+ `✅ Updated AI agents for sprint #${resolution.sprintId}`,
86
+ '',
87
+ 'Enabled agents:',
88
+ ];
89
+
90
+ for (const sprintAgent of sprintAiAgents) {
91
+ lines.push(` - ${sprintAgent.agent.name} (${sprintAgent.agentKey})`);
92
+ }
93
+
94
+ return lines.join('\n');
95
+ } catch (error: any) {
96
+ return `❌ Error: ${error.message}`;
97
+ }
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,275 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { resolveSprintId } from '../capabilities/context-resolver';
4
+ import { validateToken } from '../capabilities/auth';
5
+
6
+ export function createAgentSuggestTool(client: ForLoopAPIClient) {
7
+ return tool({
8
+ description: 'Get AI suggestions for story breakdowns, estimates, and planning',
9
+ args: {
10
+ type: tool.schema.enum([
11
+ 'breakdown',
12
+ 'estimate',
13
+ 'acceptance_criteria',
14
+ 'test_cases',
15
+ 'related',
16
+ 'sprint_planning',
17
+ ]).describe('Type of suggestion needed'),
18
+ sprintId: tool.schema.number()
19
+ .optional()
20
+ .describe('Sprint ID for context'),
21
+ storyId: tool.schema.number()
22
+ .optional()
23
+ .describe('Story ID to analyze'),
24
+ query: tool.schema.string()
25
+ .optional()
26
+ .describe('Specific question or requirement'),
27
+ },
28
+ async execute(args, _context) {
29
+ const tokenResult = await validateToken();
30
+ if (!tokenResult.valid) {
31
+ return tokenResult.error;
32
+ }
33
+ client.setToken?.(tokenResult.token);
34
+
35
+ if (!args.sprintId && !args.storyId) {
36
+ return [
37
+ '❌ Please provide sprintId or storyId',
38
+ '',
39
+ 'Examples:',
40
+ ' forloopAgentSuggest --type breakdown --sprintId 123',
41
+ ' forloopAgentSuggest --type estimate --storyId 456',
42
+ ].join('\n');
43
+ }
44
+
45
+ try {
46
+ const response = await client.getAgentSuggestions({
47
+ sprintId: args.sprintId,
48
+ storyId: args.storyId,
49
+ type: args.type,
50
+ query: args.query,
51
+ });
52
+
53
+ const lines = [
54
+ `🤖 AI Suggestions (${response.agentKey})`,
55
+ `Confidence: ${(response.confidence * 100).toFixed(0)}%`,
56
+ '',
57
+ '**Suggestions:**',
58
+ '',
59
+ ];
60
+
61
+ for (const suggestion of response.suggestions) {
62
+ lines.push(`- ${suggestion}`);
63
+ }
64
+
65
+ return lines.join('\n');
66
+ } catch (error: any) {
67
+ return `❌ Error: ${error.message}`;
68
+ }
69
+ },
70
+ });
71
+ }
72
+
73
+ export function createStoryBreakdownTool(client: ForLoopAPIClient) {
74
+ return tool({
75
+ description: 'Break down a story into actionable subtasks',
76
+ args: {
77
+ storyId: tool.schema.number()
78
+ .describe('Story ID to break down'),
79
+ },
80
+ async execute(args, _context) {
81
+ const tokenResult = await validateToken();
82
+ if (!tokenResult.valid) {
83
+ return tokenResult.error;
84
+ }
85
+ client.setToken?.(tokenResult.token);
86
+
87
+ try {
88
+ const breakdown = await client.getStoryBreakdown(args.storyId);
89
+
90
+ const lines = [
91
+ `📋 Story Breakdown: #${breakdown.storyId}`,
92
+ `**${breakdown.title}**`,
93
+ '',
94
+ '**Subtasks to consider:**',
95
+ '',
96
+ ];
97
+
98
+ for (let i = 0; i < breakdown.breakdown.length; i++) {
99
+ lines.push(`${i + 1}. ${breakdown.breakdown[i]}`);
100
+ }
101
+
102
+ lines.push('');
103
+ lines.push(`💡 Generated by ${breakdown.agentKey}`);
104
+
105
+ return lines.join('\n');
106
+ } catch (error: any) {
107
+ return `❌ Error: ${error.message}`;
108
+ }
109
+ },
110
+ });
111
+ }
112
+
113
+ export function createStoryEstimateTool(client: ForLoopAPIClient) {
114
+ return tool({
115
+ description: 'Get AI-powered story point estimates',
116
+ args: {
117
+ storyId: tool.schema.number()
118
+ .describe('Story ID to estimate'),
119
+ },
120
+ async execute(args, _context) {
121
+ const tokenResult = await validateToken();
122
+ if (!tokenResult.valid) {
123
+ return tokenResult.error;
124
+ }
125
+ client.setToken?.(tokenResult.token);
126
+
127
+ try {
128
+ const estimate = await client.getStoryEstimate(args.storyId);
129
+
130
+ const lines = [
131
+ `⏱️ Story Estimate: #${estimate.storyId}`,
132
+ `**${estimate.title}**`,
133
+ '',
134
+ `Current Points: ${estimate.currentPoints || 'Not set'}`,
135
+ `Suggested Points: ${estimate.suggestedPoints}`,
136
+ '',
137
+ '**Estimation rationale:**',
138
+ '',
139
+ ];
140
+
141
+ for (const item of estimate.estimates) {
142
+ lines.push(`- ${item}`);
143
+ }
144
+
145
+ return lines.join('\n');
146
+ } catch (error: any) {
147
+ return `❌ Error: ${error.message}`;
148
+ }
149
+ },
150
+ });
151
+ }
152
+
153
+ export function createConversationHistoryTool(client: ForLoopAPIClient) {
154
+ return tool({
155
+ description: 'View conversation history with AI agents from opencode sessions',
156
+ args: {
157
+ sprintId: tool.schema.number()
158
+ .optional()
159
+ .describe('Filter by sprint'),
160
+ limit: tool.schema.number()
161
+ .min(1)
162
+ .max(200)
163
+ .default(50)
164
+ .describe('Number of messages to retrieve'),
165
+ },
166
+ async execute(args, context) {
167
+ const tokenResult = await validateToken();
168
+ if (!tokenResult.valid) {
169
+ return tokenResult.error;
170
+ }
171
+ client.setToken?.(tokenResult.token);
172
+
173
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
174
+ const sprintId = resolution.sprintId || args.sprintId;
175
+
176
+ try {
177
+ const history = await client.getConversationHistory({
178
+ sprintId,
179
+ limit: args.limit,
180
+ });
181
+
182
+ const messages = history.messages || [];
183
+ if (messages.length === 0) {
184
+ return 'No conversation history found.';
185
+ }
186
+
187
+ const lines = [
188
+ `💬 Conversation History (${history.total || messages.length} total)`,
189
+ '',
190
+ ];
191
+
192
+ for (const msg of messages.slice(0, args.limit)) {
193
+ const preview = (msg.userMessage || 'No message').substring(0, 80);
194
+ const hasResponse = msg.agentResponse && msg.agentResponse.trim();
195
+
196
+ lines.push(`📌 **${preview}**${preview.length >= 80 ? '...' : ''}`);
197
+
198
+ if (msg.conversationId) {
199
+ const agentMatch = msg.conversationId.match(/agent:([^:]+)/);
200
+ if (agentMatch) {
201
+ lines.push(` Agent: ${agentMatch[1]} | ${formatDate(msg.timestamp)}`);
202
+ } else {
203
+ lines.push(` ${formatDate(msg.timestamp)}`);
204
+ }
205
+ } else {
206
+ lines.push(` ${formatDate(msg.timestamp)}`);
207
+ }
208
+
209
+ if (hasResponse) {
210
+ const respPreview = msg.agentResponse.substring(0, 100);
211
+ lines.push(` → ${respPreview}${msg.agentResponse.length > 100 ? '...' : ''}`);
212
+ }
213
+
214
+ lines.push('');
215
+ }
216
+
217
+ return lines.join('\n');
218
+ } catch (error: any) {
219
+ return `❌ Error: ${error.message}`;
220
+ }
221
+ },
222
+ });
223
+ }
224
+
225
+ export function createClearHistoryTool(client: ForLoopAPIClient) {
226
+ return tool({
227
+ description: 'Clear opencode conversation history for a sprint',
228
+ args: {
229
+ sprintId: tool.schema.number()
230
+ .optional()
231
+ .describe('Clear only for specific sprint'),
232
+ confirm: tool.schema.boolean()
233
+ .default(false)
234
+ .describe('Confirm deletion'),
235
+ },
236
+ async execute(args, context) {
237
+ const tokenResult = await validateToken();
238
+ if (!tokenResult.valid) {
239
+ return tokenResult.error;
240
+ }
241
+ client.setToken?.(tokenResult.token);
242
+
243
+ if (!args.confirm) {
244
+ return [
245
+ '⚠️ Warning: This will permanently delete conversation history.',
246
+ '',
247
+ 'Use --confirm true to proceed.',
248
+ 'Or specify --sprintId to delete only for a specific sprint.',
249
+ ].join('\n');
250
+ }
251
+
252
+ const resolution = await resolveSprintId(args.sprintId, context.directory);
253
+ const sprintId = resolution.sprintId || args.sprintId;
254
+
255
+ try {
256
+ const result = await client.clearConversationHistory({
257
+ sprintId,
258
+ });
259
+
260
+ return `✅ Deleted ${result.count} messages successfully.`;
261
+ } catch (error: any) {
262
+ return `❌ Error: ${error.message}`;
263
+ }
264
+ },
265
+ });
266
+ }
267
+
268
+ function formatDate(dateString: string): string {
269
+ return new Date(dateString).toLocaleDateString('en-US', {
270
+ month: 'short',
271
+ day: 'numeric',
272
+ hour: '2-digit',
273
+ minute: '2-digit',
274
+ });
275
+ }