@forloop-cc/forloop-opencode-plugin-planner 2.2.1 → 2.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  **AI-powered sprint planning, story management, and task automation for opencode**
9
9
 
10
+ [English](README.md) · [中文](README_zh.md) · [日本語](README_ja.md)
11
+
10
12
  [Quick Install](#quick-install) · [How It Works](#how-it-works) · [Capabilities](#capabilities) · [FAQ](#faq)
11
13
 
12
14
  </div>
@@ -64,6 +66,16 @@ curl -fsSL https://raw.githubusercontent.com/forloop-cc/forloop-opencode-plugin-
64
66
  - A [ForLoop](https://forloop.cc) account with an API token ([create one here](https://forloop.cc/profile?tab=api-tokens))
65
67
  - Token scopes: `sprint:read`, `sprint:write`, `story:read`, `story:write`, `agent:query`, `profile:read`
66
68
 
69
+ ### Quick Start
70
+
71
+ ```bash
72
+ opencode --agent forLoopPlanner
73
+ ```
74
+
75
+ Or launch opencode normally and press **Tab** to switch to the **ForLoop Planner** agent from the agent picker.
76
+
77
+ The `forLoopPlanner` agent uses the plugin's built-in tools for full sprint, story, and file management. For environments without the opencode plugin installed, use `forLoopPlannerCLI` which works with the standalone `forloop` CLI binary.
78
+
67
79
  ### Set Your Token
68
80
 
69
81
  **Easiest — ask the agent:**
@@ -0,0 +1,110 @@
1
+ # Plugin Docker Integration Test Design
2
+
3
+ **Date:** 2026-06-08
4
+ **Status:** Approved
5
+ **Context:** end-to-end verification of the ForLoop plugin install flow in a clean environment
6
+
7
+ ## Purpose
8
+
9
+ Verify that the one-line install command (`curl -fsSL .../install.sh | bash`) produces a working plugin installation with correct agent/skill discovery, and that the planner agent loads correctly and is self-aware of its tools and skills.
10
+
11
+ ## Architecture
12
+
13
+ ```
14
+ ┌──────────────────────────────────────────┐
15
+ │ Docker Container │
16
+ │ Ubuntu 22.04 (clean, no mounts) │
17
+ │ │
18
+ │ 1. Install deps (curl, git, jq, node) │
19
+ │ 2. Install opencode (official script) │
20
+ │ 3. Install plugin (official script) │
21
+ │ 4. Verify installation structure │
22
+ │ 5. Verify planner agent self-awareness │
23
+ │ │
24
+ │ Entrypoint: test.sh │
25
+ │ Exit code: 0 = pass, non-zero = fail │
26
+ └──────────────────────────────────────────┘
27
+ ```
28
+
29
+ **Key constraint:** no volume mounts, no pre-installed dependencies. Everything installed from the internet inside the container, matching the real user experience.
30
+
31
+ ## Files
32
+
33
+ ```
34
+ forloop-opencode-plugin-planner/
35
+ test/
36
+ Dockerfile # Builds container with Ubuntu + all deps
37
+ test.sh # Verification script (entrypoint)
38
+ ```
39
+
40
+ ## Install Flow
41
+
42
+ The Docker container installs in this order:
43
+
44
+ 1. **System deps:** `apt-get install -y curl git jq ca-certificates gnupg`
45
+ 2. **Node.js 20:** via NodeSource setup script
46
+ 3. **OpenCode:** `curl -fsSL https://opencode.ai/install.sh | bash`
47
+ 4. **ForLoop plugin:** `curl -fsSL https://raw.githubusercontent.com/forloop-cc/forloop-opencode-plugin-planner/main/install.sh | bash`
48
+ 5. **API token:** NOT configured (test runs without token)
49
+
50
+ ## Verification Stages
51
+
52
+ ### Stage A: Plugin Registration
53
+ **Assert:** `~/.config/opencode/opencode.json` exists and contains the plugin entry in the `plugin` array.
54
+
55
+ ### Stage B: Agents Installed
56
+ **Assert:** 3 agent `.md` files are symlinked in `~/.config/opencode/agents/`:
57
+ - `forLoopPlanner.md`
58
+ - `forLoopPlannerCLI.md`
59
+ - `forLoopStoryEvaluator.md`
60
+
61
+ ### Stage C: Skills Installed
62
+ **Assert:** 16 skill directories are symlinked in `~/.config/opencode/skills/`, each containing a `SKILL.md`:
63
+ - agent-auto-assignment, aivy-documents-sync, file-management, forloop-cli, forloop-context, forloop-skill, knowledge-management, plan-documentation, sprint-planning, story-creation, story-points, task-tracking, tech-stack-default, template-based-tasks, user-management, verification-before-completion
64
+
65
+ ### Stage D: Planner Self-Awareness (Workflow)
66
+ **Assert:** `opencode run --agent forLoopPlanner "who are you and what is your workflow"` produces output that:
67
+ - Identifies itself as the ForLoop Planner agent
68
+ - Mentions its 7-phase workflow (or at minimum: context loading, sprint review, planning, execution)
69
+ - References ForLoop platform (sprints, stories, agents)
70
+
71
+ ### Stage E: Tool Discovery
72
+ **Assert:** `opencode run --agent forLoopPlanner "what tools and skills do you have"` produces output that:
73
+ - Lists or references ForLoop plugin tools (forloopSprintList, forloopStoryCreate, forloopAgentQuery, etc.)
74
+ - References skills loaded from the plugin (sprint-planning, task-tracking, file-management, forloop-skill)
75
+
76
+ ## No Token Behavior
77
+
78
+ Since no API token is configured, the planner will note this and offer to help the user set one. This is expected and acceptable — the test verifies installation correctness and agent self-awareness, not actual API calls.
79
+
80
+ ## Test Script Output
81
+
82
+ The test script prints pass/fail for each stage:
83
+
84
+ ```
85
+ === Stage A: Plugin Registration ===
86
+ PASS: Plugin registered in opencode.json
87
+
88
+ === Stage B: Agents ===
89
+ PASS: 3 agents installed (forLoopPlanner, forLoopPlannerCLI, forLoopStoryEvaluator)
90
+
91
+ === Stage C: Skills ===
92
+ PASS: 16 skills installed
93
+
94
+ === Stage D: Planner Self-Awareness ===
95
+ PASS: Planner identifies itself and workflow
96
+
97
+ === Stage E: Tool Discovery ===
98
+ PASS: Tools and skills discovered
99
+
100
+ === ALL TESTS PASSED ===
101
+ ```
102
+
103
+ On failure, prints the actual vs expected and exits with code 1.
104
+
105
+ ## Build and Run
106
+
107
+ ```bash
108
+ docker build -t forloop-plugin-test -f test/Dockerfile .
109
+ docker run --rm forloop-plugin-test
110
+ ```
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.1",
4
+ "version": "2.2.5",
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",
@@ -0,0 +1,25 @@
1
+ const MIME_TYPES: Record<string, string> = {
2
+ '.png': 'image/png',
3
+ '.jpg': 'image/jpeg',
4
+ '.jpeg': 'image/jpeg',
5
+ '.gif': 'image/gif',
6
+ '.pdf': 'application/pdf',
7
+ '.txt': 'text/plain',
8
+ '.md': 'text/markdown',
9
+ '.json': 'application/json',
10
+ '.csv': 'text/csv',
11
+ '.mp4': 'video/mp4',
12
+ '.mp3': 'audio/mpeg',
13
+ };
14
+
15
+ export function contentTypeFromExtension(ext: string): string {
16
+ return MIME_TYPES[String(ext || '').toLowerCase()] || 'application/octet-stream';
17
+ }
18
+
19
+ export function formatFileSize(bytes: number): string {
20
+ if (bytes === 0) return '0 Bytes';
21
+ const k = 1024;
22
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
23
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
24
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
25
+ }
@@ -1,6 +1,7 @@
1
1
  import { tool } from '@opencode-ai/plugin';
2
2
  import { ForLoopAPIClient } from '../capabilities/api-client';
3
3
  import { validateToken } from '../capabilities/auth';
4
+ import { contentTypeFromExtension, formatFileSize } from '../capabilities/mime-types';
4
5
  import * as fs from 'fs';
5
6
  import * as path from 'path';
6
7
 
@@ -41,21 +42,7 @@ export function createFileUploadTool(client: ForLoopAPIClient) {
41
42
  const ext = path.extname(fileName);
42
43
  const fileSize = fileContent.length;
43
44
 
44
- // Determine content type
45
- const mimeTypes: Record<string, string> = {
46
- '.png': 'image/png',
47
- '.jpg': 'image/jpeg',
48
- '.jpeg': 'image/jpeg',
49
- '.gif': 'image/gif',
50
- '.pdf': 'application/pdf',
51
- '.txt': 'text/plain',
52
- '.md': 'text/markdown',
53
- '.json': 'application/json',
54
- '.csv': 'text/csv',
55
- '.mp4': 'video/mp4',
56
- '.mp3': 'audio/mpeg',
57
- };
58
- const contentType = mimeTypes[ext] || 'application/octet-stream';
45
+ const contentType = contentTypeFromExtension(ext);
59
46
 
60
47
  // Get presigned URL
61
48
  const presign = await client.createPresignedUpload({
@@ -91,19 +78,11 @@ export function createFileUploadTool(client: ForLoopAPIClient) {
91
78
  storyId: args.storyId,
92
79
  });
93
80
 
94
- const formatSize = (bytes: number) => {
95
- if (bytes === 0) return '0 Bytes';
96
- const k = 1024;
97
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
98
- const i = Math.floor(Math.log(bytes) / Math.log(k));
99
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
100
- };
101
-
102
81
  return [
103
82
  `✅ File uploaded successfully!`,
104
83
  '',
105
84
  `**File**: ${fileName}`,
106
- `**Size**: ${formatSize(fileSize)}`,
85
+ `**Size**: ${formatFileSize(fileSize)}`,
107
86
  `**URL**: ${complete.url}`,
108
87
  args.description ? `**Description**: ${args.description}` : null,
109
88
  args.folder ? `**Folder**: ${args.folder}` : null,
@@ -137,18 +116,10 @@ export function createFileListTool(client: ForLoopAPIClient) {
137
116
  return '🗂️ No files found in this sprint.';
138
117
  }
139
118
 
140
- const formatSize = (bytes: number) => {
141
- if (bytes === 0) return '0 Bytes';
142
- const k = 1024;
143
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
144
- const i = Math.floor(Math.log(bytes) / Math.log(k));
145
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
146
- };
147
-
148
119
  const lines: string[] = [`🗂️ Files in Sprint #${args.sprintId}:`, ''];
149
-
120
+
150
121
  for (const file of files) {
151
- const size = formatSize(file.size);
122
+ const size = formatFileSize(file.size);
152
123
  const uploadedBy = file.uploader?.name || file.uploaderId ? `by ${file.uploader?.name || `User #${file.uploaderId}`}` : '';
153
124
  const date = file.createdAt ? new Date(file.createdAt).toLocaleDateString() : '';
154
125
 
@@ -52,7 +52,7 @@ export function createScheduleTool(client: ForLoopAPIClient) {
52
52
  }
53
53
 
54
54
  // Build schedule metadata
55
- const scheduleMetadata = {
55
+ const scheduleMetadata = JSON.stringify({
56
56
  title: args.title,
57
57
  description: args.description,
58
58
  startAt: args.startAt,
@@ -60,7 +60,7 @@ export function createScheduleTool(client: ForLoopAPIClient) {
60
60
  timezone: args.timezone,
61
61
  videoUrl: args.videoUrl,
62
62
  location: args.location,
63
- };
63
+ });
64
64
 
65
65
  // Create schedule story
66
66
  const story = await client.createStory({
@@ -68,7 +68,7 @@ export function createScheduleTool(client: ForLoopAPIClient) {
68
68
  description: args.description,
69
69
  sprintId: resolution.sprintId,
70
70
  type: 'schedule',
71
- metadata: JSON.stringify(scheduleMetadata),
71
+ metadata: scheduleMetadata,
72
72
  status: 'todo',
73
73
  });
74
74
 
@@ -148,14 +148,14 @@ export function createScheduleUpdateTool(client: ForLoopAPIClient) {
148
148
  const existingMetadata = story.metadata ? JSON.parse(story.metadata) : {};
149
149
 
150
150
  // Update with new values
151
- const updatedMetadata = {
151
+ const updatedMetadata = JSON.stringify({
152
152
  ...existingMetadata,
153
153
  title: args.title !== undefined ? args.title : existingMetadata.title,
154
154
  description: args.description !== undefined ? args.description : existingMetadata.description,
155
155
  startAt: args.startAt !== undefined ? args.startAt : existingMetadata.startAt,
156
156
  endAt: args.endAt !== undefined ? args.endAt : existingMetadata.endAt,
157
157
  videoUrl: args.videoUrl !== undefined ? args.videoUrl : existingMetadata.videoUrl,
158
- };
158
+ });
159
159
 
160
160
  // Update story
161
161
  await client.updateStory(args.storyId, {
@@ -96,7 +96,10 @@ export function createSprintListTool(client: ForLoopAPIClient) {
96
96
  if (args.organizationId) params.organizationId = String(args.organizationId);
97
97
 
98
98
  try {
99
- const sprints = await client.listSprints(params);
99
+ const sprints = await client.listSprints({
100
+ organizationId: args.organizationId,
101
+ includeSystemOrg: args.includeSystemOrg ?? true,
102
+ });
100
103
 
101
104
  if (!sprints.length) {
102
105
  return 'No sprints found.';
@@ -2,6 +2,7 @@ import { tool } from '@opencode-ai/plugin';
2
2
  import { ForLoopAPIClient } from '../capabilities/api-client';
3
3
  import { validateToken } from '../capabilities/auth';
4
4
  import { resolveSprintId, getForloopRoot as getForloopRootPath } from '../capabilities/context-resolver';
5
+ import { contentTypeFromExtension } from '../capabilities/mime-types';
5
6
  import * as fs from 'fs';
6
7
  import * as path from 'path';
7
8
 
@@ -363,23 +364,6 @@ export function createLocalToS3SyncTool(client: ForLoopAPIClient) {
363
364
  });
364
365
  }
365
366
 
366
- function contentTypeFromExtension(ext: string): string {
367
- const map: Record<string, string> = {
368
- '.png': 'image/png',
369
- '.jpg': 'image/jpeg',
370
- '.jpeg': 'image/jpeg',
371
- '.gif': 'image/gif',
372
- '.pdf': 'application/pdf',
373
- '.txt': 'text/plain',
374
- '.md': 'text/markdown',
375
- '.json': 'application/json',
376
- '.csv': 'text/csv',
377
- '.mp4': 'video/mp4',
378
- '.mp3': 'audio/mpeg',
379
- };
380
- return map[String(ext || '').toLowerCase()] || 'application/octet-stream';
381
- }
382
-
383
367
  function resolveForloopRoot(contextDir?: string, sprintId?: number): string {
384
368
  if (sprintId !== undefined) {
385
369
  return getForloopRootPath(sprintId);
@@ -22,8 +22,14 @@ export function createTemplateListTool(client: ForLoopAPIClient) {
22
22
  for (const t of templates) {
23
23
  if (t.deletedAt) continue; // Skip deleted templates
24
24
 
25
- const fields = JSON.parse(t.fields);
26
- const fieldNames = fields.map((f: any) => f.label).join(', ');
25
+ const fieldNames = (() => {
26
+ try {
27
+ const fields = JSON.parse(t.fields);
28
+ return fields.map((f: any) => f.label).join(', ');
29
+ } catch {
30
+ return 'N/A';
31
+ }
32
+ })();
27
33
 
28
34
  lines.push(`📝 **${t.name}** (\`${t.slug}\`)`);
29
35
  lines.push(` ${t.description}`);
package/update.sh CHANGED
@@ -129,7 +129,6 @@ echo ""
129
129
 
130
130
  check_jq || exit 1
131
131
 
132
- local config_file
133
132
  config_file="$(find_config)" || {
134
133
  print_error "No opencode.json found"
135
134
  echo ""
@@ -141,7 +140,6 @@ config_file="$(find_config)" || {
141
140
  exit 1
142
141
  }
143
142
 
144
- local plugin_entry
145
143
  plugin_entry="$(find_plugin_entry "$config_file")"
146
144
 
147
145
  if [ -z "$plugin_entry" ]; then