wendkeep 0.67.3 → 0.68.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.en.md +9 -3
- package/README.md +9 -3
- package/docs/en/commands/maintenance-and-diagnostics.md +11 -1
- package/docs/en/commands/memory.md +15 -4
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +11 -2
- package/docs/pt-BR/commands/memory.md +15 -4
- package/hooks/vault-health.mjs +74 -30
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +10 -3
- package/packages/harness/src/flow-store.mjs +12 -3
- package/packages/vault/src/vault-path-safety.mjs +50 -25
- package/src/doctor.mjs +61 -14
- package/src/memory-curate.mjs +328 -0
- package/src/memory.mjs +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ 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.1] — 2026-08-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **A contenção de locks do Vault deixa de expor `VAULT_PATH_UNSAFE` no Windows.** A revalidação de
|
|
12
|
+
uma falha transiente de resolução do lock público passa a decidir por um walk fresco do
|
|
13
|
+
componente, e não pelo `errno` reportado pela plataforma. O Windows devolve `UNKNOWN`, `EBADF` ou
|
|
14
|
+
`EPERM` onde o Linux devolve `ENOENT`, então a guarda de retry nunca disparava lá e promoções FLOW
|
|
15
|
+
concorrentes falhavam de forma intermitente com o código de fronteira física em vez do conflito de
|
|
16
|
+
promoção. Sufixo ausente ou diretório canônico estabilizado autorizam o retry; junction, symlink,
|
|
17
|
+
reparse point, componente redirecionado ou estado irresolvível persistente continuam falhando
|
|
18
|
+
fechado, e o orçamento de retry permanece único e limitado por aquisição.
|
|
19
|
+
- **Falha de lock nos caminhos FLOW reporta o código do domínio.** `withFlowPromotionLock` e o store
|
|
20
|
+
de sessão passam a propagar `FLOW_VAULT_BOUNDARY` para a fronteira física, alinhando a superfície
|
|
21
|
+
de erro ao resto da saga de promoção.
|
|
22
|
+
|
|
23
|
+
## [0.68.0] — 2026-08-02
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Curadoria de memória agora tem um assistente interativo para pessoas.** O novo
|
|
28
|
+
`memory curate --vault <vault>` agrupa conflitos por nomes amigáveis, exibe somente previews
|
|
29
|
+
sanitizados e guia promoção, rejeição, pulo ou saída com confirmação negativa por padrão. O
|
|
30
|
+
trabalho restante pode ser retomado em outra execução; ambientes não-TTY recebem o fallback
|
|
31
|
+
seguro `memory candidates --active` e nenhuma escrita implícita.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **O `doctor` apresenta integridade e conflitos de memória em formato humano.** A saída principal
|
|
36
|
+
usa seções e categorias amigáveis e recomenda primeiro o assistente guiado, enquanto o hook de
|
|
37
|
+
health preserva seu contrato JSON para automações. O diagnóstico continua somente leitura e
|
|
38
|
+
`memory repair` continua estritamente estrutural, sem escolher vencedores semânticos.
|
|
39
|
+
|
|
7
40
|
## [0.67.3] — 2026-08-02
|
|
8
41
|
|
|
9
42
|
### Fixed
|
package/README.en.md
CHANGED
|
@@ -369,8 +369,11 @@ 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
|
|
373
|
-
|
|
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,
|
|
374
377
|
`memory promote <id> --event <event-id>` selects one event from the candidate, while
|
|
375
378
|
`memory reject <id>` keeps the current value. `memory repair` does not choose a winner. The decision is idempotent, and a
|
|
376
379
|
new promotion accepts a later Stop from the same session/activation without recreating a conflict.
|
|
@@ -381,7 +384,10 @@ superseded. Repair migrates the checkpoint and mirror only when it proves the ex
|
|
|
381
384
|
replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
|
|
382
385
|
appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
|
|
383
386
|
install 0.66.2; use 0.66.3 or later. Decisions survive repair/replay;
|
|
384
|
-
`blocked_by_core` cannot override CORE. Doctor only diagnoses
|
|
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
|
|
385
391
|
acknowledgement pending on 0.66.4 or later, first run the targeted dry run
|
|
386
392
|
`memory recover-attempt <session> --vault <vault>`, then authorize `--apply`; it changes only
|
|
387
393
|
registry/checkpoint. See syntax, preconditions, and fail-closed behavior in
|
package/README.md
CHANGED
|
@@ -369,8 +369,11 @@ 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
|
|
373
|
-
|
|
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,
|
|
374
377
|
`memory promote <id> --event <event-id>` selects one event from the candidate, while
|
|
375
378
|
`memory reject <id>` keeps the current value. `memory repair` does not choose a winner. The decision is idempotent, and a
|
|
376
379
|
new promotion accepts a later Stop from the same session/activation without recreating a conflict.
|
|
@@ -381,7 +384,10 @@ superseded. Repair migrates the checkpoint and mirror only when it proves the ex
|
|
|
381
384
|
replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
|
|
382
385
|
appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
|
|
383
386
|
install 0.66.2; use 0.66.3 or later. Decisions survive repair/replay;
|
|
384
|
-
`blocked_by_core` cannot override CORE. Doctor only diagnoses
|
|
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
|
|
385
391
|
acknowledgement pending on 0.66.4 or later, first run the targeted dry run
|
|
386
392
|
`memory recover-attempt <session> --vault <vault>`, then authorize `--apply`; it changes only
|
|
387
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,7 @@ 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
|
|
60
68
|
npx --no-install wendkeep memory candidates --active --vault .MyApp-vault
|
|
61
69
|
npx --no-install wendkeep cost rebuild --session <id> --json --vault .MyApp-vault
|
|
62
70
|
npx --no-install wendkeep cost rebuild --session <id> --json --vault .MyApp-vault --apply
|
|
@@ -83,7 +91,9 @@ dry-run path before any write.
|
|
|
83
91
|
outbox, and SHARED so `last_memory_attempt` can be correlated before repair.
|
|
84
92
|
- Corrupt bundle: preserve evidence and run `memory status --gate` before `memory repair`.
|
|
85
93
|
- An active semantic conflict requires a human decision: `memory repair` does not choose a winner.
|
|
86
|
-
|
|
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
|
|
87
97
|
`memory promote <candidate-id> --event <event-id> --vault <vault>` to select an event or
|
|
88
98
|
`memory reject <candidate-id> --vault <vault>` to keep the current operational value.
|
|
89
99
|
- `legacy`/`degraded`/`stale`/`manifest-unproven` observability: run
|
|
@@ -24,12 +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>
|
|
27
28
|
npx wendkeep memory candidates [--active] --vault <vault>
|
|
28
29
|
npx wendkeep memory repair --vault <vault>
|
|
29
30
|
npx wendkeep memory recover-attempt <session> [--apply] --vault <vault>
|
|
30
31
|
npx wendkeep memory reconcile <ambiguous-session> --by-session <successor-session> --reason <reason> [--apply] --vault <vault>
|
|
31
|
-
npx wendkeep memory promote <candidate> [--event <event-id>] --vault <vault>
|
|
32
|
-
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>
|
|
33
34
|
npx wendkeep validate-memory [CORE-path]
|
|
34
35
|
npx wendkeep validate-memory --vault <v2-vault>
|
|
35
36
|
```
|
|
@@ -37,6 +38,14 @@ npx wendkeep validate-memory --vault <v2-vault>
|
|
|
37
38
|
## Options and exit codes
|
|
38
39
|
|
|
39
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`.
|
|
40
49
|
- `memory candidates` is read-only and prints deterministic JSON containing only `candidate_id`,
|
|
41
50
|
`reason`, `status`, `memory_key`, and `event_ids`; it does not expose memory values or content and
|
|
42
51
|
does not create a lock or mutate the bundle. `--active` omits terminal candidates (`resolved`,
|
|
@@ -89,8 +98,9 @@ npx wendkeep validate-memory --vault <v2-vault>
|
|
|
89
98
|
the lease they acquired.
|
|
90
99
|
- `promote`/`reject` append an auditable, idempotent decision to the ledger. Replay and repair
|
|
91
100
|
preserve that decision and do not recreate the resolved candidate. For a `conflict` candidate,
|
|
92
|
-
`promote
|
|
93
|
-
never picks an implicit winner. `reject` preserves the current
|
|
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
|
|
94
104
|
`blocked_by_core` candidate can only be rejected: promotion first requires canonical CORE
|
|
95
105
|
curation. If the selected event still belongs to the matching latest `projected` attempt,
|
|
96
106
|
promotion also refreshes its checkpoint and mirror causally; JSON reports
|
|
@@ -113,6 +123,7 @@ npx wendkeep validate-memory --vault <v2-vault>
|
|
|
113
123
|
|
|
114
124
|
```bash
|
|
115
125
|
npx wendkeep memory status --gate --vault .MyApp-vault
|
|
126
|
+
npx wendkeep memory curate --vault .MyApp-vault
|
|
116
127
|
npx wendkeep memory candidates --active --vault .MyApp-vault
|
|
117
128
|
npx wendkeep memory recover-attempt session-123 --vault .MyApp-vault
|
|
118
129
|
npx wendkeep memory recover-attempt session-123 --apply --vault .MyApp-vault
|
|
@@ -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,7 @@ 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
|
|
60
68
|
npx --no-install wendkeep memory candidates --active --vault .MeuApp-vault
|
|
61
69
|
npx --no-install wendkeep cost rebuild --session <id> --json --vault .MeuApp-vault
|
|
62
70
|
npx --no-install wendkeep cost rebuild --session <id> --json --vault .MeuApp-vault --apply
|
|
@@ -82,8 +90,9 @@ ou sem manifest comprovado e oferece um caminho dry-run antes de qualquer escrit
|
|
|
82
90
|
- `ambiguous`, publicação perdida ou checkpoint divergente: bloqueante; preserve registry, ledger,
|
|
83
91
|
outbox e SHARED para correlacionar `last_memory_attempt` antes de reparar.
|
|
84
92
|
- Bundle corrompido: preserve a evidência e use `memory status --gate` antes de `memory repair`.
|
|
85
|
-
- Conflito semântico ativo exige decisão humana: `memory repair` não escolhe vencedor.
|
|
86
|
-
|
|
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
|
|
87
96
|
`memory promote <candidate-id> --event <event-id> --vault <cofre>` para selecionar um evento ou
|
|
88
97
|
`memory reject <candidate-id> --vault <cofre>` para manter o valor operacional atual.
|
|
89
98
|
- Observabilidade `legacy`/`degraded`/`stale`/`manifest-unproven`: rode
|
|
@@ -24,12 +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>
|
|
27
28
|
npx wendkeep memory candidates [--active] --vault <cofre>
|
|
28
29
|
npx wendkeep memory repair --vault <cofre>
|
|
29
30
|
npx wendkeep memory recover-attempt <sessão> [--apply] --vault <cofre>
|
|
30
31
|
npx wendkeep memory reconcile <sessão-ambígua> --by-session <sessão-sucessora> --reason <motivo> [--apply] --vault <cofre>
|
|
31
|
-
npx wendkeep memory promote <candidate> [--event <event-id>] --vault <cofre>
|
|
32
|
-
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>
|
|
33
34
|
npx wendkeep validate-memory [caminho-do-CORE]
|
|
34
35
|
npx wendkeep validate-memory --vault <cofre-v2>
|
|
35
36
|
```
|
|
@@ -37,6 +38,14 @@ npx wendkeep validate-memory --vault <cofre-v2>
|
|
|
37
38
|
## Opções e códigos de saída
|
|
38
39
|
|
|
39
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`.
|
|
40
49
|
- `memory candidates` é read-only e imprime JSON determinístico com somente `candidate_id`,
|
|
41
50
|
`reason`, `status`, `memory_key` e `event_ids`; não expõe valores nem conteúdo da memória e não
|
|
42
51
|
cria lock nem altera o bundle. `--active` omite candidates terminais (`resolved`, `rejected` e
|
|
@@ -87,8 +96,9 @@ npx wendkeep validate-memory --vault <cofre-v2>
|
|
|
87
96
|
symlink, reparse point ou hardlink falham fechados sem tocar bytes externos. Locks publicam owner
|
|
88
97
|
e lease atomicamente, não colhem PID vivo apenas por idade e só liberam a lease adquirida.
|
|
89
98
|
- `promote`/`reject` acrescentam uma decisão auditável e idempotente ao ledger. Replay e repair
|
|
90
|
-
preservam a decisão e não recriam o candidate resolvido. Para candidate `conflict`,
|
|
91
|
-
|
|
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.
|
|
92
102
|
`reject` preserva o valor operacional atual. Candidate `blocked_by_core` só pode ser rejeitado:
|
|
93
103
|
promover exige antes alterar CORE pela curadoria canônica. Se o evento escolhido ainda pertence
|
|
94
104
|
ao último attempt `projected` correspondente, a promoção também atualiza causalmente checkpoint
|
|
@@ -111,6 +121,7 @@ npx wendkeep validate-memory --vault <cofre-v2>
|
|
|
111
121
|
|
|
112
122
|
```bash
|
|
113
123
|
npx wendkeep memory status --gate --vault .MeuApp-vault
|
|
124
|
+
npx wendkeep memory curate --vault .MeuApp-vault
|
|
114
125
|
npx wendkeep memory candidates --active --vault .MeuApp-vault
|
|
115
126
|
npx wendkeep memory recover-attempt sessao-123 --vault .MeuApp-vault
|
|
116
127
|
npx wendkeep memory recover-attempt sessao-123 --apply --vault .MeuApp-vault
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -112,13 +112,29 @@ const memoryMigrateCommand = (vaultBase) => (
|
|
|
112
112
|
const memoryCandidatesCommand = (vaultBase) => (
|
|
113
113
|
`${WENDKEEP_COMMAND} memory candidates --active --vault ${quoteCommandArgument(vaultBase)}`
|
|
114
114
|
);
|
|
115
|
-
const
|
|
116
|
-
`${WENDKEEP_COMMAND} memory
|
|
117
|
-
);
|
|
118
|
-
const memoryRejectCommand = (vaultBase) => (
|
|
119
|
-
`${WENDKEEP_COMMAND} memory reject <candidate-id> --vault ${quoteCommandArgument(vaultBase)}`
|
|
115
|
+
const memoryCurateCommand = (vaultBase) => (
|
|
116
|
+
`${WENDKEEP_COMMAND} memory curate --vault ${quoteCommandArgument(vaultBase)}`
|
|
120
117
|
);
|
|
121
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
|
+
}
|
|
137
|
+
|
|
122
138
|
function readJsonLines(vaultBase, path, label) {
|
|
123
139
|
let checked;
|
|
124
140
|
try {
|
|
@@ -218,11 +234,11 @@ function memoryMetrics() {
|
|
|
218
234
|
};
|
|
219
235
|
}
|
|
220
236
|
|
|
221
|
-
function blockedMemoryBoundary(error) {
|
|
237
|
+
function blockedMemoryBoundary(error, vaultBase) {
|
|
222
238
|
return {
|
|
223
239
|
ok: false,
|
|
224
240
|
status: 'blocked',
|
|
225
|
-
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)}.`],
|
|
226
242
|
warnings: [],
|
|
227
243
|
metrics: memoryMetrics(),
|
|
228
244
|
};
|
|
@@ -393,17 +409,17 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
|
|
|
393
409
|
return {
|
|
394
410
|
ok: false,
|
|
395
411
|
status: 'blocked',
|
|
396
|
-
failures: [`Vault not found: ${vaultBase}
|
|
412
|
+
failures: [`Vault not found: ${vaultBase}. Inspecione com: ${memoryStatusCommand(vaultBase)}.`],
|
|
397
413
|
warnings: [],
|
|
398
414
|
metrics: memoryMetrics(),
|
|
399
415
|
};
|
|
400
416
|
}
|
|
401
417
|
try { preflightMemoryBundle(vaultBase); }
|
|
402
|
-
catch (error) { return blockedMemoryBoundary(error); }
|
|
418
|
+
catch (error) { return blockedMemoryBoundary(error, vaultBase); }
|
|
403
419
|
const brain = join(vaultBase, '.brain');
|
|
404
420
|
let mode;
|
|
405
421
|
try { mode = detectMemoryMode(vaultBase); }
|
|
406
|
-
catch (error) { return blockedMemoryBoundary(error); }
|
|
422
|
+
catch (error) { return blockedMemoryBoundary(error, vaultBase); }
|
|
407
423
|
if (mode.mode === 'legacy') {
|
|
408
424
|
return {
|
|
409
425
|
ok: true,
|
|
@@ -463,7 +479,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
|
|
|
463
479
|
let effectiveRegistry = registry;
|
|
464
480
|
if (!effectiveRegistry) {
|
|
465
481
|
try { effectiveRegistry = readSessionRegistry(vaultBase); }
|
|
466
|
-
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)}.`); }
|
|
467
483
|
}
|
|
468
484
|
const lifecycle = checkMemoryAttempts(effectiveRegistry || { version: 2, sessions: {} }, {
|
|
469
485
|
vaultBase,
|
|
@@ -478,12 +494,11 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
|
|
|
478
494
|
const activeConflicts = unresolved.filter((item) => item?.reason === 'conflict');
|
|
479
495
|
const ordinaryCandidates = unresolved.filter((item) => item?.reason !== 'conflict');
|
|
480
496
|
if (activeConflicts.length) {
|
|
481
|
-
const keys = [...new Set(activeConflicts.map((item) => item.memory_key || item.candidate_id))]
|
|
482
|
-
.sort((left, right) => String(left).localeCompare(String(right)));
|
|
483
497
|
const label = activeConflicts.length === 1
|
|
484
498
|
? '1 conflito ativo'
|
|
485
499
|
: `${activeConflicts.length} conflitos ativos`;
|
|
486
|
-
|
|
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)}.`);
|
|
487
502
|
}
|
|
488
503
|
if (outbox.count) warnings.push(`${outbox.count} evento(s) pendente(s) na outbox; execute o projector quando seguro.`);
|
|
489
504
|
if (ordinaryCandidates.length) warnings.push(`${ordinaryCandidates.length} candidate(s) aguardando curadoria humana.`);
|
|
@@ -562,8 +577,49 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
|
|
|
562
577
|
}
|
|
563
578
|
|
|
564
579
|
export function runVaultHealth({ vaultBase, session = '' }) {
|
|
565
|
-
const
|
|
566
|
-
|
|
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
|
+
|
|
567
623
|
const sessionRel = session || control.session_file || control.last_session_file || '';
|
|
568
624
|
const failures = [];
|
|
569
625
|
const warnings = [];
|
|
@@ -596,20 +652,8 @@ export function runVaultHealth({ vaultBase, session = '' }) {
|
|
|
596
652
|
return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
|
|
597
653
|
}, 0);
|
|
598
654
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
join(vaultBase, '.brain', 'MEMORY_EVENTS.jsonl'),
|
|
602
|
-
join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl'),
|
|
603
|
-
join(vaultBase, '.brain', 'memory-outbox'),
|
|
604
|
-
];
|
|
605
|
-
let memory = { status: 'legacy', metrics: {} };
|
|
606
|
-
if (memoryMarkers.some((path) => existsSync(path))) {
|
|
607
|
-
memory = checkMemoryBundle(vaultBase, { registry });
|
|
608
|
-
failures.push(...memory.failures.map((item) => `Memória: ${item}`));
|
|
609
|
-
warnings.push(...memory.warnings.map((item) => `Memória: ${item}`));
|
|
610
|
-
} else {
|
|
611
|
-
warnings.push(`Bundle de memória v2 ausente (vault legado); inspecione com: ${memoryStatusCommand(vaultBase)}.`);
|
|
612
|
-
}
|
|
655
|
+
failures.push(...memory.failures.map((item) => `Memória: ${item}`));
|
|
656
|
+
warnings.push(...memory.warnings.map((item) => `Memória: ${item}`));
|
|
613
657
|
|
|
614
658
|
return {
|
|
615
659
|
ok: failures.length === 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.1",
|
|
4
4
|
"description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -70,6 +70,6 @@
|
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"acorn": "^8.18.0",
|
|
73
|
-
"wendkeep": "^0.
|
|
73
|
+
"wendkeep": "^0.68.0"
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -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
|
|
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
|
-
|
|
234
|
-
|
|
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': {
|
|
@@ -84,10 +84,16 @@ function immutableJson(vaultBase, path, value) {
|
|
|
84
84
|
return { created: true, path };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Lock failures on the FLOW paths surface with the domain boundary code, like every other
|
|
88
|
+
// Vault write in the promotion saga — not with the physical-boundary default.
|
|
87
89
|
function underSessionLock(vaultBase, sessionId, fn) {
|
|
88
90
|
const root = sessionRoot(vaultBase, sessionId);
|
|
89
|
-
mkdirVaultPath(vaultBase, root, {
|
|
90
|
-
|
|
91
|
+
mkdirVaultPath(vaultBase, root, {
|
|
92
|
+
label: 'raiz runtime da sessão FLOW', code: 'FLOW_VAULT_BOUNDARY',
|
|
93
|
+
});
|
|
94
|
+
const outcome = withVaultPathLock(vaultBase, join(root, '.state'), fn, {
|
|
95
|
+
timeoutMs: 5000, code: 'FLOW_VAULT_BOUNDARY',
|
|
96
|
+
});
|
|
91
97
|
if (typeof outcome === 'symbol') {
|
|
92
98
|
const error = new Error(`store FLOW ocupado para sessão ${sessionId}`);
|
|
93
99
|
error.code = 'FLOW_STORE_BUSY';
|
|
@@ -107,10 +113,13 @@ export function withFlowPromotionLock(vaultBase, changeSlug, fn, {
|
|
|
107
113
|
} = {}) {
|
|
108
114
|
const slug = safeId(changeSlug, 'change_slug');
|
|
109
115
|
const root = join(vaultBase, '.brain', 'runtime', 'flow-promotion-locks');
|
|
110
|
-
mkdirVaultPath(vaultBase, root, {
|
|
116
|
+
mkdirVaultPath(vaultBase, root, {
|
|
117
|
+
label: 'raiz de locks de promoção FLOW', code: 'FLOW_VAULT_BOUNDARY',
|
|
118
|
+
});
|
|
111
119
|
const outcome = withVaultPathLock(vaultBase, join(root, slug), fn, {
|
|
112
120
|
timeoutMs,
|
|
113
121
|
staleMs: ownerGraceMs,
|
|
122
|
+
code: 'FLOW_VAULT_BOUNDARY',
|
|
114
123
|
});
|
|
115
124
|
if (outcome === VAULT_LOCK_BUSY) {
|
|
116
125
|
const busy = new Error(`promoção FLOW ocupada para a change ${slug}`);
|
|
@@ -335,19 +335,36 @@ function waitBriefly(ms) {
|
|
|
335
335
|
Atomics.wait(signal, 0, 0, ms);
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
// A public lock may legitimately disappear while another owner releases it.
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
338
|
+
// A public lock may legitimately disappear while another owner releases it. Structural shape
|
|
339
|
+
// only: which errno a concurrent removal produces is a platform detail — Windows reports
|
|
340
|
+
// UNKNOWN, EBADF or EPERM where Linux reports ENOENT — so the code is merely the trigger to go
|
|
341
|
+
// re-observe, never the answer. Private .pending paths are siblings, not descendants, of the
|
|
342
|
+
// canonical lock and are excluded by the containment check.
|
|
343
|
+
function publicLockRetryCandidate(lock, error, code, { allowRaw = true } = {}) {
|
|
342
344
|
const failure = error?.[VAULT_PATH_FAILURE];
|
|
345
|
+
// A raw error carries no component to re-observe, so it stays on the narrow ENOENT path.
|
|
343
346
|
if (!failure) return allowRaw && error?.code === 'ENOENT' && !error?.cause;
|
|
344
|
-
|
|
345
|
-
return causeCode === 'ENOENT'
|
|
346
|
-
&& (error?.code === code || error?.code === 'VAULT_PATH_UNSAFE')
|
|
347
|
+
return (error?.code === code || error?.code === 'VAULT_PATH_UNSAFE')
|
|
347
348
|
&& ['component-realpath', 'component-missing'].includes(failure.kind)
|
|
348
349
|
&& containedBy(resolve(lock), resolve(failure.component));
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
// The decision itself. Only a fresh walk that settles as a missing suffix or the canonical
|
|
353
|
+
// entry authorizes a retry; junction, symlink, reparse, a redirected component or a state that
|
|
354
|
+
// is still unresolvable keeps failing closed, exactly as a first-time validation would.
|
|
355
|
+
function publicLockComponentSettled(vaultBase, error, code) {
|
|
356
|
+
const component = error?.[VAULT_PATH_FAILURE]?.component;
|
|
357
|
+
if (typeof component !== 'string' || !component) return false;
|
|
358
|
+
try {
|
|
359
|
+
assertVaultPathSafe(vaultBase, component, {
|
|
360
|
+
label: 'revalidação do lock de escrita do Vault', code,
|
|
361
|
+
});
|
|
362
|
+
return true;
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
351
368
|
function vaultLockRenameCollision(error, pending, lock) {
|
|
352
369
|
const acceptedCodes = process.platform === 'win32'
|
|
353
370
|
? ['EEXIST', 'ENOTEMPTY', 'EPERM']
|
|
@@ -374,17 +391,22 @@ function vaultLockRetryDeadlineError() {
|
|
|
374
391
|
return error;
|
|
375
392
|
}
|
|
376
393
|
|
|
377
|
-
function withPublicLockRetry(lock, code, retryState, operation, initialError = null) {
|
|
394
|
+
function withPublicLockRetry(vaultBase, lock, code, retryState, operation, initialError = null) {
|
|
378
395
|
let error = initialError;
|
|
379
396
|
while (true) {
|
|
380
397
|
if (error) {
|
|
381
|
-
if (!
|
|
398
|
+
if (!publicLockRetryCandidate(lock, error, code)
|
|
382
399
|
|| retryState.remaining <= 0) throw error;
|
|
383
400
|
const remainingMs = retryState.deadline - Date.now();
|
|
384
401
|
if (remainingMs <= 0) throw vaultLockRetryDeadlineError();
|
|
385
402
|
retryState.remaining -= 1;
|
|
386
403
|
waitBriefly(Math.min(VAULT_LOCK_TOPOLOGY_RETRY_MS, remainingMs));
|
|
387
404
|
if (Date.now() >= retryState.deadline) throw vaultLockRetryDeadlineError();
|
|
405
|
+
// Reclassify only after the backoff, and only while the deadline still allows a retry:
|
|
406
|
+
// "settled" means observed once the concurrent owner had a chance to finish releasing.
|
|
407
|
+
// A raw error carries no component and already passed the narrow ENOENT filter above.
|
|
408
|
+
if (error[VAULT_PATH_FAILURE]
|
|
409
|
+
&& !publicLockComponentSettled(vaultBase, error, code)) throw error;
|
|
388
410
|
}
|
|
389
411
|
try {
|
|
390
412
|
return operation();
|
|
@@ -395,9 +417,11 @@ function withPublicLockRetry(lock, code, retryState, operation, initialError = n
|
|
|
395
417
|
}
|
|
396
418
|
|
|
397
419
|
function inspectVaultLock(vaultBase, lock, code, retryState, initialError = null) {
|
|
398
|
-
return withPublicLockRetry(lock, code, retryState, () => assertVaultPathSafe(
|
|
399
|
-
|
|
400
|
-
|
|
420
|
+
return withPublicLockRetry(vaultBase, lock, code, retryState, () => assertVaultPathSafe(
|
|
421
|
+
vaultBase, lock, {
|
|
422
|
+
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
423
|
+
},
|
|
424
|
+
), initialError);
|
|
401
425
|
}
|
|
402
426
|
|
|
403
427
|
function processIsAlive(pid) {
|
|
@@ -447,7 +471,7 @@ function vaultLockOwner(vaultBase, lock, code, retryState = null) {
|
|
|
447
471
|
}
|
|
448
472
|
};
|
|
449
473
|
return retryState
|
|
450
|
-
? withPublicLockRetry(lock, code, retryState, inspect)
|
|
474
|
+
? withPublicLockRetry(vaultBase, lock, code, retryState, inspect)
|
|
451
475
|
: inspect();
|
|
452
476
|
}
|
|
453
477
|
|
|
@@ -492,7 +516,7 @@ function releaseOwnedVaultLock(vaultBase, lock, {
|
|
|
492
516
|
// The token-specific lease is the filesystem CAS. An old finally/reaper can only
|
|
493
517
|
// remove the directory after successfully unlinking the lease it originally saw;
|
|
494
518
|
// a replacement lock never contains that unguessable path.
|
|
495
|
-
const leaseRemoved = withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
|
|
519
|
+
const leaseRemoved = withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
|
|
496
520
|
vaultBase, vaultLockLease(lock, token), {
|
|
497
521
|
label: 'lease do lock de escrita do Vault', code,
|
|
498
522
|
},
|
|
@@ -503,12 +527,12 @@ function releaseOwnedVaultLock(vaultBase, lock, {
|
|
|
503
527
|
const current = currentState.owner;
|
|
504
528
|
if (current?.pid !== pid || current?.token !== token) return false;
|
|
505
529
|
const ownerPath = join(lock, VAULT_LOCK_OWNER_FILE);
|
|
506
|
-
if (!withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
|
|
530
|
+
if (!withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
|
|
507
531
|
vaultBase, ownerPath, {
|
|
508
532
|
label: 'owner do lock de escrita do Vault', code,
|
|
509
533
|
},
|
|
510
534
|
))) return false;
|
|
511
|
-
return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
|
|
535
|
+
return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
|
|
512
536
|
vaultBase, lock, {
|
|
513
537
|
missingOk: false, label: 'lock de escrita do Vault', code,
|
|
514
538
|
},
|
|
@@ -516,7 +540,7 @@ function releaseOwnedVaultLock(vaultBase, lock, {
|
|
|
516
540
|
}
|
|
517
541
|
|
|
518
542
|
function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
|
|
519
|
-
const initial = withPublicLockRetry(lock, code, retryState, () => {
|
|
543
|
+
const initial = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
|
|
520
544
|
const checked = assertVaultPathSafe(vaultBase, lock, {
|
|
521
545
|
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
522
546
|
});
|
|
@@ -530,7 +554,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
|
|
|
530
554
|
if (!observed.lockExists) return true;
|
|
531
555
|
if (observed.owner) {
|
|
532
556
|
if (processIsAlive(observed.owner.pid)) return false;
|
|
533
|
-
const lease = withPublicLockRetry(lock, code, retryState, () => {
|
|
557
|
+
const lease = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
|
|
534
558
|
const current = assertVaultPathSafe(vaultBase, lock, {
|
|
535
559
|
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
536
560
|
});
|
|
@@ -548,7 +572,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
|
|
|
548
572
|
// Compatibility with owner-aware locks from 0.58.x, which predate token leases.
|
|
549
573
|
// A dead PID plus byte-identical owner and directory identity is sufficient here;
|
|
550
574
|
// a live legacy owner was returned above and is never reaped by age.
|
|
551
|
-
const legacy = withPublicLockRetry(lock, code, retryState, () => {
|
|
575
|
+
const legacy = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
|
|
552
576
|
const current = assertVaultPathSafe(vaultBase, lock, {
|
|
553
577
|
expectedType: 'directory', label: 'lock legado de escrita do Vault', code,
|
|
554
578
|
});
|
|
@@ -565,12 +589,12 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
|
|
|
565
589
|
if (currentStat.birthtimeMs !== before.birthtimeMs
|
|
566
590
|
|| currentStat.mtimeMs !== before.mtimeMs
|
|
567
591
|
|| currentOwner.raw !== observed.raw) return false;
|
|
568
|
-
if (!withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
|
|
592
|
+
if (!withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
|
|
569
593
|
vaultBase, currentOwner.path, {
|
|
570
594
|
label: 'owner legado morto do lock de escrita do Vault', code,
|
|
571
595
|
},
|
|
572
596
|
))) return false;
|
|
573
|
-
return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
|
|
597
|
+
return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
|
|
574
598
|
vaultBase, checked.target, {
|
|
575
599
|
missingOk: false, label: 'lock legado de escrita do Vault', code,
|
|
576
600
|
},
|
|
@@ -580,7 +604,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
|
|
|
580
604
|
// Locks are published by atomic directory rename only after owner + lease exist.
|
|
581
605
|
// Thus an old empty/partial directory is legacy or crash residue, never an in-flight
|
|
582
606
|
// live acquisition. Unknown children remain fail-closed.
|
|
583
|
-
const partial = withPublicLockRetry(lock, code, retryState, () => {
|
|
607
|
+
const partial = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
|
|
584
608
|
const current = assertVaultPathSafe(vaultBase, lock, {
|
|
585
609
|
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
586
610
|
});
|
|
@@ -604,12 +628,12 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
|
|
|
604
628
|
|| currentStat.mtimeMs !== before.mtimeMs
|
|
605
629
|
|| currentOwner.raw !== observed.raw) return false;
|
|
606
630
|
if (entries.includes(VAULT_LOCK_OWNER_FILE)
|
|
607
|
-
&& !withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
|
|
631
|
+
&& !withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
|
|
608
632
|
vaultBase, currentOwner.path, {
|
|
609
633
|
label: 'owner parcial do lock de escrita do Vault', code,
|
|
610
634
|
},
|
|
611
635
|
))) return false;
|
|
612
|
-
return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
|
|
636
|
+
return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
|
|
613
637
|
vaultBase, checked.target, {
|
|
614
638
|
missingOk: false, label: 'lock de escrita do Vault', code,
|
|
615
639
|
},
|
|
@@ -672,7 +696,8 @@ export function withVaultPathLock(vaultBase, path, fn, {
|
|
|
672
696
|
});
|
|
673
697
|
break;
|
|
674
698
|
} catch (error) {
|
|
675
|
-
|
|
699
|
+
// Shape check only; inspectVaultLock below re-observes through the full retry path.
|
|
700
|
+
const retryableRenameRace = publicLockRetryCandidate(lock, error, code, {
|
|
676
701
|
allowRaw: false,
|
|
677
702
|
});
|
|
678
703
|
const nativeRenameCollision = vaultLockRenameCollision(error, pending, lock);
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
@@ -279,6 +279,62 @@ export function listMemoryCandidates(vault, { activeOnly = false } = {}) {
|
|
|
279
279
|
return { status: 'ok', candidates };
|
|
280
280
|
}
|
|
281
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
|
+
|
|
282
338
|
function priorCandidateDecision(vault, candidateId) {
|
|
283
339
|
return readMemoryLedger(vault).events.find(
|
|
284
340
|
(event) => event.candidate_decision?.candidate_id === candidateId,
|