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.
- package/CHANGELOG.md +19 -0
- package/hooks/brain-inject.mjs +74 -74
- package/hooks/obsidian-common.mjs +586 -586
- package/hooks/pricing.json +53 -53
- package/hooks/session-ensure.mjs +377 -377
- package/hooks/session-identity.mjs +22 -0
- package/hooks/session-observability.mjs +1 -1
- package/hooks/session-start.mjs +299 -299
- package/hooks/session-stop.mjs +1132 -1132
- package/hooks/subagent-stop.mjs +2 -2
- package/hooks/task-log.mjs +2 -2
- package/hooks/token-usage.mjs +949 -944
- package/hooks/vault-health.mjs +186 -186
- package/package.json +7 -2
- package/src/release-changelog.mjs +38 -0
package/hooks/vault-health.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|