wendkeep 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,536 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
3
+ import { basename, join } from 'path';
4
+ import {
5
+ datedFolderRelFromDateStr,
6
+ derivedContentKey,
7
+ ensureDir,
8
+ getNextAdrNumber,
9
+ keysBate,
10
+ providerMeta,
11
+ slugify,
12
+ toVaultRelative,
13
+ wikilinkFromRel,
14
+ } from './obsidian-common.mjs';
15
+
16
+ function yamlQuote(value) {
17
+ return `"${String(value || '').replaceAll('"', '\\"')}"`;
18
+ }
19
+
20
+ function uniqueTags(tags) {
21
+ return [...new Set(tags.filter(Boolean).map((tag) => slugify(tag, 'tag')))];
22
+ }
23
+
24
+ function assistantText(tx) {
25
+ return (tx.assistantMessages || []).join('\n');
26
+ }
27
+
28
+ function firstUserPrompt(tx) {
29
+ return (tx.userPrompts || [])[0] || tx.latestUserPrompt || '';
30
+ }
31
+
32
+ function normalizeInline(text, max = 0) {
33
+ const clean = String(text || '').replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
34
+ return max && clean.length > max ? `${clean.slice(0, max).trim()}...` : clean;
35
+ }
36
+
37
+ function adrFileExistsBySlug(dir, slug) {
38
+ try {
39
+ return readdirSync(dir).find((file) => /^ADR-\d{3}-.+\.md$/i.test(file) && file.includes(`-${slug}`));
40
+ } catch {
41
+ return '';
42
+ }
43
+ }
44
+
45
+ function markdownList(items, fallback) {
46
+ return items.length ? items.map((item) => `- ${item}`).join('\n') : fallback;
47
+ }
48
+
49
+ function yamlTags(tags) {
50
+ return uniqueTags(tags).map((tag) => ` - ${tag}`).join('\n');
51
+ }
52
+
53
+ function sessionYamlLinks(sessionRel) {
54
+ const link = wikilinkFromRel(sessionRel);
55
+ return [
56
+ 'source:',
57
+ ` - ${yamlQuote(link)}`,
58
+ 'related:',
59
+ ` - ${yamlQuote(link)}`,
60
+ ].join('\n');
61
+ }
62
+
63
+ function extractIssueRefs(tx) {
64
+ const text = [
65
+ assistantText(tx),
66
+ firstUserPrompt(tx),
67
+ tx.rawTextForDetection || '',
68
+ ].join('\n');
69
+ return [...new Set((text.match(/\bNUT-\d+\b/gi) || []).map((ref) => ref.toUpperCase()))];
70
+ }
71
+
72
+ export function extractBugDetails(tx) {
73
+ const allContent = assistantText(tx);
74
+ const hasFixCommit = (tx.assistantMessages || []).some((message) => /git commit[^\"]*"fix\(/i.test(message));
75
+ const hasFixMention = /(?:commit|commitar)[\s:*]*`?fix\(/i.test(allContent);
76
+ const hasFixPattern = /\*\*Fix\s+\d+\s*[—–-]/i.test(allContent);
77
+ const hasRootCause = /\*?\*?(?:causa[- ]?raiz|root cause)\*?\*?\s*:/i.test(allContent);
78
+
79
+ if (!hasFixCommit && !(hasFixMention && hasRootCause) && !(hasFixPattern && hasRootCause)) return null;
80
+
81
+ let match;
82
+ let rootCause = '';
83
+ const rootCauses = [];
84
+ const rootCausePattern = /\*?\*?(?:causa[- ]?raiz|root cause)\*?\*?[:\s]+(.{30,500}?)(?:\.\s|\n\n|\n\*\*|$)/gim;
85
+ while ((match = rootCausePattern.exec(allContent)) !== null) {
86
+ const text = normalizeInline(match[1]);
87
+ if (text.length > 20) rootCauses.push(text);
88
+ }
89
+ if (rootCauses.length > 0) rootCause = rootCauses.sort((a, b) => b.length - a.length)[0];
90
+
91
+ const bugPrompt = (tx.userPrompts || []).find((prompt) =>
92
+ /NUT-\d+|bug|erro|problema|não funciona|falha|corrigir|fix\b/i.test(prompt)
93
+ );
94
+ const symptom = bugPrompt ? normalizeInline(bugPrompt, 300) : '';
95
+
96
+ const fixes = [];
97
+ const fixPattern = /\*\*Fix\s+\d+\s*[—–-]\s*(\w+)\*\*[^:]*:\s*(.{20,300}?)(?:\.\s|\n\n|$)/gim;
98
+ while ((match = fixPattern.exec(allContent)) !== null) {
99
+ fixes.push(`**${match[1]}:** ${normalizeInline(match[2])}`);
100
+ }
101
+
102
+ for (const message of tx.assistantMessages || []) {
103
+ const commits = message.match(/git commit[^\"]*"(fix\([^\"]+)"/gi);
104
+ if (!commits) continue;
105
+ for (const commit of commits) {
106
+ const commitMatch = commit.match(/"(fix\([^\"]+)"/i);
107
+ if (commitMatch) fixes.push(`Commit: \`${commitMatch[1]}\``);
108
+ }
109
+ }
110
+
111
+ const correctionPattern = /(?:corre[çc][ãa]o|a\s+corre[çc][ãa]o\s+(?:foi|é))\s*[:\s]+(.{20,300}?)(?:\n\n|$)/gim;
112
+ while ((match = correctionPattern.exec(allContent)) !== null) {
113
+ const text = normalizeInline(match[1]);
114
+ if (!fixes.some((fix) => fix.includes(text.slice(0, 30)))) fixes.push(text);
115
+ }
116
+
117
+ const fileSet = new Set();
118
+ for (const file of [...(tx.changedFiles || []), ...(tx.editedFiles || [])]) {
119
+ const rel = String(file).replace(/^.*?(?=backend-core|mobile-app|ngv-admin|vision-|\.\.)/i, '');
120
+ if (rel) fileSet.add(rel);
121
+ }
122
+ const pathPattern = /`((?:backend-core|mobile-app|ngv-admin-api|vision-food|vision-gym|\.?\.?\/?)[^\s`]+\.(?:py|ts|tsx|js|jsx|sql|md|mjs))`/g;
123
+ while ((match = pathPattern.exec(allContent)) !== null) fileSet.add(match[1]);
124
+
125
+ const evidence = [];
126
+ const testMatch = allContent.match(/(\d+)\s+(?:passed|tests?\s+pass)/i);
127
+ const failMatch = allContent.match(/(\d+)\s+(?:failures?|failed)/i);
128
+ if (testMatch) evidence.push(`Testes: ${testMatch[1]} passed, ${failMatch ? failMatch[1] : '0'} failures`);
129
+ if (/deploy\s+(?:concluído|realizado|com\s+sucesso)/i.test(allContent)) evidence.push('Deploy realizado com sucesso');
130
+ const migrationMatch = allContent.match(/(?:migra[çc][ãa]o|alembic\s+upgrade)\s+(\S+)/i);
131
+ if (migrationMatch) evidence.push(`Migração aplicada: ${migrationMatch[1]}`);
132
+ const httpMatch = allContent.match(/(?:status|HTTP|health)[:\s]*(\d{3})\s*(?:OK)?/i);
133
+ if (httpMatch) evidence.push(`HTTP ${httpMatch[1]} OK`);
134
+
135
+ let lessons = '';
136
+ const lessonMatch = allContent.match(/(?:li[çc][ãa]o|aprendizado|lesson|sempre)\s*(?:aprendida|learned)?[:\s]+(.{20,300}?)(?:\.\s|\n\n|$)/i);
137
+ if (lessonMatch) lessons = normalizeInline(lessonMatch[1]);
138
+
139
+ const lc = allContent.toLowerCase();
140
+ let severity = 'média';
141
+ if (/produ[çc][ãa]o|vps|deploy|billing|payment|data.?loss|race.?condition|security/.test(lc)) severity = 'alta';
142
+ else if (/ui|visual|layout|estilo|css|\bcor\b/.test(lc)) severity = 'baixa';
143
+
144
+ const tags = ['bug', 'codex', 'obsidian'];
145
+ if (/stripe|billing|payment|subscription/i.test(lc)) tags.push('stripe', 'billing');
146
+ if (/celery|worker|task|queue/i.test(lc)) tags.push('celery');
147
+ if (/alembic|migra[çc]|database|postgres/i.test(lc)) tags.push('database');
148
+ if (/react.?native|expo|mobile/i.test(lc)) tags.push('mobile');
149
+ if (/fastapi|backend|endpoint|api/i.test(lc)) tags.push('backend');
150
+ if (/race.?condition|concurrent|deadlock/i.test(lc)) tags.push('concurrency');
151
+
152
+ return {
153
+ symptom,
154
+ rootCause: rootCause || '_Causa raiz não identificada automaticamente._',
155
+ fixes,
156
+ changedFiles: [...fileSet].sort(),
157
+ evidence,
158
+ lessons: lessons || '_Revisar e complementar._',
159
+ severity,
160
+ tags: uniqueTags(tags),
161
+ };
162
+ }
163
+
164
+ export function buildBugNoteContent(bug, issueRef, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(bug.rootCause)) {
165
+ const title = issueRef
166
+ ? `${issueRef} - ${normalizeInline(bug.rootCause, 80)}`
167
+ : normalizeInline(bug.rootCause, 80);
168
+
169
+ return `---
170
+ type: bug
171
+ date: ${dateStr}
172
+ status: fixed
173
+ provider: ${provider.id}
174
+ content_key: "${contentKey}"
175
+ ${sessionYamlLinks(sessionRel)}
176
+ cssclasses:
177
+ - topic-bug
178
+ tags:
179
+ ${yamlTags(bug.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
180
+ severity: ${yamlQuote(bug.severity)}
181
+ issue: ${yamlQuote(issueRef || '')}
182
+ ---
183
+
184
+ # Bug - ${title}
185
+
186
+ > [!note] Auto-gerada
187
+ > Nota criada automaticamente pelo hook Stop do ${provider.label}.
188
+ > Sessão: ${wikilinkFromRel(sessionRel)}
189
+
190
+ ## Sintoma
191
+
192
+ ${bug.symptom || '_Extraído da sessão — verificar._'}
193
+
194
+ ## Causa raiz
195
+
196
+ ${bug.rootCause}
197
+
198
+ ## Correção
199
+
200
+ ${markdownList(bug.fixes, '_Nenhuma correção explícita detectada._')}
201
+
202
+ ## Arquivos alterados
203
+
204
+ ${markdownList(bug.changedFiles.map((file) => `\`${file}\``), '_Ver sessão vinculada._')}
205
+
206
+ ## Evidência
207
+
208
+ ${markdownList(bug.evidence, '_Adicionar evidência empírica._')}
209
+
210
+ ## Lições aprendidas
211
+
212
+ ${bug.lessons}
213
+ `;
214
+ }
215
+
216
+ export function extractDecisionDetails(tx) {
217
+ const allContent = assistantText(tx);
218
+ const lc = allContent.toLowerCase();
219
+ const metaSignals = [
220
+ /\bextract\w*Details\b/,
221
+ /\bcreateLinkedNotes\b/,
222
+ /\bbuildDecisionNoteContent\b/,
223
+ /\bgetNextAdrNumber\b/,
224
+ /\bsession-stop\.mjs\b/,
225
+ /hasDecisionKeyword|hasAlternatives|hasArchCommit/,
226
+ ];
227
+ if (metaSignals.filter((rx) => rx.test(allContent)).length >= 2) return null;
228
+
229
+ // Registro DELIBERADO: linha com rótulo `Decisão:`/`ADR:` (opcional negrito/
230
+ // heading). A palavra "decisão"/"decidimos"/"adotar" solta em prosa do
231
+ // assistente NÃO conta — senão fragmentos de conversa viram ADRs no Vault.
232
+ const hasDecisionKeyword = /(?:^|\n)\s*(?:#{1,6}\s*|[-*]\s*)?\*{0,2}\s*(?:decis[ãa]o(?:\s+t[ée]cnica|\s+de\s+arquitetura)?|ADR(?:-\d+)?)\s*\*{0,2}\s*:/im.test(allContent);
233
+ const hasAlternatives = /\b(?:alternativ|em\s+vez\s+de|ao\s+inv[eé]s\s+de|consideramos|op[çc][ãa]o\s+[A-C]|descartamos)\b/i.test(allContent);
234
+ const hasArchCommit = (tx.assistantMessages || []).some((message) =>
235
+ /git commit[^\"]*"(?:refactor|chore|feat)\([^\"]*(?:arch|pattern|convention|design|theme|stack)/i.test(message)
236
+ );
237
+
238
+ if (!hasDecisionKeyword && !(hasAlternatives && hasArchCommit)) return null;
239
+
240
+ const isMetaText = (text) => {
241
+ if (/[\"']{2,}|[(\[]\?[:!]|\\[bdsw]|\|\||\b(?:const|function|import|return|=>)\b/i.test(text)) return true;
242
+ if ((String(text).match(/[\"'][^\"']+[\"']/g) || []).length >= 3) return true;
243
+ return /extract\w+Details|buildDecisionNote|createLinkedNotes|session-stop/i.test(text);
244
+ };
245
+
246
+ const matches = [];
247
+ let match;
248
+ const decisionPattern = /(?:^|\n)\s*(?:#{1,6}\s*|[-*]\s*)?\*{0,2}\s*(?:decis[ãa]o(?:\s+t[ée]cnica|\s+de\s+arquitetura)?|ADR(?:-\d+)?)\s*\*{0,2}\s*:\s*\*{0,2}\s*(.{10,500}?)(?:\.\s|\n|$)/gim;
249
+ while ((match = decisionPattern.exec(allContent)) !== null) matches.push(normalizeInline(match[1]));
250
+
251
+ if (matches.length === 0 && !hasArchCommit) return null;
252
+
253
+ const cleanMatches = matches.filter((item) => !isMetaText(item));
254
+ let title = '';
255
+ let detail = '';
256
+ if (cleanMatches.length > 0) {
257
+ const best = cleanMatches.sort((a, b) => b.length - a.length)[0];
258
+ detail = best;
259
+ title = best.slice(0, 80);
260
+ } else if (hasArchCommit) {
261
+ for (const message of tx.assistantMessages || []) {
262
+ const commitMatch = message.match(/git commit[^\"]*"((?:refactor|feat|chore)\([^\"]+)"/i);
263
+ if (commitMatch) {
264
+ title = commitMatch[1];
265
+ detail = commitMatch[1];
266
+ break;
267
+ }
268
+ }
269
+ }
270
+
271
+ if (!title || isMetaText(title)) return null;
272
+
273
+ const contextMatch = allContent.match(/\*?\*?contexto\*?\*?\s*[:\s]+(.{20,500}?)(?:\n\n|\n\*\*|$)/i);
274
+ const context = contextMatch && !isMetaText(contextMatch[1])
275
+ ? normalizeInline(contextMatch[1])
276
+ : normalizeInline(firstUserPrompt(tx), 300);
277
+
278
+ const consequencesMatch = allContent.match(/\*?\*?consequ[êe]ncia\*?\*?s?\s*[:\s]+(.{20,500}?)(?:\n\n|\n##|$)/i);
279
+ const consequences = consequencesMatch && !isMetaText(consequencesMatch[1])
280
+ ? normalizeInline(consequencesMatch[1])
281
+ : '_Avaliar impacto._';
282
+
283
+ const alternatives = [];
284
+ const alternativesPattern = /\b(?:alternativ\w*|op[çc][ãa]\s+[A-C]|consideramos|descartamos)\b[:\s]+(.{10,300}?)(?:\.\s|\n|$)/gim;
285
+ while ((match = alternativesPattern.exec(allContent)) !== null) {
286
+ const text = normalizeInline(match[1]);
287
+ if (text.length > 10 && !isMetaText(text)) alternatives.push(text);
288
+ }
289
+
290
+ const tags = ['decisao', 'arquitetura', 'codex'];
291
+ const domainTags = [];
292
+ if (/backend|fastapi|python/i.test(lc) && !/\bbackend.specialist\b/i.test(lc)) domainTags.push('backend');
293
+ if (/mobile|react.?native|expo/i.test(lc)) domainTags.push('mobile');
294
+ if (/database|postgres|alembic|migra/i.test(lc)) domainTags.push('database');
295
+ if (/docker|infra|deploy/i.test(lc)) domainTags.push('infra');
296
+ if (/\btema\b|theme|design.?system/i.test(lc)) domainTags.push('design');
297
+ if (/\btest\b|jest|pytest/i.test(lc) && !/test-linked-notes/i.test(lc)) domainTags.push('testes');
298
+ if (domainTags.length >= 5) return null;
299
+ tags.push(...domainTags);
300
+
301
+ return {
302
+ title,
303
+ detail,
304
+ context,
305
+ consequences,
306
+ alternatives,
307
+ tags: uniqueTags(tags),
308
+ };
309
+ }
310
+
311
+ export function buildDecisionNoteContent(decision, adrNum, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(decision.title)) {
312
+ const adrId = `ADR-${String(adrNum).padStart(3, '0')}`;
313
+ return `---
314
+ type: decision
315
+ date: ${dateStr}
316
+ status: accepted
317
+ provider: ${provider.id}
318
+ content_key: "${contentKey}"
319
+ ${sessionYamlLinks(sessionRel)}
320
+ cssclasses:
321
+ - topic-decision
322
+ tags:
323
+ ${yamlTags(decision.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
324
+ superseded_by: ""
325
+ ---
326
+
327
+ # ${adrId} - ${decision.title}
328
+
329
+ > [!note] Auto-gerada
330
+ > Nota criada automaticamente pelo hook Stop do ${provider.label}.
331
+ > Sessão: ${wikilinkFromRel(sessionRel)}
332
+
333
+ ## Contexto
334
+
335
+ ${decision.context || '_Extraído da sessão — complementar._'}
336
+
337
+ ## Decisão
338
+
339
+ ${decision.detail}
340
+
341
+ ## Consequências
342
+
343
+ ${decision.consequences}
344
+
345
+ ## Alternativas consideradas
346
+
347
+ ${markdownList(decision.alternatives, '_Nenhuma alternativa registrada automaticamente._')}
348
+ `;
349
+ }
350
+
351
+ export function extractLearningDetails(tx, bugDetails) {
352
+ const allContent = assistantText(tx);
353
+ const learnings = [];
354
+ const seen = new Set();
355
+ let match;
356
+
357
+ const fixPattern = /\*\*Fix\s+\d+\s*[—–-]\s*(\w+)\*\*[^:]*:\s*(.{20,500}?)(?:\n\n|\n\*\*|$)/gim;
358
+ while ((match = fixPattern.exec(allContent)) !== null) {
359
+ const scope = match[1];
360
+ const detail = normalizeInline(match[2]);
361
+ const key = detail.toLowerCase().slice(0, 60);
362
+ if (!seen.has(key)) {
363
+ seen.add(key);
364
+ learnings.push({
365
+ title: `${scope}: ${detail.slice(0, 60)}`,
366
+ context: bugDetails?.symptom || normalizeInline(firstUserPrompt(tx), 200),
367
+ content: detail,
368
+ tags: ['aprendizado', 'codex', scope.toLowerCase()],
369
+ });
370
+ }
371
+ }
372
+
373
+ // Só registro DELIBERADO conta: linha com rótulo `Aprendizado:`/`Lição:`/`TIL:`
374
+ // (opcional negrito/heading). Frases conversacionais soltas ("a solução foi…",
375
+ // "descobrimos que…", "importante:…") NÃO viram nota — senão prosa do
376
+ // assistente vira aprendizado-lixo e ressuscita a cada Stop.
377
+ const lessonPatterns = [
378
+ /(?:^|\n)\s*(?:#{1,6}\s*|[-*]\s*)?\*{0,2}\s*(?:li[çc][ãa]o(?:\s+aprendida)?|aprendizado|lesson(?:\s+learned)?|TIL)\s*\*{0,2}\s*:\s*\*{0,2}\s*(.{15,500}?)(?:\n|$)/gim,
379
+ ];
380
+ for (const pattern of lessonPatterns) {
381
+ while ((match = pattern.exec(allContent)) !== null) {
382
+ const detail = normalizeInline(match[1]);
383
+ const key = detail.toLowerCase().slice(0, 60);
384
+ if (detail.length > 15 && !seen.has(key)) {
385
+ seen.add(key);
386
+ learnings.push({
387
+ title: detail.slice(0, 80),
388
+ context: normalizeInline(firstUserPrompt(tx), 200),
389
+ content: detail,
390
+ tags: ['aprendizado', 'codex'],
391
+ });
392
+ }
393
+ }
394
+ }
395
+
396
+ if (bugDetails?.rootCause && !bugDetails.rootCause.startsWith('_')) {
397
+ const key = bugDetails.rootCause.toLowerCase().slice(0, 60);
398
+ if (!seen.has(key)) {
399
+ seen.add(key);
400
+ learnings.push({
401
+ title: `Debugging: ${bugDetails.rootCause.slice(0, 60)}`,
402
+ context: bugDetails.symptom || '',
403
+ content: `**Causa raiz identificada:** ${bugDetails.rootCause}\n\n**Lição:** ${bugDetails.lessons}`,
404
+ tags: ['aprendizado', 'codex', 'debugging'],
405
+ });
406
+ }
407
+ }
408
+
409
+ // Sem fallback genérico: sessão que só mexeu em arquivos, sem registro
410
+ // deliberado (`Aprendizado:`/`Lição:`), NÃO vira nota — complemento é manual
411
+ // (regra do Vault). Evita aprendizado-lixo + ressurreição a cada Stop.
412
+ return learnings.length ? learnings.slice(0, 5) : null;
413
+ }
414
+
415
+ export function buildLearningNoteContent(learning, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(learning.title)) {
416
+ return `---
417
+ type: learning
418
+ date: ${dateStr}
419
+ status: active
420
+ provider: ${provider.id}
421
+ content_key: "${contentKey}"
422
+ ${sessionYamlLinks(sessionRel)}
423
+ cssclasses:
424
+ - topic-learning
425
+ tags:
426
+ ${yamlTags(learning.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
427
+ ---
428
+
429
+ # Aprendizado - ${learning.title}
430
+
431
+ > [!note] Auto-gerada
432
+ > Nota criada automaticamente pelo hook Stop do ${provider.label}.
433
+ > Sessão: ${wikilinkFromRel(sessionRel)}
434
+
435
+ ## Contexto
436
+
437
+ ${learning.context || '_Extraído da sessão — complementar._'}
438
+
439
+ ## O que aprendemos
440
+
441
+ ${learning.content}
442
+
443
+ ## Como aplicar no futuro
444
+
445
+ _Registrar como este conhecimento pode ser reutilizado._
446
+ `;
447
+ }
448
+
449
+ const DERIVED_FOLDERS = { bugs: '05-Bugs', decisions: '04-Decisões', learnings: '06-Aprendizados' };
450
+
451
+ function listMd(dir) {
452
+ try { return readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { return []; }
453
+ }
454
+
455
+ // Chaves content_key das derivadas já existentes que linkam esta sessão.
456
+ function existingKeysForSession(vaultBase, sessionRel, dateStr) {
457
+ const wikilink = wikilinkFromRel(sessionRel);
458
+ const out = { bugs: [], decisions: [], learnings: [] };
459
+ for (const [type, folder] of Object.entries(DERIVED_FOLDERS)) {
460
+ const dir = join(vaultBase, datedFolderRelFromDateStr(folder, dateStr));
461
+ for (const fileName of listMd(dir)) {
462
+ try {
463
+ const c = readFileSync(join(dir, fileName), 'utf-8');
464
+ if (!c.includes(sessionRel) && !c.includes(wikilink)) continue;
465
+ const m = c.match(/^content_key:\s*"?(.*?)"?\s*$/m);
466
+ if (m && m[1]) out[type].push(m[1]);
467
+ } catch { /* ignora nota ilegível */ }
468
+ }
469
+ }
470
+ return out;
471
+ }
472
+
473
+ function alreadyHasKey(keys, candidate) {
474
+ return !!candidate && keys.some((k) => keysBate(k, candidate));
475
+ }
476
+
477
+ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options = {}) {
478
+ const linked = { decisions: [], bugs: [], learnings: [] };
479
+ const provider = providerMeta(options.provider);
480
+ const bugsDir = join(vaultBase, datedFolderRelFromDateStr('05-Bugs', dateStr));
481
+ const decisionsDir = join(vaultBase, datedFolderRelFromDateStr('04-Decisões', dateStr));
482
+ const learningsDir = join(vaultBase, datedFolderRelFromDateStr('06-Aprendizados', dateStr));
483
+ ensureDir(bugsDir);
484
+ ensureDir(decisionsDir);
485
+ ensureDir(learningsDir);
486
+
487
+ const existingKeys = existingKeysForSession(vaultBase, sessionRel, dateStr);
488
+
489
+ const issueRefs = options.issueRefs?.length ? options.issueRefs : extractIssueRefs(tx);
490
+ const bugDetails = extractBugDetails(tx);
491
+ if (bugDetails) {
492
+ const issueRef = issueRefs[0] || '';
493
+ const bugKey = derivedContentKey(bugDetails.rootCause);
494
+ if (!alreadyHasKey(existingKeys.bugs, bugKey)) {
495
+ const causeSlug = slugify(bugDetails.rootCause.slice(0, 40), 'bug');
496
+ const fileName = issueRef ? `${issueRef}-${causeSlug}.md` : `${dateStr}-bug-${causeSlug}.md`;
497
+ const filePath = join(bugsDir, fileName);
498
+ if (!existsSync(filePath)) writeFileSync(filePath, buildBugNoteContent(bugDetails, issueRef, dateStr, sessionRel, provider, bugKey), 'utf-8');
499
+ linked.bugs.push(toVaultRelative(vaultBase, filePath));
500
+ existingKeys.bugs.push(bugKey);
501
+ }
502
+ }
503
+
504
+ const decisionDetails = extractDecisionDetails(tx);
505
+ if (decisionDetails) {
506
+ const decisionKey = derivedContentKey(decisionDetails.title);
507
+ if (!alreadyHasKey(existingKeys.decisions, decisionKey)) {
508
+ const titleSlug = slugify(decisionDetails.title.slice(0, 40), 'decisao');
509
+ const existing = adrFileExistsBySlug(decisionsDir, titleSlug);
510
+ const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(3, '0')}-${titleSlug}.md`;
511
+ const filePath = join(decisionsDir, fileName);
512
+ if (!existsSync(filePath)) {
513
+ const adrNum = Number(fileName.match(/^ADR-(\d+)/i)?.[1]) || getNextAdrNumber(vaultBase);
514
+ writeFileSync(filePath, buildDecisionNoteContent(decisionDetails, adrNum, dateStr, sessionRel, provider, decisionKey), 'utf-8');
515
+ }
516
+ linked.decisions.push(toVaultRelative(vaultBase, filePath));
517
+ existingKeys.decisions.push(decisionKey);
518
+ }
519
+ }
520
+
521
+ const learnings = extractLearningDetails(tx, bugDetails);
522
+ if (learnings) {
523
+ for (const learning of learnings) {
524
+ const learningKey = derivedContentKey(learning.title);
525
+ if (alreadyHasKey(existingKeys.learnings, learningKey)) continue;
526
+ const learningSlug = slugify(learning.title.slice(0, 40), 'aprendizado');
527
+ const fileName = `${dateStr}-${learningSlug}.md`;
528
+ const filePath = join(learningsDir, fileName);
529
+ if (!existsSync(filePath)) writeFileSync(filePath, buildLearningNoteContent(learning, dateStr, sessionRel, provider, learningKey), 'utf-8');
530
+ linked.learnings.push(toVaultRelative(vaultBase, filePath));
531
+ existingKeys.learnings.push(learningKey);
532
+ }
533
+ }
534
+
535
+ return linked;
536
+ }