wendkeep 0.66.0 → 0.66.1

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 CHANGED
@@ -4,6 +4,18 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.66.1] — 2026-07-29
8
+
9
+ ### Fixed
10
+
11
+ - **A curadoria de candidates agora é durável e idempotente.** `memory promote` e
12
+ `memory reject` registram decisões causais no ledger; repair/replay não recriam o conflito
13
+ resolvido, retries não duplicam eventos, candidates sobrepostos permanecem isolados e a
14
+ promoção recupera o checkpoint divergente do attempt correspondente sem tocar attempt novo.
15
+ - **Promoção de conflito exige escolha explícita.** `memory promote <candidate> --event
16
+ <event-id>` publica exatamente o evento escolhido, rejeita IDs externos ao candidate e não
17
+ permite que `blocked_by_core` sobrescreva a autoridade canônica de CORE.
18
+
7
19
  ## [0.66.0] — 2026-07-29
8
20
 
9
21
  ### Added
package/README.en.md CHANGED
@@ -294,7 +294,10 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
294
294
  ambiguity uses `memory reconcile <session> --by-session <successor>
295
295
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
296
296
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
297
- explicit curation with `memory promote <id>` or `memory reject <id>`; doctor only diagnoses.
297
+ explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
298
+ candidate, while `memory reject <id>` keeps the current value. Decisions are idempotent and
299
+ survive repair/replay; `blocked_by_core` cannot override CORE. Doctor only diagnoses. See
300
+ [memory and curation](docs/en/commands/memory.md).
298
301
 
299
302
  Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
300
303
 
package/README.md CHANGED
@@ -294,7 +294,10 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
294
294
  ambiguity uses `memory reconcile <session> --by-session <successor>
295
295
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
296
296
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
297
- explicit curation with `memory promote <id>` or `memory reject <id>`; doctor only diagnoses.
297
+ explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
298
+ candidate, while `memory reject <id>` keeps the current value. Decisions are idempotent and
299
+ survive repair/replay; `blocked_by_core` cannot override CORE. Doctor only diagnoses. See
300
+ [memory and curation](docs/en/commands/memory.md).
298
301
 
299
302
  Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
300
303
 
@@ -64,7 +64,8 @@ to revision 1. Replaying that prompt or Stop does not duplicate the event/revisi
64
64
  - Partial/corrupt v2 bundle: use status and repair; migration is not a corruption tool.
65
65
  - First post-migration Stop is `ambiguous`: verify that its `turn_id` belongs to the transcript and
66
66
  that `UserPromptSubmit` opened/advanced the recovery activation.
67
- - Many candidates: curate gradually with `memory promote`/`memory reject`.
67
+ - Many candidates: curate gradually with `memory promote`/`memory reject`; conflicts require
68
+ `memory promote <candidate> --event <event-id>` to choose the winner explicitly.
68
69
  - Legacy warning remains after apply: verify the selected vault and project binding.
69
70
 
70
71
  ## Next steps
@@ -26,7 +26,7 @@ Pass the vault explicitly in automation. Preserve backups and evidence before re
26
26
  npx wendkeep memory status [--gate] --vault <vault>
27
27
  npx wendkeep memory repair --vault <vault>
28
28
  npx wendkeep memory reconcile <ambiguous-session> --by-session <successor-session> --reason <reason> [--apply] --vault <vault>
29
- npx wendkeep memory promote <candidate> --vault <vault>
29
+ npx wendkeep memory promote <candidate> [--event <event-id>] --vault <vault>
30
30
  npx wendkeep memory reject <candidate> --vault <vault>
31
31
  npx wendkeep validate-memory [CORE-path]
32
32
  npx wendkeep validate-memory --vault <v2-vault>
@@ -63,7 +63,14 @@ npx wendkeep validate-memory --vault <v2-vault>
63
63
  Junctions, symlinks, reparse points, or hardlinks fail closed without touching external bytes.
64
64
  Locks publish owner and lease atomically, never reap a live PID by age alone, and release only
65
65
  the lease they acquired.
66
- - `promote`/`reject` append auditable decisions and never rewrite the ledger in place.
66
+ - `promote`/`reject` append an auditable, idempotent decision to the ledger. Replay and repair
67
+ preserve that decision and do not recreate the resolved candidate. For a `conflict` candidate,
68
+ `promote` requires an `--event <event-id>` that belongs to the candidate; date or random ID
69
+ never picks an implicit winner. `reject` preserves the current operational value. A
70
+ `blocked_by_core` candidate can only be rejected: promotion first requires canonical CORE
71
+ curation. If the selected event still belongs to the matching latest `projected` attempt,
72
+ promotion also refreshes its checkpoint and mirror causally; JSON reports
73
+ `checkpointRefreshed`, and a newer concurrent attempt remains untouched.
67
74
  - `validate-memory <CORE.md>` checks the 25-line cap, required sections, and secrets.
68
75
  - `validate-memory --vault` requires a complete v2 bundle and is not the legacy-vault gate.
69
76
 
@@ -74,7 +81,8 @@ npx wendkeep memory status --gate --vault .MyApp-vault
74
81
  npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --vault .MyApp-vault
75
82
  npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --apply --vault .MyApp-vault
76
83
  npx wendkeep validate-memory .MyApp-vault/.brain/CORE.md
77
- npx wendkeep memory promote candidate-123 --vault .MyApp-vault
84
+ npx wendkeep memory promote candidate-123 --event mem-selected --vault .MyApp-vault
85
+ npx wendkeep memory reject candidate-456 --vault .MyApp-vault
78
86
  ```
79
87
 
80
88
  ## Expected result
@@ -97,6 +105,9 @@ of a global projection that has already advanced with concurrent events.
97
105
  `memory reconcile` dry run before authorizing `--apply`; the command fails when the ambiguous
98
106
  attempt already contains event IDs.
99
107
  - Ordinary pending candidate: recoverable warning, requiring human choice when appropriate.
108
+ - `promote` reports that `--event` is required: inspect the candidate `event_ids`, compare their
109
+ provenance/value, and name the winner explicitly. An ID outside the candidate fails without
110
+ mutating the ledger or projections.
100
111
  - Missing `event_cursor` or mismatched v2 hash: preserve the bundle and assess `memory repair`.
101
112
  - `validate-memory --vault` fails on legacy: validate CORE only or migrate first.
102
113
 
@@ -64,7 +64,8 @@ revision 1. Repetir esse prompt ou Stop não duplica evento/revision.
64
64
  - Bundle v2 parcial/corrompido: use status e repair; migração não é ferramenta de corrupção.
65
65
  - Primeiro Stop pós-migração fica `ambiguous`: confirme que o `turn_id` pertence ao transcript e
66
66
  que `UserPromptSubmit` abriu/avançou a activation de recuperação.
67
- - Candidates numerosos: curate gradualmente com `memory promote`/`memory reject`.
67
+ - Candidates numerosos: curate gradualmente com `memory promote`/`memory reject`; conflitos
68
+ exigem `memory promote <candidate> --event <event-id>` para escolher o vencedor explicitamente.
68
69
  - Warning legado após apply: confirme o vault efetivamente selecionado e o vínculo do projeto.
69
70
 
70
71
  ## Próximos passos
@@ -26,7 +26,7 @@ Informe o vault explicitamente em automações. Preserve backups e evidências a
26
26
  npx wendkeep memory status [--gate] --vault <cofre>
27
27
  npx wendkeep memory repair --vault <cofre>
28
28
  npx wendkeep memory reconcile <sessão-ambígua> --by-session <sessão-sucessora> --reason <motivo> [--apply] --vault <cofre>
29
- npx wendkeep memory promote <candidate> --vault <cofre>
29
+ npx wendkeep memory promote <candidate> [--event <event-id>] --vault <cofre>
30
30
  npx wendkeep memory reject <candidate> --vault <cofre>
31
31
  npx wendkeep validate-memory [caminho-do-CORE]
32
32
  npx wendkeep validate-memory --vault <cofre-v2>
@@ -61,7 +61,13 @@ npx wendkeep validate-memory --vault <cofre-v2>
61
61
  candidates, registry, notas, backups, temporários e sidecars antes de ler ou escrever. Junction,
62
62
  symlink, reparse point ou hardlink falham fechados sem tocar bytes externos. Locks publicam owner
63
63
  e lease atomicamente, não colhem PID vivo apenas por idade e só liberam a lease adquirida.
64
- - `promote`/`reject` acrescentam decisão auditável; nunca reescrevem o ledger no lugar.
64
+ - `promote`/`reject` acrescentam uma decisão auditável e idempotente ao ledger. Replay e repair
65
+ preservam a decisão e não recriam o candidate resolvido. Para candidate `conflict`, `promote`
66
+ exige `--event <event-id>` pertencente ao candidate; não há vencedor implícito por data ou ID.
67
+ `reject` preserva o valor operacional atual. Candidate `blocked_by_core` só pode ser rejeitado:
68
+ promover exige antes alterar CORE pela curadoria canônica. Se o evento escolhido ainda pertence
69
+ ao último attempt `projected` correspondente, a promoção também atualiza causalmente checkpoint
70
+ e espelho; o JSON retorna `checkpointRefreshed`, e um attempt concorrente mais novo não é tocado.
65
71
  - `validate-memory <CORE.md>` valida cap de 25 linhas, seções e segredos.
66
72
  - `validate-memory --vault` exige bundle v2 completo; não é o gate correto para vault legado.
67
73
 
@@ -72,7 +78,8 @@ npx wendkeep memory status --gate --vault .MeuApp-vault
72
78
  npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --vault .MeuApp-vault
73
79
  npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --apply --vault .MeuApp-vault
74
80
  npx wendkeep validate-memory .MeuApp-vault/.brain/CORE.md
75
- npx wendkeep memory promote candidate-123 --vault .MeuApp-vault
81
+ npx wendkeep memory promote candidate-123 --event mem-escolhido --vault .MeuApp-vault
82
+ npx wendkeep memory reject candidate-456 --vault .MeuApp-vault
76
83
  ```
77
84
 
78
85
  ## Resultado esperado
@@ -94,6 +101,9 @@ prefixo válido de uma projeção global que já avançou com eventos concorrent
94
101
  ambiguidade for comprovadamente substituída por uma sessão sucessora, revise o dry-run de
95
102
  `memory reconcile` antes de autorizar `--apply`; o comando falha se o attempt ambíguo tiver IDs.
96
103
  - Candidate pendente comum: warning recuperável, exige decisão humana quando apropriado.
104
+ - `promote` informa que `--event` é obrigatório: leia os `event_ids` do candidate, compare a
105
+ proveniência/valor e indique explicitamente o vencedor. ID que não pertence ao candidate falha
106
+ sem mutar ledger ou projeções.
97
107
  - `event_cursor` ausente ou hash divergente em v2: preserve o bundle e avalie `memory repair`.
98
108
  - `validate-memory --vault` falha no legado: valide apenas CORE ou migre primeiro.
99
109
 
@@ -1,159 +1,159 @@
1
- // .agent/hooks/brain-core.mjs
2
- // Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
3
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { basename, join } from 'node:path';
5
- import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
- import { getLocale } from './locale.mjs';
7
-
8
- export function brainDir(vaultBase) {
9
- return join(vaultBase, '.brain');
10
- }
11
-
12
- // Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
13
- export function parseFrontmatter(content) {
14
- const m = content.match(/^---\n([\s\S]*?)\n---/);
15
- if (!m) return {};
16
- const data = {};
17
- const lines = m[1].split('\n');
18
- for (let i = 0; i < lines.length; i++) {
19
- const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
20
- if (!kv) continue;
21
- const key = kv[1];
22
- const val = kv[2];
23
- if (val === '') {
24
- const list = [];
25
- while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
26
- list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
27
- }
28
- data[key] = list.length ? list : '';
29
- } else {
30
- data[key] = stripYamlQuotes(val.trim());
31
- }
32
- }
33
- return data;
34
- }
35
-
36
- function walkMd(dir) {
37
- const out = [];
38
- let entries;
39
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
40
- for (const e of entries) {
41
- const fp = join(dir, e.name);
42
- if (e.isDirectory()) out.push(...walkMd(fp));
43
- else if (e.name.endsWith('.md')) out.push(fp);
44
- }
45
- return out;
46
- }
47
-
48
- const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
49
- function derivedLinks(content) {
50
- const dec = new Set(), bug = new Set(), lea = new Set();
51
- let m;
52
- while ((m = DERIVED_RE.exec(content))) {
53
- const t = m[1];
54
- if (t.startsWith('04-')) dec.add(t);
55
- else if (t.startsWith('05-')) bug.add(t);
56
- else if (t.startsWith('06-')) lea.add(t);
57
- }
58
- return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
59
- }
60
-
61
- // Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
62
- export function buildBrainIndex(vaultBase) {
63
- const rows = [];
64
- for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
65
- let content;
66
- try { content = readFileSync(fp, 'utf8'); } catch { continue; }
67
- const fm = parseFrontmatter(content);
68
- if (fm.type && fm.type !== 'session') continue;
69
- const der = derivedLinks(content);
70
- rows.push({
71
- session_id: fm.session_id || '',
72
- date: fm.date || '',
73
- provider: fm.provider || '',
74
- status: fm.status || '',
75
- summary: fm.summary || '',
76
- file: toVaultRelative(vaultBase, fp),
77
- tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
78
- decisions: der.decisions,
79
- bugs: der.bugs,
80
- learnings: der.learnings,
81
- });
82
- }
83
- rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
84
- ensureDir(brainDir(vaultBase));
85
- const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
86
- writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
87
- return rows;
88
- }
89
-
90
- // Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
91
- export function loadIndex(vaultBase) {
92
- try {
93
- return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
94
- .split('\n').filter(Boolean).map((l) => JSON.parse(l));
95
- } catch {
96
- return [];
97
- }
98
- }
99
-
100
- const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
101
-
102
- function adrNumber(path) {
103
- const m = path.match(/ADR-(\d+)/);
104
- return m ? Number(m[1]) : -1;
105
- }
106
-
107
- // Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
108
- // Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
109
- export function buildBrainDigest(vaultBase, rows = null) {
110
- const data = rows ?? loadIndex(vaultBase);
111
- const byDateDesc = [...data].sort((a, b) =>
112
- String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
113
-
114
- const seen = new Set();
115
- const pick = (kind, max) => {
116
- const out = [];
117
- for (const r of byDateDesc) {
118
- for (const p of r[kind] || []) {
119
- if (out.length >= max) return out;
120
- if (!seen.has(p)) { seen.add(p); out.push(p); }
121
- }
122
- }
123
- return out;
124
- };
125
-
126
- // The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
127
- // context. Keep only targets that resolve to a real note (by vault-relative path or basename)
128
- // and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
129
- // caps still fill after filtering.
130
- const known = new Set();
131
- for (const r of data) {
132
- const rel = String(r.file || '').replace(/\.md$/i, '');
133
- if (rel) { known.add(rel); known.add(basename(rel)); }
134
- }
135
- const resolves = (p) => {
136
- const t = String(p || '').replace(/\.md$/i, '').trim();
137
- if (!t || t.includes('...') || t.includes('…')) return false;
138
- return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
139
- };
140
- const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
141
-
142
- const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
143
- const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
144
- const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
145
- const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
146
-
147
- const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
148
- for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
149
- for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
150
- for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
151
- for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
152
-
153
- const shown = sessions.length;
154
- if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
155
-
156
- ensureDir(brainDir(vaultBase));
157
- writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
158
- return lines;
159
- }
1
+ // .agent/hooks/brain-core.mjs
2
+ // Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
3
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { basename, join } from 'node:path';
5
+ import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
+ import { getLocale } from './locale.mjs';
7
+
8
+ export function brainDir(vaultBase) {
9
+ return join(vaultBase, '.brain');
10
+ }
11
+
12
+ // Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
13
+ export function parseFrontmatter(content) {
14
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
15
+ if (!m) return {};
16
+ const data = {};
17
+ const lines = m[1].split('\n');
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
20
+ if (!kv) continue;
21
+ const key = kv[1];
22
+ const val = kv[2];
23
+ if (val === '') {
24
+ const list = [];
25
+ while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
26
+ list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
27
+ }
28
+ data[key] = list.length ? list : '';
29
+ } else {
30
+ data[key] = stripYamlQuotes(val.trim());
31
+ }
32
+ }
33
+ return data;
34
+ }
35
+
36
+ function walkMd(dir) {
37
+ const out = [];
38
+ let entries;
39
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
40
+ for (const e of entries) {
41
+ const fp = join(dir, e.name);
42
+ if (e.isDirectory()) out.push(...walkMd(fp));
43
+ else if (e.name.endsWith('.md')) out.push(fp);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
49
+ function derivedLinks(content) {
50
+ const dec = new Set(), bug = new Set(), lea = new Set();
51
+ let m;
52
+ while ((m = DERIVED_RE.exec(content))) {
53
+ const t = m[1];
54
+ if (t.startsWith('04-')) dec.add(t);
55
+ else if (t.startsWith('05-')) bug.add(t);
56
+ else if (t.startsWith('06-')) lea.add(t);
57
+ }
58
+ return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
59
+ }
60
+
61
+ // Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
62
+ export function buildBrainIndex(vaultBase) {
63
+ const rows = [];
64
+ for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
65
+ let content;
66
+ try { content = readFileSync(fp, 'utf8'); } catch { continue; }
67
+ const fm = parseFrontmatter(content);
68
+ if (fm.type && fm.type !== 'session') continue;
69
+ const der = derivedLinks(content);
70
+ rows.push({
71
+ session_id: fm.session_id || '',
72
+ date: fm.date || '',
73
+ provider: fm.provider || '',
74
+ status: fm.status || '',
75
+ summary: fm.summary || '',
76
+ file: toVaultRelative(vaultBase, fp),
77
+ tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
78
+ decisions: der.decisions,
79
+ bugs: der.bugs,
80
+ learnings: der.learnings,
81
+ });
82
+ }
83
+ rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
84
+ ensureDir(brainDir(vaultBase));
85
+ const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
86
+ writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
87
+ return rows;
88
+ }
89
+
90
+ // Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
91
+ export function loadIndex(vaultBase) {
92
+ try {
93
+ return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
94
+ .split('\n').filter(Boolean).map((l) => JSON.parse(l));
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
101
+
102
+ function adrNumber(path) {
103
+ const m = path.match(/ADR-(\d+)/);
104
+ return m ? Number(m[1]) : -1;
105
+ }
106
+
107
+ // Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
108
+ // Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
109
+ export function buildBrainDigest(vaultBase, rows = null) {
110
+ const data = rows ?? loadIndex(vaultBase);
111
+ const byDateDesc = [...data].sort((a, b) =>
112
+ String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
113
+
114
+ const seen = new Set();
115
+ const pick = (kind, max) => {
116
+ const out = [];
117
+ for (const r of byDateDesc) {
118
+ for (const p of r[kind] || []) {
119
+ if (out.length >= max) return out;
120
+ if (!seen.has(p)) { seen.add(p); out.push(p); }
121
+ }
122
+ }
123
+ return out;
124
+ };
125
+
126
+ // The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
127
+ // context. Keep only targets that resolve to a real note (by vault-relative path or basename)
128
+ // and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
129
+ // caps still fill after filtering.
130
+ const known = new Set();
131
+ for (const r of data) {
132
+ const rel = String(r.file || '').replace(/\.md$/i, '');
133
+ if (rel) { known.add(rel); known.add(basename(rel)); }
134
+ }
135
+ const resolves = (p) => {
136
+ const t = String(p || '').replace(/\.md$/i, '').trim();
137
+ if (!t || t.includes('...') || t.includes('…')) return false;
138
+ return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
139
+ };
140
+ const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
141
+
142
+ const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
143
+ const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
144
+ const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
145
+ const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
146
+
147
+ const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
148
+ for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
149
+ for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
150
+ for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
151
+ for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
152
+
153
+ const shown = sessions.length;
154
+ if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
155
+
156
+ ensureDir(brainDir(vaultBase));
157
+ writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
158
+ return lines;
159
+ }
@@ -1,32 +1,32 @@
1
- // .agent/hooks/brain-recall.mjs
2
- // Query engine read-only: pontua o índice por tópico. Token só no resultado.
3
- // Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
4
- import { pathToFileURL } from 'node:url';
5
- import { getVaultBase } from './obsidian-common.mjs';
6
- import { loadIndex } from './brain-core.mjs';
7
-
8
- export { loadIndex };
9
-
10
- export function scoreRows(rows, query, topK = 5) {
11
- const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
12
- if (!terms.length) return [];
13
- return rows
14
- .map((r) => {
15
- // Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
16
- // os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
17
- const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
18
- let score = 0;
19
- for (const t of terms) if (hay.includes(t)) score++;
20
- return { row: r, score };
21
- })
22
- .filter((s) => s.score > 0)
23
- .sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
24
- .slice(0, topK)
25
- .map((s) => s.row);
26
- }
27
-
28
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
29
- const vaultBase = getVaultBase();
30
- const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
31
- process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
32
- }
1
+ // .agent/hooks/brain-recall.mjs
2
+ // Query engine read-only: pontua o índice por tópico. Token só no resultado.
3
+ // Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getVaultBase } from './obsidian-common.mjs';
6
+ import { loadIndex } from './brain-core.mjs';
7
+
8
+ export { loadIndex };
9
+
10
+ export function scoreRows(rows, query, topK = 5) {
11
+ const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
12
+ if (!terms.length) return [];
13
+ return rows
14
+ .map((r) => {
15
+ // Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
16
+ // os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
17
+ const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
18
+ let score = 0;
19
+ for (const t of terms) if (hay.includes(t)) score++;
20
+ return { row: r, score };
21
+ })
22
+ .filter((s) => s.score > 0)
23
+ .sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
24
+ .slice(0, topK)
25
+ .map((s) => s.row);
26
+ }
27
+
28
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
29
+ const vaultBase = getVaultBase();
30
+ const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
31
+ process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
32
+ }
@@ -1,13 +1,13 @@
1
- // .agent/hooks/brain-reindex.mjs
2
- // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
- // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
- import { pathToFileURL } from 'node:url';
5
- import { getVaultBase } from './obsidian-common.mjs';
6
- import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
-
8
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
- const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
- const rows = buildBrainIndex(vaultBase);
11
- const digest = buildBrainDigest(vaultBase, rows);
12
- process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
- }
1
+ // .agent/hooks/brain-reindex.mjs
2
+ // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
+ // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getVaultBase } from './obsidian-common.mjs';
6
+ import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
+
8
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
+ const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
+ const rows = buildBrainIndex(vaultBase);
11
+ const digest = buildBrainDigest(vaultBase, rows);
12
+ process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.66.0",
3
+ "version": "0.66.1",
4
4
  "description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -101,7 +101,7 @@ Usage:
101
101
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
102
102
  wendkeep memory <sub> Shared memory v2: status | migrate [--apply] | repair |
103
103
  reconcile <session> --by-session <session> --reason <text> [--apply] |
104
- promote <candidate> | reject <candidate>. --vault P.
104
+ promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
105
105
  Reconcile is dry-run by default; the original attempt remains audited.
106
106
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
107
107
  protocol (cap 25, 3 sections, no secrets/PII).
@@ -108,6 +108,31 @@ export function validateMemoryEvent(event, { projectId } = {}) {
108
108
  errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
109
109
  }
110
110
 
111
+ if (event.candidate_decision !== undefined) {
112
+ const decision = event.candidate_decision;
113
+ if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
114
+ errors.push('candidate_decision deve ser objeto.');
115
+ } else {
116
+ if (typeof decision.candidate_id !== 'string' || !decision.candidate_id) {
117
+ errors.push('candidate_decision.candidate_id deve ser string não vazia.');
118
+ }
119
+ if (!['promote', 'reject'].includes(decision.action)) {
120
+ errors.push('candidate_decision.action deve ser promote ou reject.');
121
+ }
122
+ if (!Array.isArray(decision.event_ids)
123
+ || decision.event_ids.some((item) => typeof item !== 'string' || !item)) {
124
+ errors.push('candidate_decision.event_ids deve ser array de strings não vazias.');
125
+ }
126
+ if (decision.action === 'promote' && decision.selected_event_id !== undefined
127
+ && (typeof decision.selected_event_id !== 'string' || !decision.selected_event_id)) {
128
+ errors.push('candidate_decision.selected_event_id deve ser string não vazia.');
129
+ }
130
+ if (decision.action === 'reject' && decision.selected_event_id !== undefined) {
131
+ errors.push('candidate_decision.selected_event_id não é permitido em reject.');
132
+ }
133
+ }
134
+ }
135
+
111
136
  for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
112
137
  return { ok: errors.length === 0, errors, warnings };
113
138
  }
@@ -461,6 +461,19 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
461
461
  if (!existing) unique.set(event.event_id, event);
462
462
  }
463
463
  const events = [...unique.values()].sort(eventOrder);
464
+ const candidateDecisions = new Map();
465
+ for (const item of events) {
466
+ const decision = item.candidate_decision;
467
+ if (!decision) continue;
468
+ const existing = candidateDecisions.get(decision.candidate_id);
469
+ if (existing && canonicalMemoryJson(existing.decision) !== canonicalMemoryJson(decision)) {
470
+ throw new MemoryEventCollision(
471
+ decision.candidate_id,
472
+ `Ledger contains incompatible decisions for candidate ${decision.candidate_id}`,
473
+ );
474
+ }
475
+ if (!existing) candidateDecisions.set(decision.candidate_id, { decision, event: item });
476
+ }
464
477
 
465
478
  const peerGroups = new Map();
466
479
  for (const item of events) {
@@ -488,6 +501,11 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
488
501
  let revision = 0;
489
502
 
490
503
  for (const item of events) {
504
+ if (item.candidate_decision && item.candidate_decision.action === 'reject') {
505
+ appliedEventIds.push(item.event_id);
506
+ continue;
507
+ }
508
+
491
509
  if (protectedValues.has(item.memory_key)) {
492
510
  const coreValue = protectedValues.get(item.memory_key);
493
511
  const agreesWithCore = item.operation === 'assert'
@@ -604,7 +622,9 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
604
622
  const state = sortedObject(stateEntries);
605
623
  const recordObject = sortedObject(recordEntries);
606
624
  const tombstoneObject = sortedObject(tombstoneEntries);
607
- candidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
625
+ const unresolvedCandidates = candidates
626
+ .filter((item) => !candidateDecisions.has(item.candidate_id));
627
+ unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
608
628
  superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
609
629
  const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
610
630
  ...record.source,
@@ -618,7 +638,7 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
618
638
  return {
619
639
  state,
620
640
  records: recordObject,
621
- candidates,
641
+ candidates: unresolvedCandidates,
622
642
  tombstones: tombstoneObject,
623
643
  superseded,
624
644
  appliedEventIds,
package/src/memory.mjs CHANGED
@@ -237,37 +237,191 @@ function readCandidates(vault) {
237
237
  .split('\n').filter(Boolean).map((line) => JSON.parse(line));
238
238
  }
239
239
 
240
- export function decideMemoryCandidate(vault, { action, candidateId, value } = {}) {
240
+ function priorCandidateDecision(vault, candidateId) {
241
+ return readMemoryLedger(vault).events.find(
242
+ (event) => event.candidate_decision?.candidate_id === candidateId,
243
+ ) || null;
244
+ }
245
+
246
+ function candidateEvent(candidate, eventId) {
247
+ const events = Array.isArray(candidate.events) ? candidate.events : [];
248
+ if (candidate.reason === 'conflict' && !eventId) {
249
+ throw new Error('Candidate de conflito exige eventId (--event na CLI).');
250
+ }
251
+ if (!eventId) return events.length === 1 ? events[0] : null;
252
+ const selected = events.find((event) => event.event_id === eventId);
253
+ if (!selected) throw new Error(`event_id ${eventId} não pertence ao candidate ${candidate.candidate_id}.`);
254
+ return selected;
255
+ }
256
+
257
+ function assertCompatibleDecision(prior, { action, eventId }) {
258
+ const decision = prior.candidate_decision;
259
+ const sameSelection = action !== 'promote'
260
+ || (decision.selected_event_id || null) === (eventId || null);
261
+ if (decision.action !== action || !sameSelection) {
262
+ throw new Error(`Candidate ${decision.candidate_id} já possui decisão incompatível (${decision.action}).`);
263
+ }
264
+ }
265
+
266
+ function matchesPromotedAttempt(attempt, selected) {
267
+ if (attempt?.memory_mode !== 'v2' || attempt.state !== 'projected'
268
+ || !Array.isArray(attempt.event_ids) || !attempt.event_ids.includes(selected.event_id)) return false;
269
+ if (attempt.activation_id && selected.activation_id
270
+ && attempt.activation_id !== selected.activation_id) return false;
271
+ if (Number.isInteger(attempt.activation_epoch) && Number.isInteger(selected.activation_epoch)
272
+ && attempt.activation_epoch !== selected.activation_epoch) return false;
273
+ if (Number.isInteger(attempt.turn_sequence) && Number.isInteger(selected.turn_sequence)
274
+ && attempt.turn_sequence !== selected.turn_sequence) return false;
275
+ if (attempt.canonical_session_id && selected.canonical_session_id
276
+ && attempt.canonical_session_id !== selected.canonical_session_id) return false;
277
+ return true;
278
+ }
279
+
280
+ function snapshotPromotedAttemptCheckpoints(vault, selected) {
281
+ if (!selected) return new Map();
282
+ const registry = readSessionRegistry(vault);
283
+ return new Map(Object.entries(registry.sessions || {})
284
+ .filter(([, entry]) => matchesPromotedAttempt(entry?.last_memory_attempt, selected))
285
+ .map(([sessionId, entry]) => [sessionId, {
286
+ attempt: attemptFingerprint(entry.last_memory_attempt),
287
+ checkpoint: memoryCheckpointFingerprint(entry),
288
+ }]));
289
+ }
290
+
291
+ function refreshPromotedAttemptCheckpoint(vault, {
292
+ candidateId, decisionEventId, selected, checkpoint, decidedAt, expectedAttempts,
293
+ }) {
294
+ if (!selected || !checkpoint || !expectedAttempts?.size) return 0;
295
+ return mutateSessionRegistry(vault, (registry) => {
296
+ let refreshed = 0;
297
+ for (const [sessionId, entry] of Object.entries(registry.sessions || {})) {
298
+ const expected = expectedAttempts.get(sessionId);
299
+ if (!expected) continue;
300
+ const attempt = entry?.last_memory_attempt;
301
+ if (attemptFingerprint(attempt) !== expected.attempt
302
+ || memoryCheckpointFingerprint(entry) !== expected.checkpoint) continue;
303
+
304
+ const alreadyAudited = (entry.memory_candidate_decisions || [])
305
+ .some((audit) => audit.decision_event_id === decisionEventId);
306
+ if (alreadyAudited && sameCheckpoint(attempt.checkpoint, checkpoint)
307
+ && sameCheckpoint(entry.memory_checkpoint, checkpoint)) continue;
308
+ const originalCheckpoint = cloneJson(attempt.checkpoint || entry.memory_checkpoint || null);
309
+ attempt.checkpoint = cloneJson(checkpoint);
310
+ entry.memory_checkpoint = cloneJson(checkpoint);
311
+ entry.memory_status = 'projected';
312
+ if (!alreadyAudited) {
313
+ entry.memory_candidate_decisions = [
314
+ ...(Array.isArray(entry.memory_candidate_decisions) ? entry.memory_candidate_decisions : []),
315
+ {
316
+ v: 1,
317
+ type: 'candidate_checkpoint_refreshed',
318
+ candidate_id: candidateId,
319
+ decision_event_id: decisionEventId,
320
+ selected_event_id: selected.event_id,
321
+ decided_at: decidedAt,
322
+ original_checkpoint: originalCheckpoint,
323
+ checkpoint: cloneJson(checkpoint),
324
+ },
325
+ ];
326
+ }
327
+ refreshed += 1;
328
+ }
329
+ return refreshed;
330
+ });
331
+ }
332
+
333
+ export function decideMemoryCandidate(vault, {
334
+ action, candidateId, value, eventId, beforeCheckpointRefresh,
335
+ } = {}) {
241
336
  if (!['promote', 'reject'].includes(action)) throw new TypeError('action deve ser promote ou reject.');
242
337
  if (!candidateId) throw new TypeError('candidateId é obrigatório.');
338
+ const preflight = projectMemoryOutbox(vault);
339
+ if (preflight.status === 'busy') return { status: 'busy', candidateId };
340
+ const prior = priorCandidateDecision(vault, candidateId);
341
+ if (prior) {
342
+ assertCompatibleDecision(prior, { action, eventId });
343
+ const selected = action === 'promote' && prior.candidate_decision.selected_event_id
344
+ ? readMemoryLedger(vault).events.find(
345
+ (event) => event.event_id === prior.candidate_decision.selected_event_id,
346
+ )
347
+ : null;
348
+ const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
349
+ beforeCheckpointRefresh?.();
350
+ const checkpointRefreshed = action === 'promote'
351
+ ? refreshPromotedAttemptCheckpoint(vault, {
352
+ candidateId,
353
+ decisionEventId: prior.event_id,
354
+ selected,
355
+ checkpoint: preflight.checkpoint,
356
+ decidedAt: prior.observed_at,
357
+ expectedAttempts,
358
+ })
359
+ : 0;
360
+ return {
361
+ status: action === 'promote' ? 'promoted' : 'rejected',
362
+ candidateId,
363
+ eventId: prior.event_id,
364
+ alreadyApplied: true,
365
+ checkpointRefreshed,
366
+ projection: preflight,
367
+ };
368
+ }
243
369
  const candidates = readCandidates(vault);
244
370
  const candidate = candidates.find((item) => item.candidate_id === candidateId);
245
371
  if (!candidate) throw new Error(`Candidate não encontrado: ${candidateId}`);
372
+ if (action === 'promote' && candidate.reason === 'blocked_by_core') {
373
+ throw new Error(`Candidate ${candidateId} está blocked_by_core; edite CORE ou rejeite o candidate.`);
374
+ }
375
+ const selected = action === 'promote' ? candidateEvent(candidate, eventId) : null;
376
+ const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
377
+ const selectedValue = selected?.value ?? value ?? candidate.value ?? candidate.proposed_value;
378
+ if (action === 'promote' && selectedValue === undefined) {
379
+ throw new Error(`Candidate ${candidateId} não contém valor promovível.`);
380
+ }
246
381
  const now = new Date().toISOString();
382
+ const decision = {
383
+ candidate_id: candidateId,
384
+ action,
385
+ event_ids: Array.isArray(candidate.event_ids) ? [...candidate.event_ids].sort() : [],
386
+ ...(selected ? { selected_event_id: selected.event_id } : {}),
387
+ };
247
388
  const event = {
248
389
  v: 1,
249
- event_id: `cli-${action}-${hash(candidateId).slice(0, 20)}`,
390
+ event_id: `cli-${action}-${hash(`${candidateId}\0${selected?.event_id || ''}`).slice(0, 20)}`,
250
391
  project_id: projectId(vault),
251
- memory_key: action === 'promote' ? candidate.memory_key : `candidate.rejected.${candidateId}`,
252
- operation: 'assert',
253
- value: sanitizeMemoryText(action === 'promote' ? (value ?? candidate.value ?? candidate.values?.[0] ?? '') : 'rejected'),
392
+ memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
393
+ operation: action === 'promote' && selected ? 'replace' : 'assert',
394
+ value: sanitizeMemoryText(action === 'promote' ? selectedValue : 'rejected'),
254
395
  authority: 'verified',
255
- activation_id: 'wendkeep-memory-cli',
256
- turn_sequence: 0,
396
+ activation_id: selected?.activation_id || 'wendkeep-memory-cli',
397
+ ...(Number.isInteger(selected?.activation_epoch) ? { activation_epoch: selected.activation_epoch } : {}),
398
+ turn_sequence: selected?.turn_sequence ?? 0,
257
399
  observed_at: now,
258
400
  evidence: [`candidate:${candidateId}`],
401
+ candidate_decision: decision,
402
+ ...(selected ? { supersedes: decision.event_ids } : {}),
259
403
  };
260
404
  enqueueMemoryEvent(vault, event);
261
405
  const projection = projectMemoryOutbox(vault);
262
406
  if (projection.status === 'busy') return { status: 'busy', candidateId };
263
- const remaining = candidates.filter((item) => item.candidate_id !== candidateId);
264
- const projected = readCandidates(vault);
265
- const merged = new Map([...remaining, ...projected].map((item) => [item.candidate_id, item]));
266
- writeVaultFileAtomic(
267
- vault, brainPath(vault, CANDIDATES), candidateText([...merged.values()]), 'utf8',
268
- { label: 'candidates após decisão humana' },
269
- );
270
- return { status: action === 'promote' ? 'promoted' : 'rejected', candidateId, eventId: event.event_id, projection };
407
+ beforeCheckpointRefresh?.();
408
+ const checkpointRefreshed = action === 'promote'
409
+ ? refreshPromotedAttemptCheckpoint(vault, {
410
+ candidateId,
411
+ decisionEventId: event.event_id,
412
+ selected,
413
+ checkpoint: projection.checkpoint,
414
+ decidedAt: now,
415
+ expectedAttempts,
416
+ })
417
+ : 0;
418
+ return {
419
+ status: action === 'promote' ? 'promoted' : 'rejected',
420
+ candidateId,
421
+ eventId: event.event_id,
422
+ checkpointRefreshed,
423
+ projection,
424
+ };
271
425
  }
272
426
 
273
427
  function cloneJson(value) {
@@ -1038,8 +1192,14 @@ export function runMemory(argv) {
1038
1192
  apply: reconcileArgs.apply,
1039
1193
  });
1040
1194
  }
1041
- else if (sub === 'promote' || sub === 'reject') result = decideMemoryCandidate(vault, { action: sub, candidateId: positional });
1042
- else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
1195
+ else if (sub === 'promote' || sub === 'reject') {
1196
+ const eventId = option(argv, '--event');
1197
+ if (sub === 'reject' && eventId) throw memoryUsageError('--event é permitido somente em memory promote.');
1198
+ result = decideMemoryCandidate(vault, {
1199
+ action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
1200
+ });
1201
+ }
1202
+ else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
1043
1203
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1044
1204
  if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
1045
1205
  else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;