@renatocostaguedesdemorais/devs-loop-mcp 0.1.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.
@@ -0,0 +1,370 @@
1
+ // ============================================================
2
+ // DEVS-LOOP - Smart ClickUp list resolution
3
+ // If the user does not pass --list, discover candidates, suggest
4
+ // the best one, and ask for confirmation before proceeding.
5
+ // ============================================================
6
+
7
+ const readline = require("readline");
8
+ const config = require("./config.cjs");
9
+ const { api } = require("./api.cjs");
10
+
11
+ let cachedLists = null;
12
+
13
+ function normalize(value = "") {
14
+ return String(value)
15
+ .normalize("NFD")
16
+ .replace(/[\u0300-\u036f]/g, "")
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, " ")
19
+ .trim();
20
+ }
21
+
22
+ function formatListPath(list) {
23
+ const folder = list.folder || "Sem pasta";
24
+ return `${list.space} / ${folder} / ${list.name}`;
25
+ }
26
+
27
+ function formatListOption(list, extra = "") {
28
+ const suffix = extra ? ` ${extra}` : "";
29
+ return `${formatListPath(list)} [${list.id}]${suffix}`;
30
+ }
31
+
32
+ async function loadAllLists() {
33
+ if (cachedLists) return cachedLists;
34
+
35
+ const cfg = config.load();
36
+ const spacesRes = await api.get(`/team/${cfg.workspace_id}/space`);
37
+ const spaces = spacesRes.data?.spaces || [];
38
+ const lists = [];
39
+
40
+ for (const space of spaces) {
41
+ const foldersRes = await api.get(`/space/${space.id}/folder`);
42
+ const folders = foldersRes.data?.folders || [];
43
+
44
+ for (const folder of folders) {
45
+ const listsRes = await api.get(`/folder/${folder.id}/list`);
46
+ const folderLists = listsRes.data?.lists || [];
47
+
48
+ for (const list of folderLists) {
49
+ lists.push({
50
+ id: String(list.id),
51
+ name: list.name,
52
+ folder: folder.name,
53
+ space: space.name,
54
+ });
55
+ }
56
+ }
57
+
58
+ const folderlessRes = await api.get(`/space/${space.id}/list`);
59
+ const folderlessLists = folderlessRes.data?.lists || [];
60
+
61
+ for (const list of folderlessLists) {
62
+ lists.push({
63
+ id: String(list.id),
64
+ name: list.name,
65
+ folder: null,
66
+ space: space.name,
67
+ });
68
+ }
69
+ }
70
+
71
+ cachedLists = lists;
72
+ return lists;
73
+ }
74
+
75
+ async function getListById(listId) {
76
+ const lists = await loadAllLists();
77
+ return lists.find((item) => String(item.id) === String(listId)) || null;
78
+ }
79
+
80
+ function scoreCandidate(project, list) {
81
+ const projectNorm = normalize(project);
82
+ const listNorm = normalize(list.name);
83
+ const folderNorm = normalize(list.folder || "");
84
+ const spaceNorm = normalize(list.space || "");
85
+ let score = 0;
86
+ const reasons = [];
87
+
88
+ if (!projectNorm) return { score, reasons };
89
+
90
+ if (listNorm === projectNorm) {
91
+ score += 160;
92
+ reasons.push("nome exato");
93
+ }
94
+
95
+ if (listNorm.startsWith(projectNorm) && listNorm !== projectNorm) {
96
+ score += 120;
97
+ reasons.push("nome comeca pelo projeto");
98
+ }
99
+
100
+ if (listNorm.includes(projectNorm) && !reasons.includes("nome comeca pelo projeto")) {
101
+ score += 90;
102
+ reasons.push("nome contem projeto");
103
+ }
104
+
105
+ if (projectNorm.includes(listNorm) && listNorm.length >= 4 && listNorm !== projectNorm) {
106
+ score += 35;
107
+ reasons.push("nome da lista contido no projeto");
108
+ }
109
+
110
+ if (folderNorm.includes(projectNorm) && projectNorm.length >= 3) {
111
+ score += 30;
112
+ reasons.push("pasta contem projeto");
113
+ }
114
+
115
+ if (spaceNorm.includes(projectNorm) && projectNorm.length >= 3) {
116
+ score += 12;
117
+ reasons.push("espaco contem projeto");
118
+ }
119
+
120
+ if (folderNorm === "produtos") {
121
+ score += 8;
122
+ reasons.push("pasta Produtos");
123
+ }
124
+
125
+ if (folderNorm === "modelo") {
126
+ score -= 30;
127
+ reasons.push("penalidade pasta Modelo");
128
+ }
129
+
130
+ return { score, reasons };
131
+ }
132
+
133
+ function mergeCandidate(candidateMap, list, patch = {}) {
134
+ if (!list) return;
135
+
136
+ const existing = candidateMap.get(list.id) || {
137
+ id: list.id,
138
+ name: list.name,
139
+ folder: list.folder,
140
+ space: list.space,
141
+ score: 0,
142
+ reasons: [],
143
+ tags: [],
144
+ recommended: false,
145
+ source: "inference",
146
+ };
147
+
148
+ existing.score += patch.score || 0;
149
+ existing.reasons = Array.from(new Set([...existing.reasons, ...(patch.reasons || [])]));
150
+ existing.tags = Array.from(new Set([...existing.tags, ...(patch.tags || [])]));
151
+ existing.recommended = existing.recommended || !!patch.recommended;
152
+ if (existing.recommended && existing.source === "project_config" && patch.source === "inference") {
153
+ existing.source = "project_config";
154
+ } else {
155
+ existing.source = patch.source || existing.source;
156
+ }
157
+
158
+ candidateMap.set(list.id, existing);
159
+ }
160
+
161
+ async function buildCandidates(project) {
162
+ const lists = await loadAllLists();
163
+ const candidateMap = new Map();
164
+
165
+ const projectListId = config.resolveProjectList(project);
166
+ if (projectListId) {
167
+ const mappedList = await getListById(projectListId);
168
+ mergeCandidate(candidateMap, mappedList, {
169
+ score: 500,
170
+ reasons: ["mapeada para o projeto em config"],
171
+ tags: ["recomendada", "config"],
172
+ recommended: true,
173
+ source: "project_config",
174
+ });
175
+ }
176
+
177
+ for (const list of lists) {
178
+ const ranking = scoreCandidate(project, list);
179
+ if (ranking.score <= 0) continue;
180
+
181
+ mergeCandidate(candidateMap, list, {
182
+ score: ranking.score,
183
+ reasons: ranking.reasons,
184
+ source: "inference",
185
+ });
186
+ }
187
+
188
+ return Array.from(candidateMap.values()).sort(
189
+ (a, b) => b.score - a.score || a.name.localeCompare(b.name)
190
+ );
191
+ }
192
+
193
+ function pickRecommended(candidates) {
194
+ if (candidates.length === 0) return null;
195
+
196
+ const explicitRecommendation = candidates.find((item) => item.recommended);
197
+ if (explicitRecommendation) return explicitRecommendation;
198
+
199
+ const top = candidates[0];
200
+ const second = candidates[1];
201
+ const gap = second ? top.score - second.score : top.score;
202
+
203
+ if (top.score >= 120) return top;
204
+ if (top.score >= 90 && gap >= 20) return top;
205
+ if (top.score >= 70 && !second) return top;
206
+
207
+ return top;
208
+ }
209
+
210
+ function askQuestion(prompt) {
211
+ return new Promise((resolve) => {
212
+ const rl = readline.createInterface({
213
+ input: process.stdin,
214
+ output: process.stdout,
215
+ });
216
+
217
+ rl.question(prompt, (answer) => {
218
+ rl.close();
219
+ resolve(answer.trim());
220
+ });
221
+ });
222
+ }
223
+
224
+ async function promptForList(project, candidates, defaultList) {
225
+ const recommended = pickRecommended(candidates);
226
+ const options = candidates.slice(0, 8);
227
+
228
+ console.log("");
229
+ console.log(`Listas encontradas para "${project}":`);
230
+
231
+ if (recommended) {
232
+ console.log(`Sugestao: ${formatListOption(recommended, "[recomendada]")}`);
233
+ } else if (defaultList) {
234
+ console.log(`Sugestao fraca: ${formatListOption(defaultList, "[padrao configurada]")}`);
235
+ } else {
236
+ console.log("Nao encontrei uma sugestao forte o suficiente.");
237
+ }
238
+
239
+ if (options.length > 0) {
240
+ console.log("Opcoes:");
241
+ for (let index = 0; index < options.length; index += 1) {
242
+ const option = options[index];
243
+ const badges = [];
244
+ if (recommended && option.id === recommended.id) badges.push("recomendada");
245
+ for (const tag of option.tags || []) {
246
+ if (!badges.includes(tag)) badges.push(tag);
247
+ }
248
+ const reasons = option.reasons.length > 0 ? ` - ${option.reasons.join(", ")}` : "";
249
+ const badgeText = badges.length > 0 ? ` [${badges.join(", ")}]` : "";
250
+ console.log(` ${index + 1}. ${formatListPath(option)} [${option.id}]${badgeText}${reasons}`);
251
+ }
252
+ }
253
+
254
+ if (defaultList && !options.some((item) => item.id === defaultList.id)) {
255
+ console.log(` D. ${formatListOption(defaultList, "[padrao configurada]")}`);
256
+ }
257
+
258
+ console.log(" X. Cancelar");
259
+ console.log("");
260
+
261
+ while (true) {
262
+ const answer = (await askQuestion("Confirme a lista desejada (numero, Enter, D ou X): ")).toLowerCase();
263
+
264
+ if (answer === "" && recommended) {
265
+ return {
266
+ listId: recommended.id,
267
+ source: "prompt_recommended",
268
+ list: recommended,
269
+ };
270
+ }
271
+
272
+ if (answer === "x") {
273
+ throw new Error("Criacao cancelada. Informe --list manualmente ou confirme uma lista.");
274
+ }
275
+
276
+ if (answer === "d" && defaultList) {
277
+ return {
278
+ listId: defaultList.id,
279
+ source: "prompt_default",
280
+ list: defaultList,
281
+ };
282
+ }
283
+
284
+ const selectedIndex = Number(answer);
285
+ if (Number.isInteger(selectedIndex) && selectedIndex >= 1 && selectedIndex <= options.length) {
286
+ const selected = options[selectedIndex - 1];
287
+ return {
288
+ listId: selected.id,
289
+ source: "prompt",
290
+ list: selected,
291
+ };
292
+ }
293
+
294
+ console.log("Entrada invalida. Use Enter para aceitar a recomendada, escolha um numero, D ou X.");
295
+ }
296
+ }
297
+
298
+ function isInteractivePromptAvailable(allowPrompt) {
299
+ return allowPrompt && process.stdin.isTTY && process.stdout.isTTY;
300
+ }
301
+
302
+ function buildAmbiguousError(project, candidates, defaultList) {
303
+ const candidateLines = candidates
304
+ .slice(0, 5)
305
+ .map((item) => ` - ${formatListOption(item)} (score ${item.score})`)
306
+ .join("\n");
307
+
308
+ const defaultLine = defaultList
309
+ ? `\nLista padrao atual: ${formatListOption(defaultList)}`
310
+ : "";
311
+
312
+ return (
313
+ `Lista nao confirmada para o projeto "${project}". Rode em terminal interativo ou informe --list.` +
314
+ (candidateLines ? `\nCandidatas encontradas:\n${candidateLines}` : "") +
315
+ defaultLine
316
+ );
317
+ }
318
+
319
+ async function resolveList({
320
+ project,
321
+ explicitListId,
322
+ sessionListId,
323
+ allowPrompt = true,
324
+ }) {
325
+ if (explicitListId) {
326
+ const explicitList = await getListById(explicitListId);
327
+ return {
328
+ listId: String(explicitListId),
329
+ source: "argument",
330
+ list: explicitList,
331
+ };
332
+ }
333
+
334
+ if (sessionListId) {
335
+ const sessionList = await getListById(sessionListId);
336
+ return {
337
+ listId: String(sessionListId),
338
+ source: "session",
339
+ list: sessionList,
340
+ };
341
+ }
342
+
343
+ const candidates = await buildCandidates(project);
344
+ const defaultListId = config.get("default_list");
345
+ const defaultList = defaultListId ? await getListById(defaultListId) : null;
346
+
347
+ if (isInteractivePromptAvailable(allowPrompt)) {
348
+ return promptForList(project, candidates, defaultList);
349
+ }
350
+
351
+ const recommended = pickRecommended(candidates);
352
+ if (recommended && recommended.recommended) {
353
+ return {
354
+ listId: recommended.id,
355
+ source: recommended.source,
356
+ list: recommended,
357
+ };
358
+ }
359
+
360
+ throw new Error(buildAmbiguousError(project, candidates, defaultList));
361
+ }
362
+
363
+ module.exports = {
364
+ buildCandidates,
365
+ formatListPath,
366
+ getListById,
367
+ loadAllLists,
368
+ pickRecommended,
369
+ resolveList,
370
+ };
package/lib/paths.cjs ADDED
@@ -0,0 +1,37 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+
5
+ function getPackageRoot() {
6
+ return path.join(__dirname, "..");
7
+ }
8
+
9
+ function findProjectRoot(startDir = process.cwd()) {
10
+ let dir = startDir;
11
+ while (dir !== path.dirname(dir)) {
12
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
13
+ dir = path.dirname(dir);
14
+ }
15
+ return startDir;
16
+ }
17
+
18
+ function getProjectConfigDir() {
19
+ return path.join(findProjectRoot(), ".devs-loop");
20
+ }
21
+
22
+ function getHomeConfigDir() {
23
+ return path.join(os.homedir(), ".devs-loop");
24
+ }
25
+
26
+ function ensureDir(dirPath) {
27
+ fs.mkdirSync(dirPath, { recursive: true });
28
+ return dirPath;
29
+ }
30
+
31
+ module.exports = {
32
+ ensureDir,
33
+ findProjectRoot,
34
+ getHomeConfigDir,
35
+ getPackageRoot,
36
+ getProjectConfigDir,
37
+ };
@@ -0,0 +1,157 @@
1
+ // ============================================================
2
+ // DEVS-LOOP - Cross-session progress log
3
+ // Append-only markdown log to preserve human and agent context
4
+ // across sessions and context resets.
5
+ // ============================================================
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const { findProjectRoot } = require("./paths.cjs");
10
+
11
+ function getProgressPath() {
12
+ return path.join(findProjectRoot(), "devs-loop-progress.md");
13
+ }
14
+
15
+ function ensureFile() {
16
+ const progressPath = getProgressPath();
17
+ if (!fs.existsSync(progressPath)) {
18
+ fs.writeFileSync(progressPath, "", "utf8");
19
+ }
20
+ return progressPath;
21
+ }
22
+
23
+ function formatMinutes(totalMinutes = 0) {
24
+ if (!totalMinutes || totalMinutes <= 0) return "0min";
25
+ const hours = Math.floor(totalMinutes / 60);
26
+ const minutes = totalMinutes % 60;
27
+ if (hours === 0) return `${minutes}min`;
28
+ if (minutes === 0) return `${hours}h`;
29
+ return `${hours}h ${minutes}min`;
30
+ }
31
+
32
+ function parseTaskLine(line) {
33
+ const match = line.match(/^- (✅|⏳) ([^:]+): (.+?)(?: \((.+)\))?$/);
34
+ if (!match) return null;
35
+ return {
36
+ completed: match[1] === "✅",
37
+ type: match[2].trim(),
38
+ name: match[3].trim(),
39
+ duration: match[4] || null,
40
+ };
41
+ }
42
+
43
+ function parseSessions(content) {
44
+ const lines = content.split(/\r?\n/);
45
+ const sessions = [];
46
+ let current = null;
47
+
48
+ for (const line of lines) {
49
+ if (line.startsWith("## Sessão ")) {
50
+ if (current) sessions.push(current);
51
+
52
+ const heading = line.match(/^## Sessão (.+?) \| (.+?) \| (.+)$/);
53
+ current = {
54
+ heading: line,
55
+ date: heading?.[1] || "",
56
+ project: heading?.[2] || "",
57
+ initiative: heading?.[3] || "",
58
+ tasks: [],
59
+ raw: [line],
60
+ };
61
+ continue;
62
+ }
63
+
64
+ if (!current) continue;
65
+
66
+ current.raw.push(line);
67
+
68
+ if (line.startsWith("- ")) {
69
+ const task = parseTaskLine(line);
70
+ if (task) current.tasks.push(task);
71
+ }
72
+ }
73
+
74
+ if (current) sessions.push(current);
75
+ return sessions;
76
+ }
77
+
78
+ function normalize(value = "") {
79
+ return String(value)
80
+ .normalize("NFD")
81
+ .replace(/[\u0300-\u036f]/g, "")
82
+ .toLowerCase()
83
+ .trim();
84
+ }
85
+
86
+ function loadRecentSessions(project, initiative, limit = 2) {
87
+ const progressPath = getProgressPath();
88
+ if (!fs.existsSync(progressPath)) return [];
89
+
90
+ const content = fs.readFileSync(progressPath, "utf8");
91
+ const sessions = parseSessions(content);
92
+ const normalizedProject = normalize(project);
93
+ const normalizedInitiative = normalize(initiative);
94
+
95
+ const filtered = sessions.filter((session) => normalize(session.project) === normalizedProject);
96
+ const exactInitiative = normalizedInitiative
97
+ ? filtered.filter((session) => normalize(session.initiative) === normalizedInitiative)
98
+ : [];
99
+
100
+ const pool = exactInitiative.length > 0 ? exactInitiative : filtered;
101
+ return pool.slice(-limit);
102
+ }
103
+
104
+ function formatRecentSessions(sessions) {
105
+ if (!sessions || sessions.length === 0) return null;
106
+
107
+ const lines = ["📚 Progresso recente encontrado:"];
108
+
109
+ for (const session of sessions) {
110
+ lines.push(`- ${session.date} | ${session.project} | ${session.initiative}`);
111
+ for (const task of session.tasks.slice(0, 6)) {
112
+ const duration = task.duration ? ` (${task.duration})` : "";
113
+ const icon = task.completed ? "✅" : "⏳";
114
+ lines.push(` ${icon} ${task.type}: ${task.name}${duration}`);
115
+ }
116
+ }
117
+
118
+ return lines.join("\n");
119
+ }
120
+
121
+ function appendSession(summary) {
122
+ const progressPath = ensureFile();
123
+ const lines = [];
124
+
125
+ lines.push(`## Sessão ${summary.date} | ${summary.project} | ${summary.initiative}`);
126
+
127
+ if (summary.listPath) {
128
+ lines.push(`Lista: ${summary.listPath}`);
129
+ }
130
+
131
+ lines.push(`Tempo total: ${summary.totalTime}`);
132
+
133
+ for (const task of summary.tasks || []) {
134
+ const icon = task.completed ? "✅" : "⏳";
135
+ const taskType = task.type || "Task";
136
+ const duration = task.minutes > 0 ? ` (${formatMinutes(task.minutes)})` : "";
137
+ lines.push(`- ${icon} ${taskType}: ${task.name}${duration}`);
138
+ }
139
+
140
+ lines.push("");
141
+
142
+ let prefix = "";
143
+ const existing = fs.readFileSync(progressPath, "utf8");
144
+ if (existing.trim().length > 0 && !existing.endsWith("\n\n")) {
145
+ prefix = existing.endsWith("\n") ? "\n" : "\n\n";
146
+ }
147
+
148
+ fs.appendFileSync(progressPath, `${prefix}${lines.join("\n")}`, "utf8");
149
+ }
150
+
151
+ module.exports = {
152
+ appendSession,
153
+ formatMinutes,
154
+ formatRecentSessions,
155
+ getProgressPath,
156
+ loadRecentSessions,
157
+ };