wendkeep 0.48.0 → 0.49.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.
@@ -1,763 +1,763 @@
1
- #!/usr/bin/env node
2
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
3
- import { basename, join, relative } from 'path';
4
- import {
5
- monthFolderRelFromDateStr,
6
- derivedContentKey,
7
- ensureDir,
8
- getNextAdrNumber,
9
- getNextDerivedNumber,
10
- keysBate,
11
- providerMeta,
12
- slugify,
13
- toVaultRelative,
14
- wikilinkFromRel,
15
- } from './obsidian-common.mjs';
16
- import { getLocale } from './locale.mjs';
17
- import { captureProseDecisions } from './decision-capture.mjs';
18
-
19
- function yamlQuote(value) {
20
- return `"${String(value || '').replaceAll('"', '\\"')}"`;
21
- }
22
-
23
- function uniqueTags(tags) {
24
- return [...new Set(tags.filter(Boolean).map((tag) => slugify(tag, 'tag')))];
25
- }
26
-
27
- function assistantText(tx) {
28
- return (tx.assistantMessages || []).join('\n');
29
- }
30
-
31
- function firstUserPrompt(tx) {
32
- return (tx.userPrompts || [])[0] || tx.latestUserPrompt || '';
33
- }
34
-
35
- function normalizeInline(text, max = 0) {
36
- const clean = String(text || '').replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
37
- return max && clean.length > max ? `${clean.slice(0, max).trim()}...` : clean;
38
- }
39
-
40
- function adrFileExistsBySlug(dir, slug) {
41
- try {
42
- return readdirSync(dir).find((file) => /^ADR-\d+-.+\.md$/i.test(file) && file.includes(`-${slug}`));
43
- } catch {
44
- return '';
45
- }
46
- }
47
-
48
- function markdownList(items, fallback) {
49
- return items.length ? items.map((item) => `- ${item}`).join('\n') : fallback;
50
- }
51
-
52
- function yamlTags(tags) {
53
- return uniqueTags(tags).map((tag) => ` - ${tag}`).join('\n');
54
- }
55
-
56
- function sessionYamlLinks(sessionRel) {
57
- const link = wikilinkFromRel(sessionRel);
58
- return [
59
- 'source:',
60
- ` - ${yamlQuote(link)}`,
61
- 'related:',
62
- ` - ${yamlQuote(link)}`,
63
- ].join('\n');
64
- }
65
-
66
- // --- note relink: backfill de proveniência das notas derivadas órfãs (DRV-9) -----
67
- // Nota derivada legada (BUG/APR criada por wendkeep antigo) nasce sem `source:` de sessão —
68
- // ilha no grafo. A origem não está registrada nela, mas os irmãos NÃO-órfãos do mesmo tipo
69
- // carregam a sessão-fonte real: o órfão herda a sessão MODAL (mais comum) do seu (tipo, mês).
70
-
71
- // Extrai a sessão do primeiro `source: - [[...]]` do frontmatter (vazio se não houver).
72
- function sourceSessionOf(content) {
73
- const m = content.match(/^source:\s*\n\s*-\s*"?\[\[([^\]"|]+)/m);
74
- return m ? m[1].trim() : '';
75
- }
76
-
77
- // Injeta source+related antes do `---` de fechamento do frontmatter. Sem frontmatter, no-op.
78
- function insertSourceLinks(content, sessionRel) {
79
- const m = content.match(/^(---\n[\s\S]*?\n)(---\n)/);
80
- if (!m) return content;
81
- return `${m[1]}${sessionYamlLinks(sessionRel)}\n${m[2]}${content.slice(m[0].length)}`;
82
- }
83
-
84
- function modalKey(counts) {
85
- const entries = Object.entries(counts || {});
86
- if (!entries.length) return '';
87
- entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
88
- return entries[0][0];
89
- }
90
-
91
- export function relinkDerivedNotes(vaultBase, { apply = false } = {}) {
92
- const loc = getLocale(vaultBase);
93
- const monthOf = (abs) => relative(vaultBase, abs).replaceAll('\\', '/').split('/').slice(0, 3).join('/');
94
- const linked = [];
95
- const skipped = [];
96
- for (const [folderKey, prefix] of [['bugs', 'BUG'], ['learnings', 'APR']]) {
97
- const root = join(vaultBase, loc.folders[folderKey]);
98
- const files = [];
99
- const walk = (dir) => {
100
- let entries;
101
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
102
- for (const e of entries) {
103
- const p = join(dir, e.name);
104
- if (e.isDirectory()) walk(p);
105
- else if (e.name.endsWith('.md') && e.name.startsWith(`${prefix}-`)) files.push(p);
106
- }
107
- };
108
- walk(root);
109
- const byMonth = {};
110
- const typeWide = {};
111
- const orphans = [];
112
- for (const p of files) {
113
- let c;
114
- try { c = readFileSync(p, 'utf8'); } catch { continue; }
115
- const src = sourceSessionOf(c);
116
- if (src) {
117
- (byMonth[monthOf(p)] ??= {})[src] = ((byMonth[monthOf(p)] || {})[src] || 0) + 1;
118
- typeWide[src] = (typeWide[src] || 0) + 1;
119
- } else {
120
- orphans.push({ p, c });
121
- }
122
- }
123
- for (const o of orphans) {
124
- const rel = relative(vaultBase, o.p).replaceAll('\\', '/');
125
- const session = modalKey(byMonth[monthOf(o.p)]) || modalKey(typeWide);
126
- if (!session) { skipped.push({ file: rel, reason: 'sem irmão com source para inferir' }); continue; }
127
- if (apply) {
128
- try { writeFileSync(o.p, insertSourceLinks(o.c, session), 'utf8'); }
129
- catch { skipped.push({ file: rel, reason: 'escrita falhou' }); continue; }
130
- }
131
- linked.push({ file: rel, session: basename(session) });
132
- }
133
- }
134
- return { applied: apply, linked, skipped };
135
- }
136
-
137
- function extractIssueRefs(tx) {
138
- const text = [
139
- assistantText(tx),
140
- firstUserPrompt(tx),
141
- tx.rawTextForDetection || '',
142
- ].join('\n');
143
- return [...new Set((text.match(/\bNUT-\d+\b/gi) || []).map((ref) => ref.toUpperCase()))];
144
- }
145
-
146
- export function extractBugDetails(tx) {
147
- const allContent = assistantText(tx);
148
- const hasFixCommit = (tx.assistantMessages || []).some((message) => /git commit[^\"]*"fix\(/i.test(message));
149
- const hasFixMention = /(?:commit|commitar)[\s:*]*`?fix\(/i.test(allContent);
150
- const hasFixPattern = /\*\*Fix\s+\d+\s*[—–-]/i.test(allContent);
151
- const hasRootCause = /\*?\*?(?:causa[- ]?raiz|root cause)\*?\*?\s*:/i.test(allContent);
152
-
153
- if (!hasFixCommit && !(hasFixMention && hasRootCause) && !(hasFixPattern && hasRootCause)) return null;
154
-
155
- let match;
156
- let rootCause = '';
157
- const rootCauses = [];
158
- const rootCausePattern = /\*?\*?(?:causa[- ]?raiz|root cause)\*?\*?[:\s]+(.{30,500}?)(?:\.\s|\n\n|\n\*\*|$)/gim;
159
- while ((match = rootCausePattern.exec(allContent)) !== null) {
160
- const text = normalizeInline(match[1]);
161
- if (text.length > 20) rootCauses.push(text);
162
- }
163
- if (rootCauses.length > 0) rootCause = rootCauses.sort((a, b) => b.length - a.length)[0];
164
-
165
- const bugPrompt = (tx.userPrompts || []).find((prompt) =>
166
- /NUT-\d+|bug|erro|problema|não funciona|falha|corrigir|fix\b/i.test(prompt)
167
- );
168
- const symptom = bugPrompt ? normalizeInline(bugPrompt, 300) : '';
169
-
170
- const fixes = [];
171
- const fixPattern = /\*\*Fix\s+\d+\s*[—–-]\s*(\w+)\*\*[^:]*:\s*(.{20,300}?)(?:\.\s|\n\n|$)/gim;
172
- while ((match = fixPattern.exec(allContent)) !== null) {
173
- fixes.push(`**${match[1]}:** ${normalizeInline(match[2])}`);
174
- }
175
-
176
- for (const message of tx.assistantMessages || []) {
177
- const commits = message.match(/git commit[^\"]*"(fix\([^\"]+)"/gi);
178
- if (!commits) continue;
179
- for (const commit of commits) {
180
- const commitMatch = commit.match(/"(fix\([^\"]+)"/i);
181
- if (commitMatch) fixes.push(`Commit: \`${commitMatch[1]}\``);
182
- }
183
- }
184
-
185
- const correctionPattern = /(?:corre[çc][ãa]o|a\s+corre[çc][ãa]o\s+(?:foi|é))\s*[:\s]+(.{20,300}?)(?:\n\n|$)/gim;
186
- while ((match = correctionPattern.exec(allContent)) !== null) {
187
- const text = normalizeInline(match[1]);
188
- if (!fixes.some((fix) => fix.includes(text.slice(0, 30)))) fixes.push(text);
189
- }
190
-
191
- const fileSet = new Set();
192
- for (const file of [...(tx.changedFiles || []), ...(tx.editedFiles || [])]) {
193
- const rel = String(file).replace(/^.*?(?=backend-core|mobile-app|ngv-admin|vision-|\.\.)/i, '');
194
- if (rel) fileSet.add(rel);
195
- }
196
- const pathPattern = /`((?:backend-core|mobile-app|ngv-admin-api|vision-food|vision-gym|\.?\.?\/?)[^\s`]+\.(?:py|ts|tsx|js|jsx|sql|md|mjs))`/g;
197
- while ((match = pathPattern.exec(allContent)) !== null) fileSet.add(match[1]);
198
-
199
- const evidence = [];
200
- const testMatch = allContent.match(/(\d+)\s+(?:passed|tests?\s+pass)/i);
201
- const failMatch = allContent.match(/(\d+)\s+(?:failures?|failed)/i);
202
- if (testMatch) evidence.push(`Testes: ${testMatch[1]} passed, ${failMatch ? failMatch[1] : '0'} failures`);
203
- if (/deploy\s+(?:concluído|realizado|com\s+sucesso)/i.test(allContent)) evidence.push('Deploy realizado com sucesso');
204
- const migrationMatch = allContent.match(/(?:migra[çc][ãa]o|alembic\s+upgrade)\s+(\S+)/i);
205
- if (migrationMatch) evidence.push(`Migração aplicada: ${migrationMatch[1]}`);
206
- const httpMatch = allContent.match(/(?:status|HTTP|health)[:\s]*(\d{3})\s*(?:OK)?/i);
207
- if (httpMatch) evidence.push(`HTTP ${httpMatch[1]} OK`);
208
-
209
- let lessons = '';
210
- const lessonMatch = allContent.match(/(?:li[çc][ãa]o|aprendizado|lesson|sempre)\s*(?:aprendida|learned)?[:\s]+(.{20,300}?)(?:\.\s|\n\n|$)/i);
211
- if (lessonMatch) lessons = normalizeInline(lessonMatch[1]);
212
-
213
- const lc = allContent.toLowerCase();
214
- let severity = 'média';
215
- if (/produ[çc][ãa]o|vps|deploy|billing|payment|data.?loss|race.?condition|security/.test(lc)) severity = 'alta';
216
- else if (/ui|visual|layout|estilo|css|\bcor\b/.test(lc)) severity = 'baixa';
217
-
218
- const tags = ['bug', 'codex', 'obsidian'];
219
- if (/stripe|billing|payment|subscription/i.test(lc)) tags.push('stripe', 'billing');
220
- if (/celery|worker|task|queue/i.test(lc)) tags.push('celery');
221
- if (/alembic|migra[çc]|database|postgres/i.test(lc)) tags.push('database');
222
- if (/react.?native|expo|mobile/i.test(lc)) tags.push('mobile');
223
- if (/fastapi|backend|endpoint|api/i.test(lc)) tags.push('backend');
224
- if (/race.?condition|concurrent|deadlock/i.test(lc)) tags.push('concurrency');
225
-
226
- return {
227
- symptom,
228
- rootCause: rootCause || '_Causa raiz não identificada automaticamente._',
229
- fixes,
230
- changedFiles: [...fileSet].sort(),
231
- evidence,
232
- lessons: lessons || '_Revisar e complementar._',
233
- severity,
234
- tags: uniqueTags(tags),
235
- };
236
- }
237
-
238
- // Locale labels for the auto-generated derived notes (0.9.0). Output-only — the extraction
239
- // heuristics are untouched. Default pt-BR keeps existing behaviour for every legacy caller.
240
- const NOTE_LABELS = {
241
- 'pt-BR': {
242
- autoTag: 'Auto-gerada', autoLine: (p) => `Nota criada automaticamente pelo hook Stop do ${p}.`, session: 'Sessão',
243
- verify: '_Extraído da sessão — verificar._', complete: '_Extraído da sessão — complementar._',
244
- bug: { symptom: 'Sintoma', rootCause: 'Causa raiz', fix: 'Correção', files: 'Arquivos alterados', evidence: 'Evidência', lessons: 'Lições aprendidas', noFix: '_Nenhuma correção explícita detectada._', seeSession: '_Ver sessão vinculada._', addEvidence: '_Adicionar evidência empírica._' },
245
- dec: { context: 'Contexto', decision: 'Decisão', consequences: 'Consequências', alternatives: 'Alternativas consideradas', noAlt: '_Nenhuma alternativa registrada automaticamente._' },
246
- learn: { title: 'Aprendizado', context: 'Contexto', learned: 'O que aprendemos', future: 'Como aplicar no futuro', futureHint: '_Registrar como este conhecimento pode ser reutilizado._' },
247
- },
248
- en: {
249
- autoTag: 'Auto-generated', autoLine: (p) => `Note created automatically by the ${p} Stop hook.`, session: 'Session',
250
- verify: '_Extracted from the session — verify._', complete: '_Extracted from the session — complete._',
251
- bug: { symptom: 'Symptom', rootCause: 'Root cause', fix: 'Fix', files: 'Changed files', evidence: 'Evidence', lessons: 'Lessons learned', noFix: '_No explicit fix detected._', seeSession: '_See the linked session._', addEvidence: '_Add empirical evidence._' },
252
- dec: { context: 'Context', decision: 'Decision', consequences: 'Consequences', alternatives: 'Alternatives considered', noAlt: '_No alternative recorded automatically._' },
253
- learn: { title: 'Learning', context: 'Context', learned: 'What we learned', future: 'How to apply in future', futureHint: '_Record how this knowledge can be reused._' },
254
- },
255
- };
256
- function noteLabels(localeId) { return NOTE_LABELS[localeId] || NOTE_LABELS['pt-BR']; }
257
-
258
- export function buildBugNoteContent(bug, issueRef, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(bug.rootCause), localeId = 'pt-BR', bugNum = 0) {
259
- const L = noteLabels(localeId);
260
- const title = issueRef
261
- ? `${issueRef} - ${normalizeInline(bug.rootCause, 80)}`
262
- : normalizeInline(bug.rootCause, 80);
263
- // bugNum = 0 keeps the legacy unnumbered shape (existing call sites/tests unaffected).
264
- const heading = bugNum ? `# BUG-${String(bugNum).padStart(4, '0')} — ${title}` : `# Bug - ${title}`;
265
-
266
- return `---
267
- type: bug
268
- date: ${dateStr}
269
- status: fixed
270
- provider: ${provider.id}
271
- content_key: "${contentKey}"
272
- ${bugNum ? `bug: ${bugNum}\n` : ''}${sessionYamlLinks(sessionRel)}
273
- cssclasses:
274
- - topic-bug
275
- tags:
276
- ${yamlTags(bug.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
277
- severity: ${yamlQuote(bug.severity)}
278
- issue: ${yamlQuote(issueRef || '')}
279
- ---
280
-
281
- ${heading}
282
-
283
- > [!note] ${L.autoTag}
284
- > ${L.autoLine(provider.label)}
285
- > ${L.session}: ${wikilinkFromRel(sessionRel)}
286
-
287
- ## ${L.bug.symptom}
288
-
289
- ${bug.symptom || L.verify}
290
-
291
- ## ${L.bug.rootCause}
292
-
293
- ${bug.rootCause}
294
-
295
- ## ${L.bug.fix}
296
-
297
- ${markdownList(bug.fixes, L.bug.noFix)}
298
-
299
- ## ${L.bug.files}
300
-
301
- ${markdownList(bug.changedFiles.map((file) => `\`${file}\``), L.bug.seeSession)}
302
-
303
- ## ${L.bug.evidence}
304
-
305
- ${markdownList(bug.evidence, L.bug.addEvidence)}
306
-
307
- ## ${L.bug.lessons}
308
-
309
- ${bug.lessons}
310
- `;
311
- }
312
-
313
- export function extractDecisionDetails(tx) {
314
- const allContent = assistantText(tx);
315
- const lc = allContent.toLowerCase();
316
- const metaSignals = [
317
- /\bextract\w*Details\b/,
318
- /\bcreateLinkedNotes\b/,
319
- /\bbuildDecisionNoteContent\b/,
320
- /\bgetNextAdrNumber\b/,
321
- /\bsession-stop\.mjs\b/,
322
- /hasDecisionKeyword|hasAlternatives|hasArchCommit/,
323
- ];
324
- if (metaSignals.filter((rx) => rx.test(allContent)).length >= 2) return null;
325
-
326
- // Registro DELIBERADO: linha com rótulo `Decisão:`/`ADR:` (opcional negrito/
327
- // heading). A palavra "decisão"/"decidimos"/"adotar" solta em prosa do
328
- // assistente NÃO conta — senão fragmentos de conversa viram ADRs no Vault.
329
- 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);
330
- 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);
331
- const hasArchCommit = (tx.assistantMessages || []).some((message) =>
332
- /git commit[^\"]*"(?:refactor|chore|feat)\([^\"]*(?:arch|pattern|convention|design|theme|stack)/i.test(message)
333
- );
334
-
335
- if (!hasDecisionKeyword && !(hasAlternatives && hasArchCommit)) return null;
336
-
337
- const isMetaText = (text) => {
338
- if (/[\"']{2,}|[(\[]\?[:!]|\\[bdsw]|\|\||\b(?:const|function|import|return|=>)\b/i.test(text)) return true;
339
- if ((String(text).match(/[\"'][^\"']+[\"']/g) || []).length >= 3) return true;
340
- return /extract\w+Details|buildDecisionNote|createLinkedNotes|session-stop/i.test(text);
341
- };
342
-
343
- const matches = [];
344
- let match;
345
- 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;
346
- while ((match = decisionPattern.exec(allContent)) !== null) matches.push(normalizeInline(match[1]));
347
-
348
- if (matches.length === 0 && !hasArchCommit) return null;
349
-
350
- const cleanMatches = matches.filter((item) => !isMetaText(item));
351
- let title = '';
352
- let detail = '';
353
- if (cleanMatches.length > 0) {
354
- const best = cleanMatches.sort((a, b) => b.length - a.length)[0];
355
- detail = best;
356
- title = best.slice(0, 80);
357
- } else if (hasArchCommit) {
358
- for (const message of tx.assistantMessages || []) {
359
- const commitMatch = message.match(/git commit[^\"]*"((?:refactor|feat|chore)\([^\"]+)"/i);
360
- if (commitMatch) {
361
- title = commitMatch[1];
362
- detail = commitMatch[1];
363
- break;
364
- }
365
- }
366
- }
367
-
368
- if (!title || isMetaText(title)) return null;
369
-
370
- const contextMatch = allContent.match(/\*?\*?contexto\*?\*?\s*[:\s]+(.{20,500}?)(?:\n\n|\n\*\*|$)/i);
371
- const context = contextMatch && !isMetaText(contextMatch[1])
372
- ? normalizeInline(contextMatch[1])
373
- : normalizeInline(firstUserPrompt(tx), 300);
374
-
375
- const consequencesMatch = allContent.match(/\*?\*?consequ[êe]ncia\*?\*?s?\s*[:\s]+(.{20,500}?)(?:\n\n|\n##|$)/i);
376
- const consequences = consequencesMatch && !isMetaText(consequencesMatch[1])
377
- ? normalizeInline(consequencesMatch[1])
378
- : '_Avaliar impacto._';
379
-
380
- const alternatives = [];
381
- const alternativesPattern = /\b(?:alternativ\w*|op[çc][ãa]\s+[A-C]|consideramos|descartamos)\b[:\s]+(.{10,300}?)(?:\.\s|\n|$)/gim;
382
- while ((match = alternativesPattern.exec(allContent)) !== null) {
383
- const text = normalizeInline(match[1]);
384
- if (text.length > 10 && !isMetaText(text)) alternatives.push(text);
385
- }
386
-
387
- const tags = ['decisao', 'arquitetura', 'codex'];
388
- const domainTags = [];
389
- if (/backend|fastapi|python/i.test(lc) && !/\bbackend.specialist\b/i.test(lc)) domainTags.push('backend');
390
- if (/mobile|react.?native|expo/i.test(lc)) domainTags.push('mobile');
391
- if (/database|postgres|alembic|migra/i.test(lc)) domainTags.push('database');
392
- if (/docker|infra|deploy/i.test(lc)) domainTags.push('infra');
393
- if (/\btema\b|theme|design.?system/i.test(lc)) domainTags.push('design');
394
- if (/\btest\b|jest|pytest/i.test(lc) && !/test-linked-notes/i.test(lc)) domainTags.push('testes');
395
- if (domainTags.length >= 5) return null;
396
- tags.push(...domainTags);
397
-
398
- return {
399
- title,
400
- detail,
401
- context,
402
- consequences,
403
- alternatives,
404
- tags: uniqueTags(tags),
405
- };
406
- }
407
-
408
- export function buildDecisionNoteContent(decision, adrNum, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(decision.title), localeId = 'pt-BR') {
409
- const L = noteLabels(localeId);
410
- const adrId = `ADR-${String(adrNum).padStart(4, '0')}`;
411
- return `---
412
- type: decision
413
- date: ${dateStr}
414
- status: accepted
415
- provider: ${provider.id}
416
- content_key: "${contentKey}"
417
- ${sessionYamlLinks(sessionRel)}
418
- cssclasses:
419
- - topic-decision
420
- tags:
421
- ${yamlTags(decision.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
422
- superseded_by: ""
423
- ---
424
-
425
- # ${adrId} - ${decision.title}
426
-
427
- > [!note] ${L.autoTag}
428
- > ${L.autoLine(provider.label)}
429
- > ${L.session}: ${wikilinkFromRel(sessionRel)}
430
-
431
- ## ${L.dec.context}
432
-
433
- ${decision.context || L.complete}
434
-
435
- ## ${L.dec.decision}
436
-
437
- ${decision.detail}
438
-
439
- ## ${L.dec.consequences}
440
-
441
- ${decision.consequences}
442
-
443
- ## ${L.dec.alternatives}
444
-
445
- ${markdownList(decision.alternatives, L.dec.noAlt)}
446
- `;
447
- }
448
-
449
- export function extractLearningDetails(tx, bugDetails) {
450
- const allContent = assistantText(tx);
451
- const learnings = [];
452
- const seen = new Set();
453
- let match;
454
-
455
- const fixPattern = /\*\*Fix\s+\d+\s*[—–-]\s*(\w+)\*\*[^:]*:\s*(.{20,500}?)(?:\n\n|\n\*\*|$)/gim;
456
- while ((match = fixPattern.exec(allContent)) !== null) {
457
- const scope = match[1];
458
- const detail = normalizeInline(match[2]);
459
- const key = detail.toLowerCase().slice(0, 60);
460
- if (!seen.has(key)) {
461
- seen.add(key);
462
- learnings.push({
463
- title: `${scope}: ${detail.slice(0, 60)}`,
464
- context: bugDetails?.symptom || normalizeInline(firstUserPrompt(tx), 200),
465
- content: detail,
466
- tags: ['aprendizado', 'codex', scope.toLowerCase()],
467
- });
468
- }
469
- }
470
-
471
- // Só registro DELIBERADO conta: linha com rótulo `Aprendizado:`/`Lição:`/`TIL:`
472
- // (opcional negrito/heading). Frases conversacionais soltas ("a solução foi…",
473
- // "descobrimos que…", "importante:…") NÃO viram nota — senão prosa do
474
- // assistente vira aprendizado-lixo e ressuscita a cada Stop.
475
- const lessonPatterns = [
476
- /(?:^|\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,
477
- ];
478
- for (const pattern of lessonPatterns) {
479
- while ((match = pattern.exec(allContent)) !== null) {
480
- const detail = normalizeInline(match[1]);
481
- const key = detail.toLowerCase().slice(0, 60);
482
- if (detail.length > 15 && !seen.has(key)) {
483
- seen.add(key);
484
- learnings.push({
485
- title: detail.slice(0, 80),
486
- context: normalizeInline(firstUserPrompt(tx), 200),
487
- content: detail,
488
- tags: ['aprendizado', 'codex'],
489
- });
490
- }
491
- }
492
- }
493
-
494
- if (bugDetails?.rootCause && !bugDetails.rootCause.startsWith('_')) {
495
- const key = bugDetails.rootCause.toLowerCase().slice(0, 60);
496
- if (!seen.has(key)) {
497
- seen.add(key);
498
- learnings.push({
499
- title: `Debugging: ${bugDetails.rootCause.slice(0, 60)}`,
500
- context: bugDetails.symptom || '',
501
- content: `**Causa raiz identificada:** ${bugDetails.rootCause}\n\n**Lição:** ${bugDetails.lessons}`,
502
- tags: ['aprendizado', 'codex', 'debugging'],
503
- });
504
- }
505
- }
506
-
507
- // Sem fallback genérico: sessão que só mexeu em arquivos, sem registro
508
- // deliberado (`Aprendizado:`/`Lição:`), NÃO vira nota — complemento é manual
509
- // (regra do Vault). Evita aprendizado-lixo + ressurreição a cada Stop.
510
- return learnings.length ? learnings.slice(0, 5) : null;
511
- }
512
-
513
- export function buildLearningNoteContent(learning, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(learning.title), localeId = 'pt-BR', aprNum = 0) {
514
- const L = noteLabels(localeId);
515
- // aprNum = 0 keeps the legacy unnumbered shape (existing call sites/tests unaffected).
516
- const heading = aprNum ? `# APR-${String(aprNum).padStart(4, '0')} — ${learning.title}` : `# ${L.learn.title} - ${learning.title}`;
517
- return `---
518
- type: learning
519
- date: ${dateStr}
520
- status: active
521
- provider: ${provider.id}
522
- content_key: "${contentKey}"
523
- ${aprNum ? `apr: ${aprNum}\n` : ''}${sessionYamlLinks(sessionRel)}
524
- cssclasses:
525
- - topic-learning
526
- tags:
527
- ${yamlTags(learning.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
528
- ---
529
-
530
- ${heading}
531
-
532
- > [!note] ${L.autoTag}
533
- > ${L.autoLine(provider.label)}
534
- > ${L.session}: ${wikilinkFromRel(sessionRel)}
535
-
536
- ## ${L.learn.context}
537
-
538
- ${learning.context || L.complete}
539
-
540
- ## ${L.learn.learned}
541
-
542
- ${learning.content}
543
-
544
- ## ${L.learn.future}
545
-
546
- ${L.learn.futureHint}
547
- `;
548
- }
549
-
550
- // Manual derived notes (`wendkeep note new`): same sections as the auto-generated shape,
551
- // but with placeholders — the agent/human fills them in. status differs: a manual bug is
552
- // OPEN (the auto one is extracted from an applied fix, hence fixed).
553
- export function buildManualBugNote(title, { num, dateStr, sessionRel = '', localeId = 'pt-BR' }) {
554
- const L = noteLabels(localeId);
555
- const src = sessionRel ? `${sessionYamlLinks(sessionRel)}\n` : '';
556
- return `---
557
- type: bug
558
- date: ${dateStr}
559
- status: open
560
- content_key: "${derivedContentKey(title)}"
561
- bug: ${num}
562
- ${src}cssclasses:
563
- - topic-bug
564
- tags:
565
- - bug
566
- severity: ""
567
- issue: ""
568
- ---
569
-
570
- # BUG-${String(num).padStart(4, '0')} — ${title}
571
-
572
- ## ${L.bug.symptom}
573
-
574
- ${L.verify}
575
-
576
- ## ${L.bug.rootCause}
577
-
578
- ${L.verify}
579
-
580
- ## ${L.bug.fix}
581
-
582
- ${L.bug.noFix}
583
-
584
- ## ${L.bug.evidence}
585
-
586
- ${L.bug.addEvidence}
587
-
588
- ## ${L.bug.lessons}
589
-
590
- ${L.complete}
591
- `;
592
- }
593
-
594
- export function buildManualLearningNote(title, { num, dateStr, sessionRel = '', localeId = 'pt-BR' }) {
595
- const L = noteLabels(localeId);
596
- const src = sessionRel ? `${sessionYamlLinks(sessionRel)}\n` : '';
597
- return `---
598
- type: learning
599
- date: ${dateStr}
600
- status: active
601
- content_key: "${derivedContentKey(title)}"
602
- apr: ${num}
603
- ${src}cssclasses:
604
- - topic-learning
605
- tags:
606
- - aprendizado
607
- ---
608
-
609
- # APR-${String(num).padStart(4, '0')} — ${title}
610
-
611
- ## ${L.learn.context}
612
-
613
- ${L.complete}
614
-
615
- ## ${L.learn.learned}
616
-
617
- ${L.complete}
618
-
619
- ## ${L.learn.future}
620
-
621
- ${L.learn.futureHint}
622
- `;
623
- }
624
-
625
- const derivedFoldersFor = (vaultBase) => { const f = getLocale(vaultBase).folders; return { bugs: f.bugs, decisions: f.decisions, learnings: f.learnings }; };
626
-
627
- // Chaves content_key das derivadas já existentes que linkam esta sessão.
628
- // Vault-wide learning content_keys (recursive over the learnings folder). existingKeysForSession
629
- // only looks at the current session + month, so the same lesson re-extracted on a later day/
630
- // session was duplicated. This dedups a learning against everything already learned in the vault.
631
- function collectLearningKeys(vaultBase) {
632
- const keys = new Set();
633
- const root = join(vaultBase, getLocale(vaultBase).folders.learnings);
634
- const walk = (d) => {
635
- let entries;
636
- try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
637
- for (const e of entries) {
638
- const p = join(d, e.name);
639
- if (e.isDirectory()) walk(p);
640
- else if (e.name.endsWith('.md')) {
641
- try {
642
- const m = readFileSync(p, 'utf-8').match(/^content_key:\s*"?(.*?)"?\s*$/m);
643
- if (m && m[1]) keys.add(m[1]);
644
- } catch { /* nota ilegível */ }
645
- }
646
- }
647
- };
648
- walk(root);
649
- return keys;
650
- }
651
-
652
- // dateStr kept for call compatibility; no longer used to narrow the scan — a session's note may
653
- // sit in a legacy `DIA` subfolder, not just the month folder, so we walk the whole derived tree
654
- // (like collectLearningKeys). The per-session semantics stay: only notes referencing THIS session
655
- // count, so bugs/decisions from other sessions never leak in.
656
- export function existingKeysForSession(vaultBase, sessionRel, dateStr) { // eslint-disable-line no-unused-vars
657
- const wikilink = wikilinkFromRel(sessionRel);
658
- const out = { bugs: [], decisions: [], learnings: [] };
659
- for (const [type, folder] of Object.entries(derivedFoldersFor(vaultBase))) {
660
- const root = join(vaultBase, folder);
661
- const walk = (d) => {
662
- let entries;
663
- try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
664
- for (const e of entries) {
665
- const p = join(d, e.name);
666
- if (e.isDirectory()) { walk(p); continue; }
667
- if (!e.name.endsWith('.md')) continue;
668
- try {
669
- const c = readFileSync(p, 'utf-8');
670
- if (!c.includes(sessionRel) && !c.includes(wikilink)) continue;
671
- const m = c.match(/^content_key:\s*"?(.*?)"?\s*$/m);
672
- if (m && m[1]) out[type].push(m[1]);
673
- } catch { /* ignora nota ilegível */ }
674
- }
675
- };
676
- walk(root);
677
- }
678
- return out;
679
- }
680
-
681
- function alreadyHasKey(keys, candidate) {
682
- return !!candidate && keys.some((k) => keysBate(k, candidate));
683
- }
684
-
685
- export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options = {}) {
686
- const linked = { decisions: [], bugs: [], learnings: [] };
687
- const provider = providerMeta(options.provider);
688
- const loc = getLocale(vaultBase);
689
- const locF = loc.folders;
690
- const bugsDir = join(vaultBase, monthFolderRelFromDateStr(locF.bugs, dateStr, vaultBase));
691
- const decisionsDir = join(vaultBase, monthFolderRelFromDateStr(locF.decisions, dateStr, vaultBase));
692
- const learningsDir = join(vaultBase, monthFolderRelFromDateStr(locF.learnings, dateStr, vaultBase));
693
- ensureDir(bugsDir);
694
- ensureDir(decisionsDir);
695
- ensureDir(learningsDir);
696
-
697
- const existingKeys = existingKeysForSession(vaultBase, sessionRel, dateStr);
698
-
699
- const issueRefs = options.issueRefs?.length ? options.issueRefs : extractIssueRefs(tx);
700
- const bugDetails = extractBugDetails(tx);
701
- if (bugDetails) {
702
- const issueRef = issueRefs[0] || '';
703
- const bugKey = derivedContentKey(bugDetails.rootCause);
704
- if (!alreadyHasKey(existingKeys.bugs, bugKey)) {
705
- const causeSlug = slugify(bugDetails.rootCause, 'bug', 40);
706
- // Numbered AFTER the dedup guard so a deduplicated note never burns a number.
707
- const bugNum = getNextDerivedNumber(vaultBase, 'bugs', 'BUG');
708
- // Keep the tracker ref in the name, but only once — root causes often repeat it.
709
- const refSlug = issueRef ? slugify(issueRef, '', 20) : '';
710
- const refPrefix = refSlug && !causeSlug.includes(refSlug) ? `${refSlug}-` : '';
711
- const fileName = `BUG-${String(bugNum).padStart(4, '0')}-${refPrefix}${causeSlug}.md`;
712
- const filePath = join(bugsDir, fileName);
713
- if (!existsSync(filePath)) writeFileSync(filePath, buildBugNoteContent(bugDetails, issueRef, dateStr, sessionRel, provider, bugKey, loc.id, bugNum), 'utf-8');
714
- linked.bugs.push(toVaultRelative(vaultBase, filePath));
715
- existingKeys.bugs.push(bugKey);
716
- }
717
- }
718
-
719
- const decisionDetails = extractDecisionDetails(tx);
720
- if (decisionDetails) {
721
- const decisionKey = derivedContentKey(decisionDetails.title);
722
- if (!alreadyHasKey(existingKeys.decisions, decisionKey)) {
723
- const titleSlug = slugify(decisionDetails.title, 'decisao', 40);
724
- const existing = adrFileExistsBySlug(decisionsDir, titleSlug);
725
- const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(4, '0')}-${titleSlug}.md`;
726
- const filePath = join(decisionsDir, fileName);
727
- if (!existsSync(filePath)) {
728
- const adrNum = Number(fileName.match(/^ADR-(\d+)/i)?.[1]) || getNextAdrNumber(vaultBase);
729
- writeFileSync(filePath, buildDecisionNoteContent(decisionDetails, adrNum, dateStr, sessionRel, provider, decisionKey, loc.id), 'utf-8');
730
- }
731
- linked.decisions.push(toVaultRelative(vaultBase, filePath));
732
- existingKeys.decisions.push(decisionKey);
733
- }
734
- }
735
-
736
- // Agnostic prose decisions (Codex parity): options-in-prose + short answer -> decision note.
737
- // One integration point covers live Stop, import and backfill, for every provider. Fail-quiet.
738
- try {
739
- for (const rel of captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId: loc.id })) {
740
- linked.decisions.push(rel);
741
- }
742
- } catch { /* prose capture é bônus — nunca derruba a captura principal */ }
743
-
744
- const learnings = extractLearningDetails(tx, bugDetails);
745
- if (learnings) {
746
- const vaultLearningKeys = collectLearningKeys(vaultBase); // vault-wide dedup
747
- for (const learning of learnings) {
748
- const learningKey = derivedContentKey(learning.title);
749
- if (alreadyHasKey(existingKeys.learnings, learningKey)) continue;
750
- if (vaultLearningKeys.has(learningKey)) continue; // already learned elsewhere in the vault
751
- const learningSlug = slugify(learning.title, 'aprendizado', 40);
752
- // Minted inside the loop: each learning consumes its own sequential number.
753
- const aprNum = getNextDerivedNumber(vaultBase, 'learnings', 'APR');
754
- const fileName = `APR-${String(aprNum).padStart(4, '0')}-${learningSlug}.md`;
755
- const filePath = join(learningsDir, fileName);
756
- if (!existsSync(filePath)) writeFileSync(filePath, buildLearningNoteContent(learning, dateStr, sessionRel, provider, learningKey, loc.id, aprNum), 'utf-8');
757
- linked.learnings.push(toVaultRelative(vaultBase, filePath));
758
- existingKeys.learnings.push(learningKey);
759
- }
760
- }
761
-
762
- return linked;
763
- }
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
3
+ import { basename, join, relative } from 'path';
4
+ import {
5
+ monthFolderRelFromDateStr,
6
+ derivedContentKey,
7
+ ensureDir,
8
+ getNextAdrNumber,
9
+ getNextDerivedNumber,
10
+ keysBate,
11
+ providerMeta,
12
+ slugify,
13
+ toVaultRelative,
14
+ wikilinkFromRel,
15
+ } from './obsidian-common.mjs';
16
+ import { getLocale } from './locale.mjs';
17
+ import { captureProseDecisions } from './decision-capture.mjs';
18
+
19
+ function yamlQuote(value) {
20
+ return `"${String(value || '').replaceAll('"', '\\"')}"`;
21
+ }
22
+
23
+ function uniqueTags(tags) {
24
+ return [...new Set(tags.filter(Boolean).map((tag) => slugify(tag, 'tag')))];
25
+ }
26
+
27
+ function assistantText(tx) {
28
+ return (tx.assistantMessages || []).join('\n');
29
+ }
30
+
31
+ function firstUserPrompt(tx) {
32
+ return (tx.userPrompts || [])[0] || tx.latestUserPrompt || '';
33
+ }
34
+
35
+ function normalizeInline(text, max = 0) {
36
+ const clean = String(text || '').replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
37
+ return max && clean.length > max ? `${clean.slice(0, max).trim()}...` : clean;
38
+ }
39
+
40
+ function adrFileExistsBySlug(dir, slug) {
41
+ try {
42
+ return readdirSync(dir).find((file) => /^ADR-\d+-.+\.md$/i.test(file) && file.includes(`-${slug}`));
43
+ } catch {
44
+ return '';
45
+ }
46
+ }
47
+
48
+ function markdownList(items, fallback) {
49
+ return items.length ? items.map((item) => `- ${item}`).join('\n') : fallback;
50
+ }
51
+
52
+ function yamlTags(tags) {
53
+ return uniqueTags(tags).map((tag) => ` - ${tag}`).join('\n');
54
+ }
55
+
56
+ function sessionYamlLinks(sessionRel) {
57
+ const link = wikilinkFromRel(sessionRel);
58
+ return [
59
+ 'source:',
60
+ ` - ${yamlQuote(link)}`,
61
+ 'related:',
62
+ ` - ${yamlQuote(link)}`,
63
+ ].join('\n');
64
+ }
65
+
66
+ // --- note relink: backfill de proveniência das notas derivadas órfãs (DRV-9) -----
67
+ // Nota derivada legada (BUG/APR criada por wendkeep antigo) nasce sem `source:` de sessão —
68
+ // ilha no grafo. A origem não está registrada nela, mas os irmãos NÃO-órfãos do mesmo tipo
69
+ // carregam a sessão-fonte real: o órfão herda a sessão MODAL (mais comum) do seu (tipo, mês).
70
+
71
+ // Extrai a sessão do primeiro `source: - [[...]]` do frontmatter (vazio se não houver).
72
+ function sourceSessionOf(content) {
73
+ const m = content.match(/^source:\s*\n\s*-\s*"?\[\[([^\]"|]+)/m);
74
+ return m ? m[1].trim() : '';
75
+ }
76
+
77
+ // Injeta source+related antes do `---` de fechamento do frontmatter. Sem frontmatter, no-op.
78
+ function insertSourceLinks(content, sessionRel) {
79
+ const m = content.match(/^(---\n[\s\S]*?\n)(---\n)/);
80
+ if (!m) return content;
81
+ return `${m[1]}${sessionYamlLinks(sessionRel)}\n${m[2]}${content.slice(m[0].length)}`;
82
+ }
83
+
84
+ function modalKey(counts) {
85
+ const entries = Object.entries(counts || {});
86
+ if (!entries.length) return '';
87
+ entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
88
+ return entries[0][0];
89
+ }
90
+
91
+ export function relinkDerivedNotes(vaultBase, { apply = false } = {}) {
92
+ const loc = getLocale(vaultBase);
93
+ const monthOf = (abs) => relative(vaultBase, abs).replaceAll('\\', '/').split('/').slice(0, 3).join('/');
94
+ const linked = [];
95
+ const skipped = [];
96
+ for (const [folderKey, prefix] of [['bugs', 'BUG'], ['learnings', 'APR']]) {
97
+ const root = join(vaultBase, loc.folders[folderKey]);
98
+ const files = [];
99
+ const walk = (dir) => {
100
+ let entries;
101
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
102
+ for (const e of entries) {
103
+ const p = join(dir, e.name);
104
+ if (e.isDirectory()) walk(p);
105
+ else if (e.name.endsWith('.md') && e.name.startsWith(`${prefix}-`)) files.push(p);
106
+ }
107
+ };
108
+ walk(root);
109
+ const byMonth = {};
110
+ const typeWide = {};
111
+ const orphans = [];
112
+ for (const p of files) {
113
+ let c;
114
+ try { c = readFileSync(p, 'utf8'); } catch { continue; }
115
+ const src = sourceSessionOf(c);
116
+ if (src) {
117
+ (byMonth[monthOf(p)] ??= {})[src] = ((byMonth[monthOf(p)] || {})[src] || 0) + 1;
118
+ typeWide[src] = (typeWide[src] || 0) + 1;
119
+ } else {
120
+ orphans.push({ p, c });
121
+ }
122
+ }
123
+ for (const o of orphans) {
124
+ const rel = relative(vaultBase, o.p).replaceAll('\\', '/');
125
+ const session = modalKey(byMonth[monthOf(o.p)]) || modalKey(typeWide);
126
+ if (!session) { skipped.push({ file: rel, reason: 'sem irmão com source para inferir' }); continue; }
127
+ if (apply) {
128
+ try { writeFileSync(o.p, insertSourceLinks(o.c, session), 'utf8'); }
129
+ catch { skipped.push({ file: rel, reason: 'escrita falhou' }); continue; }
130
+ }
131
+ linked.push({ file: rel, session: basename(session) });
132
+ }
133
+ }
134
+ return { applied: apply, linked, skipped };
135
+ }
136
+
137
+ function extractIssueRefs(tx) {
138
+ const text = [
139
+ assistantText(tx),
140
+ firstUserPrompt(tx),
141
+ tx.rawTextForDetection || '',
142
+ ].join('\n');
143
+ return [...new Set((text.match(/\bNUT-\d+\b/gi) || []).map((ref) => ref.toUpperCase()))];
144
+ }
145
+
146
+ export function extractBugDetails(tx) {
147
+ const allContent = assistantText(tx);
148
+ const hasFixCommit = (tx.assistantMessages || []).some((message) => /git commit[^\"]*"fix\(/i.test(message));
149
+ const hasFixMention = /(?:commit|commitar)[\s:*]*`?fix\(/i.test(allContent);
150
+ const hasFixPattern = /\*\*Fix\s+\d+\s*[—–-]/i.test(allContent);
151
+ const hasRootCause = /\*?\*?(?:causa[- ]?raiz|root cause)\*?\*?\s*:/i.test(allContent);
152
+
153
+ if (!hasFixCommit && !(hasFixMention && hasRootCause) && !(hasFixPattern && hasRootCause)) return null;
154
+
155
+ let match;
156
+ let rootCause = '';
157
+ const rootCauses = [];
158
+ const rootCausePattern = /\*?\*?(?:causa[- ]?raiz|root cause)\*?\*?[:\s]+(.{30,500}?)(?:\.\s|\n\n|\n\*\*|$)/gim;
159
+ while ((match = rootCausePattern.exec(allContent)) !== null) {
160
+ const text = normalizeInline(match[1]);
161
+ if (text.length > 20) rootCauses.push(text);
162
+ }
163
+ if (rootCauses.length > 0) rootCause = rootCauses.sort((a, b) => b.length - a.length)[0];
164
+
165
+ const bugPrompt = (tx.userPrompts || []).find((prompt) =>
166
+ /NUT-\d+|bug|erro|problema|não funciona|falha|corrigir|fix\b/i.test(prompt)
167
+ );
168
+ const symptom = bugPrompt ? normalizeInline(bugPrompt, 300) : '';
169
+
170
+ const fixes = [];
171
+ const fixPattern = /\*\*Fix\s+\d+\s*[—–-]\s*(\w+)\*\*[^:]*:\s*(.{20,300}?)(?:\.\s|\n\n|$)/gim;
172
+ while ((match = fixPattern.exec(allContent)) !== null) {
173
+ fixes.push(`**${match[1]}:** ${normalizeInline(match[2])}`);
174
+ }
175
+
176
+ for (const message of tx.assistantMessages || []) {
177
+ const commits = message.match(/git commit[^\"]*"(fix\([^\"]+)"/gi);
178
+ if (!commits) continue;
179
+ for (const commit of commits) {
180
+ const commitMatch = commit.match(/"(fix\([^\"]+)"/i);
181
+ if (commitMatch) fixes.push(`Commit: \`${commitMatch[1]}\``);
182
+ }
183
+ }
184
+
185
+ const correctionPattern = /(?:corre[çc][ãa]o|a\s+corre[çc][ãa]o\s+(?:foi|é))\s*[:\s]+(.{20,300}?)(?:\n\n|$)/gim;
186
+ while ((match = correctionPattern.exec(allContent)) !== null) {
187
+ const text = normalizeInline(match[1]);
188
+ if (!fixes.some((fix) => fix.includes(text.slice(0, 30)))) fixes.push(text);
189
+ }
190
+
191
+ const fileSet = new Set();
192
+ for (const file of [...(tx.changedFiles || []), ...(tx.editedFiles || [])]) {
193
+ const rel = String(file).replace(/^.*?(?=backend-core|mobile-app|ngv-admin|vision-|\.\.)/i, '');
194
+ if (rel) fileSet.add(rel);
195
+ }
196
+ const pathPattern = /`((?:backend-core|mobile-app|ngv-admin-api|vision-food|vision-gym|\.?\.?\/?)[^\s`]+\.(?:py|ts|tsx|js|jsx|sql|md|mjs))`/g;
197
+ while ((match = pathPattern.exec(allContent)) !== null) fileSet.add(match[1]);
198
+
199
+ const evidence = [];
200
+ const testMatch = allContent.match(/(\d+)\s+(?:passed|tests?\s+pass)/i);
201
+ const failMatch = allContent.match(/(\d+)\s+(?:failures?|failed)/i);
202
+ if (testMatch) evidence.push(`Testes: ${testMatch[1]} passed, ${failMatch ? failMatch[1] : '0'} failures`);
203
+ if (/deploy\s+(?:concluído|realizado|com\s+sucesso)/i.test(allContent)) evidence.push('Deploy realizado com sucesso');
204
+ const migrationMatch = allContent.match(/(?:migra[çc][ãa]o|alembic\s+upgrade)\s+(\S+)/i);
205
+ if (migrationMatch) evidence.push(`Migração aplicada: ${migrationMatch[1]}`);
206
+ const httpMatch = allContent.match(/(?:status|HTTP|health)[:\s]*(\d{3})\s*(?:OK)?/i);
207
+ if (httpMatch) evidence.push(`HTTP ${httpMatch[1]} OK`);
208
+
209
+ let lessons = '';
210
+ const lessonMatch = allContent.match(/(?:li[çc][ãa]o|aprendizado|lesson|sempre)\s*(?:aprendida|learned)?[:\s]+(.{20,300}?)(?:\.\s|\n\n|$)/i);
211
+ if (lessonMatch) lessons = normalizeInline(lessonMatch[1]);
212
+
213
+ const lc = allContent.toLowerCase();
214
+ let severity = 'média';
215
+ if (/produ[çc][ãa]o|vps|deploy|billing|payment|data.?loss|race.?condition|security/.test(lc)) severity = 'alta';
216
+ else if (/ui|visual|layout|estilo|css|\bcor\b/.test(lc)) severity = 'baixa';
217
+
218
+ const tags = ['bug', 'codex', 'obsidian'];
219
+ if (/stripe|billing|payment|subscription/i.test(lc)) tags.push('stripe', 'billing');
220
+ if (/celery|worker|task|queue/i.test(lc)) tags.push('celery');
221
+ if (/alembic|migra[çc]|database|postgres/i.test(lc)) tags.push('database');
222
+ if (/react.?native|expo|mobile/i.test(lc)) tags.push('mobile');
223
+ if (/fastapi|backend|endpoint|api/i.test(lc)) tags.push('backend');
224
+ if (/race.?condition|concurrent|deadlock/i.test(lc)) tags.push('concurrency');
225
+
226
+ return {
227
+ symptom,
228
+ rootCause: rootCause || '_Causa raiz não identificada automaticamente._',
229
+ fixes,
230
+ changedFiles: [...fileSet].sort(),
231
+ evidence,
232
+ lessons: lessons || '_Revisar e complementar._',
233
+ severity,
234
+ tags: uniqueTags(tags),
235
+ };
236
+ }
237
+
238
+ // Locale labels for the auto-generated derived notes (0.9.0). Output-only — the extraction
239
+ // heuristics are untouched. Default pt-BR keeps existing behaviour for every legacy caller.
240
+ const NOTE_LABELS = {
241
+ 'pt-BR': {
242
+ autoTag: 'Auto-gerada', autoLine: (p) => `Nota criada automaticamente pelo hook Stop do ${p}.`, session: 'Sessão',
243
+ verify: '_Extraído da sessão — verificar._', complete: '_Extraído da sessão — complementar._',
244
+ bug: { symptom: 'Sintoma', rootCause: 'Causa raiz', fix: 'Correção', files: 'Arquivos alterados', evidence: 'Evidência', lessons: 'Lições aprendidas', noFix: '_Nenhuma correção explícita detectada._', seeSession: '_Ver sessão vinculada._', addEvidence: '_Adicionar evidência empírica._' },
245
+ dec: { context: 'Contexto', decision: 'Decisão', consequences: 'Consequências', alternatives: 'Alternativas consideradas', noAlt: '_Nenhuma alternativa registrada automaticamente._' },
246
+ learn: { title: 'Aprendizado', context: 'Contexto', learned: 'O que aprendemos', future: 'Como aplicar no futuro', futureHint: '_Registrar como este conhecimento pode ser reutilizado._' },
247
+ },
248
+ en: {
249
+ autoTag: 'Auto-generated', autoLine: (p) => `Note created automatically by the ${p} Stop hook.`, session: 'Session',
250
+ verify: '_Extracted from the session — verify._', complete: '_Extracted from the session — complete._',
251
+ bug: { symptom: 'Symptom', rootCause: 'Root cause', fix: 'Fix', files: 'Changed files', evidence: 'Evidence', lessons: 'Lessons learned', noFix: '_No explicit fix detected._', seeSession: '_See the linked session._', addEvidence: '_Add empirical evidence._' },
252
+ dec: { context: 'Context', decision: 'Decision', consequences: 'Consequences', alternatives: 'Alternatives considered', noAlt: '_No alternative recorded automatically._' },
253
+ learn: { title: 'Learning', context: 'Context', learned: 'What we learned', future: 'How to apply in future', futureHint: '_Record how this knowledge can be reused._' },
254
+ },
255
+ };
256
+ function noteLabels(localeId) { return NOTE_LABELS[localeId] || NOTE_LABELS['pt-BR']; }
257
+
258
+ export function buildBugNoteContent(bug, issueRef, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(bug.rootCause), localeId = 'pt-BR', bugNum = 0) {
259
+ const L = noteLabels(localeId);
260
+ const title = issueRef
261
+ ? `${issueRef} - ${normalizeInline(bug.rootCause, 80)}`
262
+ : normalizeInline(bug.rootCause, 80);
263
+ // bugNum = 0 keeps the legacy unnumbered shape (existing call sites/tests unaffected).
264
+ const heading = bugNum ? `# BUG-${String(bugNum).padStart(4, '0')} — ${title}` : `# Bug - ${title}`;
265
+
266
+ return `---
267
+ type: bug
268
+ date: ${dateStr}
269
+ status: fixed
270
+ provider: ${provider.id}
271
+ content_key: "${contentKey}"
272
+ ${bugNum ? `bug: ${bugNum}\n` : ''}${sessionYamlLinks(sessionRel)}
273
+ cssclasses:
274
+ - topic-bug
275
+ tags:
276
+ ${yamlTags(bug.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
277
+ severity: ${yamlQuote(bug.severity)}
278
+ issue: ${yamlQuote(issueRef || '')}
279
+ ---
280
+
281
+ ${heading}
282
+
283
+ > [!note] ${L.autoTag}
284
+ > ${L.autoLine(provider.label)}
285
+ > ${L.session}: ${wikilinkFromRel(sessionRel)}
286
+
287
+ ## ${L.bug.symptom}
288
+
289
+ ${bug.symptom || L.verify}
290
+
291
+ ## ${L.bug.rootCause}
292
+
293
+ ${bug.rootCause}
294
+
295
+ ## ${L.bug.fix}
296
+
297
+ ${markdownList(bug.fixes, L.bug.noFix)}
298
+
299
+ ## ${L.bug.files}
300
+
301
+ ${markdownList(bug.changedFiles.map((file) => `\`${file}\``), L.bug.seeSession)}
302
+
303
+ ## ${L.bug.evidence}
304
+
305
+ ${markdownList(bug.evidence, L.bug.addEvidence)}
306
+
307
+ ## ${L.bug.lessons}
308
+
309
+ ${bug.lessons}
310
+ `;
311
+ }
312
+
313
+ export function extractDecisionDetails(tx) {
314
+ const allContent = assistantText(tx);
315
+ const lc = allContent.toLowerCase();
316
+ const metaSignals = [
317
+ /\bextract\w*Details\b/,
318
+ /\bcreateLinkedNotes\b/,
319
+ /\bbuildDecisionNoteContent\b/,
320
+ /\bgetNextAdrNumber\b/,
321
+ /\bsession-stop\.mjs\b/,
322
+ /hasDecisionKeyword|hasAlternatives|hasArchCommit/,
323
+ ];
324
+ if (metaSignals.filter((rx) => rx.test(allContent)).length >= 2) return null;
325
+
326
+ // Registro DELIBERADO: linha com rótulo `Decisão:`/`ADR:` (opcional negrito/
327
+ // heading). A palavra "decisão"/"decidimos"/"adotar" solta em prosa do
328
+ // assistente NÃO conta — senão fragmentos de conversa viram ADRs no Vault.
329
+ 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);
330
+ 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);
331
+ const hasArchCommit = (tx.assistantMessages || []).some((message) =>
332
+ /git commit[^\"]*"(?:refactor|chore|feat)\([^\"]*(?:arch|pattern|convention|design|theme|stack)/i.test(message)
333
+ );
334
+
335
+ if (!hasDecisionKeyword && !(hasAlternatives && hasArchCommit)) return null;
336
+
337
+ const isMetaText = (text) => {
338
+ if (/[\"']{2,}|[(\[]\?[:!]|\\[bdsw]|\|\||\b(?:const|function|import|return|=>)\b/i.test(text)) return true;
339
+ if ((String(text).match(/[\"'][^\"']+[\"']/g) || []).length >= 3) return true;
340
+ return /extract\w+Details|buildDecisionNote|createLinkedNotes|session-stop/i.test(text);
341
+ };
342
+
343
+ const matches = [];
344
+ let match;
345
+ 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;
346
+ while ((match = decisionPattern.exec(allContent)) !== null) matches.push(normalizeInline(match[1]));
347
+
348
+ if (matches.length === 0 && !hasArchCommit) return null;
349
+
350
+ const cleanMatches = matches.filter((item) => !isMetaText(item));
351
+ let title = '';
352
+ let detail = '';
353
+ if (cleanMatches.length > 0) {
354
+ const best = cleanMatches.sort((a, b) => b.length - a.length)[0];
355
+ detail = best;
356
+ title = best.slice(0, 80);
357
+ } else if (hasArchCommit) {
358
+ for (const message of tx.assistantMessages || []) {
359
+ const commitMatch = message.match(/git commit[^\"]*"((?:refactor|feat|chore)\([^\"]+)"/i);
360
+ if (commitMatch) {
361
+ title = commitMatch[1];
362
+ detail = commitMatch[1];
363
+ break;
364
+ }
365
+ }
366
+ }
367
+
368
+ if (!title || isMetaText(title)) return null;
369
+
370
+ const contextMatch = allContent.match(/\*?\*?contexto\*?\*?\s*[:\s]+(.{20,500}?)(?:\n\n|\n\*\*|$)/i);
371
+ const context = contextMatch && !isMetaText(contextMatch[1])
372
+ ? normalizeInline(contextMatch[1])
373
+ : normalizeInline(firstUserPrompt(tx), 300);
374
+
375
+ const consequencesMatch = allContent.match(/\*?\*?consequ[êe]ncia\*?\*?s?\s*[:\s]+(.{20,500}?)(?:\n\n|\n##|$)/i);
376
+ const consequences = consequencesMatch && !isMetaText(consequencesMatch[1])
377
+ ? normalizeInline(consequencesMatch[1])
378
+ : '_Avaliar impacto._';
379
+
380
+ const alternatives = [];
381
+ const alternativesPattern = /\b(?:alternativ\w*|op[çc][ãa]\s+[A-C]|consideramos|descartamos)\b[:\s]+(.{10,300}?)(?:\.\s|\n|$)/gim;
382
+ while ((match = alternativesPattern.exec(allContent)) !== null) {
383
+ const text = normalizeInline(match[1]);
384
+ if (text.length > 10 && !isMetaText(text)) alternatives.push(text);
385
+ }
386
+
387
+ const tags = ['decisao', 'arquitetura', 'codex'];
388
+ const domainTags = [];
389
+ if (/backend|fastapi|python/i.test(lc) && !/\bbackend.specialist\b/i.test(lc)) domainTags.push('backend');
390
+ if (/mobile|react.?native|expo/i.test(lc)) domainTags.push('mobile');
391
+ if (/database|postgres|alembic|migra/i.test(lc)) domainTags.push('database');
392
+ if (/docker|infra|deploy/i.test(lc)) domainTags.push('infra');
393
+ if (/\btema\b|theme|design.?system/i.test(lc)) domainTags.push('design');
394
+ if (/\btest\b|jest|pytest/i.test(lc) && !/test-linked-notes/i.test(lc)) domainTags.push('testes');
395
+ if (domainTags.length >= 5) return null;
396
+ tags.push(...domainTags);
397
+
398
+ return {
399
+ title,
400
+ detail,
401
+ context,
402
+ consequences,
403
+ alternatives,
404
+ tags: uniqueTags(tags),
405
+ };
406
+ }
407
+
408
+ export function buildDecisionNoteContent(decision, adrNum, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(decision.title), localeId = 'pt-BR') {
409
+ const L = noteLabels(localeId);
410
+ const adrId = `ADR-${String(adrNum).padStart(4, '0')}`;
411
+ return `---
412
+ type: decision
413
+ date: ${dateStr}
414
+ status: accepted
415
+ provider: ${provider.id}
416
+ content_key: "${contentKey}"
417
+ ${sessionYamlLinks(sessionRel)}
418
+ cssclasses:
419
+ - topic-decision
420
+ tags:
421
+ ${yamlTags(decision.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
422
+ superseded_by: ""
423
+ ---
424
+
425
+ # ${adrId} - ${decision.title}
426
+
427
+ > [!note] ${L.autoTag}
428
+ > ${L.autoLine(provider.label)}
429
+ > ${L.session}: ${wikilinkFromRel(sessionRel)}
430
+
431
+ ## ${L.dec.context}
432
+
433
+ ${decision.context || L.complete}
434
+
435
+ ## ${L.dec.decision}
436
+
437
+ ${decision.detail}
438
+
439
+ ## ${L.dec.consequences}
440
+
441
+ ${decision.consequences}
442
+
443
+ ## ${L.dec.alternatives}
444
+
445
+ ${markdownList(decision.alternatives, L.dec.noAlt)}
446
+ `;
447
+ }
448
+
449
+ export function extractLearningDetails(tx, bugDetails) {
450
+ const allContent = assistantText(tx);
451
+ const learnings = [];
452
+ const seen = new Set();
453
+ let match;
454
+
455
+ const fixPattern = /\*\*Fix\s+\d+\s*[—–-]\s*(\w+)\*\*[^:]*:\s*(.{20,500}?)(?:\n\n|\n\*\*|$)/gim;
456
+ while ((match = fixPattern.exec(allContent)) !== null) {
457
+ const scope = match[1];
458
+ const detail = normalizeInline(match[2]);
459
+ const key = detail.toLowerCase().slice(0, 60);
460
+ if (!seen.has(key)) {
461
+ seen.add(key);
462
+ learnings.push({
463
+ title: `${scope}: ${detail.slice(0, 60)}`,
464
+ context: bugDetails?.symptom || normalizeInline(firstUserPrompt(tx), 200),
465
+ content: detail,
466
+ tags: ['aprendizado', 'codex', scope.toLowerCase()],
467
+ });
468
+ }
469
+ }
470
+
471
+ // Só registro DELIBERADO conta: linha com rótulo `Aprendizado:`/`Lição:`/`TIL:`
472
+ // (opcional negrito/heading). Frases conversacionais soltas ("a solução foi…",
473
+ // "descobrimos que…", "importante:…") NÃO viram nota — senão prosa do
474
+ // assistente vira aprendizado-lixo e ressuscita a cada Stop.
475
+ const lessonPatterns = [
476
+ /(?:^|\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,
477
+ ];
478
+ for (const pattern of lessonPatterns) {
479
+ while ((match = pattern.exec(allContent)) !== null) {
480
+ const detail = normalizeInline(match[1]);
481
+ const key = detail.toLowerCase().slice(0, 60);
482
+ if (detail.length > 15 && !seen.has(key)) {
483
+ seen.add(key);
484
+ learnings.push({
485
+ title: detail.slice(0, 80),
486
+ context: normalizeInline(firstUserPrompt(tx), 200),
487
+ content: detail,
488
+ tags: ['aprendizado', 'codex'],
489
+ });
490
+ }
491
+ }
492
+ }
493
+
494
+ if (bugDetails?.rootCause && !bugDetails.rootCause.startsWith('_')) {
495
+ const key = bugDetails.rootCause.toLowerCase().slice(0, 60);
496
+ if (!seen.has(key)) {
497
+ seen.add(key);
498
+ learnings.push({
499
+ title: `Debugging: ${bugDetails.rootCause.slice(0, 60)}`,
500
+ context: bugDetails.symptom || '',
501
+ content: `**Causa raiz identificada:** ${bugDetails.rootCause}\n\n**Lição:** ${bugDetails.lessons}`,
502
+ tags: ['aprendizado', 'codex', 'debugging'],
503
+ });
504
+ }
505
+ }
506
+
507
+ // Sem fallback genérico: sessão que só mexeu em arquivos, sem registro
508
+ // deliberado (`Aprendizado:`/`Lição:`), NÃO vira nota — complemento é manual
509
+ // (regra do Vault). Evita aprendizado-lixo + ressurreição a cada Stop.
510
+ return learnings.length ? learnings.slice(0, 5) : null;
511
+ }
512
+
513
+ export function buildLearningNoteContent(learning, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(learning.title), localeId = 'pt-BR', aprNum = 0) {
514
+ const L = noteLabels(localeId);
515
+ // aprNum = 0 keeps the legacy unnumbered shape (existing call sites/tests unaffected).
516
+ const heading = aprNum ? `# APR-${String(aprNum).padStart(4, '0')} — ${learning.title}` : `# ${L.learn.title} - ${learning.title}`;
517
+ return `---
518
+ type: learning
519
+ date: ${dateStr}
520
+ status: active
521
+ provider: ${provider.id}
522
+ content_key: "${contentKey}"
523
+ ${aprNum ? `apr: ${aprNum}\n` : ''}${sessionYamlLinks(sessionRel)}
524
+ cssclasses:
525
+ - topic-learning
526
+ tags:
527
+ ${yamlTags(learning.tags.map((tag) => (tag === 'codex' ? provider.tag : tag)))}
528
+ ---
529
+
530
+ ${heading}
531
+
532
+ > [!note] ${L.autoTag}
533
+ > ${L.autoLine(provider.label)}
534
+ > ${L.session}: ${wikilinkFromRel(sessionRel)}
535
+
536
+ ## ${L.learn.context}
537
+
538
+ ${learning.context || L.complete}
539
+
540
+ ## ${L.learn.learned}
541
+
542
+ ${learning.content}
543
+
544
+ ## ${L.learn.future}
545
+
546
+ ${L.learn.futureHint}
547
+ `;
548
+ }
549
+
550
+ // Manual derived notes (`wendkeep note new`): same sections as the auto-generated shape,
551
+ // but with placeholders — the agent/human fills them in. status differs: a manual bug is
552
+ // OPEN (the auto one is extracted from an applied fix, hence fixed).
553
+ export function buildManualBugNote(title, { num, dateStr, sessionRel = '', localeId = 'pt-BR' }) {
554
+ const L = noteLabels(localeId);
555
+ const src = sessionRel ? `${sessionYamlLinks(sessionRel)}\n` : '';
556
+ return `---
557
+ type: bug
558
+ date: ${dateStr}
559
+ status: open
560
+ content_key: "${derivedContentKey(title)}"
561
+ bug: ${num}
562
+ ${src}cssclasses:
563
+ - topic-bug
564
+ tags:
565
+ - bug
566
+ severity: ""
567
+ issue: ""
568
+ ---
569
+
570
+ # BUG-${String(num).padStart(4, '0')} — ${title}
571
+
572
+ ## ${L.bug.symptom}
573
+
574
+ ${L.verify}
575
+
576
+ ## ${L.bug.rootCause}
577
+
578
+ ${L.verify}
579
+
580
+ ## ${L.bug.fix}
581
+
582
+ ${L.bug.noFix}
583
+
584
+ ## ${L.bug.evidence}
585
+
586
+ ${L.bug.addEvidence}
587
+
588
+ ## ${L.bug.lessons}
589
+
590
+ ${L.complete}
591
+ `;
592
+ }
593
+
594
+ export function buildManualLearningNote(title, { num, dateStr, sessionRel = '', localeId = 'pt-BR' }) {
595
+ const L = noteLabels(localeId);
596
+ const src = sessionRel ? `${sessionYamlLinks(sessionRel)}\n` : '';
597
+ return `---
598
+ type: learning
599
+ date: ${dateStr}
600
+ status: active
601
+ content_key: "${derivedContentKey(title)}"
602
+ apr: ${num}
603
+ ${src}cssclasses:
604
+ - topic-learning
605
+ tags:
606
+ - aprendizado
607
+ ---
608
+
609
+ # APR-${String(num).padStart(4, '0')} — ${title}
610
+
611
+ ## ${L.learn.context}
612
+
613
+ ${L.complete}
614
+
615
+ ## ${L.learn.learned}
616
+
617
+ ${L.complete}
618
+
619
+ ## ${L.learn.future}
620
+
621
+ ${L.learn.futureHint}
622
+ `;
623
+ }
624
+
625
+ const derivedFoldersFor = (vaultBase) => { const f = getLocale(vaultBase).folders; return { bugs: f.bugs, decisions: f.decisions, learnings: f.learnings }; };
626
+
627
+ // Chaves content_key das derivadas já existentes que linkam esta sessão.
628
+ // Vault-wide learning content_keys (recursive over the learnings folder). existingKeysForSession
629
+ // only looks at the current session + month, so the same lesson re-extracted on a later day/
630
+ // session was duplicated. This dedups a learning against everything already learned in the vault.
631
+ function collectLearningKeys(vaultBase) {
632
+ const keys = new Set();
633
+ const root = join(vaultBase, getLocale(vaultBase).folders.learnings);
634
+ const walk = (d) => {
635
+ let entries;
636
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
637
+ for (const e of entries) {
638
+ const p = join(d, e.name);
639
+ if (e.isDirectory()) walk(p);
640
+ else if (e.name.endsWith('.md')) {
641
+ try {
642
+ const m = readFileSync(p, 'utf-8').match(/^content_key:\s*"?(.*?)"?\s*$/m);
643
+ if (m && m[1]) keys.add(m[1]);
644
+ } catch { /* nota ilegível */ }
645
+ }
646
+ }
647
+ };
648
+ walk(root);
649
+ return keys;
650
+ }
651
+
652
+ // dateStr kept for call compatibility; no longer used to narrow the scan — a session's note may
653
+ // sit in a legacy `DIA` subfolder, not just the month folder, so we walk the whole derived tree
654
+ // (like collectLearningKeys). The per-session semantics stay: only notes referencing THIS session
655
+ // count, so bugs/decisions from other sessions never leak in.
656
+ export function existingKeysForSession(vaultBase, sessionRel, dateStr) { // eslint-disable-line no-unused-vars
657
+ const wikilink = wikilinkFromRel(sessionRel);
658
+ const out = { bugs: [], decisions: [], learnings: [] };
659
+ for (const [type, folder] of Object.entries(derivedFoldersFor(vaultBase))) {
660
+ const root = join(vaultBase, folder);
661
+ const walk = (d) => {
662
+ let entries;
663
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
664
+ for (const e of entries) {
665
+ const p = join(d, e.name);
666
+ if (e.isDirectory()) { walk(p); continue; }
667
+ if (!e.name.endsWith('.md')) continue;
668
+ try {
669
+ const c = readFileSync(p, 'utf-8');
670
+ if (!c.includes(sessionRel) && !c.includes(wikilink)) continue;
671
+ const m = c.match(/^content_key:\s*"?(.*?)"?\s*$/m);
672
+ if (m && m[1]) out[type].push(m[1]);
673
+ } catch { /* ignora nota ilegível */ }
674
+ }
675
+ };
676
+ walk(root);
677
+ }
678
+ return out;
679
+ }
680
+
681
+ function alreadyHasKey(keys, candidate) {
682
+ return !!candidate && keys.some((k) => keysBate(k, candidate));
683
+ }
684
+
685
+ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options = {}) {
686
+ const linked = { decisions: [], bugs: [], learnings: [] };
687
+ const provider = providerMeta(options.provider);
688
+ const loc = getLocale(vaultBase);
689
+ const locF = loc.folders;
690
+ const bugsDir = join(vaultBase, monthFolderRelFromDateStr(locF.bugs, dateStr, vaultBase));
691
+ const decisionsDir = join(vaultBase, monthFolderRelFromDateStr(locF.decisions, dateStr, vaultBase));
692
+ const learningsDir = join(vaultBase, monthFolderRelFromDateStr(locF.learnings, dateStr, vaultBase));
693
+ ensureDir(bugsDir);
694
+ ensureDir(decisionsDir);
695
+ ensureDir(learningsDir);
696
+
697
+ const existingKeys = existingKeysForSession(vaultBase, sessionRel, dateStr);
698
+
699
+ const issueRefs = options.issueRefs?.length ? options.issueRefs : extractIssueRefs(tx);
700
+ const bugDetails = extractBugDetails(tx);
701
+ if (bugDetails) {
702
+ const issueRef = issueRefs[0] || '';
703
+ const bugKey = derivedContentKey(bugDetails.rootCause);
704
+ if (!alreadyHasKey(existingKeys.bugs, bugKey)) {
705
+ const causeSlug = slugify(bugDetails.rootCause, 'bug', 40);
706
+ // Numbered AFTER the dedup guard so a deduplicated note never burns a number.
707
+ const bugNum = getNextDerivedNumber(vaultBase, 'bugs', 'BUG');
708
+ // Keep the tracker ref in the name, but only once — root causes often repeat it.
709
+ const refSlug = issueRef ? slugify(issueRef, '', 20) : '';
710
+ const refPrefix = refSlug && !causeSlug.includes(refSlug) ? `${refSlug}-` : '';
711
+ const fileName = `BUG-${String(bugNum).padStart(4, '0')}-${refPrefix}${causeSlug}.md`;
712
+ const filePath = join(bugsDir, fileName);
713
+ if (!existsSync(filePath)) writeFileSync(filePath, buildBugNoteContent(bugDetails, issueRef, dateStr, sessionRel, provider, bugKey, loc.id, bugNum), 'utf-8');
714
+ linked.bugs.push(toVaultRelative(vaultBase, filePath));
715
+ existingKeys.bugs.push(bugKey);
716
+ }
717
+ }
718
+
719
+ const decisionDetails = extractDecisionDetails(tx);
720
+ if (decisionDetails) {
721
+ const decisionKey = derivedContentKey(decisionDetails.title);
722
+ if (!alreadyHasKey(existingKeys.decisions, decisionKey)) {
723
+ const titleSlug = slugify(decisionDetails.title, 'decisao', 40);
724
+ const existing = adrFileExistsBySlug(decisionsDir, titleSlug);
725
+ const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(4, '0')}-${titleSlug}.md`;
726
+ const filePath = join(decisionsDir, fileName);
727
+ if (!existsSync(filePath)) {
728
+ const adrNum = Number(fileName.match(/^ADR-(\d+)/i)?.[1]) || getNextAdrNumber(vaultBase);
729
+ writeFileSync(filePath, buildDecisionNoteContent(decisionDetails, adrNum, dateStr, sessionRel, provider, decisionKey, loc.id), 'utf-8');
730
+ }
731
+ linked.decisions.push(toVaultRelative(vaultBase, filePath));
732
+ existingKeys.decisions.push(decisionKey);
733
+ }
734
+ }
735
+
736
+ // Agnostic prose decisions (Codex parity): options-in-prose + short answer -> decision note.
737
+ // One integration point covers live Stop, import and backfill, for every provider. Fail-quiet.
738
+ try {
739
+ for (const rel of captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId: loc.id })) {
740
+ linked.decisions.push(rel);
741
+ }
742
+ } catch { /* prose capture é bônus — nunca derruba a captura principal */ }
743
+
744
+ const learnings = extractLearningDetails(tx, bugDetails);
745
+ if (learnings) {
746
+ const vaultLearningKeys = collectLearningKeys(vaultBase); // vault-wide dedup
747
+ for (const learning of learnings) {
748
+ const learningKey = derivedContentKey(learning.title);
749
+ if (alreadyHasKey(existingKeys.learnings, learningKey)) continue;
750
+ if (vaultLearningKeys.has(learningKey)) continue; // already learned elsewhere in the vault
751
+ const learningSlug = slugify(learning.title, 'aprendizado', 40);
752
+ // Minted inside the loop: each learning consumes its own sequential number.
753
+ const aprNum = getNextDerivedNumber(vaultBase, 'learnings', 'APR');
754
+ const fileName = `APR-${String(aprNum).padStart(4, '0')}-${learningSlug}.md`;
755
+ const filePath = join(learningsDir, fileName);
756
+ if (!existsSync(filePath)) writeFileSync(filePath, buildLearningNoteContent(learning, dateStr, sessionRel, provider, learningKey, loc.id, aprNum), 'utf-8');
757
+ linked.learnings.push(toVaultRelative(vaultBase, filePath));
758
+ existingKeys.learnings.push(learningKey);
759
+ }
760
+ }
761
+
762
+ return linked;
763
+ }