wendkeep 0.38.0 → 0.38.2

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,212 +1,212 @@
1
- #!/usr/bin/env node
2
- import { existsSync, readFileSync } from 'fs';
3
- import { join } from 'path';
4
- import { pathToFileURL } from 'url';
5
- import {
6
- controlPath,
7
- getVaultBase,
8
- listMarkdownFiles,
9
- readControl,
10
- readSessionRegistry,
11
- wikilinkFromRel,
12
- } from './obsidian-common.mjs';
13
- import { getLocale } from './locale.mjs';
14
-
15
- const DEFAULT_PENDING_PATTERNS = [
16
- /^- \[ \] Revisar resumo da sessão$/i,
17
- /^- \[ \] Verificar se houve decisões a registrar$/i,
18
- /^- \[ \] Verificar se houve bugs a registrar$/i,
19
- /^- \[ \] Verificar se houve aprendizados a registrar$/i,
20
- ];
21
-
22
- function parseArgs(argv) {
23
- const args = {};
24
- for (let i = 0; i < argv.length; i += 1) {
25
- const item = argv[i];
26
- if (!item.startsWith('--')) continue;
27
- const key = item.slice(2);
28
- const next = argv[i + 1];
29
- if (!next || next.startsWith('--')) {
30
- args[key] = true;
31
- } else {
32
- args[key] = next;
33
- i += 1;
34
- }
35
- }
36
- return args;
37
- }
38
-
39
- function findDuplicateTurnMarkers(content) {
40
- const seen = new Set();
41
- const duplicated = new Set();
42
- const regex = /<!-- (?:wk-turn|codex-turn): ([^>]+) -->/g;
43
- let match;
44
- while ((match = regex.exec(content)) !== null) {
45
- const turnId = match[1].trim();
46
- if (seen.has(turnId)) duplicated.add(turnId);
47
- seen.add(turnId);
48
- }
49
- return [...duplicated];
50
- }
51
-
52
- function hasHeadingAfterClosing(content) {
53
- const closing = content.indexOf('\n## Encerramento');
54
- if (closing === -1) return false;
55
- return /\n#{2,3} /.test(content.slice(closing + '\n## Encerramento'.length));
56
- }
57
-
58
- function sectionBody(content, heading) {
59
- const marker = `\n## ${heading}\n`;
60
- const start = content.indexOf(marker);
61
- if (start === -1) return '';
62
- const bodyStart = start + marker.length;
63
- const next = content.slice(bodyStart).search(/\n## /);
64
- const bodyEnd = next === -1 ? content.length : bodyStart + next;
65
- return content.slice(bodyStart, bodyEnd);
66
- }
67
-
68
- function hasDefaultPending(content) {
69
- return sectionBody(content, 'Pendências')
70
- .split('\n')
71
- .some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
72
- }
73
-
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import {
6
+ controlPath,
7
+ getVaultBase,
8
+ listMarkdownFiles,
9
+ readControl,
10
+ readSessionRegistry,
11
+ wikilinkFromRel,
12
+ } from './obsidian-common.mjs';
13
+ import { getLocale } from './locale.mjs';
14
+
15
+ const DEFAULT_PENDING_PATTERNS = [
16
+ /^- \[ \] Revisar resumo da sessão$/i,
17
+ /^- \[ \] Verificar se houve decisões a registrar$/i,
18
+ /^- \[ \] Verificar se houve bugs a registrar$/i,
19
+ /^- \[ \] Verificar se houve aprendizados a registrar$/i,
20
+ ];
21
+
22
+ function parseArgs(argv) {
23
+ const args = {};
24
+ for (let i = 0; i < argv.length; i += 1) {
25
+ const item = argv[i];
26
+ if (!item.startsWith('--')) continue;
27
+ const key = item.slice(2);
28
+ const next = argv[i + 1];
29
+ if (!next || next.startsWith('--')) {
30
+ args[key] = true;
31
+ } else {
32
+ args[key] = next;
33
+ i += 1;
34
+ }
35
+ }
36
+ return args;
37
+ }
38
+
39
+ function findDuplicateTurnMarkers(content) {
40
+ const seen = new Set();
41
+ const duplicated = new Set();
42
+ const regex = /<!-- (?:wk-turn|codex-turn): ([^>]+) -->/g;
43
+ let match;
44
+ while ((match = regex.exec(content)) !== null) {
45
+ const turnId = match[1].trim();
46
+ if (seen.has(turnId)) duplicated.add(turnId);
47
+ seen.add(turnId);
48
+ }
49
+ return [...duplicated];
50
+ }
51
+
52
+ function hasHeadingAfterClosing(content) {
53
+ const closing = content.indexOf('\n## Encerramento');
54
+ if (closing === -1) return false;
55
+ return /\n#{2,3} /.test(content.slice(closing + '\n## Encerramento'.length));
56
+ }
57
+
58
+ function sectionBody(content, heading) {
59
+ const marker = `\n## ${heading}\n`;
60
+ const start = content.indexOf(marker);
61
+ if (start === -1) return '';
62
+ const bodyStart = start + marker.length;
63
+ const next = content.slice(bodyStart).search(/\n## /);
64
+ const bodyEnd = next === -1 ? content.length : bodyStart + next;
65
+ return content.slice(bodyStart, bodyEnd);
66
+ }
67
+
68
+ function hasDefaultPending(content) {
69
+ return sectionBody(content, 'Pendências')
70
+ .split('\n')
71
+ .some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
72
+ }
73
+
74
74
  function usageSectionIsPlaced(content, { active = false } = {}) {
75
75
  const unified = content.indexOf('\n## Agentes, tokens e custos');
76
76
  const legacy = content.indexOf('\n## Uso de tokens e custos');
77
77
  const usage = unified !== -1 ? unified : legacy;
78
- if (usage === -1) return true;
79
- const changed = content.indexOf('\n## Arquivos criados ou alterados');
80
- const pending = content.indexOf('\n## Pendências');
81
- const closing = content.indexOf('\n## Encerramento');
78
+ if (usage === -1) return true;
79
+ const changed = content.indexOf('\n## Arquivos criados ou alterados');
80
+ const pending = content.indexOf('\n## Pendências');
81
+ const closing = content.indexOf('\n## Encerramento');
82
82
  if (pending === -1 || (!active && closing === -1)) return false;
83
83
  return usage < pending && (active || usage < closing) && (changed === -1 || usage > changed);
84
- }
85
-
86
- function linkedNotesFromSession(content) {
87
- const notes = [];
88
- const regex = /\[\[((?:04-Decisões|05-Bugs|06-Aprendizados)\/[^\]]+)\]\]/g;
89
- let match;
90
- while ((match = regex.exec(content)) !== null) {
91
- if (!notes.includes(match[1])) notes.push(match[1]);
92
- }
93
- return notes;
94
- }
95
-
96
- function checkSession({ vaultBase, sessionRel, control, registry }) {
97
- const failures = [];
98
- const warnings = [];
99
- const metrics = {};
100
- const sessionPath = join(vaultBase, sessionRel);
101
-
102
- if (!existsSync(sessionPath)) {
103
- failures.push(`Sessão não encontrada: ${sessionRel}`);
104
- return { failures, warnings, metrics };
105
- }
106
-
84
+ }
85
+
86
+ function linkedNotesFromSession(content) {
87
+ const notes = [];
88
+ const regex = /\[\[((?:04-Decisões|05-Bugs|06-Aprendizados)\/[^\]]+)\]\]/g;
89
+ let match;
90
+ while ((match = regex.exec(content)) !== null) {
91
+ if (!notes.includes(match[1])) notes.push(match[1]);
92
+ }
93
+ return notes;
94
+ }
95
+
96
+ function checkSession({ vaultBase, sessionRel, control, registry }) {
97
+ const failures = [];
98
+ const warnings = [];
99
+ const metrics = {};
100
+ const sessionPath = join(vaultBase, sessionRel);
101
+
102
+ if (!existsSync(sessionPath)) {
103
+ failures.push(`Sessão não encontrada: ${sessionRel}`);
104
+ return { failures, warnings, metrics };
105
+ }
106
+
107
107
  const content = readFileSync(sessionPath, 'utf-8');
108
108
  const activeSession = control.status === 'active' && control.session_file === sessionRel;
109
- const duplicates = findDuplicateTurnMarkers(content);
110
- metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
111
- metrics.duplicateTurnMarkers = duplicates.length;
112
-
109
+ const duplicates = findDuplicateTurnMarkers(content);
110
+ metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
111
+ metrics.duplicateTurnMarkers = duplicates.length;
112
+
113
113
  if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
114
114
  if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
115
115
  if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('A seção de agentes, tokens e custos está fora da posição esperada.');
116
116
  if (content.includes('\n## Agentes, tokens e custos') && (content.includes('\n## Uso de tokens e custos') || content.includes('\n## Subagents & Workflows'))) {
117
117
  failures.push('A sessão mistura observabilidade consolidada e seções legadas.');
118
118
  }
119
- if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
120
-
119
+ if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
120
+
121
121
  const registryPair = Object.entries(registry.sessions || {}).find(([, entry]) => entry?.session_file === sessionRel);
122
122
  const registryEntry = registryPair?.[1];
123
123
  if (!registryEntry) {
124
124
  failures.push(`SESSION_REGISTRY não possui a sessão: ${sessionRel}`);
125
125
  } else {
126
- if (!registryEntry.transcript_path) {
127
- warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
128
- } else if (!existsSync(registryEntry.transcript_path)) {
129
- warnings.push(`Transcript da sessão ativa não encontrado: ${registryEntry.transcript_path}`);
130
- }
131
- }
132
-
133
- const sessionLink = wikilinkFromRel(sessionRel);
134
- for (const noteRel of linkedNotesFromSession(content)) {
135
- const notePath = join(vaultBase, noteRel.endsWith('.md') ? noteRel : `${noteRel}.md`);
136
- if (!existsSync(notePath)) {
137
- failures.push(`Nota derivada linkada não existe: ${noteRel}`);
138
- continue;
139
- }
140
- const noteContent = readFileSync(notePath, 'utf-8');
141
- if (!noteContent.includes(sessionLink) && !noteContent.includes(sessionRel)) {
142
- failures.push(`Nota derivada sem backlink para a sessão: ${noteRel}`);
143
- }
144
- }
145
-
146
- return { failures, warnings, metrics };
147
- }
148
-
149
- export function runVaultHealth({ vaultBase, session = '' }) {
150
- const control = readControl(vaultBase);
151
- const registry = readSessionRegistry(vaultBase);
152
- const sessionRel = session || control.session_file || control.last_session_file || '';
153
- const failures = [];
154
- const warnings = [];
155
-
156
- if (!existsSync(controlPath(vaultBase))) {
157
- failures.push('CURRENT_SESSION.md não encontrado.');
158
- }
159
- if (!sessionRel) failures.push('Nenhuma sessão ativa ou última sessão encontrada no controle.');
160
-
161
- const sessionResult = sessionRel
162
- ? checkSession({ vaultBase, sessionRel, control, registry })
163
- : { failures: [], warnings: [], metrics: {} };
164
- failures.push(...sessionResult.failures);
165
- warnings.push(...sessionResult.warnings);
166
-
126
+ if (!registryEntry.transcript_path) {
127
+ warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
128
+ } else if (!existsSync(registryEntry.transcript_path)) {
129
+ warnings.push(`Transcript da sessão ativa não encontrado: ${registryEntry.transcript_path}`);
130
+ }
131
+ }
132
+
133
+ const sessionLink = wikilinkFromRel(sessionRel);
134
+ for (const noteRel of linkedNotesFromSession(content)) {
135
+ const notePath = join(vaultBase, noteRel.endsWith('.md') ? noteRel : `${noteRel}.md`);
136
+ if (!existsSync(notePath)) {
137
+ failures.push(`Nota derivada linkada não existe: ${noteRel}`);
138
+ continue;
139
+ }
140
+ const noteContent = readFileSync(notePath, 'utf-8');
141
+ if (!noteContent.includes(sessionLink) && !noteContent.includes(sessionRel)) {
142
+ failures.push(`Nota derivada sem backlink para a sessão: ${noteRel}`);
143
+ }
144
+ }
145
+
146
+ return { failures, warnings, metrics };
147
+ }
148
+
149
+ export function runVaultHealth({ vaultBase, session = '' }) {
150
+ const control = readControl(vaultBase);
151
+ const registry = readSessionRegistry(vaultBase);
152
+ const sessionRel = session || control.session_file || control.last_session_file || '';
153
+ const failures = [];
154
+ const warnings = [];
155
+
156
+ if (!existsSync(controlPath(vaultBase))) {
157
+ failures.push('CURRENT_SESSION.md não encontrado.');
158
+ }
159
+ if (!sessionRel) failures.push('Nenhuma sessão ativa ou última sessão encontrada no controle.');
160
+
161
+ const sessionResult = sessionRel
162
+ ? checkSession({ vaultBase, sessionRel, control, registry })
163
+ : { failures: [], warnings: [], metrics: {} };
164
+ failures.push(...sessionResult.failures);
165
+ warnings.push(...sessionResult.warnings);
166
+
167
167
  const staleDone = Object.values(registry.sessions || {})
168
- .filter((item) => item.status === 'active' && item.ended_at)
169
- .length;
168
+ .filter((item) => item.status === 'active' && item.ended_at)
169
+ .length;
170
170
  if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
171
171
  const activeEntries = Object.values(registry.sessions || {}).filter((item) => item?.status === 'active');
172
172
  for (const entry of activeEntries) {
173
173
  if (!entry.session_file) failures.push('SESSION_REGISTRY possui sessão ativa sem session_file.');
174
174
  if (!entry.transcript_path) warnings.push(`Sessão ativa sem transcript_path: ${entry.session_file || '(sem arquivo)'}`);
175
175
  }
176
-
177
- const locF = getLocale(vaultBase).folders;
178
- const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
179
- const derivedCount = derivedFolders.reduce((total, folder) => {
180
- const dir = join(vaultBase, folder);
181
- return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
182
- }, 0);
183
-
184
- return {
185
- ok: failures.length === 0,
186
- session: sessionRel,
187
- failures,
188
- warnings,
189
- metrics: {
190
- ...sessionResult.metrics,
191
- registrySessions: Object.keys(registry.sessions || {}).length,
192
- derivedNotes: derivedCount,
193
- },
194
- };
195
- }
196
-
197
- function main() {
198
- const args = parseArgs(process.argv.slice(2));
199
- const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
200
- const result = runVaultHealth({ vaultBase, session: args.session || '' });
201
- console.log(JSON.stringify(result, null, 2));
202
- if (!result.ok) process.exitCode = 1;
203
- }
204
-
205
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
206
- try {
207
- main();
208
- } catch (error) {
209
- process.stderr.write(`[wendkeep] Vault health falhou: ${error.message}\n`);
210
- process.exitCode = 1;
211
- }
212
- }
176
+
177
+ const locF = getLocale(vaultBase).folders;
178
+ const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
179
+ const derivedCount = derivedFolders.reduce((total, folder) => {
180
+ const dir = join(vaultBase, folder);
181
+ return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
182
+ }, 0);
183
+
184
+ return {
185
+ ok: failures.length === 0,
186
+ session: sessionRel,
187
+ failures,
188
+ warnings,
189
+ metrics: {
190
+ ...sessionResult.metrics,
191
+ registrySessions: Object.keys(registry.sessions || {}).length,
192
+ derivedNotes: derivedCount,
193
+ },
194
+ };
195
+ }
196
+
197
+ function main() {
198
+ const args = parseArgs(process.argv.slice(2));
199
+ const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
200
+ const result = runVaultHealth({ vaultBase, session: args.session || '' });
201
+ console.log(JSON.stringify(result, null, 2));
202
+ if (!result.ok) process.exitCode = 1;
203
+ }
204
+
205
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
206
+ try {
207
+ main();
208
+ } catch (error) {
209
+ process.stderr.write(`[wendkeep] Vault health falhou: ${error.message}\n`);
210
+ process.exitCode = 1;
211
+ }
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,9 @@
20
20
  },
21
21
  "scripts": {
22
22
  "check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs",
23
- "test": "node --test"
23
+ "test": "node --test",
24
+ "release": "node scripts/release.mjs",
25
+ "release:dry": "node scripts/release.mjs --dry-run"
24
26
  },
25
27
  "keywords": [
26
28
  "claude-code",
@@ -42,5 +44,8 @@
42
44
  "homepage": "https://github.com/rogersialves/wendkeep#readme",
43
45
  "bugs": {
44
46
  "url": "https://github.com/rogersialves/wendkeep/issues"
47
+ },
48
+ "devDependencies": {
49
+ "wendkeep": "^0.38.1"
45
50
  }
46
51
  }
@@ -0,0 +1,38 @@
1
+ // Pure helper: extract a single version's release notes from a Keep-a-Changelog
2
+ // file. Reused by scripts/release.mjs and .github/workflows/release.yml so the
3
+ // GitHub Release body always matches the committed CHANGELOG.
4
+
5
+ const HEADER_RE = /^##\s*\[([^\]]+)\]\s*[—–-]\s*(.+?)\s*$/;
6
+
7
+ /**
8
+ * @param {string} changelogText Full CHANGELOG.md contents.
9
+ * @param {string} version Version to extract (with or without leading "v").
10
+ * @returns {{ version: string, date: string, notes: string }}
11
+ * @throws if the version has no section.
12
+ */
13
+ export function extractReleaseNotes(changelogText, version) {
14
+ const target = String(version).replace(/^v/i, '').trim();
15
+ const lines = String(changelogText).split(/\r?\n/);
16
+
17
+ let start = -1;
18
+ let date = '';
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const m = lines[i].match(HEADER_RE);
21
+ if (m && m[1].trim() === target) {
22
+ start = i;
23
+ date = m[2].trim();
24
+ break;
25
+ }
26
+ }
27
+ if (start === -1) {
28
+ throw new Error(`CHANGELOG: versão ${version} não encontrada`);
29
+ }
30
+
31
+ const body = [];
32
+ for (let j = start + 1; j < lines.length; j++) {
33
+ if (HEADER_RE.test(lines[j])) break;
34
+ body.push(lines[j]);
35
+ }
36
+
37
+ return { version: target, date, notes: body.join('\n').trim() };
38
+ }