@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.
- package/CHANGELOG.md +225 -0
- package/CODE_OF_CONDUCT.md +77 -0
- package/CONTRIBUTING.md +176 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/SECURITY.md +69 -0
- package/docs/CONFIGURATION.md +283 -0
- package/docs/DEVELOPER-VS-USER.md +129 -0
- package/docs/IMPROVED-AUTHENTICATION.md +317 -0
- package/docs/INSTALLATION-SCRIPTS.md +304 -0
- package/docs/PLANNER-AGENT-IMPROVEMENTS.md +88 -0
- package/docs/PLUGIN-ARCHITECTURE.md +308 -0
- package/docs/QUICKSTART-NON-DEV.md +94 -0
- package/docs/README-FOR-HUMANS-AND-AI.md +109 -0
- package/docs/README.md +70 -0
- package/docs/SKILLS-GAP-ANALYSIS.md +157 -0
- package/docs/USER-INTERACTION-MODEL.md +106 -0
- package/docs/file-management/2026-04-12-forloop-aivy-documents-sync.md +395 -0
- package/docs/file-management/FILE_UPLOAD_ENHANCEMENT.md +431 -0
- package/docs/file-management/FILE_UPLOAD_FLOW.md +300 -0
- package/docs/images/forloop-opencode-flow.png +0 -0
- package/docs/images/forloop-opencode-header.png +0 -0
- package/docs/superpowers/plans/2026-05-03-sprint-isolated-directory-structure.md +1450 -0
- package/docs/superpowers/plans/2026-05-29-install-system.md +850 -0
- package/docs/superpowers/specs/2026-05-03-sprint-isolated-directory-structure-design.md +149 -0
- package/docs/superpowers/specs/2026-05-19-opencode-message-recording-design.md +468 -0
- package/docs/superpowers/specs/2026-05-29-install-system-design.md +135 -0
- package/install.sh +317 -0
- package/package.json +87 -0
- package/plugins/capabilities/api-client.ts +556 -0
- package/plugins/capabilities/auth.ts +120 -0
- package/plugins/capabilities/config.ts +195 -0
- package/plugins/capabilities/context-resolver.ts +106 -0
- package/plugins/capabilities/token-storage.ts +98 -0
- package/plugins/forloop-plugin.ts +186 -0
- package/plugins/hooks/message-recorder.ts +258 -0
- package/plugins/hooks/shell-env.ts +62 -0
- package/plugins/tools/agent-sprint-tools.ts +100 -0
- package/plugins/tools/agent-suggestion-tools.ts +275 -0
- package/plugins/tools/agent-tools.ts +114 -0
- package/plugins/tools/ai-trigger-tools.ts +79 -0
- package/plugins/tools/developer-status-tool.ts +97 -0
- package/plugins/tools/file-tools.ts +240 -0
- package/plugins/tools/folder-tools.ts +75 -0
- package/plugins/tools/organization-tools.ts +213 -0
- package/plugins/tools/schedule-tools.ts +173 -0
- package/plugins/tools/sprint-tools.ts +287 -0
- package/plugins/tools/story-tools.ts +320 -0
- package/plugins/tools/sync-tools.ts +509 -0
- package/plugins/tools/template-tools.ts +170 -0
- package/plugins/tools/user-tools.ts +146 -0
- package/scripts/migrate-tokens.js +90 -0
- package/scripts/use-prod.sh +11 -0
- package/tsconfig.json +17 -0
- package/update.sh +172 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin';
|
|
2
|
+
import { ForLoopAPIClient } from '../capabilities/api-client';
|
|
3
|
+
import { validateToken } from '../capabilities/auth';
|
|
4
|
+
import { resolveSprintId, getForloopRoot as getForloopRootPath } from '../capabilities/context-resolver';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
|
|
8
|
+
type SyncAction = 'upsert' | 'delete';
|
|
9
|
+
|
|
10
|
+
type SyncManifestEntry = {
|
|
11
|
+
fileId: number;
|
|
12
|
+
folder: string;
|
|
13
|
+
originalName: string;
|
|
14
|
+
size: number;
|
|
15
|
+
syncedAt: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type SyncManifest = {
|
|
19
|
+
version: 1;
|
|
20
|
+
sprintId: number | null;
|
|
21
|
+
files: Record<string, SyncManifestEntry>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createAivyDocFolderEnsureTool(client: ForLoopAPIClient) {
|
|
25
|
+
return tool({
|
|
26
|
+
description: 'Ensure a doc_folder titled "forloop Aivy doc" exists in the working sprint',
|
|
27
|
+
args: {
|
|
28
|
+
sprintId: tool.schema.number()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Target sprint ID (auto-detected via env/branch/manifest)'),
|
|
31
|
+
title: tool.schema.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.default('forloop Aivy doc')
|
|
34
|
+
.describe('Doc folder title'),
|
|
35
|
+
},
|
|
36
|
+
async execute(args, context) {
|
|
37
|
+
const tokenResult = await validateToken();
|
|
38
|
+
if (!tokenResult.valid) {
|
|
39
|
+
return tokenResult.error;
|
|
40
|
+
}
|
|
41
|
+
client.setToken?.(tokenResult.token);
|
|
42
|
+
|
|
43
|
+
const resolution = await resolveSprintId(args.sprintId, context.directory);
|
|
44
|
+
if (!resolution.sprintId) {
|
|
45
|
+
return '❌ No sprint ID available. Provide --sprintId or set FORLOOP_SPRINT_ID or ensure ~/.forloop/manifest.json has activeSprintId.';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sprint = await client.getSprint(resolution.sprintId, { includeStories: true, includeFiles: false });
|
|
49
|
+
const stories = Array.isArray(sprint?.stories) ? sprint.stories : [];
|
|
50
|
+
const effectiveTitle = String(args.title || '').trim() || 'forloop Aivy doc';
|
|
51
|
+
const desiredTitle = effectiveTitle.toLowerCase();
|
|
52
|
+
|
|
53
|
+
const existing = stories.find((s: any) => {
|
|
54
|
+
const type = String(s?.type || '');
|
|
55
|
+
const title = String(s?.title || '').trim().toLowerCase();
|
|
56
|
+
return type === 'doc_folder' && title === desiredTitle;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (existing?.id) {
|
|
60
|
+
return `✅ Doc folder already exists: #${existing.id} "${existing.title}" (Sprint #${resolution.sprintId})`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const created = await client.createStory({
|
|
64
|
+
sprintId: resolution.sprintId,
|
|
65
|
+
type: 'doc_folder',
|
|
66
|
+
title: effectiveTitle,
|
|
67
|
+
description: 'Auto-created folder for forloop Aivy document sync',
|
|
68
|
+
status: 'todo',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return `✅ Created doc folder: #${created.id} "${created.title}" (Sprint #${resolution.sprintId})`;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createAivyDocFolderGetTool(client: ForLoopAPIClient) {
|
|
77
|
+
return tool({
|
|
78
|
+
description: 'Get the doc_folder story ID for linking files',
|
|
79
|
+
args: {
|
|
80
|
+
sprintId: tool.schema.number()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe('Target sprint ID (auto-detected via env/branch/manifest)'),
|
|
83
|
+
title: tool.schema.string()
|
|
84
|
+
.optional()
|
|
85
|
+
.default('forloop Aivy doc')
|
|
86
|
+
.describe('Doc folder title'),
|
|
87
|
+
},
|
|
88
|
+
async execute(args, context) {
|
|
89
|
+
const tokenResult = await validateToken();
|
|
90
|
+
if (!tokenResult.valid) {
|
|
91
|
+
return tokenResult.error;
|
|
92
|
+
}
|
|
93
|
+
client.setToken?.(tokenResult.token);
|
|
94
|
+
|
|
95
|
+
const resolution = await resolveSprintId(args.sprintId, context.directory);
|
|
96
|
+
if (!resolution.sprintId) {
|
|
97
|
+
return '❌ No sprint ID available.';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sprint = await client.getSprint(resolution.sprintId, { includeStories: true, includeFiles: false });
|
|
101
|
+
const stories = Array.isArray(sprint?.stories) ? sprint.stories : [];
|
|
102
|
+
const effectiveTitle = String(args.title || '').trim() || 'forloop Aivy doc';
|
|
103
|
+
const desiredTitle = effectiveTitle.toLowerCase();
|
|
104
|
+
|
|
105
|
+
const existing = stories.find((s: any) => {
|
|
106
|
+
const type = String(s?.type || '');
|
|
107
|
+
const title = String(s?.title || '').trim().toLowerCase();
|
|
108
|
+
return type === 'doc_folder' && title === desiredTitle;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (existing?.id) {
|
|
112
|
+
return `📁 Doc folder found: #${existing.id} "${existing.title}" (Sprint #${resolution.sprintId})`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return `❌ Doc folder not found. Run forloopSyncAivyFolder first.`;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createS3ToLocalSyncTool(client: ForLoopAPIClient) {
|
|
121
|
+
return tool({
|
|
122
|
+
description: 'Sync remote sprint files (S3) to local ~/.forloop/* structure',
|
|
123
|
+
args: {
|
|
124
|
+
sprintId: tool.schema.number()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe('Target sprint ID (auto-detected via env/branch/manifest)'),
|
|
127
|
+
syncKnowledge: tool.schema.boolean()
|
|
128
|
+
.default(true)
|
|
129
|
+
.describe('Sync knowledge files'),
|
|
130
|
+
syncPlans: tool.schema.boolean()
|
|
131
|
+
.default(true)
|
|
132
|
+
.describe('Sync plan files'),
|
|
133
|
+
syncTasks: tool.schema.boolean()
|
|
134
|
+
.default(true)
|
|
135
|
+
.describe('Sync task files'),
|
|
136
|
+
overwrite: tool.schema.boolean()
|
|
137
|
+
.default(false)
|
|
138
|
+
.describe('Overwrite local files even if size matches'),
|
|
139
|
+
},
|
|
140
|
+
async execute(args, context) {
|
|
141
|
+
const tokenResult = await validateToken();
|
|
142
|
+
if (!tokenResult.valid) {
|
|
143
|
+
return tokenResult.error;
|
|
144
|
+
}
|
|
145
|
+
client.setToken?.(tokenResult.token);
|
|
146
|
+
|
|
147
|
+
const resolution = await resolveSprintId(args.sprintId, context.directory);
|
|
148
|
+
if (!resolution.sprintId) {
|
|
149
|
+
return '❌ No sprint ID available. Provide --sprintId or set FORLOOP_SPRINT_ID or ensure ~/.forloop/manifest.json has activeSprintId.';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const forloopRoot = resolveForloopRoot(context.directory, resolution.sprintId);
|
|
153
|
+
ensureForloopStructure(forloopRoot, resolution.sprintId);
|
|
154
|
+
|
|
155
|
+
const manifestPath = getSyncManifestPath(forloopRoot);
|
|
156
|
+
const manifest = readSyncManifest(manifestPath, resolution.sprintId);
|
|
157
|
+
const files = await client.getSprintFiles(resolution.sprintId);
|
|
158
|
+
|
|
159
|
+
const candidates = files
|
|
160
|
+
.map((f: any) => {
|
|
161
|
+
const mapping = mapRemoteFileToLocalRelPath(f);
|
|
162
|
+
return mapping ? { file: f, mapping } : null;
|
|
163
|
+
})
|
|
164
|
+
.filter((x): x is { file: any; mapping: NonNullable<ReturnType<typeof mapRemoteFileToLocalRelPath>> } => x !== null);
|
|
165
|
+
|
|
166
|
+
const selectedKinds = new Set<string>();
|
|
167
|
+
if (args.syncKnowledge) selectedKinds.add('knowledge');
|
|
168
|
+
if (args.syncPlans) selectedKinds.add('plan');
|
|
169
|
+
if (args.syncTasks) selectedKinds.add('task');
|
|
170
|
+
|
|
171
|
+
const byRelPath = new Map<string, any>();
|
|
172
|
+
for (const item of candidates) {
|
|
173
|
+
if (!selectedKinds.has(item.mapping.kind)) continue;
|
|
174
|
+
const key = item.mapping.relPath;
|
|
175
|
+
const prev = byRelPath.get(key);
|
|
176
|
+
if (!prev || compareCreatedAt(item.file, prev.file) > 0) {
|
|
177
|
+
byRelPath.set(key, item);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let downloaded = 0;
|
|
182
|
+
let skipped = 0;
|
|
183
|
+
let failed = 0;
|
|
184
|
+
|
|
185
|
+
for (const [relPath, item] of byRelPath.entries()) {
|
|
186
|
+
const localPath = path.join(forloopRoot, relPath);
|
|
187
|
+
const shouldDownload = shouldDownloadRemoteToLocal({
|
|
188
|
+
localPath,
|
|
189
|
+
remoteSize: Number(item.file?.size) || 0,
|
|
190
|
+
overwrite: Boolean(args.overwrite),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!shouldDownload) {
|
|
194
|
+
skipped += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const open = await client.getFileOpenUrl(Number(item.file.id));
|
|
200
|
+
const url = String(open?.url || '');
|
|
201
|
+
if (!url) throw new Error('Missing open URL');
|
|
202
|
+
|
|
203
|
+
await fs.promises.mkdir(path.dirname(localPath), { recursive: true });
|
|
204
|
+
const res = await fetch(url);
|
|
205
|
+
if (!res.ok) {
|
|
206
|
+
throw new Error(`Download failed (${res.status})`);
|
|
207
|
+
}
|
|
208
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
209
|
+
await fs.promises.writeFile(localPath, buf);
|
|
210
|
+
|
|
211
|
+
manifest.files[relPath] = {
|
|
212
|
+
fileId: Number(item.file.id),
|
|
213
|
+
folder: item.mapping.folder,
|
|
214
|
+
originalName: String(item.file.originalName || item.file.filename || path.basename(relPath)),
|
|
215
|
+
size: buf.length,
|
|
216
|
+
syncedAt: new Date().toISOString(),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
downloaded += 1;
|
|
220
|
+
} catch {
|
|
221
|
+
failed += 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
writeSyncManifest(manifestPath, manifest);
|
|
226
|
+
|
|
227
|
+
return [
|
|
228
|
+
`✅ S3→Local sync complete (Sprint #${resolution.sprintId})`,
|
|
229
|
+
`Downloaded: ${downloaded}`,
|
|
230
|
+
`Skipped: ${skipped}`,
|
|
231
|
+
`Failed: ${failed}`,
|
|
232
|
+
`Local root: ${forloopRoot}`,
|
|
233
|
+
].join('\n');
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function createLocalToS3SyncTool(client: ForLoopAPIClient) {
|
|
239
|
+
return tool({
|
|
240
|
+
description: 'Sync a local ~/.forloop/* file to Sprint S3 (upload or delete)',
|
|
241
|
+
args: {
|
|
242
|
+
filePath: tool.schema.string()
|
|
243
|
+
.describe('Local file path under ~/.forloop/*'),
|
|
244
|
+
sprintId: tool.schema.number()
|
|
245
|
+
.optional()
|
|
246
|
+
.describe('Target sprint ID (auto-detected via env/branch/manifest)'),
|
|
247
|
+
action: tool.schema.enum(['upsert', 'delete'])
|
|
248
|
+
.optional()
|
|
249
|
+
.default('upsert')
|
|
250
|
+
.describe('Sync action'),
|
|
251
|
+
folder: tool.schema.string()
|
|
252
|
+
.optional()
|
|
253
|
+
.describe('Override remote folder (default inferred from local path)'),
|
|
254
|
+
storyId: tool.schema.number()
|
|
255
|
+
.optional()
|
|
256
|
+
.describe('Link file to a story (e.g., doc_folder story for logical grouping)'),
|
|
257
|
+
},
|
|
258
|
+
async execute(args, context) {
|
|
259
|
+
const tokenResult = await validateToken();
|
|
260
|
+
if (!tokenResult.valid) {
|
|
261
|
+
return tokenResult.error;
|
|
262
|
+
}
|
|
263
|
+
client.setToken?.(tokenResult.token);
|
|
264
|
+
|
|
265
|
+
const resolution = await resolveSprintId(args.sprintId, context.directory);
|
|
266
|
+
if (!resolution.sprintId) {
|
|
267
|
+
return '❌ No sprint ID available. Provide --sprintId or set FORLOOP_SPRINT_ID or ensure ~/.forloop/manifest.json has activeSprintId.';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const forloopRoot = resolveForloopRoot(context.directory, resolution.sprintId);
|
|
271
|
+
ensureForloopStructure(forloopRoot, resolution.sprintId);
|
|
272
|
+
|
|
273
|
+
const absPath = resolveToAbsolutePath(args.filePath, context.directory);
|
|
274
|
+
const relPath = toForloopRelativePath(absPath, forloopRoot);
|
|
275
|
+
if (!relPath) {
|
|
276
|
+
return `❌ filePath must be under ${forloopRoot}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const manifestPath = getSyncManifestPath(forloopRoot);
|
|
280
|
+
const manifest = readSyncManifest(manifestPath, resolution.sprintId);
|
|
281
|
+
const existing = manifest.files[relPath];
|
|
282
|
+
|
|
283
|
+
const action = args.action as SyncAction;
|
|
284
|
+
const inferredFolder = args.folder || inferRemoteFolderFromRelPath(relPath);
|
|
285
|
+
|
|
286
|
+
if (action === 'delete') {
|
|
287
|
+
if (existing?.fileId) {
|
|
288
|
+
await client.deleteFile(existing.fileId);
|
|
289
|
+
delete manifest.files[relPath];
|
|
290
|
+
writeSyncManifest(manifestPath, manifest);
|
|
291
|
+
return `✅ Deleted remote file #${existing.fileId} for ${relPath}`;
|
|
292
|
+
}
|
|
293
|
+
return `🟡 No remote mapping found for ${relPath}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!fs.existsSync(absPath)) {
|
|
297
|
+
return `❌ File not found: ${absPath}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (existing?.fileId) {
|
|
301
|
+
await client.deleteFile(existing.fileId);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const fileBuf = await fs.promises.readFile(absPath);
|
|
305
|
+
const originalName = path.basename(absPath);
|
|
306
|
+
const contentType = contentTypeFromExtension(path.extname(originalName));
|
|
307
|
+
|
|
308
|
+
const presign = await client.createPresignedUpload({
|
|
309
|
+
sprintId: resolution.sprintId,
|
|
310
|
+
contentType,
|
|
311
|
+
originalName,
|
|
312
|
+
folder: inferredFolder,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const uploadResponse = await fetch(String(presign?.uploadUrl), {
|
|
316
|
+
method: 'PUT',
|
|
317
|
+
headers: {
|
|
318
|
+
'Content-Type': contentType,
|
|
319
|
+
'Content-Length': String(fileBuf.length),
|
|
320
|
+
},
|
|
321
|
+
body: fileBuf,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (!uploadResponse.ok) {
|
|
325
|
+
const text = await uploadResponse.text().catch(() => '');
|
|
326
|
+
return `❌ Upload failed (${uploadResponse.status}): ${text || uploadResponse.statusText}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const completed = await client.completeUpload({
|
|
330
|
+
sprintId: resolution.sprintId,
|
|
331
|
+
fileName: presign.fileName,
|
|
332
|
+
originalName,
|
|
333
|
+
fileType: contentType,
|
|
334
|
+
size: fileBuf.length,
|
|
335
|
+
folder: inferredFolder,
|
|
336
|
+
x: 0,
|
|
337
|
+
y: 0,
|
|
338
|
+
storyId: args.storyId,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const fileId = Number(completed?.id || completed?.file?.id || completed?.fileId);
|
|
342
|
+
if (!Number.isFinite(fileId)) {
|
|
343
|
+
return '❌ Upload completed but remote file id is missing in response.';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
manifest.files[relPath] = {
|
|
347
|
+
fileId,
|
|
348
|
+
folder: inferredFolder,
|
|
349
|
+
originalName,
|
|
350
|
+
size: fileBuf.length,
|
|
351
|
+
syncedAt: new Date().toISOString(),
|
|
352
|
+
};
|
|
353
|
+
writeSyncManifest(manifestPath, manifest);
|
|
354
|
+
|
|
355
|
+
return [
|
|
356
|
+
`✅ Local→S3 sync complete`,
|
|
357
|
+
`Sprint: #${resolution.sprintId}`,
|
|
358
|
+
`File: ${relPath}`,
|
|
359
|
+
`Remote fileId: #${fileId}`,
|
|
360
|
+
`Folder: ${inferredFolder}`,
|
|
361
|
+
].join('\n');
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
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
|
+
function resolveForloopRoot(contextDir?: string, sprintId?: number): string {
|
|
384
|
+
if (sprintId !== undefined) {
|
|
385
|
+
return getForloopRootPath(sprintId);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (contextDir) {
|
|
389
|
+
const local = path.join(contextDir, '.forloop');
|
|
390
|
+
if (fs.existsSync(local) && fs.statSync(local).isDirectory()) return local;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return getForloopRootPath();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function ensureForloopStructure(forloopRoot: string, sprintId?: number) {
|
|
397
|
+
fs.mkdirSync(forloopRoot, { recursive: true });
|
|
398
|
+
|
|
399
|
+
if (sprintId !== undefined) {
|
|
400
|
+
fs.mkdirSync(path.join(forloopRoot, 'knowledge'), { recursive: true });
|
|
401
|
+
fs.mkdirSync(path.join(forloopRoot, 'plan'), { recursive: true });
|
|
402
|
+
fs.mkdirSync(path.join(forloopRoot, 'task'), { recursive: true });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
fs.mkdirSync(path.join(getForloopRootPath(), 'sync'), { recursive: true });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function getSyncManifestPath(forloopRoot?: string): string {
|
|
409
|
+
const syncRoot = path.join(getForloopRootPath(), 'sync');
|
|
410
|
+
return path.join(syncRoot, 'manifest.json');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function readSyncManifest(manifestPath: string, sprintId: number): SyncManifest {
|
|
414
|
+
try {
|
|
415
|
+
if (fs.existsSync(manifestPath)) {
|
|
416
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
417
|
+
const parsed = JSON.parse(raw);
|
|
418
|
+
if (parsed?.version === 1 && parsed?.files && typeof parsed.files === 'object') {
|
|
419
|
+
return {
|
|
420
|
+
version: 1,
|
|
421
|
+
sprintId: Number.isFinite(parsed.sprintId) ? Number(parsed.sprintId) : sprintId,
|
|
422
|
+
files: parsed.files as Record<string, SyncManifestEntry>,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
}
|
|
428
|
+
return { version: 1, sprintId, files: {} };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function writeSyncManifest(manifestPath: string, manifest: SyncManifest) {
|
|
432
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
433
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveToAbsolutePath(inputPath: string, contextDir?: string): string {
|
|
437
|
+
if (path.isAbsolute(inputPath)) return inputPath;
|
|
438
|
+
const base = contextDir || process.cwd();
|
|
439
|
+
return path.join(base, inputPath);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function toForloopRelativePath(absPath: string, forloopRoot: string): string | null {
|
|
443
|
+
const normalizedRoot = path.resolve(forloopRoot);
|
|
444
|
+
const normalizedPath = path.resolve(absPath);
|
|
445
|
+
if (!normalizedPath.startsWith(normalizedRoot + path.sep)) return null;
|
|
446
|
+
return path.relative(normalizedRoot, normalizedPath);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function inferRemoteFolderFromRelPath(relPath: string): string {
|
|
450
|
+
// Normalize path separators to handle both Unix and Windows
|
|
451
|
+
const normalizedRelPath = relPath.replace(/\\/g, '/');
|
|
452
|
+
const parts = normalizedRelPath.split('/');
|
|
453
|
+
const first = parts[0];
|
|
454
|
+
if (first === 'knowledge') return 'project/knowledge';
|
|
455
|
+
if (first === 'plan') return 'project/plans';
|
|
456
|
+
if (first === 'task') return 'project/tasks';
|
|
457
|
+
return 'project';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function mapRemoteFileToLocalRelPath(file: any): { kind: 'knowledge' | 'plan' | 'task'; folder: string; relPath: string } | null {
|
|
461
|
+
const storageKey = String(file?.storageKey || file?.s3Url || file?.url || '');
|
|
462
|
+
const name = String(file?.originalName || file?.filename || '').trim();
|
|
463
|
+
const lowerName = name.toLowerCase();
|
|
464
|
+
|
|
465
|
+
const keyLower = storageKey.toLowerCase();
|
|
466
|
+
if (keyLower.includes('/project/knowledge/')) {
|
|
467
|
+
return { kind: 'knowledge', folder: 'project/knowledge', relPath: path.join('knowledge', path.basename(name || 'remote')) };
|
|
468
|
+
}
|
|
469
|
+
if (keyLower.includes('/project/plans/')) {
|
|
470
|
+
return { kind: 'plan', folder: 'project/plans', relPath: path.join('plan', path.basename(name || 'remote')) };
|
|
471
|
+
}
|
|
472
|
+
if (keyLower.includes('/project/tasks/')) {
|
|
473
|
+
return { kind: 'task', folder: 'project/tasks', relPath: path.join('task', path.basename(name || 'remote')) };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (lowerName.startsWith('knowledge-')) {
|
|
477
|
+
return { kind: 'knowledge', folder: 'project/knowledge', relPath: path.join('knowledge', path.basename(name)) };
|
|
478
|
+
}
|
|
479
|
+
if (lowerName.startsWith('plan-')) {
|
|
480
|
+
return { kind: 'plan', folder: 'project/plans', relPath: path.join('plan', path.basename(name)) };
|
|
481
|
+
}
|
|
482
|
+
if (lowerName.startsWith('task-')) {
|
|
483
|
+
return { kind: 'task', folder: 'project/tasks', relPath: path.join('task', path.basename(name)) };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function compareCreatedAt(a: any, b: any): number {
|
|
490
|
+
const da = Date.parse(String(a?.createdAt || ''));
|
|
491
|
+
const db = Date.parse(String(b?.createdAt || ''));
|
|
492
|
+
if (Number.isFinite(da) && Number.isFinite(db)) return da - db;
|
|
493
|
+
if (Number.isFinite(da)) return 1;
|
|
494
|
+
if (Number.isFinite(db)) return -1;
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function shouldDownloadRemoteToLocal(params: { localPath: string; remoteSize: number; overwrite: boolean }): boolean {
|
|
499
|
+
if (params.overwrite) return true;
|
|
500
|
+
try {
|
|
501
|
+
if (!fs.existsSync(params.localPath)) return true;
|
|
502
|
+
const stat = fs.statSync(params.localPath);
|
|
503
|
+
if (!stat.isFile()) return true;
|
|
504
|
+
if (!params.remoteSize) return false;
|
|
505
|
+
return stat.size !== params.remoteSize;
|
|
506
|
+
} catch {
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin';
|
|
2
|
+
import { ForLoopAPIClient, Template } from '../capabilities/api-client';
|
|
3
|
+
import { resolveSprintId } from '../capabilities/context-resolver';
|
|
4
|
+
import { validateToken } from '../capabilities/auth';
|
|
5
|
+
|
|
6
|
+
export function createTemplateListTool(client: ForLoopAPIClient) {
|
|
7
|
+
return tool({
|
|
8
|
+
description: 'List available story templates (Basic Task, Basic Note)',
|
|
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 templates = await client.listTemplates();
|
|
19
|
+
|
|
20
|
+
const lines: string[] = ['📋 Available Story Templates:', ''];
|
|
21
|
+
|
|
22
|
+
for (const t of templates) {
|
|
23
|
+
if (t.deletedAt) continue; // Skip deleted templates
|
|
24
|
+
|
|
25
|
+
const fields = JSON.parse(t.fields);
|
|
26
|
+
const fieldNames = fields.map((f: any) => f.label).join(', ');
|
|
27
|
+
|
|
28
|
+
lines.push(`📝 **${t.name}** (\`${t.slug}\`)`);
|
|
29
|
+
lines.push(` ${t.description}`);
|
|
30
|
+
lines.push(` Fields: ${fieldNames}`);
|
|
31
|
+
lines.push('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (lines.length <= 2) {
|
|
35
|
+
return 'No templates found.';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
} catch (error: any) {
|
|
40
|
+
return `❌ Error: ${error.message}`;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createStoryFromTemplateTool(client: ForLoopAPIClient) {
|
|
47
|
+
return tool({
|
|
48
|
+
description: 'Create a story using a template (Basic Task recommended for AI tasks)',
|
|
49
|
+
args: {
|
|
50
|
+
templateSlug: tool.schema.enum(['basic-task', 'basic-note'])
|
|
51
|
+
.describe('Template slug: "basic-task" (recommended for AI tasks) or "basic-note"'),
|
|
52
|
+
sprintId: tool.schema.number()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('Target sprint ID (auto-detected from branch or env if not provided)'),
|
|
55
|
+
taskTitle: tool.schema.string()
|
|
56
|
+
.describe('Task or note title'),
|
|
57
|
+
description: tool.schema.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('Description or content'),
|
|
60
|
+
status: tool.schema.enum(['not-started', 'in-progress', 'completed'])
|
|
61
|
+
.optional()
|
|
62
|
+
.default('not-started')
|
|
63
|
+
.describe('Task status'),
|
|
64
|
+
priority: tool.schema.enum(['low', 'medium', 'high'])
|
|
65
|
+
.optional()
|
|
66
|
+
.default('medium')
|
|
67
|
+
.describe('Priority level'),
|
|
68
|
+
points: tool.schema.number()
|
|
69
|
+
.min(0)
|
|
70
|
+
.max(10)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe('Story points (0-10, complexity estimate)'),
|
|
73
|
+
assigneeId: tool.schema.number()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('User ID to assign task to'),
|
|
76
|
+
assigneeAgentKey: tool.schema.string()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('AI agent key (e.g., "developer", "aivy")'),
|
|
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
|
+
// List templates to find by slug
|
|
89
|
+
const templates = await client.listTemplates();
|
|
90
|
+
const template = templates.find(t => t.slug === args.templateSlug && !t.deletedAt);
|
|
91
|
+
|
|
92
|
+
if (!template) {
|
|
93
|
+
return `❌ Template "${args.templateSlug}" not found. Available: basic-task, basic-note`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Resolve sprint ID
|
|
97
|
+
const resolution = await resolveSprintId(args.sprintId, context.directory);
|
|
98
|
+
if (!resolution.sprintId) {
|
|
99
|
+
return [
|
|
100
|
+
'❌ No sprint ID provided.',
|
|
101
|
+
'',
|
|
102
|
+
'Please specify a sprint ID using one of these methods:',
|
|
103
|
+
' 1. Pass sprintId argument',
|
|
104
|
+
' 2. Set FORLOOP_SPRINT_ID environment variable',
|
|
105
|
+
' 3. Use a git branch named sprint-XXX',
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Build metadata from template fields
|
|
110
|
+
const metadata: any = {
|
|
111
|
+
taskTitle: args.taskTitle,
|
|
112
|
+
description: args.description,
|
|
113
|
+
status: args.status,
|
|
114
|
+
priority: args.priority,
|
|
115
|
+
points: args.points,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Add assignee (user or agent)
|
|
119
|
+
if (args.assigneeId) {
|
|
120
|
+
metadata.assignee = args.assigneeId;
|
|
121
|
+
} else if (args.assigneeAgentKey) {
|
|
122
|
+
metadata.assignee = `agent:${args.assigneeAgentKey}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Map template status to DB status
|
|
126
|
+
const statusMap: Record<
|
|
127
|
+
'not-started' | 'in-progress' | 'completed' | 'blocked',
|
|
128
|
+
'todo' | 'in_progress' | 'done' | 'blocked'
|
|
129
|
+
> = {
|
|
130
|
+
'not-started': 'todo',
|
|
131
|
+
'in-progress': 'in_progress',
|
|
132
|
+
'completed': 'done',
|
|
133
|
+
'blocked': 'blocked',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Determine story type from template
|
|
137
|
+
const storyType = template.storyType || 'task';
|
|
138
|
+
|
|
139
|
+
// Create story with all server-compatible fields
|
|
140
|
+
const story = await client.createStory({
|
|
141
|
+
title: args.taskTitle,
|
|
142
|
+
description: args.description,
|
|
143
|
+
sprintId: resolution.sprintId,
|
|
144
|
+
type: storyType,
|
|
145
|
+
templateId: template.id,
|
|
146
|
+
metadata: JSON.stringify(metadata),
|
|
147
|
+
priority: args.priority,
|
|
148
|
+
points: args.points,
|
|
149
|
+
status: statusMap[args.status] ?? 'todo',
|
|
150
|
+
assigneeId: args.assigneeId,
|
|
151
|
+
assigneeAgentKey: args.assigneeAgentKey,
|
|
152
|
+
assigneeType: args.assigneeAgentKey ? 'agent' : args.assigneeId ? 'user' : undefined,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
`✅ Story created with "${template.name}" template`,
|
|
157
|
+
'',
|
|
158
|
+
`**#${story.id}**: ${story.title}`,
|
|
159
|
+
`**Sprint**: #${resolution.sprintId}`,
|
|
160
|
+
`**Template**: ${template.name}`,
|
|
161
|
+
`**Type**: ${story.type}`,
|
|
162
|
+
args.assigneeAgentKey ? `**AI Agent**: ${args.assigneeAgentKey}` : null,
|
|
163
|
+
args.assigneeId ? `**Assignee**: User #${args.assigneeId}` : null,
|
|
164
|
+
].filter(Boolean).join('\n');
|
|
165
|
+
} catch (error: any) {
|
|
166
|
+
return `❌ Error: ${error.message}`;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|