wendkeep 0.28.1 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ 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.29.1] — 2026-07-09
8
+
9
+ ### Added
10
+ - **`wendkeep import --rescan-decisions`** — re-scan **already-imported/captured** transcripts for
11
+ prose decisions only (no session re-import). For sessions imported before 0.29.0 whose rollouts
12
+ carry options-in-prose choices that were never captured. Walks the registry
13
+ (`session_file` + `transcript_path`), runs the same conservative extraction, dedupes by filename
14
+ — re-running is a no-op. `--limit N` / `--json` supported. New `rescanDecisions()` export.
15
+
16
+ ## [0.29.0] — 2026-07-09
17
+
18
+ Codex decision parity — agnostic prose-decision capture.
19
+
20
+ ### Added
21
+ - **Prose-decision capture** (`extractProseDecisions` / `captureProseDecisions` in
22
+ `hooks/decision-capture.mjs`, wired inside `createLinkedNotes`): Codex has no
23
+ `AskUserQuestion`-style tool — the agent asks in **prose**. A conservative pattern (assistant
24
+ message with ≥2 enumerated options ending in a question + a SHORT user reply) now produces the
25
+ **same decision note** the Claude hook writes (options + the user's choice, in `04-Decisões/`,
26
+ wikilinked to the session). One integration point covers **live Stop, `import` and backfill,
27
+ for every provider**. Validated on 144 real Codex rollouts: 6 genuine decisions extracted, no
28
+ visible false positives.
29
+
30
+ ### Notes (investigated, decided against)
31
+ - **Codex subagent telemetry**: real rollouts contain **no** subagent/parallel structure — nothing
32
+ to map; documented as not applicable.
33
+ - **Codex structured events** (`thread_goal_updated`, `task_complete`): goal payload ≈ the initial
34
+ prompt; task events are turn markers already parsed. No extra capture worth the noise.
35
+
7
36
  ## [0.28.1] — 2026-07-09
8
37
 
9
38
  Startup-contention fixes — root-caused from a real VSCode startup log where the memory injection
package/bin/wendkeep.mjs CHANGED
@@ -54,6 +54,7 @@ Usage:
54
54
  wendkeep import [opts] Backfill: import this project's past Claude + Codex sessions into
55
55
  the vault (deduped by session_id). --source all|claude|codex (default
56
56
  all) · --stamp-ids (backfill session_id in existing notes) ·
57
+ --rescan-decisions (capture prose decisions from already-imported transcripts) ·
57
58
  --from <dir> · --codex-from <dir> · --since <date> · --limit N ·
58
59
  --dry-run · --json.
59
60
  wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
@@ -68,6 +68,59 @@ ${blocks}
68
68
  `;
69
69
  }
70
70
 
71
+ // --- agnostic prose decisions (Codex parity) ---------------------------------
72
+ // Codex has no AskUserQuestion-style tool: the agent asks in PROSE and the user answers in the
73
+ // next message. Conservative extraction (validated on real rollouts): an assistant message with
74
+ // >=2 enumerated options that ends in a question, followed by a SHORT user reply (a choice, not a
75
+ // new instruction). Works over the turn conversations both parsers already build — so it covers
76
+ // Claude and Codex, live and import, without depending on any hook event.
77
+ export function extractProseDecisions(tx) {
78
+ const flat = [];
79
+ for (const t of tx?.turns || []) for (const c of t.conversation || []) flat.push(c);
80
+ const out = [];
81
+ for (let i = 0; i < flat.length - 1; i++) {
82
+ const q = flat[i]; const a = flat[i + 1];
83
+ if (q.role !== 'Assistente' || a.role !== 'Usuário') continue;
84
+ const text = String(q.text || '');
85
+ const answer = String(a.text || '').trim();
86
+ if (!answer || answer.length > 200) continue; // long reply = new instruction, not a choice
87
+ // question: the message's last non-empty lines must end with '?'
88
+ const lines = text.trim().split('\n').map((l) => l.trim()).filter(Boolean);
89
+ const lastLine = lines[lines.length - 1] || '';
90
+ if (!/\?\s*$/.test(lastLine)) continue;
91
+ // options: numbered/lettered lines, or bulleted bold labels
92
+ let options = [...text.matchAll(/^\s*(?:\d+[\).]|[a-cA-C][\)])\s+(.{3,140})$/gm)].map((m) => m[1].trim());
93
+ if (options.length < 2) options = [...text.matchAll(/^\s*[-*]\s+\*\*(.{2,90}?)\*\*/gm)].map((m) => m[1].trim());
94
+ if (options.length < 2) continue;
95
+ const question = lines.filter((l) => /\?\s*$/.test(l)).pop() || lastLine;
96
+ out.push({ question: question.slice(0, 200), options: options.slice(0, 6), answer });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ // Write one decision note per extracted prose decision (same shape as the hook capture).
102
+ // Deduped by filename (day + question slug). Returns the vault-relative paths written.
103
+ export function captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId }) {
104
+ const written = [];
105
+ const decisions = extractProseDecisions(tx);
106
+ if (!decisions.length) return written;
107
+ const loc = getLocale(vaultBase);
108
+ const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
109
+ for (const d of decisions) {
110
+ ensureDir(dir);
111
+ const filePath = join(dir, `${dateStr}-escolha-${slugify(d.question, 'decisao', 50)}.md`);
112
+ if (existsSync(filePath)) continue;
113
+ writeFileSync(filePath, buildDecisionCaptureNote({
114
+ questions: [{ question: d.question, multiSelect: false, options: d.options.map((label) => ({ label, description: '' })) }],
115
+ answers: { [d.question]: d.answer },
116
+ dateStr, startedAt: `${dateStr}T00:00:00`, sessionRel,
117
+ provider: provider || providerMeta(tx?.provider), localeId: localeId || loc.id,
118
+ }), 'utf-8');
119
+ written.push(toVaultRelative(vaultBase, filePath));
120
+ }
121
+ return written;
122
+ }
123
+
71
124
  export function captureDecision(vaultBase, input) {
72
125
  const toolIn = input.tool_input || input.toolInput || {};
73
126
  const questions = Array.isArray(toolIn.questions) ? toolIn.questions : [];
@@ -18,8 +18,9 @@ import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
18
18
  import { createLinkedNotes } from './linked-notes.mjs';
19
19
  import { updateSessionUsage } from './token-usage.mjs';
20
20
  import { upsertSubagentUsage } from './subagent-usage.mjs';
21
- import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate } from './obsidian-common.mjs';
21
+ import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta } from './obsidian-common.mjs';
22
22
  import { getLocale } from './locale.mjs';
23
+ import { captureProseDecisions } from './decision-capture.mjs';
23
24
 
24
25
  // Claude encodes a project's absolute path as its `.claude/projects` dir name by replacing each
25
26
  // path separator and the drive colon with '-'. `C:\GitHub\WendKeep` -> `C--GitHub-WendKeep`.
@@ -237,6 +238,37 @@ export function importSession(vaultBase, txPath, opts = {}) {
237
238
  return { sessionId, relPath, turns: turns.length };
238
239
  }
239
240
 
241
+ // Re-scan ALREADY-imported/captured transcripts for prose decisions only (no session re-import).
242
+ // For sessions imported before 0.29.0, whose transcripts carry options-in-prose choices that were
243
+ // never captured. Walks the registry (session_file + transcript_path), parses each transcript and
244
+ // runs captureProseDecisions — filename-deduped, so re-running is a no-op. Fail-soft per session.
245
+ export function rescanDecisions(vaultBase, { limit = 0 } = {}) {
246
+ const sessions = readSessionRegistry(vaultBase).sessions || {};
247
+ const report = { scanned: 0, decisions: 0, errors: [], sessions: [] };
248
+ let done = 0;
249
+ for (const [sessionId, entry] of Object.entries(sessions)) {
250
+ if (!entry?.transcript_path || !entry.session_file) continue;
251
+ if (!existsSync(entry.transcript_path)) continue;
252
+ if (limit && done >= limit) break;
253
+ report.scanned += 1;
254
+ done += 1;
255
+ try {
256
+ const tx = parseTranscript(entry.transcript_path);
257
+ const dateStr = String(entry.started_at || entry.ended_at || '').slice(0, 10) || formatDate(new Date());
258
+ const written = captureProseDecisions(vaultBase, {
259
+ tx, dateStr, sessionRel: entry.session_file, provider: providerMeta(tx.provider),
260
+ });
261
+ if (written.length) {
262
+ report.decisions += written.length;
263
+ report.sessions.push({ sessionId, notes: written });
264
+ }
265
+ } catch (error) {
266
+ report.errors.push({ sessionId, error: error.message });
267
+ }
268
+ }
269
+ return report;
270
+ }
271
+
240
272
  // Stamp a missing/empty `session_id` into a note's frontmatter (only within the frontmatter
241
273
  // block, right after `provider:`). Leaves a note that already has a non-empty id untouched.
242
274
  function injectSessionIdFrontmatter(content, sessionId) {
@@ -13,6 +13,7 @@ import {
13
13
  wikilinkFromRel,
14
14
  } from './obsidian-common.mjs';
15
15
  import { getLocale } from './locale.mjs';
16
+ import { captureProseDecisions } from './decision-capture.mjs';
16
17
 
17
18
  function yamlQuote(value) {
18
19
  return `"${String(value || '').replaceAll('"', '\\"')}"`;
@@ -568,6 +569,14 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
568
569
  }
569
570
  }
570
571
 
572
+ // Agnostic prose decisions (Codex parity): options-in-prose + short answer -> decision note.
573
+ // One integration point covers live Stop, import and backfill, for every provider. Fail-quiet.
574
+ try {
575
+ for (const rel of captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId: loc.id })) {
576
+ linked.decisions.push(rel);
577
+ }
578
+ } catch { /* prose capture é bônus — nunca derruba a captura principal */ }
579
+
571
580
  const learnings = extractLearningDetails(tx, bugDetails);
572
581
  if (learnings) {
573
582
  const vaultLearningKeys = collectLearningKeys(vaultBase); // vault-wide dedup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.28.1",
3
+ "version": "0.29.1",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/import.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // by session_id). One command backfills your whole history: cost, subagents, iterations.
4
4
  import { existsSync } from 'node:fs';
5
5
  import { isAbsolute, resolve } from 'node:path';
6
- import { runImport, stampSessionIds } from '../hooks/import-sessions.mjs';
6
+ import { runImport, stampSessionIds, rescanDecisions } from '../hooks/import-sessions.mjs';
7
7
 
8
8
  function opt(argv, name) {
9
9
  const i = argv.indexOf(name);
@@ -18,6 +18,16 @@ export function runImportCli(argv) {
18
18
  const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
19
19
  if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep import: vault not found: ${vaultBase}\n`); process.exit(2); }
20
20
 
21
+ // Repair mode: re-scan already-imported transcripts for prose decisions only, then stop.
22
+ if (argv.includes('--rescan-decisions')) {
23
+ const r = rescanDecisions(vaultBase, { limit: Number(opt(argv, '--limit')) || 0 });
24
+ if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
25
+ process.stdout.write(`${r.scanned} transcript(s) varridos · ${r.decisions} decisão(ões) capturadas\n`);
26
+ for (const s of r.sessions) for (const n of s.notes) process.stdout.write(` → ${n}\n`);
27
+ if (r.errors.length) for (const e of r.errors) process.stderr.write(` erro ${e.sessionId}: ${e.error}\n`);
28
+ process.exit(0);
29
+ }
30
+
21
31
  // Repair mode: backfill session_id into existing notes from the registry, then stop.
22
32
  if (argv.includes('--stamp-ids')) {
23
33
  const r = stampSessionIds(vaultBase);