wendkeep 0.57.2 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { basename, join, relative } from 'node:path';
4
+
5
+ import { sanitizeMemoryText } from './memory-schema.mjs';
6
+
7
+ function canonicalValue(value) {
8
+ if (Array.isArray(value)) return value.map(canonicalValue);
9
+ if (!value || typeof value !== 'object') return value;
10
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
11
+ }
12
+
13
+ function eventId(context, memoryKey, value) {
14
+ const digest = createHash('sha256')
15
+ .update(JSON.stringify([
16
+ context.projectId,
17
+ context.identity?.canonicalConversationId,
18
+ context.activation?.id,
19
+ context.turn?.id,
20
+ memoryKey,
21
+ canonicalValue(value),
22
+ ]))
23
+ .digest('hex')
24
+ .slice(0, 24);
25
+ return `mem-${digest}`;
26
+ }
27
+
28
+ function makeEvent(context, { memoryKey, value, authority, evidence }) {
29
+ const cleanValue = typeof value === 'string' ? sanitizeMemoryText(value) : canonicalValue(value);
30
+ return {
31
+ v: 1,
32
+ event_id: eventId(context, memoryKey, cleanValue),
33
+ project_id: String(context.projectId || ''),
34
+ memory_key: memoryKey,
35
+ operation: 'assert',
36
+ value: cleanValue,
37
+ authority,
38
+ canonical_session_id: String(context.identity?.canonicalConversationId || ''),
39
+ activation_id: String(context.activation?.id || ''),
40
+ activation_epoch: Number(context.activation?.epoch || 0),
41
+ turn_sequence: Number(context.turn?.sequence || 0),
42
+ source_turn_id: String(context.turn?.id || ''),
43
+ observed_at: context.observedAt,
44
+ evidence: (evidence || []).filter(Boolean).map((item) => sanitizeMemoryText(item)),
45
+ };
46
+ }
47
+
48
+ function readJson(path) {
49
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
50
+ }
51
+
52
+ function filesBelow(dir, accept, found = []) {
53
+ let entries = [];
54
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; }
55
+ for (const entry of entries) {
56
+ const path = join(dir, entry.name);
57
+ if (entry.isDirectory()) filesBelow(path, accept, found);
58
+ else if (accept(entry.name)) found.push(path);
59
+ }
60
+ return found;
61
+ }
62
+
63
+ function vaultRel(vaultBase, path) {
64
+ return relative(vaultBase, path).replaceAll('\\', '/');
65
+ }
66
+
67
+ function nextActionFrom(summary) {
68
+ const match = String(summary || '').match(/(?:a\s+)?pr[oó]xima\s+(?:change\s+)?(?:ser[aá]|[ée]|:)\s+(?:a\s+)?([^.!?\n]+)/i);
69
+ if (!match) return null;
70
+ const text = sanitizeMemoryText(match[1].trim());
71
+ const id = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
72
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64);
73
+ return id && text ? { id, summary: text } : null;
74
+ }
75
+
76
+ export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary = '', noteRel = '' } = {}) {
77
+ const evidence = {};
78
+ const slug = String(changeSlug || '').trim();
79
+ if (slug) {
80
+ const changeRoots = ['08-Mudanças', '08-Changes'];
81
+ let archivedDir = '';
82
+ for (const root of changeRoots) {
83
+ const archive = join(vaultBase, root, '_arquivo');
84
+ let names = [];
85
+ try { names = readdirSync(archive, { withFileTypes: true }); } catch { /* absent locale */ }
86
+ const match = names.find((entry) => entry.isDirectory() && (entry.name === slug || entry.name.endsWith(`-${slug}`)));
87
+ if (match) { archivedDir = join(archive, match.name); break; }
88
+ }
89
+
90
+ const adrPattern = new RegExp(`^ADR-(\\d{4})-${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.md$`, 'i');
91
+ const adrPath = filesBelow(vaultBase, (name) => adrPattern.test(name))[0];
92
+ if (archivedDir && adrPath) {
93
+ const adr = (basename(adrPath).match(/^ADR-\d{4}/i) || [''])[0].toUpperCase();
94
+ evidence.change = { slug, status: 'archived', adr, path: vaultRel(vaultBase, adrPath) };
95
+ const verdictPath = join(archivedDir, 'verdict.json');
96
+ const verdict = readJson(verdictPath);
97
+ if (verdict && typeof verdict.ok === 'boolean' && Array.isArray(verdict.coverage)) {
98
+ evidence.verdict = {
99
+ ok: verdict.ok,
100
+ covered: verdict.coverage.filter((item) => item?.covered === true).length,
101
+ total: verdict.coverage.length,
102
+ path: vaultRel(vaultBase, verdictPath),
103
+ };
104
+ }
105
+ const sensorPath = join(archivedDir, 'evidencia.json');
106
+ const sensors = readJson(sensorPath);
107
+ if (Array.isArray(sensors) && sensors.length && sensors.every((item) => item?.status === 'green')) {
108
+ evidence.sensors = [...new Set(sensors.map((item) => String(item.id || '')).filter(Boolean))].sort();
109
+ }
110
+ }
111
+ }
112
+
113
+ const nextAction = nextActionFrom(summary);
114
+ if (nextAction) evidence.nextAction = nextAction;
115
+ const commit = String(summary || '').match(/\b[0-9a-f]{40}\b/i)?.[0];
116
+ if (commit) {
117
+ evidence.git = {
118
+ commit: commit.toLowerCase(),
119
+ pushed: !/(?:nenhum|sem)\s+push/i.test(String(summary || '')),
120
+ verified: false,
121
+ path: noteRel,
122
+ };
123
+ }
124
+ return evidence;
125
+ }
126
+
127
+ export function buildSessionMemoryEvents({
128
+ projectId,
129
+ identity,
130
+ activation,
131
+ turn,
132
+ noteRel,
133
+ observedAt,
134
+ summary,
135
+ evidence = {},
136
+ }) {
137
+ const context = { projectId, identity, activation, turn, observedAt };
138
+ const events = [makeEvent(context, {
139
+ memoryKey: 'handoff.latest',
140
+ value: sanitizeMemoryText(summary),
141
+ authority: 'reported',
142
+ evidence: [noteRel],
143
+ })];
144
+
145
+ if (evidence.change?.slug && evidence.change?.status && evidence.change?.adr) {
146
+ events.push(makeEvent(context, {
147
+ memoryKey: `change.${evidence.change.slug}.status`,
148
+ value: { status: evidence.change.status, adr: evidence.change.adr },
149
+ authority: 'verified',
150
+ evidence: [evidence.change.path || evidence.change.adr],
151
+ }));
152
+ }
153
+
154
+ if (evidence.verdict?.path && typeof evidence.verdict.ok === 'boolean') {
155
+ events.push(makeEvent(context, {
156
+ memoryKey: 'quality.latest-verdict',
157
+ value: {
158
+ ok: evidence.verdict.ok,
159
+ covered: Number(evidence.verdict.covered || 0),
160
+ total: Number(evidence.verdict.total || 0),
161
+ },
162
+ authority: 'verified',
163
+ evidence: [evidence.verdict.path],
164
+ }));
165
+ }
166
+
167
+ if (Array.isArray(evidence.sensors) && evidence.sensors.length) {
168
+ events.push(makeEvent(context, {
169
+ memoryKey: 'quality.latest-sensors',
170
+ value: [...new Set(evidence.sensors.map(String))].sort(),
171
+ authority: 'verified',
172
+ evidence: evidence.sensors,
173
+ }));
174
+ }
175
+
176
+ if (evidence.git?.commit) {
177
+ events.push(makeEvent(context, {
178
+ memoryKey: 'git.local-head',
179
+ value: {
180
+ commit: evidence.git.commit,
181
+ pushed: Boolean(evidence.git.pushed),
182
+ push_status: evidence.git.pushed ? 'pushed' : 'nenhum push',
183
+ },
184
+ authority: evidence.git.verified === false ? 'reported' : 'verified',
185
+ evidence: [evidence.git.path || evidence.git.commit],
186
+ }));
187
+ }
188
+
189
+ if (evidence.nextAction?.id && evidence.nextAction?.summary) {
190
+ events.push(makeEvent(context, {
191
+ memoryKey: `next.${evidence.nextAction.id}`,
192
+ value: sanitizeMemoryText(evidence.nextAction.summary),
193
+ authority: 'verified',
194
+ evidence: [noteRel],
195
+ }));
196
+ }
197
+
198
+ return events;
199
+ }
@@ -0,0 +1,295 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const SHARED_LIMITS = Object.freeze({ lines: 48, bytes: 6144, lineChars: 320 });
4
+
5
+ export const SHARED_SECTIONS = Object.freeze([
6
+ 'Objetivo Atual',
7
+ 'Estado Entregue',
8
+ 'Restrições Ativas',
9
+ 'Decisões em Vigor',
10
+ 'Próximas Ações',
11
+ 'Bloqueios',
12
+ 'Riscos Conhecidos',
13
+ 'Último Handoff',
14
+ ]);
15
+
16
+ const AUTHORITIES = new Set(['verified', 'reported', 'candidate']);
17
+ const OPERATIONS = new Set(['assert', 'replace', 'add', 'remove']);
18
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
19
+ const HARNESS_BLOCK = /<(recommended_plugins|environment_context|apps_instructions|plugins_instructions|skills_instructions)\b[^>]*>[\s\S]*?<\/\1>/gi;
20
+ const WINDOWS_LOCAL_PATH = /\b[A-Z]:\\(?:Users|Documents and Settings)\\[^\r\n"'<>|]*/gi;
21
+ const UNIX_TRANSCRIPT_PATH = /\/(?:home|Users|private|var|tmp)\/[^\r\n"'<>]*(?:rollout|transcript|sessions?)[^\r\n"'<>]*/gi;
22
+ const EMAIL = /\b[\w.%+-]+@(?!(?:[\w.-]+\.)?example\.(?:com|org|net)\b)[\w.-]+\.[A-Za-z]{2,}\b/gi;
23
+
24
+ function stringValue(value) {
25
+ if (typeof value === 'string') return value;
26
+ if (value === null || value === undefined) return '';
27
+ try { return JSON.stringify(value); } catch { return String(value); }
28
+ }
29
+
30
+ /**
31
+ * Pure, idempotent boundary sanitizer used before event persistence and again before
32
+ * projection/injection. It deliberately preserves example.com addresses for docs/tests.
33
+ */
34
+ export function sanitizeMemoryText(value) {
35
+ return stringValue(value)
36
+ .replace(HARNESS_BLOCK, '')
37
+ .replace(/<recommended_plugins\b[^>]*>[\s\S]*?<\/recommended_plugins>/gi, '')
38
+ .replace(/\b(PASSWORD|PASSWD|TOKEN|SECRET|API_KEY|ACCESS_KEY)\s*=\s*[^\s,;]+/gi, '$1=[REDACTED_SECRET]')
39
+ .replace(/(["']?(?:password|passwd|token|secret|api[_-]?key|access[_-]?key)["']?\s*:\s*["'])[^"'\r\n]+(["'])/gi, '$1[REDACTED_SECRET]$2')
40
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, '[REDACTED_SECRET]')
41
+ .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED_SECRET]')
42
+ .replace(/\bsk-(?:ant-)?[A-Za-z0-9_-]{32,}\b/g, '[REDACTED_SECRET]')
43
+ .replace(/\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b/g, '[REDACTED_SECRET]')
44
+ .replace(/\bwhsec_[A-Za-z0-9]{20,}\b/g, '[REDACTED_SECRET]')
45
+ .replace(WINDOWS_LOCAL_PATH, '[REDACTED_LOCAL_PATH]')
46
+ .replace(UNIX_TRANSCRIPT_PATH, '[REDACTED_LOCAL_PATH]')
47
+ .replace(/\btranscript(?:_path)?\s*[=:]\s*[^\s,;]+/gi, 'transcript_path=[REDACTED_LOCAL_PATH]')
48
+ .replace(EMAIL, '[REDACTED_EMAIL]');
49
+ }
50
+
51
+ export function eventBelongsToVault(event, projectId) {
52
+ return typeof event?.project_id === 'string'
53
+ && event.project_id.length > 0
54
+ && typeof projectId === 'string'
55
+ && projectId.length > 0
56
+ && event.project_id === projectId;
57
+ }
58
+
59
+ function requiredString(event, field, errors) {
60
+ if (typeof event[field] !== 'string' || !event[field].trim()) errors.push(`${field} deve ser string não vazia.`);
61
+ }
62
+
63
+ function sanitizedField(event, field, errors) {
64
+ if (!(field in event)) return;
65
+ const raw = stringValue(event[field]);
66
+ if (sanitizeMemoryText(raw) !== raw) errors.push(`${field} contém segredo, PII, path local ou payload de harness não sanitizado.`);
67
+ }
68
+
69
+ /** Validate one immutable ledger/outbox event without reading or writing the vault. */
70
+ export function validateMemoryEvent(event, { projectId } = {}) {
71
+ const errors = [];
72
+ const warnings = [];
73
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
74
+ return { ok: false, errors: ['Evento deve ser um objeto.'], warnings };
75
+ }
76
+
77
+ if (event.v !== 1) errors.push('v deve ser 1.');
78
+ requiredString(event, 'event_id', errors);
79
+ requiredString(event, 'memory_key', errors);
80
+ requiredString(event, 'operation', errors);
81
+ requiredString(event, 'authority', errors);
82
+ requiredString(event, 'activation_id', errors);
83
+ requiredString(event, 'observed_at', errors);
84
+
85
+ if (event.operation && !OPERATIONS.has(event.operation)) {
86
+ errors.push(`operation inválida: ${event.operation}.`);
87
+ }
88
+ if (event.operation !== 'remove' && !Object.hasOwn(event, 'value')) {
89
+ errors.push('value é obrigatório para operation diferente de remove.');
90
+ }
91
+ if (event.authority && !AUTHORITIES.has(event.authority)) {
92
+ errors.push(`authority inválida: ${event.authority}.`);
93
+ }
94
+ if (!Number.isInteger(event.turn_sequence) || event.turn_sequence < 0) {
95
+ errors.push('turn_sequence deve ser inteiro não negativo.');
96
+ }
97
+ if (typeof event.observed_at === 'string'
98
+ && (!ISO_INSTANT.test(event.observed_at) || Number.isNaN(Date.parse(event.observed_at)))) {
99
+ errors.push('observed_at deve ser um instante ISO-8601 UTC.');
100
+ }
101
+ if (!Array.isArray(event.evidence) || event.evidence.some((item) => typeof item !== 'string')) {
102
+ errors.push('evidence deve ser um array de strings.');
103
+ }
104
+ if (event.project_id !== undefined && (typeof event.project_id !== 'string' || !event.project_id)) {
105
+ errors.push('project_id, quando presente, deve ser string não vazia.');
106
+ }
107
+ if (projectId !== undefined && !eventBelongsToVault(event, projectId)) {
108
+ errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
109
+ }
110
+
111
+ for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
112
+ return { ok: errors.length === 0, errors, warnings };
113
+ }
114
+
115
+ function sectionFor(memoryKey) {
116
+ const key = String(memoryKey || '').toLowerCase();
117
+ if (/^(objective|goal)\b/.test(key)) return 'Objetivo Atual';
118
+ if (/^(constraint|restriction)\b/.test(key)) return 'Restrições Ativas';
119
+ if (/^(decision|adr)\b/.test(key)) return 'Decisões em Vigor';
120
+ if (/^(next|action)\b/.test(key)) return 'Próximas Ações';
121
+ if (/^(block|blocker)\b/.test(key)) return 'Bloqueios';
122
+ if (/^risk\b/.test(key)) return 'Riscos Conhecidos';
123
+ if (/^handoff\b/.test(key)) return 'Último Handoff';
124
+ return 'Estado Entregue';
125
+ }
126
+
127
+ function hashProjection(events) {
128
+ if (!events.length) {
129
+ // Must match memory-store.reduceMemoryEvents([]): an empty operational state is still
130
+ // represented by the reducer's canonical {state,tombstones} envelope, not by raw [].
131
+ return createHash('sha256').update(JSON.stringify({ state: {}, tombstones: {} })).digest('hex');
132
+ }
133
+ const canonical = events.map((event) => ({
134
+ event_id: event.event_id,
135
+ memory_key: event.memory_key,
136
+ operation: event.operation,
137
+ value: sanitizeMemoryText(event.value),
138
+ authority: event.authority,
139
+ observed_at: event.observed_at,
140
+ evidence: Array.isArray(event.evidence) ? event.evidence.map(sanitizeMemoryText) : [],
141
+ }));
142
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
143
+ }
144
+
145
+ function eventLine(event) {
146
+ const value = event.operation === 'remove' ? '[removido]' : sanitizeMemoryText(event.value);
147
+ const evidence = Array.isArray(event.evidence) && event.evidence.length
148
+ ? event.evidence.map(sanitizeMemoryText).join(', ')
149
+ : 'none';
150
+ const source = sanitizeMemoryText(event.source_turn_id || event.canonical_session_id || event.activation_id || 'unknown');
151
+ const line = `- [${sanitizeMemoryText(event.event_id)}] ${value} · authority:${sanitizeMemoryText(event.authority)} · source:${source} · as_of:${sanitizeMemoryText(event.observed_at)} · evidence:${evidence}`;
152
+ return line.length <= SHARED_LIMITS.lineChars
153
+ ? line
154
+ : `${line.slice(0, SHARED_LIMITS.lineChars - 1).trimEnd()}…`;
155
+ }
156
+
157
+ function defaultInstant(events) {
158
+ const instants = events
159
+ .map((event) => event.observed_at)
160
+ .filter((value) => typeof value === 'string' && !Number.isNaN(Date.parse(value)))
161
+ .sort();
162
+ return instants.at(-1) || new Date(0).toISOString();
163
+ }
164
+
165
+ /** Render the generated operational projection. Inputs are sanitized a second time. */
166
+ export function renderSharedMemory({
167
+ revision = 0,
168
+ eventCursor = 'none',
169
+ events = [],
170
+ stateHash,
171
+ updatedAt,
172
+ reviewAfter,
173
+ } = {}) {
174
+ const safeEvents = Array.isArray(events) ? events : [];
175
+ const updated = updatedAt || defaultInstant(safeEvents);
176
+ const review = reviewAfter || new Date(Date.parse(updated) + (7 * 24 * 60 * 60 * 1000)).toISOString();
177
+ const grouped = new Map(SHARED_SECTIONS.map((section) => [section, []]));
178
+ for (const event of safeEvents) grouped.get(sectionFor(event.memory_key)).push(eventLine(event));
179
+
180
+ const lines = [
181
+ '---',
182
+ 'schema_version: 2',
183
+ `revision: ${Number.isInteger(revision) ? revision : 0}`,
184
+ `event_cursor: ${sanitizeMemoryText(eventCursor || 'none')}`,
185
+ `state_hash: ${sanitizeMemoryText(stateHash || hashProjection(safeEvents))}`,
186
+ `updated_at: ${sanitizeMemoryText(updated)}`,
187
+ `review_after: ${sanitizeMemoryText(review)}`,
188
+ '---',
189
+ '',
190
+ '# SHARED_MEMORY — projeção operacional gerada',
191
+ '',
192
+ ];
193
+ for (const section of SHARED_SECTIONS) {
194
+ lines.push(`## ${section}`, ...(grouped.get(section).length ? grouped.get(section) : ['- (vazio)']), '');
195
+ }
196
+ return `${lines.join('\n').trimEnd()}\n`;
197
+ }
198
+
199
+ function parseScalar(value) {
200
+ if (/^-?\d+$/.test(value)) return Number(value);
201
+ return value;
202
+ }
203
+
204
+ /** Parse SHARED without throwing so hooks can surface a valid degraded context. */
205
+ export function parseSharedMemory(content) {
206
+ const text = String(content ?? '').replace(/\r\n/g, '\n');
207
+ const errors = [];
208
+ const metadata = {};
209
+ const sections = new Map();
210
+ const match = text.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
211
+ if (!match) {
212
+ return { ok: false, errors: ['Frontmatter de SHARED ausente ou inválido.'], metadata, sections };
213
+ }
214
+ for (const line of match[1].split('\n')) {
215
+ const separator = line.indexOf(':');
216
+ if (separator <= 0) {
217
+ errors.push(`Linha inválida no frontmatter: ${line}`);
218
+ continue;
219
+ }
220
+ const key = line.slice(0, separator).trim();
221
+ metadata[key] = parseScalar(line.slice(separator + 1).trim());
222
+ }
223
+
224
+ let current = null;
225
+ const seenOrder = [];
226
+ for (const line of text.slice(match[0].length).split('\n')) {
227
+ const heading = line.match(/^##\s+(.+?)\s*$/);
228
+ if (heading) {
229
+ current = heading[1];
230
+ if (sections.has(current)) errors.push(`Seção duplicada: ## ${current}`);
231
+ else {
232
+ sections.set(current, []);
233
+ seenOrder.push(current);
234
+ }
235
+ } else if (current && line.trim()) {
236
+ sections.get(current).push(line);
237
+ }
238
+ }
239
+ if (seenOrder.length !== SHARED_SECTIONS.length
240
+ || seenOrder.some((section, index) => section !== SHARED_SECTIONS[index])) {
241
+ errors.push(`Seções fixas ausentes, extras ou fora de ordem: esperado ${SHARED_SECTIONS.join(' | ')}.`);
242
+ }
243
+ return { ok: errors.length === 0, errors, metadata, sections };
244
+ }
245
+
246
+ function actualLineCount(text) {
247
+ const lines = text.split('\n');
248
+ return text.endsWith('\n') ? lines.length - 1 : lines.length;
249
+ }
250
+
251
+ export function validateSharedMemory(content, { eventIds } = {}) {
252
+ const text = String(content ?? '').replace(/\r\n/g, '\n');
253
+ const parsed = parseSharedMemory(text);
254
+ const errors = [...parsed.errors];
255
+ const warnings = [];
256
+ const lineCount = actualLineCount(text);
257
+ const bytes = Buffer.byteLength(text, 'utf8');
258
+
259
+ if (lineCount > SHARED_LIMITS.lines) errors.push(`SHARED tem ${lineCount} linhas; limite ${SHARED_LIMITS.lines}.`);
260
+ if (bytes > SHARED_LIMITS.bytes) errors.push(`SHARED tem ${bytes} bytes; limite ${SHARED_LIMITS.bytes}.`);
261
+ text.split('\n').forEach((line, index) => {
262
+ if (line.length > SHARED_LIMITS.lineChars) {
263
+ errors.push(`Linha ${index + 1} tem ${line.length} caracteres; limite ${SHARED_LIMITS.lineChars}.`);
264
+ }
265
+ });
266
+ if (sanitizeMemoryText(text) !== text) errors.push('SHARED contém segredo, PII, path local ou payload de harness não sanitizado.');
267
+
268
+ const { metadata } = parsed;
269
+ if (metadata.schema_version !== 2) errors.push('schema_version deve ser 2.');
270
+ if (!Number.isInteger(metadata.revision) || metadata.revision < 0) errors.push('revision deve ser inteiro não negativo.');
271
+ for (const key of ['event_cursor', 'state_hash', 'updated_at', 'review_after']) {
272
+ if (typeof metadata[key] !== 'string' || !metadata[key]) errors.push(`${key} é obrigatório.`);
273
+ }
274
+ for (const key of ['updated_at', 'review_after']) {
275
+ if (typeof metadata[key] === 'string'
276
+ && (!ISO_INSTANT.test(metadata[key]) || Number.isNaN(Date.parse(metadata[key])))) {
277
+ errors.push(`${key} deve ser um instante ISO-8601 UTC.`);
278
+ }
279
+ }
280
+ if (eventIds instanceof Set) {
281
+ const cursor = metadata.event_cursor;
282
+ if (cursor !== 'none' && !eventIds.has(cursor)) errors.push(`event_cursor "${cursor}" não existe no ledger.`);
283
+ if (cursor === 'none' && eventIds.size > 0) errors.push('event_cursor none diverge de um ledger não vazio.');
284
+ }
285
+
286
+ return {
287
+ ok: errors.length === 0,
288
+ errors,
289
+ warnings,
290
+ lineCount,
291
+ bytes,
292
+ metadata,
293
+ sections: parsed.sections,
294
+ };
295
+ }