wendkeep 0.75.0 → 0.75.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ 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.75.2] — 2026-08-20
8
+
9
+ ### Fixed
10
+
11
+ - **Handoffs legados reescopáveis.** `memory rescope` agora separa individualmente eventos
12
+ `handoff.latest` ambíguos quando a sessão de origem é comprovável. A migração permanece
13
+ append-only, não escolhe vencedor e conserva ambiguidades reais dentro da mesma work session.
14
+ - **Curadoria proporcional.** `memory curate` mostra conflitos acionáveis por padrão; `--all`
15
+ inclui handoffs de sessões encerradas com status/change sanitizados e permite `H` para encerrar
16
+ em lote somente recomendações seguras, sempre com confirmação e releitura entre decisões.
17
+ - **Diagnóstico sem falso bloqueio.** O doctor separa conflitos acionáveis de handoffs históricos
18
+ reparáveis no mesmo snapshot. Dívida histórica isolada gera atenção e orienta `memory rescope`,
19
+ sem degradar a memória nem ocultar a contagem restante.
20
+
21
+ ## [0.75.1] — 2026-08-20
22
+
23
+ ### Fixed
24
+
25
+ - **Coalescência sem perda.** A outbox seleciona o identificador próprio de cada tipo de evento;
26
+ chamadas, agentes, rollups e transcripts distintos da mesma sessão não disputam mais a mesma chave.
27
+ - **Reconciliação realmente integral.** `observer reconcile --url` e `observer memory import`
28
+ ignoram o cursor incremental local, consultam as revisões remotas e regeneram documentos,
29
+ consumo, chamadas e transcripts mesmo após restauração ou troca do Observer.
30
+ - **Leases compatíveis com o transporte.** Locks de batch e publisher permanecem válidos além do
31
+ timeout máximo de request e só podem ser liberados pelo proprietário que os adquiriu.
32
+
7
33
  ## [0.75.0] — 2026-08-20
8
34
 
9
35
  ### Added
package/README.en.md CHANGED
@@ -88,7 +88,7 @@ Decisions, dead ends, the reason you chose X over Y — gone next session. The p
88
88
  | **Cost** — what it all cost | Per‑model, cache‑aware token pricing per session — plus `cost --trend` with a run‑rate projection across the whole vault; research previews without a final rate remain unestimated. |
89
89
  | **Multi‑agent** — one vault, both agents | `init` wires the session hooks into `.claude/settings.json` *and* `.codex/hooks.json`, and every note is tagged with the agent that wrote it: Claude Code is detected from its environment, anything else is recorded as Codex. One shared graph, whichever agent you are in. |
90
90
  | **Local‑first** — no cloud, no account | Everything is plain Markdown on your disk. An optional MCP server (`@bitbonsai/mcpvault`) lets the agent read/write the vault. |
91
- | **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed, while `observer reconcile` reserves full scans for explicit recovery. |
91
+ | **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed; `observer reconcile --url` ignores the incremental cursor to regenerate the complete projection while preserving local/remote revision baselines. |
92
92
 
93
93
  During historical migration, the Observer preserves differences between frontmatter totals and the
94
94
  ledger as explicit reconciliation rows, and disambiguates duplicate `session_id` values per file
@@ -413,10 +413,13 @@ CAS-migrated on both the attempt and `memory_checkpoint` to the correct physical
413
413
  backup/audit; divergent mirrors fail closed. A demonstrably superseded
414
414
  ambiguity uses `memory reconcile <session> --by-session <successor>
415
415
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
416
- without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
417
- explicit, durable curation. Start with `memory curate --vault <vault>`: the menu groups conflicts
418
- under friendly names, shows sanitized previews, and confirms every write with default `no`. Skip
419
- or quit and run it again to resume. In a non-TTY environment, use
416
+ without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. `memory rescope`
417
+ append-only separates legacy handoffs with proven identity even when they already participate in
418
+ candidates; it never selects a winner. Actionable conflicts require explicit, durable curation.
419
+ Start with `memory curate --vault <vault>`: the menu shows actionable conflicts only, groups them
420
+ under friendly names, shows sanitized previews, and confirms every write with default `no`.
421
+ `memory curate --all` includes proven historical handoffs and lets `H` batch-close them after
422
+ confirmation. In a non-TTY environment, use
420
423
  `memory candidates --active --vault <vault>` to list only safe IDs and metadata in read-only mode —
421
424
  it does not expose memory values or content. After human review,
422
425
  `memory promote <id> --event <event-id>` selects one event from the candidate, while
package/README.md CHANGED
@@ -88,7 +88,7 @@ Decisions, dead ends, the reason you chose X over Y — gone next session. The p
88
88
  | **Cost** — what it all cost | Per‑model, cache‑aware token pricing per session — plus `cost --trend` with a run‑rate projection across the whole vault; research previews without a final rate remain unestimated. |
89
89
  | **Multi‑agent** — one vault, both agents | `init` wires the session hooks into `.claude/settings.json` *and* `.codex/hooks.json`, and every note is tagged with the agent that wrote it: Claude Code is detected from its environment, anything else is recorded as Codex. One shared graph, whichever agent you are in. |
90
90
  | **Local‑first** — no cloud, no account | Everything is plain Markdown on your disk. An optional MCP server (`@bitbonsai/mcpvault`) lets the agent read/write the vault. |
91
- | **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed, while `observer reconcile` reserves full scans for explicit recovery. |
91
+ | **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed; `observer reconcile --url` ignores the incremental cursor to regenerate the complete projection while preserving local/remote revision baselines. |
92
92
 
93
93
  During historical migration, the Observer preserves differences between frontmatter totals and the
94
94
  ledger as explicit reconciliation rows, and disambiguates duplicate `session_id` values per file
@@ -413,10 +413,13 @@ CAS-migrated on both the attempt and `memory_checkpoint` to the correct physical
413
413
  backup/audit; divergent mirrors fail closed. A demonstrably superseded
414
414
  ambiguity uses `memory reconcile <session> --by-session <successor>
415
415
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
416
- without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
417
- explicit, durable curation. Start with `memory curate --vault <vault>`: the menu groups conflicts
418
- under friendly names, shows sanitized previews, and confirms every write with default `no`. Skip
419
- or quit and run it again to resume. In a non-TTY environment, use
416
+ without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. `memory rescope`
417
+ append-only separates legacy handoffs with proven identity even when they already participate in
418
+ candidates; it never selects a winner. Actionable conflicts require explicit, durable curation.
419
+ Start with `memory curate --vault <vault>`: the menu shows actionable conflicts only, groups them
420
+ under friendly names, shows sanitized previews, and confirms every write with default `no`.
421
+ `memory curate --all` includes proven historical handoffs and lets `H` batch-close them after
422
+ confirmation. In a non-TTY environment, use
420
423
  `memory candidates --active --vault <vault>` to list only safe IDs and metadata in read-only mode —
421
424
  it does not expose memory values or content. After human review,
422
425
  `memory promote <id> --event <event-id>` selects one event from the candidate, while
@@ -104,6 +104,10 @@ dry-run path before any write.
104
104
  the evidence, and then use
105
105
  `memory promote <candidate-id> --event <event-id> --vault <vault>` to select an event or
106
106
  `memory reject <candidate-id> --vault <vault>` to keep the current operational value.
107
+ - Historical handoffs from closed sessions are repairable debt, not actionable conflicts. Run
108
+ `memory rescope --vault <vault>` first and review the dry run, then apply with `--apply`. If debt
109
+ remains, `memory curate --all --vault <vault>` shows context and offers `H` to batch-close safe
110
+ recommendations only, always after confirmation.
107
111
  - `legacy`/`degraded`/`stale`/`manifest-unproven` observability: run
108
112
  `npx --no-install wendkeep cost rebuild --session <id> --json --vault <vault>`, review diagnostics,
109
113
  and only then authorize the second variant with `--apply`.
@@ -28,7 +28,7 @@ Pass the vault explicitly in automation. Preserve backups and evidence before re
28
28
 
29
29
  ```bash
30
30
  npx wendkeep memory status [--gate] --vault <vault>
31
- npx wendkeep memory curate --vault <vault>
31
+ npx wendkeep memory curate [--all] --vault <vault>
32
32
  npx wendkeep memory candidates [--active] --vault <vault>
33
33
  npx wendkeep memory rescope [--apply] --vault <vault>
34
34
  npx wendkeep memory repair --vault <vault>
@@ -43,12 +43,14 @@ npx wendkeep validate-memory --vault <v2-vault>
43
43
  ## Options and exit codes
44
44
 
45
45
  - `memory status` is read-only; `--gate` exits `1` only for blocking state.
46
- - `memory curate` is the recommended human path: in an interactive terminal it groups each
47
- conflict under a friendly name, shows sanitized previews only, and offers numbered choices,
48
- `P` to skip, `R` to reject, `D` for technical details, and `Q` to quit. Every promotion or
49
- rejection asks for confirmation with default `no`: Enter or `N` does not write. Skip or quit
50
- leaves the remaining work pending; running the command again resumes the active conflicts.
51
- - The assistant accepts only `--vault`: there is no `--yes`, `--apply`, or batch mode. In a
46
+ - `memory curate` is the recommended human path and shows actionable conflicts only by default.
47
+ In an interactive terminal it groups conflicts under friendly names, shows sanitized previews
48
+ and context, and offers numbered choices, `P` to skip, `R` to reject, `D` for technical details,
49
+ and `Q` to quit. `--all` includes handoffs from proven closed sessions; for those, `H` batch-closes
50
+ only safe recommendations while reloading authority between decisions. Every write asks for
51
+ confirmation with default `no`: Enter or `N` does not write. Skip or quit preserves the
52
+ remaining conflicts so a later run can resume them.
53
+ - The assistant accepts `--vault` and `--all`: there is no `--yes` or `--apply`. In a
52
54
  non-TTY environment it exits `2` without changing bytes and recommends the advanced fallback
53
55
  `memory candidates --active`.
54
56
  - `memory candidates` is read-only and prints deterministic JSON containing only `candidate_id`,
@@ -98,8 +100,10 @@ npx wendkeep validate-memory --vault <v2-vault>
98
100
  decision is idempotent.
99
101
  - `memory rescope` is a dry run by default and lists only planned IDs, keys, and scopes. With
100
102
  `--apply`, it appends explicit project, work-session, change, branch, or worktree events while
101
- preserving historic bytes as the ledger prefix. Ambiguous candidates are neither migrated nor
102
- assigned a winner; retry returns `unchanged`.
103
+ preserving historic bytes as the ledger prefix. Legacy `handoff.latest` events inside candidates
104
+ are also rescoped individually when they carry proven session identity, separating independent
105
+ workflows without selecting a winner. Ambiguities that remain inside one scope stay under human
106
+ curation; retry returns `unchanged`.
103
107
  - Registers such as `git.local-head`, `handoff.latest`, `quality.latest-*`, and
104
108
  `change.<slug>.status` compete only inside the same scope. Automatic resolution still requires
105
109
  the same project and causal lineage; incompatible decisions, constraints, and blockers remain
@@ -92,8 +92,13 @@ agent sessions, cost rollups, and calls. Messages and transcripts are only sent
92
92
  that explicitly enable them. The container stores everything in
93
93
  `/data/observer.sqlite`; it does not mount `C:\GitHub` or any `.WendKeep-vault`. Markdown is only
94
94
  the text held in SQL and is recreated as files only by an explicit read-only export.
95
- `memory import` performs the initial load and returns file/hash parity. During migration, the
96
- cost/token total recorded in frontmatter is preserved through an explicit reconciliation row when
95
+ `memory import` performs the initial load and returns file/hash parity. The remote recovery
96
+ commands `observer reconcile --url` and `observer memory import` do not use the
97
+ incremental cursor to decide what to omit: they regenerate documents, sessions, usage, calls, and
98
+ transcripts. The cursor still supplies the local revision baseline and the remote tree, when
99
+ available, supplies the highest known baseline; a transient read failure never lowers the locally
100
+ persisted revision. During migration, the cost/token total recorded in frontmatter is preserved
101
+ through an explicit reconciliation row when
97
102
  the detailed ledger does not add up; that row does not invent calls. Historical sessions sharing
98
103
  one `session_id` receive a canonical per-file identity so one rollup cannot overwrite the other.
99
104
 
@@ -102,6 +102,10 @@ ou sem manifest comprovado e oferece um caminho dry-run antes de qualquer escrit
102
102
  liste os IDs seguros com `memory candidates --active --vault <cofre>`, revise a evidência e use
103
103
  `memory promote <candidate-id> --event <event-id> --vault <cofre>` para selecionar um evento ou
104
104
  `memory reject <candidate-id> --vault <cofre>` para manter o valor operacional atual.
105
+ - Handoffs históricos de sessões encerradas são dívida reparável, não conflito acionável. Rode
106
+ primeiro `memory rescope --vault <cofre>` e revise o dry-run; aplique com `--apply`. Se restar
107
+ dívida, `memory curate --all --vault <cofre>` mostra o contexto e oferece `H` para encerrar em
108
+ lote somente as recomendações seguras, sempre com confirmação.
105
109
  - Observabilidade `legacy`/`degraded`/`stale`/`manifest-unproven`: rode
106
110
  `npx --no-install wendkeep cost rebuild --session <id> --json --vault <cofre>`, revise diagnostics
107
111
  e só então autorize a segunda variante com `--apply`.
@@ -28,7 +28,7 @@ Informe o vault explicitamente em automações. Preserve backups e evidências a
28
28
 
29
29
  ```bash
30
30
  npx wendkeep memory status [--gate] --vault <cofre>
31
- npx wendkeep memory curate --vault <cofre>
31
+ npx wendkeep memory curate [--all] --vault <cofre>
32
32
  npx wendkeep memory candidates [--active] --vault <cofre>
33
33
  npx wendkeep memory rescope [--apply] --vault <cofre>
34
34
  npx wendkeep memory repair --vault <cofre>
@@ -43,12 +43,14 @@ npx wendkeep validate-memory --vault <cofre-v2>
43
43
  ## Opções e códigos de saída
44
44
 
45
45
  - `memory status` é read-only; `--gate` retorna exit `1` apenas para estado bloqueante.
46
- - `memory curate` é o caminho recomendado para pessoas: em um terminal interativo, agrupa cada
47
- conflito por nome amigável, mostra somente previews sanitizados e oferece escolhas numeradas,
48
- `P` para pular, `R` para rejeitar, `D` para detalhes técnicos e `Q` para sair. Cada promoção ou
49
- rejeição pede confirmação com padrão negativo: Enter ou `N` não grava. Pular ou sair deixa o
50
- restante pendente; uma nova execução retoma os conflitos ainda ativos.
51
- - O assistente aceita somente `--vault`: não `--yes`, `--apply` ou modo em lote. Em ambiente
46
+ - `memory curate` é o caminho recomendado para pessoas: por padrão mostra somente conflitos
47
+ acionáveis. Em um terminal interativo, agrupa por nome amigável, mostra previews e contexto
48
+ sanitizados e oferece escolhas numeradas, `P` para pular, `R` para rejeitar, `D` para detalhes
49
+ técnicos e `Q` para sair. `--all` inclui handoffs de sessões comprovadamente encerradas; nesses
50
+ casos, `H` encerra em lote somente as recomendações seguras, relendo a autoridade entre cada
51
+ decisão. Toda escrita pede confirmação com padrão negativo: Enter ou `N` não grava. Pular ou
52
+ sair preserva o restante para retomar em outra execução.
53
+ - O assistente aceita `--vault` e `--all`: não há `--yes` nem `--apply`. Em ambiente
52
54
  não-TTY/terminal não interativo, ele retorna exit `2` sem alterar bytes e orienta usar o fallback
53
55
  avançado `memory candidates --active`.
54
56
  - `memory candidates` é read-only e imprime JSON determinístico com somente `candidate_id`,
@@ -98,8 +100,10 @@ npx wendkeep validate-memory --vault <cofre-v2>
98
100
  notas, nem consome a outbox. Repetir a mesma decisão aplicada é idempotente.
99
101
  - `memory rescope` é dry-run por padrão e lista somente IDs, chaves e escopos planejados. Com
100
102
  `--apply`, anexa eventos explícitos de projeto, work session, change, branch ou worktree e mantém
101
- os bytes históricos como prefixo do ledger. Candidates ambíguos não são migrados nem recebem
102
- vencedor; uma repetição retorna `unchanged`.
103
+ os bytes históricos como prefixo do ledger. Eventos legados de `handoff.latest` que participam
104
+ de candidates também são reescopados individualmente quando possuem identidade de sessão
105
+ comprovável: isso separa workflows independentes sem selecionar vencedor. Ambiguidades que
106
+ permanecem no mesmo escopo continuam sob curadoria; uma repetição retorna `unchanged`.
103
107
  - Registradores como `git.local-head`, `handoff.latest`, `quality.latest-*` e
104
108
  `change.<slug>.status` só competem dentro do mesmo escopo. Resolução automática ainda exige o
105
109
  mesmo projeto e linhagem causal; decisões, constraints e blockers incompatíveis permanecem sob
@@ -94,6 +94,11 @@ níveis de captura que os habilitam. O container grava tudo em
94
94
  `/data/observer.sqlite`; não monta `C:\GitHub` nem qualquer `.WendKeep-vault`. Markdown é aceito
95
95
  somente como conteúdo de uma coluna SQL e volta a existir como arquivo apenas pela exportação
96
96
  read-only sob demanda. `memory import` faz a carga inicial e retorna a paridade por arquivo e hash.
97
+ Na recuperação remota, `observer reconcile --url` e `observer memory import` não usam o cursor
98
+ incremental para decidir o que omitir: regeneram documentos, sessões, consumo, chamadas e
99
+ transcripts. O cursor continua fornecendo o baseline local de revisão e a árvore remota, quando
100
+ disponível, fornece o maior baseline conhecido; uma falha temporária nessa leitura nunca reduz a
101
+ revisão persistida localmente.
97
102
  Na migração, o total de custo/token registrado no frontmatter é preservado por uma linha de
98
103
  reconciliação quando o ledger detalhado não fecha com ele; essa linha não inventa chamadas.
99
104
  Sessões históricas com o mesmo `session_id` recebem uma identidade canônica por arquivo para
@@ -18,6 +18,7 @@ import { detectMemoryMode, LEGACY_MEMORY_WARNING } from './memory-mode.mjs';
18
18
  import { deriveMemoryProjection } from './memory-store.mjs';
19
19
  import { assertVaultPathSafe, assertVaultPathsSafe } from './vault-path-safety.mjs';
20
20
  import { validateMemoryBundle } from '../src/validate-memory.mjs';
21
+ import { isHistoricalHandoffCandidate } from '../packages/vault/src/memory-candidate-policy.mjs';
21
22
 
22
23
  const DEFAULT_PENDING_PATTERNS = [
23
24
  /^- \[ \] Revisar resumo da sessão$/i,
@@ -115,6 +116,12 @@ const memoryCandidatesCommand = (vaultBase) => (
115
116
  const memoryCurateCommand = (vaultBase) => (
116
117
  `${WENDKEEP_COMMAND} memory curate --vault ${quoteCommandArgument(vaultBase)}`
117
118
  );
119
+ const memoryCurateAllCommand = (vaultBase) => (
120
+ `${WENDKEEP_COMMAND} memory curate --all --vault ${quoteCommandArgument(vaultBase)}`
121
+ );
122
+ const memoryRescopeCommand = (vaultBase, { apply = false } = {}) => (
123
+ `${WENDKEEP_COMMAND} memory rescope${apply ? ' --apply' : ''} --vault ${quoteCommandArgument(vaultBase)}`
124
+ );
118
125
 
119
126
  const MEMORY_KEY_PURPOSES = new Map([
120
127
  ['handoff.latest', 'próximo handoff'],
@@ -231,6 +238,7 @@ function memoryMetrics() {
231
238
  pendingOutbox: 0,
232
239
  candidates: 0,
233
240
  activeConflicts: 0,
241
+ repairableHandoffs: 0,
234
242
  semanticStatus: null,
235
243
  semanticCode: null,
236
244
  semanticActiveKeys: [],
@@ -497,7 +505,12 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
497
505
  warnings.push(...lifecycle.warnings);
498
506
 
499
507
  const unresolved = candidates.items.filter((item) => !['resolved', 'rejected', 'superseded'].includes(item?.status));
500
- const activeConflicts = unresolved.filter((item) => item?.reason === 'conflict');
508
+ const allConflicts = unresolved.filter((item) => item?.reason === 'conflict');
509
+ const historicalHandoffs = allConflicts.filter(
510
+ (item) => isHistoricalHandoffCandidate(item, effectiveRegistry),
511
+ );
512
+ const historicalIds = new Set(historicalHandoffs.map((item) => item.candidate_id));
513
+ const activeConflicts = allConflicts.filter((item) => !historicalIds.has(item.candidate_id));
501
514
  const ordinaryCandidates = unresolved.filter((item) => item?.reason !== 'conflict');
502
515
  if (activeConflicts.length) {
503
516
  const label = activeConflicts.length === 1
@@ -506,6 +519,17 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
506
519
  const purposes = groupConflictPurposes(activeConflicts).join('; ');
507
520
  warnings.push(`${label} (${purposes}). Existem versões concorrentes e nenhum dado foi escolhido automaticamente. Conflito semântico degrada somente as chaves afetadas e exige curadoria humana; memory repair não escolhe vencedor. Próximo passo: ${memoryCurateCommand(vaultBase)}. Inventário avançado: ${memoryCandidatesCommand(vaultBase)}.`);
508
521
  }
522
+ if (historicalHandoffs.length) {
523
+ const label = historicalHandoffs.length === 1
524
+ ? '1 handoff histórico reparável'
525
+ : `${historicalHandoffs.length} handoffs históricos reparáveis`;
526
+ warnings.push(
527
+ `${label}; sessões de origem estão encerradas e nenhuma versão precisa virar estado atual. `
528
+ + `Execute primeiro o dry-run: ${memoryRescopeCommand(vaultBase)}. `
529
+ + `Se a prévia estiver correta: ${memoryRescopeCommand(vaultBase, { apply: true })}. `
530
+ + `Dívida restante: ${memoryCurateAllCommand(vaultBase)}.`,
531
+ );
532
+ }
509
533
  if (outbox.count) warnings.push(`${outbox.count} evento(s) pendente(s) na outbox; execute o projector quando seguro.`);
510
534
  if (ordinaryCandidates.length) warnings.push(`${ordinaryCandidates.length} candidate(s) aguardando curadoria humana.`);
511
535
  for (const warning of bundle.warnings || []) warnings.push(warning);
@@ -526,6 +550,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
526
550
  pendingOutbox: outbox.count,
527
551
  candidates: candidates.items.length,
528
552
  activeConflicts: activeConflicts.length,
553
+ repairableHandoffs: historicalHandoffs.length,
529
554
  semanticStatus: semantic.status ?? null,
530
555
  semanticCode: semantic.code ?? null,
531
556
  semanticActiveKeys: semantic.activeKeys || [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.75.0",
3
+ "version": "0.75.2",
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": [
@@ -41,7 +41,7 @@
41
41
  "node": ">=18"
42
42
  },
43
43
  "scripts": {
44
- "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check scripts/release-provenance.mjs && node --check scripts/run-scope.mjs && node --check src/release-provenance.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/observer-auth.mjs && node --check src/observer-privacy.mjs && node --check src/observer-snapshot.mjs && node --check src/observer-store.mjs && node --check src/observer-memory.mjs && node --check src/observer-memory-publish.mjs && node --check src/observer-sql-store.mjs && node --check src/observer-sql-migrate.mjs && node --check src/observer-sql-publish.mjs && node --check src/observer-transcript-store.mjs && node --check src/observer-server.mjs && node --check src/observer.mjs && node --check src/observer-publish.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check src/work-kind.mjs && node --check src/delivery.mjs && node --check web/observer/app.mjs && node --check hooks/observer-publish.mjs && node --check hooks/evidence-context.mjs && node --check hooks/evidence-recall.mjs && node --check hooks/memory-scope.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-scope.mjs && node --check packages/vault/src/evidence-recall.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
44
+ "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check scripts/release-provenance.mjs && node --check scripts/run-scope.mjs && node --check src/release-provenance.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/observer-auth.mjs && node --check src/observer-privacy.mjs && node --check src/observer-snapshot.mjs && node --check src/observer-store.mjs && node --check src/observer-memory.mjs && node --check src/observer-memory-publish.mjs && node --check src/observer-sql-store.mjs && node --check src/observer-sql-migrate.mjs && node --check src/observer-sql-publish.mjs && node --check src/observer-transcript-store.mjs && node --check src/observer-server.mjs && node --check src/observer.mjs && node --check src/observer-publish.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check src/work-kind.mjs && node --check src/delivery.mjs && node --check web/observer/app.mjs && node --check hooks/observer-publish.mjs && node --check hooks/evidence-context.mjs && node --check hooks/evidence-recall.mjs && node --check hooks/memory-scope.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-scope.mjs && node --check packages/vault/src/memory-candidate-policy.mjs && node --check packages/vault/src/evidence-recall.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
45
45
  "test": "node --test --test-concurrency=2",
46
46
  "test:core": "node scripts/run-scope.mjs core",
47
47
  "release": "node scripts/release.mjs",
@@ -102,8 +102,9 @@ Usage:
102
102
  in session notes from the linked derived notes — the body used to lag
103
103
  behind the closing block. Dry-run by default · --apply · --json.
104
104
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
105
- wendkeep memory curate Guide one semantic conflict at a time in an interactive terminal.
106
- Every promote/reject requires confirmation; --vault P.
105
+ wendkeep memory curate Guide actionable semantic conflicts in an interactive terminal.
106
+ --all includes historical handoffs and enables confirmed safe batch close;
107
+ every promote/reject requires confirmation. --vault P.
107
108
  wendkeep memory <sub> Shared memory v2: status | candidates [--active] | curate | migrate [--apply] | rescope [--apply] | repair |
108
109
  recover-attempt <session> [--apply] |
109
110
  reconcile <session> --by-session <session> --reason <text> [--apply] |
@@ -0,0 +1,47 @@
1
+ const TERMINAL_SESSION_STATUSES = new Set([
2
+ 'done',
3
+ 'superseded',
4
+ 'completed',
5
+ 'closed',
6
+ 'inactive',
7
+ 'abandoned',
8
+ ]);
9
+
10
+ function sessionEntry(registry, event) {
11
+ const sessionId = String(event?.canonical_session_id || '').trim();
12
+ return sessionId ? registry?.sessions?.[sessionId] || null : null;
13
+ }
14
+
15
+ /** Pure, fail-closed policy shared by doctor and interactive curation. */
16
+ export function classifyMemoryCandidate(candidate, registry = { sessions: {} }) {
17
+ if (candidate?.reason !== 'conflict' || candidate?.memory_key !== 'handoff.latest') {
18
+ return { classification: 'actionable' };
19
+ }
20
+
21
+ const events = Array.isArray(candidate.events) ? candidate.events : [];
22
+ if (!events.length) return { classification: 'unknown' };
23
+ const entries = events.map((event) => sessionEntry(registry, event));
24
+ if (entries.some((entry) => !entry)) return { classification: 'unknown' };
25
+ if (entries.some((entry) => String(entry.status || '').trim() === 'active')) {
26
+ return { classification: 'actionable' };
27
+ }
28
+ if (entries.every((entry) => TERMINAL_SESSION_STATUSES.has(String(entry.status || '').trim()))) {
29
+ return { classification: 'historical', recommended_action: 'reject' };
30
+ }
31
+ return { classification: 'unknown' };
32
+ }
33
+
34
+ export function candidateSessionContext(event, registry = { sessions: {} }) {
35
+ const entry = sessionEntry(registry, event);
36
+ if (!entry) return {};
37
+ const status = String(entry.status || '').trim();
38
+ const changeSlug = String(entry.change_slug || entry.changeSlug || '').trim();
39
+ return {
40
+ ...(status ? { session_status: status } : {}),
41
+ ...(changeSlug ? { change_slug: changeSlug } : {}),
42
+ };
43
+ }
44
+
45
+ export function isHistoricalHandoffCandidate(candidate, registry) {
46
+ return classifyMemoryCandidate(candidate, registry).classification === 'historical';
47
+ }
package/src/doctor.mjs CHANGED
@@ -39,7 +39,8 @@ export function renderVaultHealthLines(result) {
39
39
  lines.push(
40
40
  `[memória] ${healthStatusLabel(result.memoryStatus)} — schema: ${metricValue(memory.schemaVersion)} · revisão: ${metricValue(memory.revision)} · cursor: ${metricValue(memory.eventCursor)} · hash: ${metricValue(memory.stateHash)}`,
41
41
  );
42
- lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}`);
42
+ const repairableHandoffs = Number(memory.repairableHandoffs || 0);
43
+ lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}${repairableHandoffs ? ` · handoffs reparáveis: ${repairableHandoffs}` : ''}`);
43
44
  const semanticKeys = memory.semanticActiveKeys || [];
44
45
  const semanticProjected = memory.semanticProjectedKeys || [];
45
46
  const semanticMissing = memory.semanticMissingKeys || [];
@@ -21,10 +21,18 @@ const TEXT = {
21
21
  observed: 'registrado em',
22
22
  newer: 'mais recente',
23
23
  actions: '[1-N] Manter uma versão · [P] Pular · [R] Encerrar sem vencedor · [D] Detalhes · [Q] Sair',
24
+ historicalAction: '[H] Encerrar todos os handoffs históricos recomendados',
25
+ terminalContext: 'sessão encerrada',
26
+ activeContext: 'sessão ativa',
27
+ changeContext: (slug) => `change: ${slug}`,
28
+ safeRecommendation: 'Recomendação segura: encerrar sem vencedor; as evidências permanecem no ledger.',
29
+ historicalOmitted: (count) => `${count} handoff(s) histórico(s) reparável(is) foram omitidos. Use --all para revisar ou encerrar em lote.`,
30
+ confirmHistorical: (count) => `Encerrar ${count} handoff(s) histórico(s) sem vencedor? Cada decisão será auditada. [s/N] `,
31
+ historicalClosed: (count) => `${count} handoff(s) histórico(s) encerrado(s) com decisão auditada.`,
24
32
  choose: '> ',
25
33
  confirmPromote: (number) => `Manter a versão ${number}? Essa decisão será gravada agora. [s/N] `,
26
34
  confirmReject: 'Encerrar este conflito sem escolher uma versão? Essa decisão será gravada agora. [s/N] ',
27
- invalid: 'Opção inválida. Escolha um número exibido, P, R, D ou Q.',
35
+ invalid: 'Opção inválida. Escolha um número exibido, P, R, D, H ou Q.',
28
36
  declined: 'Decisão não confirmada; nenhum byte foi alterado.',
29
37
  promoted: 'Versão promovida e decisão auditada.',
30
38
  rejected: 'Conflito encerrado sem promover uma versão.',
@@ -47,10 +55,18 @@ const TEXT = {
47
55
  observed: 'recorded at',
48
56
  newer: 'newest timestamp',
49
57
  actions: '[1-N] Keep one version · [P] Skip · [R] Close without a winner · [D] Details · [Q] Quit',
58
+ historicalAction: '[H] Close every safely recommended historical handoff',
59
+ terminalContext: 'closed session',
60
+ activeContext: 'active session',
61
+ changeContext: (slug) => `change: ${slug}`,
62
+ safeRecommendation: 'Safe recommendation: close without a winner; evidence remains in the ledger.',
63
+ historicalOmitted: (count) => `${count} repairable historical handoff(s) were omitted. Use --all to review or close them in a batch.`,
64
+ confirmHistorical: (count) => `Close ${count} historical handoff(s) without a winner? Every decision will be audited. [y/N] `,
65
+ historicalClosed: (count) => `${count} historical handoff(s) closed with audited decisions.`,
50
66
  choose: '> ',
51
67
  confirmPromote: (number) => `Keep version ${number}? This decision will be written now. [y/N] `,
52
68
  confirmReject: 'Close this conflict without choosing a version? This decision will be written now. [y/N] ',
53
- invalid: 'Invalid option. Choose a displayed number, P, R, D, or Q.',
69
+ invalid: 'Invalid option. Choose a displayed number, P, R, D, H, or Q.',
54
70
  declined: 'Decision not confirmed; no bytes were changed.',
55
71
  promoted: 'Version promoted and decision audited.',
56
72
  rejected: 'Conflict closed without promoting a version.',
@@ -125,13 +141,24 @@ export function renderMemoryConflict(candidate, {
125
141
  const source = text.sourceKinds[event.source] || text.sourceKinds.unknown;
126
142
  lines.push(`[${eventIndex + 1}] ${text.source}: ${source}${recent}`);
127
143
  if (event.observed_at) lines.push(` ${text.observed}: ${event.observed_at}`);
144
+ const context = [
145
+ event.session_status === 'active' ? text.activeContext
146
+ : event.session_status ? text.terminalContext : '',
147
+ event.change_slug ? text.changeContext(event.change_slug) : '',
148
+ ].filter(Boolean);
149
+ if (context.length) lines.push(` ${context.join(' · ')}`);
128
150
  lines.push(` ${event.preview}`);
129
151
  });
152
+ if (candidate.classification === 'historical'
153
+ && candidate.recommended_action === 'reject') {
154
+ lines.push('', text.safeRecommendation);
155
+ }
130
156
  if (details) {
131
157
  lines.push('', `${text.details}:`, ` candidate: ${candidate.candidate_id}`);
132
158
  candidate.events.forEach((event) => lines.push(` event: ${event.event_id}`));
133
159
  }
134
160
  lines.push('', text.actions);
161
+ if (candidate.classification === 'historical') lines.push(text.historicalAction);
135
162
  return `${lines.join('\n')}\n`;
136
163
  }
137
164
 
@@ -141,6 +168,7 @@ export async function runGuidedMemoryCuration(vault, {
141
168
  loadCandidates = listMemoryCandidatesForCuration,
142
169
  decide = decideMemoryCandidate,
143
170
  locale = 'pt-BR',
171
+ omittedHistorical = 0,
144
172
  } = {}) {
145
173
  if (typeof ask !== 'function' || typeof write !== 'function') {
146
174
  throw new TypeError('runGuidedMemoryCuration requer ask e write.');
@@ -167,6 +195,7 @@ export async function runGuidedMemoryCuration(vault, {
167
195
  if (first) {
168
196
  sessionTotal = loaded.length;
169
197
  write(`${text.title}\n${text.intro(loaded.length)}\n`);
198
+ if (omittedHistorical) write(`${text.historicalOmitted(omittedHistorical)}\n`);
170
199
  if (loaded.length) write(`${text.categories}\n${groupLines(loaded, id).join('\n')}\n`);
171
200
  first = false;
172
201
  }
@@ -207,6 +236,56 @@ export async function runGuidedMemoryCuration(vault, {
207
236
  }));
208
237
  continue;
209
238
  }
239
+ if (answer === 'h') {
240
+ const historicalCount = loaded.filter((item) => (
241
+ item.classification === 'historical' && item.recommended_action === 'reject'
242
+ )).length;
243
+ if (!historicalCount) {
244
+ write(`${text.invalid}\n`);
245
+ continue;
246
+ }
247
+ const confirmation = await ask(text.confirmHistorical(historicalCount));
248
+ if (!isAffirmative(confirmation)) {
249
+ write(`${text.declined}\n`);
250
+ continue;
251
+ }
252
+ let closed = 0;
253
+ const batchIds = new Set();
254
+ while (true) {
255
+ let refreshed;
256
+ try {
257
+ refreshed = loadCandidates(vault);
258
+ } catch {
259
+ write(`${text.blocked}\n`);
260
+ return { status: 'blocked', decisions, skipped };
261
+ }
262
+ const next = refreshed.find((item) => (
263
+ item.classification === 'historical' && item.recommended_action === 'reject'
264
+ ));
265
+ if (!next) break;
266
+ if (batchIds.has(next.candidate_id)) {
267
+ write(`${text.blocked}\n`);
268
+ return { status: 'blocked', decisions, skipped };
269
+ }
270
+ batchIds.add(next.candidate_id);
271
+ let batchResult;
272
+ try {
273
+ batchResult = decide(vault, { action: 'reject', candidateId: next.candidate_id });
274
+ } catch {
275
+ write(`${text.blocked}\n`);
276
+ return { status: 'blocked', decisions, skipped };
277
+ }
278
+ if (batchResult?.status !== 'rejected') {
279
+ write(`${text.blocked}\n`);
280
+ return { status: 'blocked', decisions, skipped };
281
+ }
282
+ decisions += 1;
283
+ closed += 1;
284
+ decidedIds.add(next.candidate_id);
285
+ }
286
+ write(`${text.historicalClosed(closed)}\n`);
287
+ continue;
288
+ }
210
289
 
211
290
  let decision;
212
291
  let confirmation;
@@ -260,11 +339,18 @@ function usageError(message) {
260
339
  export function parseMemoryCurateArgs(argv) {
261
340
  let vault = '';
262
341
  let seenVault = false;
342
+ let includeHistorical = false;
263
343
  for (let index = 0; index < argv.length; index += 1) {
264
344
  const token = argv[index];
265
345
  if (!token.startsWith('--')) throw usageError(`argumento posicional inesperado: ${token}.`);
266
346
  const equalAt = token.indexOf('=');
267
347
  const name = equalAt >= 0 ? token.slice(0, equalAt) : token;
348
+ if (name === '--all') {
349
+ if (equalAt >= 0) throw usageError('--all não aceita valor.');
350
+ if (includeHistorical) throw usageError('--all duplicado.');
351
+ includeHistorical = true;
352
+ continue;
353
+ }
268
354
  if (name !== '--vault') throw usageError(`opção desconhecida: ${name}.`);
269
355
  if (seenVault) throw usageError('--vault duplicado.');
270
356
  const value = equalAt >= 0 ? token.slice(equalAt + 1) : argv[index + 1];
@@ -275,7 +361,7 @@ export function parseMemoryCurateArgs(argv) {
275
361
  seenVault = true;
276
362
  if (equalAt < 0) index += 1;
277
363
  }
278
- return { vault };
364
+ return { vault, ...(includeHistorical ? { includeHistorical: true } : {}) };
279
365
  }
280
366
 
281
367
  export async function runMemoryCurateCli(argv, {
@@ -313,8 +399,16 @@ export async function runMemoryCurateCli(argv, {
313
399
 
314
400
  const rl = createInterface({ input, output });
315
401
  try {
402
+ const allCandidates = listMemoryCandidatesForCuration(vault, { includeHistorical: true });
403
+ const omittedHistorical = args.includeHistorical
404
+ ? 0
405
+ : allCandidates.filter((item) => item.classification === 'historical').length;
316
406
  const result = await runGuidedMemoryCuration(vault, {
317
407
  locale: getLocale(vault).id,
408
+ omittedHistorical,
409
+ loadCandidates: (base) => listMemoryCandidatesForCuration(base, {
410
+ includeHistorical: Boolean(args.includeHistorical),
411
+ }),
318
412
  ask: (question) => rl.question(question),
319
413
  write: (value) => output.write(String(value)),
320
414
  });
package/src/memory.mjs CHANGED
@@ -21,6 +21,10 @@ import {
21
21
  import { validateCore } from './validate-core.mjs';
22
22
  import { checkMemoryBundle } from '../hooks/vault-health.mjs';
23
23
  import { scopeForMemoryKey } from '../hooks/memory-scope.mjs';
24
+ import {
25
+ candidateSessionContext,
26
+ classifyMemoryCandidate,
27
+ } from '../packages/vault/src/memory-candidate-policy.mjs';
24
28
 
25
29
  const BRAIN = '.brain';
26
30
  const LEDGER = 'MEMORY_EVENTS.jsonl';
@@ -241,24 +245,17 @@ export function planScopedMemoryMigration(vault) {
241
245
  const projection = deriveMemoryProjection(vault, ledger.events);
242
246
  const ambiguous = new Set(projection.candidates.map((candidate) => candidate.record_key || candidate.memory_key));
243
247
  const existingIds = new Set(ledger.events.map((event) => event.event_id));
248
+ const alreadyRescopedSourceIds = new Set(ledger.events.flatMap((event) => [
249
+ event.rescopes_event_id,
250
+ ...(Array.isArray(event.rescopes_event_ids) ? event.rescopes_event_ids : []),
251
+ ]).filter(Boolean));
244
252
  const planned = [];
253
+ const plannedSourceIds = new Set();
245
254
 
246
- for (const [recordKey, record] of Object.entries(projection.records)) {
247
- if (ambiguous.has(recordKey) || record.source?.scope) continue;
248
- const source = record.source;
249
- const sameLegacyKey = ledger.events.filter((event) => (
250
- !event.scope && event.memory_key === source.memory_key && !event.rescopes_event_id
251
- ));
252
- const scope = scopeForMemoryKey(source.memory_key, {
253
- ...source,
254
- projectId: source.project_id,
255
- workSessionId: source.work_session_id,
256
- branch: source.value?.branch || source.value?.branch_name,
257
- worktreeId: source.value?.worktree_id,
258
- repositoryId: source.value?.repository_id,
259
- });
255
+ const appendRescope = (source, scope, rescopesEventIds = [source.event_id]) => {
260
256
  const eventId = rescopeEventId(source.event_id, scope);
261
- if (existingIds.has(eventId)) continue;
257
+ if (existingIds.has(eventId) || alreadyRescopedSourceIds.has(source.event_id)
258
+ || plannedSourceIds.has(source.event_id)) return false;
262
259
  planned.push({
263
260
  v: 1,
264
261
  event_id: eventId,
@@ -266,7 +263,7 @@ export function planScopedMemoryMigration(vault) {
266
263
  memory_key: source.memory_key,
267
264
  scope,
268
265
  operation: 'assert',
269
- value: record.value,
266
+ value: source.value,
270
267
  authority: source.authority,
271
268
  canonical_session_id: source.canonical_session_id || 'memory-rescope',
272
269
  activation_id: source.activation_id || 'memory-rescope',
@@ -275,9 +272,58 @@ export function planScopedMemoryMigration(vault) {
275
272
  source_turn_id: source.source_turn_id || 'memory-rescope',
276
273
  observed_at: source.observed_at || new Date(0).toISOString(),
277
274
  evidence: [...new Set([...(source.evidence || []), `memory-rescope:${source.event_id}`])],
275
+ ...(source.work_session_id ? { work_session_id: source.work_session_id } : {}),
278
276
  rescopes_event_id: source.event_id,
279
- rescopes_event_ids: sameLegacyKey.map((event) => event.event_id).sort(),
277
+ rescopes_event_ids: [...new Set(rescopesEventIds)].sort(),
280
278
  });
279
+ plannedSourceIds.add(source.event_id);
280
+ return true;
281
+ };
282
+
283
+ for (const [recordKey, record] of Object.entries(projection.records)) {
284
+ if (ambiguous.has(recordKey) || record.source?.scope) continue;
285
+ const source = record.source;
286
+ const sameLegacyKey = ledger.events.filter((event) => (
287
+ !event.scope && event.memory_key === source.memory_key && !event.rescopes_event_id
288
+ ));
289
+ const scope = scopeForMemoryKey(source.memory_key, {
290
+ ...source,
291
+ projectId: source.project_id,
292
+ workSessionId: source.work_session_id,
293
+ branch: source.value?.branch || source.value?.branch_name,
294
+ worktreeId: source.value?.worktree_id,
295
+ repositoryId: source.value?.repository_id,
296
+ });
297
+ const eventId = rescopeEventId(source.event_id, scope);
298
+ if (existingIds.has(eventId)) continue;
299
+ appendRescope(
300
+ { ...source, value: record.value },
301
+ scope,
302
+ sameLegacyKey.map((event) => event.event_id),
303
+ );
304
+ }
305
+
306
+ let rescopableConflicts = 0;
307
+ for (const candidate of projection.candidates) {
308
+ if (candidate.reason !== 'conflict' || candidate.memory_key !== 'handoff.latest') continue;
309
+ const sources = Array.isArray(candidate.events) ? candidate.events : [];
310
+ if (!sources.length || sources.some((source) => (
311
+ source.scope || (!source.work_session_id && !source.canonical_session_id)
312
+ ))) continue;
313
+
314
+ const scopedSources = sources.map((source) => ({
315
+ source,
316
+ scope: scopeForMemoryKey(source.memory_key, {
317
+ ...source,
318
+ projectId: source.project_id,
319
+ workSessionId: source.work_session_id,
320
+ }),
321
+ }));
322
+ if (scopedSources.some(({ scope }) => scope.type !== 'work_session'
323
+ || scope.id.startsWith('legacy:'))) continue;
324
+
325
+ rescopableConflicts += 1;
326
+ for (const { source, scope } of scopedSources) appendRescope(source, scope);
281
327
  }
282
328
 
283
329
  return {
@@ -285,6 +331,7 @@ export function planScopedMemoryMigration(vault) {
285
331
  ledger_events: ledger.events.length,
286
332
  planned: planned.length,
287
333
  ambiguous: projection.candidates.length,
334
+ rescopable_conflicts: rescopableConflicts,
288
335
  events: planned,
289
336
  };
290
337
  }
@@ -298,13 +345,18 @@ export function rescopeMemoryEvents(vault, { apply = false } = {}) {
298
345
  ledger_events: plan.ledger_events,
299
346
  planned: plan.planned,
300
347
  ambiguous: plan.ambiguous,
348
+ rescopable_conflicts: plan.rescopable_conflicts,
301
349
  scopes: plan.events.map((event) => ({
302
350
  event_id: event.event_id, memory_key: event.memory_key, scope: event.scope,
303
351
  })),
304
352
  };
305
353
  }
306
354
  if (!plan.events.length) {
307
- return { status: 'unchanged', migrated: 0, ambiguous: plan.ambiguous };
355
+ return {
356
+ status: 'unchanged', migrated: 0, ambiguous: plan.ambiguous,
357
+ rescopable_conflicts: plan.rescopable_conflicts,
358
+ remaining_ambiguous: plan.ambiguous,
359
+ };
308
360
  }
309
361
  for (const event of plan.events) enqueueMemoryEvent(vault, event);
310
362
  const projection = projectMemoryOutbox(vault);
@@ -312,6 +364,10 @@ export function rescopeMemoryEvents(vault, { apply = false } = {}) {
312
364
  status: projection.status === 'projected' ? 'migrated' : projection.status,
313
365
  migrated: plan.events.length,
314
366
  ambiguous: plan.ambiguous,
367
+ rescopable_conflicts: plan.rescopable_conflicts,
368
+ remaining_ambiguous: Number.isInteger(projection.candidates)
369
+ ? projection.candidates
370
+ : plan.ambiguous,
315
371
  checkpoint: projection.checkpoint || null,
316
372
  };
317
373
  }
@@ -377,7 +433,7 @@ function curationPreview(value) {
377
433
  : `${visible.slice(0, CURATION_PREVIEW_CHARS - 1).trimEnd()}…`;
378
434
  }
379
435
 
380
- function sanitizedCurationEvent(candidate, eventId, candidateIndex) {
436
+ function sanitizedCurationEvent(candidate, eventId, candidateIndex, registry) {
381
437
  const events = Array.isArray(candidate.events) ? candidate.events : [];
382
438
  const event = events.find((item) => item?.event_id === eventId);
383
439
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
@@ -397,15 +453,24 @@ function sanitizedCurationEvent(candidate, eventId, candidateIndex) {
397
453
  observed_at: sanitizeMemoryText(event.observed_at || event.effective_at || ''),
398
454
  source: sanitizeMemoryText(source),
399
455
  preview: curationPreview(event.value),
456
+ ...Object.fromEntries(Object.entries(candidateSessionContext(event, registry))
457
+ .map(([key, value]) => [key, sanitizeMemoryText(value)])),
400
458
  };
401
459
  }
402
460
 
403
- export function listMemoryCandidatesForCuration(vault) {
461
+ export function listMemoryCandidatesForCuration(vault, { includeHistorical = false } = {}) {
462
+ const registry = readSessionRegistry(vault);
404
463
  return readCandidates(vault)
405
- .map((candidate, index) => ({ candidate, index, safe: sanitizedCandidate(candidate, index) }))
464
+ .map((candidate, index) => ({
465
+ candidate,
466
+ index,
467
+ safe: sanitizedCandidate(candidate, index),
468
+ policy: classifyMemoryCandidate(candidate, registry),
469
+ }))
406
470
  .filter(({ safe }) => safe.reason === 'conflict'
407
471
  && !TERMINAL_CANDIDATE_STATUSES.has(safe.status))
408
- .map(({ candidate, index, safe }) => {
472
+ .filter(({ policy }) => includeHistorical || policy.classification !== 'historical')
473
+ .map(({ candidate, index, safe, policy }) => {
409
474
  if (!safe.event_ids.length) {
410
475
  throw new Error(
411
476
  `MEMORY_CANDIDATES.jsonl: candidate ${index + 1} sem eventos elegíveis.`,
@@ -416,8 +481,12 @@ export function listMemoryCandidatesForCuration(vault) {
416
481
  reason: safe.reason,
417
482
  status: safe.status,
418
483
  memory_key: safe.memory_key,
484
+ classification: policy.classification,
485
+ ...(policy.recommended_action ? { recommended_action: policy.recommended_action } : {}),
419
486
  ...(safe.scope ? { scope: safe.scope } : {}),
420
- events: safe.event_ids.map((eventId) => sanitizedCurationEvent(candidate, eventId, index)),
487
+ events: safe.event_ids.map(
488
+ (eventId) => sanitizedCurationEvent(candidate, eventId, index, registry),
489
+ ),
421
490
  };
422
491
  })
423
492
  .sort((left, right) => lexicalCompare(left.memory_key, right.memory_key)
@@ -26,6 +26,7 @@ export const SQL_EVENT_BATCH_SIZE = 64;
26
26
  export const SQL_EVENT_BATCH_BYTES = 8 * 1024 * 1024;
27
27
  const REQUEST_TIMEOUT_MS = 15000;
28
28
  const MAX_REQUEST_TIMEOUT_MS = 120000;
29
+ export const SQL_LEASE_STALE_MS = MAX_REQUEST_TIMEOUT_MS + 30000;
29
30
  const REQUEST_TIMEOUT_BYTES_STEP = 1024 * 1024;
30
31
  const CAPTURE_LEVELS = new Set(['metadata', 'messages', 'full-transcript']);
31
32
 
@@ -262,7 +263,7 @@ function dedupeEvents(events) {
262
263
  });
263
264
  }
264
265
 
265
- export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {}, captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata' } = {}) {
266
+ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {}, captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata', forceFull = false } = {}) {
266
267
  if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
267
268
  const occurredAt = isoNow(now);
268
269
  const resolvedCaptureLevel = normalizeObserverCaptureLevel(captureLevel);
@@ -289,7 +290,7 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
289
290
  nextState.files[file.logicalPath] = { content_hash: contentHash, revision: revision || 1 };
290
291
  const fm = parseFrontmatter(content);
291
292
  if (fm.type === 'session') sessionContexts.push({ file, content, fm, contentHash, revision: revision || 1, sessionId: sessionIdentity.get(file.logicalPath) });
292
- if (previous?.content_hash === contentHash) continue;
293
+ if (!forceFull && previous?.content_hash === contentHash) continue;
293
294
  changed += 1;
294
295
  events.push(documentEvent({ projectId, logicalPath: file.logicalPath, content, metadata: fm, revision: revision || 1, occurredAt }));
295
296
  if (fm.type === 'session') {
@@ -309,7 +310,7 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
309
310
  const content = readFileSync(source.path, 'utf8');
310
311
  const fingerprint = hash(content);
311
312
  const previousTranscript = state.transcripts?.[source.transcriptId];
312
- if (previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
313
+ if (!forceFull && previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
313
314
  const complete = completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now: occurredAt, captureLevel: resolvedCaptureLevel });
314
315
  nextState.transcripts[source.transcriptId] = { content_hash: complete.fingerprint, coverage: resolvedCaptureLevel };
315
316
  const summaryId = complete.transcriptId;
@@ -467,11 +468,20 @@ function queueOutbox(vaultBase, batch) {
467
468
 
468
469
  function eventCoalesceKey(event) {
469
470
  const payload = event.payload || {};
471
+ const entityFields = {
472
+ 'document.upsert': ['logical_path'],
473
+ 'document.delete': ['logical_path'],
474
+ 'session.upsert': ['session_id'],
475
+ 'agent.upsert': ['agent_id'],
476
+ 'usage.rollup': ['rollup_key'],
477
+ llm_call: ['call_id'],
478
+ 'transcript.upsert': ['transcript_id'],
479
+ }[event.kind] || [];
480
+ const entityId = entityFields.map((field) => payload[field]).find(Boolean) || event.event_id;
470
481
  return [
471
482
  event.project_id,
472
483
  event.kind,
473
- payload.logical_path || payload.session_id || payload.agent_id || payload.rollup_key
474
- || payload.call_id || payload.transcript_id || event.event_id,
484
+ entityId,
475
485
  ].join('\u001f');
476
486
  }
477
487
 
@@ -480,15 +490,17 @@ function waitSync(milliseconds) { Atomics.wait(WAIT_ARRAY, 0, 0, milliseconds);
480
490
 
481
491
  function acquireBatchFileLease(path, waitMs = 0) {
482
492
  const lock = `${path}.lock`;
493
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
483
494
  const deadline = Date.now() + Math.max(0, waitMs);
484
495
  do {
485
496
  try {
486
497
  mkdirSync(lock);
487
- return lock;
498
+ atomicJson(join(lock, 'owner.json'), { token, pid: process.pid, acquired_at: new Date().toISOString() });
499
+ return { path: lock, token };
488
500
  } catch (error) {
489
501
  if (error?.code !== 'EEXIST') throw error;
490
502
  try {
491
- if (Date.now() - statSync(lock).mtimeMs > 60_000) {
503
+ if (Date.now() - statSync(lock).mtimeMs > SQL_LEASE_STALE_MS) {
492
504
  const stale = `${lock}.stale-${process.pid}-${Date.now()}`;
493
505
  renameSync(lock, stale);
494
506
  rmSync(stale, { recursive: true, force: true });
@@ -502,8 +514,10 @@ function acquireBatchFileLease(path, waitMs = 0) {
502
514
  return null;
503
515
  }
504
516
 
505
- function releaseBatchFileLease(lock) {
506
- if (lock) rmSync(lock, { recursive: true, force: true });
517
+ function releaseBatchFileLease(lease) {
518
+ if (!lease) return;
519
+ const owner = readJson(join(lease.path, 'owner.json'), {});
520
+ if (owner.token === lease.token) rmSync(lease.path, { recursive: true, force: true });
507
521
  }
508
522
 
509
523
  /** Queue a precise writer batch and replace older pending state for the same logical scope. */
@@ -625,7 +639,7 @@ function acquirePublisherLease(vaultBase, currentTime = Date.now()) {
625
639
  if (error?.code !== 'EEXIST') throw error;
626
640
  let age = 0;
627
641
  try { age = currentTime - statSync(path).mtimeMs; } catch { return null; }
628
- if (age <= 60_000) return null;
642
+ if (age <= SQL_LEASE_STALE_MS) return null;
629
643
  const stale = `${path}.stale-${token}`;
630
644
  try {
631
645
  renameSync(path, stale);
@@ -770,14 +784,14 @@ export async function publishObserverSqlIncremental({
770
784
  };
771
785
  }
772
786
 
773
- export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '', captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata' } = {}) {
787
+ export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '', captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata', forceFull = false } = {}) {
774
788
  if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
775
789
  const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl, token });
776
790
  const state = readState(vaultBase);
777
- const remoteDocuments = Object.keys(state.files || {}).length === 0
791
+ const remoteDocuments = forceFull || Object.keys(state.files || {}).length === 0
778
792
  ? await readRemoteDocuments({ url, projectId, fetchImpl, token })
779
793
  : {};
780
- const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel });
794
+ const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel, forceFull });
781
795
  if (!batch.events.length) {
782
796
  atomicJson(statePath(vaultBase), batch.nextState);
783
797
  return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
package/src/observer.mjs CHANGED
@@ -147,6 +147,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
147
147
  projectId: snapshot.project_id,
148
148
  url,
149
149
  token,
150
+ forceFull: true,
150
151
  captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
151
152
  });
152
153
  const snapshotResponse = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(snapshot.project_id)}/snapshot`, {
@@ -209,6 +210,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
209
210
  projectId: snapshot.project_id,
210
211
  url,
211
212
  token,
213
+ forceFull: true,
212
214
  captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
213
215
  });
214
216
  const snapshotResponse = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(snapshot.project_id)}/snapshot`, {