wendkeep 0.76.1 → 0.76.3

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,30 @@ 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.76.3] — 2026-08-21
8
+
9
+ ### Added
10
+
11
+ - **Recuperação explícita do contexto em quarentena.** `context status --session <id>` inventaria
12
+ candidatas `reserved`/`observed` sem paths locais; `context recover` exige seleção, revisão CAS,
13
+ motivo e correspondência integral com o checkout/HEAD antes de limpar o conflito.
14
+ - **Receipt pós-conflito.** A recuperação preserva change, lease e autorizações, incrementa a revisão
15
+ e registra evidência sanitizada `from/to`; revision stale, identidade divergente ou falha de
16
+ persistência mantêm registry e quarentena intactos.
17
+
18
+ ## [0.76.2] — 2026-08-21
19
+
20
+ ### Fixed
21
+
22
+ - **Projeção SHARED bounded na origem.** O projector agora admite eventos completos de forma
23
+ determinística e priorizada, nunca publica `SHARED_MEMORY.md` acima de 48 linhas/6144 bytes e
24
+ declara as contagens projetada/omitida sem perder a autoridade integral do ledger.
25
+ - **Omissão bounded verificável.** O gate semântico rederiva a seleção esperada; apenas o recorte
26
+ exato é aceito como aviso operacional, enquanto IDs ou contagens divergentes continuam bloqueando.
27
+ - **Recuperação antes de rescope.** Com memória estruturalmente bloqueada, o doctor orienta
28
+ `memory repair` e novo `memory status --gate` antes de voltar ao dry-run, sem recomendar
29
+ `memory rescope --apply` sobre uma projeção inválida.
30
+
7
31
  ## [0.76.1] — 2026-08-21
8
32
 
9
33
  ### Added
package/README.en.md CHANGED
@@ -115,7 +115,7 @@ npx wendkeep init
115
115
 
116
116
  1. Create the vault folder taxonomy and a templated `README.md` (default vault: `<project>/.<project-name>-vault`, e.g. `.MyApp-vault`; override with `--vault`).
117
117
  2. Write a provider-neutral **`.wendkeep.json`** binding at the project root and a matching `.brain/PROJECT.json` marker in the vault, then merge the session hooks into **`.claude/settings.json`**. The binding is provider-neutral by design: any agent resolves the same vault from its session `cwd`, with no machine-global environment variable. Older registrations already in `.claude/settings.json` are adopted automatically.
118
- 3. Wire the Codex hooks in **`.codex/hooks.json`** — twelve compatible entries: `brain-inject` + `session-start` + `observer-publish` on `SessionStart`, `session-ensure` + `evidence-context` + `change-context` on `UserPromptSubmit`, `session-stop` + `observer-publish` + `change-nag` on `Stop`, `subagent-stop` + `observer-publish` on `SubagentStop`, and `change-guard` on `PreToolUse` for `Bash`, `exec_command`, `apply_patch`, and mutable MCP tools, always in the `npx wendkeep hook <name>` form. For the Observer, `SessionStart` only drains the outbox, `Stop` enqueues the changed session, and `SubagentStop` enqueues only the affected transcript; full scanning is explicit through `observer reconcile`. The guard accepts object, raw-string, and argv Codex payloads; before a mutation it compares the session with the project, Git root, remote, branch, and worktree, denying missing or divergent targets. A raw `git checkout/switch` branch transition is denied before it can strand the session; use `wendkeep context switch <branch> [--create]`, which moves Git and the causal scope together in the same worktree with an audited revision and rollback. The other four stay out because Codex offers no equivalent payload, tool, or event: `change-warn` (no reliable `tool_input.file_path`), `plan-capture` (no `ExitPlanMode`), `decision-capture` (`AskUserQuestion` is Claude-only), and `task-log` (`TaskCompleted` is not in Codex's event enum). Codex scope blocks use `permissionDecision: "deny"`; `ask` is never emitted in `PreToolUse`. The merge remains non-destructive, preserves third-party hooks, and migrates legacy `timeout` to `timeoutSec`. **Codex enumerates every hook as untrusted and runs none until you approve the “Hooks need review” prompt at startup — `init` cannot pre-approve them**.
118
+ 3. Wire the Codex hooks in **`.codex/hooks.json`** — twelve compatible entries: `brain-inject` + `session-start` + `observer-publish` on `SessionStart`, `session-ensure` + `evidence-context` + `change-context` on `UserPromptSubmit`, `session-stop` + `observer-publish` + `change-nag` on `Stop`, `subagent-stop` + `observer-publish` on `SubagentStop`, and `change-guard` on `PreToolUse` for `Bash`, `exec_command`, `apply_patch`, and mutable MCP tools, always in the `npx wendkeep hook <name>` form. For the Observer, `SessionStart` only drains the outbox, `Stop` enqueues the changed session, and `SubagentStop` enqueues only the affected transcript; full scanning is explicit through `observer reconcile`. The guard accepts object, raw-string, and argv Codex payloads; before a mutation it compares the session with the project, Git root, remote, branch, and worktree, denying missing or divergent targets. A raw `git checkout/switch` branch transition is denied before it can strand the session; use `wendkeep context switch <branch> [--create]`, which moves Git and the causal scope together in the same worktree with an audited revision and rollback. If a divergence is already quarantined, `context status --session <id>` inventories sanitized `reserved`/`observed` candidates; `context recover --session <id> --select <reserved|observed> --revision <n> --reason <text>` requires an explicit choice, CAS, and current-checkout proof, failing closed before clearing the conflict if revalidation changes. The other four stay out because Codex offers no equivalent payload, tool, or event: `change-warn` (no reliable `tool_input.file_path`), `plan-capture` (no `ExitPlanMode`), `decision-capture` (`AskUserQuestion` is Claude-only), and `task-log` (`TaskCompleted` is not in Codex's event enum). Codex scope blocks use `permissionDecision: "deny"`; `ask` is never emitted in `PreToolUse`. The merge remains non-destructive, preserves third-party hooks, and migrates legacy `timeout` to `timeoutSec`. **Codex enumerates every hook as untrusted and runs none until you approve the “Hooks need review” prompt at startup — `init` cannot pre-approve them**.
119
119
  4. Add the **`wendkeep-vault`** MCP server to `.mcp.json` so the agent can read/write the vault. Skip with `--no-mcp` — e.g. when the agent already has a vault MCP. (`--no-mcp` skips *only wendkeep's own* MCP; companion MCPs still follow `--companions`.)
120
120
  5. Offer to pin **companion** plugins/MCP (multi-choice; **none** pre-checked — wendkeep is a neutral harness and presumes no third-party plugin). Each is wired the most agent-agnostic way it supports:
121
121
  - **`context-mode`** — context optimizer + FTS5 memory, wired as a Claude Code plugin. It ships its own MCP server, so wendkeep deliberately adds no `.mcp.json` entry (registering both cold-started two servers at once). On non-Claude agents, add the MCP by hand: `npx -y context-mode`.
@@ -239,7 +239,7 @@ The README is the map; the guides provide syntax, options, exit codes, examples,
239
239
  |---|---|---|
240
240
  | **Installation and updates** | `init`, `sync`, companions, and the first project↔vault binding | [Installation and first use](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/getting-started.md) |
241
241
  | **Managed worktrees** | `worktree create/list/status/open`, Vault binding, and recovery | [Managed worktrees](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/worktrees.md) |
242
- | **Active context** | `context switch`, causal branch transition, and rollback | [Active context](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/context.md) |
242
+ | **Active context** | `context switch/status/recover`, causal transition, quarantine, and explicit recovery | [Active context](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/context.md) |
243
243
  | **Operating profiles** | `profile`, `flow`, always-on Keep Core, and Wend Runtime governance | [Operating profiles](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/operating-profiles.md) |
244
244
  | **Changes and verification** | `change`, specs, sensors, TDD, evidence, and archive | [Changes and verification](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/changes-and-verification.md) |
245
245
  | **Shared memory** | CORE, SHARED, status, validation, repair, and curation | [Memory](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/memory.md) |
@@ -373,7 +373,7 @@ an installation failure.
373
373
  Hot memory now separates human authorship, operational state, and evidence:
374
374
 
375
375
  - **`CORE.md` is canonical.** It is the short, hand-curated nucleus for durable preferences, active patterns, and open issues; no projector may infer or overwrite it.
376
- - **`SHARED_MEMORY.md` is generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
376
+ - **`SHARED_MEMORY.md` is bounded generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Admission prioritizes critical operational state and never publishes beyond 48 lines/6 KiB; omitted events remain in the append-only authority and surface only as verifiable counts. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
377
377
  - **`MEMORY_EVENTS.jsonl` is the append-only authority.** `Stop` makes events durable in the outbox before acknowledging the attempt; the projector runs outside the registry lock and retries reuse the same IDs. Repeating an identical `event_id`/payload is a no-op; reusing the ID with different bytes is observable corruption.
378
378
  - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event; promotion preserves the selected event's JSON type, session, activation/epoch, and source turn.
379
379
  - **Registers are scoped.** `git.local-head`, handoffs, verdicts, and change status carry project, work-session, change, branch, or worktree scope. Two branches do not create a global conflict; only events in the same scope and causal lineage may advance automatically.
@@ -389,9 +389,9 @@ Codex uses `session_id`/`turn_id` plus transcript order, with no artificial caus
389
389
 
390
390
  ### Injection and budgets
391
391
 
392
- `CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
392
+ `CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 48 lines/6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE is never sliced and SHARED admits deterministic whole events rather than prefix-slicing; a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
393
393
 
394
- `memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable event, placeholders as the only content, or an unresolved decision link becomes an explicit degraded/blocking diagnosis without printing memory values.
394
+ `memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a bounded omission that exactly reproduces ledger selection is an operational warning. An arbitrarily missing event, false count, placeholders as the only content, or an unresolved decision link remains explicitly degraded/blocking without printing memory values.
395
395
 
396
396
  `DIGEST.md` is no longer the operational handoff: it remains the `/brain-recall` bridge and legacy-vault fallback. A vault without SHARED receives CORE+DIGEST with a deprecation warning; migrate during the compatibility window:
397
397
 
package/README.md CHANGED
@@ -115,7 +115,7 @@ npx wendkeep init
115
115
 
116
116
  1. Create the vault folder taxonomy and a templated `README.md` (default vault: `<project>/.<project-name>-vault`, e.g. `.MyApp-vault`; override with `--vault`).
117
117
  2. Write a provider-neutral **`.wendkeep.json`** binding at the project root and a matching `.brain/PROJECT.json` marker in the vault, then merge the session hooks into **`.claude/settings.json`**. The binding is provider-neutral by design: any agent resolves the same vault from its session `cwd`, with no machine-global environment variable. Older registrations already in `.claude/settings.json` are adopted automatically.
118
- 3. Wire the Codex hooks in **`.codex/hooks.json`** — twelve compatible entries: `brain-inject` + `session-start` + `observer-publish` on `SessionStart`, `session-ensure` + `evidence-context` + `change-context` on `UserPromptSubmit`, `session-stop` + `observer-publish` + `change-nag` on `Stop`, `subagent-stop` + `observer-publish` on `SubagentStop`, and `change-guard` on `PreToolUse` for `Bash`, `exec_command`, `apply_patch`, and mutable MCP tools, always in the `npx wendkeep hook <name>` form. For the Observer, `SessionStart` only drains the outbox, `Stop` enqueues the changed session, and `SubagentStop` enqueues only the affected transcript; full scanning is explicit through `observer reconcile`. The guard accepts object, raw-string, and argv Codex payloads; before a mutation it compares the session with the project, Git root, remote, branch, and worktree, denying missing or divergent targets. A raw `git checkout/switch` branch transition is denied before it can strand the session; use `wendkeep context switch <branch> [--create]`, which moves Git and the causal scope together in the same worktree with an audited revision and rollback. The other four stay out because Codex offers no equivalent payload, tool, or event: `change-warn` (no reliable `tool_input.file_path`), `plan-capture` (no `ExitPlanMode`), `decision-capture` (`AskUserQuestion` is Claude-only), and `task-log` (`TaskCompleted` is not in Codex's event enum). Codex scope blocks use `permissionDecision: "deny"`; `ask` is never emitted in `PreToolUse`. The merge remains non-destructive, preserves third-party hooks, and migrates legacy `timeout` to `timeoutSec`. **Codex enumerates every hook as untrusted and runs none until you approve the “Hooks need review” prompt at startup — `init` cannot pre-approve them**.
118
+ 3. Wire the Codex hooks in **`.codex/hooks.json`** — twelve compatible entries: `brain-inject` + `session-start` + `observer-publish` on `SessionStart`, `session-ensure` + `evidence-context` + `change-context` on `UserPromptSubmit`, `session-stop` + `observer-publish` + `change-nag` on `Stop`, `subagent-stop` + `observer-publish` on `SubagentStop`, and `change-guard` on `PreToolUse` for `Bash`, `exec_command`, `apply_patch`, and mutable MCP tools, always in the `npx wendkeep hook <name>` form. For the Observer, `SessionStart` only drains the outbox, `Stop` enqueues the changed session, and `SubagentStop` enqueues only the affected transcript; full scanning is explicit through `observer reconcile`. The guard accepts object, raw-string, and argv Codex payloads; before a mutation it compares the session with the project, Git root, remote, branch, and worktree, denying missing or divergent targets. A raw `git checkout/switch` branch transition is denied before it can strand the session; use `wendkeep context switch <branch> [--create]`, which moves Git and the causal scope together in the same worktree with an audited revision and rollback. If a divergence is already quarantined, `context status --session <id>` inventories sanitized `reserved`/`observed` candidates; `context recover --session <id> --select <reserved|observed> --revision <n> --reason <text>` requires an explicit choice, CAS, and current-checkout proof, failing closed before clearing the conflict if revalidation changes. The other four stay out because Codex offers no equivalent payload, tool, or event: `change-warn` (no reliable `tool_input.file_path`), `plan-capture` (no `ExitPlanMode`), `decision-capture` (`AskUserQuestion` is Claude-only), and `task-log` (`TaskCompleted` is not in Codex's event enum). Codex scope blocks use `permissionDecision: "deny"`; `ask` is never emitted in `PreToolUse`. The merge remains non-destructive, preserves third-party hooks, and migrates legacy `timeout` to `timeoutSec`. **Codex enumerates every hook as untrusted and runs none until you approve the “Hooks need review” prompt at startup — `init` cannot pre-approve them**.
119
119
  4. Add the **`wendkeep-vault`** MCP server to `.mcp.json` so the agent can read/write the vault. Skip with `--no-mcp` — e.g. when the agent already has a vault MCP. (`--no-mcp` skips *only wendkeep's own* MCP; companion MCPs still follow `--companions`.)
120
120
  5. Offer to pin **companion** plugins/MCP (multi-choice; **none** pre-checked — wendkeep is a neutral harness and presumes no third-party plugin). Each is wired the most agent-agnostic way it supports:
121
121
  - **`context-mode`** — context optimizer + FTS5 memory, wired as a Claude Code plugin. It ships its own MCP server, so wendkeep deliberately adds no `.mcp.json` entry (registering both cold-started two servers at once). On non-Claude agents, add the MCP by hand: `npx -y context-mode`.
@@ -239,7 +239,7 @@ The README is the map; the guides provide syntax, options, exit codes, examples,
239
239
  |---|---|---|
240
240
  | **Installation and updates** | `init`, `sync`, companions, and the first project↔vault binding | [Installation and first use](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/getting-started.md) |
241
241
  | **Managed worktrees** | `worktree create/list/status/open`, Vault binding, and recovery | [Managed worktrees](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/worktrees.md) |
242
- | **Active context** | `context switch`, causal branch transition, and rollback | [Active context](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/context.md) |
242
+ | **Active context** | `context switch/status/recover`, causal transition, quarantine, and explicit recovery | [Active context](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/context.md) |
243
243
  | **Operating profiles** | `profile`, `flow`, always-on Keep Core, and Wend Runtime governance | [Operating profiles](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/operating-profiles.md) |
244
244
  | **Changes and verification** | `change`, specs, sensors, TDD, evidence, and archive | [Changes and verification](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/changes-and-verification.md) |
245
245
  | **Shared memory** | CORE, SHARED, status, validation, repair, and curation | [Memory](https://github.com/rogersialves/wendkeep/blob/main/docs/en/commands/memory.md) |
@@ -373,7 +373,7 @@ an installation failure.
373
373
  Hot memory now separates human authorship, operational state, and evidence:
374
374
 
375
375
  - **`CORE.md` is canonical.** It is the short, hand-curated nucleus for durable preferences, active patterns, and open issues; no projector may infer or overwrite it.
376
- - **`SHARED_MEMORY.md` is generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
376
+ - **`SHARED_MEMORY.md` is bounded generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Admission prioritizes critical operational state and never publishes beyond 48 lines/6 KiB; omitted events remain in the append-only authority and surface only as verifiable counts. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
377
377
  - **`MEMORY_EVENTS.jsonl` is the append-only authority.** `Stop` makes events durable in the outbox before acknowledging the attempt; the projector runs outside the registry lock and retries reuse the same IDs. Repeating an identical `event_id`/payload is a no-op; reusing the ID with different bytes is observable corruption.
378
378
  - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event; promotion preserves the selected event's JSON type, session, activation/epoch, and source turn.
379
379
  - **Registers are scoped.** `git.local-head`, handoffs, verdicts, and change status carry project, work-session, change, branch, or worktree scope. Two branches do not create a global conflict; only events in the same scope and causal lineage may advance automatically.
@@ -389,9 +389,9 @@ Codex uses `session_id`/`turn_id` plus transcript order, with no artificial caus
389
389
 
390
390
  ### Injection and budgets
391
391
 
392
- `CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
392
+ `CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 48 lines/6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE is never sliced and SHARED admits deterministic whole events rather than prefix-slicing; a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
393
393
 
394
- `memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable event, placeholders as the only content, or an unresolved decision link becomes an explicit degraded/blocking diagnosis without printing memory values.
394
+ `memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a bounded omission that exactly reproduces ledger selection is an operational warning. An arbitrarily missing event, false count, placeholders as the only content, or an unresolved decision link remains explicitly degraded/blocking without printing memory values.
395
395
 
396
396
  `DIGEST.md` is no longer the operational handoff: it remains the `/brain-recall` bridge and legacy-vault fallback. A vault without SHARED receives CORE+DIGEST with a deprecation warning; migrate during the compatibility window:
397
397
 
@@ -4,18 +4,19 @@
4
4
 
5
5
  ## Purpose
6
6
 
7
- Switch the Git branch and the same session's causal scope together inside the current worktree,
8
- without opening a new session or weakening the guard.
7
+ Inspect and move the same session's causal scope with proof of the current checkout: during a
8
+ normal branch transition or when explicitly recovering a divergence already under quarantine.
9
9
 
10
10
  ## When to use
11
11
 
12
- Use `context switch` when an active session must create or select another branch in the same
13
- worktree and continue mutating the repository after the transition.
12
+ Use `context switch` to create or select another branch in the same worktree. If the registry
13
+ already records `project_scope_conflict`, use `context status` to inventory `reserved` and
14
+ `observed` without local paths; recover only through `context recover` and an explicit human choice.
14
15
 
15
16
  ## When not to use
16
17
 
17
- Do not use it to move to another worktree, adopt an already-divergent scope, repair the registry,
18
- or replace `worktree create`. Those cases require a separate physical context or explicit diagnosis.
18
+ Do not use it to move to another worktree, hand-edit the registry, or replace `worktree create`.
19
+ Recovery never selects a candidate automatically or accepts a scope that no longer matches HEAD.
19
20
 
20
21
  ## Prerequisites
21
22
 
@@ -27,6 +28,8 @@ or replace `worktree create`. Those cases require a separate physical context or
27
28
 
28
29
  ```bash
29
30
  npx --no-install wendkeep context switch <branch> [--create] [--session <id>] [--project <root>] [--vault <vault>] [--json]
31
+ npx --no-install wendkeep context status --session <id> [--project <root>] [--vault <vault>] [--json]
32
+ npx --no-install wendkeep context recover --session <id> --select <reserved|observed> --revision <n> --reason <text> [--project <root>] [--vault <vault>] [--json]
30
33
  ```
31
34
 
32
35
  Without `--session`, exactly one active session must fully match the current scope. `--create`
@@ -36,6 +39,9 @@ uses `git switch -c`; without it, the command follows `git switch` semantics.
36
39
 
37
40
  - `--create`: create the branch from the current HEAD.
38
41
  - `--session <id>`: select the causal session explicitly; recommended whenever selection is unclear.
42
+ - `--select <reserved|observed>`: select exactly one quarantined candidate; required for recovery.
43
+ - `--revision <n>`: CAS against the revision returned by `context status`; required for recovery.
44
+ - `--reason <text>`: auditable reason, sanitized and limited to 240 characters.
39
45
  - `--project <root>` and `--vault <vault>`: select the binding and paths for manual use.
40
46
  - `--json`: emit status, session id, branch, HEAD, revision, and event without exposing the Vault.
41
47
 
@@ -49,6 +55,8 @@ ambiguity, scope mismatch, conflict, Git failure, or rollback returns `2` with a
49
55
  npx --no-install wendkeep context switch wk/auth --create
50
56
  npx --no-install wendkeep context switch main --session 019abc-session-id
51
57
  npx --no-install wendkeep context switch wk/auth --session 019abc-session-id --json
58
+ npx --no-install wendkeep context status --session 019abc-session-id --json
59
+ npx --no-install wendkeep context recover --session 019abc-session-id --select observed --revision 7 --reason "checkout confirmed"
52
60
  ```
53
61
 
54
62
  Do not replace it with the raw command below while the harness is active:
@@ -70,11 +78,22 @@ authorizations are preserved.
70
78
  If validation or persistence fails after the switch, rollback restores the previous branch or
71
79
  detached HEAD; a branch created by the failed attempt is removed as well.
72
80
 
81
+ During recovery, both candidates must be complete and retain the same causal identity. The selected
82
+ candidate must fully match the current project, repository, remote, worktree, branch, and HEAD.
83
+ Under the registry lock, the command revalidates the revision, increments `context_revision`,
84
+ preserves the change/lease/authorizations, clears only the quarantine, and appends a sanitized
85
+ receipt to `context_recoveries`. Any failure leaves the registry and quarantine byte-identical.
86
+ This is fail-closed: no candidate, receipt, or partial scope is published after a failed check.
87
+
73
88
  ## Common errors and diagnosis
74
89
 
75
90
  - `WENDKEEP_CONTEXT_AMBIGUOUS`: pass `--session <id>`; no candidate is selected silently.
76
- - `WENDKEEP_CONTEXT_SCOPE_MISMATCH` or `WENDKEEP_CONTEXT_SCOPE_CONFLICT`: return to the reserved
77
- checkout or diagnose the session; the command never adopts a post-hoc divergence.
91
+ - `WENDKEEP_CONTEXT_SCOPE_MISMATCH`: the selected candidate does not prove the current checkout/HEAD;
92
+ run `context status` again and select only a candidate with `matches_actual: true`.
93
+ - `WENDKEEP_CONTEXT_SCOPE_CONFLICT`: the session is not quarantined or a candidate is missing.
94
+ - `WENDKEEP_CONTEXT_CAS_MISMATCH`: the revision changed; discard the stale decision and rerun status.
95
+ - `WENDKEEP_CONTEXT_IDENTITY_CHANGED`: candidates belong to different causal identities; preserve
96
+ quarantine and diagnose the registry.
78
97
  - `WENDKEEP_CONTEXT_CONFLICT`: another active context occupies the target; use another
79
98
  branch/worktree or close the competing context correctly.
80
99
  - `WENDKEEP_CONTEXT_GIT`: fix the branch, conflicting dirty state, or Git error and retry.
@@ -98,12 +98,19 @@ npx wendkeep validate-memory --vault <v2-vault>
98
98
  and its successor. Replay is CORE-aware, checkpoints use the physical ledger cursor, and the
99
99
  command neither rewrites ledger/CORE/notes nor consumes the outbox. Retrying the same applied
100
100
  decision is idempotent.
101
+ - The projector deterministically admits whole events, prioritizing critical operational state up
102
+ to 48 lines/6144 bytes. `projection_mode`, `projected_events`, and `omitted_events` make the
103
+ selection verifiable; revision, cursor, and `state_hash` still cover the complete ledger. The
104
+ gate accepts only omissions that re-derive exactly from the same policy.
101
105
  - `memory rescope` is a dry run by default and lists only planned IDs, keys, and scopes. With
102
106
  `--apply`, it appends explicit project, work-session, change, branch, or worktree events while
103
107
  preserving historic bytes as the ledger prefix. Legacy `handoff.latest` events inside candidates
104
108
  are also rescoped individually when they carry proven session identity, separating independent
105
109
  workflows without selecting a winner. Ambiguities that remain inside one scope stay under human
106
110
  curation; retry returns `unchanged`.
111
+ If doctor reports structurally blocked memory, run `memory repair` first, confirm a green
112
+ `memory status --gate`, and only then return to the dry run; never apply rescope over an invalid
113
+ projection.
107
114
  - Registers such as `git.local-head`, `handoff.latest`, `quality.latest-*`, and
108
115
  `change.<slug>.status` compete only inside the same scope. Automatic resolution still requires
109
116
  the same project and causal lineage; incompatible decisions, constraints, and blockers remain
@@ -4,18 +4,19 @@
4
4
 
5
5
  ## Objetivo
6
6
 
7
- Trocar a branch Git e a scope causal da mesma sessão juntas, dentro da worktree atual, sem abrir
8
- uma nova sessão e sem relaxar o guard.
7
+ Inspecionar e mover a scope causal da mesma sessão com prova do checkout atual: durante uma troca
8
+ de branch normal ou ao recuperar explicitamente uma divergência já colocada em quarentena.
9
9
 
10
10
  ## Quando usar
11
11
 
12
- Use `context switch` quando uma sessão ativa precisa criar ou selecionar outra branch na mesma
13
- worktree e deve continuar mutando o repositório depois da troca.
12
+ Use `context switch` para criar ou selecionar outra branch na mesma worktree. Se o registry já
13
+ registrou `project_scope_conflict`, use `context status` para inventariar `reserved` e `observed`
14
+ sem paths locais; recupere somente com `context recover` e uma seleção humana explícita.
14
15
 
15
16
  ## Quando não usar
16
17
 
17
- Não use para mudar de worktree, adotar uma scope divergente, reparar o registry ou substituir
18
- `worktree create`. Esses casos exigem outro contexto físico ou diagnóstico explícito.
18
+ Não use para mudar de worktree, editar o registry à mão ou substituir `worktree create`. Recovery
19
+ não escolhe a candidata automaticamente e não aceita uma scope que deixou de corresponder ao HEAD.
19
20
 
20
21
  ## Pré-requisitos
21
22
 
@@ -27,6 +28,8 @@ Não use para mudar de worktree, adotar uma scope já divergente, reparar o regi
27
28
 
28
29
  ```bash
29
30
  npx --no-install wendkeep context switch <branch> [--create] [--session <id>] [--project <raiz>] [--vault <cofre>] [--json]
31
+ npx --no-install wendkeep context status --session <id> [--project <raiz>] [--vault <cofre>] [--json]
32
+ npx --no-install wendkeep context recover --session <id> --select <reserved|observed> --revision <n> --reason <texto> [--project <raiz>] [--vault <cofre>] [--json]
30
33
  ```
31
34
 
32
35
  Sem `--session`, exatamente uma sessão ativa deve corresponder integralmente à scope atual. Use
@@ -36,6 +39,9 @@ Sem `--session`, exatamente uma sessão ativa deve corresponder integralmente à
36
39
 
37
40
  - `--create`: cria a branch a partir do HEAD atual.
38
41
  - `--session <id>`: seleciona explicitamente a sessão causal; recomendado quando houver dúvida.
42
+ - `--select <reserved|observed>`: escolhe exatamente uma candidata da quarentena; obrigatório no recovery.
43
+ - `--revision <n>`: CAS contra a revisão exibida por `context status`; obrigatório no recovery.
44
+ - `--reason <texto>`: justificativa auditável, sanitizada e limitada a 240 caracteres.
39
45
  - `--project <raiz>` e `--vault <cofre>`: selecionam binding e paths para uso manual.
40
46
  - `--json`: emite status, session id, branch, HEAD, revisão e evento sem expor o Vault.
41
47
 
@@ -48,6 +54,8 @@ scope divergente, conflito, falha Git ou rollback retorna `2` com um código `WE
48
54
  npx --no-install wendkeep context switch wk/auth --create
49
55
  npx --no-install wendkeep context switch main --session 019abc-session-id
50
56
  npx --no-install wendkeep context switch wk/auth --session 019abc-session-id --json
57
+ npx --no-install wendkeep context status --session 019abc-session-id --json
58
+ npx --no-install wendkeep context recover --session 019abc-session-id --select observed --revision 7 --reason "checkout confirmado"
51
59
  ```
52
60
 
53
61
  Não substitua pelo comando cru abaixo quando o harness estiver ativo:
@@ -68,11 +76,21 @@ worktree, provider e session id não mudaram, incrementa `context_revision` e an
68
76
  Se qualquer validação ou persistência falhar depois do switch, o rollback restaura a branch ou
69
77
  detached HEAD anterior; uma branch criada pela tentativa também é removida.
70
78
 
79
+ No recovery, ambas as candidatas precisam ser completas e manter a mesma identidade causal. A
80
+ selecionada deve corresponder integralmente a projeto, repositório, remoto, worktree, branch e HEAD
81
+ atuais. Sob o lock do registry, o comando revalida a revisão, incrementa `context_revision`, preserva
82
+ change/lease/autorizações, limpa somente a quarentena e anexa um receipt sanitizado em
83
+ `context_recoveries`. Qualquer falha deixa registry e quarentena byte a byte intactos.
84
+
71
85
  ## Erros comuns e diagnóstico
72
86
 
73
87
  - `WENDKEEP_CONTEXT_AMBIGUOUS`: informe `--session <id>`; nenhuma candidata é escolhida em silêncio.
74
- - `WENDKEEP_CONTEXT_SCOPE_MISMATCH` ou `WENDKEEP_CONTEXT_SCOPE_CONFLICT`: volte ao checkout
75
- reservado ou diagnostique a sessão; o comando não adota uma divergência posterior.
88
+ - `WENDKEEP_CONTEXT_SCOPE_MISMATCH`: a candidata escolhida não prova o checkout/HEAD atual; rode
89
+ `context status` novamente e selecione apenas uma candidata com `matches_actual: true`.
90
+ - `WENDKEEP_CONTEXT_SCOPE_CONFLICT`: a sessão não está em quarentena ou uma candidata está ausente.
91
+ - `WENDKEEP_CONTEXT_CAS_MISMATCH`: a revisão mudou; descarte a decisão antiga e repita o status.
92
+ - `WENDKEEP_CONTEXT_IDENTITY_CHANGED`: as candidatas pertencem a identidades causais diferentes;
93
+ preserve a quarentena e diagnostique o registry.
76
94
  - `WENDKEEP_CONTEXT_CONFLICT`: outro contexto ativo ocupa o destino; use outra branch/worktree ou
77
95
  encerre corretamente o contexto concorrente.
78
96
  - `WENDKEEP_CONTEXT_GIT`: corrija a branch, dirty state conflitante ou erro do Git e repita.
@@ -98,12 +98,19 @@ npx wendkeep validate-memory --vault <cofre-v2>
98
98
  do attempt exato, salva backup do registry e limita a mutação ao attempt ambíguo e à sucessora.
99
99
  O replay é CORE-aware, usa cursor físico do ledger no checkpoint e não reescreve ledger, CORE ou
100
100
  notas, nem consome a outbox. Repetir a mesma decisão aplicada é idempotente.
101
+ - O projector admite eventos completos de forma determinística e prioriza estado operacional
102
+ crítico até o limite de 48 linhas/6144 bytes. `projection_mode`, `projected_events` e
103
+ `omitted_events` tornam o recorte verificável; revision, cursor e `state_hash` continuam cobrindo
104
+ o ledger integral. O gate aceita somente a omissão que rederiva exatamente da mesma política.
101
105
  - `memory rescope` é dry-run por padrão e lista somente IDs, chaves e escopos planejados. Com
102
106
  `--apply`, anexa eventos explícitos de projeto, work session, change, branch ou worktree e mantém
103
107
  os bytes históricos como prefixo do ledger. Eventos legados de `handoff.latest` que participam
104
108
  de candidates também são reescopados individualmente quando possuem identidade de sessão
105
109
  comprovável: isso separa workflows independentes sem selecionar vencedor. Ambiguidades que
106
110
  permanecem no mesmo escopo continuam sob curadoria; uma repetição retorna `unchanged`.
111
+ Se o doctor indicar memória estruturalmente bloqueada, execute primeiro `memory repair`, confirme
112
+ `memory status --gate` verde e só então volte ao dry-run; não aplique rescope sobre uma projeção
113
+ inválida.
107
114
  - Registradores como `git.local-head`, `handoff.latest`, `quality.latest-*` e
108
115
  `change.<slug>.status` só competem dentro do mesmo escopo. Resolução automática ainda exige o
109
116
  mesmo projeto e linhagem causal; decisões, constraints e blockers incompatíveis permanecem sob
@@ -523,12 +523,21 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
523
523
  const label = historicalHandoffs.length === 1
524
524
  ? '1 handoff histórico reparável'
525
525
  : `${historicalHandoffs.length} handoffs históricos reparáveis`;
526
- warnings.push(
527
- `${label}; sessões de origem estão encerradas e nenhuma versão precisa virar estado atual. `
528
- + `Execute primeiro o dry-run: ${memoryRescopeCommand(vaultBase)}. `
529
- + `Se a prévia estiver correta: ${memoryRescopeCommand(vaultBase, { apply: true })}. `
530
- + `Dívida restante: ${memoryCurateAllCommand(vaultBase)}.`,
531
- );
526
+ if (!bundle.ok) {
527
+ warnings.push(
528
+ `${label}; a memória está bloqueada e rescope não deve ampliar uma projeção inválida. `
529
+ + `Repare primeiro: ${memoryRepairCommand(vaultBase)}. `
530
+ + `Confirme a recuperação: ${memoryStatusCommand(vaultBase)}. `
531
+ + 'Somente depois de um gate verde reexecute o doctor para revisar o dry-run de rescope.',
532
+ );
533
+ } else {
534
+ warnings.push(
535
+ `${label}; sessões de origem estão encerradas e nenhuma versão precisa virar estado atual. `
536
+ + `Execute primeiro o dry-run: ${memoryRescopeCommand(vaultBase)}. `
537
+ + `Se a prévia estiver correta: ${memoryRescopeCommand(vaultBase, { apply: true })}. `
538
+ + `Dívida restante: ${memoryCurateAllCommand(vaultBase)}.`,
539
+ );
540
+ }
532
541
  }
533
542
  if (outbox.count) warnings.push(`${outbox.count} evento(s) pendente(s) na outbox; execute o projector quando seguro.`);
534
543
  if (ordinaryCandidates.length) warnings.push(`${ordinaryCandidates.length} candidate(s) aguardando curadoria humana.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.76.1",
3
+ "version": "0.76.3",
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": [
@@ -59,6 +59,9 @@ Usage:
59
59
  Managed linked worktrees under .worktrees (branch default wk/<slug>).
60
60
  wendkeep context switch <branch> [--create] [--session <id>] [--json]
61
61
  Switch Git branch and the causal session scope in the same worktree.
62
+ wendkeep context status --session <id> [--json]
63
+ wendkeep context recover --session <id> --select reserved|observed --revision <n> --reason <text> [--json]
64
+ Inspect or explicitly recover a quarantined causal scope conflict.
62
65
  wendkeep change <sub> Change lifecycle: new [--simple|--guide] | use | bind <slug> --session <id> | continue | list | show |
63
66
  status | done <id> | undone <id> | diff | archive [--force] | abandon | relink | backlink.
64
67
  archive exige verdict (rode verify --deep); abandon descarta sem ADR.
@@ -14,6 +14,17 @@ export const SHARED_SECTIONS = Object.freeze([
14
14
  'Último Handoff',
15
15
  ]);
16
16
 
17
+ const SHARED_ADMISSION_SECTIONS = Object.freeze([
18
+ 'Bloqueios',
19
+ 'Objetivo Atual',
20
+ 'Restrições Ativas',
21
+ 'Decisões em Vigor',
22
+ 'Próximas Ações',
23
+ 'Riscos Conhecidos',
24
+ 'Último Handoff',
25
+ 'Estado Entregue',
26
+ ]);
27
+
17
28
  const AUTHORITIES = new Set(['verified', 'reported', 'candidate']);
18
29
  const OPERATIONS = new Set(['assert', 'replace', 'add', 'remove']);
19
30
  const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
@@ -197,29 +208,63 @@ function defaultInstant(events) {
197
208
  return instants.at(-1) || new Date(0).toISOString();
198
209
  }
199
210
 
200
- /** Render the generated operational projection. Inputs are sanitized a second time. */
201
- export function renderSharedMemory({
202
- revision = 0,
203
- eventCursor = 'none',
204
- events = [],
211
+ function authorityPriority(authority) {
212
+ if (authority === 'verified') return 0;
213
+ if (authority === 'reported') return 1;
214
+ return 2;
215
+ }
216
+
217
+ function compareProjectionEvents(left, right) {
218
+ return authorityPriority(left?.authority) - authorityPriority(right?.authority)
219
+ || String(right?.observed_at || '').localeCompare(String(left?.observed_at || ''))
220
+ || String(left?.projection_key || left?.memory_key || '').localeCompare(
221
+ String(right?.projection_key || right?.memory_key || ''),
222
+ )
223
+ || String(left?.event_id || '').localeCompare(String(right?.event_id || ''));
224
+ }
225
+
226
+ function admissionOrder(events) {
227
+ const grouped = new Map(SHARED_ADMISSION_SECTIONS.map((section) => [section, []]));
228
+ for (const event of events) grouped.get(sectionFor(event?.memory_key)).push(event);
229
+ for (const bucket of grouped.values()) bucket.sort(compareProjectionEvents);
230
+
231
+ const ordered = [];
232
+ for (let round = 0; ; round += 1) {
233
+ let admitted = false;
234
+ for (const section of SHARED_ADMISSION_SECTIONS) {
235
+ const event = grouped.get(section)[round];
236
+ if (!event) continue;
237
+ ordered.push(event);
238
+ admitted = true;
239
+ }
240
+ if (!admitted) return ordered;
241
+ }
242
+ }
243
+
244
+ function renderSharedMemoryContent({
245
+ revision,
246
+ eventCursor,
247
+ allEvents,
248
+ projectedEvents,
205
249
  stateHash,
206
- updatedAt,
207
- reviewAfter,
208
- } = {}) {
209
- const safeEvents = Array.isArray(events) ? events : [];
210
- const updated = updatedAt || defaultInstant(safeEvents);
211
- const review = reviewAfter || new Date(Date.parse(updated) + (7 * 24 * 60 * 60 * 1000)).toISOString();
250
+ updated,
251
+ review,
252
+ }) {
212
253
  const grouped = new Map(SHARED_SECTIONS.map((section) => [section, []]));
213
- for (const event of safeEvents) grouped.get(sectionFor(event.memory_key)).push(eventLine(event));
254
+ for (const event of projectedEvents) grouped.get(sectionFor(event.memory_key)).push(eventLine(event));
255
+ const omittedEvents = allEvents.length - projectedEvents.length;
214
256
 
215
257
  const lines = [
216
258
  '---',
217
259
  'schema_version: 2',
218
260
  `revision: ${Number.isInteger(revision) ? revision : 0}`,
219
261
  `event_cursor: ${sanitizeMemoryText(eventCursor || 'none')}`,
220
- `state_hash: ${sanitizeMemoryText(stateHash || hashProjection(safeEvents))}`,
262
+ `state_hash: ${sanitizeMemoryText(stateHash || hashProjection(allEvents))}`,
221
263
  `updated_at: ${sanitizeMemoryText(updated)}`,
222
264
  `review_after: ${sanitizeMemoryText(review)}`,
265
+ `projection_mode: ${omittedEvents ? 'bounded' : 'complete'}`,
266
+ `projected_events: ${projectedEvents.length}`,
267
+ `omitted_events: ${omittedEvents}`,
223
268
  '---',
224
269
  '',
225
270
  '# SHARED_MEMORY — projeção operacional gerada',
@@ -231,6 +276,45 @@ export function renderSharedMemory({
231
276
  return `${lines.join('\n').trimEnd()}\n`;
232
277
  }
233
278
 
279
+ function actualLineCount(text) {
280
+ const lines = text.split('\n');
281
+ return text.endsWith('\n') ? lines.length - 1 : lines.length;
282
+ }
283
+
284
+ function sharedFits(content) {
285
+ return actualLineCount(content) <= SHARED_LIMITS.lines
286
+ && Buffer.byteLength(content, 'utf8') <= SHARED_LIMITS.bytes;
287
+ }
288
+
289
+ /** Render the generated operational projection. Inputs are sanitized a second time. */
290
+ export function renderSharedMemory({
291
+ revision = 0,
292
+ eventCursor = 'none',
293
+ events = [],
294
+ stateHash,
295
+ updatedAt,
296
+ reviewAfter,
297
+ } = {}) {
298
+ const safeEvents = Array.isArray(events) ? events : [];
299
+ const updated = updatedAt || defaultInstant(safeEvents);
300
+ const review = reviewAfter || new Date(Date.parse(updated) + (7 * 24 * 60 * 60 * 1000)).toISOString();
301
+ const render = (projectedEvents) => renderSharedMemoryContent({
302
+ revision,
303
+ eventCursor,
304
+ allEvents: safeEvents,
305
+ projectedEvents,
306
+ stateHash,
307
+ updated,
308
+ review,
309
+ });
310
+ const projectedEvents = [];
311
+ for (const event of admissionOrder(safeEvents)) {
312
+ const trial = [...projectedEvents, event];
313
+ if (sharedFits(render(trial))) projectedEvents.push(event);
314
+ }
315
+ return render(projectedEvents);
316
+ }
317
+
234
318
  function parseScalar(value) {
235
319
  if (/^-?\d+$/.test(value)) return Number(value);
236
320
  return value;
@@ -278,11 +362,6 @@ export function parseSharedMemory(content) {
278
362
  return { ok: errors.length === 0, errors, metadata, sections };
279
363
  }
280
364
 
281
- function actualLineCount(text) {
282
- const lines = text.split('\n');
283
- return text.endsWith('\n') ? lines.length - 1 : lines.length;
284
- }
285
-
286
365
  export function validateSharedMemory(content, { eventIds } = {}) {
287
366
  const text = String(content ?? '').replace(/\r\n/g, '\n');
288
367
  const parsed = parseSharedMemory(text);
@@ -306,6 +385,31 @@ export function validateSharedMemory(content, { eventIds } = {}) {
306
385
  for (const key of ['event_cursor', 'state_hash', 'updated_at', 'review_after']) {
307
386
  if (typeof metadata[key] !== 'string' || !metadata[key]) errors.push(`${key} é obrigatório.`);
308
387
  }
388
+ const projectionFields = ['projection_mode', 'projected_events', 'omitted_events'];
389
+ const projectionFieldCount = projectionFields.filter((key) => metadata[key] !== undefined).length;
390
+ if (projectionFieldCount && projectionFieldCount !== projectionFields.length) {
391
+ errors.push('Metadados bounded incompletos: projection_mode, projected_events e omitted_events são inseparáveis.');
392
+ } else if (projectionFieldCount === projectionFields.length) {
393
+ const projectedEventLines = [...parsed.sections.values()].flat()
394
+ .filter((line) => /^\s*-\s+\[[^\]]+\]/.test(line)).length;
395
+ if (!['complete', 'bounded'].includes(metadata.projection_mode)) {
396
+ errors.push('projection_mode deve ser complete ou bounded.');
397
+ }
398
+ if (!Number.isInteger(metadata.projected_events) || metadata.projected_events < 0) {
399
+ errors.push('projected_events deve ser inteiro não negativo.');
400
+ } else if (metadata.projected_events !== projectedEventLines) {
401
+ errors.push(`projected_events declara ${metadata.projected_events}, mas SHARED contém ${projectedEventLines} evento(s).`);
402
+ }
403
+ if (!Number.isInteger(metadata.omitted_events) || metadata.omitted_events < 0) {
404
+ errors.push('omitted_events deve ser inteiro não negativo.');
405
+ }
406
+ if (metadata.projection_mode === 'complete' && metadata.omitted_events !== 0) {
407
+ errors.push('projection_mode complete exige omitted_events igual a 0.');
408
+ }
409
+ if (metadata.projection_mode === 'bounded' && !(metadata.omitted_events > 0)) {
410
+ errors.push('projection_mode bounded exige omitted_events maior que 0.');
411
+ }
412
+ }
309
413
  for (const key of ['updated_at', 'review_after']) {
310
414
  if (typeof metadata[key] === 'string'
311
415
  && (!ISO_INSTANT.test(metadata[key]) || Number.isNaN(Date.parse(metadata[key])))) {
@@ -10,6 +10,7 @@ import {
10
10
  } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import {
13
+ parseSharedMemory,
13
14
  renderSharedMemory,
14
15
  sanitizeMemoryText,
15
16
  validateMemoryEvent,
@@ -1007,6 +1008,7 @@ export function prepareMemoryProjection(vaultBase, allEvents) {
1007
1008
  stateHash: reduced.stateHash,
1008
1009
  updatedAt,
1009
1010
  });
1011
+ const sharedMetadata = parseSharedMemory(shared).metadata;
1010
1012
  const candidates = reduced.candidates.map((item) => canonicalMemoryJson(item)).join('\n')
1011
1013
  + (reduced.candidates.length ? '\n' : '');
1012
1014
  return {
@@ -1018,6 +1020,8 @@ export function prepareMemoryProjection(vaultBase, allEvents) {
1018
1020
  stateHash: reduced.stateHash,
1019
1021
  checkpoint: reduced.checkpoint,
1020
1022
  candidates: reduced.candidates.length,
1023
+ projectedEvents: sharedMetadata.projected_events ?? reduced.activeEvents.length,
1024
+ omittedEvents: sharedMetadata.omitted_events ?? 0,
1021
1025
  };
1022
1026
  }
1023
1027
 
@@ -1,6 +1,8 @@
1
1
  import { readdirSync, readFileSync } from 'node:fs';
2
2
  import { basename, join, relative } from 'node:path';
3
- import { sanitizeMemoryText, validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
3
+ import {
4
+ parseSharedMemory, renderSharedMemory, sanitizeMemoryText, validateMemoryEvent, validateSharedMemory,
5
+ } from './memory-schema.mjs';
4
6
  import { deriveMemoryProjection } from './memory-store.mjs';
5
7
  import { assertVaultPathSafe } from './vault-path-safety.mjs';
6
8
  import { validateCore } from './validate-core.mjs';
@@ -161,6 +163,10 @@ function sharedEventIds(content) {
161
163
  return ids;
162
164
  }
163
165
 
166
+ function sameStringSet(left, right) {
167
+ return left.size === right.size && [...left].every((value) => right.has(value));
168
+ }
169
+
164
170
  function walkDecisionFiles(root, vaultBase, output = []) {
165
171
  let entries;
166
172
  try { entries = readdirSync(root, { withFileTypes: true }); } catch { return output; }
@@ -255,13 +261,41 @@ function semanticMemoryHealth(vaultBase, { ledger, shared, candidates }) {
255
261
  const errors = [];
256
262
  const warnings = [];
257
263
  const codes = [];
264
+ const metadata = shared.metadata || {};
265
+ const boundedDeclared = metadata.projection_mode === 'bounded';
266
+ let boundedProjection = false;
267
+ if (boundedDeclared) {
268
+ const expectedContent = renderSharedMemory({
269
+ revision: metadata.revision,
270
+ eventCursor: metadata.event_cursor,
271
+ stateHash: metadata.state_hash,
272
+ updatedAt: metadata.updated_at,
273
+ reviewAfter: metadata.review_after,
274
+ events: replay.activeEvents,
275
+ });
276
+ const expectedIds = sharedEventIds(expectedContent);
277
+ const expectedMetadata = parseSharedMemory(expectedContent).metadata;
278
+ boundedProjection = sameStringSet(projectedIds, expectedIds)
279
+ && metadata.projected_events === expectedMetadata.projected_events
280
+ && metadata.omitted_events === expectedMetadata.omitted_events;
281
+ if (!boundedProjection) {
282
+ codes.push('MEMORY_SEMANTIC_COVERAGE_MISSING');
283
+ errors.push('[MEMORY_SEMANTIC_COVERAGE_MISSING] SHARED declara bounded, mas IDs ou contagens não reproduzem a seleção do ledger.');
284
+ }
285
+ }
258
286
 
259
287
  if (missingKeys.length && placeholderOnly) {
260
288
  codes.push('MEMORY_SEMANTIC_PLACEHOLDER_ONLY');
261
289
  errors.push(`[MEMORY_SEMANTIC_PLACEHOLDER_ONLY] SHARED contém somente placeholders para ${activeKeys.length} chave(s) ativa(s); candidates=${candidateCount}.`);
262
290
  } else if (missingKeys.length) {
263
- codes.push('MEMORY_SEMANTIC_COVERAGE_MISSING');
264
- errors.push(`[MEMORY_SEMANTIC_COVERAGE_MISSING] SHARED não cobre ${missingKeys.length} chave(s) ativa(s): ${missingKeys.join(', ')}.`);
291
+ if (boundedProjection) {
292
+ warnings.push(`[MEMORY_SEMANTIC_BOUNDED_PROJECTION] SHARED omite deterministicamente ${metadata.omitted_events} evento(s) do ledger dentro do budget.`);
293
+ } else if (!boundedDeclared) {
294
+ codes.push('MEMORY_SEMANTIC_COVERAGE_MISSING');
295
+ errors.push(`[MEMORY_SEMANTIC_COVERAGE_MISSING] SHARED não cobre ${missingKeys.length} chave(s) ativa(s): ${missingKeys.join(', ')}.`);
296
+ }
297
+ } else if (boundedProjection) {
298
+ warnings.push(`[MEMORY_SEMANTIC_BOUNDED_PROJECTION] SHARED omite deterministicamente ${metadata.omitted_events} evento(s) do ledger dentro do budget.`);
265
299
  }
266
300
  if (unresolvedLinks.length) {
267
301
  codes.push('MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED');
@@ -280,6 +314,9 @@ function semanticMemoryHealth(vaultBase, { ledger, shared, candidates }) {
280
314
  } else if (codes.length) {
281
315
  status = 'degraded';
282
316
  code = codes[0];
317
+ } else if (boundedProjection) {
318
+ status = 'bounded';
319
+ code = 'MEMORY_SEMANTIC_BOUNDED_PROJECTION';
283
320
  }
284
321
 
285
322
  return {
package/src/context.mjs CHANGED
@@ -10,16 +10,23 @@ import {
10
10
  concurrentScopeConflicts,
11
11
  scopeForRegistry,
12
12
  } from '../hooks/project-scope.mjs';
13
+ import { readVaultMarker } from './project-vault.mjs';
14
+ import { sanitizeMemoryText } from '../packages/vault/src/memory-schema.mjs';
13
15
 
14
16
  export const CONTEXT_HELP = `wendkeep context <subcommand>
15
17
 
16
18
  switch <branch> [--create] [--session <id>] [--project <path>] [--vault <path>] [--json]
19
+ status --session <id> [--project <path>] [--vault <path>] [--json]
20
+ recover --session <id> --select <reserved|observed> --revision <n> --reason <text>
21
+ [--project <path>] [--vault <path>] [--json]
17
22
 
18
23
  Switches Git branch and the causal session scope together inside the same worktree.
19
24
  Without --session, exactly one active session must match the current scope.
25
+ Status inventories reserved/observed recovery candidates without selecting one.
26
+ Recover resolves a quarantined conflict only when the selected candidate still matches the checkout.
20
27
  `;
21
28
 
22
- const VALUE_OPTIONS = new Set(['--project', '--vault', '--session']);
29
+ const VALUE_OPTIONS = new Set(['--project', '--vault', '--session', '--select', '--revision', '--reason']);
23
30
  const FLAG_OPTIONS = new Set(['--create', '--json']);
24
31
 
25
32
  function contextError(code, message) {
@@ -121,6 +128,247 @@ function contextRevision(entry) {
121
128
  ? entry.context_revision : 0;
122
129
  }
123
130
 
131
+ function activeSessionEntry(registry, sessionId) {
132
+ const id = String(sessionId || '').trim();
133
+ if (!id) throw contextError('WENDKEEP_CONTEXT_SESSION', 'status requer --session <id>');
134
+ const entry = registry.sessions?.[id];
135
+ if (!entry || entry.status !== 'active') {
136
+ throw contextError('WENDKEEP_CONTEXT_SESSION', `sessão ativa não encontrada: ${id}`);
137
+ }
138
+ return { id, entry };
139
+ }
140
+
141
+ function validCandidateScope(scope, label) {
142
+ const required = [
143
+ 'projectId', 'projectRoot', 'repoRoot', 'remote', 'branch', 'worktree',
144
+ 'head', 'provider', 'sessionId',
145
+ ];
146
+ const complete = scope && typeof scope === 'object' && scope.complete === true
147
+ && required.every((field) => typeof scope[field] === 'string' && scope[field].trim());
148
+ const head = complete ? scope.head.trim() : '';
149
+ const branch = complete ? scope.branch.trim() : '';
150
+ const safeHead = /^[0-9a-f]{40,64}$/i.test(head);
151
+ const safeBranch = branch === `detached:${head}` || (
152
+ branch.length <= 255
153
+ && !branch.startsWith('/')
154
+ && !branch.endsWith('/')
155
+ && !branch.endsWith('.')
156
+ && !branch.endsWith('.lock')
157
+ && !branch.includes('..')
158
+ && !branch.includes('@{')
159
+ && !branch.includes('//')
160
+ && !/[\u0000-\u0020~^:?*[\]\\]/u.test(branch)
161
+ );
162
+ if (!complete || !safeHead || !safeBranch) {
163
+ throw contextError('WENDKEEP_CONTEXT_SCOPE_CONFLICT', `scope ${label} ausente ou incompleta`);
164
+ }
165
+ return scope;
166
+ }
167
+
168
+ function scopeMatchesActual(candidate, actual) {
169
+ return compareProjectScopes(candidate, actual).ok
170
+ && Boolean(candidate.head)
171
+ && candidate.head === actual.head;
172
+ }
173
+
174
+ function candidateSummary(id, candidate, actual) {
175
+ return {
176
+ id,
177
+ branch: candidate.branch || '',
178
+ head: candidate.head || '',
179
+ complete: candidate.complete === true,
180
+ matches_actual: scopeMatchesActual(candidate, actual),
181
+ };
182
+ }
183
+
184
+ function requiredRevision(value) {
185
+ const raw = String(value ?? '').trim();
186
+ const revision = Number(raw);
187
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(revision)) {
188
+ throw contextError('WENDKEEP_CONTEXT_ARGS', 'recover requer --revision <inteiro não negativo>');
189
+ }
190
+ return revision;
191
+ }
192
+
193
+ function recoveryReason(value) {
194
+ const raw = String(value || '').replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
195
+ if (!raw) throw contextError('WENDKEEP_CONTEXT_ARGS', 'recover requer --reason <texto>');
196
+ if (raw.length > 240) throw contextError('WENDKEEP_CONTEXT_ARGS', '--reason excede 240 caracteres');
197
+ return sanitizeMemoryText(raw).replace(/\s+/g, ' ').trim();
198
+ }
199
+
200
+ function recoverySelection(value) {
201
+ const selected = String(value || '').trim();
202
+ if (!['reserved', 'observed'].includes(selected)) {
203
+ throw contextError('WENDKEEP_CONTEXT_ARGS', 'recover requer --select <reserved|observed>');
204
+ }
205
+ return selected;
206
+ }
207
+
208
+ function scopeIdentityMismatches(reserved, observed) {
209
+ const fields = ['projectId', 'remote', 'provider', 'sessionId'];
210
+ return fields.filter((field) => String(reserved?.[field] || '') !== String(observed?.[field] || ''));
211
+ }
212
+
213
+ function validateRecoveryIdentity(vaultBase, entry, sessionId, reserved, observed = null) {
214
+ const mismatches = observed ? scopeIdentityMismatches(reserved, observed) : [];
215
+ const candidates = observed ? [reserved, observed] : [reserved];
216
+ if (candidates.some((candidate) => candidate.sessionId !== sessionId)) mismatches.push('sessionId');
217
+ if (entry.provider && candidates.some((candidate) => candidate.provider !== entry.provider)) {
218
+ mismatches.push('provider');
219
+ }
220
+ let marker = null;
221
+ try { marker = readVaultMarker(vaultBase)?.marker || null; } catch { /* fail closed below */ }
222
+ if (!marker?.projectId || candidates.some((candidate) => candidate.projectId !== marker.projectId)) {
223
+ mismatches.push('projectId');
224
+ }
225
+ if (mismatches.length) {
226
+ throw contextError(
227
+ 'WENDKEEP_CONTEXT_IDENTITY_CHANGED',
228
+ `candidatas divergem na identidade causal (${[...new Set(mismatches)].join(', ')})`,
229
+ );
230
+ }
231
+ }
232
+
233
+ function recoveryActualScope(projectRoot, expected, sessionId, spawn) {
234
+ return captureProjectScope({
235
+ input: { cwd: projectRoot },
236
+ projectRoot,
237
+ projectId: expected.projectId,
238
+ provider: expected.provider,
239
+ sessionId,
240
+ targetCwd: projectRoot,
241
+ spawn,
242
+ });
243
+ }
244
+
245
+ const CONFLICT_FIELDS = new Set([
246
+ 'scope.projectId', 'scope.projectRoot', 'scope.repoRoot', 'scope.remote', 'scope.branch',
247
+ 'scope.worktree', 'scope.provider', 'scope.sessionId', 'scope.incomplete',
248
+ ]);
249
+
250
+ function sanitizedConflictFields(value) {
251
+ if (!Array.isArray(value) || value.some((field) => !CONFLICT_FIELDS.has(String(field)))) {
252
+ throw contextError('WENDKEEP_CONTEXT_SCOPE_CONFLICT', 'campos de conflito ausentes ou inválidos');
253
+ }
254
+ return [...new Set(value.map(String))].sort();
255
+ }
256
+
257
+ function receiptScope(candidate) {
258
+ return { branch: candidate.branch || '', head: candidate.head || '' };
259
+ }
260
+
261
+ export function inspectSessionContext({
262
+ vaultBase,
263
+ projectRoot = process.cwd(),
264
+ sessionId = '',
265
+ spawn = spawnSync,
266
+ } = {}) {
267
+ const registry = readSessionRegistry(vaultBase);
268
+ const selected = activeSessionEntry(registry, sessionId);
269
+ const reserved = validCandidateScope(selected.entry.project_scope, 'reserved');
270
+ const conflict = selected.entry.project_scope_conflict === true;
271
+ const observed = conflict
272
+ ? validCandidateScope(selected.entry.project_scope_observed, 'observed')
273
+ : null;
274
+ validateRecoveryIdentity(vaultBase, selected.entry, selected.id, reserved, observed);
275
+ const actual = recoveryActualScope(projectRoot, reserved, selected.id, spawn);
276
+ const candidates = [candidateSummary('reserved', reserved, actual)];
277
+ if (observed) candidates.push(candidateSummary('observed', observed, actual));
278
+ return {
279
+ status: conflict ? 'conflict' : 'healthy',
280
+ session_id: selected.id,
281
+ revision: contextRevision(selected.entry),
282
+ conflict,
283
+ conflict_fields: conflict ? sanitizedConflictFields(selected.entry.project_scope_conflict_fields) : [],
284
+ candidates,
285
+ };
286
+ }
287
+
288
+ export function recoverSessionContext({
289
+ vaultBase,
290
+ projectRoot = process.cwd(),
291
+ sessionId = '',
292
+ select = '',
293
+ revision,
294
+ reason = '',
295
+ spawn = spawnSync,
296
+ mutateRegistry = mutateSessionRegistry,
297
+ now = () => new Date(),
298
+ } = {}) {
299
+ const requestedSessionId = String(sessionId || '').trim();
300
+ if (!requestedSessionId) throw contextError('WENDKEEP_CONTEXT_SESSION', 'recover requer --session <id>');
301
+ const selectedId = recoverySelection(select);
302
+ const expectedRevision = requiredRevision(revision);
303
+ const safeReason = recoveryReason(reason);
304
+
305
+ return mutateRegistry(vaultBase, (registry) => {
306
+ const selected = activeSessionEntry(registry, requestedSessionId);
307
+ const entry = selected.entry;
308
+ if (entry.project_scope_conflict !== true) {
309
+ throw contextError('WENDKEEP_CONTEXT_SCOPE_CONFLICT', 'a sessão não possui conflito de scope ativo');
310
+ }
311
+ const currentRevision = contextRevision(entry);
312
+ if (currentRevision !== expectedRevision) {
313
+ throw contextError(
314
+ 'WENDKEEP_CONTEXT_CAS_MISMATCH',
315
+ `context_revision mudou de ${expectedRevision} para ${currentRevision}; inspecione o status novamente`,
316
+ );
317
+ }
318
+ const reserved = validCandidateScope(entry.project_scope, 'reserved');
319
+ const observed = validCandidateScope(entry.project_scope_observed, 'observed');
320
+ validateRecoveryIdentity(vaultBase, entry, selected.id, reserved, observed);
321
+ const candidate = selectedId === 'reserved' ? reserved : observed;
322
+ const actual = recoveryActualScope(projectRoot, candidate, selected.id, spawn);
323
+ if (!scopeMatchesActual(candidate, actual)) {
324
+ throw contextError(
325
+ 'WENDKEEP_CONTEXT_SCOPE_MISMATCH',
326
+ `a candidata ${selectedId} não corresponde integralmente ao checkout atual`,
327
+ );
328
+ }
329
+
330
+ const nextRevision = currentRevision + 1;
331
+ const at = now().toISOString();
332
+ const receipt = {
333
+ revision: nextRevision,
334
+ operation: 'recover',
335
+ selected: selectedId,
336
+ from: {
337
+ reserved: receiptScope(reserved),
338
+ observed: receiptScope(observed),
339
+ },
340
+ to: receiptScope(actual),
341
+ actor: { provider: candidate.provider || entry.provider || '', session_id: selected.id },
342
+ reason: safeReason,
343
+ at,
344
+ };
345
+ const {
346
+ project_scope_conflict: _conflict,
347
+ project_scope_conflict_fields: _conflictFields,
348
+ project_scope_observed: _observed,
349
+ ...preserved
350
+ } = entry;
351
+ registry.sessions[selected.id] = {
352
+ ...preserved,
353
+ project_scope: scopeForRegistry(actual, { authorizedActions: reserved.authorizedActions }),
354
+ context_revision: nextRevision,
355
+ context_recoveries: [
356
+ ...(Array.isArray(entry.context_recoveries) ? entry.context_recoveries : []),
357
+ receipt,
358
+ ],
359
+ last_seen: at,
360
+ updated_at: at,
361
+ };
362
+ return {
363
+ status: 'recovered',
364
+ session_id: selected.id,
365
+ selected: selectedId,
366
+ revision: nextRevision,
367
+ receipt,
368
+ };
369
+ });
370
+ }
371
+
124
372
  function resolveSessionId(vaultBase, projectRoot, requested, spawn) {
125
373
  const registry = readSessionRegistry(vaultBase);
126
374
  if (requested) {
@@ -271,6 +519,12 @@ export function switchSessionContext({
271
519
 
272
520
  function output(result, json) {
273
521
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
522
+ else if (Array.isArray(result.candidates)) {
523
+ process.stdout.write(`context ${result.status}: session ${result.session_id}; revision ${result.revision}; candidates ${result.candidates.map((candidate) => candidate.id).join(', ')}\n`);
524
+ }
525
+ else if (result.status === 'recovered') {
526
+ process.stdout.write(`context recovered: ${result.selected} selected (session ${result.session_id}; revision ${result.revision})\n`);
527
+ }
274
528
  else process.stdout.write(`context ${result.status}: ${result.branch} (session ${result.session_id}; revision ${result.revision})\n`);
275
529
  }
276
530
 
@@ -278,6 +532,27 @@ export function runContext(argv = []) {
278
532
  try {
279
533
  validateArgv(argv);
280
534
  const [sub, branch, ...extra] = positionals(argv);
535
+ if (sub === 'status' && !branch && !extra.length) {
536
+ const result = inspectSessionContext({
537
+ vaultBase: vaultOf(argv),
538
+ projectRoot: projectOf(argv),
539
+ sessionId: optionValue(argv, '--session'),
540
+ });
541
+ output(result, argv.includes('--json'));
542
+ return 0;
543
+ }
544
+ if (sub === 'recover' && !branch && !extra.length) {
545
+ const result = recoverSessionContext({
546
+ vaultBase: vaultOf(argv),
547
+ projectRoot: projectOf(argv),
548
+ sessionId: optionValue(argv, '--session'),
549
+ select: optionValue(argv, '--select'),
550
+ revision: optionValue(argv, '--revision'),
551
+ reason: optionValue(argv, '--reason'),
552
+ });
553
+ output(result, argv.includes('--json'));
554
+ return 0;
555
+ }
281
556
  if (sub !== 'switch' || !branch || extra.length) {
282
557
  throw contextError('WENDKEEP_CONTEXT_ARGS', 'use: wendkeep context switch <branch> [--create] [--session <id>]');
283
558
  }