wendkeep 0.67.2 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ 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.68.0] — 2026-08-02
8
+
9
+ ### Added
10
+
11
+ - **Curadoria de memória agora tem um assistente interativo para pessoas.** O novo
12
+ `memory curate --vault <vault>` agrupa conflitos por nomes amigáveis, exibe somente previews
13
+ sanitizados e guia promoção, rejeição, pulo ou saída com confirmação negativa por padrão. O
14
+ trabalho restante pode ser retomado em outra execução; ambientes não-TTY recebem o fallback
15
+ seguro `memory candidates --active` e nenhuma escrita implícita.
16
+
17
+ ### Changed
18
+
19
+ - **O `doctor` apresenta integridade e conflitos de memória em formato humano.** A saída principal
20
+ usa seções e categorias amigáveis e recomenda primeiro o assistente guiado, enquanto o hook de
21
+ health preserva seu contrato JSON para automações. O diagnóstico continua somente leitura e
22
+ `memory repair` continua estritamente estrutural, sem escolher vencedores semânticos.
23
+
24
+ ## [0.67.3] — 2026-08-02
25
+
26
+ ### Fixed
27
+
28
+ - **Conflitos semânticos agora levam a uma curadoria humana executável.** O doctor diferencia
29
+ candidates em conflito de corrupção estrutural, explica que `memory repair` não escolhe um
30
+ vencedor e mostra `memory candidates --active --vault <vault>` com o Vault resolvido, seguido
31
+ pelos modelos explícitos para promover ou rejeitar a decisão.
32
+ - **A inspeção de candidates deixa de exigir leitura direta do sidecar.** O novo comando read-only
33
+ `memory candidates [--active]` devolve somente `candidate_id`, `reason`, `status`, `memory_key` e
34
+ `event_ids`, sem valores ou conteúdo, em ordem determinística e sem alterar o bundle de memória.
35
+
7
36
  ## [0.67.2] — 2026-08-02
8
37
 
9
38
  ### Fixed
package/README.en.md CHANGED
@@ -369,8 +369,13 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
369
369
  ambiguity uses `memory reconcile <session> --by-session <successor>
370
370
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
371
371
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
372
- explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
373
- candidate, while `memory reject <id>` keeps the current value. The decision is idempotent, and a
372
+ explicit, durable curation. Start with `memory curate --vault <vault>`: the menu groups conflicts
373
+ under friendly names, shows sanitized previews, and confirms every write with default `no`. Skip
374
+ or quit and run it again to resume. In a non-TTY environment, use
375
+ `memory candidates --active --vault <vault>` to list only safe IDs and metadata in read-only mode —
376
+ it does not expose memory values or content. After human review,
377
+ `memory promote <id> --event <event-id>` selects one event from the candidate, while
378
+ `memory reject <id>` keeps the current value. `memory repair` does not choose a winner. The decision is idempotent, and a
374
379
  new promotion accepts a later Stop from the same session/activation without recreating a conflict.
375
380
  A promotion written by 0.66.1 remains historical: if the next Stop forms a new candidate, update
376
381
  to 0.66.3 and run `memory repair`. During replay, a transient candidate is re-evaluated against the
@@ -379,7 +384,10 @@ superseded. Repair migrates the checkpoint and mirror only when it proves the ex
379
384
  replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
380
385
  appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
381
386
  install 0.66.2; use 0.66.3 or later. Decisions survive repair/replay;
382
- `blocked_by_core` cannot override CORE. Doctor only diagnoses. When status/doctor reports projected
387
+ `blocked_by_core` cannot override CORE. Doctor only diagnoses, now with human-readable output and
388
+ the guided next action; its health hook preserves JSON for automation. A missing Vault or unsafe
389
+ boundary/registry also yields blocked memory, a safe command with the resolved path, and structured
390
+ JSON—never a false “intact bundle” or a stack trace. When status/doctor reports projected
383
391
  acknowledgement pending on 0.66.4 or later, first run the targeted dry run
384
392
  `memory recover-attempt <session> --vault <vault>`, then authorize `--apply`; it changes only
385
393
  registry/checkpoint. See syntax, preconditions, and fail-closed behavior in
package/README.md CHANGED
@@ -369,8 +369,13 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
369
369
  ambiguity uses `memory reconcile <session> --by-session <successor>
370
370
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
371
371
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
372
- explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
373
- candidate, while `memory reject <id>` keeps the current value. The decision is idempotent, and a
372
+ explicit, durable curation. Start with `memory curate --vault <vault>`: the menu groups conflicts
373
+ under friendly names, shows sanitized previews, and confirms every write with default `no`. Skip
374
+ or quit and run it again to resume. In a non-TTY environment, use
375
+ `memory candidates --active --vault <vault>` to list only safe IDs and metadata in read-only mode —
376
+ it does not expose memory values or content. After human review,
377
+ `memory promote <id> --event <event-id>` selects one event from the candidate, while
378
+ `memory reject <id>` keeps the current value. `memory repair` does not choose a winner. The decision is idempotent, and a
374
379
  new promotion accepts a later Stop from the same session/activation without recreating a conflict.
375
380
  A promotion written by 0.66.1 remains historical: if the next Stop forms a new candidate, update
376
381
  to 0.66.3 and run `memory repair`. During replay, a transient candidate is re-evaluated against the
@@ -379,7 +384,10 @@ superseded. Repair migrates the checkpoint and mirror only when it proves the ex
379
384
  replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
380
385
  appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
381
386
  install 0.66.2; use 0.66.3 or later. Decisions survive repair/replay;
382
- `blocked_by_core` cannot override CORE. Doctor only diagnoses. When status/doctor reports projected
387
+ `blocked_by_core` cannot override CORE. Doctor only diagnoses, now with human-readable output and
388
+ the guided next action; its health hook preserves JSON for automation. A missing Vault or unsafe
389
+ boundary/registry also yields blocked memory, a safe command with the resolved path, and structured
390
+ JSON—never a false “intact bundle” or a stack trace. When status/doctor reports projected
383
391
  acknowledgement pending on 0.66.4 or later, first run the targeted dry run
384
392
  `memory recover-attempt <session> --vault <vault>`, then authorize `--apply`; it changes only
385
393
  registry/checkpoint. See syntax, preconditions, and fail-closed behavior in
@@ -24,6 +24,7 @@ Run from the project root or provide `--project` and `--vault` explicitly.
24
24
 
25
25
  ```bash
26
26
  npx --no-install wendkeep doctor [--vault <vault>]
27
+ npx --no-install wendkeep memory curate --vault <vault>
27
28
  npx --no-install wendkeep sync-defs [--check|--reseed] --vault <vault> --project <root>
28
29
  npx --no-install wendkeep theme sync --vault <vault>
29
30
  npx --no-install wendkeep --version
@@ -33,6 +34,12 @@ npx --no-install wendkeep --help
33
34
  ## Options and exit codes
34
35
 
35
36
  - `doctor` is read-only; exit `0` accepts recoverable warnings, while non-zero means failure.
37
+ - `doctor` uses human-readable output with `[integrity]` and `[memory]` sections, friendly
38
+ categories, and a copyable next action. The `vault-health.mjs` hook remains the JSON surface for
39
+ automation; neither surface applies curation.
40
+ - Even with a missing Vault, unsafe physical boundary, or unsafe registry, `doctor` marks memory as
41
+ blocked and shows `memory status --gate` with the resolved path; the hook preserves structured
42
+ JSON instead of replacing the result with stderr or a stack trace.
36
43
  - In v2, `doctor`/`memory status --gate` correlate `last_memory_attempt` (mode, disposition, event
37
44
  IDs, and checkpoint) with outbox, ledger, and SHARED; they do not infer health from revision alone.
38
45
  - `revision: 0` after a valid migration, with no v2 attempt, is healthy. A `degraded` attempt whose
@@ -57,6 +64,8 @@ npx --no-install wendkeep --version
57
64
  npx --no-install wendkeep sync-defs --check --vault .MyApp-vault --project .
58
65
  npx --no-install wendkeep doctor --vault .MyApp-vault
59
66
  npx --no-install wendkeep memory status --gate --vault .MyApp-vault
67
+ npx --no-install wendkeep memory curate --vault .MyApp-vault
68
+ npx --no-install wendkeep memory candidates --active --vault .MyApp-vault
60
69
  npx --no-install wendkeep cost rebuild --session <id> --json --vault .MyApp-vault
61
70
  npx --no-install wendkeep cost rebuild --session <id> --json --vault .MyApp-vault --apply
62
71
  ```
@@ -74,11 +83,19 @@ dry-run path before any write.
74
83
 
75
84
  - `no vault`: run from the bound root or pass `--vault`.
76
85
  - `defs stale`: confirm the version and run `sync-defs --reseed`.
77
- - Legacy vault: this is a non-blocking warning; plan `memory migrate --apply` separately.
86
+ - Legacy vault: this is a non-blocking warning; doctor shows
87
+ `npx --no-install wendkeep memory migrate --apply --vault <vault>` with the resolved Vault, but
88
+ migration remains opt-in and must be planned separately.
78
89
  - `degraded` plus an intact outbox: warning; preserve the outbox and allow idempotent replay.
79
90
  - `ambiguous`, lost publication, or a mismatched checkpoint: blocking; preserve registry, ledger,
80
91
  outbox, and SHARED so `last_memory_attempt` can be correlated before repair.
81
92
  - Corrupt bundle: preserve evidence and run `memory status --gate` before `memory repair`.
93
+ - An active semantic conflict requires a human decision: `memory repair` does not choose a winner.
94
+ Start with the guided menu `memory curate --vault <vault>`. For advanced inspection or a
95
+ non-interactive terminal, list safe IDs with `memory candidates --active --vault <vault>`, review
96
+ the evidence, and then use
97
+ `memory promote <candidate-id> --event <event-id> --vault <vault>` to select an event or
98
+ `memory reject <candidate-id> --vault <vault>` to keep the current operational value.
82
99
  - `legacy`/`degraded`/`stale`/`manifest-unproven` observability: run
83
100
  `npx --no-install wendkeep cost rebuild --session <id> --json --vault <vault>`, review diagnostics,
84
101
  and only then authorize the second variant with `--apply`.
@@ -24,11 +24,13 @@ Pass the vault explicitly in automation. Preserve backups and evidence before re
24
24
 
25
25
  ```bash
26
26
  npx wendkeep memory status [--gate] --vault <vault>
27
+ npx wendkeep memory curate --vault <vault>
28
+ npx wendkeep memory candidates [--active] --vault <vault>
27
29
  npx wendkeep memory repair --vault <vault>
28
30
  npx wendkeep memory recover-attempt <session> [--apply] --vault <vault>
29
31
  npx wendkeep memory reconcile <ambiguous-session> --by-session <successor-session> --reason <reason> [--apply] --vault <vault>
30
- npx wendkeep memory promote <candidate> [--event <event-id>] --vault <vault>
31
- npx wendkeep memory reject <candidate> --vault <vault>
32
+ npx wendkeep memory promote <candidate-id> [--event <event-id>] --vault <vault>
33
+ npx wendkeep memory reject <candidate-id> --vault <vault>
32
34
  npx wendkeep validate-memory [CORE-path]
33
35
  npx wendkeep validate-memory --vault <v2-vault>
34
36
  ```
@@ -36,6 +38,21 @@ npx wendkeep validate-memory --vault <v2-vault>
36
38
  ## Options and exit codes
37
39
 
38
40
  - `memory status` is read-only; `--gate` exits `1` only for blocking state.
41
+ - `memory curate` is the recommended human path: in an interactive terminal it groups each
42
+ conflict under a friendly name, shows sanitized previews only, and offers numbered choices,
43
+ `P` to skip, `R` to reject, `D` for technical details, and `Q` to quit. Every promotion or
44
+ rejection asks for confirmation with default `no`: Enter or `N` does not write. Skip or quit
45
+ leaves the remaining work pending; running the command again resumes the active conflicts.
46
+ - The assistant accepts only `--vault`: there is no `--yes`, `--apply`, or batch mode. In a
47
+ non-TTY environment it exits `2` without changing bytes and recommends the advanced fallback
48
+ `memory candidates --active`.
49
+ - `memory candidates` is read-only and prints deterministic JSON containing only `candidate_id`,
50
+ `reason`, `status`, `memory_key`, and `event_ids`; it does not expose memory values or content and
51
+ does not create a lock or mutate the bundle. `--active` omits terminal candidates (`resolved`,
52
+ `rejected`, and `superseded`). A missing status is normalized to `active`.
53
+ - For `memory candidates`, exit `0` means a valid inventory (including empty or conflicted), exit
54
+ `1` means an invalid sidecar/unsafe topology, and exit `2` means a missing `--vault`, unknown or
55
+ duplicate option, extra argument, or an invalid value passed to `--active`.
39
56
  - `Stop` writes events to the outbox before acknowledging `last_memory_attempt: enqueued`, then the
40
57
  projector runs outside the registry lock. Retrying the same attempt reuses its frozen event IDs
41
58
  and can project them at most once.
@@ -81,8 +98,9 @@ npx wendkeep validate-memory --vault <v2-vault>
81
98
  the lease they acquired.
82
99
  - `promote`/`reject` append an auditable, idempotent decision to the ledger. Replay and repair
83
100
  preserve that decision and do not recreate the resolved candidate. For a `conflict` candidate,
84
- `promote` requires an `--event <event-id>` that belongs to the candidate; date or random ID
85
- never picks an implicit winner. `reject` preserves the current operational value. A
101
+ use `memory promote <candidate-id> --event <event-id>` with an event that belongs to the
102
+ candidate; date or random ID never picks an implicit winner. `reject` preserves the current
103
+ operational value. A
86
104
  `blocked_by_core` candidate can only be rejected: promotion first requires canonical CORE
87
105
  curation. If the selected event still belongs to the matching latest `projected` attempt,
88
106
  promotion also refreshes its checkpoint and mirror causally; JSON reports
@@ -105,6 +123,8 @@ npx wendkeep validate-memory --vault <v2-vault>
105
123
 
106
124
  ```bash
107
125
  npx wendkeep memory status --gate --vault .MyApp-vault
126
+ npx wendkeep memory curate --vault .MyApp-vault
127
+ npx wendkeep memory candidates --active --vault .MyApp-vault
108
128
  npx wendkeep memory recover-attempt session-123 --vault .MyApp-vault
109
129
  npx wendkeep memory recover-attempt session-123 --apply --vault .MyApp-vault
110
130
  npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --vault .MyApp-vault
@@ -121,6 +141,9 @@ state of the last attempt. CORE stays hand-curated and canonical; SHARED stays a
121
141
  operational projection. After successful projection, an attempt checkpoint may be a valid prefix
122
142
  of a global projection that has already advanced with concurrent events.
123
143
 
144
+ `memory candidates` returns `status: "ok"` and the sanitized candidate list in stable order; with
145
+ `--active`, the list contains only decisions still open for human curation.
146
+
124
147
  ## Common errors and diagnosis
125
148
 
126
149
  - `legacy`: follow the migration guide; this is not corruption.
@@ -24,6 +24,7 @@ Execute na raiz do projeto ou informe `--project`/`--vault` explicitamente.
24
24
 
25
25
  ```bash
26
26
  npx --no-install wendkeep doctor [--vault <cofre>]
27
+ npx --no-install wendkeep memory curate --vault <cofre>
27
28
  npx --no-install wendkeep sync-defs [--check|--reseed] --vault <cofre> --project <raiz>
28
29
  npx --no-install wendkeep theme sync --vault <cofre>
29
30
  npx --no-install wendkeep --version
@@ -33,6 +34,12 @@ npx --no-install wendkeep --help
33
34
  ## Opções e códigos de saída
34
35
 
35
36
  - `doctor` é read-only; exit `0` aceita warnings recuperáveis e exit não zero indica falha.
37
+ - O `doctor` usa saída em formato humano, com blocos `[integridade]` e `[memória]`, categorias
38
+ amigáveis e uma próxima ação copiável. O hook `vault-health.mjs` continua sendo a superfície JSON
39
+ para automações; nenhum dos dois aplica curadoria.
40
+ - Mesmo com Vault ausente, boundary física insegura ou registry inseguro, o `doctor` marca a memória
41
+ como bloqueada e mostra `memory status --gate` com o caminho resolvido; o hook preserva JSON
42
+ estruturado em vez de substituir o resultado por stderr ou stack trace.
36
43
  - Em v2, `doctor`/`memory status --gate` correlacionam `last_memory_attempt` (mode, disposition,
37
44
  event IDs e checkpoint) com outbox, ledger e SHARED; não inferem saúde só pela revision atual.
38
45
  - `revision: 0` após migração válida, sem attempt v2, é saudável. Attempt `degraded` cujos eventos
@@ -57,6 +64,8 @@ npx --no-install wendkeep --version
57
64
  npx --no-install wendkeep sync-defs --check --vault .MeuApp-vault --project .
58
65
  npx --no-install wendkeep doctor --vault .MeuApp-vault
59
66
  npx --no-install wendkeep memory status --gate --vault .MeuApp-vault
67
+ npx --no-install wendkeep memory curate --vault .MeuApp-vault
68
+ npx --no-install wendkeep memory candidates --active --vault .MeuApp-vault
60
69
  npx --no-install wendkeep cost rebuild --session <id> --json --vault .MeuApp-vault
61
70
  npx --no-install wendkeep cost rebuild --session <id> --json --vault .MeuApp-vault --apply
62
71
  ```
@@ -74,11 +83,18 @@ ou sem manifest comprovado e oferece um caminho dry-run antes de qualquer escrit
74
83
 
75
84
  - `no vault`: execute da raiz vinculada ou passe `--vault`.
76
85
  - `defs stale`: confirme a versão e rode `sync-defs --reseed`.
77
- - Vault legado: é warning não bloqueante; planeje `memory migrate --apply` separadamente.
86
+ - Vault legado: é warning não bloqueante; o doctor mostra
87
+ `npx --no-install wendkeep memory migrate --apply --vault <cofre>` com o Vault resolvido, mas a
88
+ migração continua sendo opt-in e deve ser planejada separadamente.
78
89
  - `degraded` + outbox íntegra: warning; preserve a outbox e permita replay idempotente.
79
90
  - `ambiguous`, publicação perdida ou checkpoint divergente: bloqueante; preserve registry, ledger,
80
91
  outbox e SHARED para correlacionar `last_memory_attempt` antes de reparar.
81
92
  - Bundle corrompido: preserve a evidência e use `memory status --gate` antes de `memory repair`.
93
+ - Conflito semântico ativo exige decisão humana: `memory repair` não escolhe vencedor. Comece pelo
94
+ menu guiado `memory curate --vault <cofre>`. Para inspeção avançada ou terminal não interativo,
95
+ liste os IDs seguros com `memory candidates --active --vault <cofre>`, revise a evidência e use
96
+ `memory promote <candidate-id> --event <event-id> --vault <cofre>` para selecionar um evento ou
97
+ `memory reject <candidate-id> --vault <cofre>` para manter o valor operacional atual.
82
98
  - Observabilidade `legacy`/`degraded`/`stale`/`manifest-unproven`: rode
83
99
  `npx --no-install wendkeep cost rebuild --session <id> --json --vault <cofre>`, revise diagnostics
84
100
  e só então autorize a segunda variante com `--apply`.
@@ -24,11 +24,13 @@ Informe o vault explicitamente em automações. Preserve backups e evidências a
24
24
 
25
25
  ```bash
26
26
  npx wendkeep memory status [--gate] --vault <cofre>
27
+ npx wendkeep memory curate --vault <cofre>
28
+ npx wendkeep memory candidates [--active] --vault <cofre>
27
29
  npx wendkeep memory repair --vault <cofre>
28
30
  npx wendkeep memory recover-attempt <sessão> [--apply] --vault <cofre>
29
31
  npx wendkeep memory reconcile <sessão-ambígua> --by-session <sessão-sucessora> --reason <motivo> [--apply] --vault <cofre>
30
- npx wendkeep memory promote <candidate> [--event <event-id>] --vault <cofre>
31
- npx wendkeep memory reject <candidate> --vault <cofre>
32
+ npx wendkeep memory promote <candidate-id> [--event <event-id>] --vault <cofre>
33
+ npx wendkeep memory reject <candidate-id> --vault <cofre>
32
34
  npx wendkeep validate-memory [caminho-do-CORE]
33
35
  npx wendkeep validate-memory --vault <cofre-v2>
34
36
  ```
@@ -36,6 +38,21 @@ npx wendkeep validate-memory --vault <cofre-v2>
36
38
  ## Opções e códigos de saída
37
39
 
38
40
  - `memory status` é read-only; `--gate` retorna exit `1` apenas para estado bloqueante.
41
+ - `memory curate` é o caminho recomendado para pessoas: em um terminal interativo, agrupa cada
42
+ conflito por nome amigável, mostra somente previews sanitizados e oferece escolhas numeradas,
43
+ `P` para pular, `R` para rejeitar, `D` para detalhes técnicos e `Q` para sair. Cada promoção ou
44
+ rejeição pede confirmação com padrão negativo: Enter ou `N` não grava. Pular ou sair deixa o
45
+ restante pendente; uma nova execução retoma os conflitos ainda ativos.
46
+ - O assistente aceita somente `--vault`: não há `--yes`, `--apply` ou modo em lote. Em ambiente
47
+ não-TTY/terminal não interativo, ele retorna exit `2` sem alterar bytes e orienta usar o fallback
48
+ avançado `memory candidates --active`.
49
+ - `memory candidates` é read-only e imprime JSON determinístico com somente `candidate_id`,
50
+ `reason`, `status`, `memory_key` e `event_ids`; não expõe valores nem conteúdo da memória e não
51
+ cria lock nem altera o bundle. `--active` omite candidates terminais (`resolved`, `rejected` e
52
+ `superseded`). Status ausente é normalizado para `active`.
53
+ - Em `memory candidates`, exit `0` indica inventário válido (inclusive vazio ou com conflitos),
54
+ exit `1` indica sidecar inválido/topologia insegura e exit `2` indica `--vault` ausente, opção
55
+ desconhecida/duplicada, argumento extra ou valor indevido em `--active`.
39
56
  - O `Stop` grava os eventos na outbox antes de reconhecer `last_memory_attempt: enqueued`; depois o
40
57
  projector roda fora do lock do registry. Retry do mesmo attempt reutiliza os event IDs congelados
41
58
  e pode projetá-los no máximo uma vez.
@@ -79,8 +96,9 @@ npx wendkeep validate-memory --vault <cofre-v2>
79
96
  symlink, reparse point ou hardlink falham fechados sem tocar bytes externos. Locks publicam owner
80
97
  e lease atomicamente, não colhem PID vivo apenas por idade e só liberam a lease adquirida.
81
98
  - `promote`/`reject` acrescentam uma decisão auditável e idempotente ao ledger. Replay e repair
82
- preservam a decisão e não recriam o candidate resolvido. Para candidate `conflict`, `promote`
83
- exige `--event <event-id>` pertencente ao candidate; não há vencedor implícito por data ou ID.
99
+ preservam a decisão e não recriam o candidate resolvido. Para candidate `conflict`, use
100
+ `memory promote <candidate-id> --event <event-id>` com um evento pertencente ao candidate; não há
101
+ vencedor implícito por data ou ID.
84
102
  `reject` preserva o valor operacional atual. Candidate `blocked_by_core` só pode ser rejeitado:
85
103
  promover exige antes alterar CORE pela curadoria canônica. Se o evento escolhido ainda pertence
86
104
  ao último attempt `projected` correspondente, a promoção também atualiza causalmente checkpoint
@@ -103,6 +121,8 @@ npx wendkeep validate-memory --vault <cofre-v2>
103
121
 
104
122
  ```bash
105
123
  npx wendkeep memory status --gate --vault .MeuApp-vault
124
+ npx wendkeep memory curate --vault .MeuApp-vault
125
+ npx wendkeep memory candidates --active --vault .MeuApp-vault
106
126
  npx wendkeep memory recover-attempt sessao-123 --vault .MeuApp-vault
107
127
  npx wendkeep memory recover-attempt sessao-123 --apply --vault .MeuApp-vault
108
128
  npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --vault .MeuApp-vault
@@ -119,6 +139,9 @@ causal do último attempt. CORE permanece canônico e curado à mão; SHARED per
119
139
  operacional verificável. Depois de uma projeção bem-sucedida, o checkpoint do attempt pode ser um
120
140
  prefixo válido de uma projeção global que já avançou com eventos concorrentes.
121
141
 
142
+ `memory candidates` retorna `status: "ok"` e a lista sanitizada de candidates em ordem estável;
143
+ com `--active`, a lista contém somente decisões ainda abertas para curadoria humana.
144
+
122
145
  ## Erros comuns e diagnóstico
123
146
 
124
147
  - `legacy`: siga o guia de migração; não é corrupção.
@@ -106,6 +106,34 @@ const memoryStatusCommand = (vaultBase) => (
106
106
  const memoryRepairCommand = (vaultBase) => (
107
107
  `${WENDKEEP_COMMAND} memory repair --vault ${quoteCommandArgument(vaultBase)}`
108
108
  );
109
+ const memoryMigrateCommand = (vaultBase) => (
110
+ `${WENDKEEP_COMMAND} memory migrate --apply --vault ${quoteCommandArgument(vaultBase)}`
111
+ );
112
+ const memoryCandidatesCommand = (vaultBase) => (
113
+ `${WENDKEEP_COMMAND} memory candidates --active --vault ${quoteCommandArgument(vaultBase)}`
114
+ );
115
+ const memoryCurateCommand = (vaultBase) => (
116
+ `${WENDKEEP_COMMAND} memory curate --vault ${quoteCommandArgument(vaultBase)}`
117
+ );
118
+
119
+ const MEMORY_KEY_PURPOSES = new Map([
120
+ ['handoff.latest', 'próximo handoff'],
121
+ ['quality.latest-sensors', 'sensores de qualidade'],
122
+ ['quality.latest-verdict', 'veredito de qualidade'],
123
+ ['git.local-head', 'commit local conhecido'],
124
+ ]);
125
+
126
+ function groupConflictPurposes(conflicts) {
127
+ const counts = new Map();
128
+ for (const conflict of conflicts) {
129
+ const key = String(conflict?.memory_key || '').trim();
130
+ const label = MEMORY_KEY_PURPOSES.get(key) || (key ? `outras memórias (${key})` : 'outras memórias');
131
+ counts.set(label, (counts.get(label) || 0) + 1);
132
+ }
133
+ return [...counts.entries()]
134
+ .sort(([left], [right]) => left.localeCompare(right))
135
+ .map(([label, count]) => `${label}: ${count}`);
136
+ }
109
137
 
110
138
  function readJsonLines(vaultBase, path, label) {
111
139
  let checked;
@@ -206,11 +234,11 @@ function memoryMetrics() {
206
234
  };
207
235
  }
208
236
 
209
- function blockedMemoryBoundary(error) {
237
+ function blockedMemoryBoundary(error, vaultBase) {
210
238
  return {
211
239
  ok: false,
212
240
  status: 'blocked',
213
- failures: [`Boundary física da memória insegura: ${error?.message || error}`],
241
+ failures: [`Boundary física da memória insegura: ${error?.message || error}. Inspecione com: ${memoryStatusCommand(vaultBase)}.`],
214
242
  warnings: [],
215
243
  metrics: memoryMetrics(),
216
244
  };
@@ -381,23 +409,23 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
381
409
  return {
382
410
  ok: false,
383
411
  status: 'blocked',
384
- failures: [`Vault not found: ${vaultBase}`],
412
+ failures: [`Vault not found: ${vaultBase}. Inspecione com: ${memoryStatusCommand(vaultBase)}.`],
385
413
  warnings: [],
386
414
  metrics: memoryMetrics(),
387
415
  };
388
416
  }
389
417
  try { preflightMemoryBundle(vaultBase); }
390
- catch (error) { return blockedMemoryBoundary(error); }
418
+ catch (error) { return blockedMemoryBoundary(error, vaultBase); }
391
419
  const brain = join(vaultBase, '.brain');
392
420
  let mode;
393
421
  try { mode = detectMemoryMode(vaultBase); }
394
- catch (error) { return blockedMemoryBoundary(error); }
422
+ catch (error) { return blockedMemoryBoundary(error, vaultBase); }
395
423
  if (mode.mode === 'legacy') {
396
424
  return {
397
425
  ok: true,
398
426
  status: 'legacy',
399
427
  failures: [],
400
- warnings: [LEGACY_MEMORY_WARNING],
428
+ warnings: [`${LEGACY_MEMORY_WARNING} Comando com Vault resolvido: ${memoryMigrateCommand(vaultBase)}.`],
401
429
  metrics: memoryMetrics(),
402
430
  };
403
431
  }
@@ -451,7 +479,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
451
479
  let effectiveRegistry = registry;
452
480
  if (!effectiveRegistry) {
453
481
  try { effectiveRegistry = readSessionRegistry(vaultBase); }
454
- catch (error) { failures.push(`SESSION_REGISTRY.json inseguro ou ilegível: ${error?.message || error}.`); }
482
+ catch (error) { failures.push(`SESSION_REGISTRY.json inseguro ou ilegível: ${error?.message || error}. Inspecione com: ${memoryStatusCommand(vaultBase)}.`); }
455
483
  }
456
484
  const lifecycle = checkMemoryAttempts(effectiveRegistry || { version: 2, sessions: {} }, {
457
485
  vaultBase,
@@ -466,7 +494,11 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
466
494
  const activeConflicts = unresolved.filter((item) => item?.reason === 'conflict');
467
495
  const ordinaryCandidates = unresolved.filter((item) => item?.reason !== 'conflict');
468
496
  if (activeConflicts.length) {
469
- failures.push(`${activeConflicts.length} conflito ativo em chave operacional (${activeConflicts.map((item) => item.memory_key || item.candidate_id).join(', ')}). Inspecione com: ${memoryStatusCommand(vaultBase)}.`);
497
+ const label = activeConflicts.length === 1
498
+ ? '1 conflito ativo'
499
+ : `${activeConflicts.length} conflitos ativos`;
500
+ const purposes = groupConflictPurposes(activeConflicts).join('; ');
501
+ failures.push(`${label} (${purposes}). Existem versões concorrentes e nenhum dado foi escolhido automaticamente. Conflito semântico exige curadoria humana; memory repair não escolhe vencedor. Próximo passo: ${memoryCurateCommand(vaultBase)}. Inventário avançado: ${memoryCandidatesCommand(vaultBase)}.`);
470
502
  }
471
503
  if (outbox.count) warnings.push(`${outbox.count} evento(s) pendente(s) na outbox; execute o projector quando seguro.`);
472
504
  if (ordinaryCandidates.length) warnings.push(`${ordinaryCandidates.length} candidate(s) aguardando curadoria humana.`);
@@ -545,8 +577,49 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
545
577
  }
546
578
 
547
579
  export function runVaultHealth({ vaultBase, session = '' }) {
548
- const control = readControl(vaultBase);
549
- const registry = readSessionRegistry(vaultBase);
580
+ const memoryMarkers = [
581
+ join(vaultBase, '.brain', 'SHARED_MEMORY.md'),
582
+ join(vaultBase, '.brain', 'MEMORY_EVENTS.jsonl'),
583
+ join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl'),
584
+ join(vaultBase, '.brain', 'memory-outbox'),
585
+ ];
586
+ const memory = !existsSync(vaultBase) || memoryMarkers.some((path) => existsSync(path))
587
+ ? checkMemoryBundle(vaultBase)
588
+ : {
589
+ ok: true,
590
+ status: 'legacy',
591
+ failures: [],
592
+ warnings: [`Bundle de memória v2 ausente (vault legado); inspecione com: ${memoryStatusCommand(vaultBase)}.`],
593
+ metrics: {},
594
+ };
595
+
596
+ let control = {};
597
+ let registry = { version: 2, sessions: {} };
598
+ const contextErrors = [];
599
+ try { control = readControl(vaultBase); }
600
+ catch (error) { contextErrors.push(error?.message || String(error)); }
601
+ try { registry = readSessionRegistry(vaultBase); }
602
+ catch (error) { contextErrors.push(error?.message || String(error)); }
603
+
604
+ if (contextErrors.length) {
605
+ const failures = [
606
+ `Validação de sessão e artefatos indisponível com segurança: ${contextErrors.join('; ')}. Inspecione com: ${memoryStatusCommand(vaultBase)}.`,
607
+ ...memory.failures.map((item) => `Memória: ${item}`),
608
+ ];
609
+ return {
610
+ ok: false,
611
+ session,
612
+ failures,
613
+ warnings: memory.warnings.map((item) => `Memória: ${item}`),
614
+ metrics: {
615
+ registrySessions: 0,
616
+ derivedNotes: 0,
617
+ memory: memory.metrics,
618
+ },
619
+ memoryStatus: memory.status,
620
+ };
621
+ }
622
+
550
623
  const sessionRel = session || control.session_file || control.last_session_file || '';
551
624
  const failures = [];
552
625
  const warnings = [];
@@ -579,20 +652,8 @@ export function runVaultHealth({ vaultBase, session = '' }) {
579
652
  return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
580
653
  }, 0);
581
654
 
582
- const memoryMarkers = [
583
- join(vaultBase, '.brain', 'SHARED_MEMORY.md'),
584
- join(vaultBase, '.brain', 'MEMORY_EVENTS.jsonl'),
585
- join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl'),
586
- join(vaultBase, '.brain', 'memory-outbox'),
587
- ];
588
- let memory = { status: 'legacy', metrics: {} };
589
- if (memoryMarkers.some((path) => existsSync(path))) {
590
- memory = checkMemoryBundle(vaultBase, { registry });
591
- failures.push(...memory.failures.map((item) => `Memória: ${item}`));
592
- warnings.push(...memory.warnings.map((item) => `Memória: ${item}`));
593
- } else {
594
- warnings.push(`Bundle de memória v2 ausente (vault legado); inspecione com: ${memoryStatusCommand(vaultBase)}.`);
595
- }
655
+ failures.push(...memory.failures.map((item) => `Memória: ${item}`));
656
+ warnings.push(...memory.warnings.map((item) => `Memória: ${item}`));
596
657
 
597
658
  return {
598
659
  ok: failures.length === 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.67.2",
3
+ "version": "0.68.0",
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": [
@@ -99,7 +99,9 @@ Usage:
99
99
  in session notes from the linked derived notes — the body used to lag
100
100
  behind the closing block. Dry-run by default · --apply · --json.
101
101
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
102
- wendkeep memory <sub> Shared memory v2: status | migrate [--apply] | repair |
102
+ wendkeep memory curate Guide one semantic conflict at a time in an interactive terminal.
103
+ Every promote/reject requires confirmation; --vault P.
104
+ wendkeep memory <sub> Shared memory v2: status | candidates [--active] | curate | migrate [--apply] | repair |
103
105
  recover-attempt <session> [--apply] |
104
106
  reconcile <session> --by-session <session> --reason <text> [--apply] |
105
107
  promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
@@ -230,8 +232,13 @@ async function main(argv) {
230
232
  break;
231
233
  }
232
234
  case 'memory': {
233
- const { runMemory } = await import('../../../src/memory.mjs');
234
- runMemory(rest);
235
+ if (rest[0] === 'curate') {
236
+ const { runMemoryCurateCli } = await import('../../../src/memory-curate.mjs');
237
+ process.exitCode = await runMemoryCurateCli(rest.slice(1));
238
+ } else {
239
+ const { runMemory } = await import('../../../src/memory.mjs');
240
+ runMemory(rest);
241
+ }
235
242
  break;
236
243
  }
237
244
  case 'sync-defs': {
package/src/doctor.mjs CHANGED
@@ -1,27 +1,64 @@
1
1
  // `wendkeep doctor` — vault/session integrity (hooks/vault-health.mjs) PLUS the a2
2
2
  // harness integrity check (hooks/harness-doctor.mjs). Exits 1 on any error.
3
- import { spawnSync } from 'node:child_process';
4
- import { existsSync } from 'node:fs';
5
- import { dirname, join, resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
3
+ import { resolve } from 'node:path';
7
4
  import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines, checkStaleDerivedSections, renderStaleDerivedSectionLines, checkSessionObservability, renderSessionObservabilityLines } from '../hooks/harness-doctor.mjs';
5
+ import { runVaultHealth } from '../hooks/vault-health.mjs';
8
6
  import { checkSyncDefs } from './sync-defs.mjs';
9
7
  import { resolveProjectVault } from './project-vault.mjs';
10
8
 
11
- export function runDoctor(argv) {
12
- const here = dirname(fileURLToPath(import.meta.url));
13
- const hookFile = join(here, '..', 'hooks', 'vault-health.mjs');
9
+ const healthStatusLabel = (status) => ({
10
+ healthy: 'saudável', warning: 'atenção', blocked: 'bloqueada', legacy: 'legado',
11
+ }[status] || status || 'desconhecido');
12
+
13
+ const metricValue = (value) => value === null || value === undefined || value === '' ? 'n/a' : value;
14
+
15
+ export function renderVaultHealthLines(result) {
16
+ const memoryFailures = [];
17
+ const memoryWarnings = [];
18
+ const integrityFailures = [];
19
+ const integrityWarnings = [];
20
+ for (const failure of result.failures || []) {
21
+ const match = String(failure).match(/^Memória:\s*(.*)$/s);
22
+ (match ? memoryFailures : integrityFailures).push(match ? match[1] : failure);
23
+ }
24
+ for (const warning of result.warnings || []) {
25
+ const match = String(warning).match(/^Memória:\s*(.*)$/s);
26
+ (match ? memoryWarnings : integrityWarnings).push(match ? match[1] : warning);
27
+ }
28
+
29
+ const lines = [
30
+ `[integridade] ${integrityFailures.length ? 'bloqueada' : integrityWarnings.length ? 'atenção' : 'saudável'} — ${integrityFailures.length} falha(s), ${integrityWarnings.length} aviso(s)`,
31
+ ];
32
+ for (const failure of integrityFailures) lines.push(` ✗ ${failure}`);
33
+ for (const warning of integrityWarnings) lines.push(` ! ${warning}`);
34
+ if (!integrityFailures.length && !integrityWarnings.length) lines.push(' ✓ sessão e artefatos íntegros');
35
+ lines.push(` sessão: ${result.session || 'nenhuma'} · registros: ${metricValue(result.metrics?.registrySessions)} · notas derivadas: ${metricValue(result.metrics?.derivedNotes)}`);
14
36
 
37
+ const memory = result.metrics?.memory || {};
38
+ lines.push(
39
+ `[memória] ${healthStatusLabel(result.memoryStatus)} — schema: ${metricValue(memory.schemaVersion)} · revisão: ${metricValue(memory.revision)} · cursor: ${metricValue(memory.eventCursor)} · hash: ${metricValue(memory.stateHash)}`,
40
+ );
41
+ lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}`);
42
+ for (const failure of memoryFailures) lines.push(` ✗ ${failure}`);
43
+ for (const warning of memoryWarnings) lines.push(` ! ${warning}`);
44
+ if (result.memoryStatus === 'healthy' && !memoryFailures.length && !memoryWarnings.length) {
45
+ lines.push(' ✓ bundle de memória íntegro');
46
+ }
47
+ return lines;
48
+ }
49
+
50
+ export function runDoctor(argv) {
15
51
  let vault;
16
52
  let project;
17
- const passthrough = [];
53
+ let session = '';
18
54
  for (let i = 0; i < argv.length; i += 1) {
19
55
  const a = argv[i];
20
56
  if (a === '--vault') vault = argv[++i];
21
57
  else if (a.startsWith('--vault=')) vault = a.slice(8);
22
58
  else if (a === '--project') project = argv[++i];
23
59
  else if (a.startsWith('--project=')) project = a.slice(10);
24
- else passthrough.push(a);
60
+ else if (a === '--session') session = argv[++i] || '';
61
+ else if (a.startsWith('--session=')) session = a.slice(10);
25
62
  }
26
63
 
27
64
  const projectRoot = resolve(project || process.cwd());
@@ -42,12 +79,22 @@ export function runDoctor(argv) {
42
79
  process.stdout.write(' ! migração pendente: rode `wendkeep init --project . --vault "<vault>" --yes` para criar .wendkeep.json\n');
43
80
  }
44
81
 
45
- // 1. Session/vault integrity (existing check).
46
- let healthStatus = 0;
47
- if (existsSync(hookFile)) {
48
- const r = spawnSync(process.execPath, [hookFile, ...passthrough, '--vault', vaultBase], { stdio: 'inherit' });
49
- healthStatus = r.status ?? 0;
82
+ // 1. Session/vault integrity. The standalone hook remains JSON; doctor renders it for humans.
83
+ let health;
84
+ try {
85
+ health = runVaultHealth({ vaultBase, session });
86
+ } catch (error) {
87
+ health = {
88
+ ok: false,
89
+ session,
90
+ failures: [`Vault health falhou: ${error?.message || error}`],
91
+ warnings: [],
92
+ metrics: { memory: {} },
93
+ memoryStatus: 'blocked',
94
+ };
50
95
  }
96
+ process.stdout.write(`${renderVaultHealthLines(health).join('\n')}\n`);
97
+ const healthStatus = health.ok ? 0 : 1;
51
98
 
52
99
  // 2. Harness integrity (Wave B).
53
100
  const { errors, warnings } = checkHarness(vaultBase, projectRoot);
@@ -0,0 +1,328 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline/promises';
3
+
4
+ import { getLocale } from '../hooks/locale.mjs';
5
+ import {
6
+ decideMemoryCandidate,
7
+ listMemoryCandidatesForCuration,
8
+ } from './memory.mjs';
9
+
10
+ const TEXT = {
11
+ 'pt-BR': {
12
+ title: 'Curadoria guiada de memória',
13
+ intro: (count) => `${count} conflito(s) aguardam uma decisão humana. Nada será escolhido automaticamente.`,
14
+ categories: 'Categorias pendentes:',
15
+ progress: (index, total, label) => `Conflito ${index} de ${total} — ${label}`,
16
+ source: 'origem',
17
+ sourceKinds: {
18
+ session: 'sessão capturada', turn: 'turno capturado',
19
+ activation: 'ativação capturada', unknown: 'não identificada',
20
+ },
21
+ observed: 'registrado em',
22
+ newer: 'mais recente',
23
+ actions: '[1-N] Manter uma versão · [P] Pular · [R] Encerrar sem vencedor · [D] Detalhes · [Q] Sair',
24
+ choose: '> ',
25
+ confirmPromote: (number) => `Manter a versão ${number}? Essa decisão será gravada agora. [s/N] `,
26
+ 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.',
28
+ declined: 'Decisão não confirmada; nenhum byte foi alterado.',
29
+ promoted: 'Versão promovida e decisão auditada.',
30
+ rejected: 'Conflito encerrado sem promover uma versão.',
31
+ details: 'Detalhes técnicos',
32
+ paused: (decisions, skipped) => `Sessão encerrada: ${decisions} decisão(ões) gravada(s), ${skipped} conflito(s) pulado(s).`,
33
+ complete: (decisions) => `Curadoria concluída: ${decisions} decisão(ões) gravada(s); nenhum conflito semântico ativo restante.`,
34
+ blocked: 'O estado da memória mudou ou está ocupado. Nada mais foi aplicado; execute memory curate novamente.',
35
+ noConflicts: 'Nenhum conflito semântico ativo precisa de curadoria.',
36
+ },
37
+ en: {
38
+ title: 'Guided memory curation',
39
+ intro: (count) => `${count} conflict(s) require a human decision. Nothing will be selected automatically.`,
40
+ categories: 'Pending categories:',
41
+ progress: (index, total, label) => `Conflict ${index} of ${total} — ${label}`,
42
+ source: 'source',
43
+ sourceKinds: {
44
+ session: 'captured session', turn: 'captured turn',
45
+ activation: 'captured activation', unknown: 'unidentified',
46
+ },
47
+ observed: 'recorded at',
48
+ newer: 'newest timestamp',
49
+ actions: '[1-N] Keep one version · [P] Skip · [R] Close without a winner · [D] Details · [Q] Quit',
50
+ choose: '> ',
51
+ confirmPromote: (number) => `Keep version ${number}? This decision will be written now. [y/N] `,
52
+ 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.',
54
+ declined: 'Decision not confirmed; no bytes were changed.',
55
+ promoted: 'Version promoted and decision audited.',
56
+ rejected: 'Conflict closed without promoting a version.',
57
+ details: 'Technical details',
58
+ paused: (decisions, skipped) => `Session ended: ${decisions} decision(s) written, ${skipped} conflict(s) skipped.`,
59
+ complete: (decisions) => `Curation complete: ${decisions} decision(s) written; no active semantic conflicts remain.`,
60
+ blocked: 'Memory state changed or is busy. Nothing else was applied; run memory curate again.',
61
+ noConflicts: 'No active semantic conflict requires curation.',
62
+ },
63
+ };
64
+
65
+ const KEY_TEXT = {
66
+ 'handoff.latest': {
67
+ 'pt-BR': ['Próximo handoff', 'Resumo que será apresentado à próxima sessão.'],
68
+ en: ['Next handoff', 'Summary that will be presented to the next session.'],
69
+ },
70
+ 'quality.latest-sensors': {
71
+ 'pt-BR': ['Sensores de qualidade', 'Conjunto de testes e sensores considerado mais recente.'],
72
+ en: ['Quality sensors', 'Test and sensor set considered the latest.'],
73
+ },
74
+ 'quality.latest-verdict': {
75
+ 'pt-BR': ['Veredito de qualidade', 'Resultado de verificação apresentado como vigente.'],
76
+ en: ['Quality verdict', 'Verification result presented as current.'],
77
+ },
78
+ 'git.local-head': {
79
+ 'pt-BR': ['Commit local conhecido', 'Commit que o WendKeep considera o último estado local.'],
80
+ en: ['Known local commit', 'Commit WendKeep considers the latest local state.'],
81
+ },
82
+ };
83
+
84
+ function localeId(value) {
85
+ return value === 'en' ? 'en' : 'pt-BR';
86
+ }
87
+
88
+ function keyText(memoryKey, locale) {
89
+ return KEY_TEXT[memoryKey]?.[locale]
90
+ || (locale === 'en'
91
+ ? [memoryKey, 'Operational memory value with competing versions.']
92
+ : [memoryKey, 'Valor operacional com versões concorrentes.']);
93
+ }
94
+
95
+ function isAffirmative(answer) {
96
+ return ['s', 'sim', 'y', 'yes'].includes(String(answer || '').trim().toLowerCase());
97
+ }
98
+
99
+ function groupLines(candidates, locale) {
100
+ const counts = new Map();
101
+ for (const candidate of candidates) {
102
+ counts.set(candidate.memory_key, (counts.get(candidate.memory_key) || 0) + 1);
103
+ }
104
+ return [...counts.entries()]
105
+ .sort(([left], [right]) => left.localeCompare(right))
106
+ .map(([key, count]) => ` • ${keyText(key, locale)[0]}: ${count}`);
107
+ }
108
+
109
+ function newestEventId(events) {
110
+ return [...events]
111
+ .filter((event) => !Number.isNaN(Date.parse(event.observed_at || '')))
112
+ .sort((left, right) => Date.parse(right.observed_at) - Date.parse(left.observed_at))[0]?.event_id;
113
+ }
114
+
115
+ export function renderMemoryConflict(candidate, {
116
+ locale = 'pt-BR', index = 1, total = 1, details = false,
117
+ } = {}) {
118
+ const id = localeId(locale);
119
+ const text = TEXT[id];
120
+ const [label, description] = keyText(candidate.memory_key, id);
121
+ const newest = newestEventId(candidate.events);
122
+ const lines = ['', text.progress(index, total, label), description, ''];
123
+ candidate.events.forEach((event, eventIndex) => {
124
+ const recent = event.event_id === newest ? ` — ${text.newer}` : '';
125
+ const source = text.sourceKinds[event.source] || text.sourceKinds.unknown;
126
+ lines.push(`[${eventIndex + 1}] ${text.source}: ${source}${recent}`);
127
+ if (event.observed_at) lines.push(` ${text.observed}: ${event.observed_at}`);
128
+ lines.push(` ${event.preview}`);
129
+ });
130
+ if (details) {
131
+ lines.push('', `${text.details}:`, ` candidate: ${candidate.candidate_id}`);
132
+ candidate.events.forEach((event) => lines.push(` event: ${event.event_id}`));
133
+ }
134
+ lines.push('', text.actions);
135
+ return `${lines.join('\n')}\n`;
136
+ }
137
+
138
+ export async function runGuidedMemoryCuration(vault, {
139
+ ask,
140
+ write,
141
+ loadCandidates = listMemoryCandidatesForCuration,
142
+ decide = decideMemoryCandidate,
143
+ locale = 'pt-BR',
144
+ } = {}) {
145
+ if (typeof ask !== 'function' || typeof write !== 'function') {
146
+ throw new TypeError('runGuidedMemoryCuration requer ask e write.');
147
+ }
148
+ const id = localeId(locale);
149
+ const text = TEXT[id];
150
+ let decisions = 0;
151
+ let skipped = 0;
152
+ const skippedIds = new Set();
153
+ const decidedIds = new Set();
154
+ let first = true;
155
+ let sessionTotal = 0;
156
+
157
+ while (true) {
158
+ let loaded;
159
+ try {
160
+ loaded = loadCandidates(vault);
161
+ if (!Array.isArray(loaded)) throw new TypeError('candidate inventory must be an array');
162
+ } catch {
163
+ write(`${text.blocked}\n`);
164
+ return { status: 'blocked', decisions, skipped };
165
+ }
166
+ const candidates = loaded.filter((candidate) => !skippedIds.has(candidate.candidate_id));
167
+ if (first) {
168
+ sessionTotal = loaded.length;
169
+ write(`${text.title}\n${text.intro(loaded.length)}\n`);
170
+ if (loaded.length) write(`${text.categories}\n${groupLines(loaded, id).join('\n')}\n`);
171
+ first = false;
172
+ }
173
+ if (!loaded.length) {
174
+ write(`${decisions ? text.complete(decisions) : text.noConflicts}\n`);
175
+ return { status: 'complete', decisions, skipped };
176
+ }
177
+ if (!candidates.length) {
178
+ write(`${text.paused(decisions, skipped)}\n`);
179
+ return { status: 'paused', decisions, skipped };
180
+ }
181
+
182
+ const candidate = candidates[0];
183
+ if (decidedIds.has(candidate.candidate_id)) {
184
+ write(`${text.blocked}\n`);
185
+ return { status: 'blocked', decisions, skipped };
186
+ }
187
+ const completed = decisions + skipped;
188
+ const progressTotal = Math.max(sessionTotal, completed + candidates.length);
189
+ const progressIndex = completed + 1;
190
+ write(renderMemoryConflict(candidate, {
191
+ locale: id, index: progressIndex, total: progressTotal,
192
+ }));
193
+ const answer = String(await ask(text.choose)).trim().toLowerCase();
194
+
195
+ if (answer === 'q') {
196
+ write(`${text.paused(decisions, skipped)}\n`);
197
+ return { status: 'quit', decisions, skipped };
198
+ }
199
+ if (answer === 'p') {
200
+ skippedIds.add(candidate.candidate_id);
201
+ skipped += 1;
202
+ continue;
203
+ }
204
+ if (answer === 'd') {
205
+ write(renderMemoryConflict(candidate, {
206
+ locale: id, index: progressIndex, total: progressTotal, details: true,
207
+ }));
208
+ continue;
209
+ }
210
+
211
+ let decision;
212
+ let confirmation;
213
+ if (answer === 'r') {
214
+ decision = { action: 'reject', candidateId: candidate.candidate_id };
215
+ confirmation = await ask(text.confirmReject);
216
+ } else if (/^\d+$/.test(answer)) {
217
+ const eventIndex = Number(answer) - 1;
218
+ const selected = candidate.events[eventIndex];
219
+ if (!selected) {
220
+ write(`${text.invalid}\n`);
221
+ continue;
222
+ }
223
+ decision = {
224
+ action: 'promote', candidateId: candidate.candidate_id, eventId: selected.event_id,
225
+ };
226
+ confirmation = await ask(text.confirmPromote(eventIndex + 1));
227
+ } else {
228
+ write(`${text.invalid}\n`);
229
+ continue;
230
+ }
231
+
232
+ if (!isAffirmative(confirmation)) {
233
+ write(`${text.declined}\n`);
234
+ continue;
235
+ }
236
+
237
+ let result;
238
+ try {
239
+ result = decide(vault, decision);
240
+ } catch {
241
+ write(`${text.blocked}\n`);
242
+ return { status: 'blocked', decisions, skipped };
243
+ }
244
+ if (!result || !['promoted', 'rejected'].includes(result.status)) {
245
+ write(`${text.blocked}\n`);
246
+ return { status: 'blocked', decisions, skipped };
247
+ }
248
+ decisions += 1;
249
+ decidedIds.add(candidate.candidate_id);
250
+ write(`${result.status === 'promoted' ? text.promoted : text.rejected}\n`);
251
+ }
252
+ }
253
+
254
+ function usageError(message) {
255
+ const error = new Error(message);
256
+ error.code = 'WENDKEEP_MEMORY_CURATE_USAGE';
257
+ return error;
258
+ }
259
+
260
+ export function parseMemoryCurateArgs(argv) {
261
+ let vault = '';
262
+ let seenVault = false;
263
+ for (let index = 0; index < argv.length; index += 1) {
264
+ const token = argv[index];
265
+ if (!token.startsWith('--')) throw usageError(`argumento posicional inesperado: ${token}.`);
266
+ const equalAt = token.indexOf('=');
267
+ const name = equalAt >= 0 ? token.slice(0, equalAt) : token;
268
+ if (name !== '--vault') throw usageError(`opção desconhecida: ${name}.`);
269
+ if (seenVault) throw usageError('--vault duplicado.');
270
+ const value = equalAt >= 0 ? token.slice(equalAt + 1) : argv[index + 1];
271
+ if (!value || !value.trim() || value.startsWith('--')) {
272
+ throw usageError('--vault requer valor não vazio que não comece com --.');
273
+ }
274
+ vault = value;
275
+ seenVault = true;
276
+ if (equalAt < 0) index += 1;
277
+ }
278
+ return { vault };
279
+ }
280
+
281
+ export async function runMemoryCurateCli(argv, {
282
+ input = process.stdin,
283
+ output = process.stdout,
284
+ error = process.stderr,
285
+ env = process.env,
286
+ } = {}) {
287
+ let args;
288
+ try {
289
+ args = parseMemoryCurateArgs(argv);
290
+ } catch {
291
+ error.write(
292
+ 'wendkeep memory curate: não foi possível abrir a curadoria com segurança. '
293
+ + 'Inspecione o estado com memory candidates --active e tente novamente.\n',
294
+ );
295
+ return 2;
296
+ }
297
+ const vault = args.vault || env.OBSIDIAN_VAULT_PATH;
298
+ if (!vault) {
299
+ error.write('wendkeep memory curate: passe --vault <path>.\n');
300
+ return 2;
301
+ }
302
+ if (!existsSync(vault)) {
303
+ error.write(`wendkeep memory curate: not found: ${vault}\n`);
304
+ return 2;
305
+ }
306
+ if (!input?.isTTY || !output?.isTTY) {
307
+ error.write(
308
+ `wendkeep memory curate requer terminal interativo (TTY). Inspecione sem alterar com: `
309
+ + `npx --no-install wendkeep memory candidates --active --vault "${vault}"\n`,
310
+ );
311
+ return 2;
312
+ }
313
+
314
+ const rl = createInterface({ input, output });
315
+ try {
316
+ const result = await runGuidedMemoryCuration(vault, {
317
+ locale: getLocale(vault).id,
318
+ ask: (question) => rl.question(question),
319
+ write: (value) => output.write(String(value)),
320
+ });
321
+ return result.status === 'blocked' ? 1 : 0;
322
+ } catch (cause) {
323
+ error.write(`wendkeep memory curate: ${cause.message}\n`);
324
+ return 1;
325
+ } finally {
326
+ rl.close();
327
+ }
328
+ }
package/src/memory.mjs CHANGED
@@ -237,6 +237,104 @@ function readCandidates(vault) {
237
237
  .split('\n').filter(Boolean).map((line) => JSON.parse(line));
238
238
  }
239
239
 
240
+ const TERMINAL_CANDIDATE_STATUSES = new Set(['resolved', 'rejected', 'superseded']);
241
+
242
+ function lexicalCompare(left, right) {
243
+ if (left < right) return -1;
244
+ if (left > right) return 1;
245
+ return 0;
246
+ }
247
+
248
+ function sanitizedCandidate(candidate, index) {
249
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
250
+ throw new Error(`MEMORY_CANDIDATES.jsonl: candidate ${index + 1} inválido.`);
251
+ }
252
+ const required = ['candidate_id', 'reason', 'memory_key'];
253
+ const missing = required.filter((field) => typeof candidate[field] !== 'string' || !candidate[field]);
254
+ if (missing.length) {
255
+ throw new Error(`MEMORY_CANDIDATES.jsonl: candidate ${index + 1} sem ${missing.join(', ')}.`);
256
+ }
257
+ if (candidate.status !== undefined && (typeof candidate.status !== 'string' || !candidate.status)) {
258
+ throw new Error(`MEMORY_CANDIDATES.jsonl: candidate ${index + 1} possui status inválido.`);
259
+ }
260
+ const eventIds = candidate.event_ids ?? [];
261
+ if (!Array.isArray(eventIds) || eventIds.some((eventId) => typeof eventId !== 'string' || !eventId)) {
262
+ throw new Error(`MEMORY_CANDIDATES.jsonl: candidate ${index + 1} possui event_ids inválidos.`);
263
+ }
264
+ return {
265
+ candidate_id: candidate.candidate_id,
266
+ reason: candidate.reason,
267
+ status: candidate.status || 'active',
268
+ memory_key: candidate.memory_key,
269
+ event_ids: [...eventIds].sort(lexicalCompare),
270
+ };
271
+ }
272
+
273
+ export function listMemoryCandidates(vault, { activeOnly = false } = {}) {
274
+ const candidates = readCandidates(vault)
275
+ .map(sanitizedCandidate)
276
+ .filter((candidate) => !activeOnly || !TERMINAL_CANDIDATE_STATUSES.has(candidate.status))
277
+ .sort((left, right) => lexicalCompare(left.memory_key, right.memory_key)
278
+ || lexicalCompare(left.candidate_id, right.candidate_id));
279
+ return { status: 'ok', candidates };
280
+ }
281
+
282
+ const CURATION_PREVIEW_CHARS = 160;
283
+
284
+ function curationPreview(value) {
285
+ const normalized = sanitizeMemoryText(value).replace(/\s+/g, ' ').trim();
286
+ const visible = normalized || '(sem conteúdo)';
287
+ return visible.length <= CURATION_PREVIEW_CHARS
288
+ ? visible
289
+ : `${visible.slice(0, CURATION_PREVIEW_CHARS - 1).trimEnd()}…`;
290
+ }
291
+
292
+ function sanitizedCurationEvent(candidate, eventId, candidateIndex) {
293
+ const events = Array.isArray(candidate.events) ? candidate.events : [];
294
+ const event = events.find((item) => item?.event_id === eventId);
295
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
296
+ throw new Error(
297
+ `MEMORY_CANDIDATES.jsonl: candidate ${candidateIndex + 1} sem evento elegível.`,
298
+ );
299
+ }
300
+ const source = event.canonical_session_id
301
+ ? 'session'
302
+ : event.source_turn_id
303
+ ? 'turn'
304
+ : event.activation_id
305
+ ? 'activation'
306
+ : 'unknown';
307
+ return {
308
+ event_id: eventId,
309
+ observed_at: sanitizeMemoryText(event.observed_at || event.effective_at || ''),
310
+ source: sanitizeMemoryText(source),
311
+ preview: curationPreview(event.value),
312
+ };
313
+ }
314
+
315
+ export function listMemoryCandidatesForCuration(vault) {
316
+ return readCandidates(vault)
317
+ .map((candidate, index) => ({ candidate, index, safe: sanitizedCandidate(candidate, index) }))
318
+ .filter(({ safe }) => safe.reason === 'conflict'
319
+ && !TERMINAL_CANDIDATE_STATUSES.has(safe.status))
320
+ .map(({ candidate, index, safe }) => {
321
+ if (!safe.event_ids.length) {
322
+ throw new Error(
323
+ `MEMORY_CANDIDATES.jsonl: candidate ${index + 1} sem eventos elegíveis.`,
324
+ );
325
+ }
326
+ return {
327
+ candidate_id: safe.candidate_id,
328
+ reason: safe.reason,
329
+ status: safe.status,
330
+ memory_key: safe.memory_key,
331
+ events: safe.event_ids.map((eventId) => sanitizedCurationEvent(candidate, eventId, index)),
332
+ };
333
+ })
334
+ .sort((left, right) => lexicalCompare(left.memory_key, right.memory_key)
335
+ || lexicalCompare(left.candidate_id, right.candidate_id));
336
+ }
337
+
240
338
  function priorCandidateDecision(vault, candidateId) {
241
339
  return readMemoryLedger(vault).events.find(
242
340
  (event) => event.candidate_decision?.candidate_id === candidateId,
@@ -1897,10 +1995,49 @@ function parseRecoverAttemptArgs(argv) {
1897
1995
  };
1898
1996
  }
1899
1997
 
1998
+ function parseCandidatesArgs(argv) {
1999
+ const seen = new Set();
2000
+ let activeOnly = false;
2001
+ let vault = '';
2002
+
2003
+ for (let index = 1; index < argv.length; index += 1) {
2004
+ const token = argv[index];
2005
+ if (!token.startsWith('--')) {
2006
+ throw memoryUsageError(`memory candidates recebeu argumento posicional extra: ${token}.`);
2007
+ }
2008
+
2009
+ const equalAt = token.indexOf('=');
2010
+ const name = equalAt >= 0 ? token.slice(0, equalAt) : token;
2011
+ if (name !== '--active' && name !== '--vault') {
2012
+ throw memoryUsageError(`memory candidates recebeu opção desconhecida: ${name}.`);
2013
+ }
2014
+ if (seen.has(name)) {
2015
+ throw memoryUsageError(`memory candidates recebeu opção duplicada: ${name}.`);
2016
+ }
2017
+ seen.add(name);
2018
+
2019
+ if (name === '--active') {
2020
+ if (equalAt >= 0) throw memoryUsageError('--active não aceita valor.');
2021
+ activeOnly = true;
2022
+ continue;
2023
+ }
2024
+
2025
+ const value = equalAt >= 0 ? token.slice(equalAt + 1) : argv[index + 1];
2026
+ if (!value || !value.trim() || value.startsWith('--')) {
2027
+ throw memoryUsageError('--vault requer valor não vazio que não comece com --.');
2028
+ }
2029
+ vault = value;
2030
+ if (equalAt < 0) index += 1;
2031
+ }
2032
+
2033
+ return { activeOnly, vault };
2034
+ }
2035
+
1900
2036
  export function runMemory(argv) {
1901
2037
  const [sub, positional] = argv;
1902
2038
  let reconcileArgs = null;
1903
2039
  let recoverAttemptArgs = null;
2040
+ let candidatesArgs = null;
1904
2041
  if (sub === 'reconcile') {
1905
2042
  try {
1906
2043
  reconcileArgs = parseReconcileArgs(argv);
@@ -1919,14 +2056,26 @@ export function runMemory(argv) {
1919
2056
  return;
1920
2057
  }
1921
2058
  }
2059
+ if (sub === 'candidates') {
2060
+ try {
2061
+ candidatesArgs = parseCandidatesArgs(argv);
2062
+ } catch (error) {
2063
+ process.stderr.write(`wendkeep memory: ${error.message}\n`);
2064
+ process.exitCode = error.code === 'WENDKEEP_MEMORY_USAGE' ? 2 : 1;
2065
+ return;
2066
+ }
2067
+ }
1922
2068
  const vault = (
1923
- recoverAttemptArgs?.vault || reconcileArgs?.vault || option(argv, '--vault')
2069
+ recoverAttemptArgs?.vault || reconcileArgs?.vault || candidatesArgs?.vault || option(argv, '--vault')
1924
2070
  ) || process.env.OBSIDIAN_VAULT_PATH;
1925
2071
  if (!vault) { process.stderr.write('wendkeep memory: passe --vault <path>.\n'); process.exitCode = 2; return; }
1926
2072
  if (!existsSync(vault)) { process.stderr.write(`wendkeep memory: not found: ${vault}\n`); process.exitCode = 2; return; }
1927
2073
  try {
1928
2074
  let result;
1929
2075
  if (sub === 'status') result = memoryStatus(vault);
2076
+ else if (sub === 'candidates') {
2077
+ result = listMemoryCandidates(vault, { activeOnly: candidatesArgs.activeOnly });
2078
+ }
1930
2079
  else if (sub === 'migrate') result = migrateMemory(vault, { apply: argv.includes('--apply') });
1931
2080
  else if (sub === 'repair') result = repairMemory(vault);
1932
2081
  else if (sub === 'reconcile') {
@@ -1950,7 +2099,7 @@ export function runMemory(argv) {
1950
2099
  action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
1951
2100
  });
1952
2101
  }
1953
- else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | recover-attempt <session> [--apply] | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
2102
+ else { process.stderr.write('wendkeep memory: use status | candidates [--active] | migrate [--apply] | repair | recover-attempt <session> [--apply] | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
1954
2103
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1955
2104
  if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
1956
2105
  else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;