@aexol/spectral 0.9.157 → 0.9.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/auth-helper.d.ts +3 -3
  2. package/dist/auth-helper.d.ts.map +1 -1
  3. package/dist/auth-helper.js +3 -3
  4. package/dist/commands/login.d.ts +1 -1
  5. package/dist/commands/login.js +6 -6
  6. package/dist/extensions/browser/index.d.ts +1 -1
  7. package/dist/extensions/browser/index.js +1 -1
  8. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  9. package/dist/extensions/desktop-screenshot/index.js +46 -39
  10. package/dist/extensions/image-generation/index.js +1 -1
  11. package/dist/memory/compaction.d.ts +0 -89
  12. package/dist/memory/compaction.d.ts.map +1 -1
  13. package/dist/memory/compaction.js +2 -759
  14. package/dist/memory/hooks/compaction-hook.d.ts.map +1 -1
  15. package/dist/memory/hooks/compaction-hook.js +9 -235
  16. package/dist/memory/prompts.d.ts +0 -4
  17. package/dist/memory/prompts.d.ts.map +1 -1
  18. package/dist/memory/prompts.js +0 -165
  19. package/dist/relay/auto-research.d.ts.map +1 -1
  20. package/dist/relay/auto-research.js +0 -1
  21. package/dist/sdk/ai/env-api-keys.d.ts.map +1 -1
  22. package/dist/sdk/ai/env-api-keys.js +0 -4
  23. package/dist/sdk/ai/models.generated.d.ts +0 -399
  24. package/dist/sdk/ai/models.generated.d.ts.map +1 -1
  25. package/dist/sdk/ai/models.generated.js +0 -395
  26. package/dist/sdk/ai/providers/register-builtins.d.ts +0 -4
  27. package/dist/sdk/ai/providers/register-builtins.d.ts.map +1 -1
  28. package/dist/sdk/ai/providers/register-builtins.js +0 -16
  29. package/dist/sdk/ai/types.d.ts +1 -1
  30. package/dist/sdk/ai/types.d.ts.map +1 -1
  31. package/dist/sdk/ai/utils/oauth/index.d.ts +0 -1
  32. package/dist/sdk/ai/utils/oauth/index.d.ts.map +1 -1
  33. package/dist/sdk/ai/utils/oauth/index.js +0 -3
  34. package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
  35. package/dist/sdk/coding-agent/core/agent-session.js +4 -0
  36. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  37. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +0 -10
  38. package/dist/sdk/coding-agent/core/model-resolver.d.ts.map +1 -1
  39. package/dist/sdk/coding-agent/core/model-resolver.js +0 -1
  40. package/dist/server/agent-bridge.d.ts.map +1 -1
  41. package/dist/server/agent-bridge.js +0 -2
  42. package/package.json +1 -1
  43. package/dist/extensions/kanban-bridge.d.ts +0 -24
  44. package/dist/extensions/kanban-bridge.d.ts.map +0 -1
  45. package/dist/extensions/kanban-bridge.js +0 -858
  46. package/dist/memory/unified-compaction.d.ts +0 -59
  47. package/dist/memory/unified-compaction.d.ts.map +0 -1
  48. package/dist/memory/unified-compaction.js +0 -332
  49. package/dist/sdk/ai/providers/anthropic.d.ts +0 -54
  50. package/dist/sdk/ai/providers/anthropic.d.ts.map +0 -1
  51. package/dist/sdk/ai/providers/anthropic.js +0 -921
  52. package/dist/sdk/ai/utils/oauth/anthropic.d.ts +0 -25
  53. package/dist/sdk/ai/utils/oauth/anthropic.d.ts.map +0 -1
  54. package/dist/sdk/ai/utils/oauth/anthropic.js +0 -334
@@ -1,858 +0,0 @@
1
- /**
2
- * Kanban Bridge spectral extension.
3
- *
4
- * Registers kanban management tools that allow the coding agent to read,
5
- * create, update, and move Kanban tasks on the Aexol Studio board. This
6
- * enables task-driven execution — the agent can pick up TODO tasks, work
7
- * on them, and move them to DONE.
8
- *
9
- * Auth is read from ~/.spectral/config.json (same as aexol-mcp).
10
- * The Studio project ID comes from the local binding (.aexol/aexol.jsonc),
11
- * falling back to an explicit kanban_project_id flag.
12
- *
13
- * Registered tools:
14
- * - kanban_list — List all Kanban tasks for the project
15
- * - kanban_get — Get a single task by ID
16
- * - kanban_create — Create a new Kanban task
17
- * - kanban_update — Update a task (title, description, priority, tags)
18
- * - kanban_move — Move a task between columns (BACKLOG → TODO → IN_PROGRESS → DONE)
19
- * - kanban_next — Pick the next TODO task and mark it IN_PROGRESS
20
- * - kanban_delete — Delete a task
21
- */
22
- /**
23
- * Send a GraphQL query/mutation to the Aexol backend.
24
- * Uses the same auth and endpoint as the Studio frontend.
25
- */
26
- async function graphqlRequest(opts, query, variables) {
27
- const body = JSON.stringify({ query, variables });
28
- const res = await fetch(`${opts.backendUrl}/graphql`, {
29
- method: "POST",
30
- headers: {
31
- "Content-Type": "application/json",
32
- Accept: "application/json",
33
- Authorization: `Bearer ${opts.token}`,
34
- },
35
- body,
36
- signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
37
- });
38
- if (!res.ok) {
39
- let detail = "";
40
- try {
41
- detail = (await res.text()).slice(0, 300);
42
- }
43
- catch { /* ignore */ }
44
- throw new GraphQLError(`HTTP ${res.status} from GraphQL${detail ? `: ${detail}` : ""}`, res.status);
45
- }
46
- const rawBody = await res.text();
47
- let json;
48
- try {
49
- json = JSON.parse(rawBody);
50
- }
51
- catch {
52
- throw new GraphQLError(`GraphQL returned non-JSON response (${res.status}): ${rawBody.slice(0, 200)}`);
53
- }
54
- if (json.errors && json.errors.length > 0) {
55
- const errDetail = json.errors.map((e) => {
56
- let s = e.message;
57
- if (e.extensions?.code)
58
- s += ` [code: ${e.extensions.code}]`;
59
- return s;
60
- }).join("; ");
61
- // Detect auth-related errors — the backend returns 200 with auth errors in the body
62
- const isAuthError = json.errors.some((e) => e.extensions?.code === "UNAUTHENTICATED" ||
63
- (e.extensions?.originalError &&
64
- typeof e.extensions.originalError === "object" &&
65
- e.extensions.originalError.message === "Authentication required"));
66
- process.stderr.write(`[kanban-bridge] GraphQL errors from ${opts.backendUrl}/graphql (project ${opts.projectId}): ${errDetail}\n`);
67
- process.stderr.write(`[kanban-bridge] Full response: ${rawBody.slice(0, 500)}\n`);
68
- throw new GraphQLError(errDetail, isAuthError ? 401 : undefined);
69
- }
70
- if (!json.data) {
71
- process.stderr.write(`[kanban-bridge] GraphQL missing data from ${opts.backendUrl}/graphql (project ${opts.projectId}): ${rawBody.slice(0, 500)}\n`);
72
- throw new GraphQLError("GraphQL response missing data");
73
- }
74
- return json.data;
75
- }
76
- class GraphQLError extends Error {
77
- status;
78
- constructor(message, status) {
79
- super(message);
80
- this.name = "GraphQLError";
81
- this.status = status;
82
- }
83
- }
84
- // ---------------------------------------------------------------------------
85
- // GraphQL fragments (minimal inline selectors — no codegen dependency)
86
- // ---------------------------------------------------------------------------
87
- const KANBAN_TASK_FRAGMENT = `
88
- id
89
- projectId
90
- column
91
- title
92
- description
93
- priority
94
- sortOrder
95
- tags
96
- createdAt
97
- updatedAt
98
- completedAt
99
- assignee { id username email }
100
- `;
101
- const KANBAN_TASK_FRAGMENT_LIGHT = `
102
- id
103
- column
104
- title
105
- priority
106
- tags
107
- `;
108
- // ---------------------------------------------------------------------------
109
- // GraphQL operations
110
- // ---------------------------------------------------------------------------
111
- async function listKanbanTasks(opts) {
112
- const data = await graphqlRequest(opts, `query($projectId: ID!) {
113
- kanbanTasks(projectId: $projectId, limit: 500) {
114
- ${KANBAN_TASK_FRAGMENT_LIGHT}
115
- }
116
- }`, { projectId: opts.projectId });
117
- return data.kanbanTasks;
118
- }
119
- async function getKanbanTask(opts, taskId) {
120
- // There's no single-task query, so fetch all and filter
121
- const data = await graphqlRequest(opts, `query($projectId: ID!) {
122
- kanbanTasks(projectId: $projectId, limit: 500) {
123
- ${KANBAN_TASK_FRAGMENT}
124
- }
125
- }`, { projectId: opts.projectId });
126
- const task = data.kanbanTasks.find((t) => t.id === taskId);
127
- if (!task)
128
- throw new GraphQLError(`Task not found: ${taskId}`);
129
- return task;
130
- }
131
- async function createKanbanTask(opts, input) {
132
- const data = await graphqlRequest(opts, `mutation($input: CreateKanbanTaskInput!) {
133
- createKanbanTask(input: $input) {
134
- ${KANBAN_TASK_FRAGMENT}
135
- }
136
- }`, {
137
- input: {
138
- projectId: opts.projectId,
139
- title: input.title,
140
- description: input.description ?? null,
141
- column: input.column ?? "BACKLOG",
142
- priority: input.priority ?? 0,
143
- tags: input.tags ?? null,
144
- },
145
- });
146
- return data.createKanbanTask;
147
- }
148
- async function updateKanbanTask(opts, taskId, input) {
149
- const data = await graphqlRequest(opts, `mutation($id: ID!, $input: UpdateKanbanTaskInput!) {
150
- updateKanbanTask(id: $id, input: $input) {
151
- ${KANBAN_TASK_FRAGMENT}
152
- }
153
- }`, {
154
- id: taskId,
155
- input: {
156
- title: input.title ?? undefined,
157
- description: input.description ?? undefined,
158
- column: input.column ?? undefined,
159
- priority: input.priority ?? undefined,
160
- tags: input.tags ?? undefined,
161
- },
162
- });
163
- return data.updateKanbanTask;
164
- }
165
- async function moveKanbanTask(opts, taskId, targetColumn, targetSortOrder) {
166
- const data = await graphqlRequest(opts, `mutation($input: MoveKanbanTaskInput!) {
167
- moveKanbanTask(input: $input) {
168
- ${KANBAN_TASK_FRAGMENT}
169
- }
170
- }`, {
171
- input: {
172
- taskId,
173
- targetColumn,
174
- targetSortOrder,
175
- },
176
- });
177
- return data.moveKanbanTask;
178
- }
179
- async function deleteKanbanTask(opts, taskId) {
180
- await graphqlRequest(opts, `mutation($id: ID!) {
181
- deleteKanbanTask(id: $id)
182
- }`, { id: taskId });
183
- return true;
184
- }
185
- // ---------------------------------------------------------------------------
186
- // Config resolution
187
- // ---------------------------------------------------------------------------
188
- import { getApiUrl, getConfigFile, readConfig } from "../config.js";
189
- import { readStudioBinding } from "../studio-binding.js";
190
- import { ensureAuthenticated, runOAuthFlow, OAUTH_REASON } from "../auth-helper.js";
191
- import { loadMachine } from "../relay/machine-store.js";
192
- import { isJwtExpired } from "../relay/registration.js";
193
- import { stat } from "node:fs/promises";
194
- import { join } from "node:path";
195
- // Track whether we've already attempted reauth in this session to avoid loops
196
- let _reauthAttempted = false;
197
- const _bindingCache = new Map();
198
- async function refreshBindingForCwd(cwd) {
199
- const bindingFile = join(cwd, ".aexol", "aexol.jsonc");
200
- const cached = _bindingCache.get(cwd);
201
- let mtimeMs;
202
- try {
203
- mtimeMs = (await stat(bindingFile)).mtimeMs;
204
- }
205
- catch (err) {
206
- const code = err.code;
207
- if (code === "ENOENT" || code === "ENOTDIR") {
208
- _bindingCache.set(cwd, { binding: null, mtimeMs: null });
209
- return null;
210
- }
211
- return cached?.binding ?? null;
212
- }
213
- if (cached && mtimeMs === cached.mtimeMs)
214
- return cached.binding;
215
- try {
216
- const binding = await readStudioBinding(cwd);
217
- _bindingCache.set(cwd, { binding, mtimeMs });
218
- return binding;
219
- }
220
- catch {
221
- _bindingCache.set(cwd, { binding: cached?.binding ?? null, mtimeMs: null });
222
- return cached?.binding ?? null;
223
- }
224
- }
225
- async function resolveKanbanConfig(explicitProjectId, cwd) {
226
- const cfg = await readConfig();
227
- if (!cfg) {
228
- if (!_reauthAttempted) {
229
- _reauthAttempted = true;
230
- process.stderr.write(`[kanban-bridge] No config found at ${getConfigFile()}. Attempting OAuth login...\n`);
231
- const result = await ensureAuthenticated();
232
- if (result.ok && result.config) {
233
- return resolveKanbanConfig(explicitProjectId, cwd); // Retry with fresh config
234
- }
235
- }
236
- process.stderr.write(`[kanban-bridge] No config found at ${getConfigFile()}. Run \`spectral login\`.\n`);
237
- return null;
238
- }
239
- // If we only have a team API key, proactively get a user JWT via OAuth.
240
- // The team API key passes MCP health checks but fails GraphQL kanban auth.
241
- if (cfg.teamApiKey && !cfg.userJwt && !_reauthAttempted) {
242
- _reauthAttempted = true;
243
- process.stderr.write(`[kanban-bridge] Team API key found but no user JWT. Kanban requires user auth — obtaining via OAuth...\n`);
244
- const oauthResult = await runOAuthFlow(undefined, undefined, OAUTH_REASON.KANBAN);
245
- if (oauthResult.ok) {
246
- return resolveKanbanConfig(explicitProjectId, cwd); // Retry with fresh config (now has userJwt)
247
- }
248
- process.stderr.write(`[kanban-bridge] OAuth failed: ${oauthResult.reason}. Kanban calls will likely fail.\n`);
249
- }
250
- // Prefer machine JWT (long-lived, no browser OAuth needed)
251
- // Falls back to user JWT / team API key for non-serve contexts
252
- let token = "";
253
- let tokenSource = "";
254
- const machine = await loadMachine();
255
- if (machine?.machineJwt && !isJwtExpired(machine.machineJwt)) {
256
- token = machine.machineJwt;
257
- tokenSource = "machine-jwt";
258
- process.stderr.write(`[kanban-bridge] Using machine JWT (machine: ${machine.machineName}, team: ${machine.teamId ?? "unknown"}).\n`);
259
- }
260
- else {
261
- token = cfg.userJwt || cfg.teamApiKey || "";
262
- if (cfg.userJwt) {
263
- tokenSource = "user-jwt";
264
- }
265
- else if (cfg.teamApiKey) {
266
- tokenSource = "team-api-key";
267
- }
268
- }
269
- if (!token) {
270
- if (!_reauthAttempted) {
271
- _reauthAttempted = true;
272
- process.stderr.write(`[kanban-bridge] No auth token in config. Attempting OAuth login...\n`);
273
- const result = await ensureAuthenticated();
274
- if (result.ok && result.config) {
275
- return resolveKanbanConfig(explicitProjectId, cwd);
276
- }
277
- }
278
- process.stderr.write(`[kanban-bridge] No auth token in config. Run \`spectral login\`.\n`);
279
- return null;
280
- }
281
- // Resolve backend URL — the config stores the MCP URL, we need the base
282
- const backendUrl = process.env.SPECTRAL_BACKEND_URL ||
283
- getApiUrl(cfg.apiUrl).replace(/\/mcp\/?$/, "");
284
- // Resolve projectId: explicit param > studio binding
285
- let projectId = explicitProjectId || null;
286
- if (!projectId) {
287
- const lookupCwd = cwd ?? process.cwd();
288
- const binding = await refreshBindingForCwd(lookupCwd);
289
- if (binding?.projectId) {
290
- projectId = binding.projectId;
291
- }
292
- }
293
- if (!projectId) {
294
- process.stderr.write(`[kanban-bridge] No projectId available for cwd ${cwd ?? process.cwd()}. Bind a project via \`spectral bind <project-id>\` or pass projectId explicitly.\n`);
295
- return null;
296
- }
297
- return { token, backendUrl, projectId };
298
- }
299
- // ---------------------------------------------------------------------------
300
- // Tool: kanban_list
301
- // ---------------------------------------------------------------------------
302
- const kanbanListTool = {
303
- name: "kanban_list",
304
- label: "List Kanban Tasks",
305
- description: [
306
- "List all Kanban tasks for the bound Studio project.",
307
- "Returns tasks grouped by column: BACKLOG, TODO, IN_PROGRESS, DONE.",
308
- "Use this to see what work is planned and what's currently in progress.",
309
- ].join(" "),
310
- parameters: {
311
- type: "object",
312
- properties: {
313
- column: {
314
- type: "string",
315
- description: "Optional: filter by column (BACKLOG, TODO, IN_PROGRESS, DONE). If omitted, returns all tasks.",
316
- },
317
- },
318
- },
319
- async execute(_toolCallId, params) {
320
- const p = params;
321
- const cfg = await resolveKanbanConfig(p.projectId);
322
- if (!cfg) {
323
- return {
324
- content: [{ type: "text", text: "Error: Kanban bridge not configured. Run `spectral login` and `spectral bind <project-id>`." }],
325
- details: { isError: true },
326
- };
327
- }
328
- try {
329
- const allTasks = await listKanbanTasks(cfg);
330
- // Group by column
331
- const groups = {
332
- BACKLOG: [],
333
- TODO: [],
334
- IN_PROGRESS: [],
335
- DONE: [],
336
- };
337
- for (const task of allTasks) {
338
- (groups[task.column] ??= []).push(task);
339
- }
340
- if (p.column && groups[p.column]) {
341
- const col = groups[p.column];
342
- const lines = [`## ${p.column} (${col.length} tasks)`, ""];
343
- for (const t of col) {
344
- lines.push(`- **[${t.id}]** ${t.title} (priority: ${t.priority}${t.tags ? `, tags: ${t.tags}` : ""})`);
345
- }
346
- return {
347
- content: [{ type: "text", text: lines.join("\n") }],
348
- details: {},
349
- };
350
- }
351
- const lines = [`## Kanban Board — Project ${cfg.projectId}`, `Total: ${allTasks.length} tasks`, ""];
352
- for (const [col, tasks] of Object.entries(groups)) {
353
- if (tasks.length === 0) {
354
- lines.push(`### ${col} — empty`);
355
- }
356
- else {
357
- lines.push(`### ${col} (${tasks.length})`);
358
- for (const t of tasks) {
359
- lines.push(`- **[${t.id}]** ${t.title} (priority: ${t.priority}${t.tags ? `, tags: ${t.tags}` : ""})`);
360
- }
361
- }
362
- lines.push("");
363
- }
364
- return {
365
- content: [{ type: "text", text: lines.join("\n") }],
366
- details: {},
367
- };
368
- }
369
- catch (err) {
370
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
371
- throw err; // Propagate to withReauthRetry for OAuth handling
372
- }
373
- const msg = err instanceof Error ? err.message : String(err);
374
- process.stderr.write(`[kanban-bridge] kanban_list failed for project ${cfg.projectId}: ${msg}\n` +
375
- `${err instanceof Error && err.stack ? err.stack + '\n' : ''}`);
376
- return {
377
- content: [{ type: "text", text: `Error listing tasks: ${msg}` }],
378
- details: { isError: true },
379
- };
380
- }
381
- },
382
- };
383
- // ---------------------------------------------------------------------------
384
- // Tool: kanban_get
385
- // ---------------------------------------------------------------------------
386
- const kanbanGetTool = {
387
- name: "kanban_get",
388
- label: "Get Kanban Task",
389
- description: "Get full details for a specific Kanban task by ID.",
390
- parameters: {
391
- type: "object",
392
- properties: {
393
- taskId: {
394
- type: "string",
395
- description: "The Kanban task ID (from kanban_list output)",
396
- },
397
- projectId: {
398
- type: "string",
399
- description: "Optional: override the default project ID",
400
- },
401
- },
402
- required: ["taskId"],
403
- },
404
- async execute(_toolCallId, params) {
405
- const p = params;
406
- const cfg = await resolveKanbanConfig(p.projectId);
407
- if (!cfg) {
408
- return { content: [{ type: "text", text: "Error: Kanban bridge not configured." }], details: { isError: true } };
409
- }
410
- try {
411
- const task = await getKanbanTask(cfg, p.taskId);
412
- const lines = [
413
- `## Task: ${task.title}`,
414
- `ID: ${task.id}`,
415
- `Column: ${task.column}`,
416
- `Priority: ${task.priority}`,
417
- `Tags: ${task.tags ?? "(none)"}`,
418
- `Created: ${task.createdAt}`,
419
- `Updated: ${task.updatedAt}`,
420
- task.completedAt ? `Completed: ${task.completedAt}` : null,
421
- task.assignee ? `Assignee: ${task.assignee.username} (${task.assignee.email})` : "Assignee: unassigned",
422
- "",
423
- "### Description",
424
- task.description ?? "(no description)",
425
- ];
426
- return {
427
- content: [{ type: "text", text: lines.filter(Boolean).join("\n") }],
428
- details: {},
429
- };
430
- }
431
- catch (err) {
432
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
433
- throw err; // Propagate to withReauthRetry for OAuth handling
434
- }
435
- const msg = err instanceof Error ? err.message : String(err);
436
- process.stderr.write(`[kanban-bridge] kanban_get failed for project ${cfg.projectId}: ${msg}\n` +
437
- `${err instanceof Error && err.stack ? err.stack + '\n' : ''}`);
438
- return { content: [{ type: "text", text: `Error: ${msg}` }], details: { isError: true } };
439
- }
440
- },
441
- };
442
- // ---------------------------------------------------------------------------
443
- // Tool: kanban_create
444
- // ---------------------------------------------------------------------------
445
- const kanbanCreateTool = {
446
- name: "kanban_create",
447
- label: "Create Kanban Task",
448
- description: "Create a new Kanban task on the board.",
449
- parameters: {
450
- type: "object",
451
- properties: {
452
- title: { type: "string", description: "Task title" },
453
- description: { type: "string", description: "Detailed task description including acceptance criteria" },
454
- column: { type: "string", description: "Column: BACKLOG (default), TODO, IN_PROGRESS, DONE" },
455
- priority: { type: "number", description: "Priority 0-100. Higher = more urgent." },
456
- tags: { type: "string", description: "Comma-separated tags: auth,api,frontend,backend,testing" },
457
- projectId: { type: "string", description: "Optional: override the default project ID" },
458
- },
459
- required: ["title", "description"],
460
- },
461
- async execute(_toolCallId, params) {
462
- const p = params;
463
- const cfg = await resolveKanbanConfig(p.projectId);
464
- if (!cfg) {
465
- return { content: [{ type: "text", text: "Error: Kanban bridge not configured." }], details: { isError: true } };
466
- }
467
- try {
468
- const task = await createKanbanTask(cfg, {
469
- title: p.title,
470
- description: p.description,
471
- column: p.column ?? "BACKLOG",
472
- priority: p.priority ?? 0,
473
- tags: p.tags,
474
- });
475
- return {
476
- content: [{
477
- type: "text",
478
- text: `✓ Created task "${task.title}" (${task.id}) in ${task.column}`,
479
- }],
480
- details: {},
481
- };
482
- }
483
- catch (err) {
484
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
485
- throw err; // Propagate to withReauthRetry for OAuth handling
486
- }
487
- const msg = err instanceof Error ? err.message : String(err);
488
- process.stderr.write(`[kanban-bridge] kanban_create failed for project ${cfg.projectId}: ${msg}\n` +
489
- `${err instanceof Error && err.stack ? err.stack + '\n' : ''}`);
490
- return { content: [{ type: "text", text: `Error creating task: ${msg}` }], details: { isError: true } };
491
- }
492
- },
493
- };
494
- // ---------------------------------------------------------------------------
495
- // Tool: kanban_update
496
- // ---------------------------------------------------------------------------
497
- const kanbanUpdateTool = {
498
- name: "kanban_update",
499
- label: "Update Kanban Task",
500
- description: "Update a Kanban task's metadata (title, description, priority, tags). To move between columns, use kanban_move.",
501
- parameters: {
502
- type: "object",
503
- properties: {
504
- taskId: { type: "string", description: "The Kanban task ID" },
505
- title: { type: "string", description: "New title" },
506
- description: { type: "string", description: "Updated description with results/progress" },
507
- priority: { type: "number", description: "Updated priority 0-100" },
508
- tags: { type: "string", description: "Updated comma-separated tags" },
509
- projectId: { type: "string", description: "Optional: override the default project ID" },
510
- },
511
- required: ["taskId"],
512
- },
513
- async execute(_toolCallId, params) {
514
- const p = params;
515
- const cfg = await resolveKanbanConfig(p.projectId);
516
- if (!cfg) {
517
- return { content: [{ type: "text", text: "Error: Kanban bridge not configured." }], details: { isError: true } };
518
- }
519
- try {
520
- const input = {};
521
- if (p.title !== undefined)
522
- input.title = p.title;
523
- if (p.description !== undefined)
524
- input.description = p.description;
525
- if (p.priority !== undefined)
526
- input.priority = p.priority;
527
- if (p.tags !== undefined)
528
- input.tags = p.tags;
529
- if (Object.keys(input).length === 0) {
530
- return { content: [{ type: "text", text: "No changes provided. Specify at least one field to update." }], details: { isError: true } };
531
- }
532
- const task = await updateKanbanTask(cfg, p.taskId, input);
533
- return {
534
- content: [{ type: "text", text: `✓ Updated task "${task.title}" (${task.id})` }],
535
- details: {},
536
- };
537
- }
538
- catch (err) {
539
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
540
- throw err; // Propagate to withReauthRetry for OAuth handling
541
- }
542
- const msg = err instanceof Error ? err.message : String(err);
543
- process.stderr.write(`[kanban-bridge] kanban_update failed for project ${cfg.projectId}: ${msg}\n` +
544
- `${err instanceof Error && err.stack ? err.stack + '\n' : ''}`);
545
- return { content: [{ type: "text", text: `Error updating task: ${msg}` }], details: { isError: true } };
546
- }
547
- },
548
- };
549
- // ---------------------------------------------------------------------------
550
- // Tool: kanban_move
551
- // ---------------------------------------------------------------------------
552
- const kanbanMoveTool = {
553
- name: "kanban_move",
554
- label: "Move Kanban Task",
555
- description: [
556
- "Move a Kanban task between columns.",
557
- "BACKLOG → TODO → IN_PROGRESS → DONE.",
558
- "Moving to DONE automatically sets the completedAt timestamp.",
559
- "Use this to reflect actual progress on the board.",
560
- ].join(" "),
561
- parameters: {
562
- type: "object",
563
- properties: {
564
- taskId: { type: "string", description: "The Kanban task ID" },
565
- targetColumn: { type: "string", description: "Target column: BACKLOG, TODO, IN_PROGRESS, DONE" },
566
- sortOrder: { type: "number", description: "Position within the target column (0 = top). Defaults to 0 (top of column)." },
567
- projectId: { type: "string", description: "Optional: override the default project ID" },
568
- },
569
- required: ["taskId", "targetColumn"],
570
- },
571
- async execute(_toolCallId, params) {
572
- const p = params;
573
- const validColumns = ["BACKLOG", "TODO", "IN_PROGRESS", "DONE"];
574
- if (!validColumns.includes(p.targetColumn)) {
575
- return {
576
- content: [{ type: "text", text: `Invalid column "${p.targetColumn}". Must be one of: ${validColumns.join(", ")}` }],
577
- details: { isError: true },
578
- };
579
- }
580
- const cfg = await resolveKanbanConfig(p.projectId);
581
- if (!cfg) {
582
- return { content: [{ type: "text", text: "Error: Kanban bridge not configured." }], details: { isError: true } };
583
- }
584
- try {
585
- const task = await moveKanbanTask(cfg, p.taskId, p.targetColumn, p.sortOrder ?? 0);
586
- return {
587
- content: [{
588
- type: "text",
589
- text: `✓ Moved "${task.title}" from ${task.column !== p.targetColumn ? "to " : "within "}${p.targetColumn}`,
590
- }],
591
- details: {},
592
- };
593
- }
594
- catch (err) {
595
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
596
- throw err; // Propagate to withReauthRetry for OAuth handling
597
- }
598
- const msg = err instanceof Error ? err.message : String(err);
599
- process.stderr.write(`[kanban-bridge] kanban_move failed for project ${cfg.projectId}: ${msg}\n` +
600
- `${err instanceof Error && err.stack ? err.stack + '\n' : ''}`);
601
- return { content: [{ type: "text", text: `Error moving task: ${msg}` }], details: { isError: true } };
602
- }
603
- },
604
- };
605
- // ---------------------------------------------------------------------------
606
- // Tool: kanban_next — pick next TODO, mark IN_PROGRESS
607
- // ---------------------------------------------------------------------------
608
- const kanbanNextTool = {
609
- name: "kanban_next",
610
- label: "Start Next Kanban Task",
611
- description: [
612
- "Pick the next-highest-priority TODO task, mark it IN_PROGRESS, and return its details.",
613
- "This is the primary task-driven execution tool: call it when you're ready to start working on the next planned task.",
614
- "The agent should call this, work on the task, then call kanban_move to DONE when finished.",
615
- ].join(" "),
616
- parameters: {
617
- type: "object",
618
- properties: {
619
- taskId: { type: "string", description: "Optional: start a specific task by ID instead of auto-selecting the next one." },
620
- projectId: { type: "string", description: "Optional: override the default project ID" },
621
- },
622
- },
623
- async execute(_toolCallId, params) {
624
- const p = params;
625
- const cfg = await resolveKanbanConfig(p.projectId);
626
- if (!cfg) {
627
- return { content: [{ type: "text", text: "Error: Kanban bridge not configured." }], details: { isError: true } };
628
- }
629
- try {
630
- const allTasks = await listKanbanTasks(cfg);
631
- // If a specific task ID is given, start that one
632
- if (p.taskId) {
633
- const task = allTasks.find((t) => t.id === p.taskId);
634
- if (!task) {
635
- return { content: [{ type: "text", text: `Task not found: ${p.taskId}` }], details: { isError: true } };
636
- }
637
- if (task.column !== "TODO" && task.column !== "BACKLOG") {
638
- return {
639
- content: [{ type: "text", text: `Task "${task.title}" is already in ${task.column}. Only TODO or BACKLOG tasks can be started.` }],
640
- details: { isError: true },
641
- };
642
- }
643
- const moved = await moveKanbanTask(cfg, p.taskId, "IN_PROGRESS", 0);
644
- const fullTask = await getKanbanTask(cfg, p.taskId);
645
- const lines = [
646
- `## Started: ${fullTask.title}`,
647
- `ID: ${fullTask.id}`,
648
- `Priority: ${fullTask.priority}`,
649
- `Tags: ${fullTask.tags ?? "(none)"}`,
650
- "",
651
- "### Description",
652
- fullTask.description ?? "(no description)",
653
- "",
654
- "### Instructions",
655
- "1. Read and understand the task description above",
656
- "2. Implement the required changes using your tools (read, write, edit, bash)",
657
- "3. Test your changes when applicable",
658
- `4. When done, call kanban_move to mark this task as DONE`,
659
- "5. Then call kanban_next to start the next task (if any)",
660
- ];
661
- return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
662
- }
663
- // Auto-select: pick the highest-priority TODO task
664
- const todoTasks = allTasks
665
- .filter((t) => t.column === "TODO")
666
- .sort((a, b) => b.priority - a.priority);
667
- if (todoTasks.length === 0) {
668
- // Check BACKLOG as fallback
669
- const backlogTasks = allTasks
670
- .filter((t) => t.column === "BACKLOG")
671
- .sort((a, b) => b.priority - a.priority);
672
- if (backlogTasks.length === 0) {
673
- return {
674
- content: [{ type: "text", text: "No TODO or BACKLOG tasks available. All tasks may be IN_PROGRESS or DONE. Use kanban_list to check the board." }],
675
- details: {},
676
- };
677
- }
678
- // Move the first BACKLOG task to TODO, then start it
679
- const next = backlogTasks[0];
680
- await moveKanbanTask(cfg, next.id, "TODO", 0);
681
- await moveKanbanTask(cfg, next.id, "IN_PROGRESS", 0);
682
- const fullTask = await getKanbanTask(cfg, next.id);
683
- const lines = [
684
- `## Started (from BACKLOG): ${fullTask.title}`,
685
- `ID: ${fullTask.id}`,
686
- `Priority: ${fullTask.priority}`,
687
- `Tags: ${fullTask.tags ?? "(none)"}`,
688
- "",
689
- "### Description",
690
- fullTask.description ?? "(no description)",
691
- "",
692
- `Remaining TODO tasks: ${todoTasks.length}. Backlog: ${backlogTasks.length - 1}.`,
693
- ];
694
- return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
695
- }
696
- // Start the highest-priority TODO
697
- const next = todoTasks[0];
698
- const moved = await moveKanbanTask(cfg, next.id, "IN_PROGRESS", 0);
699
- const fullTask = await getKanbanTask(cfg, next.id);
700
- const lines = [
701
- `## Started: ${fullTask.title}`,
702
- `ID: ${fullTask.id}`,
703
- `Priority: ${fullTask.priority}`,
704
- `Tags: ${fullTask.tags ?? "(none)"}`,
705
- "",
706
- "### Description",
707
- fullTask.description ?? "(no description)",
708
- "",
709
- "### Instructions",
710
- "1. Read and understand the task description above",
711
- "2. Implement the required changes using your tools (read, write, edit, bash)",
712
- "3. Test your changes when applicable",
713
- `4. When done, call kanban_move to mark this task as DONE`,
714
- "5. Then call kanban_next to start the next task (if any)",
715
- "",
716
- `Remaining TODO tasks: ${todoTasks.length - 1}`,
717
- ];
718
- return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
719
- }
720
- catch (err) {
721
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
722
- throw err; // Propagate to withReauthRetry for OAuth handling
723
- }
724
- const msg = err instanceof Error ? err.message : String(err);
725
- process.stderr.write(`[kanban-bridge] kanban_next failed for project ${cfg.projectId}: ${msg}\n` +
726
- `${err instanceof Error && err.stack ? err.stack + '\n' : ''}`);
727
- return { content: [{ type: "text", text: `Error: ${msg}` }], details: { isError: true } };
728
- }
729
- },
730
- };
731
- // ---------------------------------------------------------------------------
732
- // Tool: kanban_delete
733
- // ---------------------------------------------------------------------------
734
- const kanbanDeleteTool = {
735
- name: "kanban_delete",
736
- label: "Delete Kanban Task",
737
- description: "Delete a Kanban task from the board.",
738
- parameters: {
739
- type: "object",
740
- properties: {
741
- taskId: { type: "string", description: "The Kanban task ID to delete" },
742
- projectId: { type: "string", description: "Optional: override the default project ID" },
743
- },
744
- required: ["taskId"],
745
- },
746
- async execute(_toolCallId, params) {
747
- const p = params;
748
- const cfg = await resolveKanbanConfig(p.projectId);
749
- if (!cfg) {
750
- return { content: [{ type: "text", text: "Error: Kanban bridge not configured." }], details: { isError: true } };
751
- }
752
- try {
753
- await deleteKanbanTask(cfg, p.taskId);
754
- return {
755
- content: [{ type: "text", text: `✓ Deleted task ${p.taskId}` }],
756
- details: {},
757
- };
758
- }
759
- catch (err) {
760
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
761
- throw err; // Propagate to withReauthRetry for OAuth handling
762
- }
763
- const msg = err instanceof Error ? err.message : String(err);
764
- process.stderr.write(`[kanban-bridge] kanban_delete failed for project ${cfg.projectId}: ${msg}\n` +
765
- `${err instanceof Error && err.stack ? err.stack + '\n' : ''}`);
766
- return { content: [{ type: "text", text: `Error deleting task: ${msg}` }], details: { isError: true } };
767
- }
768
- },
769
- };
770
- // ---------------------------------------------------------------------------
771
- // Auth retry wrapper — handles 401/403 during tool execution
772
- // ---------------------------------------------------------------------------
773
- /**
774
- * Wrap a tool's execute function so that GraphQL 401/403 errors trigger
775
- * OAuth re-authentication and a single retry with the fresh token.
776
- */
777
- function withReauthRetry(toolName, execute) {
778
- return async (toolCallId, params, signal, onUpdate, ctx) => {
779
- try {
780
- return await execute(toolCallId, params, signal, onUpdate, ctx);
781
- }
782
- catch (err) {
783
- if (err instanceof GraphQLError && (err.status === 401 || err.status === 403)) {
784
- if (_reauthAttempted) {
785
- return {
786
- content: [{ type: "text", text: `Error: Authentication failed (HTTP ${err.status}). Please run \`spectral login\` to re-authenticate.` }],
787
- details: { isError: true },
788
- };
789
- }
790
- _reauthAttempted = true;
791
- process.stderr.write(`[kanban-bridge] Auth error (${err.status}) on ${toolName}. Attempting OAuth re-login...\n`);
792
- const oauthResult = await runOAuthFlow(undefined, undefined, OAUTH_REASON.REAUTH);
793
- if (oauthResult.ok) {
794
- process.stderr.write(`[kanban-bridge] Re-authenticated — retrying ${toolName}...\n`);
795
- // Reset reauth flag so resolveKanbanConfig can use the fresh token
796
- _reauthAttempted = false;
797
- try {
798
- return await execute(toolCallId, params, signal, onUpdate, ctx);
799
- }
800
- catch (retryErr) {
801
- const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
802
- return {
803
- content: [{ type: "text", text: `Retry after re-auth also failed: ${retryMsg}` }],
804
- details: { isError: true },
805
- };
806
- }
807
- }
808
- return {
809
- content: [{ type: "text", text: `Error: Re-authentication failed: ${oauthResult.reason ?? "unknown"}. Please run \`spectral login\` manually.` }],
810
- details: { isError: true },
811
- };
812
- }
813
- const msg = err instanceof Error ? err.message : String(err);
814
- process.stderr.write(`[kanban-bridge] ${toolName} unexpected failure: ${msg}\n` +
815
- `${err instanceof Error && err.stack ? err.stack + "\n" : ""}`);
816
- return {
817
- content: [{ type: "text", text: `Kanban ${toolName} failed: ${msg}` }],
818
- details: { isError: true },
819
- };
820
- }
821
- };
822
- }
823
- // ---------------------------------------------------------------------------
824
- // Extension entry point
825
- // ---------------------------------------------------------------------------
826
- export default async function kanbanBridgeExtension(ext) {
827
- const tools = [
828
- kanbanListTool,
829
- kanbanGetTool,
830
- kanbanCreateTool,
831
- kanbanUpdateTool,
832
- kanbanMoveTool,
833
- kanbanNextTool,
834
- kanbanDeleteTool,
835
- ];
836
- let registered = 0;
837
- for (const tool of tools) {
838
- try {
839
- const wrappedTool = { ...tool, execute: withReauthRetry(tool.name, tool.execute) };
840
- ext.registerTool(wrappedTool);
841
- registered++;
842
- }
843
- catch (err) {
844
- const msg = err instanceof Error ? err.message : String(err);
845
- process.stderr.write(`[kanban-bridge] Failed to register tool "${tool.name}": ${msg}\n`);
846
- }
847
- }
848
- // Verify config is resolvable on startup (non-fatal — tools work lazily too)
849
- ext.on("session_start", async (_event, ctx) => {
850
- const cfg = await resolveKanbanConfig(undefined, ctx.cwd);
851
- if (cfg) {
852
- process.stderr.write(`[kanban-bridge] Registered ${registered} Kanban tool(s). Connected to project ${cfg.projectId}.\n`);
853
- }
854
- else {
855
- process.stderr.write(`[kanban-bridge] Registered ${registered} Kanban tool(s). No project bound yet — tools will work once kanban_project_id or studio binding is available.\n`);
856
- }
857
- });
858
- }