wendkeep 0.68.0 → 0.68.5

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.
@@ -17,6 +17,7 @@ import {
17
17
  readControl,
18
18
  readHookInput,
19
19
  readSessionRegistry,
20
+ resolveVault,
20
21
  sessionFileName,
21
22
  sessionFolderRel,
22
23
  sessionSummaryFromInput,
@@ -32,6 +33,7 @@ import {
32
33
  yamlQuote,
33
34
  } from './obsidian-common.mjs';
34
35
  import { resolveSessionIdentity } from './session-identity.mjs';
36
+ import { captureProjectScope, projectScopePatch } from './project-scope.mjs';
35
37
 
36
38
  export function buildSessionContent({ relPath, now, summary = 'session', provider: providerId, sessionId = '' }) {
37
39
  const date = formatDate(now);
@@ -153,6 +155,7 @@ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
153
155
  function main() {
154
156
  const input = readHookInput();
155
157
  const vaultBase = getVaultBase(input);
158
+ const projectResolution = resolveVault(input);
156
159
  warnIfDefaultVault(input);
157
160
  const now = new Date();
158
161
  const provider = providerMeta();
@@ -169,11 +172,23 @@ function main() {
169
172
  }
170
173
  const sessionId = identity.canonicalConversationId;
171
174
  const transcriptPath = identity.transcriptPath;
175
+ const initialScope = captureProjectScope({
176
+ input,
177
+ projectRoot: projectResolution.projectRoot,
178
+ projectId: projectResolution.projectId,
179
+ provider: provider.id,
180
+ sessionId,
181
+ });
182
+ const scopePatchFor = (canonicalSessionId) => projectScopePatch(
183
+ readSessionRegistry(vaultBase).sessions?.[canonicalSessionId]?.project_scope,
184
+ { ...initialScope, sessionId: canonicalSessionId },
185
+ );
172
186
  const control = readControl(vaultBase);
173
187
  const activationId = input.activation_id || input.activationId || randomUUID();
174
188
  const activationStartedAt = formatLocalIso(now);
175
189
  const registerActivation = (canonicalSessionId, patch) => upsertSessionRegistry(vaultBase, canonicalSessionId, {
176
190
  ...patch,
191
+ ...scopePatchFor(canonicalSessionId),
177
192
  activation_id: activationId,
178
193
  activation_started_at: activationStartedAt,
179
194
  last_turn_sequence: 0,
@@ -16,6 +16,7 @@ import {
16
16
  readObservabilityStore,
17
17
  } from './session-observability-store.mjs';
18
18
  import { mutateSessionNote } from './session-note-io.mjs';
19
+ import { appendIterationOutcome } from './session-iteration-outcome.mjs';
19
20
  import { applyDerivedSections, provenanceSessions } from './derived-sections.mjs';
20
21
  import { buildSessionMemoryEvents, collectLifecycleEvidence } from './memory-handoff.mjs';
21
22
  import { enqueueMemoryEvent, projectMemoryOutbox } from './memory-store.mjs';
@@ -33,7 +34,15 @@ import {
33
34
  parseTranscriptContent,
34
35
  resolveTurnIdentity,
35
36
  } from '../packages/integrations/src/transcripts.mjs';
36
- import { sanitizeAssistantMessage } from '../packages/integrations/src/prompt-content.mjs';
37
+ import {
38
+ isSyntheticTranscriptText,
39
+ sanitizeAssistantMessage,
40
+ } from '../packages/integrations/src/prompt-content.mjs';
41
+
42
+ const UNRESOLVED_SESSION_ID = 'unresolved';
43
+ const UNRESOLVED_TURN_ID = 'unresolved-turn';
44
+ const ABORTED_TURN_NOTICE = 'wendkeep: Stop ignorado; o turno foi abortado no transcript.';
45
+
37
46
  export { resolveTurnIdentity };
38
47
  import {
39
48
  ensureDir,
@@ -43,7 +52,6 @@ import {
43
52
  formatLocalIso,
44
53
  getNextAdrNumber,
45
54
  getVaultBase,
46
- isBootstrapPrompt,
47
55
  warnIfDefaultVault,
48
56
  listMarkdownFiles,
49
57
  readControl,
@@ -61,28 +69,15 @@ import {
61
69
  hasTurnMarker,
62
70
  normalizeTurnMarkers,
63
71
  mutateSessionRegistry,
72
+ closeSessionActivation,
64
73
  resolveRegisteredTurnSequence,
65
74
  resolveStopActivation,
66
75
  applyStopActivation,
67
76
  } from './obsidian-common.mjs';
68
77
 
69
- // Tags injetadas pelo harness (não são fala humana): notificações de task,
70
- // reminders do sistema, stdout de comando local, wrappers de slash-command e
71
- // contexto da IDE. Nunca devem virar título/Pedido/Usuário de iteração no Vault.
72
- const SYNTHETIC_EVENT_TAG = /^<\/?(?:task-notification|system-reminder|local-command-stdout|local-command-stderr|command-message|command-name|command-args|user-prompt-submit-hook|ide_selection|ide_opened_file|environment_context)\b/i;
73
-
74
78
  function shouldIgnoreUserText(text) {
75
- const trimmed = String(text || '').trim();
76
- // Bootstrap detection is DELEGATED, not copied: this used to duplicate isBootstrapPrompt's
77
- // prefix list and the copies drifted — <recommended_plugins> made it into one and not the
78
- // other, so the title came out clean while the same block still showed as "Usuário" in the
79
- // conversation context. One filter, one place to add the next injected block.
80
- return SYNTHETIC_EVENT_TAG.test(trimmed)
81
- || isBootstrapPrompt(trimmed)
82
- // Harness utility meta-prompts (title generation, classifiers) — not real user turns; they
83
- // were leaking into note titles/summaries on import.
84
- || /^Generate a concise( UI)? title/i.test(trimmed)
85
- || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(trimmed);
79
+ // Tags injetadas pelo harness não são fala humana e nunca viram título/Pedido/Usuário.
80
+ return isSyntheticTranscriptText(text);
86
81
  }
87
82
 
88
83
  function addUnique(list, value) {
@@ -486,11 +481,13 @@ function relocateOrphanIterations(content) {
486
481
 
487
482
  export function insertIteration(sessionPath, block, turnId, tx, vaultBase = '') {
488
483
  let inserted = false;
484
+ let duplicate = false;
489
485
  // Sob lock: outro hook (subagent-stop) pode estar reescrevendo a mesma nota agora.
490
- mutateSessionNote(sessionPath, (original) => {
486
+ const mutation = mutateSessionNote(sessionPath, (original) => {
491
487
  // Self-heal: migrate any legacy `codex-turn` markers to the neutral name on this write.
492
488
  let content = normalizeTurnMarkers(original);
493
489
  if (hasTurnMarker(content, turnId)) {
490
+ duplicate = true;
494
491
  // Turno já registrado: ainda assim repara órfãos e seções dedicadas.
495
492
  return applyDedicatedSections(relocateOrphanIterations(content), tx);
496
493
  }
@@ -499,7 +496,24 @@ export function insertIteration(sessionPath, block, turnId, tx, vaultBase = '')
499
496
  inserted = true;
500
497
  return applyDedicatedSections(content, tx);
501
498
  }, { vaultBase });
502
- return inserted;
499
+ const result = duplicate
500
+ ? 'duplicate'
501
+ : mutation.written && inserted
502
+ ? 'inserted'
503
+ : mutation.reason === 'busy'
504
+ ? 'busy'
505
+ : 'failed';
506
+ return {
507
+ inserted: result === 'inserted',
508
+ confirmed: result === 'inserted' || result === 'duplicate',
509
+ written: Boolean(mutation.written),
510
+ result,
511
+ reason: mutation.reason || 'unknown',
512
+ };
513
+ }
514
+
515
+ export function confirmedLoggedTurnId(currentTurnId, candidateTurnId, projection) {
516
+ return projection?.confirmed ? candidateTurnId : currentTurnId;
503
517
  }
504
518
 
505
519
  function shouldFinalizeSession() {
@@ -993,10 +1007,14 @@ export async function refreshStopObservability({
993
1007
  readStore = readObservabilityStore,
994
1008
  resolveRoots = resolveObservabilityRoots,
995
1009
  materialize = materializeStopObservability,
1010
+ returnDetails = false,
996
1011
  } = {}) {
997
- if (causalStop && !causalStop.canPromoteMemory) return false;
1012
+ const outcome = (ok, status, reason = '') => (
1013
+ returnDetails ? { ok, status, reason } : ok
1014
+ );
1015
+ if (causalStop && !causalStop.canPromoteMemory) return outcome(false, 'skipped', 'causal-stop-not-promotable');
998
1016
  const deadlineAt = hookStartedAt + STOP_OBSERVABILITY_DEADLINE_MS;
999
- if (now() >= deadlineAt) return false;
1017
+ if (now() >= deadlineAt) return outcome(false, 'stale', 'deadline-before-refresh');
1000
1018
 
1001
1019
  const expected = expectedStopCausalSnapshot(entry, causalStop, turnSequence);
1002
1020
  const fresh = resolveEntry(vaultBase, input, entry?.provider);
@@ -1004,18 +1022,24 @@ export async function refreshStopObservability({
1004
1022
  || fresh.identity.canonicalConversationId !== sessionId
1005
1023
  || !fresh.entry?.session_file
1006
1024
  || fresh.entry.session_file !== entry?.session_file
1007
- || !sameStopCausalSnapshot(expected, stopEntryCausalSnapshot(fresh.entry))) return false;
1025
+ || !sameStopCausalSnapshot(expected, stopEntryCausalSnapshot(fresh.entry))) {
1026
+ return outcome(false, 'stale', 'causal-snapshot-changed');
1027
+ }
1008
1028
 
1009
1029
  const runtime = readStore(vaultBase, sessionId);
1010
1030
  const signalSequence = Number(runtime?.observability_signal_sequence || 0);
1011
1031
  if (expectedSignalSequence !== undefined
1012
- && signalSequence !== Number(expectedSignalSequence)) return false;
1013
- if (now() >= deadlineAt) return false;
1032
+ && signalSequence !== Number(expectedSignalSequence)) {
1033
+ return outcome(false, 'stale', 'signal-sequence-changed');
1034
+ }
1035
+ if (now() >= deadlineAt) return outcome(false, 'stale', 'deadline-before-materialize');
1014
1036
 
1015
1037
  const roots = fresh.identity.provider === 'codex'
1016
1038
  ? resolveRoots(fresh.entry)
1017
1039
  : stopClaudeRoots(fresh.entry);
1018
- if (roots?.state !== 'complete' || !roots.rootPaths?.length) return false;
1040
+ if (roots?.state !== 'complete' || !roots.rootPaths?.length) {
1041
+ return outcome(false, 'missing', 'observability-roots-missing');
1042
+ }
1019
1043
 
1020
1044
  const frontier = {
1021
1045
  canonical_session_id: sessionId,
@@ -1106,7 +1130,64 @@ export async function refreshStopObservability({
1106
1130
  withPublicationGuard,
1107
1131
  writeRegistryCheckpoint,
1108
1132
  }));
1109
- return !['stale', 'conflict', 'degraded', 'missing'].includes(result?.status);
1133
+ const status = result?.status || (result?.ok === false ? 'failed' : 'published');
1134
+ return outcome(!['stale', 'conflict', 'degraded', 'missing'].includes(status), status);
1135
+ }
1136
+
1137
+ export function recordStopOutcome(vaultBase, {
1138
+ sessionId = UNRESOLVED_SESSION_ID,
1139
+ transcriptId = '',
1140
+ turnId = UNRESOLVED_TURN_ID,
1141
+ turnSequence = 0,
1142
+ hook = 'Stop',
1143
+ stage = 'iteration',
1144
+ result = 'failed',
1145
+ lockStatus = 'unknown',
1146
+ durationMs = 0,
1147
+ reason = '',
1148
+ } = {}) {
1149
+ try {
1150
+ return appendIterationOutcome(vaultBase, {
1151
+ session_id: sessionId,
1152
+ transcript_id: transcriptId,
1153
+ turn_id: turnId,
1154
+ turn_sequence: turnSequence,
1155
+ hook,
1156
+ stage,
1157
+ result,
1158
+ lock_status: lockStatus,
1159
+ duration_ms: durationMs,
1160
+ occurred_at: new Date().toISOString(),
1161
+ reason,
1162
+ });
1163
+ } catch {
1164
+ // O ledger é diagnóstico: um problema nele nunca deve bloquear a sessão nem publicar o erro.
1165
+ return { written: false, result: 'failed', reason: 'outcome-ledger-write-failed' };
1166
+ }
1167
+ }
1168
+
1169
+ export function finalizeSessionRegistry(vaultBase, {
1170
+ sessionId = '',
1171
+ activationId = '',
1172
+ turnId = '',
1173
+ endedAt = '',
1174
+ } = {}) {
1175
+ let disposition = 'ambiguous';
1176
+ mutateSessionRegistry(vaultBase, (registry) => {
1177
+ const closed = closeSessionActivation(registry, {
1178
+ session_id: sessionId,
1179
+ activation_id: activationId,
1180
+ turn_id: turnId,
1181
+ ended_at: endedAt,
1182
+ });
1183
+ disposition = closed.stopDisposition;
1184
+ if (closed.stopDisposition === 'finalized') {
1185
+ registry.version = closed.registry.version;
1186
+ registry.sessions = closed.registry.sessions;
1187
+ }
1188
+ return null;
1189
+ });
1190
+ return disposition;
1110
1191
  }
1111
1192
 
1112
1193
  export async function main({
@@ -1128,6 +1209,14 @@ export async function main({
1128
1209
  const { identity, entry } = resolveSessionEntry(vaultBase, input);
1129
1210
  if (identity.state !== 'resolved' || !entry?.session_file) {
1130
1211
  const why = identity.diagnostics?.join('; ') || 'sessão não registrada';
1212
+ recordStopOutcome(vaultBase, {
1213
+ sessionId: identity.canonicalConversationId || input.session_id || 'unresolved',
1214
+ transcriptId: identity.transcriptId || input.transcript_id || '',
1215
+ turnId: input.turn_id || input.turnId || 'unresolved-turn',
1216
+ hook: input.hook_event_name || 'Stop',
1217
+ result: 'ambiguous',
1218
+ reason: `identity-unresolved: ${why}`,
1219
+ });
1131
1220
  process.stderr.write(`[wendkeep] Stop sem identidade segura: ${why}\n`);
1132
1221
  // stderr alone is a black hole here: Codex discards it, which is how an entire session of
1133
1222
  // lost turns produced no signal at all. systemMessage is what the UI actually shows.
@@ -1141,6 +1230,14 @@ export async function main({
1141
1230
  // no global (contaminaria nota alheia): pulamos e o backfill recupera depois.
1142
1231
  const sessionRel = entry.session_file;
1143
1232
  if (!sessionRel) {
1233
+ recordStopOutcome(vaultBase, {
1234
+ sessionId: identity.canonicalConversationId,
1235
+ transcriptId: identity.transcriptId,
1236
+ turnId: input.turn_id || input.turnId || 'unresolved-turn',
1237
+ hook: input.hook_event_name || 'Stop',
1238
+ result: 'ambiguous',
1239
+ reason: 'session-note-not-registered',
1240
+ });
1144
1241
  writeHookOutput({});
1145
1242
  return;
1146
1243
  }
@@ -1149,6 +1246,14 @@ export async function main({
1149
1246
  expectedType: 'file', label: 'nota de sessão do Stop',
1150
1247
  });
1151
1248
  if (!checkedSession.exists) {
1249
+ recordStopOutcome(vaultBase, {
1250
+ sessionId: identity.canonicalConversationId,
1251
+ transcriptId: identity.transcriptId,
1252
+ turnId: input.turn_id || input.turnId || 'unresolved-turn',
1253
+ hook: input.hook_event_name || 'Stop',
1254
+ result: 'failed',
1255
+ reason: 'session-note-missing-on-disk',
1256
+ });
1152
1257
  writeHookOutput({});
1153
1258
  return;
1154
1259
  }
@@ -1173,12 +1278,36 @@ export async function main({
1173
1278
  observedAt: new Date(0).toISOString(),
1174
1279
  });
1175
1280
  }
1281
+ recordStopOutcome(vaultBase, {
1282
+ sessionId,
1283
+ transcriptId: identity.transcriptId,
1284
+ turnId: requestedTurnId || 'unresolved-turn',
1285
+ turnSequence: Number(entry.last_turn_sequence || 0),
1286
+ hook: input.hook_event_name || 'Stop',
1287
+ result: 'ambiguous',
1288
+ reason: 'turn-not-proven-by-transcript',
1289
+ });
1176
1290
  const message = 'wendkeep: Stop ambiguous; o turno solicitado não foi provado pelo transcript.';
1177
1291
  process.stderr.write(`[wendkeep] ${message}\n`);
1178
1292
  writeHookOutput({ systemMessage: message });
1179
1293
  return;
1180
1294
  }
1181
1295
  const turnId = turnIdentity.id;
1296
+ const parsedTurn = tx.turns.find((turn) => turn.turnId === turnId);
1297
+ if (parsedTurn?.status === 'aborted') {
1298
+ recordStopOutcome(vaultBase, {
1299
+ sessionId,
1300
+ transcriptId: identity.transcriptId,
1301
+ turnId,
1302
+ turnSequence: turnIdentity.order,
1303
+ hook: input.hook_event_name || 'Stop',
1304
+ result: 'aborted',
1305
+ reason: 'transcript-turn-aborted',
1306
+ });
1307
+ process.stderr.write(`[wendkeep] ${ABORTED_TURN_NOTICE}\n`);
1308
+ writeHookOutput({ systemMessage: ABORTED_TURN_NOTICE });
1309
+ return;
1310
+ }
1182
1311
  const now = finalizing ? new Date() : null;
1183
1312
  const endedAt = finalizing ? formatLocalIso(now) : '';
1184
1313
  const causalStop = finalizing
@@ -1269,6 +1398,17 @@ export async function main({
1269
1398
  }
1270
1399
  if (shouldAbortStopAfterStaging(causalStop, memoryAttempt)) {
1271
1400
  const disposition = memoryAttempt?.disposition || causalStop?.stopDisposition || 'ambiguous';
1401
+ recordStopOutcome(vaultBase, {
1402
+ sessionId,
1403
+ transcriptId: identity.transcriptId,
1404
+ turnId,
1405
+ turnSequence: stopTurnSequence,
1406
+ hook: input.hook_event_name || 'Stop',
1407
+ result: disposition === 'duplicate' && memoryAttempt?.state === 'duplicate'
1408
+ ? 'duplicate'
1409
+ : 'skipped',
1410
+ reason: `staging-${disposition}`,
1411
+ });
1272
1412
  if (disposition === 'duplicate' && memoryAttempt?.state === 'duplicate') {
1273
1413
  writeHookOutput({});
1274
1414
  return;
@@ -1278,7 +1418,19 @@ export async function main({
1278
1418
  writeHookOutput({ systemMessage: message });
1279
1419
  return;
1280
1420
  }
1421
+ const iterationStartedAt = clock();
1281
1422
  const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx, vaultBase);
1423
+ recordStopOutcome(vaultBase, {
1424
+ sessionId,
1425
+ transcriptId: identity.transcriptId,
1426
+ turnId,
1427
+ turnSequence: stopTurnSequence,
1428
+ hook: input.hook_event_name || 'Stop',
1429
+ result: logged.result,
1430
+ lockStatus: logged.result === 'busy' ? 'busy' : logged.confirmed ? 'acquired' : 'unknown',
1431
+ durationMs: Math.max(0, clock() - iterationStartedAt),
1432
+ reason: `note-${logged.reason}`,
1433
+ });
1282
1434
 
1283
1435
  try {
1284
1436
  applyLinearLinks(sessionPath, tx, vaultBase, sessionRel);
@@ -1287,7 +1439,7 @@ export async function main({
1287
1439
  }
1288
1440
 
1289
1441
  try {
1290
- await refreshObservability({
1442
+ const refreshed = await refreshObservability({
1291
1443
  vaultBase,
1292
1444
  input,
1293
1445
  sessionPath,
@@ -1298,9 +1450,33 @@ export async function main({
1298
1450
  hookStartedAt,
1299
1451
  }, {
1300
1452
  now: clock,
1453
+ returnDetails: true,
1454
+ });
1455
+ const observability = typeof refreshed === 'boolean'
1456
+ ? { status: refreshed ? 'published' : 'failed', reason: 'legacy-boolean-result' }
1457
+ : (refreshed || { status: 'missing', reason: 'empty-result' });
1458
+ recordStopOutcome(vaultBase, {
1459
+ sessionId,
1460
+ transcriptId: identity.transcriptId,
1461
+ turnId,
1462
+ turnSequence: stopTurnSequence,
1463
+ hook: input.hook_event_name || 'Stop',
1464
+ stage: 'observability',
1465
+ result: observability.status,
1466
+ reason: observability.reason || 'observability-refresh',
1301
1467
  });
1302
1468
  } catch (error) {
1303
- process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1469
+ recordStopOutcome(vaultBase, {
1470
+ sessionId,
1471
+ transcriptId: identity.transcriptId,
1472
+ turnId,
1473
+ turnSequence: stopTurnSequence,
1474
+ hook: input.hook_event_name || 'Stop',
1475
+ stage: 'observability',
1476
+ result: 'failed',
1477
+ reason: 'observability-refresh-threw',
1478
+ });
1479
+ process.stderr.write(`[wendkeep] Observabilidade falhou: ${error.message}\n`);
1304
1480
  }
1305
1481
 
1306
1482
  if (!finalizing) {
@@ -1310,7 +1486,7 @@ export async function main({
1310
1486
  session_file: sessionRel,
1311
1487
  last_session_file: control.last_session_file || sessionRel,
1312
1488
  session_id: sessionId,
1313
- last_logged_turn_id: logged ? turnId : control.last_logged_turn_id,
1489
+ last_logged_turn_id: confirmedLoggedTurnId(control.last_logged_turn_id, turnId, logged),
1314
1490
  });
1315
1491
  upsertSessionRegistry(vaultBase, sessionId, {
1316
1492
  session_file: sessionRel,
@@ -1319,7 +1495,7 @@ export async function main({
1319
1495
  // (definido no SessionStart). Usar control.started_at contaminava com o
1320
1496
  // started_at de sessões concorrentes que sobrescrevem o ponteiro global.
1321
1497
  ended_at: '',
1322
- last_turn_id: logged ? turnId : control.last_logged_turn_id,
1498
+ last_turn_id: confirmedLoggedTurnId(control.last_logged_turn_id, turnId, logged),
1323
1499
  transcript_path: transcriptPath,
1324
1500
  transcript_id: identity.transcriptId,
1325
1501
  provider: identity.provider,
@@ -1333,7 +1509,6 @@ export async function main({
1333
1509
  createLinkedNotes(vaultBase, formatDate(now), sessionRel, tx),
1334
1510
  findLinkedDerivedNotes(vaultBase, sessionRel),
1335
1511
  );
1336
- finalizeSessionFile(sessionPath, tx, created, endedAt, vaultBase);
1337
1512
  // Link durável sessão↔change: uma seção "Mudanças" ANTES de `## Encerramento`. O append antigo
1338
1513
  // (após o Encerramento) era apagado a cada reopen por stripClosingSection, perdendo a aresta do
1339
1514
  // grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
@@ -1349,16 +1524,6 @@ export async function main({
1349
1524
  ), { vaultBase });
1350
1525
  }
1351
1526
  } catch { /* nunca derruba o Stop */ }
1352
- writeControl(vaultBase, {
1353
- status: 'inactive',
1354
- session_file: '',
1355
- last_session_file: sessionRel,
1356
- started_at: control.started_at,
1357
- ended_at: endedAt,
1358
- session_id: sessionId,
1359
- last_logged_turn_id: turnId,
1360
- });
1361
-
1362
1527
  const memoryResult = projectStopMemoryAttempt(vaultBase, memoryAttempt);
1363
1528
  if (memoryResult.status === 'legacy') {
1364
1529
  mutateSessionRegistry(vaultBase, (registry) => {
@@ -1378,6 +1543,62 @@ export async function main({
1378
1543
  recordStopMemoryOutcome(vaultBase, memoryAttempt, memoryResult);
1379
1544
  }
1380
1545
 
1546
+ // A memória compartilhada é um consumidor causal do fechamento. Se o projetor estiver
1547
+ // ocupado ou falhar, o outbox é a autoridade de retry e a sessão não pode ser marcada como
1548
+ // encerrada: fechar aqui criaria uma sessão `done` sem a publicação que o fechamento promete.
1549
+ if (memoryResult.status === 'degraded') {
1550
+ upsertSessionRegistry(vaultBase, sessionId, {
1551
+ session_file: sessionRel,
1552
+ status: 'active',
1553
+ ended_at: '',
1554
+ last_turn_id: confirmedLoggedTurnId(control.last_logged_turn_id, turnId, logged),
1555
+ transcript_path: transcriptPath,
1556
+ transcript_id: identity.transcriptId,
1557
+ provider: identity.provider,
1558
+ });
1559
+ writeControl(vaultBase, {
1560
+ ...control,
1561
+ status: 'active',
1562
+ session_file: sessionRel,
1563
+ last_session_file: sessionRel,
1564
+ session_id: sessionId,
1565
+ ended_at: '',
1566
+ last_logged_turn_id: confirmedLoggedTurnId(control.last_logged_turn_id, turnId, logged),
1567
+ });
1568
+ pingObsidianVault(input.obsidian_api_key);
1569
+ writeHookOutput({
1570
+ systemMessage: 'wendkeep: memória compartilhada degradada; outbox preservado para retry e sessão mantida ativa.',
1571
+ });
1572
+ return;
1573
+ }
1574
+
1575
+ finalizeSessionFile(sessionPath, tx, created, endedAt, vaultBase);
1576
+
1577
+ // Só fecha a activation depois do último consumidor causal (memória compartilhada) ter lido
1578
+ // a activation ainda ativa. O registry fechado alimenta a visão CURRENT_SESSION abaixo.
1579
+ let registryFinalization = 'ambiguous';
1580
+ try {
1581
+ registryFinalization = finalizeSessionRegistry(vaultBase, {
1582
+ sessionId,
1583
+ activationId: causalStop?.activationId || '',
1584
+ turnId,
1585
+ endedAt,
1586
+ });
1587
+ } catch (error) {
1588
+ process.stderr.write(`[wendkeep] fechamento do registry falhou: ${error.message}\n`);
1589
+ }
1590
+
1591
+ const registryClosed = registryFinalization === 'finalized' || registryFinalization === 'duplicate';
1592
+ writeControl(vaultBase, {
1593
+ status: registryClosed ? 'inactive' : 'active',
1594
+ session_file: registryClosed ? '' : sessionRel,
1595
+ last_session_file: sessionRel,
1596
+ started_at: control.started_at,
1597
+ ended_at: registryClosed ? endedAt : '',
1598
+ session_id: sessionId,
1599
+ last_logged_turn_id: confirmedLoggedTurnId(control.last_logged_turn_id, turnId, logged),
1600
+ });
1601
+
1381
1602
  // Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
1382
1603
  try {
1383
1604
  const rows = buildBrainIndex(vaultBase);
@@ -14,6 +14,7 @@ import {
14
14
  normalizeClaudeUsage,
15
15
  normalizeCodexUsage,
16
16
  } from '../packages/integrations/src/transcript-usage.mjs';
17
+ import { isSyntheticTranscriptText } from '../packages/integrations/src/prompt-content.mjs';
17
18
  export {
18
19
  addUsage,
19
20
  emptyTokenUsage,
@@ -277,17 +278,7 @@ function extractTextContent(content) {
277
278
  }
278
279
 
279
280
  function shouldIgnoreUserText(text) {
280
- return /^# AGENTS\.md instructions/.test(text)
281
- || text.startsWith('<environment_context>')
282
- || text.startsWith('<permissions instructions>')
283
- || text.startsWith('<system-reminder>')
284
- || text.startsWith('<local-command-caveat>')
285
- || text.startsWith('<command-name>')
286
- || text.startsWith('<ide_')
287
- || text.startsWith('## Memory')
288
- || text.includes('You are Codex, a coding agent')
289
- || /^Generate a concise( UI)? title/i.test(text)
290
- || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(text);
281
+ return isSyntheticTranscriptText(text);
291
282
  }
292
283
 
293
284
  function emptyParseResult(transcriptPath) {
@@ -371,6 +362,17 @@ function parseCodexLines(lines, result) {
371
362
  continue;
372
363
  }
373
364
 
365
+ if (event.type === 'response_item' && payload.type === 'custom_tool_call') {
366
+ result.toolCalls += 1;
367
+ addUnique(result.tools, payload.name || payload.tool_name || payload.tool || 'custom_tool_call');
368
+ continue;
369
+ }
370
+
371
+ if (event.type === 'response_item' && payload.type === 'custom_tool_call_output') {
372
+ // The output closes the call; it is not a second tool invocation and never a prompt.
373
+ continue;
374
+ }
375
+
374
376
  if (event.type === 'response_item' && payload.type === 'tool_search_call') {
375
377
  result.toolCalls += 1;
376
378
  addUnique(result.tools, 'tool_search');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.68.0",
3
+ "version": "0.68.5",
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": [
@@ -40,7 +40,7 @@
40
40
  "node": ">=18"
41
41
  },
42
42
  "scripts": {
43
- "check": "node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
43
+ "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
44
44
  "test": "node --test --test-concurrency=2",
45
45
  "release": "node scripts/release.mjs",
46
46
  "release:dry": "node scripts/release.mjs --dry-run",
@@ -70,6 +70,6 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "acorn": "^8.18.0",
73
- "wendkeep": "^0.67.1"
73
+ "wendkeep": "^0.68.0"
74
74
  }
75
75
  }
@@ -84,10 +84,16 @@ function immutableJson(vaultBase, path, value) {
84
84
  return { created: true, path };
85
85
  }
86
86
 
87
+ // Lock failures on the FLOW paths surface with the domain boundary code, like every other
88
+ // Vault write in the promotion saga — not with the physical-boundary default.
87
89
  function underSessionLock(vaultBase, sessionId, fn) {
88
90
  const root = sessionRoot(vaultBase, sessionId);
89
- mkdirVaultPath(vaultBase, root, { label: 'raiz runtime da sessão FLOW' });
90
- const outcome = withVaultPathLock(vaultBase, join(root, '.state'), fn, { timeoutMs: 5000 });
91
+ mkdirVaultPath(vaultBase, root, {
92
+ label: 'raiz runtime da sessão FLOW', code: 'FLOW_VAULT_BOUNDARY',
93
+ });
94
+ const outcome = withVaultPathLock(vaultBase, join(root, '.state'), fn, {
95
+ timeoutMs: 5000, code: 'FLOW_VAULT_BOUNDARY',
96
+ });
91
97
  if (typeof outcome === 'symbol') {
92
98
  const error = new Error(`store FLOW ocupado para sessão ${sessionId}`);
93
99
  error.code = 'FLOW_STORE_BUSY';
@@ -107,10 +113,13 @@ export function withFlowPromotionLock(vaultBase, changeSlug, fn, {
107
113
  } = {}) {
108
114
  const slug = safeId(changeSlug, 'change_slug');
109
115
  const root = join(vaultBase, '.brain', 'runtime', 'flow-promotion-locks');
110
- mkdirVaultPath(vaultBase, root, { label: 'raiz de locks de promoção FLOW' });
116
+ mkdirVaultPath(vaultBase, root, {
117
+ label: 'raiz de locks de promoção FLOW', code: 'FLOW_VAULT_BOUNDARY',
118
+ });
111
119
  const outcome = withVaultPathLock(vaultBase, join(root, slug), fn, {
112
120
  timeoutMs,
113
121
  staleMs: ownerGraceMs,
122
+ code: 'FLOW_VAULT_BOUNDARY',
114
123
  });
115
124
  if (outcome === VAULT_LOCK_BUSY) {
116
125
  const busy = new Error(`promoção FLOW ocupada para a change ${slug}`);
@@ -41,7 +41,7 @@ export function hookCommandLocalLegacy(name) {
41
41
  }
42
42
 
43
43
  // Hooks do lifecycle de change (0.31.0) — enforcement do loop a2. Nudges (contexto/aviso/
44
- // cobrança/captura de plano) e gate (deny/ask no Bash). Separados em dois grupos para
44
+ // cobrança/captura de plano) e gate (deny/ask no Bash/PreToolUse). Separados em dois grupos para
45
45
  // preservar a opção futura de gates opt-in; hoje o init wira TODOS por default.
46
46
  // preferLocal: alta frequência → invocação node-direta quando houver instalação local.
47
47
  export const CHANGE_NUDGE_HOOKS = [
@@ -54,9 +54,9 @@ export const CHANGE_NUDGE_HOOKS = [
54
54
  ];
55
55
 
56
56
  export const CHANGE_GATE_HOOKS = [
57
- // codex: reads tool_input.command; Codex's exec sends a raw string and exec_command an argv,
58
- // so the guard would silently fail OPEN worse than absent, since the docs would promise it.
59
- { event: 'PreToolUse', matcher: 'Bash', name: 'change-guard', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change gate' },
57
+ // The adapter accepts Codex's object, raw-string and argv forms and fails closed when a
58
+ // mutable target cannot be proven. Keep the matcher narrow to the payloads covered by tests.
59
+ { event: 'PreToolUse', matcher: 'Bash|exec_command|apply_patch|mcp__.*', name: 'change-guard', timeout: 10, order: 10, preferLocal: true, codex: true, statusMessage: 'wendkeep: change gate' },
60
60
  ];
61
61
 
62
62
  // --- Codex projection ---------------------------------------------------------
@@ -65,9 +65,9 @@ export const CHANGE_GATE_HOOKS = [
65
65
  // rest carry a `// codex:` comment above them saying why. Three deltas from Claude, each
66
66
  // verified against codex-rs and each silent when wrong: the timeout key is `timeoutSec`
67
67
  // (`timeout` is not a field and falls through to a 600s default), there is no
68
- // ${CLAUDE_PROJECT_DIR} so `preferLocal` never applies, and matcher is only honoured on
69
- // SessionStart (UserPromptSubmit/Stop null it at discovery).
70
- export const CODEX_MATCHER_EVENTS = new Set(['SessionStart']);
68
+ // ${CLAUDE_PROJECT_DIR} so `preferLocal` never applies, and matcher is honoured only for
69
+ // events whose host contract is covered by tests.
70
+ export const CODEX_MATCHER_EVENTS = new Set(['SessionStart', 'PreToolUse']);
71
71
 
72
72
  export function codexHookSpecs(specs) {
73
73
  return specs.filter((h) => h.codex === true && !h.command);