wendkeep 0.65.0 → 0.66.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/src/memory.mjs CHANGED
@@ -237,37 +237,191 @@ function readCandidates(vault) {
237
237
  .split('\n').filter(Boolean).map((line) => JSON.parse(line));
238
238
  }
239
239
 
240
- export function decideMemoryCandidate(vault, { action, candidateId, value } = {}) {
240
+ function priorCandidateDecision(vault, candidateId) {
241
+ return readMemoryLedger(vault).events.find(
242
+ (event) => event.candidate_decision?.candidate_id === candidateId,
243
+ ) || null;
244
+ }
245
+
246
+ function candidateEvent(candidate, eventId) {
247
+ const events = Array.isArray(candidate.events) ? candidate.events : [];
248
+ if (candidate.reason === 'conflict' && !eventId) {
249
+ throw new Error('Candidate de conflito exige eventId (--event na CLI).');
250
+ }
251
+ if (!eventId) return events.length === 1 ? events[0] : null;
252
+ const selected = events.find((event) => event.event_id === eventId);
253
+ if (!selected) throw new Error(`event_id ${eventId} não pertence ao candidate ${candidate.candidate_id}.`);
254
+ return selected;
255
+ }
256
+
257
+ function assertCompatibleDecision(prior, { action, eventId }) {
258
+ const decision = prior.candidate_decision;
259
+ const sameSelection = action !== 'promote'
260
+ || (decision.selected_event_id || null) === (eventId || null);
261
+ if (decision.action !== action || !sameSelection) {
262
+ throw new Error(`Candidate ${decision.candidate_id} já possui decisão incompatível (${decision.action}).`);
263
+ }
264
+ }
265
+
266
+ function matchesPromotedAttempt(attempt, selected) {
267
+ if (attempt?.memory_mode !== 'v2' || attempt.state !== 'projected'
268
+ || !Array.isArray(attempt.event_ids) || !attempt.event_ids.includes(selected.event_id)) return false;
269
+ if (attempt.activation_id && selected.activation_id
270
+ && attempt.activation_id !== selected.activation_id) return false;
271
+ if (Number.isInteger(attempt.activation_epoch) && Number.isInteger(selected.activation_epoch)
272
+ && attempt.activation_epoch !== selected.activation_epoch) return false;
273
+ if (Number.isInteger(attempt.turn_sequence) && Number.isInteger(selected.turn_sequence)
274
+ && attempt.turn_sequence !== selected.turn_sequence) return false;
275
+ if (attempt.canonical_session_id && selected.canonical_session_id
276
+ && attempt.canonical_session_id !== selected.canonical_session_id) return false;
277
+ return true;
278
+ }
279
+
280
+ function snapshotPromotedAttemptCheckpoints(vault, selected) {
281
+ if (!selected) return new Map();
282
+ const registry = readSessionRegistry(vault);
283
+ return new Map(Object.entries(registry.sessions || {})
284
+ .filter(([, entry]) => matchesPromotedAttempt(entry?.last_memory_attempt, selected))
285
+ .map(([sessionId, entry]) => [sessionId, {
286
+ attempt: attemptFingerprint(entry.last_memory_attempt),
287
+ checkpoint: memoryCheckpointFingerprint(entry),
288
+ }]));
289
+ }
290
+
291
+ function refreshPromotedAttemptCheckpoint(vault, {
292
+ candidateId, decisionEventId, selected, checkpoint, decidedAt, expectedAttempts,
293
+ }) {
294
+ if (!selected || !checkpoint || !expectedAttempts?.size) return 0;
295
+ return mutateSessionRegistry(vault, (registry) => {
296
+ let refreshed = 0;
297
+ for (const [sessionId, entry] of Object.entries(registry.sessions || {})) {
298
+ const expected = expectedAttempts.get(sessionId);
299
+ if (!expected) continue;
300
+ const attempt = entry?.last_memory_attempt;
301
+ if (attemptFingerprint(attempt) !== expected.attempt
302
+ || memoryCheckpointFingerprint(entry) !== expected.checkpoint) continue;
303
+
304
+ const alreadyAudited = (entry.memory_candidate_decisions || [])
305
+ .some((audit) => audit.decision_event_id === decisionEventId);
306
+ if (alreadyAudited && sameCheckpoint(attempt.checkpoint, checkpoint)
307
+ && sameCheckpoint(entry.memory_checkpoint, checkpoint)) continue;
308
+ const originalCheckpoint = cloneJson(attempt.checkpoint || entry.memory_checkpoint || null);
309
+ attempt.checkpoint = cloneJson(checkpoint);
310
+ entry.memory_checkpoint = cloneJson(checkpoint);
311
+ entry.memory_status = 'projected';
312
+ if (!alreadyAudited) {
313
+ entry.memory_candidate_decisions = [
314
+ ...(Array.isArray(entry.memory_candidate_decisions) ? entry.memory_candidate_decisions : []),
315
+ {
316
+ v: 1,
317
+ type: 'candidate_checkpoint_refreshed',
318
+ candidate_id: candidateId,
319
+ decision_event_id: decisionEventId,
320
+ selected_event_id: selected.event_id,
321
+ decided_at: decidedAt,
322
+ original_checkpoint: originalCheckpoint,
323
+ checkpoint: cloneJson(checkpoint),
324
+ },
325
+ ];
326
+ }
327
+ refreshed += 1;
328
+ }
329
+ return refreshed;
330
+ });
331
+ }
332
+
333
+ export function decideMemoryCandidate(vault, {
334
+ action, candidateId, value, eventId, beforeCheckpointRefresh,
335
+ } = {}) {
241
336
  if (!['promote', 'reject'].includes(action)) throw new TypeError('action deve ser promote ou reject.');
242
337
  if (!candidateId) throw new TypeError('candidateId é obrigatório.');
338
+ const preflight = projectMemoryOutbox(vault);
339
+ if (preflight.status === 'busy') return { status: 'busy', candidateId };
340
+ const prior = priorCandidateDecision(vault, candidateId);
341
+ if (prior) {
342
+ assertCompatibleDecision(prior, { action, eventId });
343
+ const selected = action === 'promote' && prior.candidate_decision.selected_event_id
344
+ ? readMemoryLedger(vault).events.find(
345
+ (event) => event.event_id === prior.candidate_decision.selected_event_id,
346
+ )
347
+ : null;
348
+ const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
349
+ beforeCheckpointRefresh?.();
350
+ const checkpointRefreshed = action === 'promote'
351
+ ? refreshPromotedAttemptCheckpoint(vault, {
352
+ candidateId,
353
+ decisionEventId: prior.event_id,
354
+ selected,
355
+ checkpoint: preflight.checkpoint,
356
+ decidedAt: prior.observed_at,
357
+ expectedAttempts,
358
+ })
359
+ : 0;
360
+ return {
361
+ status: action === 'promote' ? 'promoted' : 'rejected',
362
+ candidateId,
363
+ eventId: prior.event_id,
364
+ alreadyApplied: true,
365
+ checkpointRefreshed,
366
+ projection: preflight,
367
+ };
368
+ }
243
369
  const candidates = readCandidates(vault);
244
370
  const candidate = candidates.find((item) => item.candidate_id === candidateId);
245
371
  if (!candidate) throw new Error(`Candidate não encontrado: ${candidateId}`);
372
+ if (action === 'promote' && candidate.reason === 'blocked_by_core') {
373
+ throw new Error(`Candidate ${candidateId} está blocked_by_core; edite CORE ou rejeite o candidate.`);
374
+ }
375
+ const selected = action === 'promote' ? candidateEvent(candidate, eventId) : null;
376
+ const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
377
+ const selectedValue = selected?.value ?? value ?? candidate.value ?? candidate.proposed_value;
378
+ if (action === 'promote' && selectedValue === undefined) {
379
+ throw new Error(`Candidate ${candidateId} não contém valor promovível.`);
380
+ }
246
381
  const now = new Date().toISOString();
382
+ const decision = {
383
+ candidate_id: candidateId,
384
+ action,
385
+ event_ids: Array.isArray(candidate.event_ids) ? [...candidate.event_ids].sort() : [],
386
+ ...(selected ? { selected_event_id: selected.event_id } : {}),
387
+ };
247
388
  const event = {
248
389
  v: 1,
249
- event_id: `cli-${action}-${hash(candidateId).slice(0, 20)}`,
390
+ event_id: `cli-${action}-${hash(`${candidateId}\0${selected?.event_id || ''}`).slice(0, 20)}`,
250
391
  project_id: projectId(vault),
251
- memory_key: action === 'promote' ? candidate.memory_key : `candidate.rejected.${candidateId}`,
252
- operation: 'assert',
253
- value: sanitizeMemoryText(action === 'promote' ? (value ?? candidate.value ?? candidate.values?.[0] ?? '') : 'rejected'),
392
+ memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
393
+ operation: action === 'promote' && selected ? 'replace' : 'assert',
394
+ value: sanitizeMemoryText(action === 'promote' ? selectedValue : 'rejected'),
254
395
  authority: 'verified',
255
- activation_id: 'wendkeep-memory-cli',
256
- turn_sequence: 0,
396
+ activation_id: selected?.activation_id || 'wendkeep-memory-cli',
397
+ ...(Number.isInteger(selected?.activation_epoch) ? { activation_epoch: selected.activation_epoch } : {}),
398
+ turn_sequence: selected?.turn_sequence ?? 0,
257
399
  observed_at: now,
258
400
  evidence: [`candidate:${candidateId}`],
401
+ candidate_decision: decision,
402
+ ...(selected ? { supersedes: decision.event_ids } : {}),
259
403
  };
260
404
  enqueueMemoryEvent(vault, event);
261
405
  const projection = projectMemoryOutbox(vault);
262
406
  if (projection.status === 'busy') return { status: 'busy', candidateId };
263
- const remaining = candidates.filter((item) => item.candidate_id !== candidateId);
264
- const projected = readCandidates(vault);
265
- const merged = new Map([...remaining, ...projected].map((item) => [item.candidate_id, item]));
266
- writeVaultFileAtomic(
267
- vault, brainPath(vault, CANDIDATES), candidateText([...merged.values()]), 'utf8',
268
- { label: 'candidates após decisão humana' },
269
- );
270
- return { status: action === 'promote' ? 'promoted' : 'rejected', candidateId, eventId: event.event_id, projection };
407
+ beforeCheckpointRefresh?.();
408
+ const checkpointRefreshed = action === 'promote'
409
+ ? refreshPromotedAttemptCheckpoint(vault, {
410
+ candidateId,
411
+ decisionEventId: event.event_id,
412
+ selected,
413
+ checkpoint: projection.checkpoint,
414
+ decidedAt: now,
415
+ expectedAttempts,
416
+ })
417
+ : 0;
418
+ return {
419
+ status: action === 'promote' ? 'promoted' : 'rejected',
420
+ candidateId,
421
+ eventId: event.event_id,
422
+ checkpointRefreshed,
423
+ projection,
424
+ };
271
425
  }
272
426
 
273
427
  function cloneJson(value) {
@@ -1038,8 +1192,14 @@ export function runMemory(argv) {
1038
1192
  apply: reconcileArgs.apply,
1039
1193
  });
1040
1194
  }
1041
- else if (sub === 'promote' || sub === 'reject') result = decideMemoryCandidate(vault, { action: sub, candidateId: positional });
1042
- else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
1195
+ else if (sub === 'promote' || sub === 'reject') {
1196
+ const eventId = option(argv, '--event');
1197
+ if (sub === 'reject' && eventId) throw memoryUsageError('--event é permitido somente em memory promote.');
1198
+ result = decideMemoryCandidate(vault, {
1199
+ action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
1200
+ });
1201
+ }
1202
+ else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
1043
1203
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1044
1204
  if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
1045
1205
  else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;
package/src/taxonomy.mjs CHANGED
@@ -3,8 +3,31 @@ import {
3
3
  mcpServerEntry,
4
4
  selectMcpServers,
5
5
  } from '../packages/mcp/src/index.mjs';
6
+ import {
7
+ CHANGE_GATE_HOOKS,
8
+ CHANGE_NUDGE_HOOKS,
9
+ CODEX_MATCHER_EVENTS,
10
+ SESSION_HOOKS,
11
+ codexHookEntry,
12
+ codexHookSpecs,
13
+ hookCommand,
14
+ hookCommandLocal,
15
+ hookCommandLocalLegacy,
16
+ } from '../packages/integrations/src/host-hooks.mjs';
6
17
 
7
- export { MCP_SERVER_KEY, mcpServerEntry };
18
+ export {
19
+ CHANGE_GATE_HOOKS,
20
+ CHANGE_NUDGE_HOOKS,
21
+ CODEX_MATCHER_EVENTS,
22
+ MCP_SERVER_KEY,
23
+ SESSION_HOOKS,
24
+ codexHookEntry,
25
+ codexHookSpecs,
26
+ hookCommand,
27
+ hookCommandLocal,
28
+ hookCommandLocalLegacy,
29
+ mcpServerEntry,
30
+ };
8
31
 
9
32
  // Shared, data-only constants for the wendkeep installer and CLI.
10
33
  // Kept free of side effects so both bin/ and src/ can import it cheaply.
@@ -101,83 +124,6 @@ export const RUNNABLE_HOOKS = [
101
124
  'plan-capture',
102
125
  ];
103
126
 
104
- // The three Claude Code session hooks, expressed as `wendkeep hook <name>` so the
105
- // installed package is the single source of truth (update with `npm update wendkeep`,
106
- // no re-copying). Returned as a spec the merge logic folds into settings.json.
107
- export const SESSION_HOOKS = [
108
- // Memory + active-change injection. Runs FIRST on SessionStart (order -10, folds before
109
- // session-start) so the agent gets CORE + DIGEST + the active change + lessons as context.
110
- // matcher 'startup|clear|compact' re-injects after a compaction/clear, not only cold startup.
111
- // timeout 45 (was 15): measured ~4s warm via npx, but Windows startup contention (several npx
112
- // cold-starts at once — a sibling MCP took 26s in a real log) blew 15s and silently dropped the
113
- // memory injection for the whole session.
114
- { event: 'SessionStart', matcher: 'startup|clear|compact', name: 'brain-inject', timeout: 45, order: -10, codex: true, statusMessage: 'wendkeep: injecting memory + active change' },
115
- { event: 'SessionStart', matcher: 'startup', name: 'session-start', timeout: 30, codex: true, statusMessage: 'wendkeep: opening Obsidian session' },
116
- { event: 'Stop', matcher: null, name: 'session-stop', timeout: 60, codex: true, statusMessage: 'wendkeep: writing session checkpoint' },
117
- { event: 'UserPromptSubmit', matcher: null, name: 'session-ensure', timeout: 30, codex: true, statusMessage: 'wendkeep: ensuring active session' },
118
- // Capture an interactive decision (AskUserQuestion) — options + the user's choice — into 04-Decisões.
119
- // codex: AskUserQuestion is a Claude-only tool; there is nothing to match on.
120
- { event: 'PostToolUse', matcher: 'AskUserQuestion', name: 'decision-capture', timeout: 15, statusMessage: 'wendkeep: recording decision' },
121
- // Refresh subagent/workflow telemetry as each subagent finishes (resilient to a missed Stop).
122
- { event: 'SubagentStop', matcher: null, name: 'subagent-stop', timeout: 20, codex: true, statusMessage: 'wendkeep: subagent telemetry' },
123
- // Log plan/task progress into the active session note when a task is marked complete.
124
- // codex: TaskCompleted is not in Codex's hook event enum.
125
- { event: 'TaskCompleted', matcher: null, name: 'task-log', timeout: 10, statusMessage: 'wendkeep: plan progress' },
126
- ];
127
-
128
- export function hookCommand(name) {
129
- return `npx wendkeep hook ${name}`;
130
- }
131
-
132
- // Forma node-direta do comando de hook: 1 processo (~100-250ms) em vez dos 3 do npx (cold-start
133
- // de segundos no Windows). Usada pelos hooks de ALTA FREQUÊNCIA (por prompt / por tool-call)
134
- // quando o projeto tem wendkeep instalado localmente; o init decide (hookCommandFor).
135
- export function hookCommandLocal(name) {
136
- return `node "${'${CLAUDE_PROJECT_DIR}'}/node_modules/wendkeep/hooks/${name}.mjs"`;
137
- }
138
-
139
- export function hookCommandLocalLegacy(name) {
140
- return `node node_modules/wendkeep/hooks/${name}.mjs`;
141
- }
142
-
143
- // Hooks do lifecycle de change (0.31.0) — enforcement do loop a2. Nudges (contexto/aviso/
144
- // cobrança/captura de plano) e gate (deny/ask no Bash). Separados em dois grupos para
145
- // preservar a opção futura de gates opt-in; hoje o init wira TODOS por default.
146
- // preferLocal: alta frequência → invocação node-direta quando houver instalação local.
147
- export const CHANGE_NUDGE_HOOKS = [
148
- { event: 'UserPromptSubmit', matcher: null, name: 'change-context', timeout: 15, order: 10, preferLocal: true, codex: true, statusMessage: 'wendkeep: change ping' },
149
- // codex: reads tool_input.file_path, which Codex's apply_patch envelope does not carry.
150
- { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', name: 'change-warn', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change warn' },
151
- // codex: no ExitPlanMode equivalent — update_plan is the running TODO list, not an approval.
152
- { event: 'PostToolUse', matcher: 'ExitPlanMode', name: 'plan-capture', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: capturing approved plan' },
153
- { event: 'Stop', matcher: null, name: 'change-nag', timeout: 15, order: 10, preferLocal: true, codex: true, statusMessage: 'wendkeep: open tasks check' },
154
- ];
155
- export const CHANGE_GATE_HOOKS = [
156
- // codex: reads tool_input.command; Codex's exec sends a raw string and exec_command an argv,
157
- // so the guard would silently fail OPEN — worse than absent, since the docs would promise it.
158
- { event: 'PreToolUse', matcher: 'Bash', name: 'change-guard', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change gate' },
159
- ];
160
-
161
- // --- Codex projection ---------------------------------------------------------
162
- // Codex reads <project>/.codex/hooks.json (PascalCase event keys, same group shape as
163
- // Claude's settings.json). Only specs that opt in with `codex: true` are projected — the
164
- // rest carry a `// codex:` comment above them saying why. Three deltas from Claude, each
165
- // verified against codex-rs and each silent when wrong: the timeout key is `timeoutSec`
166
- // (`timeout` is not a field and falls through to a 600s default), there is no
167
- // ${CLAUDE_PROJECT_DIR} so `preferLocal` never applies, and matcher is only honoured on
168
- // SessionStart (UserPromptSubmit/Stop null it at discovery).
169
- export const CODEX_MATCHER_EVENTS = new Set(['SessionStart']);
170
-
171
- export function codexHookSpecs(specs) {
172
- return specs.filter((h) => h.codex === true && !h.command);
173
- }
174
-
175
- export function codexHookEntry(spec) {
176
- const entry = { type: 'command', command: hookCommand(spec.name), timeoutSec: spec.timeout };
177
- if (spec.statusMessage) entry.statusMessage = spec.statusMessage;
178
- return entry;
179
- }
180
-
181
127
  // --- companion plugins / MCP --------------------------------------------------
182
128
  // Optional tools wendkeep init can pin alongside the vault. Each is wired through
183
129
  // the MOST agent-agnostic mechanism it supports; the Claude Code plugin entry