wendkeep 0.76.5 → 0.76.6

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,17 @@ 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.6] — 2026-08-22
8
+
9
+ ### Fixed
10
+
11
+ - **Task lease causal.** `operating_profile_task` passa a pertencer ao `active_contexts` da
12
+ worktree/work session; rotas temporárias irmãs não se sobrescrevem nem ampliam autorização.
13
+ - **CLI e hooks escopados.** `profile route/status`, os hooks de operating profile e o consumo no
14
+ Stop resolvem a lease do chamador e preservam o sibling byte a byte por revision/CAS.
15
+ - **Fallback legado fail-closed.** Vault sem registry contextual conserva a lease na sessão;
16
+ depois da inicialização, uma lease global não é copiada nem aplicada sem identidade provada.
17
+
7
18
  ## [0.76.5] — 2026-08-22
8
19
 
9
20
  ### Added
package/README.en.md CHANGED
@@ -343,6 +343,11 @@ selects the caller explicitly. `CURRENT_DELIVERY` is only a derived projection w
343
343
  single, unambiguous context; hooks consult only the causal delivery, and an ID from another context
344
344
  fails with `WENDKEEP_DELIVERY_CONTEXT_MISMATCH`.
345
345
 
346
+ `operating_profile_task` belongs to the causal work session's active context. `profile route` and
347
+ `profile status --session <id>`, hooks, and Stop read/consume only that lease; a temporary route
348
+ never crosses into a sibling. Legacy session fallback exists only without `active_contexts`;
349
+ an initialized registry never copies a global authorization without proven identity.
350
+
346
351
  ```bash
347
352
  npx wendkeep delivery start release-0-74-0 --allow git:merge --allow git:push --allow publish --source-change <slug> --source-commit <sha> --session <id>
348
353
  npx wendkeep delivery status release-0-74-0 --session <id>
package/README.md CHANGED
@@ -343,6 +343,11 @@ selects the caller explicitly. `CURRENT_DELIVERY` is only a derived projection w
343
343
  single, unambiguous context; hooks consult only the causal delivery, and an ID from another context
344
344
  fails with `WENDKEEP_DELIVERY_CONTEXT_MISMATCH`.
345
345
 
346
+ `operating_profile_task` belongs to the causal work session's active context. `profile route` and
347
+ `profile status --session <id>`, hooks, and Stop read/consume only that lease; a temporary route
348
+ never crosses into a sibling. Legacy session fallback exists only without `active_contexts`;
349
+ an initialized registry never copies a global authorization without proven identity.
350
+
346
351
  ```bash
347
352
  npx wendkeep delivery start release-0-74-0 --allow git:merge --allow git:push --allow publish --source-change <slug> --source-commit <sha> --session <id>
348
353
  npx wendkeep delivery status release-0-74-0 --session <id>
@@ -130,6 +130,12 @@ ownership to the native LLM harness.
130
130
  session identity.
131
131
  - `profile route` requires `--session` and `--reason`, accepts only the four adaptive profiles,
132
132
  and records lease id, reason, turn/sequence, and timestamp without touching persistent profiles.
133
+ - `operating_profile_task` belongs to the causal session/work session's active context. `profile route`
134
+ creates and `profile status --session <id>` reads only that lease; hooks use the same caller
135
+ context and an accepted Stop consumes it by `lease_id` with revision/CAS without changing a sibling.
136
+ - Legacy fallback of the lease in the session exists only without `active_contexts`. With an
137
+ initialized registry, a global lease absent from the context is neither copied nor applied;
138
+ missing, ambiguous, or stale context fails closed without a partial mutation.
133
139
  - `.wendkeep.json` stays on `schemaVersion: 1`; the additive field is, for example,
134
140
  `"harness": { "profile": "GOVERN" }`. A legacy binding without it also resolves to `GOVERN`.
135
141
  - A corrupt binding never means `OFF`. When the payload or legacy integration identifies one
@@ -131,6 +131,12 @@ harness nativo da LLM.
131
131
  projeto; com `--session`, grava override, origem e timestamp sem trocar a identidade da sessão.
132
132
  - `profile route` exige `--session` e `--reason`, aceita somente os quatro perfis adaptativos e
133
133
  grava `lease_id`, motivo, turno/sequência e timestamp sem tocar no perfil persistente.
134
+ - `operating_profile_task` pertence ao active context da work session/sessão causal. `profile route`
135
+ cria e `profile status --session <id>` consulta somente essa lease; os hooks usam o mesmo contexto
136
+ do chamador e o Stop aceito consome por `lease_id` com revision/CAS, sem alterar o sibling.
137
+ - O fallback legado de lease na sessão existe apenas sem `active_contexts`. Com o registry
138
+ inicializado, uma lease global ausente do contexto não é copiada nem aplicada; contexto
139
+ ausente, ambíguo ou stale falha fechado sem mutação parcial.
134
140
  - `.wendkeep.json` continua em `schemaVersion: 1`; o campo aditivo usa, por exemplo,
135
141
  `"harness": { "profile": "GOVERN" }`. Binding legado sem o campo também resolve `GOVERN`.
136
142
  - Binding corrompido nunca equivale a `OFF`. Quando o payload ou a integração legada identifica
@@ -139,6 +139,7 @@ export function mutateActiveContext(vaultBase, identity, updater, {
139
139
  expectedRevision,
140
140
  now = new Date().toISOString(),
141
141
  mutateRegistry = mutateSessionRegistry,
142
+ projectLegacy = true,
142
143
  } = {}) {
143
144
  const normalized = normalizeIdentity(identity);
144
145
  const key = activeContextKey(normalized);
@@ -191,8 +192,10 @@ export function mutateActiveContext(vaultBase, identity, updater, {
191
192
  registry.active_contexts = { ...contexts, [key]: next };
192
193
  return { key, context: structuredClone(next), registryRevision: registry.active_contexts_revision };
193
194
  });
194
- projectLegacyActiveChange(vaultBase);
195
- projectLegacyActiveDelivery(vaultBase);
195
+ if (projectLegacy) {
196
+ projectLegacyActiveChange(vaultBase);
197
+ projectLegacyActiveDelivery(vaultBase);
198
+ }
196
199
  return result;
197
200
  }
198
201
 
@@ -15,11 +15,11 @@ import {
15
15
  } from './operating-profile-runtime.mjs';
16
16
  import { consumeSessionTaskOperatingProfile } from './operating-profile-task-store.mjs';
17
17
 
18
- export function nagDecision(input, vaultBase, { profile = 'GOVERN' } = {}) {
18
+ export function nagDecision(input, vaultBase, { profile = 'GOVERN', context = null } = {}) {
19
19
  if (input && input.stop_hook_active) return null; // anti-loop: sempre primeiro
20
20
  const policy = hookProfilePolicy(profile);
21
21
  if (!policy.harness) return null;
22
- const gate = quickGateState(vaultBase);
22
+ const gate = quickGateState(vaultBase, { context });
23
23
  if (!gate || !gate.openTasks) return null;
24
24
  const sid = input?.session_id || input?.sessionId || '';
25
25
  const sentinelId = profileSentinelId(sid, profile);
@@ -39,12 +39,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
39
39
  ? null
40
40
  : runtime.bindingError
41
41
  ? { decision: 'block', reason: profileRuntimeError(runtime.bindingError) }
42
- : nagDecision(input, runtime.vaultBase, { profile: runtime.profile });
42
+ : nagDecision(input, runtime.vaultBase, {
43
+ profile: runtime.profile,
44
+ context: runtime.activeContext,
45
+ });
43
46
  if (!decision && runtime.taskLease?.state === 'active') {
44
47
  consumeSessionTaskOperatingProfile(
45
48
  runtime.vaultBase,
46
49
  runtime.identity?.canonicalConversationId || input?.session_id || input?.sessionId || '',
47
50
  runtime.taskLease.lease_id,
51
+ { context: runtime.activeContext },
48
52
  );
49
53
  }
50
54
  writeHookOutput(decision || {});
@@ -11,6 +11,8 @@ import {
11
11
  import { findProjectBinding, resolveProjectVault } from '../src/project-vault.mjs';
12
12
  import { readSessionRegistry } from './obsidian-common.mjs';
13
13
  import { resolveSessionIdentity } from './session-identity.mjs';
14
+ import { resolveCommandActiveContext } from '../src/active-context-runtime.mjs';
15
+ import { sessionTaskOperatingProfile } from './operating-profile-task-store.mjs';
14
16
 
15
17
  function bindingDiagnostic(error, configPath = '') {
16
18
  return {
@@ -152,9 +154,26 @@ export function resolveHookOperatingProfile({
152
154
  const entry = identity.state === 'resolved'
153
155
  ? readSessionRegistry(vaultResolution.base).sessions?.[identity.canonicalConversationId] || null
154
156
  : null;
157
+ let activeContext = null;
158
+ let contextError = null;
159
+ if (!bindingError && identity.state === 'resolved') {
160
+ try {
161
+ activeContext = resolveCommandActiveContext({
162
+ vaultBase: vaultResolution.base,
163
+ projectRoot: input?.cwd || vaultResolution.projectRoot || process.cwd(),
164
+ sessionId: identity.canonicalConversationId,
165
+ });
166
+ } catch (error) {
167
+ contextError = bindingDiagnostic(error);
168
+ }
169
+ }
155
170
  const base = sessionOverride(entry) || project;
156
171
  const taskLease = evaluateTaskOperatingProfileLease(
157
- entry?.operating_profile_task,
172
+ contextError ? null : sessionTaskOperatingProfile(
173
+ vaultResolution.base,
174
+ identity.canonicalConversationId || '',
175
+ { context: activeContext },
176
+ ),
158
177
  taskLeaseContext(entry, input, identity.canonicalConversationId || ''),
159
178
  );
160
179
  const selected = taskLease.state === 'active'
@@ -176,6 +195,8 @@ export function resolveHookOperatingProfile({
176
195
  baseProfile: base.profile,
177
196
  baseSource: base.source,
178
197
  taskLease,
198
+ activeContext,
199
+ ...(contextError ? { contextError } : {}),
179
200
  resolution: vaultResolution,
180
201
  ...(bindingError ? { bindingError } : {}),
181
202
  };
@@ -3,7 +3,8 @@ import { randomUUID } from 'node:crypto';
3
3
  import {
4
4
  createTaskOperatingProfileLease,
5
5
  } from '../src/operating-profile.mjs';
6
- import { mutateSessionRegistry } from './obsidian-common.mjs';
6
+ import { mutateSessionRegistry, readSessionRegistry } from './obsidian-common.mjs';
7
+ import { mutateActiveContext, resolveActiveContext } from './active-context-store.mjs';
7
8
 
8
9
  function isoTimestamp(now) {
9
10
  if (typeof now === 'string') return now;
@@ -17,33 +18,93 @@ function missingSessionError(sessionId) {
17
18
  return error;
18
19
  }
19
20
 
21
+ function activeContextRequiredError() {
22
+ const error = new Error('active context causal é obrigatório quando o registry contextual está inicializado');
23
+ error.code = 'WENDKEEP_ACTIVE_CONTEXT_REQUIRED';
24
+ return error;
25
+ }
26
+
27
+ function hasActiveContextRegistry(registry) {
28
+ return Boolean(registry && (
29
+ Object.hasOwn(registry, 'active_contexts')
30
+ || Object.hasOwn(registry, 'active_contexts_schema')
31
+ || Object.hasOwn(registry, 'active_contexts_revision')
32
+ ));
33
+ }
34
+
35
+ function leaseFromSession(current, profile, { reason, leaseId, issuedAt, sessionId }) {
36
+ const turnId = typeof current.last_prompt_turn_id === 'string'
37
+ ? current.last_prompt_turn_id.trim()
38
+ : '';
39
+ const hasRegisteredTurn = Boolean(
40
+ turnId
41
+ && current.turn_sequences
42
+ && Object.hasOwn(current.turn_sequences, turnId)
43
+ && current.turn_sequences[turnId] === current.last_turn_sequence
44
+ );
45
+ return createTaskOperatingProfileLease({
46
+ profile,
47
+ reason,
48
+ sessionId,
49
+ turnId,
50
+ turnSequence: hasRegisteredTurn ? current.last_turn_sequence : undefined,
51
+ leaseId,
52
+ issuedAt,
53
+ });
54
+ }
55
+
56
+ export function sessionTaskOperatingProfile(vaultBase, sessionId, { context = null } = {}) {
57
+ if (context) {
58
+ try { return resolveActiveContext(vaultBase, context).operating_profile_task || null; }
59
+ catch (error) {
60
+ if (error?.code === 'WENDKEEP_ACTIVE_CONTEXT_NOT_FOUND') {
61
+ const registry = readSessionRegistry(vaultBase);
62
+ if (hasActiveContextRegistry(registry)) return null;
63
+ return registry.sessions?.[sessionId]?.operating_profile_task || null;
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ const registry = readSessionRegistry(vaultBase);
69
+ if (hasActiveContextRegistry(registry)) return null;
70
+ return registry.sessions?.[sessionId]?.operating_profile_task || null;
71
+ }
72
+
20
73
  export function setSessionTaskOperatingProfile(vaultBase, sessionId, profile, {
21
74
  reason,
22
75
  leaseId = randomUUID(),
23
76
  now,
77
+ context = null,
78
+ mutateContext = mutateActiveContext,
24
79
  } = {}) {
25
80
  const issuedAt = isoTimestamp(now);
81
+ if (context) {
82
+ let lease;
83
+ mutateContext(vaultBase, context, (active) => ({
84
+ ...active,
85
+ operating_profile_task: lease,
86
+ state: 'active',
87
+ }), {
88
+ now: issuedAt,
89
+ projectLegacy: false,
90
+ mutateRegistry: (base, mutator) => mutateSessionRegistry(base, (registry) => {
91
+ const sessions = registry.sessions || (registry.sessions = {});
92
+ if (!Object.hasOwn(sessions, sessionId)) throw missingSessionError(sessionId);
93
+ lease = leaseFromSession(sessions[sessionId], profile, {
94
+ reason, leaseId, issuedAt, sessionId,
95
+ });
96
+ return mutator(registry);
97
+ }),
98
+ });
99
+ return lease;
100
+ }
26
101
  return mutateSessionRegistry(vaultBase, (registry) => {
102
+ if (hasActiveContextRegistry(registry)) throw activeContextRequiredError();
27
103
  const sessions = registry.sessions || (registry.sessions = {});
28
104
  if (!Object.hasOwn(sessions, sessionId)) throw missingSessionError(sessionId);
29
105
  const current = sessions[sessionId];
30
- const turnId = typeof current.last_prompt_turn_id === 'string'
31
- ? current.last_prompt_turn_id.trim()
32
- : '';
33
- const hasRegisteredTurn = Boolean(
34
- turnId
35
- && current.turn_sequences
36
- && Object.hasOwn(current.turn_sequences, turnId)
37
- && current.turn_sequences[turnId] === current.last_turn_sequence
38
- );
39
- const lease = createTaskOperatingProfileLease({
40
- profile,
41
- reason,
42
- sessionId,
43
- turnId,
44
- turnSequence: hasRegisteredTurn ? current.last_turn_sequence : undefined,
45
- leaseId,
46
- issuedAt,
106
+ const lease = leaseFromSession(current, profile, {
107
+ reason, leaseId, issuedAt, sessionId,
47
108
  });
48
109
  sessions[sessionId] = {
49
110
  ...current,
@@ -56,10 +117,53 @@ export function setSessionTaskOperatingProfile(vaultBase, sessionId, profile, {
56
117
 
57
118
  export function consumeSessionTaskOperatingProfile(vaultBase, sessionId, leaseId, {
58
119
  now,
120
+ context = null,
121
+ mutateContext = mutateActiveContext,
59
122
  } = {}) {
60
123
  if (!sessionId || !leaseId) return false;
61
124
  const consumedAt = isoTimestamp(now);
125
+ if (context) {
126
+ let current;
127
+ try { current = resolveActiveContext(vaultBase, context); }
128
+ catch (error) {
129
+ if (error?.code !== 'WENDKEEP_ACTIVE_CONTEXT_NOT_FOUND') throw error;
130
+ const registry = readSessionRegistry(vaultBase);
131
+ if (hasActiveContextRegistry(registry)) return false;
132
+ return consumeSessionTaskOperatingProfile(vaultBase, sessionId, leaseId, {
133
+ now: consumedAt,
134
+ });
135
+ }
136
+ const lease = current.operating_profile_task;
137
+ if (!lease || lease.state !== 'active' || lease.lease_id !== leaseId) return false;
138
+ let consumed = false;
139
+ try {
140
+ mutateContext(vaultBase, context, (active) => {
141
+ const activeLease = active.operating_profile_task;
142
+ if (!activeLease || activeLease.state !== 'active' || activeLease.lease_id !== leaseId) {
143
+ return active;
144
+ }
145
+ consumed = true;
146
+ return {
147
+ ...active,
148
+ operating_profile_task: {
149
+ ...activeLease,
150
+ state: 'consumed',
151
+ consumed_at: consumedAt,
152
+ },
153
+ };
154
+ }, {
155
+ expectedRevision: current.revision,
156
+ now: consumedAt,
157
+ projectLegacy: false,
158
+ });
159
+ } catch (error) {
160
+ if (error?.code === 'WENDKEEP_ACTIVE_CONTEXT_STALE') return false;
161
+ throw error;
162
+ }
163
+ return consumed;
164
+ }
62
165
  return mutateSessionRegistry(vaultBase, (registry) => {
166
+ if (hasActiveContextRegistry(registry)) throw activeContextRequiredError();
63
167
  const current = registry.sessions?.[sessionId];
64
168
  const lease = current?.operating_profile_task;
65
169
  if (!lease || lease.state !== 'active' || lease.lease_id !== leaseId) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.76.5",
3
+ "version": "0.76.6",
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": [
package/src/profile.mjs CHANGED
@@ -8,7 +8,11 @@ import {
8
8
  resolveOperatingProfile,
9
9
  setOperatingProfile,
10
10
  } from './operating-profile.mjs';
11
- import { setSessionTaskOperatingProfile } from '../hooks/operating-profile-task-store.mjs';
11
+ import {
12
+ sessionTaskOperatingProfile,
13
+ setSessionTaskOperatingProfile,
14
+ } from '../hooks/operating-profile-task-store.mjs';
15
+ import { resolveCommandActiveContext } from './active-context-runtime.mjs';
12
16
  import { resolve } from 'node:path';
13
17
  import { findProjectBinding, resolveProjectVault, updateProjectBinding } from './project-vault.mjs';
14
18
 
@@ -163,12 +167,13 @@ function sessionProfile(vaultBase, sessionId, projectResolved) {
163
167
  return sessionBaseProfile(sessions[sessionId], projectResolved);
164
168
  }
165
169
 
166
- function sessionProfileStatus(vaultBase, sessionId, projectResolved) {
170
+ function sessionProfileStatus(vaultBase, sessionId, projectResolved, { context = null } = {}) {
167
171
  const sessions = readSessionRegistry(vaultBase).sessions || {};
168
172
  if (!Object.hasOwn(sessions, sessionId)) throw new Error(`sessão não encontrada: ${sessionId}`);
169
173
  const entry = sessions[sessionId];
170
174
  const base = sessionBaseProfile(entry, projectResolved);
171
- const taskLease = evaluateTaskOperatingProfileLease(entry.operating_profile_task, {
175
+ const taskLease = evaluateTaskOperatingProfileLease(
176
+ sessionTaskOperatingProfile(vaultBase, sessionId, { context }), {
172
177
  sessionId,
173
178
  turnId: entry.last_prompt_turn_id || '',
174
179
  turnSequence: entry.last_turn_sequence,
@@ -183,6 +188,15 @@ function sessionProfileStatus(vaultBase, sessionId, projectResolved) {
183
188
  };
184
189
  }
185
190
 
191
+ function commandActiveContext(state, sessionId) {
192
+ if (!sessionId || state.resolved.bindingError) return null;
193
+ return resolveCommandActiveContext({
194
+ vaultBase: state.vaultBase,
195
+ projectRoot: state.resolved.projectRoot || process.cwd(),
196
+ sessionId,
197
+ });
198
+ }
199
+
186
200
  function sessionOutputPayload(effective, sessionId) {
187
201
  return {
188
202
  profile: effective.profile,
@@ -213,8 +227,9 @@ export function runProfile(argv = []) {
213
227
  if (sub === 'status' || sub === 'show') {
214
228
  if (args.length > 1) return fail(`${sub} não aceita argumentos posicionais adicionais`);
215
229
  try {
230
+ const activeContext = sessionId ? commandActiveContext(state, sessionId) : null;
216
231
  const effective = sessionId
217
- ? sessionProfileStatus(state.vaultBase, sessionId, projectResolved)
232
+ ? sessionProfileStatus(state.vaultBase, sessionId, projectResolved, { context: activeContext })
218
233
  : { profile: projectResolved.profile, source: projectResolved.source, scope: 'project' };
219
234
  output({
220
235
  ...(sessionId ? sessionOutputPayload(effective, sessionId) : {
@@ -235,11 +250,12 @@ export function runProfile(argv = []) {
235
250
  if (!reason) return fail('route requer --reason <text>');
236
251
  try {
237
252
  const base = sessionProfile(state.vaultBase, sessionId, projectResolved);
253
+ const activeContext = commandActiveContext(state, sessionId);
238
254
  const lease = setSessionTaskOperatingProfile(
239
255
  state.vaultBase,
240
256
  sessionId,
241
257
  args[1],
242
- { reason },
258
+ { reason, context: activeContext },
243
259
  );
244
260
  output({
245
261
  profile: lease.profile,