@crowdlisten/harness 1.0.0

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 (109) hide show
  1. package/AGENTS.md +167 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/dist/agent-proxy.d.ts +24 -0
  5. package/dist/agent-proxy.js +140 -0
  6. package/dist/agent-tools.d.ts +736 -0
  7. package/dist/agent-tools.js +409 -0
  8. package/dist/context/api.d.ts +5 -0
  9. package/dist/context/api.js +164 -0
  10. package/dist/context/cli.d.ts +19 -0
  11. package/dist/context/cli.js +108 -0
  12. package/dist/context/extractor.d.ts +12 -0
  13. package/dist/context/extractor.js +43 -0
  14. package/dist/context/index.d.ts +12 -0
  15. package/dist/context/index.js +11 -0
  16. package/dist/context/matcher.d.ts +39 -0
  17. package/dist/context/matcher.js +246 -0
  18. package/dist/context/parser.d.ts +28 -0
  19. package/dist/context/parser.js +157 -0
  20. package/dist/context/pipeline.d.ts +26 -0
  21. package/dist/context/pipeline.js +56 -0
  22. package/dist/context/prompts.d.ts +6 -0
  23. package/dist/context/prompts.js +60 -0
  24. package/dist/context/providers.d.ts +6 -0
  25. package/dist/context/providers.js +106 -0
  26. package/dist/context/redactor.d.ts +10 -0
  27. package/dist/context/redactor.js +68 -0
  28. package/dist/context/server.d.ts +5 -0
  29. package/dist/context/server.js +134 -0
  30. package/dist/context/store.d.ts +12 -0
  31. package/dist/context/store.js +82 -0
  32. package/dist/context/types.d.ts +79 -0
  33. package/dist/context/types.js +4 -0
  34. package/dist/context/user-state.d.ts +40 -0
  35. package/dist/context/user-state.js +144 -0
  36. package/dist/index.d.ts +14 -0
  37. package/dist/index.js +385 -0
  38. package/dist/insights/browser/BrowserPool.d.ts +87 -0
  39. package/dist/insights/browser/BrowserPool.js +266 -0
  40. package/dist/insights/browser/RequestInterceptor.d.ts +46 -0
  41. package/dist/insights/browser/RequestInterceptor.js +115 -0
  42. package/dist/insights/cli.d.ts +8 -0
  43. package/dist/insights/cli.js +206 -0
  44. package/dist/insights/core/base/BaseAdapter.d.ts +37 -0
  45. package/dist/insights/core/base/BaseAdapter.js +123 -0
  46. package/dist/insights/core/health/HealthMonitor.d.ts +75 -0
  47. package/dist/insights/core/health/HealthMonitor.js +171 -0
  48. package/dist/insights/core/interfaces/SocialMediaPlatform.d.ts +125 -0
  49. package/dist/insights/core/interfaces/SocialMediaPlatform.js +42 -0
  50. package/dist/insights/core/utils/DataNormalizer.d.ts +53 -0
  51. package/dist/insights/core/utils/DataNormalizer.js +349 -0
  52. package/dist/insights/core/utils/InstagramUrlUtils.d.ts +11 -0
  53. package/dist/insights/core/utils/InstagramUrlUtils.js +60 -0
  54. package/dist/insights/core/utils/TikTokUrlUtils.d.ts +10 -0
  55. package/dist/insights/core/utils/TikTokUrlUtils.js +57 -0
  56. package/dist/insights/handlers.d.ts +157 -0
  57. package/dist/insights/handlers.js +246 -0
  58. package/dist/insights/index.d.ts +437 -0
  59. package/dist/insights/index.js +426 -0
  60. package/dist/insights/platforms/instagram/InstagramAdapter.d.ts +34 -0
  61. package/dist/insights/platforms/instagram/InstagramAdapter.js +342 -0
  62. package/dist/insights/platforms/moltbook/MoltbookAdapter.d.ts +31 -0
  63. package/dist/insights/platforms/moltbook/MoltbookAdapter.js +227 -0
  64. package/dist/insights/platforms/reddit/RedditAdapter.d.ts +21 -0
  65. package/dist/insights/platforms/reddit/RedditAdapter.js +212 -0
  66. package/dist/insights/platforms/tiktok/TikTokAdapter.d.ts +34 -0
  67. package/dist/insights/platforms/tiktok/TikTokAdapter.js +269 -0
  68. package/dist/insights/platforms/twitter/TwitterAdapter.d.ts +23 -0
  69. package/dist/insights/platforms/twitter/TwitterAdapter.js +211 -0
  70. package/dist/insights/platforms/xiaohongshu/XiaohongshuAdapter.d.ts +35 -0
  71. package/dist/insights/platforms/xiaohongshu/XiaohongshuAdapter.js +258 -0
  72. package/dist/insights/platforms/youtube/YouTubeAdapter.d.ts +22 -0
  73. package/dist/insights/platforms/youtube/YouTubeAdapter.js +254 -0
  74. package/dist/insights/service-config.d.ts +7 -0
  75. package/dist/insights/service-config.js +60 -0
  76. package/dist/insights/services/UnifiedSocialMediaService.d.ts +94 -0
  77. package/dist/insights/services/UnifiedSocialMediaService.js +259 -0
  78. package/dist/insights/vision/VisionExtractor.d.ts +46 -0
  79. package/dist/insights/vision/VisionExtractor.js +236 -0
  80. package/dist/learnings.d.ts +50 -0
  81. package/dist/learnings.js +130 -0
  82. package/dist/openapi.d.ts +29 -0
  83. package/dist/openapi.js +169 -0
  84. package/dist/server-factory.d.ts +20 -0
  85. package/dist/server-factory.js +41 -0
  86. package/dist/suggestions.d.ts +16 -0
  87. package/dist/suggestions.js +72 -0
  88. package/dist/telemetry.d.ts +44 -0
  89. package/dist/telemetry.js +93 -0
  90. package/dist/tools/registry.d.ts +65 -0
  91. package/dist/tools/registry.js +256 -0
  92. package/dist/tools.d.ts +2433 -0
  93. package/dist/tools.js +2294 -0
  94. package/dist/transport/http.d.ts +15 -0
  95. package/dist/transport/http.js +154 -0
  96. package/package.json +76 -0
  97. package/skills/catalog.json +272 -0
  98. package/skills/community-catalog.json +4202 -0
  99. package/skills/competitive-analysis/SKILL.md +174 -0
  100. package/skills/content-creator/SKILL.md +256 -0
  101. package/skills/content-strategy/SKILL.md +222 -0
  102. package/skills/data-storytelling/SKILL.md +248 -0
  103. package/skills/heuristic-evaluation/SKILL.md +201 -0
  104. package/skills/market-research-reports/SKILL.md +184 -0
  105. package/skills/user-stories/SKILL.md +178 -0
  106. package/skills/ux-researcher/SKILL.md +239 -0
  107. package/web-dist/assets/index-B1b25lNd.css +1 -0
  108. package/web-dist/assets/index-CDWHwHbl.js +64 -0
  109. package/web-dist/index.html +16 -0
package/dist/tools.js ADDED
@@ -0,0 +1,2294 @@
1
+ /**
2
+ * CrowdListen Tasks — Planning and Delegation Business Logic
3
+ *
4
+ * All pure functions, tool handlers, and Supabase interaction logic.
5
+ * Routes executable work to coding agents with project context intact.
6
+ * Extracted from index.ts for testability.
7
+ */
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as os from "os";
11
+ import { execSync } from "child_process";
12
+ import { runPipeline } from "./context/pipeline.js";
13
+ import { getBlocks, addBlocks } from "./context/store.js";
14
+ import { matchSkills, discoverSkills, searchSkills, getFullCatalog } from "./context/matcher.js";
15
+ import { loadUserState, saveUserState, activatePack } from "./context/user-state.js";
16
+ import { listPacks, hasPack, getPack, getSkillMdContent, getPackTools } from "./tools/registry.js";
17
+ // logLearning/searchLearnings — kept in learnings.ts but no longer imported (consolidated into save/recall)
18
+ import { AGENT_TOOLS, isAgentTool, handleAgentTool } from "./agent-tools.js";
19
+ // ─── Constants ──────────────────────────────────────────────────────────────
20
+ export const AUTH_DIR = path.join(os.homedir(), ".crowdlisten");
21
+ export const AUTH_FILE = path.join(AUTH_DIR, "auth.json");
22
+ export function loadAuth() {
23
+ try {
24
+ if (!fs.existsSync(AUTH_FILE))
25
+ return null;
26
+ const raw = fs.readFileSync(AUTH_FILE, "utf-8");
27
+ return JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export function saveAuth(auth) {
34
+ fs.mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
35
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
36
+ }
37
+ export function clearAuth() {
38
+ try {
39
+ fs.unlinkSync(AUTH_FILE);
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ }
45
+ // ─── Browser Helper ────────────────────────────────────────────────────────
46
+ export function openBrowser(url) {
47
+ try {
48
+ if (process.platform === "darwin") {
49
+ execSync(`open "${url}"`);
50
+ }
51
+ else if (process.platform === "win32") {
52
+ execSync(`start "" "${url}"`);
53
+ }
54
+ else {
55
+ execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null || echo ""`);
56
+ }
57
+ }
58
+ catch {
59
+ // Silent fail
60
+ }
61
+ }
62
+ /**
63
+ * HTML page shown in the browser after auth callback
64
+ */
65
+ export function callbackHtml(success, error) {
66
+ if (success) {
67
+ return `<!DOCTYPE html>
68
+ <html><head><title>CrowdListen</title>
69
+ <style>
70
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa; }
71
+ .card { text-align: center; padding: 3rem; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); max-width: 400px; }
72
+ .check { font-size: 3rem; margin-bottom: 1rem; }
73
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; color: #111; }
74
+ p { color: #666; font-size: 0.875rem; margin: 0; }
75
+ </style></head>
76
+ <body><div class="card">
77
+ <div class="check">&#10004;</div>
78
+ <h1>You're connected!</h1>
79
+ <p>You can close this tab and go back to your terminal.</p>
80
+ </div></body></html>`;
81
+ }
82
+ return `<!DOCTYPE html>
83
+ <html><head><title>CrowdListen</title>
84
+ <style>
85
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa; }
86
+ .card { text-align: center; padding: 3rem; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); max-width: 400px; }
87
+ .icon { font-size: 3rem; margin-bottom: 1rem; }
88
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; color: #111; }
89
+ p { color: #666; font-size: 0.875rem; margin: 0; }
90
+ </style></head>
91
+ <body><div class="card">
92
+ <div class="icon">&#10060;</div>
93
+ <h1>Login failed</h1>
94
+ <p>${error || "Something went wrong. Please try again."}</p>
95
+ </div></body></html>`;
96
+ }
97
+ // ─── Auto-Install MCP Config ────────────────────────────────────────────────
98
+ export const MCP_ENTRY = {
99
+ command: "npx",
100
+ args: ["-y", "@crowdlisten/harness"],
101
+ };
102
+ export function getAgentConfigs() {
103
+ const home = os.homedir();
104
+ return [
105
+ {
106
+ name: "Claude Code",
107
+ configPath: path.join(home, ".claude.json"),
108
+ mcpKey: "mcpServers",
109
+ },
110
+ {
111
+ name: "Cursor",
112
+ configPath: path.join(home, ".cursor", "mcp.json"),
113
+ mcpKey: "mcpServers",
114
+ },
115
+ {
116
+ name: "Gemini CLI",
117
+ configPath: path.join(home, ".gemini", "settings.json"),
118
+ mcpKey: "mcpServers",
119
+ },
120
+ {
121
+ name: "Codex",
122
+ configPath: path.join(home, ".codex", "config.json"),
123
+ mcpKey: "mcp_servers",
124
+ },
125
+ {
126
+ name: "OpenClaw",
127
+ configPath: path.join(home, ".openclaw", "openclaw.json"),
128
+ mcpKey: "mcpServers",
129
+ },
130
+ ];
131
+ }
132
+ export async function autoInstallMcp() {
133
+ const installed = [];
134
+ for (const agent of getAgentConfigs()) {
135
+ try {
136
+ if (!fs.existsSync(agent.configPath))
137
+ continue;
138
+ let config = {};
139
+ try {
140
+ const raw = fs.readFileSync(agent.configPath, "utf-8");
141
+ config = JSON.parse(raw);
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ const keys = agent.mcpKey.split(".");
147
+ let target = config;
148
+ for (let i = 0; i < keys.length - 1; i++) {
149
+ if (!target[keys[i]] || typeof target[keys[i]] !== "object") {
150
+ target[keys[i]] = {};
151
+ }
152
+ target = target[keys[i]];
153
+ }
154
+ const leafKey = keys[keys.length - 1];
155
+ if (!target[leafKey] || typeof target[leafKey] !== "object") {
156
+ target[leafKey] = {};
157
+ }
158
+ const servers = target[leafKey];
159
+ let changed = false;
160
+ // Unified server replaces the old two-server setup
161
+ if (!servers["crowdlisten"]) {
162
+ servers["crowdlisten"] = { ...MCP_ENTRY };
163
+ changed = true;
164
+ }
165
+ // Clean up old entries if present
166
+ if (servers["crowdlisten/harness"]) {
167
+ delete servers["crowdlisten/harness"];
168
+ changed = true;
169
+ }
170
+ if (servers["crowdlisten/insights"]) {
171
+ delete servers["crowdlisten/insights"];
172
+ changed = true;
173
+ }
174
+ if (!changed)
175
+ continue;
176
+ target[leafKey] = servers;
177
+ fs.writeFileSync(agent.configPath, JSON.stringify(config, null, 2) + "\n");
178
+ installed.push(agent.name);
179
+ }
180
+ catch {
181
+ // Non-fatal
182
+ }
183
+ }
184
+ return installed;
185
+ }
186
+ // ─── Tool Definitions ───────────────────────────────────────────────────────
187
+ export const TOOLS = [
188
+ {
189
+ name: "get_or_create_global_board",
190
+ description: "[Setup] Get (or create) your single global task board. Call once at start of session if you need the board_id. All tasks go here by default.",
191
+ inputSchema: { type: "object", properties: {} },
192
+ },
193
+ {
194
+ name: "list_projects",
195
+ description: "[Setup] List all projects you have access to. Use to find project_id for scoping tasks and context.",
196
+ inputSchema: { type: "object", properties: {} },
197
+ },
198
+ {
199
+ name: "list_boards",
200
+ description: "[Setup] List task boards for a project. Most users have one global board — use get_or_create_global_board instead.",
201
+ inputSchema: {
202
+ type: "object",
203
+ properties: {
204
+ project_id: { type: "string", description: "Project UUID" },
205
+ },
206
+ required: ["project_id"],
207
+ },
208
+ },
209
+ {
210
+ name: "create_board",
211
+ description: "[Setup] Create a new task board for a project with default columns (To Do, In Progress, In Review, Done, Cancelled). Rarely needed — get_or_create_global_board handles this automatically.",
212
+ inputSchema: {
213
+ type: "object",
214
+ properties: {
215
+ project_id: { type: "string", description: "Project UUID" },
216
+ name: { type: "string", description: "Board name (default: 'Tasks')" },
217
+ },
218
+ required: ["project_id"],
219
+ },
220
+ },
221
+ {
222
+ name: "list_tasks",
223
+ description: "List tasks on the board. Call this first to see what work is available. Uses global board by default. Filter by status: todo, inprogress, inreview, done, cancelled.",
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: {
227
+ board_id: { type: "string", description: "Optional: specific board (defaults to global board)" },
228
+ status: { type: "string", description: "Filter by status" },
229
+ limit: { type: "number", description: "Max results (default 50)" },
230
+ },
231
+ },
232
+ },
233
+ {
234
+ name: "get_task",
235
+ description: "Get full details of a task including description, status, priority, and labels.",
236
+ inputSchema: {
237
+ type: "object",
238
+ properties: {
239
+ task_id: { type: "string", description: "Card/task UUID" },
240
+ },
241
+ required: ["task_id"],
242
+ },
243
+ },
244
+ {
245
+ name: "create_task",
246
+ description: "Create a new task on the board. Uses global board by default. Optionally tag with a project_id for scoping.",
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: {
250
+ title: { type: "string", description: "Task title" },
251
+ description: { type: "string", description: "Task description" },
252
+ priority: { type: "string", description: "low, medium, or high" },
253
+ project_id: { type: "string", description: "Optional: tag task with a project" },
254
+ board_id: { type: "string", description: "Optional: specific board (defaults to global board)" },
255
+ labels: {
256
+ type: "array",
257
+ items: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } },
258
+ description: "Label objects [{name, color}]",
259
+ },
260
+ },
261
+ required: ["title"],
262
+ },
263
+ },
264
+ {
265
+ name: "update_task",
266
+ description: "Update a task's title, description, status, or priority. Pass only the fields you want to change.",
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {
270
+ task_id: { type: "string", description: "Card/task UUID" },
271
+ title: { type: "string" },
272
+ description: { type: "string" },
273
+ status: { type: "string", description: "todo, inprogress, inreview, done, cancelled" },
274
+ priority: { type: "string", description: "low, medium, high" },
275
+ },
276
+ required: ["task_id"],
277
+ },
278
+ },
279
+ {
280
+ name: "claim_task",
281
+ description: "Claim a task to start working on it. Call this after list_tasks to begin. Moves task to In Progress, creates workspace + session. Returns context (semantic map, knowledge base, existing plan) and branch name. Call query_context next to check existing decisions.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {
285
+ task_id: { type: "string", description: "Card/task UUID" },
286
+ executor: {
287
+ type: "string",
288
+ description: "Coding agent name: CLAUDE_CODE, CURSOR, GEMINI, CODEX, AMP, OPENCLAW, OPENCODE, COPILOT, DROID, QWEN_CODE",
289
+ },
290
+ branch: { type: "string", description: "Custom branch name (auto-generated if omitted)" },
291
+ },
292
+ required: ["task_id"],
293
+ },
294
+ },
295
+ {
296
+ name: "complete_task",
297
+ description: "Mark task done. Call record_learning before this to capture what you learned. Optionally attach a summary of what was accomplished. Auto-completes the plan if one exists.",
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ task_id: { type: "string", description: "Card/task UUID" },
302
+ summary: { type: "string", description: "Summary of work completed" },
303
+ },
304
+ required: ["task_id"],
305
+ },
306
+ },
307
+ {
308
+ name: "log_progress",
309
+ description: "Log a progress note to the task's execution session. Call periodically during execution to track what you're doing. Useful for handoff between agents.",
310
+ inputSchema: {
311
+ type: "object",
312
+ properties: {
313
+ task_id: { type: "string", description: "Card/task UUID" },
314
+ message: { type: "string", description: "Progress message" },
315
+ session_id: {
316
+ type: "string",
317
+ description: "Optional: specific session UUID (defaults to most recent active session)",
318
+ },
319
+ },
320
+ required: ["task_id", "message"],
321
+ },
322
+ },
323
+ {
324
+ name: "delete_task",
325
+ description: "Permanently delete a task. Cannot be undone.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ task_id: { type: "string", description: "Card/task UUID" },
330
+ },
331
+ required: ["task_id"],
332
+ },
333
+ },
334
+ {
335
+ name: "migrate_to_global_board",
336
+ description: "[Setup] Migrate all tasks from all boards to the global board. Run once to consolidate if you have tasks spread across multiple boards.",
337
+ inputSchema: { type: "object", properties: {} },
338
+ },
339
+ {
340
+ name: "start_session",
341
+ description: "[Advanced] Start a new parallel agent session for a task. Use when multiple agents need to work on different aspects of the same task simultaneously. claim_task already creates one session.",
342
+ inputSchema: {
343
+ type: "object",
344
+ properties: {
345
+ task_id: { type: "string", description: "Card/task UUID" },
346
+ executor: {
347
+ type: "string",
348
+ description: "Agent: CLAUDE_CODE, CURSOR, GEMINI, CODEX, AMP, etc.",
349
+ },
350
+ focus: {
351
+ type: "string",
352
+ description: "What this session will work on (e.g., 'implement auth backend')",
353
+ },
354
+ },
355
+ required: ["task_id", "focus"],
356
+ },
357
+ },
358
+ {
359
+ name: "list_sessions",
360
+ description: "[Advanced] List all agent sessions for a task, showing status and what each is working on. Useful for coordinating parallel agents.",
361
+ inputSchema: {
362
+ type: "object",
363
+ properties: {
364
+ task_id: { type: "string", description: "Card/task UUID" },
365
+ status: {
366
+ type: "string",
367
+ description: "Filter by status: idle, running, completed, failed, stopped",
368
+ },
369
+ },
370
+ required: ["task_id"],
371
+ },
372
+ },
373
+ {
374
+ name: "update_session",
375
+ description: "[Advanced] Update a session's status or focus. Use to mark running/completed/stopped when coordinating parallel agents.",
376
+ inputSchema: {
377
+ type: "object",
378
+ properties: {
379
+ session_id: { type: "string", description: "Session UUID" },
380
+ status: {
381
+ type: "string",
382
+ description: "idle, running, completed, failed, stopped",
383
+ },
384
+ focus: { type: "string", description: "Updated focus description" },
385
+ },
386
+ required: ["session_id"],
387
+ },
388
+ },
389
+ // ── Planning & Context Tools ─────────────────────────────────────────────
390
+ {
391
+ name: "create_plan",
392
+ description: "Create an execution plan for a task. Call after claim_task and query_context. Plans go through draft → review → approved → executing → completed lifecycle. Submit for human review with update_plan(status='review').",
393
+ inputSchema: {
394
+ type: "object",
395
+ properties: {
396
+ task_id: { type: "string", description: "Card/task UUID" },
397
+ approach: { type: "string", description: "How you plan to execute this task" },
398
+ assumptions: {
399
+ type: "array",
400
+ items: { type: "string" },
401
+ description: "Assumptions the plan relies on",
402
+ },
403
+ constraints: {
404
+ type: "array",
405
+ items: { type: "string" },
406
+ description: "Known constraints to work within",
407
+ },
408
+ success_criteria: {
409
+ type: "array",
410
+ items: { type: "string" },
411
+ description: "How to know the task is done correctly",
412
+ },
413
+ risks: {
414
+ type: "array",
415
+ items: { type: "string" },
416
+ description: "Potential risks or blockers",
417
+ },
418
+ estimated_steps: { type: "number", description: "Estimated number of steps" },
419
+ },
420
+ required: ["task_id", "approach"],
421
+ },
422
+ },
423
+ {
424
+ name: "get_plan",
425
+ description: "Get the current plan for a task including version history and any pending human feedback. Check this after human review to see feedback.",
426
+ inputSchema: {
427
+ type: "object",
428
+ properties: {
429
+ task_id: { type: "string", description: "Card/task UUID" },
430
+ },
431
+ required: ["task_id"],
432
+ },
433
+ },
434
+ {
435
+ name: "update_plan",
436
+ description: "Iterate on a plan: update approach, change status, or add human feedback. Set status='review' to submit for review, status='executing' after approval. Content changes archive the current version. Setting feedback auto-reverts status to draft for revision.",
437
+ inputSchema: {
438
+ type: "object",
439
+ properties: {
440
+ plan_id: { type: "string", description: "Plan UUID (from create_plan or get_plan)" },
441
+ approach: { type: "string", description: "Updated approach" },
442
+ status: {
443
+ type: "string",
444
+ description: "draft, review, approved, executing, completed",
445
+ },
446
+ feedback: { type: "string", description: "Human feedback — auto-reverts plan to draft" },
447
+ assumptions: { type: "array", items: { type: "string" } },
448
+ constraints: { type: "array", items: { type: "string" } },
449
+ success_criteria: { type: "array", items: { type: "string" } },
450
+ risks: { type: "array", items: { type: "string" } },
451
+ },
452
+ required: ["plan_id"],
453
+ },
454
+ },
455
+ // query_context, add_context, record_learning → consolidated into save/recall
456
+ // ─── Context Extraction Tools ──────────────────────────────────────────────
457
+ {
458
+ name: "process_transcript",
459
+ description: "[Context] Process text through the context extraction pipeline: PII redaction → LLM extraction → skill matching. Returns extracted context blocks and recommended skills. Requires LLM provider to be configured (run setup-context).",
460
+ inputSchema: {
461
+ type: "object",
462
+ properties: {
463
+ text: {
464
+ type: "string",
465
+ description: "The transcript/chat text to process. PII will be redacted before LLM sees it.",
466
+ },
467
+ source: {
468
+ type: "string",
469
+ description: "Label for the source (e.g. 'slack-export', 'chat-history'). Defaults to 'mcp'.",
470
+ },
471
+ is_chat: {
472
+ type: "boolean",
473
+ description: "Whether the text is chat history (uses 4-type extraction: style/insight/pattern/preference). Default true.",
474
+ },
475
+ },
476
+ required: ["text"],
477
+ },
478
+ },
479
+ {
480
+ name: "get_context_blocks",
481
+ description: "[Context] Retrieve locally-stored context blocks from previous extractions. Blocks are stored in ~/.crowdlisten/context.json.",
482
+ inputSchema: { type: "object", properties: {} },
483
+ },
484
+ {
485
+ name: "recommend_skills",
486
+ description: "[Context] Get CrowdListen skill recommendations based on stored context blocks. Matches block content against the skill catalog using keyword overlap scoring.",
487
+ inputSchema: { type: "object", properties: {} },
488
+ },
489
+ // ─── Skill Discovery Tools ──────────────────────────────────────────────
490
+ {
491
+ name: "discover_skills",
492
+ description: "[Context] Context-driven skill discovery — scores all skills (native CrowdListen + 146 community skills from 4 repos) against your extracted context blocks. Returns ranked skills with install instructions. Optionally process new context text first.",
493
+ inputSchema: {
494
+ type: "object",
495
+ properties: {
496
+ context: {
497
+ type: "string",
498
+ description: "Optional: raw context text to process through extraction pipeline first. If omitted, uses stored blocks.",
499
+ },
500
+ category: {
501
+ type: "string",
502
+ description: "Filter by category: development, data, content, research, automation, design, business, productivity",
503
+ },
504
+ tier: {
505
+ type: "string",
506
+ description: "Filter by tier: crowdlisten (native, need API key) or community (open source)",
507
+ },
508
+ limit: {
509
+ type: "number",
510
+ description: "Max results to return (default 10)",
511
+ },
512
+ },
513
+ },
514
+ },
515
+ {
516
+ name: "search_skills",
517
+ description: "[Context] Text search across all 154 skills (8 native + 146 community). Browse by category or search by name/keyword. Returns matching skills with descriptions and install methods.",
518
+ inputSchema: {
519
+ type: "object",
520
+ properties: {
521
+ query: { type: "string", description: "Search query (name, keyword, or description text)" },
522
+ tier: { type: "string", description: "Filter: crowdlisten or community" },
523
+ category: {
524
+ type: "string",
525
+ description: "Filter: development, data, content, research, automation, design, business, productivity",
526
+ },
527
+ },
528
+ required: ["query"],
529
+ },
530
+ },
531
+ {
532
+ name: "install_skill",
533
+ description: "[Context] Install a skill by ID — copies SKILL.md content to .claude/commands/ for 'copy' type skills, or returns npx/git instructions for other install methods.",
534
+ inputSchema: {
535
+ type: "object",
536
+ properties: {
537
+ skill_id: { type: "string", description: "Skill ID (e.g., 'comm-typescript-expert' or 'competitive-analysis')" },
538
+ target_dir: {
539
+ type: "string",
540
+ description: "Target directory for SKILL.md (default: .claude/commands/)",
541
+ },
542
+ },
543
+ required: ["skill_id"],
544
+ },
545
+ },
546
+ // ─── Core Always-On Tools (Skill Pack Discovery + Memory) ────────────────
547
+ {
548
+ name: "list_skill_packs",
549
+ description: "List all available skill packs with status (active/available). Skill packs group related tools — activate a pack to unlock its tools. Start here to see what capabilities are available.",
550
+ inputSchema: {
551
+ type: "object",
552
+ properties: {
553
+ include_virtual: {
554
+ type: "boolean",
555
+ description: "Include SKILL.md workflow packs (default true)",
556
+ },
557
+ },
558
+ },
559
+ },
560
+ {
561
+ name: "activate_skill_pack",
562
+ description: "Activate a skill pack to unlock its tools. After activation, the new tools appear in tools/list. For SKILL.md packs, returns the full workflow instructions. Call list_skill_packs first to see available packs.",
563
+ inputSchema: {
564
+ type: "object",
565
+ properties: {
566
+ pack_id: {
567
+ type: "string",
568
+ description: "Pack ID to activate (e.g., 'planning', 'social-listening', 'competitive-analysis')",
569
+ },
570
+ },
571
+ required: ["pack_id"],
572
+ },
573
+ },
574
+ {
575
+ name: "save",
576
+ description: "Save context that persists across sessions. Use tags to categorize (e.g. 'decision', 'pattern', 'preference'). Embedding is auto-generated for semantic recall.",
577
+ inputSchema: {
578
+ type: "object",
579
+ properties: {
580
+ title: {
581
+ type: "string",
582
+ description: "Short title",
583
+ },
584
+ content: {
585
+ type: "string",
586
+ description: "The content to remember",
587
+ },
588
+ tags: {
589
+ type: "array",
590
+ items: { type: "string" },
591
+ description: "Freeform tags (e.g. ['decision', 'auth', 'pattern'])",
592
+ },
593
+ project_id: {
594
+ type: "string",
595
+ description: "Optional project scope",
596
+ },
597
+ task_id: {
598
+ type: "string",
599
+ description: "Optional task association",
600
+ },
601
+ confidence: {
602
+ type: "number",
603
+ description: "Confidence 0-1 (default 1.0)",
604
+ },
605
+ },
606
+ required: ["title", "content"],
607
+ },
608
+ },
609
+ {
610
+ name: "recall",
611
+ description: "Search saved memories using semantic similarity. Returns most relevant results ranked by meaning, not just keywords.",
612
+ inputSchema: {
613
+ type: "object",
614
+ properties: {
615
+ search: {
616
+ type: "string",
617
+ description: "Natural language search query",
618
+ },
619
+ tags: {
620
+ type: "array",
621
+ items: { type: "string" },
622
+ description: "Filter by tags",
623
+ },
624
+ project_id: {
625
+ type: "string",
626
+ description: "Filter by project",
627
+ },
628
+ limit: {
629
+ type: "number",
630
+ description: "Max results (default 20)",
631
+ },
632
+ },
633
+ },
634
+ },
635
+ // ── Spec Delivery ─────────────────────────────────────────────────
636
+ {
637
+ name: "get_specs",
638
+ description: "[Specs] List actionable specs generated from crowd feedback analysis. " +
639
+ "These are agent-consumable implementation specs with evidence, acceptance criteria, and priority. " +
640
+ "Filter by status (pending/claimed/in_progress/completed), type, priority, or minimum confidence.",
641
+ inputSchema: {
642
+ type: "object",
643
+ properties: {
644
+ project_id: {
645
+ type: "string",
646
+ description: "Filter by project UUID",
647
+ },
648
+ status: {
649
+ type: "string",
650
+ enum: ["pending", "claimed", "in_progress", "completed", "rejected"],
651
+ description: "Filter by lifecycle status (default: pending)",
652
+ },
653
+ spec_type: {
654
+ type: "string",
655
+ enum: ["feature", "bug_fix", "improvement", "investigation"],
656
+ description: "Filter by spec type",
657
+ },
658
+ min_confidence: {
659
+ type: "number",
660
+ description: "Minimum confidence threshold (0.0-1.0)",
661
+ },
662
+ priority: {
663
+ type: "string",
664
+ enum: ["critical", "high", "medium", "low"],
665
+ description: "Filter by priority level",
666
+ },
667
+ limit: {
668
+ type: "number",
669
+ description: "Max results (default 20)",
670
+ },
671
+ },
672
+ },
673
+ },
674
+ {
675
+ name: "get_spec_detail",
676
+ description: "[Specs] Get full spec details including evidence citations, acceptance criteria, " +
677
+ "and implementation context. Read this before starting implementation.",
678
+ inputSchema: {
679
+ type: "object",
680
+ properties: {
681
+ spec_id: {
682
+ type: "string",
683
+ description: "Spec UUID to retrieve",
684
+ },
685
+ },
686
+ required: ["spec_id"],
687
+ },
688
+ },
689
+ {
690
+ name: "start_spec",
691
+ description: "[Specs] Claim an actionable spec and begin implementation. " +
692
+ "Creates a kanban task from the spec, claims it (moves to In Progress), " +
693
+ "and returns workspace context for the coding agent. " +
694
+ "Composes create_task → claim_task internally.",
695
+ inputSchema: {
696
+ type: "object",
697
+ properties: {
698
+ spec_id: {
699
+ type: "string",
700
+ description: "Spec UUID to start working on",
701
+ },
702
+ executor: {
703
+ type: "string",
704
+ description: "Coding agent type (auto-detected if omitted)",
705
+ },
706
+ },
707
+ required: ["spec_id"],
708
+ },
709
+ },
710
+ // ── Preferences ─────────────────────────────────────────────────
711
+ {
712
+ name: "set_preferences",
713
+ description: "Set user preferences for telemetry, proactive suggestions, and cross-project learnings. " +
714
+ "Telemetry levels: off (no tracking), anonymous (local-only stats), community (anonymous aggregate stats). " +
715
+ "Pass only the fields you want to change.",
716
+ inputSchema: {
717
+ type: "object",
718
+ properties: {
719
+ telemetry: {
720
+ type: "string",
721
+ enum: ["off", "anonymous", "community"],
722
+ description: "Telemetry privacy level",
723
+ },
724
+ proactive_suggestions: {
725
+ type: "boolean",
726
+ description: "Enable/disable proactive skill pack suggestions",
727
+ },
728
+ cross_project_learnings: {
729
+ type: "boolean",
730
+ description: "Enable/disable cross-project learning persistence",
731
+ },
732
+ },
733
+ },
734
+ },
735
+ // log_learning, search_learnings → consolidated into save/recall
736
+ ...AGENT_TOOLS,
737
+ ];
738
+ // ─── Status <-> Column mapping ────────────────────────────────────────────────
739
+ export const STATUS_COLUMN = {
740
+ todo: "To Do",
741
+ inprogress: "In Progress",
742
+ inreview: "In Review",
743
+ done: "Done",
744
+ cancelled: "Cancelled",
745
+ };
746
+ export async function getColumnByStatus(sb, boardId, status) {
747
+ const colName = STATUS_COLUMN[status];
748
+ if (!colName)
749
+ return null;
750
+ const { data } = await sb
751
+ .from("kanban_columns")
752
+ .select("id")
753
+ .eq("board_id", boardId)
754
+ .eq("name", colName)
755
+ .single();
756
+ return data?.id || null;
757
+ }
758
+ export const GLOBAL_BOARD_NAME = "Global Tasks";
759
+ export async function getOrCreateGlobalBoard(sb, userId) {
760
+ // Look for existing global board
761
+ const { data: existing } = await sb
762
+ .from("kanban_boards")
763
+ .select("id, name")
764
+ .eq("user_id", userId)
765
+ .eq("name", GLOBAL_BOARD_NAME)
766
+ .single();
767
+ if (existing) {
768
+ return { id: existing.id, name: existing.name, created: false };
769
+ }
770
+ // Create a "global" project to house the board (required by schema)
771
+ let projectId;
772
+ const { data: globalProject } = await sb
773
+ .from("projects")
774
+ .select("id")
775
+ .eq("user_id", userId)
776
+ .eq("name", "Global Tasks")
777
+ .single();
778
+ if (globalProject) {
779
+ projectId = globalProject.id;
780
+ }
781
+ else {
782
+ const { data: newProject, error: projErr } = await sb
783
+ .from("projects")
784
+ .insert({
785
+ user_id: userId,
786
+ name: "Global Tasks",
787
+ description: "Container for your global task board",
788
+ })
789
+ .select("id")
790
+ .single();
791
+ if (projErr)
792
+ throw new Error(`Failed to create global project: ${projErr.message}`);
793
+ projectId = newProject.id;
794
+ }
795
+ // Create the global board
796
+ const { data: board, error: boardErr } = await sb
797
+ .from("kanban_boards")
798
+ .insert({
799
+ project_id: projectId,
800
+ name: GLOBAL_BOARD_NAME,
801
+ user_id: userId,
802
+ })
803
+ .select("id")
804
+ .single();
805
+ if (boardErr)
806
+ throw new Error(`Failed to create global board: ${boardErr.message}`);
807
+ // Create default columns
808
+ const defaultColumns = ["To Do", "In Progress", "In Review", "Done", "Cancelled"];
809
+ for (let i = 0; i < defaultColumns.length; i++) {
810
+ await sb.from("kanban_columns").insert({
811
+ board_id: board.id,
812
+ name: defaultColumns[i],
813
+ position: i,
814
+ });
815
+ }
816
+ return { id: board.id, name: GLOBAL_BOARD_NAME, created: true };
817
+ }
818
+ // ─── Tool Handlers ──────────────────────────────────────────────────────────
819
+ export async function handleTool(sb, userId, name, args) {
820
+ switch (name) {
821
+ // ── Global Board ─────────────────────────────────────────
822
+ case "get_or_create_global_board": {
823
+ const board = await getOrCreateGlobalBoard(sb, userId);
824
+ return json({
825
+ board_id: board.id,
826
+ name: board.name,
827
+ status: board.created ? "created" : "exists",
828
+ });
829
+ }
830
+ // ── Projects ──────────────────────────────────────────────
831
+ case "list_projects": {
832
+ const { data, error } = await sb
833
+ .from("projects")
834
+ .select("id, name, updated_at")
835
+ .order("updated_at", { ascending: false })
836
+ .limit(20);
837
+ if (error)
838
+ throw new Error(error.message);
839
+ const slim = (data || []).map((p) => ({ id: p.id, name: p.name }));
840
+ return json({ projects: slim, count: slim.length });
841
+ }
842
+ // ── Boards ────────────────────────────────────────────────
843
+ case "list_boards": {
844
+ const { data, error } = await sb
845
+ .from("kanban_boards")
846
+ .select("id, name, description, created_at")
847
+ .eq("project_id", args.project_id)
848
+ .order("created_at", { ascending: false });
849
+ if (error)
850
+ throw new Error(error.message);
851
+ return json({ boards: data, count: data?.length || 0 });
852
+ }
853
+ case "create_board": {
854
+ const projectId = args.project_id;
855
+ const boardName = args.name || "Tasks";
856
+ // Verify project exists
857
+ const { data: project, error: projErr } = await sb
858
+ .from("projects")
859
+ .select("id")
860
+ .eq("id", projectId)
861
+ .single();
862
+ if (projErr || !project)
863
+ throw new Error("Project not found");
864
+ // Create board
865
+ const { data: board, error: boardErr } = await sb
866
+ .from("kanban_boards")
867
+ .insert({
868
+ project_id: projectId,
869
+ name: boardName,
870
+ user_id: userId,
871
+ })
872
+ .select("id")
873
+ .single();
874
+ if (boardErr)
875
+ throw new Error(boardErr.message);
876
+ // Create default columns
877
+ const defaultColumns = ["To Do", "In Progress", "In Review", "Done", "Cancelled"];
878
+ for (let i = 0; i < defaultColumns.length; i++) {
879
+ const { error: colErr } = await sb.from("kanban_columns").insert({
880
+ board_id: board.id,
881
+ name: defaultColumns[i],
882
+ position: i,
883
+ });
884
+ if (colErr)
885
+ throw new Error(`Failed to create column '${defaultColumns[i]}': ${colErr.message}`);
886
+ }
887
+ return json({ board_id: board.id, name: boardName, status: "created", columns: defaultColumns });
888
+ }
889
+ // ── Tasks ─────────────────────────────────────────────────
890
+ case "list_tasks": {
891
+ // Use global board if no board_id specified
892
+ let boardId = args.board_id;
893
+ if (!boardId) {
894
+ const globalBoard = await getOrCreateGlobalBoard(sb, userId);
895
+ boardId = globalBoard.id;
896
+ }
897
+ let query = sb
898
+ .from("kanban_cards")
899
+ .select(`id, title, description, status, priority, labels, due_date, position, created_at, updated_at,
900
+ column:column_id(id, name)`)
901
+ .eq("board_id", boardId)
902
+ .order("position", { ascending: true });
903
+ if (args.status)
904
+ query = query.eq("status", args.status);
905
+ query = query.limit(args.limit || 50);
906
+ const { data, error } = await query;
907
+ if (error)
908
+ throw new Error(error.message);
909
+ return json({ tasks: data, count: data?.length || 0, board_id: boardId });
910
+ }
911
+ case "get_task": {
912
+ const { data, error } = await sb
913
+ .from("kanban_cards")
914
+ .select(`id, title, description, status, priority, labels, due_date, position, created_at, updated_at,
915
+ column:column_id(id, name),
916
+ board:board_id(id, name, project_id)`)
917
+ .eq("id", args.task_id)
918
+ .single();
919
+ if (error)
920
+ throw new Error(error.message);
921
+ return json({ task: data });
922
+ }
923
+ case "create_task": {
924
+ // Use global board if no board_id specified
925
+ let boardId = args.board_id;
926
+ if (!boardId) {
927
+ const globalBoard = await getOrCreateGlobalBoard(sb, userId);
928
+ boardId = globalBoard.id;
929
+ }
930
+ const colId = await getColumnByStatus(sb, boardId, "todo");
931
+ if (!colId)
932
+ throw new Error("Could not find 'To Do' column");
933
+ const { data: last } = await sb
934
+ .from("kanban_cards")
935
+ .select("position")
936
+ .eq("column_id", colId)
937
+ .order("position", { ascending: false })
938
+ .limit(1)
939
+ .single();
940
+ // Add project_id as a label if provided
941
+ const labels = args.labels || [];
942
+ const projectId = args.project_id;
943
+ if (projectId) {
944
+ labels.push({ name: `project:${projectId}`, color: "#6366f1" });
945
+ }
946
+ const { data, error } = await sb
947
+ .from("kanban_cards")
948
+ .insert({
949
+ board_id: boardId,
950
+ column_id: colId,
951
+ user_id: userId,
952
+ title: args.title,
953
+ description: args.description || null,
954
+ priority: args.priority || "medium",
955
+ labels,
956
+ status: "todo",
957
+ position: (last?.position || 0) + 1,
958
+ })
959
+ .select("id")
960
+ .single();
961
+ if (error)
962
+ throw new Error(error.message);
963
+ return json({ task_id: data.id, board_id: boardId, status: "created", project_id: projectId || null });
964
+ }
965
+ case "update_task": {
966
+ const taskId = args.task_id;
967
+ const updates = {};
968
+ if (args.title)
969
+ updates.title = args.title;
970
+ if (args.description !== undefined)
971
+ updates.description = args.description;
972
+ if (args.priority)
973
+ updates.priority = args.priority;
974
+ if (args.status) {
975
+ updates.status = args.status;
976
+ const { data: card } = await sb
977
+ .from("kanban_cards")
978
+ .select("board_id")
979
+ .eq("id", taskId)
980
+ .single();
981
+ if (card) {
982
+ const col = await getColumnByStatus(sb, card.board_id, args.status);
983
+ if (col)
984
+ updates.column_id = col;
985
+ }
986
+ }
987
+ const { data, error } = await sb
988
+ .from("kanban_cards")
989
+ .update(updates)
990
+ .eq("id", taskId)
991
+ .select("id, title, status, priority")
992
+ .single();
993
+ if (error)
994
+ throw new Error(error.message);
995
+ return json({ task: data, status: "updated" });
996
+ }
997
+ // ── Claim (start working) ─────────────────────────────────
998
+ case "claim_task": {
999
+ const taskId = args.task_id;
1000
+ const executor = args.executor || detectExecutor();
1001
+ // Get card
1002
+ const { data: card, error: cardErr } = await sb
1003
+ .from("kanban_cards")
1004
+ .select("id, board_id, title")
1005
+ .eq("id", taskId)
1006
+ .single();
1007
+ if (cardErr || !card)
1008
+ throw new Error(cardErr?.message || "Task not found");
1009
+ // Move to In Progress
1010
+ const col = await getColumnByStatus(sb, card.board_id, "inprogress");
1011
+ if (col) {
1012
+ await sb
1013
+ .from("kanban_cards")
1014
+ .update({ status: "inprogress", column_id: col })
1015
+ .eq("id", taskId);
1016
+ }
1017
+ // Create workspace
1018
+ const branch = args.branch ||
1019
+ `task/${slugify(card.title)}-${taskId.slice(0, 8)}`;
1020
+ const { data: ws, error: wsErr } = await sb
1021
+ .from("kanban_workspaces")
1022
+ .insert({ card_id: taskId, user_id: userId, branch })
1023
+ .select("id")
1024
+ .single();
1025
+ if (wsErr)
1026
+ throw new Error(wsErr.message);
1027
+ // Create session
1028
+ const { data: sess } = await sb
1029
+ .from("kanban_sessions")
1030
+ .insert({ workspace_id: ws.id, user_id: userId, executor })
1031
+ .select("id")
1032
+ .single();
1033
+ // Fetch project context if this board belongs to a project
1034
+ let projectContext = null;
1035
+ let contextEntries = [];
1036
+ let existingPlan = null;
1037
+ try {
1038
+ const { data: board } = await sb
1039
+ .from("kanban_boards")
1040
+ .select("project_id")
1041
+ .eq("id", card.board_id)
1042
+ .single();
1043
+ if (board?.project_id) {
1044
+ projectContext = await buildProjectContextMd(sb, board.project_id);
1045
+ // Fetch relevant context entries (active decisions, constraints, patterns, etc.)
1046
+ const { data: ctx } = await sb
1047
+ .from("planning_context")
1048
+ .select("id, type, title, body, tags, confidence")
1049
+ .eq("user_id", userId)
1050
+ .eq("project_id", board.project_id)
1051
+ .in("status", ["active", "approved", "executing"])
1052
+ .order("updated_at", { ascending: false })
1053
+ .limit(20);
1054
+ if (ctx)
1055
+ contextEntries = ctx;
1056
+ }
1057
+ // Fetch existing plan for this task
1058
+ const { data: plan } = await sb
1059
+ .from("planning_context")
1060
+ .select("id, title, body, metadata, status, version")
1061
+ .eq("task_id", taskId)
1062
+ .eq("type", "plan")
1063
+ .not("status", "in", '("completed","archived","superseded")')
1064
+ .single();
1065
+ if (plan)
1066
+ existingPlan = plan;
1067
+ }
1068
+ catch {
1069
+ // Non-blocking — proceed without context
1070
+ }
1071
+ return json({
1072
+ task_id: taskId,
1073
+ workspace_id: ws.id,
1074
+ session_id: sess?.id,
1075
+ branch,
1076
+ executor,
1077
+ status: "claimed",
1078
+ project_context: projectContext,
1079
+ context_entries: contextEntries,
1080
+ existing_plan: existingPlan,
1081
+ });
1082
+ }
1083
+ // ── Complete ──────────────────────────────────────────────
1084
+ case "complete_task": {
1085
+ const taskId = args.task_id;
1086
+ const summary = args.summary || null;
1087
+ // Move to Done
1088
+ const { data: card } = await sb
1089
+ .from("kanban_cards")
1090
+ .select("board_id")
1091
+ .eq("id", taskId)
1092
+ .single();
1093
+ if (card) {
1094
+ const col = await getColumnByStatus(sb, card.board_id, "done");
1095
+ const updates = { status: "done" };
1096
+ if (col)
1097
+ updates.column_id = col;
1098
+ await sb.from("kanban_cards").update(updates).eq("id", taskId);
1099
+ }
1100
+ // Mark active plan as completed
1101
+ try {
1102
+ await sb
1103
+ .from("planning_context")
1104
+ .update({ status: "completed", updated_at: new Date().toISOString() })
1105
+ .eq("task_id", taskId)
1106
+ .eq("type", "plan")
1107
+ .in("status", ["draft", "review", "approved", "executing"]);
1108
+ }
1109
+ catch {
1110
+ // Non-blocking
1111
+ }
1112
+ // Log summary if provided
1113
+ if (summary) {
1114
+ await logToSession(sb, userId, taskId, summary, true);
1115
+ }
1116
+ return json({ task_id: taskId, status: "done" });
1117
+ }
1118
+ // ── Log Progress ──────────────────────────────────────────
1119
+ case "log_progress": {
1120
+ const taskId = args.task_id;
1121
+ const message = args.message;
1122
+ const sessionId = args.session_id;
1123
+ await logToSession(sb, userId, taskId, message, false, sessionId);
1124
+ return json({ task_id: taskId, session_id: sessionId || null, status: "logged" });
1125
+ }
1126
+ // ── Delete ────────────────────────────────────────────────
1127
+ case "delete_task": {
1128
+ const { error } = await sb
1129
+ .from("kanban_cards")
1130
+ .delete()
1131
+ .eq("id", args.task_id);
1132
+ if (error)
1133
+ throw new Error(error.message);
1134
+ return json({ deleted_task_id: args.task_id, status: "deleted" });
1135
+ }
1136
+ // ── Migration ─────────────────────────────────────────────
1137
+ case "migrate_to_global_board": {
1138
+ // Get or create global board
1139
+ const globalBoard = await getOrCreateGlobalBoard(sb, userId);
1140
+ // Get all tasks from ALL boards (except global board)
1141
+ const { data: allTasks, error: tasksErr } = await sb
1142
+ .from("kanban_cards")
1143
+ .select("id, title, status, board_id")
1144
+ .eq("user_id", userId)
1145
+ .neq("board_id", globalBoard.id);
1146
+ if (tasksErr)
1147
+ throw new Error(tasksErr.message);
1148
+ if (!allTasks || allTasks.length === 0) {
1149
+ return json({ migrated: 0, message: "No tasks to migrate", global_board_id: globalBoard.id });
1150
+ }
1151
+ // Move each task to global board
1152
+ let migrated = 0;
1153
+ for (const task of allTasks) {
1154
+ const colId = await getColumnByStatus(sb, globalBoard.id, task.status || "todo");
1155
+ if (!colId)
1156
+ continue;
1157
+ const { error: updateErr } = await sb
1158
+ .from("kanban_cards")
1159
+ .update({ board_id: globalBoard.id, column_id: colId })
1160
+ .eq("id", task.id);
1161
+ if (!updateErr)
1162
+ migrated++;
1163
+ }
1164
+ return json({
1165
+ migrated,
1166
+ total_found: allTasks.length,
1167
+ global_board_id: globalBoard.id,
1168
+ status: "migration_complete",
1169
+ });
1170
+ }
1171
+ // ── Start Session ─────────────────────────────────────────
1172
+ case "start_session": {
1173
+ const taskId = args.task_id;
1174
+ const executor = args.executor || detectExecutor();
1175
+ const focus = args.focus;
1176
+ // Find existing non-archived workspace for this task
1177
+ const { data: existingWs } = await sb
1178
+ .from("kanban_workspaces")
1179
+ .select("id, branch")
1180
+ .eq("card_id", taskId)
1181
+ .eq("archived", false)
1182
+ .order("created_at", { ascending: false })
1183
+ .limit(1)
1184
+ .single();
1185
+ let workspaceId;
1186
+ let branch;
1187
+ if (existingWs) {
1188
+ workspaceId = existingWs.id;
1189
+ branch = existingWs.branch;
1190
+ }
1191
+ else {
1192
+ const { data: card, error: cardErr } = await sb
1193
+ .from("kanban_cards")
1194
+ .select("id, board_id, title")
1195
+ .eq("id", taskId)
1196
+ .single();
1197
+ if (cardErr || !card)
1198
+ throw new Error(cardErr?.message || "Task not found");
1199
+ const col = await getColumnByStatus(sb, card.board_id, "inprogress");
1200
+ if (col) {
1201
+ await sb
1202
+ .from("kanban_cards")
1203
+ .update({ status: "inprogress", column_id: col })
1204
+ .eq("id", taskId);
1205
+ }
1206
+ branch = `task/${slugify(card.title)}-${taskId.slice(0, 8)}`;
1207
+ const { data: ws, error: wsErr } = await sb
1208
+ .from("kanban_workspaces")
1209
+ .insert({ card_id: taskId, user_id: userId, branch })
1210
+ .select("id")
1211
+ .single();
1212
+ if (wsErr)
1213
+ throw new Error(wsErr.message);
1214
+ workspaceId = ws.id;
1215
+ }
1216
+ const { data: sess, error: sessErr } = await sb
1217
+ .from("kanban_sessions")
1218
+ .insert({
1219
+ workspace_id: workspaceId,
1220
+ user_id: userId,
1221
+ executor,
1222
+ focus,
1223
+ status: "running",
1224
+ started_at: new Date().toISOString(),
1225
+ })
1226
+ .select("id, executor, focus, status, started_at")
1227
+ .single();
1228
+ if (sessErr)
1229
+ throw new Error(sessErr.message);
1230
+ return json({
1231
+ session_id: sess.id,
1232
+ workspace_id: workspaceId,
1233
+ executor: sess.executor,
1234
+ focus: sess.focus,
1235
+ status: sess.status,
1236
+ started_at: sess.started_at,
1237
+ branch,
1238
+ });
1239
+ }
1240
+ // ── List Sessions ─────────────────────────────────────────
1241
+ case "list_sessions": {
1242
+ const taskId = args.task_id;
1243
+ const statusFilter = args.status;
1244
+ const { data: workspaces, error: wsErr } = await sb
1245
+ .from("kanban_workspaces")
1246
+ .select("id, branch, archived, created_at")
1247
+ .eq("card_id", taskId)
1248
+ .order("created_at", { ascending: false });
1249
+ if (wsErr)
1250
+ throw new Error(wsErr.message);
1251
+ if (!workspaces || workspaces.length === 0) {
1252
+ return json({ sessions: [], count: 0, task_id: taskId });
1253
+ }
1254
+ const workspaceIds = workspaces.map((w) => w.id);
1255
+ let sessionQuery = sb
1256
+ .from("kanban_sessions")
1257
+ .select("id, workspace_id, executor, focus, status, started_at, completed_at, created_at")
1258
+ .in("workspace_id", workspaceIds)
1259
+ .order("created_at", { ascending: false });
1260
+ if (statusFilter) {
1261
+ sessionQuery = sessionQuery.eq("status", statusFilter);
1262
+ }
1263
+ const { data: sessions, error: sessErr } = await sessionQuery;
1264
+ if (sessErr)
1265
+ throw new Error(sessErr.message);
1266
+ const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
1267
+ const enrichedSessions = (sessions || []).map((s) => {
1268
+ const ws = workspaceMap.get(s.workspace_id);
1269
+ return {
1270
+ session_id: s.id,
1271
+ workspace_id: s.workspace_id,
1272
+ branch: ws?.branch,
1273
+ workspace_archived: ws?.archived,
1274
+ executor: s.executor,
1275
+ focus: s.focus,
1276
+ status: s.status,
1277
+ started_at: s.started_at,
1278
+ completed_at: s.completed_at,
1279
+ created_at: s.created_at,
1280
+ };
1281
+ });
1282
+ return json({
1283
+ sessions: enrichedSessions,
1284
+ count: enrichedSessions.length,
1285
+ task_id: taskId,
1286
+ });
1287
+ }
1288
+ // ── Update Session ────────────────────────────────────────
1289
+ case "update_session": {
1290
+ const sessionId = args.session_id;
1291
+ const updates = {};
1292
+ if (args.status) {
1293
+ updates.status = args.status;
1294
+ if (args.status === "completed") {
1295
+ updates.completed_at = new Date().toISOString();
1296
+ }
1297
+ }
1298
+ if (args.focus !== undefined) {
1299
+ updates.focus = args.focus;
1300
+ }
1301
+ if (Object.keys(updates).length === 0) {
1302
+ throw new Error("No updates provided. Specify status or focus.");
1303
+ }
1304
+ const { data: sess, error: sessErr } = await sb
1305
+ .from("kanban_sessions")
1306
+ .update(updates)
1307
+ .eq("id", sessionId)
1308
+ .select("id, workspace_id, executor, focus, status, started_at, completed_at")
1309
+ .single();
1310
+ if (sessErr)
1311
+ throw new Error(sessErr.message);
1312
+ return json({
1313
+ session: sess,
1314
+ status: "updated",
1315
+ });
1316
+ }
1317
+ // ── Planning & Context ────────────────────────────────────
1318
+ case "create_plan": {
1319
+ const taskId = args.task_id;
1320
+ const approach = args.approach;
1321
+ // Build metadata from optional structured fields
1322
+ const metadata = {};
1323
+ if (args.assumptions)
1324
+ metadata.assumptions = args.assumptions;
1325
+ if (args.constraints)
1326
+ metadata.constraints = args.constraints;
1327
+ if (args.success_criteria)
1328
+ metadata.success_criteria = args.success_criteria;
1329
+ if (args.risks)
1330
+ metadata.risks = args.risks;
1331
+ if (args.estimated_steps)
1332
+ metadata.estimated_steps = args.estimated_steps;
1333
+ // Look up project_id from the task's board
1334
+ let projectId = null;
1335
+ try {
1336
+ const { data: card } = await sb
1337
+ .from("kanban_cards")
1338
+ .select("board_id")
1339
+ .eq("id", taskId)
1340
+ .single();
1341
+ if (card) {
1342
+ const { data: board } = await sb
1343
+ .from("kanban_boards")
1344
+ .select("project_id")
1345
+ .eq("id", card.board_id)
1346
+ .single();
1347
+ if (board?.project_id)
1348
+ projectId = board.project_id;
1349
+ }
1350
+ }
1351
+ catch {
1352
+ // Non-blocking
1353
+ }
1354
+ const { data: plan, error: planErr } = await sb
1355
+ .from("planning_context")
1356
+ .insert({
1357
+ user_id: userId,
1358
+ project_id: projectId,
1359
+ task_id: taskId,
1360
+ type: "plan",
1361
+ title: `Plan: ${approach.slice(0, 80)}`,
1362
+ body: approach,
1363
+ metadata,
1364
+ status: "draft",
1365
+ source: "agent",
1366
+ source_agent: detectExecutor(),
1367
+ })
1368
+ .select("id, status, version")
1369
+ .single();
1370
+ if (planErr)
1371
+ throw new Error(planErr.message);
1372
+ return json({ plan_id: plan.id, status: plan.status, version: plan.version });
1373
+ }
1374
+ case "get_plan": {
1375
+ const taskId = args.task_id;
1376
+ const { data: plan, error: planErr } = await sb
1377
+ .from("planning_context")
1378
+ .select("id, title, body, metadata, status, version, source, source_agent, confidence, created_at, updated_at")
1379
+ .eq("task_id", taskId)
1380
+ .eq("type", "plan")
1381
+ .not("status", "in", '("completed","archived","superseded")')
1382
+ .single();
1383
+ if (planErr || !plan) {
1384
+ return json({ plan: null, versions: [], message: "No active plan for this task" });
1385
+ }
1386
+ const { data: versions } = await sb
1387
+ .from("planning_context_versions")
1388
+ .select("version, title, body, metadata, status, feedback, created_at")
1389
+ .eq("context_id", plan.id)
1390
+ .order("version", { ascending: false });
1391
+ return json({ plan, versions: versions || [] });
1392
+ }
1393
+ case "update_plan": {
1394
+ const planId = args.plan_id;
1395
+ // Get current plan state
1396
+ const { data: current, error: getErr } = await sb
1397
+ .from("planning_context")
1398
+ .select("id, title, body, metadata, status, version")
1399
+ .eq("id", planId)
1400
+ .single();
1401
+ if (getErr || !current)
1402
+ throw new Error(getErr?.message || "Plan not found");
1403
+ const hasContentChange = args.approach || args.assumptions || args.constraints ||
1404
+ args.success_criteria || args.risks;
1405
+ const hasFeedback = !!args.feedback;
1406
+ // Archive current version if content is changing or feedback given
1407
+ if (hasContentChange || hasFeedback) {
1408
+ await sb.from("planning_context_versions").insert({
1409
+ context_id: planId,
1410
+ version: current.version,
1411
+ title: current.title,
1412
+ body: current.body,
1413
+ metadata: current.metadata,
1414
+ status: current.status,
1415
+ feedback: args.feedback || null,
1416
+ });
1417
+ }
1418
+ // Build updates
1419
+ const updates = {
1420
+ updated_at: new Date().toISOString(),
1421
+ };
1422
+ if (args.approach) {
1423
+ updates.body = args.approach;
1424
+ updates.title = `Plan: ${args.approach.slice(0, 80)}`;
1425
+ }
1426
+ // Merge metadata fields
1427
+ const meta = { ...current.metadata };
1428
+ if (args.assumptions)
1429
+ meta.assumptions = args.assumptions;
1430
+ if (args.constraints)
1431
+ meta.constraints = args.constraints;
1432
+ if (args.success_criteria)
1433
+ meta.success_criteria = args.success_criteria;
1434
+ if (args.risks)
1435
+ meta.risks = args.risks;
1436
+ if (hasContentChange)
1437
+ updates.metadata = meta;
1438
+ // Feedback auto-reverts to draft
1439
+ if (hasFeedback) {
1440
+ updates.status = "draft";
1441
+ const feedbackMeta = { ...meta, feedback: args.feedback };
1442
+ updates.metadata = feedbackMeta;
1443
+ }
1444
+ else if (args.status) {
1445
+ updates.status = args.status;
1446
+ }
1447
+ if (hasContentChange || hasFeedback) {
1448
+ updates.version = current.version + 1;
1449
+ }
1450
+ const { data: updated, error: upErr } = await sb
1451
+ .from("planning_context")
1452
+ .update(updates)
1453
+ .eq("id", planId)
1454
+ .select("id, status, version")
1455
+ .single();
1456
+ if (upErr)
1457
+ throw new Error(upErr.message);
1458
+ return json({ plan_id: updated.id, version: updated.version, status: updated.status });
1459
+ }
1460
+ // query_context, add_context, record_learning → removed (consolidated into save/recall)
1461
+ // ─── Context Extraction Tools ────────────────────────────────────────────
1462
+ case "process_transcript": {
1463
+ const text = args.text;
1464
+ const source = args.source || "mcp";
1465
+ const isChat = args.is_chat !== false;
1466
+ const result = await runPipeline({ text, source, isChat });
1467
+ return json({
1468
+ blocks_extracted: result.blocks.length,
1469
+ blocks: result.blocks,
1470
+ skills: result.skills,
1471
+ redaction_stats: result.redactionStats,
1472
+ total_redactions: result.totalRedactions,
1473
+ chunks_processed: result.chunkCount,
1474
+ });
1475
+ }
1476
+ case "get_context_blocks": {
1477
+ const blocks = getBlocks();
1478
+ return json({ count: blocks.length, blocks });
1479
+ }
1480
+ case "recommend_skills": {
1481
+ const blocks = getBlocks();
1482
+ const skills = await matchSkills(blocks);
1483
+ return json({ skills });
1484
+ }
1485
+ // ─── Skill Discovery Tools ──────────────────────────────────────────────
1486
+ case "discover_skills": {
1487
+ let blocks = getBlocks();
1488
+ // If context text provided, process it first
1489
+ if (args.context) {
1490
+ const result = await runPipeline({
1491
+ text: args.context,
1492
+ source: "discover",
1493
+ isChat: true,
1494
+ });
1495
+ blocks = result.blocks;
1496
+ }
1497
+ if (blocks.length === 0) {
1498
+ return json({
1499
+ error: "No context blocks found. Process a transcript first with process_transcript, or provide context text.",
1500
+ skills: [],
1501
+ });
1502
+ }
1503
+ const skills = await discoverSkills(blocks, {
1504
+ category: args.category,
1505
+ tier: args.tier,
1506
+ limit: args.limit || 10,
1507
+ });
1508
+ return json({
1509
+ total_available: 154,
1510
+ results: skills.length,
1511
+ skills: skills.map((s) => ({
1512
+ id: s.skillId,
1513
+ name: s.name,
1514
+ description: s.description,
1515
+ score: `${Math.round(s.score * 100)}%`,
1516
+ tier: s.tier,
1517
+ category: s.category,
1518
+ install: s.installMethod === "copy" ? `Copy SKILL.md from ${s.installTarget}` : s.installTarget,
1519
+ matched_keywords: s.matchedKeywords,
1520
+ })),
1521
+ });
1522
+ }
1523
+ case "search_skills": {
1524
+ const query = args.query;
1525
+ if (!query)
1526
+ return json({ error: "Missing 'query' parameter" });
1527
+ const results = searchSkills(query, {
1528
+ tier: args.tier,
1529
+ category: args.category,
1530
+ });
1531
+ return json({
1532
+ query,
1533
+ results: results.length,
1534
+ skills: results.map((s) => ({
1535
+ id: s.skillId,
1536
+ name: s.name,
1537
+ description: s.description,
1538
+ score: `${Math.round(s.score * 100)}%`,
1539
+ tier: s.tier,
1540
+ category: s.category,
1541
+ install_method: s.installMethod,
1542
+ install_target: s.installTarget,
1543
+ })),
1544
+ });
1545
+ }
1546
+ case "install_skill": {
1547
+ const skillId = args.skill_id;
1548
+ if (!skillId)
1549
+ return json({ error: "Missing 'skill_id' parameter" });
1550
+ // Search both catalogs for the skill
1551
+ const skill = getFullCatalog().find((s) => s.id === skillId);
1552
+ if (!skill) {
1553
+ return json({ error: `Skill '${skillId}' not found. Use search_skills to find available skills.` });
1554
+ }
1555
+ if (skill.installMethod === "copy") {
1556
+ const targetDir = args.target_dir || path.join(process.cwd(), ".claude", "commands");
1557
+ const skillName = skill.id.replace(/^comm-/, "");
1558
+ // For native CrowdListen skills, the installTarget is a crowdlisten: reference
1559
+ if (skill.installTarget.startsWith("crowdlisten:")) {
1560
+ return json({
1561
+ skill: skill.name,
1562
+ tier: skill.tier,
1563
+ instructions: `This is a native CrowdListen skill. It requires a CROWDLISTEN_API_KEY.`,
1564
+ install: `Add to your .claude/commands/ directory from the crowdlisten_tasks/skills/${skill.id}/ folder.`,
1565
+ });
1566
+ }
1567
+ // Community skill — provide the raw URL
1568
+ return json({
1569
+ skill: skill.name,
1570
+ tier: skill.tier,
1571
+ install_method: "copy",
1572
+ instructions: [
1573
+ `1. Create directory: mkdir -p "${targetDir}"`,
1574
+ `2. Download: curl -o "${path.join(targetDir, skillName + ".md")}" "${skill.installTarget}"`,
1575
+ `3. Or copy the SKILL.md content from: ${skill.installTarget}`,
1576
+ ],
1577
+ source: skill.source,
1578
+ });
1579
+ }
1580
+ return json({
1581
+ skill: skill.name,
1582
+ install_method: skill.installMethod,
1583
+ install_target: skill.installTarget,
1584
+ instructions: skill.installMethod === "npx"
1585
+ ? `Run: npx ${skill.installTarget}`
1586
+ : `Clone: git clone ${skill.installTarget}`,
1587
+ });
1588
+ }
1589
+ // ── Core Always-On Tools ─────────────────────────────────────
1590
+ case "list_skill_packs": {
1591
+ const includeVirtual = args.include_virtual !== false;
1592
+ const state = loadUserState();
1593
+ let packList = listPacks(state.activePacks);
1594
+ if (!includeVirtual) {
1595
+ packList = packList.filter(p => !p.isVirtual);
1596
+ }
1597
+ return json({
1598
+ packs: packList,
1599
+ activePacks: state.activePacks.length,
1600
+ totalPacks: packList.length,
1601
+ hint: "Call activate_skill_pack with a pack_id to unlock its tools.",
1602
+ });
1603
+ }
1604
+ case "activate_skill_pack": {
1605
+ const packId = args.pack_id;
1606
+ if (!packId)
1607
+ return json({ error: "Missing 'pack_id' parameter" });
1608
+ if (!hasPack(packId))
1609
+ return json({ error: `Pack '${packId}' not found. Use list_skill_packs to see available packs.` });
1610
+ const pack = getPack(packId);
1611
+ // Virtual SKILL.md packs — return content instead of activating tools
1612
+ if (pack.isVirtual) {
1613
+ const content = getSkillMdContent(packId);
1614
+ if (!content)
1615
+ return json({ error: `SKILL.md not found for pack '${packId}'` });
1616
+ return json({
1617
+ activated: packId,
1618
+ type: "skill_workflow",
1619
+ name: pack.name,
1620
+ description: pack.description,
1621
+ instructions: content,
1622
+ });
1623
+ }
1624
+ // Regular tool pack — activate and signal tools/list_changed
1625
+ const state = activatePack(packId);
1626
+ const tools = getPackTools(packId).map(t => t.name);
1627
+ // Note: server.sendToolListChanged() is called by the index.ts dispatcher
1628
+ return json({
1629
+ activated: packId,
1630
+ type: "tool_pack",
1631
+ name: pack.name,
1632
+ tools,
1633
+ toolCount: tools.length,
1634
+ totalActivePacks: state.activePacks.length,
1635
+ _needsListChanged: true,
1636
+ });
1637
+ }
1638
+ case "save": {
1639
+ const title = args.title;
1640
+ const content = args.content;
1641
+ if (!title || !content) {
1642
+ return json({ error: "Missing required parameters: title, content" });
1643
+ }
1644
+ const tags = args.tags || [];
1645
+ const projectId = args.project_id || null;
1646
+ const taskId = args.task_id || null;
1647
+ const confidence = args.confidence ?? 1.0;
1648
+ const sourceAgent = detectExecutor();
1649
+ // Primary: write to Supabase memories table
1650
+ let savedToSupabase = false;
1651
+ let memoryId = null;
1652
+ try {
1653
+ const { data: row, error: sbErr } = await sb
1654
+ .from("memories")
1655
+ .insert({
1656
+ user_id: userId,
1657
+ project_id: projectId,
1658
+ task_id: taskId,
1659
+ title,
1660
+ content,
1661
+ tags,
1662
+ source: "agent",
1663
+ source_agent: sourceAgent,
1664
+ confidence,
1665
+ })
1666
+ .select("id")
1667
+ .single();
1668
+ if (sbErr)
1669
+ throw sbErr;
1670
+ savedToSupabase = true;
1671
+ memoryId = row.id;
1672
+ // Fire-and-forget: generate embedding via agent backend
1673
+ const AGENT_BASE = process.env.CROWDLISTEN_AGENT_URL || "https://agent.crowdlisten.com";
1674
+ fetch(`${AGENT_BASE}/agent/v1/content/embed`, {
1675
+ method: "POST",
1676
+ headers: { "Content-Type": "application/json" },
1677
+ body: JSON.stringify({ text: `${title}\n${content}` }),
1678
+ })
1679
+ .then(res => res.ok ? res.json() : null)
1680
+ .then(data => {
1681
+ if (data?.embedding && memoryId) {
1682
+ sb.from("memories")
1683
+ .update({ embedding: data.embedding })
1684
+ .eq("id", memoryId)
1685
+ .then(() => { });
1686
+ }
1687
+ })
1688
+ .catch(() => {
1689
+ // Non-blocking — row exists without embedding, keyword fallback on recall
1690
+ });
1691
+ // Side effect: dual-write to project_insights for frontend visibility
1692
+ const knowledgeTags = ["decision", "pattern", "preference", "learning", "principle"];
1693
+ if (projectId && tags.some(t => knowledgeTags.includes(t))) {
1694
+ try {
1695
+ await sb.from("project_insights").insert({
1696
+ project_id: projectId,
1697
+ user_id: userId,
1698
+ title,
1699
+ content,
1700
+ source: "mcp_save",
1701
+ source_agent: sourceAgent,
1702
+ category: tags.find(t => knowledgeTags.includes(t)) || "insight",
1703
+ });
1704
+ }
1705
+ catch {
1706
+ // Non-blocking
1707
+ }
1708
+ }
1709
+ }
1710
+ catch (err) {
1711
+ console.error(`[save] Supabase write failed: ${err?.message || err}`);
1712
+ // Fallback to local store
1713
+ const block = {
1714
+ type: (tags[0] || "insight"),
1715
+ title,
1716
+ content,
1717
+ source: "save",
1718
+ };
1719
+ addBlocks([block], "save");
1720
+ }
1721
+ return json({ saved: true, id: memoryId, title, tags, supabase: savedToSupabase });
1722
+ }
1723
+ case "recall": {
1724
+ const search = args.search;
1725
+ const tags = args.tags;
1726
+ const projectId = args.project_id;
1727
+ const limit = args.limit || 20;
1728
+ try {
1729
+ // Try semantic search first via agent embedding endpoint
1730
+ const AGENT_BASE = process.env.CROWDLISTEN_AGENT_URL || "https://agent.crowdlisten.com";
1731
+ let usedSemantic = false;
1732
+ if (search) {
1733
+ try {
1734
+ const embedRes = await fetch(`${AGENT_BASE}/agent/v1/content/embed`, {
1735
+ method: "POST",
1736
+ headers: { "Content-Type": "application/json" },
1737
+ body: JSON.stringify({ text: search }),
1738
+ });
1739
+ if (embedRes.ok) {
1740
+ const embedData = await embedRes.json();
1741
+ if (embedData.embedding) {
1742
+ // Use RPC for semantic search
1743
+ const rpcArgs = {
1744
+ p_user_id: userId,
1745
+ p_query_embedding: embedData.embedding,
1746
+ p_match_count: limit,
1747
+ };
1748
+ if (projectId)
1749
+ rpcArgs.p_project_id = projectId;
1750
+ if (tags && tags.length > 0)
1751
+ rpcArgs.p_tags = tags;
1752
+ const { data, error: rpcErr } = await sb.rpc("search_memories", rpcArgs);
1753
+ if (!rpcErr && data && data.length > 0) {
1754
+ usedSemantic = true;
1755
+ return json({
1756
+ memories: data,
1757
+ count: data.length,
1758
+ search_mode: "semantic",
1759
+ });
1760
+ }
1761
+ }
1762
+ }
1763
+ }
1764
+ catch {
1765
+ // Embedding API unavailable — fall through to keyword search
1766
+ }
1767
+ }
1768
+ // Keyword fallback: ILIKE on title/content
1769
+ if (!usedSemantic) {
1770
+ let query = sb
1771
+ .from("memories")
1772
+ .select("id, title, content, tags, source, source_agent, task_id, project_id, confidence, metadata, created_at")
1773
+ .eq("user_id", userId)
1774
+ .order("created_at", { ascending: false })
1775
+ .limit(limit);
1776
+ if (projectId)
1777
+ query = query.eq("project_id", projectId);
1778
+ if (tags && tags.length > 0)
1779
+ query = query.overlaps("tags", tags);
1780
+ if (search)
1781
+ query = query.or(`title.ilike.%${search}%,content.ilike.%${search}%`);
1782
+ const { data, error: sbErr } = await query;
1783
+ if (sbErr)
1784
+ throw sbErr;
1785
+ return json({
1786
+ memories: data || [],
1787
+ count: (data || []).length,
1788
+ search_mode: "keyword",
1789
+ });
1790
+ }
1791
+ }
1792
+ catch {
1793
+ // Fallback to local store
1794
+ let blocks = getBlocks();
1795
+ if (search) {
1796
+ const lower = search.toLowerCase();
1797
+ blocks = blocks.filter(b => b.title.toLowerCase().includes(lower) ||
1798
+ b.content.toLowerCase().includes(lower));
1799
+ }
1800
+ blocks = blocks.slice(-limit);
1801
+ return json({
1802
+ memories: blocks,
1803
+ count: blocks.length,
1804
+ search_mode: "local",
1805
+ });
1806
+ }
1807
+ // Should not reach here, but return empty just in case
1808
+ return json({ memories: [], count: 0, search_mode: "none" });
1809
+ }
1810
+ // ── Spec Delivery ────────────────────────────────────────────────
1811
+ case "get_specs": {
1812
+ const statusFilter = args.status || "pending";
1813
+ const limit = args.limit || 20;
1814
+ let query = sb
1815
+ .from("actionable_specs")
1816
+ .select("id, spec_type, title, objective, priority, confidence, status, project_id, created_at")
1817
+ .eq("user_id", userId);
1818
+ if (args.project_id)
1819
+ query = query.eq("project_id", args.project_id);
1820
+ if (statusFilter)
1821
+ query = query.eq("status", statusFilter);
1822
+ if (args.spec_type)
1823
+ query = query.eq("spec_type", args.spec_type);
1824
+ if (args.min_confidence != null)
1825
+ query = query.gte("confidence", args.min_confidence);
1826
+ if (args.priority)
1827
+ query = query.eq("priority", args.priority);
1828
+ const { data, error } = await query
1829
+ .order("created_at", { ascending: false })
1830
+ .limit(limit);
1831
+ if (error)
1832
+ throw new Error(error.message);
1833
+ return json({ specs: data || [], count: (data || []).length, filters: { status: statusFilter } });
1834
+ }
1835
+ case "get_spec_detail": {
1836
+ const specId = args.spec_id;
1837
+ const { data, error } = await sb
1838
+ .from("actionable_specs")
1839
+ .select("*")
1840
+ .eq("id", specId)
1841
+ .eq("user_id", userId)
1842
+ .single();
1843
+ if (error || !data)
1844
+ throw new Error(error?.message || "Spec not found or access denied");
1845
+ // Format for agent consumption
1846
+ const spec = data;
1847
+ const markdown = [
1848
+ `# ${spec.title}`,
1849
+ `**Type:** ${spec.spec_type} | **Priority:** ${spec.priority} | **Confidence:** ${(spec.confidence * 100).toFixed(0)}%`,
1850
+ "",
1851
+ `## Objective`,
1852
+ spec.objective,
1853
+ "",
1854
+ ];
1855
+ if (spec.description) {
1856
+ markdown.push("## Description", spec.description, "");
1857
+ }
1858
+ const evidence = spec.evidence;
1859
+ if (evidence && evidence.length > 0) {
1860
+ markdown.push("## Evidence");
1861
+ for (const e of evidence) {
1862
+ markdown.push(`- [${e.source}] "${e.excerpt}" (confidence: ${((e.confidence || 0) * 100).toFixed(0)}%)`);
1863
+ }
1864
+ markdown.push("");
1865
+ }
1866
+ const criteria = spec.acceptance_criteria;
1867
+ if (criteria && criteria.length > 0) {
1868
+ markdown.push("## Acceptance Criteria");
1869
+ for (const c of criteria) {
1870
+ markdown.push(`- [ ] ${c}`);
1871
+ }
1872
+ markdown.push("");
1873
+ }
1874
+ return json({
1875
+ spec: data,
1876
+ formatted: markdown.join("\n"),
1877
+ });
1878
+ }
1879
+ case "start_spec": {
1880
+ const specId = args.spec_id;
1881
+ const executor = args.executor || detectExecutor();
1882
+ // 1. Fetch the spec
1883
+ const { data: spec, error: specErr } = await sb
1884
+ .from("actionable_specs")
1885
+ .select("*")
1886
+ .eq("id", specId)
1887
+ .eq("user_id", userId)
1888
+ .single();
1889
+ if (specErr || !spec)
1890
+ throw new Error(specErr?.message || "Spec not found or access denied");
1891
+ if (spec.status !== "pending") {
1892
+ return json({ error: `Spec is already ${spec.status}. Only pending specs can be started.` });
1893
+ }
1894
+ // 2. Create a kanban task from the spec
1895
+ const globalBoard = await getOrCreateGlobalBoard(sb, userId);
1896
+ const todoCol = await getColumnByStatus(sb, globalBoard.id, "todo");
1897
+ if (!todoCol)
1898
+ throw new Error("Could not find 'To Do' column");
1899
+ const { data: lastCard } = await sb
1900
+ .from("kanban_cards")
1901
+ .select("position")
1902
+ .eq("column_id", todoCol)
1903
+ .order("position", { ascending: false })
1904
+ .limit(1)
1905
+ .single();
1906
+ // Build task description from spec
1907
+ const criteria = spec.acceptance_criteria;
1908
+ const evidence = spec.evidence;
1909
+ const taskDesc = [
1910
+ `**Objective:** ${spec.objective}`,
1911
+ spec.description ? `\n${spec.description}` : "",
1912
+ criteria?.length ? `\n**Acceptance Criteria:**\n${criteria.map((c) => `- [ ] ${c}`).join("\n")}` : "",
1913
+ evidence?.length ? `\n**Evidence:**\n${evidence.map((e) => `- [${e.source}] "${e.excerpt}"`).join("\n")}` : "",
1914
+ `\n_Generated from spec ${specId} (${spec.spec_type}, ${spec.priority} priority, ${(spec.confidence * 100).toFixed(0)}% confidence)_`,
1915
+ ].filter(Boolean).join("\n");
1916
+ const labels = [];
1917
+ if (spec.project_id) {
1918
+ labels.push({ name: `project:${spec.project_id}`, color: "#6366f1" });
1919
+ }
1920
+ labels.push({ name: `spec:${spec.spec_type}`, color: "#10b981" });
1921
+ labels.push({ name: `priority:${spec.priority}`, color: spec.priority === "critical" ? "#ef4444" : spec.priority === "high" ? "#f59e0b" : "#6b7280" });
1922
+ const { data: card, error: cardErr } = await sb
1923
+ .from("kanban_cards")
1924
+ .insert({
1925
+ board_id: globalBoard.id,
1926
+ column_id: todoCol,
1927
+ user_id: userId,
1928
+ title: spec.title,
1929
+ description: taskDesc,
1930
+ priority: spec.priority,
1931
+ labels,
1932
+ status: "todo",
1933
+ position: (lastCard?.position || 0) + 1,
1934
+ })
1935
+ .select("id")
1936
+ .single();
1937
+ if (cardErr || !card)
1938
+ throw new Error(cardErr?.message || "Failed to create task from spec");
1939
+ // 3. Claim the task (move to In Progress, create workspace + session)
1940
+ const inProgressCol = await getColumnByStatus(sb, globalBoard.id, "inprogress");
1941
+ if (inProgressCol) {
1942
+ await sb.from("kanban_cards")
1943
+ .update({ status: "inprogress", column_id: inProgressCol })
1944
+ .eq("id", card.id);
1945
+ }
1946
+ const branch = `spec/${slugify(spec.title)}-${card.id.slice(0, 8)}`;
1947
+ const { data: ws } = await sb
1948
+ .from("kanban_workspaces")
1949
+ .insert({ card_id: card.id, user_id: userId, branch })
1950
+ .select("id")
1951
+ .single();
1952
+ const { data: sess } = await sb
1953
+ .from("kanban_sessions")
1954
+ .insert({ workspace_id: ws.id, user_id: userId, executor })
1955
+ .select("id")
1956
+ .single();
1957
+ // 4. Update spec status to claimed
1958
+ await sb.from("actionable_specs")
1959
+ .update({ status: "claimed" })
1960
+ .eq("id", specId);
1961
+ return json({
1962
+ spec_id: specId,
1963
+ task_id: card.id,
1964
+ workspace_id: ws.id,
1965
+ session_id: sess?.id,
1966
+ branch,
1967
+ executor,
1968
+ status: "started",
1969
+ spec: {
1970
+ title: spec.title,
1971
+ spec_type: spec.spec_type,
1972
+ priority: spec.priority,
1973
+ objective: spec.objective,
1974
+ acceptance_criteria: spec.acceptance_criteria,
1975
+ },
1976
+ });
1977
+ }
1978
+ // ── Preferences ─────────────────────────────────────────────────
1979
+ case "set_preferences": {
1980
+ const state = loadUserState();
1981
+ const changed = [];
1982
+ if (args.telemetry !== undefined) {
1983
+ const level = args.telemetry;
1984
+ if (!["off", "anonymous", "community"].includes(level)) {
1985
+ return json({ error: "Invalid telemetry level. Use: off, anonymous, community" });
1986
+ }
1987
+ state.preferences.telemetry = level;
1988
+ changed.push(`telemetry → ${level}`);
1989
+ // Mark telemetry onboarding complete
1990
+ if (!state.onboardingCompleted.includes("telemetry")) {
1991
+ state.onboardingCompleted.push("telemetry");
1992
+ }
1993
+ }
1994
+ if (args.proactive_suggestions !== undefined) {
1995
+ state.preferences.proactiveSuggestions = !!args.proactive_suggestions;
1996
+ changed.push(`proactive_suggestions → ${state.preferences.proactiveSuggestions}`);
1997
+ if (!state.onboardingCompleted.includes("proactive")) {
1998
+ state.onboardingCompleted.push("proactive");
1999
+ }
2000
+ }
2001
+ if (args.cross_project_learnings !== undefined) {
2002
+ state.preferences.crossProjectLearnings = !!args.cross_project_learnings;
2003
+ changed.push(`cross_project_learnings → ${state.preferences.crossProjectLearnings}`);
2004
+ if (!state.onboardingCompleted.includes("learnings")) {
2005
+ state.onboardingCompleted.push("learnings");
2006
+ }
2007
+ }
2008
+ if (changed.length === 0) {
2009
+ return json({ message: "No preferences changed. Pass telemetry, proactive_suggestions, or cross_project_learnings." });
2010
+ }
2011
+ saveUserState(state);
2012
+ return json({
2013
+ updated: changed,
2014
+ preferences: {
2015
+ telemetry: state.preferences.telemetry,
2016
+ proactiveSuggestions: state.preferences.proactiveSuggestions,
2017
+ crossProjectLearnings: state.preferences.crossProjectLearnings,
2018
+ },
2019
+ });
2020
+ }
2021
+ // log_learning, search_learnings → removed (consolidated into save/recall)
2022
+ default: {
2023
+ // Delegate to agent-proxied tools
2024
+ if (isAgentTool(name)) {
2025
+ return handleAgentTool(name, args);
2026
+ }
2027
+ throw new Error(`Unknown tool: ${name}`);
2028
+ }
2029
+ }
2030
+ }
2031
+ // ─── Helpers ────────────────────────────────────────────────────────────────
2032
+ export function json(obj) {
2033
+ return JSON.stringify(obj, null, 2);
2034
+ }
2035
+ export function slugify(text) {
2036
+ return text
2037
+ .toLowerCase()
2038
+ .replace(/[^a-z0-9]+/g, "-")
2039
+ .replace(/^-|-$/g, "")
2040
+ .slice(0, 40);
2041
+ }
2042
+ export function detectExecutor() {
2043
+ if (process.env.OPENCLAW_SESSION || process.env.OPENCLAW_AGENT)
2044
+ return "OPENCLAW";
2045
+ if (process.env.CLAUDE_CODE === "1" || process.env.CLAUDE_SESSION_ID)
2046
+ return "CLAUDE_CODE";
2047
+ if (process.env.CURSOR_SESSION_ID || process.env.CURSOR_TRACE_ID)
2048
+ return "CURSOR";
2049
+ if (process.env.CODEX_SESSION_ID)
2050
+ return "CODEX";
2051
+ if (process.env.GEMINI_CLI)
2052
+ return "GEMINI";
2053
+ if (process.env.AMP_SESSION_ID)
2054
+ return "AMP";
2055
+ try {
2056
+ const ppid = process.ppid;
2057
+ if (ppid) {
2058
+ // Best effort
2059
+ }
2060
+ }
2061
+ catch {
2062
+ // ignore
2063
+ }
2064
+ return "UNKNOWN";
2065
+ }
2066
+ export async function logToSession(sb, userId, taskId, message, complete, sessionId) {
2067
+ let sessId;
2068
+ let wsId;
2069
+ if (sessionId) {
2070
+ const { data: sess } = await sb
2071
+ .from("kanban_sessions")
2072
+ .select("id, workspace_id")
2073
+ .eq("id", sessionId)
2074
+ .single();
2075
+ if (!sess)
2076
+ return;
2077
+ sessId = sess.id;
2078
+ wsId = sess.workspace_id;
2079
+ }
2080
+ else {
2081
+ const { data: ws } = await sb
2082
+ .from("kanban_workspaces")
2083
+ .select("id")
2084
+ .eq("card_id", taskId)
2085
+ .eq("archived", false)
2086
+ .order("created_at", { ascending: false })
2087
+ .limit(1)
2088
+ .single();
2089
+ if (!ws)
2090
+ return;
2091
+ wsId = ws.id;
2092
+ const { data: sess } = await sb
2093
+ .from("kanban_sessions")
2094
+ .select("id")
2095
+ .eq("workspace_id", ws.id)
2096
+ .order("created_at", { ascending: false })
2097
+ .limit(1)
2098
+ .single();
2099
+ if (!sess)
2100
+ return;
2101
+ sessId = sess.id;
2102
+ }
2103
+ const { data: proc } = await sb
2104
+ .from("kanban_execution_processes")
2105
+ .insert({
2106
+ session_id: sessId,
2107
+ run_reason: "codingagent",
2108
+ status: complete ? "completed" : "running",
2109
+ ...(complete ? { completed_at: new Date().toISOString() } : {}),
2110
+ })
2111
+ .select("id")
2112
+ .single();
2113
+ if (!proc)
2114
+ return;
2115
+ await sb.from("kanban_execution_process_logs").insert({
2116
+ execution_process_id: proc.id,
2117
+ log_type: "stdout",
2118
+ output: message,
2119
+ byte_size: Buffer.byteLength(message, "utf-8"),
2120
+ });
2121
+ if (complete) {
2122
+ await sb.from("kanban_coding_agent_turns").insert({
2123
+ execution_process_id: proc.id,
2124
+ summary: message,
2125
+ seen: false,
2126
+ });
2127
+ await sb
2128
+ .from("kanban_workspaces")
2129
+ .update({ archived: true })
2130
+ .eq("id", wsId);
2131
+ }
2132
+ }
2133
+ // ─── Project Context Builder ─────────────────────────────────────────────────
2134
+ /**
2135
+ * Build a project context markdown document from Supabase data.
2136
+ * No LLM call — pure data assembly with ~8K char cap.
2137
+ */
2138
+ export async function buildProjectContextMd(sb, projectId) {
2139
+ const { data: project } = await sb
2140
+ .from("projects")
2141
+ .select("name, description")
2142
+ .eq("id", projectId)
2143
+ .single();
2144
+ if (!project)
2145
+ return null;
2146
+ const sections = [`# Project Context: ${project.name}\n`];
2147
+ if (project.description) {
2148
+ sections.push(`## Overview\n${project.description}\n`);
2149
+ }
2150
+ // PRD (most recent)
2151
+ const { data: prd } = await sb
2152
+ .from("project_documents")
2153
+ .select("content")
2154
+ .eq("project_id", projectId)
2155
+ .eq("doc_type", "prd")
2156
+ .order("updated_at", { ascending: false })
2157
+ .limit(1)
2158
+ .single();
2159
+ if (prd?.content) {
2160
+ const prdText = tiptapToText(prd.content);
2161
+ if (prdText) {
2162
+ const truncated = prdText.length > 4000 ? prdText.slice(0, 4000) + "\n...(truncated)" : prdText;
2163
+ sections.push(`## PRD\n${truncated}\n`);
2164
+ }
2165
+ }
2166
+ // Analyses
2167
+ const { data: analyses } = await sb
2168
+ .from("analyses")
2169
+ .select("question, takeaway, sentiment, themes")
2170
+ .eq("project_id", projectId)
2171
+ .eq("status", "completed")
2172
+ .order("created_at", { ascending: false })
2173
+ .limit(10);
2174
+ if (analyses && analyses.length > 0) {
2175
+ const parts = [`## Research Analyses (${analyses.length})\n`];
2176
+ for (const a of analyses) {
2177
+ let entry = `### ${a.question || "Untitled"}\n`;
2178
+ if (a.takeaway)
2179
+ entry += `${a.takeaway}\n`;
2180
+ const meta = [];
2181
+ if (a.sentiment) {
2182
+ const s = a.sentiment;
2183
+ const sp = Object.entries(s)
2184
+ .filter(([, v]) => v)
2185
+ .map(([k, v]) => `${k}: ${v}%`);
2186
+ if (sp.length)
2187
+ meta.push(`Sentiment: ${sp.join(", ")}`);
2188
+ }
2189
+ if (a.themes && Array.isArray(a.themes)) {
2190
+ const names = a.themes
2191
+ .slice(0, 5)
2192
+ .map((t) => (typeof t === "object" && t !== null && "name" in t ? t.name : String(t)));
2193
+ if (names.length)
2194
+ meta.push(`Themes: ${names.join(", ")}`);
2195
+ }
2196
+ if (meta.length)
2197
+ entry += meta.join(" | ") + "\n";
2198
+ parts.push(entry);
2199
+ }
2200
+ sections.push(parts.join("\n"));
2201
+ }
2202
+ // Documents
2203
+ const { data: docs } = await sb
2204
+ .from("project_documents")
2205
+ .select("title, doc_type, content")
2206
+ .eq("project_id", projectId)
2207
+ .order("position", { ascending: true });
2208
+ if (docs && docs.length > 0) {
2209
+ const parts = [`## Documents (${docs.length})\n`];
2210
+ for (const d of docs) {
2211
+ const text = d.content ? tiptapToText(d.content) : "";
2212
+ const preview = text.length > 500 ? text.slice(0, 500) + "..." : text;
2213
+ parts.push(`### ${d.title || "Untitled"} (${d.doc_type || "unknown"})\n${preview}\n`);
2214
+ }
2215
+ sections.push(parts.join("\n"));
2216
+ }
2217
+ // Insights
2218
+ const { data: insights } = await sb
2219
+ .from("project_insights")
2220
+ .select("title, content")
2221
+ .eq("project_id", projectId)
2222
+ .order("created_at", { ascending: false })
2223
+ .limit(20);
2224
+ if (insights && insights.length > 0) {
2225
+ const parts = [`## Insights (${insights.length})\n`];
2226
+ for (const i of insights) {
2227
+ parts.push(`- **${i.title || "Untitled"}**: ${i.content || ""}\n`);
2228
+ }
2229
+ sections.push(parts.join("\n"));
2230
+ }
2231
+ // Planning context (decisions, patterns, principles, learnings)
2232
+ try {
2233
+ const { data: contextEntries } = await sb
2234
+ .from("planning_context")
2235
+ .select("type, title, body, confidence")
2236
+ .eq("project_id", projectId)
2237
+ .in("status", ["active", "approved"])
2238
+ .in("type", ["decision", "constraint", "preference", "pattern", "learning", "principle"])
2239
+ .order("updated_at", { ascending: false })
2240
+ .limit(15);
2241
+ if (contextEntries && contextEntries.length > 0) {
2242
+ const parts = [`## Knowledge Base (${contextEntries.length})\n`];
2243
+ for (const e of contextEntries) {
2244
+ const conf = e.confidence < 1 ? ` (confidence: ${e.confidence})` : "";
2245
+ const preview = e.body.length > 200 ? e.body.slice(0, 200) + "..." : e.body;
2246
+ parts.push(`- **[${e.type}] ${e.title}**${conf}: ${preview}\n`);
2247
+ }
2248
+ sections.push(parts.join("\n"));
2249
+ }
2250
+ }
2251
+ catch {
2252
+ // Non-blocking — planning_context table may not exist yet
2253
+ }
2254
+ let result = sections.join("\n");
2255
+ if (result.length > 8000) {
2256
+ result = result.slice(0, 7950) + "\n\n...(truncated for length)";
2257
+ }
2258
+ return result;
2259
+ }
2260
+ /**
2261
+ * Simple Tiptap JSON to plain text converter.
2262
+ */
2263
+ function tiptapToText(content) {
2264
+ if (!content || typeof content !== "object")
2265
+ return "";
2266
+ function extract(node) {
2267
+ if (typeof node === "string")
2268
+ return node;
2269
+ if (!node || typeof node !== "object")
2270
+ return "";
2271
+ const n = node;
2272
+ const type = n.type;
2273
+ const children = n.content || [];
2274
+ if (type === "text")
2275
+ return n.text || "";
2276
+ if (type === "heading") {
2277
+ const level = (n.attrs?.level) || 1;
2278
+ return "\n" + "#".repeat(level) + " " + children.map(extract).join("") + "\n";
2279
+ }
2280
+ if (type === "paragraph")
2281
+ return children.map(extract).join("") + "\n";
2282
+ if (type === "bulletList" || type === "orderedList") {
2283
+ return children
2284
+ .map((item, i) => {
2285
+ const prefix = type === "bulletList" ? "- " : `${i + 1}. `;
2286
+ const inner = (item.content || []).map(extract).join("").trim();
2287
+ return prefix + inner;
2288
+ })
2289
+ .join("\n") + "\n";
2290
+ }
2291
+ return children.map(extract).join("");
2292
+ }
2293
+ return extract(content).trim();
2294
+ }