@elyracode/felagi 0.9.25
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 +6 -0
- package/README.md +55 -0
- package/extensions/index.ts +831 -0
- package/package.json +37 -0
- package/skills/felagi/SKILL.md +73 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.9.25] - 2026-08-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release: Elyra Félagi integration. Tools for issues (list/search/read/create/update), comments (with agent mentions), time tracking (`felagi_log_time` with human durations, `felagi_time_entries` with source provenance), the stopwatch (`felagi_timer`), the knowledge base (`felagi_articles`), projects, agents, and agent runs (`felagi_run`, queue/list/get). Ships a `felagi` skill covering the API's semantics (id shapes, time provenance, one-clock timer, actor model) and `/felagi`, `/felagi-timer`, and `/felagi-log` commands. Configured via `FELAGI_URL` + `FELAGI_TOKEN` (environment or project `.env`).
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @elyracode/felagi
|
|
2
|
+
|
|
3
|
+
[Elyra Félagi](https://elyracode.com/docs/felagi) integration for Elyra. Félagi is a project portal where people and agents share one board — this extension puts issues, comments, time tracking, the stopwatch, the knowledge base, projects, and agent runs inside your Elyra session.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
elyra install npm:@elyracode/felagi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Create a token in Félagi under **Settings → API tokens** and paste the block it hands you into your project's `.env` (or export the variables):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
FELAGI_URL=https://felagi.example.com
|
|
17
|
+
FELAGI_TOKEN=fat_...
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A read-only token works for everything except writes (which answer 403 with a message saying so).
|
|
21
|
+
|
|
22
|
+
## Tools
|
|
23
|
+
|
|
24
|
+
| Tool | Description |
|
|
25
|
+
|------|-------------|
|
|
26
|
+
| `felagi_me` | Who the token acts as, its workspace and abilities |
|
|
27
|
+
| `felagi_issues` | List/search issues: text, status, type, priority, project, assignee, labels (AND), cycle, `updated_since` |
|
|
28
|
+
| `felagi_issue` | One issue in full, optionally with the comment thread and delivered artifacts |
|
|
29
|
+
| `felagi_create_issue` | Create an issue (title required) |
|
|
30
|
+
| `felagi_update_issue` | PATCH any subset of fields; assigning an agent queues a run |
|
|
31
|
+
| `felagi_comment` | Comment on an issue; mentioning an agent hands it the thread |
|
|
32
|
+
| `felagi_log_time` | Log time as a person says it: `2h 30m`, `1d`, `90m` |
|
|
33
|
+
| `felagi_time_entries` | Read hours, filtered by mine/date range/issue/source |
|
|
34
|
+
| `felagi_timer` | Stopwatch: status, start (switches cleanly), stop, discard |
|
|
35
|
+
| `felagi_articles` | Search the team knowledge base; read full articles |
|
|
36
|
+
| `felagi_projects` | List projects |
|
|
37
|
+
| `felagi_agents` | List assignable agents with runtime status |
|
|
38
|
+
| `felagi_run` | Queue an agent run on an issue; list/read runs with summary and cost |
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
| Command | Description |
|
|
43
|
+
|---------|-------------|
|
|
44
|
+
| `/felagi` | Dashboard — my issues, running timer, workspace |
|
|
45
|
+
| `/felagi-timer` | `/felagi-timer start ACM-231`, `stop`, `status`, `discard` |
|
|
46
|
+
| `/felagi-log` | `/felagi-log ACM-231 2h30m traced the redirect loop` |
|
|
47
|
+
|
|
48
|
+
## Behavior notes
|
|
49
|
+
|
|
50
|
+
- Issue references accept both the human identifier (`ACM-231`) and the numeric id.
|
|
51
|
+
- Time durations are written the way a person says them; `spent_on` defaults to today.
|
|
52
|
+
- Hours are always the token owner's own — the API has no actor field for time, deliberately.
|
|
53
|
+
- Starting a timer while one runs stops and logs the first; the tool reports what was banked.
|
|
54
|
+
- The knowledge base (`felagi_articles`) is the endpoint worth knowing: the agent reads the team's runbook before it starts.
|
|
55
|
+
- API reference: [elyracode.com/docs/felagi/api](https://elyracode.com/docs/felagi/api)
|
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elyra Félagi integration.
|
|
3
|
+
*
|
|
4
|
+
* Félagi is a project portal where people and agents share one board:
|
|
5
|
+
* issues, comments, time tracking (manual, stopwatch, and agent runs),
|
|
6
|
+
* a knowledge base, whiteboards, and agent runs. This extension talks to
|
|
7
|
+
* its REST API (/api/v1) with a personal token.
|
|
8
|
+
*
|
|
9
|
+
* Configuration (environment or the project's .env):
|
|
10
|
+
* FELAGI_URL=https://felagi.example.com
|
|
11
|
+
* FELAGI_TOKEN=fat_...
|
|
12
|
+
*
|
|
13
|
+
* API notes baked into this client (from the specification):
|
|
14
|
+
* - An issue's `id` in responses is the human identifier (ACM-231); the
|
|
15
|
+
* URL accepts both the numeric key and the identifier.
|
|
16
|
+
* - GET /timer answers 200 with null data when nothing runs - not a 404.
|
|
17
|
+
* - Starting a timer stops and logs any running one; the response says so.
|
|
18
|
+
* - Time durations are written the way a person says them ("2h 30m").
|
|
19
|
+
* - Hours are always the token owner's own; there is no actor field.
|
|
20
|
+
* - 404 means "does not exist OR belongs to another workspace".
|
|
21
|
+
* - Enums grow inside v1; unknown values are passed through, never matched
|
|
22
|
+
* exhaustively.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import type { ExtensionAPI } from "@elyracode/coding-agent";
|
|
28
|
+
import { Type } from "typebox";
|
|
29
|
+
|
|
30
|
+
// ── Configuration ───────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
interface FelagiConfig {
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
token: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadEnv(cwd: string): Record<string, string> {
|
|
38
|
+
const envPath = join(cwd, ".env");
|
|
39
|
+
if (!existsSync(envPath)) return {};
|
|
40
|
+
const result: Record<string, string> = {};
|
|
41
|
+
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
44
|
+
const eq = trimmed.indexOf("=");
|
|
45
|
+
if (eq < 0) continue;
|
|
46
|
+
const key = trimmed.slice(0, eq).trim();
|
|
47
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
48
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
49
|
+
value = value.slice(1, -1);
|
|
50
|
+
}
|
|
51
|
+
result[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getConfig(cwd: string): FelagiConfig | null {
|
|
57
|
+
const env = loadEnv(cwd);
|
|
58
|
+
const baseUrl = process.env.FELAGI_URL || env.FELAGI_URL;
|
|
59
|
+
const token = process.env.FELAGI_TOKEN || env.FELAGI_TOKEN;
|
|
60
|
+
if (!baseUrl || !token) return null;
|
|
61
|
+
return { baseUrl: baseUrl.replace(/\/+$/, ""), token };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── HTTP client ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
interface FelagiResponse {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
status: number;
|
|
69
|
+
data: unknown;
|
|
70
|
+
retryAfter?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function felagiFetch(config: FelagiConfig, method: string, path: string, body?: unknown): Promise<FelagiResponse> {
|
|
74
|
+
const url = `${config.baseUrl}/api/v1${path}`;
|
|
75
|
+
const headers: Record<string, string> = {
|
|
76
|
+
Authorization: `Bearer ${config.token}`,
|
|
77
|
+
Accept: "application/json",
|
|
78
|
+
};
|
|
79
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
80
|
+
|
|
81
|
+
const response = await fetch(url, {
|
|
82
|
+
method,
|
|
83
|
+
headers,
|
|
84
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
85
|
+
signal: AbortSignal.timeout(30_000),
|
|
86
|
+
});
|
|
87
|
+
const text = await response.text();
|
|
88
|
+
let data: unknown;
|
|
89
|
+
try {
|
|
90
|
+
data = text ? JSON.parse(text) : null;
|
|
91
|
+
} catch {
|
|
92
|
+
data = text;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ok: response.ok,
|
|
96
|
+
status: response.status,
|
|
97
|
+
data,
|
|
98
|
+
retryAfter: response.headers.get("Retry-After") ?? undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ok(text: string) {
|
|
103
|
+
return { content: [{ type: "text" as const, text }], details: {} };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function err(text: string) {
|
|
107
|
+
return { content: [{ type: "text" as const, text }], details: {}, isError: true as const };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function noConfig() {
|
|
111
|
+
return err(
|
|
112
|
+
"F\u00e9lagi is not configured. Set FELAGI_URL and FELAGI_TOKEN in the environment or the project's .env file. " +
|
|
113
|
+
"Create a token under Settings \u2192 API tokens in F\u00e9lagi.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Map the documented error statuses to actionable messages. */
|
|
118
|
+
function apiError(response: FelagiResponse): ReturnType<typeof err> {
|
|
119
|
+
const message =
|
|
120
|
+
typeof response.data === "object" && response.data !== null && "message" in response.data
|
|
121
|
+
? String((response.data as { message: unknown }).message)
|
|
122
|
+
: String(response.data ?? "");
|
|
123
|
+
switch (response.status) {
|
|
124
|
+
case 401:
|
|
125
|
+
return err(`401: the token is wrong, revoked, expired, or its owner left the workspace. ${message}`);
|
|
126
|
+
case 403:
|
|
127
|
+
return err(`403: this token is read-only and the request was a write. ${message}`);
|
|
128
|
+
case 404:
|
|
129
|
+
return err("404: no such record, or it belongs to another workspace (F\u00e9lagi deliberately answers both the same).");
|
|
130
|
+
case 422: {
|
|
131
|
+
const errors =
|
|
132
|
+
typeof response.data === "object" && response.data !== null && "errors" in response.data
|
|
133
|
+
? JSON.stringify((response.data as { errors: unknown }).errors)
|
|
134
|
+
: "";
|
|
135
|
+
return err(`422 validation error: ${message} ${errors}`);
|
|
136
|
+
}
|
|
137
|
+
case 429:
|
|
138
|
+
return err(`429 rate limited. Retry after ${response.retryAfter ?? "?"} seconds.`);
|
|
139
|
+
default:
|
|
140
|
+
return err(`HTTP ${response.status}: ${message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Formatters ──────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** Rich text comes back as sanitised HTML; strip it for terminal display. */
|
|
147
|
+
function stripHtml(html: string): string {
|
|
148
|
+
return html
|
|
149
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
150
|
+
.replace(/<\/(p|div|h[1-6]|li)>/gi, "\n")
|
|
151
|
+
.replace(/<[^>]+>/g, "")
|
|
152
|
+
.replace(/&/g, "&")
|
|
153
|
+
.replace(/</g, "<")
|
|
154
|
+
.replace(/>/g, ">")
|
|
155
|
+
.replace(/"/g, '"')
|
|
156
|
+
.replace(/�?39;/g, "'")
|
|
157
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface Actor {
|
|
162
|
+
type?: string;
|
|
163
|
+
id?: number;
|
|
164
|
+
name?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatActor(actor: Actor | null | undefined): string {
|
|
168
|
+
if (!actor) return "unassigned";
|
|
169
|
+
return `${actor.name ?? "?"} (${actor.type ?? "?"})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface Issue {
|
|
173
|
+
id?: string;
|
|
174
|
+
title?: string;
|
|
175
|
+
description?: string | null;
|
|
176
|
+
type?: string;
|
|
177
|
+
status?: string;
|
|
178
|
+
priority?: string;
|
|
179
|
+
project?: { id?: number; name?: string } | null;
|
|
180
|
+
epic?: { id?: string; title?: string } | null;
|
|
181
|
+
assignee?: Actor | null;
|
|
182
|
+
creator?: Actor | null;
|
|
183
|
+
start_date?: string | null;
|
|
184
|
+
due_date?: string | null;
|
|
185
|
+
estimate_minutes?: number | null;
|
|
186
|
+
spent_minutes?: number;
|
|
187
|
+
labels?: string[];
|
|
188
|
+
cycle?: { number?: number; starts_on?: string; ends_on?: string } | null;
|
|
189
|
+
carried_over?: number;
|
|
190
|
+
updated_at?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatIssueLine(issue: Issue): string {
|
|
194
|
+
const parts = [`${issue.id}: ${issue.title}`];
|
|
195
|
+
parts.push(`[${issue.type ?? "?"}/${issue.status ?? "?"}/${issue.priority ?? "?"}]`);
|
|
196
|
+
if (issue.assignee) parts.push(`\u2192 ${formatActor(issue.assignee)}`);
|
|
197
|
+
return parts.join(" ");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatIssueFull(issue: Issue): string {
|
|
201
|
+
const lines: string[] = [];
|
|
202
|
+
lines.push(`# ${issue.id}: ${issue.title}`);
|
|
203
|
+
lines.push(`Type: ${issue.type ?? "?"} | Status: ${issue.status ?? "?"} | Priority: ${issue.priority ?? "?"}`);
|
|
204
|
+
if (issue.project?.name) lines.push(`Project: ${issue.project.name} (id ${issue.project.id})`);
|
|
205
|
+
if (issue.epic?.title) lines.push(`Epic: ${issue.epic.id} ${issue.epic.title}`);
|
|
206
|
+
lines.push(`Assignee: ${formatActor(issue.assignee)} | Creator: ${formatActor(issue.creator)}`);
|
|
207
|
+
if (issue.start_date || issue.due_date) lines.push(`Dates: ${issue.start_date ?? "\u2014"} \u2192 ${issue.due_date ?? "\u2014"}`);
|
|
208
|
+
const estimate = issue.estimate_minutes != null ? `${issue.estimate_minutes}m estimated` : "no estimate";
|
|
209
|
+
lines.push(`Time: ${issue.spent_minutes ?? 0}m spent (includes agent run time), ${estimate}`);
|
|
210
|
+
if (issue.labels && issue.labels.length > 0) lines.push(`Labels: ${issue.labels.join(", ")}`);
|
|
211
|
+
if (issue.cycle?.number != null) {
|
|
212
|
+
lines.push(`Cycle: ${issue.cycle.number} (${issue.cycle.starts_on} \u2192 ${issue.cycle.ends_on})`);
|
|
213
|
+
}
|
|
214
|
+
if (issue.carried_over) lines.push(`Carried over ${issue.carried_over} closed cycle(s)`);
|
|
215
|
+
if (issue.description) {
|
|
216
|
+
lines.push("");
|
|
217
|
+
lines.push(stripHtml(issue.description));
|
|
218
|
+
}
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface CollectionMeta {
|
|
223
|
+
total?: number;
|
|
224
|
+
current_page?: number;
|
|
225
|
+
last_page?: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function collectionFooter(data: unknown): string {
|
|
229
|
+
const meta = (data as { meta?: CollectionMeta })?.meta;
|
|
230
|
+
if (!meta || meta.total == null) return "";
|
|
231
|
+
if ((meta.last_page ?? 1) > 1) {
|
|
232
|
+
return `\n\n(${meta.total} total, page ${meta.current_page}/${meta.last_page})`;
|
|
233
|
+
}
|
|
234
|
+
return `\n\n(${meta.total} total)`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function rows(data: unknown): Record<string, unknown>[] {
|
|
238
|
+
const list = (data as { data?: unknown })?.data;
|
|
239
|
+
return Array.isArray(list) ? (list as Record<string, unknown>[]) : [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function record(data: unknown): Record<string, unknown> {
|
|
243
|
+
const item = (data as { data?: unknown })?.data;
|
|
244
|
+
return (item ?? {}) as Record<string, unknown>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Extension ───────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
export default function (elyra: ExtensionAPI): void {
|
|
250
|
+
// ── Tool: felagi_me ──
|
|
251
|
+
elyra.registerTool({
|
|
252
|
+
name: "felagi_me",
|
|
253
|
+
label: "F\u00e9lagi: Who Am I",
|
|
254
|
+
description:
|
|
255
|
+
"Verify the F\u00e9lagi connection: who the token acts as, which workspace it names, and whether it can write. " +
|
|
256
|
+
"Use this first when other F\u00e9lagi calls fail, or to learn the workspace issue prefix.",
|
|
257
|
+
parameters: Type.Object({}),
|
|
258
|
+
execute: async (_id, _params, _signal, _onUpdate, ctx) => {
|
|
259
|
+
const config = getConfig(ctx.cwd);
|
|
260
|
+
if (!config) return noConfig();
|
|
261
|
+
const response = await felagiFetch(config, "GET", "/me");
|
|
262
|
+
if (!response.ok) return apiError(response);
|
|
263
|
+
const me = record(response.data) as {
|
|
264
|
+
user?: { name?: string; email?: string };
|
|
265
|
+
workspace?: { name?: string; issue_prefix?: string };
|
|
266
|
+
role?: string;
|
|
267
|
+
token?: { name?: string; abilities?: string[]; expires_at?: string | null };
|
|
268
|
+
abilities?: string[];
|
|
269
|
+
};
|
|
270
|
+
const abilities = me.token?.abilities ?? me.abilities ?? [];
|
|
271
|
+
return ok(
|
|
272
|
+
[
|
|
273
|
+
`User: ${me.user?.name ?? "?"} (${me.user?.email ?? "?"})`,
|
|
274
|
+
`Workspace: ${me.workspace?.name ?? "?"} (issue prefix: ${me.workspace?.issue_prefix ?? "?"})`,
|
|
275
|
+
`Role: ${me.role ?? "?"}`,
|
|
276
|
+
`Token abilities: ${abilities.join(", ") || "?"}${abilities.includes("write") ? "" : " (read-only: writes will 403)"}`,
|
|
277
|
+
].join("\n"),
|
|
278
|
+
);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── Tool: felagi_issues ──
|
|
283
|
+
elyra.registerTool({
|
|
284
|
+
name: "felagi_issues",
|
|
285
|
+
label: "F\u00e9lagi: List Issues",
|
|
286
|
+
description:
|
|
287
|
+
"List and search F\u00e9lagi issues. Filter by free text (q, matches title and description), status " +
|
|
288
|
+
"(backlog/todo/in_progress/in_review/on_hold/done/canceled), type (epic/bug/feature/task/...), priority " +
|
|
289
|
+
"(urgent/high/medium/low), project_id, assignee, labels (multiple = AND), cycle (number, or 'none' for " +
|
|
290
|
+
"unplanned work), or updated_since for cheap polling.",
|
|
291
|
+
parameters: Type.Object({
|
|
292
|
+
q: Type.Optional(Type.String({ description: "Free-text search in title and description" })),
|
|
293
|
+
status: Type.Optional(Type.String()),
|
|
294
|
+
type: Type.Optional(Type.String()),
|
|
295
|
+
priority: Type.Optional(Type.String()),
|
|
296
|
+
project_id: Type.Optional(Type.Number()),
|
|
297
|
+
assignee_type: Type.Optional(Type.String({ description: "'user' or 'agent'" })),
|
|
298
|
+
assignee_id: Type.Optional(Type.Number()),
|
|
299
|
+
labels: Type.Optional(Type.Array(Type.String(), { description: "Multiple labels mean the overlap (AND)" })),
|
|
300
|
+
cycle: Type.Optional(Type.String({ description: "Cycle number, or 'none' for issues in no cycle" })),
|
|
301
|
+
updated_since: Type.Optional(Type.String({ description: "ISO timestamp; everything touched since" })),
|
|
302
|
+
per_page: Type.Optional(Type.Number({ description: "Up to 100 (default 25)" })),
|
|
303
|
+
}),
|
|
304
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
305
|
+
const config = getConfig(ctx.cwd);
|
|
306
|
+
if (!config) return noConfig();
|
|
307
|
+
const query = new URLSearchParams();
|
|
308
|
+
if (params.q) query.set("q", params.q);
|
|
309
|
+
if (params.status) query.set("status", params.status);
|
|
310
|
+
if (params.type) query.set("type", params.type);
|
|
311
|
+
if (params.priority) query.set("priority", params.priority);
|
|
312
|
+
if (params.project_id != null) query.set("project_id", String(params.project_id));
|
|
313
|
+
if (params.assignee_type) query.set("assignee_type", params.assignee_type);
|
|
314
|
+
if (params.assignee_id != null) query.set("assignee_id", String(params.assignee_id));
|
|
315
|
+
for (const label of params.labels ?? []) query.append("label", label);
|
|
316
|
+
if (params.cycle) query.set("cycle", params.cycle);
|
|
317
|
+
if (params.updated_since) query.set("updated_since", params.updated_since);
|
|
318
|
+
if (params.per_page != null) query.set("per_page", String(params.per_page));
|
|
319
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
320
|
+
const response = await felagiFetch(config, "GET", `/issues${suffix}`);
|
|
321
|
+
if (!response.ok) return apiError(response);
|
|
322
|
+
const issues = rows(response.data) as Issue[];
|
|
323
|
+
if (issues.length === 0) return ok("No issues match.");
|
|
324
|
+
return ok(issues.map((i) => `- ${formatIssueLine(i)}`).join("\n") + collectionFooter(response.data));
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ── Tool: felagi_issue ──
|
|
329
|
+
elyra.registerTool({
|
|
330
|
+
name: "felagi_issue",
|
|
331
|
+
label: "F\u00e9lagi: Read Issue",
|
|
332
|
+
description:
|
|
333
|
+
"Read one F\u00e9lagi issue in full, optionally with its comment thread and delivered artifacts " +
|
|
334
|
+
"(pull requests, branches, commits). Accepts the human identifier (ACM-231) or the numeric id.",
|
|
335
|
+
parameters: Type.Object({
|
|
336
|
+
issue: Type.String({ description: "Issue identifier (e.g. ACM-231) or numeric id" }),
|
|
337
|
+
include_comments: Type.Optional(Type.Boolean({ description: "Include the comment thread" })),
|
|
338
|
+
include_artifacts: Type.Optional(Type.Boolean({ description: "Include what runs delivered" })),
|
|
339
|
+
}),
|
|
340
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
341
|
+
const config = getConfig(ctx.cwd);
|
|
342
|
+
if (!config) return noConfig();
|
|
343
|
+
const issueRef = encodeURIComponent(params.issue);
|
|
344
|
+
const response = await felagiFetch(config, "GET", `/issues/${issueRef}`);
|
|
345
|
+
if (!response.ok) return apiError(response);
|
|
346
|
+
const parts = [formatIssueFull(record(response.data) as Issue)];
|
|
347
|
+
|
|
348
|
+
if (params.include_comments) {
|
|
349
|
+
const comments = await felagiFetch(config, "GET", `/issues/${issueRef}/comments`);
|
|
350
|
+
if (comments.ok) {
|
|
351
|
+
const list = rows(comments.data) as Array<{
|
|
352
|
+
author?: Actor;
|
|
353
|
+
body?: string;
|
|
354
|
+
from_run?: string | null;
|
|
355
|
+
created_at?: string;
|
|
356
|
+
}>;
|
|
357
|
+
parts.push("\n## Comments");
|
|
358
|
+
if (list.length === 0) parts.push("(none)");
|
|
359
|
+
for (const c of list) {
|
|
360
|
+
const runNote = c.from_run ? " [agent run report]" : "";
|
|
361
|
+
parts.push(`- ${formatActor(c.author)}${runNote} (${c.created_at ?? "?"}):\n ${stripHtml(c.body ?? "").replace(/\n/g, "\n ")}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (params.include_artifacts) {
|
|
367
|
+
const artifacts = await felagiFetch(config, "GET", `/issues/${issueRef}/artifacts`);
|
|
368
|
+
if (artifacts.ok) {
|
|
369
|
+
const list = rows(artifacts.data) as Array<{
|
|
370
|
+
type?: string;
|
|
371
|
+
state?: string | null;
|
|
372
|
+
title?: string;
|
|
373
|
+
url?: string | null;
|
|
374
|
+
reference?: string | null;
|
|
375
|
+
}>;
|
|
376
|
+
parts.push("\n## Artifacts");
|
|
377
|
+
if (list.length === 0) parts.push("(none)");
|
|
378
|
+
for (const a of list) {
|
|
379
|
+
const state = a.state ? ` (${a.state})` : "";
|
|
380
|
+
parts.push(`- [${a.type}]${state} ${a.title ?? a.reference ?? ""} ${a.url ?? ""}`.trim());
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return ok(parts.join("\n"));
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ── Tool: felagi_create_issue ──
|
|
390
|
+
elyra.registerTool({
|
|
391
|
+
name: "felagi_create_issue",
|
|
392
|
+
label: "F\u00e9lagi: Create Issue",
|
|
393
|
+
description: "Create a F\u00e9lagi issue. Only title is required. Requires a write token.",
|
|
394
|
+
parameters: Type.Object({
|
|
395
|
+
title: Type.String(),
|
|
396
|
+
description: Type.Optional(Type.String({ description: "HTML (sanitised server-side); plain text is fine" })),
|
|
397
|
+
type: Type.Optional(Type.String({ description: "epic/bug/feature/task/cosmetics/exception/..." })),
|
|
398
|
+
priority: Type.Optional(Type.String({ description: "urgent/high/medium/low" })),
|
|
399
|
+
project_id: Type.Optional(Type.Number()),
|
|
400
|
+
estimate_minutes: Type.Optional(Type.Number()),
|
|
401
|
+
}),
|
|
402
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
403
|
+
const config = getConfig(ctx.cwd);
|
|
404
|
+
if (!config) return noConfig();
|
|
405
|
+
const response = await felagiFetch(config, "POST", "/issues", params);
|
|
406
|
+
if (!response.ok) return apiError(response);
|
|
407
|
+
const issue = record(response.data) as Issue;
|
|
408
|
+
return ok(`Created ${issue.id}: ${issue.title}`);
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ── Tool: felagi_update_issue ──
|
|
413
|
+
elyra.registerTool({
|
|
414
|
+
name: "felagi_update_issue",
|
|
415
|
+
label: "F\u00e9lagi: Update Issue",
|
|
416
|
+
description:
|
|
417
|
+
"Update any subset of an issue's fields (PATCH semantics: omitted fields are left alone). " +
|
|
418
|
+
"Setting assignee_type='agent' queues a run for that agent, exactly like assigning it in the interface.",
|
|
419
|
+
parameters: Type.Object({
|
|
420
|
+
issue: Type.String({ description: "Issue identifier (e.g. ACM-231) or numeric id" }),
|
|
421
|
+
title: Type.Optional(Type.String()),
|
|
422
|
+
description: Type.Optional(Type.String()),
|
|
423
|
+
status: Type.Optional(Type.String({ description: "backlog/todo/in_progress/in_review/on_hold/done/canceled" })),
|
|
424
|
+
priority: Type.Optional(Type.String()),
|
|
425
|
+
type: Type.Optional(Type.String()),
|
|
426
|
+
assignee_type: Type.Optional(Type.String({ description: "'user' or 'agent' \u2014 agent queues a run" })),
|
|
427
|
+
assignee_id: Type.Optional(Type.Number()),
|
|
428
|
+
start_date: Type.Optional(Type.String({ description: "YYYY-MM-DD" })),
|
|
429
|
+
due_date: Type.Optional(Type.String({ description: "YYYY-MM-DD" })),
|
|
430
|
+
estimate_minutes: Type.Optional(Type.Number()),
|
|
431
|
+
}),
|
|
432
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
433
|
+
const config = getConfig(ctx.cwd);
|
|
434
|
+
if (!config) return noConfig();
|
|
435
|
+
const { issue, ...fields } = params;
|
|
436
|
+
const response = await felagiFetch(config, "PATCH", `/issues/${encodeURIComponent(issue)}`, fields);
|
|
437
|
+
if (!response.ok) return apiError(response);
|
|
438
|
+
const updated = record(response.data) as Issue;
|
|
439
|
+
return ok(`Updated ${updated.id}: ${formatIssueLine(updated)}`);
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ── Tool: felagi_comment ──
|
|
444
|
+
elyra.registerTool({
|
|
445
|
+
name: "felagi_comment",
|
|
446
|
+
label: "F\u00e9lagi: Comment",
|
|
447
|
+
description:
|
|
448
|
+
"Comment on a F\u00e9lagi issue. Mentioning an agent by name (e.g. '@Freya have a look') hands it the " +
|
|
449
|
+
"thread, exactly as from the interface.",
|
|
450
|
+
parameters: Type.Object({
|
|
451
|
+
issue: Type.String({ description: "Issue identifier (e.g. ACM-231) or numeric id" }),
|
|
452
|
+
body: Type.String(),
|
|
453
|
+
}),
|
|
454
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
455
|
+
const config = getConfig(ctx.cwd);
|
|
456
|
+
if (!config) return noConfig();
|
|
457
|
+
const response = await felagiFetch(config, "POST", `/issues/${encodeURIComponent(params.issue)}/comments`, {
|
|
458
|
+
body: params.body,
|
|
459
|
+
});
|
|
460
|
+
if (!response.ok) return apiError(response);
|
|
461
|
+
return ok(`Comment posted on ${params.issue}.`);
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ── Tool: felagi_log_time ──
|
|
466
|
+
elyra.registerTool({
|
|
467
|
+
name: "felagi_log_time",
|
|
468
|
+
label: "F\u00e9lagi: Log Time",
|
|
469
|
+
description:
|
|
470
|
+
"Log time on an issue the way a person says it: '2h 30m', '1d', '90m' (or minutes as a number). " +
|
|
471
|
+
"spent_on defaults to today \u2014 pass the day the work actually happened when logging late. " +
|
|
472
|
+
"Hours are always the token owner's own; there is no way to log against a colleague.",
|
|
473
|
+
parameters: Type.Object({
|
|
474
|
+
issue: Type.String({ description: "Issue identifier (e.g. ACM-231) or numeric id" }),
|
|
475
|
+
duration: Type.Optional(Type.String({ description: "'2h 30m', '1d', '90m'" })),
|
|
476
|
+
minutes: Type.Optional(Type.Number({ description: "Alternative to duration" })),
|
|
477
|
+
spent_on: Type.Optional(Type.String({ description: "YYYY-MM-DD, the day the work happened (default today)" })),
|
|
478
|
+
note: Type.Optional(Type.String({ description: "Plain text note" })),
|
|
479
|
+
}),
|
|
480
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
481
|
+
const config = getConfig(ctx.cwd);
|
|
482
|
+
if (!config) return noConfig();
|
|
483
|
+
if (!params.duration && params.minutes == null) {
|
|
484
|
+
return err("Provide duration ('2h 30m') or minutes.");
|
|
485
|
+
}
|
|
486
|
+
const { issue, ...body } = params;
|
|
487
|
+
const response = await felagiFetch(config, "POST", `/issues/${encodeURIComponent(issue)}/time`, body);
|
|
488
|
+
if (!response.ok) return apiError(response);
|
|
489
|
+
const entry = record(response.data) as { minutes?: number; spent_on?: string };
|
|
490
|
+
return ok(`Logged ${entry.minutes ?? "?"}m on ${issue} (${entry.spent_on ?? "today"}).`);
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// ── Tool: felagi_time_entries ──
|
|
495
|
+
elyra.registerTool({
|
|
496
|
+
name: "felagi_time_entries",
|
|
497
|
+
label: "F\u00e9lagi: Time Entries",
|
|
498
|
+
description:
|
|
499
|
+
"Read time entries. Filter by mine, date range (on spent_on \u2014 the day the work happened), issue, or " +
|
|
500
|
+
"source. Sources are different kinds of numbers: 'timer' is measured, 'manual' is remembered, " +
|
|
501
|
+
"'agent_run' is machine time \u2014 say which when summing.",
|
|
502
|
+
parameters: Type.Object({
|
|
503
|
+
mine: Type.Optional(Type.Boolean({ description: "Only the token owner's hours" })),
|
|
504
|
+
from: Type.Optional(Type.String({ description: "YYYY-MM-DD" })),
|
|
505
|
+
to: Type.Optional(Type.String({ description: "YYYY-MM-DD" })),
|
|
506
|
+
issue_id: Type.Optional(Type.Number()),
|
|
507
|
+
source: Type.Optional(Type.String({ description: "manual, timer, or agent_run" })),
|
|
508
|
+
}),
|
|
509
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
510
|
+
const config = getConfig(ctx.cwd);
|
|
511
|
+
if (!config) return noConfig();
|
|
512
|
+
const query = new URLSearchParams();
|
|
513
|
+
if (params.mine) query.set("mine", "1");
|
|
514
|
+
if (params.from) query.set("from", params.from);
|
|
515
|
+
if (params.to) query.set("to", params.to);
|
|
516
|
+
if (params.issue_id != null) query.set("issue_id", String(params.issue_id));
|
|
517
|
+
if (params.source) query.set("source", params.source);
|
|
518
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
519
|
+
const response = await felagiFetch(config, "GET", `/time-entries${suffix}`);
|
|
520
|
+
if (!response.ok) return apiError(response);
|
|
521
|
+
const entries = rows(response.data) as Array<{
|
|
522
|
+
id?: number;
|
|
523
|
+
issue?: string;
|
|
524
|
+
minutes?: number;
|
|
525
|
+
spent_on?: string;
|
|
526
|
+
source?: string;
|
|
527
|
+
note?: string | null;
|
|
528
|
+
actor?: Actor;
|
|
529
|
+
}>;
|
|
530
|
+
if (entries.length === 0) return ok("No time entries match.");
|
|
531
|
+
let total = 0;
|
|
532
|
+
const lines = entries.map((e) => {
|
|
533
|
+
total += e.minutes ?? 0;
|
|
534
|
+
const note = e.note ? ` \u2014 ${e.note}` : "";
|
|
535
|
+
return `- ${e.spent_on} ${e.issue}: ${e.minutes}m [${e.source}] ${formatActor(e.actor)}${note}`;
|
|
536
|
+
});
|
|
537
|
+
lines.push(`\nSum of listed entries: ${Math.floor(total / 60)}h ${total % 60}m (mixes sources unless filtered)`);
|
|
538
|
+
return ok(lines.join("\n") + collectionFooter(response.data));
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ── Tool: felagi_timer ──
|
|
543
|
+
elyra.registerTool({
|
|
544
|
+
name: "felagi_timer",
|
|
545
|
+
label: "F\u00e9lagi: Timer",
|
|
546
|
+
description:
|
|
547
|
+
"The stopwatch: one running clock per person. Actions: 'status' (what runs, or nothing \u2014 that is an " +
|
|
548
|
+
"answer, not an error), 'start' (on an issue; stops and logs any running timer, and says so), 'stop' " +
|
|
549
|
+
"(stops and writes the hours down), 'discard' (stops recording nothing). A forgotten timer is capped at " +
|
|
550
|
+
"8 hours server-side.",
|
|
551
|
+
parameters: Type.Object({
|
|
552
|
+
action: Type.Union([Type.Literal("status"), Type.Literal("start"), Type.Literal("stop"), Type.Literal("discard")]),
|
|
553
|
+
issue: Type.Optional(Type.String({ description: "Required for 'start': issue identifier or numeric id" })),
|
|
554
|
+
note: Type.Optional(Type.String({ description: "Optional note for 'start'" })),
|
|
555
|
+
}),
|
|
556
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
557
|
+
const config = getConfig(ctx.cwd);
|
|
558
|
+
if (!config) return noConfig();
|
|
559
|
+
|
|
560
|
+
if (params.action === "status") {
|
|
561
|
+
const response = await felagiFetch(config, "GET", "/timer");
|
|
562
|
+
if (!response.ok) return apiError(response);
|
|
563
|
+
const timer = (response.data as { data?: { issue?: string; started_at?: string; note?: string | null } | null })
|
|
564
|
+
?.data;
|
|
565
|
+
if (!timer) return ok("No timer is running.");
|
|
566
|
+
return ok(`Timer running on ${timer.issue} since ${timer.started_at}${timer.note ? ` \u2014 ${timer.note}` : ""}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (params.action === "start") {
|
|
570
|
+
if (!params.issue) return err("'start' needs an issue.");
|
|
571
|
+
const response = await felagiFetch(config, "POST", `/issues/${encodeURIComponent(params.issue)}/timer`, {
|
|
572
|
+
note: params.note,
|
|
573
|
+
});
|
|
574
|
+
if (!response.ok) return apiError(response);
|
|
575
|
+
const payload = response.data as {
|
|
576
|
+
data?: { issue?: string; started_at?: string };
|
|
577
|
+
stopped?: { issue?: string; minutes?: number } | null;
|
|
578
|
+
};
|
|
579
|
+
const started = `Timer started on ${payload.data?.issue ?? params.issue}.`;
|
|
580
|
+
if (payload.stopped) {
|
|
581
|
+
return ok(`${started} Stopped and logged the previous one: ${payload.stopped.minutes}m on ${payload.stopped.issue}.`);
|
|
582
|
+
}
|
|
583
|
+
return ok(started);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (params.action === "stop") {
|
|
587
|
+
const response = await felagiFetch(config, "POST", "/timer/stop");
|
|
588
|
+
if (!response.ok) return apiError(response);
|
|
589
|
+
const entry = record(response.data) as { issue?: string; minutes?: number };
|
|
590
|
+
return ok(`Timer stopped: logged ${entry.minutes ?? "?"}m on ${entry.issue ?? "?"}.`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const response = await felagiFetch(config, "DELETE", "/timer");
|
|
594
|
+
if (!response.ok && response.status !== 204) return apiError(response);
|
|
595
|
+
return ok("Timer discarded; nothing was recorded.");
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ── Tool: felagi_articles ──
|
|
600
|
+
elyra.registerTool({
|
|
601
|
+
name: "felagi_articles",
|
|
602
|
+
label: "F\u00e9lagi: Knowledge Base",
|
|
603
|
+
description:
|
|
604
|
+
"Search and read the team's knowledge base \u2014 runbooks, conventions, decisions. Read the relevant " +
|
|
605
|
+
"article BEFORE starting work that the team has documented (deploys, restores, conventions). A search " +
|
|
606
|
+
"returns a table of contents (titles + excerpts); pass an article id to get the full body.",
|
|
607
|
+
parameters: Type.Object({
|
|
608
|
+
q: Type.Optional(Type.String({ description: "Search query (omit with article_id)" })),
|
|
609
|
+
article_id: Type.Optional(Type.String({ description: "ULID of one article \u2014 returns the full body" })),
|
|
610
|
+
}),
|
|
611
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
612
|
+
const config = getConfig(ctx.cwd);
|
|
613
|
+
if (!config) return noConfig();
|
|
614
|
+
|
|
615
|
+
if (params.article_id) {
|
|
616
|
+
const response = await felagiFetch(config, "GET", `/articles/${encodeURIComponent(params.article_id)}`);
|
|
617
|
+
if (!response.ok) return apiError(response);
|
|
618
|
+
const article = record(response.data) as { title?: string; body?: string; updated_at?: string };
|
|
619
|
+
return ok(`# ${article.title}\n(updated ${article.updated_at})\n\n${stripHtml(article.body ?? "")}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const query = new URLSearchParams();
|
|
623
|
+
if (params.q) query.set("q", params.q);
|
|
624
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
625
|
+
const response = await felagiFetch(config, "GET", `/articles${suffix}`);
|
|
626
|
+
if (!response.ok) return apiError(response);
|
|
627
|
+
const articles = rows(response.data) as Array<{
|
|
628
|
+
id?: string;
|
|
629
|
+
title?: string;
|
|
630
|
+
depth?: number;
|
|
631
|
+
excerpt?: string;
|
|
632
|
+
}>;
|
|
633
|
+
if (articles.length === 0) return ok("No articles match.");
|
|
634
|
+
const lines = articles.map((a) => `- ${" ".repeat(a.depth ?? 0)}${a.title} (${a.id})\n ${a.excerpt ?? ""}`);
|
|
635
|
+
lines.push("\nPass article_id to read a full article.");
|
|
636
|
+
return ok(lines.join("\n") + collectionFooter(response.data));
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// ── Tool: felagi_projects ──
|
|
641
|
+
elyra.registerTool({
|
|
642
|
+
name: "felagi_projects",
|
|
643
|
+
label: "F\u00e9lagi: Projects",
|
|
644
|
+
description: "List F\u00e9lagi projects with status, priority, lead, and which issue types each offers.",
|
|
645
|
+
parameters: Type.Object({}),
|
|
646
|
+
execute: async (_id, _params, _signal, _onUpdate, ctx) => {
|
|
647
|
+
const config = getConfig(ctx.cwd);
|
|
648
|
+
if (!config) return noConfig();
|
|
649
|
+
const response = await felagiFetch(config, "GET", "/projects");
|
|
650
|
+
if (!response.ok) return apiError(response);
|
|
651
|
+
const projects = rows(response.data) as Array<{
|
|
652
|
+
id?: number;
|
|
653
|
+
name?: string;
|
|
654
|
+
status?: string;
|
|
655
|
+
priority?: string;
|
|
656
|
+
lead?: Actor | null;
|
|
657
|
+
target_date?: string | null;
|
|
658
|
+
issue_types?: string[];
|
|
659
|
+
}>;
|
|
660
|
+
if (projects.length === 0) return ok("No projects.");
|
|
661
|
+
return ok(
|
|
662
|
+
projects
|
|
663
|
+
.map((p) => {
|
|
664
|
+
const target = p.target_date ? `, target ${p.target_date}` : "";
|
|
665
|
+
return `- [${p.id}] ${p.name} (${p.status}/${p.priority}${target}) lead: ${formatActor(p.lead)} types: ${(p.issue_types ?? []).join(", ")}`;
|
|
666
|
+
})
|
|
667
|
+
.join("\n") + collectionFooter(response.data),
|
|
668
|
+
);
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ── Tool: felagi_agents ──
|
|
673
|
+
elyra.registerTool({
|
|
674
|
+
name: "felagi_agents",
|
|
675
|
+
label: "F\u00e9lagi: Agents",
|
|
676
|
+
description:
|
|
677
|
+
"List the F\u00e9lagi agents you can assign work to, with provider, model, status, and whether their " +
|
|
678
|
+
"runtime machine is online.",
|
|
679
|
+
parameters: Type.Object({}),
|
|
680
|
+
execute: async (_id, _params, _signal, _onUpdate, ctx) => {
|
|
681
|
+
const config = getConfig(ctx.cwd);
|
|
682
|
+
if (!config) return noConfig();
|
|
683
|
+
const response = await felagiFetch(config, "GET", "/agents");
|
|
684
|
+
if (!response.ok) return apiError(response);
|
|
685
|
+
const agents = rows(response.data) as Array<{
|
|
686
|
+
id?: number;
|
|
687
|
+
name?: string;
|
|
688
|
+
provider?: string;
|
|
689
|
+
model?: string | null;
|
|
690
|
+
status?: string;
|
|
691
|
+
runtime?: { name?: string; online?: boolean } | null;
|
|
692
|
+
}>;
|
|
693
|
+
if (agents.length === 0) return ok("No agents you can assign to.");
|
|
694
|
+
return ok(
|
|
695
|
+
agents
|
|
696
|
+
.map((a) => {
|
|
697
|
+
const runtime = a.runtime ? ` on ${a.runtime.name} (${a.runtime.online ? "online" : "offline"})` : "";
|
|
698
|
+
return `- [${a.id}] ${a.name}: ${a.provider}${a.model ? `/${a.model}` : ""}, ${a.status}${runtime}`;
|
|
699
|
+
})
|
|
700
|
+
.join("\n"),
|
|
701
|
+
);
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// ── Tool: felagi_run ──
|
|
706
|
+
elyra.registerTool({
|
|
707
|
+
name: "felagi_run",
|
|
708
|
+
label: "F\u00e9lagi: Agent Runs",
|
|
709
|
+
description:
|
|
710
|
+
"Queue an agent run on an issue, or read runs. Actions: 'queue' (issue + agent_id; answers 202 \u2014 the " +
|
|
711
|
+
"run is queued, whether a machine picks it up depends on a daemon), 'list' (filter by status/agent_id), " +
|
|
712
|
+
"'get' (one run by ULID, with summary, error, and usage).",
|
|
713
|
+
parameters: Type.Object({
|
|
714
|
+
action: Type.Union([Type.Literal("queue"), Type.Literal("list"), Type.Literal("get")]),
|
|
715
|
+
issue: Type.Optional(Type.String({ description: "For 'queue': issue identifier or numeric id" })),
|
|
716
|
+
agent_id: Type.Optional(Type.Number({ description: "For 'queue' (required) and 'list' (filter)" })),
|
|
717
|
+
run_id: Type.Optional(Type.String({ description: "For 'get': the run's ULID" })),
|
|
718
|
+
status: Type.Optional(Type.String({ description: "For 'list': queued/dispatched/running/completed/failed/canceled" })),
|
|
719
|
+
}),
|
|
720
|
+
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
721
|
+
const config = getConfig(ctx.cwd);
|
|
722
|
+
if (!config) return noConfig();
|
|
723
|
+
|
|
724
|
+
if (params.action === "queue") {
|
|
725
|
+
if (!params.issue || params.agent_id == null) return err("'queue' needs issue and agent_id.");
|
|
726
|
+
const response = await felagiFetch(config, "POST", `/issues/${encodeURIComponent(params.issue)}/runs`, {
|
|
727
|
+
agent_id: params.agent_id,
|
|
728
|
+
});
|
|
729
|
+
if (!response.ok) return apiError(response);
|
|
730
|
+
return ok(`Run queued on ${params.issue} (202: whether a machine picks it up depends on the daemon).`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (params.action === "get") {
|
|
734
|
+
if (!params.run_id) return err("'get' needs run_id.");
|
|
735
|
+
const response = await felagiFetch(config, "GET", `/runs/${encodeURIComponent(params.run_id)}`);
|
|
736
|
+
if (!response.ok) return apiError(response);
|
|
737
|
+
const run = record(response.data) as {
|
|
738
|
+
id?: string;
|
|
739
|
+
status?: string;
|
|
740
|
+
agent?: { name?: string };
|
|
741
|
+
issue?: string | null;
|
|
742
|
+
attempts?: number;
|
|
743
|
+
finished_at?: string | null;
|
|
744
|
+
error?: string | null;
|
|
745
|
+
summary?: string | null;
|
|
746
|
+
usage?: { cost_usd?: number | null } | null;
|
|
747
|
+
};
|
|
748
|
+
const lines = [
|
|
749
|
+
`Run ${run.id}: ${run.status} (agent: ${run.agent?.name ?? "?"}, issue: ${run.issue ?? "\u2014"}, attempts: ${run.attempts ?? "?"})`,
|
|
750
|
+
];
|
|
751
|
+
if (run.finished_at) lines.push(`Finished: ${run.finished_at}`);
|
|
752
|
+
if (run.error) lines.push(`Error: ${run.error}`);
|
|
753
|
+
if (run.summary) lines.push(`Summary: ${run.summary}`);
|
|
754
|
+
if (run.usage?.cost_usd != null) lines.push(`Cost: $${run.usage.cost_usd}`);
|
|
755
|
+
return ok(lines.join("\n"));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const query = new URLSearchParams();
|
|
759
|
+
if (params.status) query.set("status", params.status);
|
|
760
|
+
if (params.agent_id != null) query.set("agent_id", String(params.agent_id));
|
|
761
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
762
|
+
const response = await felagiFetch(config, "GET", `/runs${suffix}`);
|
|
763
|
+
if (!response.ok) return apiError(response);
|
|
764
|
+
const runs = rows(response.data) as Array<{
|
|
765
|
+
id?: string;
|
|
766
|
+
status?: string;
|
|
767
|
+
agent?: { name?: string };
|
|
768
|
+
issue?: string | null;
|
|
769
|
+
finished_at?: string | null;
|
|
770
|
+
}>;
|
|
771
|
+
if (runs.length === 0) return ok("No runs match.");
|
|
772
|
+
return ok(
|
|
773
|
+
runs
|
|
774
|
+
.map((r) => `- ${r.id} [${r.status}] agent: ${r.agent?.name ?? "?"}, issue: ${r.issue ?? "\u2014"}`)
|
|
775
|
+
.join("\n") + collectionFooter(response.data),
|
|
776
|
+
);
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// ── Commands ────────────────────────────────────────────────────────────
|
|
781
|
+
|
|
782
|
+
elyra.registerCommand("felagi", {
|
|
783
|
+
description: "F\u00e9lagi dashboard \u2014 my issues, running timer, workspace",
|
|
784
|
+
handler: async (_args, _ctx) => {
|
|
785
|
+
elyra.sendUserMessage(
|
|
786
|
+
"Show my F\u00e9lagi status: who I am (felagi_me), my open issues (felagi_issues with assignee_type=user " +
|
|
787
|
+
"and my id from felagi_me, status in_progress then todo), and whether a timer is running (felagi_timer status).",
|
|
788
|
+
);
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
elyra.registerCommand("felagi-timer", {
|
|
793
|
+
description: "F\u00e9lagi stopwatch: /felagi-timer [start <issue>|stop|status|discard]",
|
|
794
|
+
handler: async (args, _ctx) => {
|
|
795
|
+
const trimmed = args.trim();
|
|
796
|
+
if (!trimmed || trimmed === "status") {
|
|
797
|
+
elyra.sendUserMessage("Check the F\u00e9lagi timer status (felagi_timer action=status) and tell me what is running.");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (trimmed === "stop") {
|
|
801
|
+
elyra.sendUserMessage("Stop the F\u00e9lagi timer (felagi_timer action=stop) and tell me what was logged.");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (trimmed === "discard") {
|
|
805
|
+
elyra.sendUserMessage("Discard the F\u00e9lagi timer (felagi_timer action=discard) without recording anything.");
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (trimmed.startsWith("start ")) {
|
|
809
|
+
const issue = trimmed.slice(6).trim();
|
|
810
|
+
elyra.sendUserMessage(`Start the F\u00e9lagi timer on issue ${issue} (felagi_timer action=start).`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
elyra.sendUserMessage(`Interpret this as a F\u00e9lagi timer request and use the felagi_timer tool: ${trimmed}`);
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
elyra.registerCommand("felagi-log", {
|
|
818
|
+
description: "Log time in F\u00e9lagi: /felagi-log <issue> <duration> [note]",
|
|
819
|
+
handler: async (args, ctx) => {
|
|
820
|
+
const match = args.trim().match(/^(\S+)\s+(\S+(?:\s+\d+m)?)\s*(.*)$/);
|
|
821
|
+
if (!match) {
|
|
822
|
+
ctx.ui.notify("Usage: /felagi-log <issue> <duration> [note] \u2014 e.g. /felagi-log ACM-231 2h30m traced the loop", "error");
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const [, issue, duration, note] = match;
|
|
826
|
+
elyra.sendUserMessage(
|
|
827
|
+
`Log time in F\u00e9lagi with felagi_log_time: issue ${issue}, duration "${duration}"${note ? `, note "${note}"` : ""}.`,
|
|
828
|
+
);
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyracode/felagi",
|
|
3
|
+
"version": "0.9.25",
|
|
4
|
+
"description": "Elyra Félagi integration — issues, comments, time tracking, the stopwatch, knowledge base, projects, and agent runs from your Elyra session",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"elyra-package",
|
|
8
|
+
"felagi",
|
|
9
|
+
"issues",
|
|
10
|
+
"time-tracking",
|
|
11
|
+
"project-management"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Knut W. Horne",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/kwhorne/elyra.git",
|
|
18
|
+
"directory": "packages/felagi"
|
|
19
|
+
},
|
|
20
|
+
"elyra": {
|
|
21
|
+
"skills": [
|
|
22
|
+
"./skills"
|
|
23
|
+
],
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./extensions/index.ts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@elyracode/coding-agent": "*",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "echo 'nothing to clean'",
|
|
34
|
+
"build": "echo 'nothing to build'",
|
|
35
|
+
"check": "echo 'nothing to check'"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: felagi
|
|
3
|
+
description: Elyra Félagi project portal integration. Use when the user mentions Félagi, their issue board, logging time/hours, the stopwatch/timer, issue identifiers like ACM-231, the team knowledge base, or queueing agent runs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Félagi
|
|
7
|
+
|
|
8
|
+
Félagi is the team's project portal: people and agents share one board. Issues, comments, time tracking, a knowledge base, and agent runs are all reachable over its API.
|
|
9
|
+
|
|
10
|
+
## When to Use
|
|
11
|
+
|
|
12
|
+
- The user references an issue identifier (`ACM-231` style) or asks what's on their plate
|
|
13
|
+
- The user wants to log hours, start/stop a timer, or review a timesheet
|
|
14
|
+
- Work is starting that the team may have documented (deploys, restores, conventions) — check the knowledge base first
|
|
15
|
+
- The user wants to create/update issues, comment, or hand work to a Félagi agent
|
|
16
|
+
|
|
17
|
+
## Available Tools
|
|
18
|
+
|
|
19
|
+
| Tool | Use for |
|
|
20
|
+
|------|---------|
|
|
21
|
+
| `felagi_me` | Verify connection, workspace, token abilities. Run first when things fail |
|
|
22
|
+
| `felagi_issues` | List/search issues (q, status, type, priority, project, assignee, labels, cycle, updated_since) |
|
|
23
|
+
| `felagi_issue` | One issue in full, optionally with comments and delivered artifacts |
|
|
24
|
+
| `felagi_create_issue` | New issue (title required) |
|
|
25
|
+
| `felagi_update_issue` | PATCH any subset; `assignee_type=agent` queues a run |
|
|
26
|
+
| `felagi_comment` | Comment; mentioning an agent by name hands it the thread |
|
|
27
|
+
| `felagi_log_time` | Log a duration the way a person says it: "2h 30m", "1d", "90m" |
|
|
28
|
+
| `felagi_time_entries` | Read hours (mine/from/to/issue/source) |
|
|
29
|
+
| `felagi_timer` | The stopwatch: status/start/stop/discard |
|
|
30
|
+
| `felagi_articles` | Search and read the team knowledge base |
|
|
31
|
+
| `felagi_projects` | List projects |
|
|
32
|
+
| `felagi_agents` | List assignable agents and whether their runtime is online |
|
|
33
|
+
| `felagi_run` | Queue a run on an issue; list/read runs |
|
|
34
|
+
|
|
35
|
+
## Setup
|
|
36
|
+
|
|
37
|
+
Requires `FELAGI_URL` and `FELAGI_TOKEN` (environment or project `.env`). Tokens are created under **Settings → API tokens** in Félagi; a read-only token makes every write answer 403.
|
|
38
|
+
|
|
39
|
+
## Semantics Worth Respecting
|
|
40
|
+
|
|
41
|
+
**Knowledge base before conventions-sensitive work.** Skills are what an agent is given; the knowledge base is what it can look up. `felagi_articles` with a query, then read the matching article's body, before deploys/restores/anything the team documents.
|
|
42
|
+
|
|
43
|
+
**Issue ids are human.** Responses carry `ACM-231`, and the tools accept either that identifier or the numeric id. Use the identifier when talking to the user — it is what people say out loud.
|
|
44
|
+
|
|
45
|
+
**Time has provenance.** `timer` is measured, `manual` is remembered, `agent_run` is machine time. Never sum sources without saying which — a mixed total is a guess.
|
|
46
|
+
|
|
47
|
+
**Hours are always the token owner's own.** There is no way to log time for someone else; do not try to work around it.
|
|
48
|
+
|
|
49
|
+
**The timer is one clock per person.** Starting a second stops and logs the first (the tool reports what was banked). "No timer running" is an answer, not an error. Forgotten timers are capped at 8h server-side.
|
|
50
|
+
|
|
51
|
+
**People and agents share one shape.** `{ type: "user"|"agent", id, name }`. Do not special-case agents. `null` assignee means unassigned; "Former member" means a deleted account.
|
|
52
|
+
|
|
53
|
+
**Two ways to put a Félagi agent to work.** Assign it (`felagi_update_issue` with `assignee_type=agent`) or mention it in a comment ("@Freya the build is failing — have a look"). The mention leaves a sentence explaining why, which the agent then reads — prefer it when context matters.
|
|
54
|
+
|
|
55
|
+
**Enums grow.** Status/type/priority values may gain new members inside API v1; pass unknown values through rather than treating them as errors.
|
|
56
|
+
|
|
57
|
+
**404 is deliberate.** "No such record" and "belongs to another workspace" answer identically. Do not retry a 404 with variations hoping to find a hidden record.
|
|
58
|
+
|
|
59
|
+
## Typical Flows
|
|
60
|
+
|
|
61
|
+
**Morning**: `felagi_issues` (assignee_type=user, own id from `felagi_me`, status=todo) → pick one → `felagi_timer` start → `felagi_update_issue` status=in_progress.
|
|
62
|
+
|
|
63
|
+
**Done with a task**: `felagi_timer` stop → `felagi_comment` what happened → `felagi_update_issue` status=in_review.
|
|
64
|
+
|
|
65
|
+
**Logging forgotten hours**: `felagi_log_time` with `spent_on` set to the day the work actually happened, not today.
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
| Command | Description |
|
|
70
|
+
|---------|-------------|
|
|
71
|
+
| `/felagi` | Dashboard: who am I, my issues, running timer |
|
|
72
|
+
| `/felagi-timer` | `start <issue>`, `stop`, `status`, `discard` |
|
|
73
|
+
| `/felagi-log` | `/felagi-log ACM-231 2h30m traced the loop` |
|