wendkeep 0.57.1 → 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.
@@ -245,30 +245,67 @@ export function allChangesState(vaultBase) {
245
245
  return { current, changes, pointerWarning, hash: tasksHashOf(fingerprint.join('\n')) };
246
246
  }
247
247
 
248
- export function renderOpenChanges(state, { tag = 'open_changes' } = {}) {
248
+ function boundInjectionLine(line, maxLineChars) {
249
+ if (!Number.isFinite(maxLineChars) || line.length <= maxLineChars) return line;
250
+ const marker = ' … [linha resumida pelo budget]';
251
+ return `${line.slice(0, Math.max(0, maxLineChars - marker.length))}${marker}`;
252
+ }
253
+
254
+ function capInjectionBlock(lines, { tag, maxBytes, maxLineChars }) {
255
+ const bounded = lines.map((line) => boundInjectionLine(line, maxLineChars));
256
+ if (!Number.isFinite(maxBytes) || Buffer.byteLength(bounded.join('\n'), 'utf8') <= maxBytes) {
257
+ return bounded.join('\n');
258
+ }
259
+ const closing = tag ? `</${tag}>` : '';
260
+ const marker = '- … conteúdo restante omitido pelo budget de injeção.';
261
+ const body = tag ? bounded.slice(0, -1) : bounded;
262
+ const out = [];
263
+ for (const line of body) {
264
+ const candidate = [...out, line, marker, closing].filter(Boolean).join('\n');
265
+ if (Buffer.byteLength(candidate, 'utf8') > maxBytes) break;
266
+ out.push(line);
267
+ }
268
+ // The tag itself and an explicit marker are the minimum valid degraded block.
269
+ if (!out.length && tag) out.push(`<${tag}>`);
270
+ out.push(marker);
271
+ if (closing) out.push(closing);
272
+ return out.join('\n');
273
+ }
274
+
275
+ export function renderOpenChanges(state, {
276
+ tag = 'open_changes',
277
+ currentOnly = false,
278
+ maxBytes = Number.POSITIVE_INFINITY,
279
+ maxLineChars = 320,
280
+ } = {}) {
249
281
  if (!state?.changes?.length && !state?.pointerWarning) return '';
250
282
  const lines = [];
251
283
  if (tag) lines.push(`<${tag}>`);
252
284
  if (state.current) lines.push(`Change atual (comandos sem --change): ${state.current}.`);
253
285
  else lines.push('Nenhuma change atual selecionada; comandos sem --change continuam recusados.');
254
286
  if (state.pointerWarning) lines.push(`Aviso: ${state.pointerWarning}.`);
255
- for (const change of state.changes || []) {
287
+ const visibleChanges = currentOnly
288
+ ? (state.changes || []).filter((change) => change.current)
289
+ : (state.changes || []);
290
+ for (const change of visibleChanges) {
256
291
  const label = change.current ? 'ATUAL' : 'ABERTA';
257
292
  lines.push(`### ${label} — ${change.slug} (${change.openCount} aberta(s), ${change.doneCount} concluída(s))`);
258
293
  if (change.warning) lines.push(`- Aviso: ${change.warning}.`);
259
294
  else if (!change.openTasks.length) lines.push('- Nenhuma tarefa aberta.');
260
295
  else for (const task of change.openTasks) lines.push(`- [ ] ${task.id} ${task.text}`);
261
296
  }
297
+ const omittedChanges = (state.changes || []).length - visibleChanges.length;
298
+ if (omittedChanges > 0) lines.push(`- ${omittedChanges} change(s) não atual(is) omitida(s) pelo budget global.`);
262
299
  if (state.current) lines.push('Para change atual: `wendkeep change done <id>`; antes de archive: `wendkeep verify`.');
263
300
  lines.push('Qualquer agente pode assumir uma change: selecione-a com `wendkeep change use <slug>` ou use `--change <slug>` quando disponível.');
264
301
  if (tag) lines.push(`</${tag}>`);
265
- return lines.join('\n');
302
+ return capInjectionBlock(lines, { tag, maxBytes, maxLineChars });
266
303
  }
267
304
 
268
305
  // Mantém o nome exportado para consumidores internos existentes, mas agora injeta o backlog
269
306
  // completo em vez de ocultar changes não selecionadas.
270
- export function buildActiveChangeInjection(vaultBase) {
271
- return renderOpenChanges(allChangesState(vaultBase));
307
+ export function buildActiveChangeInjection(vaultBase, options = {}) {
308
+ return renderOpenChanges(allChangesState(vaultBase), options);
272
309
  }
273
310
 
274
311
  export function activeChangeLink(vaultBase) {
@@ -36,7 +36,7 @@ export function addLesson(vaultBase, { trigger, lesson, sourceChange = '', dateS
36
36
 
37
37
  // Compact <lessons> block of the most recent lessons (filename-sorted desc; a date prefix
38
38
  // makes that chronological). Budget-capped by `max`. '' when there are none.
39
- export function buildLessonsInjection(vaultBase, { max = 5 } = {}) {
39
+ export function buildLessonsInjection(vaultBase, { max = 5, maxLineChars = 320 } = {}) {
40
40
  const dir = join(vaultBase, '.brain', 'lessons');
41
41
  let files;
42
42
  try { files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort().reverse().slice(0, max); }
@@ -45,7 +45,13 @@ export function buildLessonsInjection(vaultBase, { max = 5 } = {}) {
45
45
  for (const f of files) {
46
46
  try {
47
47
  const body = readFileSync(join(dir, f), 'utf8').replace(/^---[\s\S]*?---\n/, '').trim().split('\n')[0];
48
- if (body) lines.push(`- ${body}`);
48
+ if (body) {
49
+ const line = `- ${body}`;
50
+ const marker = ' … [linha resumida pelo budget]';
51
+ lines.push(line.length <= maxLineChars
52
+ ? line
53
+ : `${line.slice(0, Math.max(0, maxLineChars - marker.length))}${marker}`);
54
+ }
49
55
  } catch { /* skip */ }
50
56
  }
51
57
  return lines.length ? `<lessons>\nLições do projeto (de falhas anteriores):\n${lines.join('\n')}\n</lessons>` : '';
@@ -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
+ }