wendkeep 0.27.0 → 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,78 @@ 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
+
36
+ ## [0.28.1] — 2026-07-09
37
+
38
+ Startup-contention fixes — root-caused from a real VSCode startup log where the memory injection
39
+ silently dropped and MCPs timed out.
40
+
41
+ ### Fixed
42
+ - **`brain-inject` timeout 15 → 45s.** The hook is healthy (~2.5s direct, ~4s via npx warm), but
43
+ Windows session startup runs several `npx` cold-starts at once (a sibling MCP took **26s** in the
44
+ log) and 15s silently killed the CORE+DIGEST injection for the whole session.
45
+ - **context-mode double-registration eliminated.** Its plugin ships its **own** MCP server; wiring
46
+ an `.mcp.json` entry too registered it twice — two concurrent `npx context-mode` cold-starts,
47
+ both timing out. The companion is now **plugin-only** (on non-Claude agents add the MCP manually:
48
+ `npx -y context-mode`).
49
+ - **`MCP_TIMEOUT=60000` default** added to the settings `env` by init (only when absent — a user
50
+ value is never clobbered), giving npx-launched stdio MCPs (wendkeep-vault included) headroom over
51
+ Claude Code's 30s default.
52
+
53
+ ### Upgrade
54
+ - Existing installs: re-run `wendkeep init` (now recognizes your vault) to pick up the timeout +
55
+ `MCP_TIMEOUT`; remove a duplicated `context-mode` entry from `.mcp.json`/`enabledMcpjsonServers`
56
+ by hand if present.
57
+
58
+ ## [0.28.0] — 2026-07-09
59
+
60
+ Three new hooks: decisions, subagents, plan progress.
61
+
62
+ ### Added
63
+ - **Decision capture** (`PostToolUse` / `AskUserQuestion` → `hooks/decision-capture.mjs`): when the
64
+ agent asks the user to choose between options, the decision is recorded in `04-Decisões/` — the
65
+ question, **every** option (label + description), the user's choice (✅), and a wikilink to the
66
+ session. Explicit, high-signal decisions get full traceability in the graph. Shape validated
67
+ against real transcripts.
68
+ - **Live subagent telemetry** (`SubagentStop` → `hooks/subagent-stop.mjs`): refreshes the session's
69
+ subagent/workflow cost notes the moment each subagent finishes (reuses `upsertSubagentUsage`), so
70
+ a session that never reaches `Stop` still has its telemetry. *Model choice stays the harness's
71
+ job — wendkeep observes, it does not impose a routing rule.*
72
+ - **Plan progress log** (`TaskCompleted` → `hooks/task-log.mjs`): when a task is marked complete,
73
+ appends it to a durable `## Progresso do plano` section in the active session note (before
74
+ `## Encerramento`, so reopen can't strip it). A progress trail, not a fuzzy map to `tarefas.md`.
75
+
76
+ All three are wired by `wendkeep init`, are fail-open, and localize (pt-BR / en). `--force`-free —
77
+ they only read + append.
78
+
7
79
  ## [0.27.0] — 2026-07-08
8
80
 
9
81
  ### Fixed
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);
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook (matcher: AskUserQuestion). When the agent asks the user to choose between
3
+ // options, this records the decision — the question, EVERY option (label + description), and the
4
+ // user's choice — as a note in 04-Decisões, wikilinked to the session. Explicit, high-signal
5
+ // decisions get full traceability in the graph (better than heuristic extraction). Fail-open.
6
+ import { existsSync, writeFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { pathToFileURL } from 'url';
9
+ import {
10
+ readHookInput, writeHookOutput, getVaultBase, providerMeta, ensureDir, formatDate,
11
+ formatLocalIso, monthFolderRelFromDateStr, slugify, findActiveSessionByTranscript,
12
+ wikilinkFromRel, readControl, toVaultRelative,
13
+ } from './obsidian-common.mjs';
14
+ import { getLocale } from './locale.mjs';
15
+
16
+ // The AskUserQuestion tool_output reads: `... "Question"="chosen labels" "Q2"="..."`.
17
+ export function parseAnswers(output) {
18
+ const map = {};
19
+ const re = /"([^"]+)"\s*=\s*"([^"]*)"/g;
20
+ let m;
21
+ while ((m = re.exec(String(output || '')))) map[m[1].trim()] = m[2].trim();
22
+ return map;
23
+ }
24
+
25
+ const clean = (s) => String(s || '').replace(/\r/g, '').replace(/\n{3,}/g, '\n\n').trim();
26
+
27
+ // Render the decision note from an AskUserQuestion tool call.
28
+ export function buildDecisionCaptureNote({ questions, answers, dateStr, startedAt, sessionRel, provider, localeId }) {
29
+ const en = localeId === 'en';
30
+ const src = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
31
+ const title = clean(questions[0]?.question || (en ? 'User decision' : 'Decisão do usuário')).slice(0, 90);
32
+
33
+ const blocks = questions.map((q) => {
34
+ const chosen = (answers[clean(q.question)] || '').split(',').map((s) => s.trim()).filter(Boolean);
35
+ const isChosen = (label) => chosen.some((c) => c === label.trim() || c.includes(label.trim()) || label.trim().includes(c));
36
+ const opts = (q.options || [])
37
+ .map((o) => `| ${isChosen(o.label) ? '✅' : ''} | ${clean(o.label)} | ${clean(o.description).slice(0, 200)} |`)
38
+ .join('\n');
39
+ return `### ${clean(q.question)}
40
+ ${q.multiSelect ? (en ? '_(multiple choice)_' : '_(múltipla escolha)_') : ''}
41
+
42
+ | | ${en ? 'Option' : 'Opção'} | ${en ? 'Description' : 'Descrição'} |
43
+ |---|---|---|
44
+ ${opts || `| | — | — |`}
45
+
46
+ **${en ? 'Chosen' : 'Escolhido'}:** ${chosen.length ? chosen.map((c) => `\`${c}\``).join(', ') : (en ? '(not recorded)' : '(não registrado)')}`;
47
+ }).join('\n\n');
48
+
49
+ return `---
50
+ type: decision
51
+ subtype: user-choice
52
+ date: ${dateStr}
53
+ started_at: ${startedAt}
54
+ provider: ${provider.id}
55
+ cssclasses:
56
+ - topic-decision
57
+ tags:
58
+ - decisao
59
+ - escolha-usuario
60
+ source:${src}
61
+ ---
62
+
63
+ # ${title}
64
+
65
+ > ${en ? 'Decision captured from an interactive question (options + the user\'s choice).' : 'Decisão capturada de uma pergunta interativa (opções + a escolha do usuário).'}
66
+
67
+ ${blocks}
68
+ `;
69
+ }
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
+
124
+ export function captureDecision(vaultBase, input) {
125
+ const toolIn = input.tool_input || input.toolInput || {};
126
+ const questions = Array.isArray(toolIn.questions) ? toolIn.questions : [];
127
+ if (!questions.length) return null;
128
+
129
+ const answers = parseAnswers(input.tool_output ?? input.toolOutput ?? input.tool_response ?? '');
130
+ const loc = getLocale(vaultBase);
131
+ const now = new Date();
132
+ const dateStr = formatDate(now);
133
+ const provider = providerMeta(input.provider);
134
+
135
+ const matched = input.transcript_path ? findActiveSessionByTranscript(vaultBase, input.transcript_path) : null;
136
+ const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
137
+
138
+ const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
139
+ ensureDir(dir);
140
+ const slug = slugify(questions[0]?.question || 'decisao', 'decisao', 50);
141
+ const filePath = join(dir, `${dateStr}-escolha-${slug}.md`);
142
+ if (existsSync(filePath)) return { rel: toVaultRelative(vaultBase, filePath), skipped: true };
143
+
144
+ writeFileSync(filePath, buildDecisionCaptureNote({
145
+ questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id,
146
+ }), 'utf-8');
147
+ return { rel: toVaultRelative(vaultBase, filePath), skipped: false };
148
+ }
149
+
150
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
151
+ try {
152
+ const input = readHookInput();
153
+ if ((input.tool_name || input.toolName) === 'AskUserQuestion') {
154
+ captureDecision(getVaultBase(input), input);
155
+ }
156
+ writeHookOutput({});
157
+ } catch (error) {
158
+ process.stderr.write(`[wendkeep] decision-capture falhou: ${error.message}\n`);
159
+ writeHookOutput({});
160
+ }
161
+ }
@@ -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
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // SubagentStop hook: refresh this session's subagent/workflow telemetry the MOMENT a subagent
3
+ // finishes — not only at the main Stop. Resilience: a session that never reaches Stop (crash,
4
+ // window closed) still gets its subagent cost notes. Reuses the same upsertSubagentUsage the Stop
5
+ // hook runs, so the output is identical; it just runs earlier + incrementally. Fail-open.
6
+ //
7
+ // Model choice for subagents stays the harness's job (agent frontmatter `model:` / the Task/
8
+ // workflow `model` param). wendkeep OBSERVES (this telemetry) rather than dictating a routing rule.
9
+ import { existsSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { pathToFileURL } from 'url';
12
+ import { readHookInput, writeHookOutput, getVaultBase, findActiveSessionByTranscript, readControl } from './obsidian-common.mjs';
13
+ import { upsertSubagentUsage } from './subagent-usage.mjs';
14
+
15
+ export function refreshSubagents(vaultBase, input) {
16
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
17
+ const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
18
+ const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
19
+ if (!sessionRel) return false;
20
+ const sessionPath = join(vaultBase, sessionRel);
21
+ if (!existsSync(sessionPath)) return false;
22
+ upsertSubagentUsage(sessionPath, transcriptPath);
23
+ return true;
24
+ }
25
+
26
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
27
+ try {
28
+ const input = readHookInput();
29
+ refreshSubagents(getVaultBase(input), input);
30
+ writeHookOutput({});
31
+ } catch (error) {
32
+ process.stderr.write(`[wendkeep] subagent-stop falhou: ${error.message}\n`);
33
+ writeHookOutput({});
34
+ }
35
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // TaskCompleted hook: log plan/task progress into the active session note, so the session records
3
+ // what the plan actually advanced through. It writes a durable "## Progresso do plano" section
4
+ // placed BEFORE ## Encerramento (survives the reopen/strip cycle) and does NOT try to map to a
5
+ // change's tarefas.md N.N (id-spaces differ, fuzzy) — it's a progress trail, not a task tracker.
6
+ // The TaskCompleted payload shape isn't fully pinned, so the task text is pulled defensively from
7
+ // any plausible field. Fail-open.
8
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { pathToFileURL } from 'url';
11
+ import { readHookInput, writeHookOutput, getVaultBase, findActiveSessionByTranscript, readControl, formatHourMinute } from './obsidian-common.mjs';
12
+ import { getLocale } from './locale.mjs';
13
+
14
+ // Pull the task's human text from whatever field the payload carries.
15
+ export function taskText(input) {
16
+ const t = input.task || input.tool_input || input.payload || input;
17
+ const cand = t?.content || t?.text || t?.description || t?.title || t?.subject || t?.name
18
+ || t?.prompt || input.content || input.text || input.description || '';
19
+ return String(cand || '').replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160);
20
+ }
21
+
22
+ // Append `line` at the END of a "## <heading>" section placed before ## Encerramento, so entries
23
+ // read chronologically. Deduped. Pure.
24
+ export function appendProgress(content, line, heading) {
25
+ if (content.includes(line)) return content; // dedup
26
+ const marker = `\n## ${heading}\n`;
27
+ const at = content.indexOf(marker);
28
+ if (at !== -1) {
29
+ const sectionStart = at + marker.length;
30
+ let end = content.indexOf('\n## ', sectionStart);
31
+ if (end === -1) end = content.length;
32
+ const head = content.slice(0, end).replace(/\s+$/, '');
33
+ return `${head}\n${line}\n${content.slice(end)}`;
34
+ }
35
+ const block = `## ${heading}\n\n${line}\n\n`;
36
+ const enc = content.indexOf('\n## Encerramento');
37
+ if (enc === -1) return `${content.trimEnd()}\n\n${block}`;
38
+ return `${content.slice(0, enc).trimEnd()}\n\n${block}${content.slice(enc + 1)}`;
39
+ }
40
+
41
+ export function logTask(vaultBase, input) {
42
+ const text = taskText(input);
43
+ if (!text) return false;
44
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
45
+ const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
46
+ const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
47
+ if (!sessionRel) return false;
48
+ const sessionPath = join(vaultBase, sessionRel);
49
+ if (!existsSync(sessionPath)) return false;
50
+
51
+ const heading = getLocale(vaultBase).id === 'en' ? 'Plan progress' : 'Progresso do plano';
52
+ const line = `- [x] ${formatHourMinute(new Date()).replace('-', ':')} ${text}`;
53
+ const content = readFileSync(sessionPath, 'utf8');
54
+ const next = appendProgress(content, line, heading);
55
+ if (next !== content) { writeFileSync(sessionPath, next, 'utf8'); return true; }
56
+ return false;
57
+ }
58
+
59
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
60
+ try {
61
+ const input = readHookInput();
62
+ logTask(getVaultBase(input), input);
63
+ writeHookOutput({});
64
+ } catch (error) {
65
+ process.stderr.write(`[wendkeep] task-log falhou: ${error.message}\n`);
66
+ writeHookOutput({});
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.27.0",
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);
package/src/init.mjs CHANGED
@@ -126,6 +126,10 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
126
126
  added += 1;
127
127
  }
128
128
  s.env = { ...(s.env || {}), OBSIDIAN_VAULT_PATH: vaultPath };
129
+ // npx-launched stdio MCPs (wendkeep-vault included) can cold-start near/over Claude Code's 30s
130
+ // default under Windows startup contention (26s observed in a real log). Give them headroom —
131
+ // but never clobber a value the user already set.
132
+ if (!('MCP_TIMEOUT' in s.env)) s.env.MCP_TIMEOUT = '60000';
129
133
 
130
134
  // Claude Code plugin layer for the selected companions (additive, non-clobbering).
131
135
  const cp = companionSettingsPatch(companions);
package/src/taxonomy.mjs CHANGED
@@ -44,6 +44,9 @@ export const HOOK_FILES = [
44
44
  'brain-reindex.mjs',
45
45
  'session-backfill.mjs',
46
46
  'import-sessions.mjs',
47
+ 'decision-capture.mjs',
48
+ 'subagent-stop.mjs',
49
+ 'task-log.mjs',
47
50
  'vault-health.mjs',
48
51
  'understand-inject.mjs',
49
52
  ];
@@ -56,6 +59,9 @@ export const RUNNABLE_HOOKS = [
56
59
  'session-ensure',
57
60
  'session-stop',
58
61
  'session-backfill',
62
+ 'decision-capture',
63
+ 'subagent-stop',
64
+ 'task-log',
59
65
  'brain-inject',
60
66
  'brain-recall',
61
67
  'brain-reindex',
@@ -81,10 +87,19 @@ export const SESSION_HOOKS = [
81
87
  // Memory + active-change injection. Runs FIRST on SessionStart (order -10, folds before
82
88
  // session-start) so the agent gets CORE + DIGEST + the active change + lessons as context.
83
89
  // matcher 'startup|clear|compact' re-injects after a compaction/clear, not only cold startup.
84
- { event: 'SessionStart', matcher: 'startup|clear|compact', name: 'brain-inject', timeout: 15, order: -10, statusMessage: 'wendkeep: injecting memory + active change' },
90
+ // timeout 45 (was 15): measured ~4s warm via npx, but Windows startup contention (several npx
91
+ // cold-starts at once — a sibling MCP took 26s in a real log) blew 15s and silently dropped the
92
+ // memory injection for the whole session.
93
+ { event: 'SessionStart', matcher: 'startup|clear|compact', name: 'brain-inject', timeout: 45, order: -10, statusMessage: 'wendkeep: injecting memory + active change' },
85
94
  { event: 'SessionStart', matcher: 'startup', name: 'session-start', timeout: 30, statusMessage: 'wendkeep: opening Obsidian session' },
86
95
  { event: 'Stop', matcher: null, name: 'session-stop', timeout: 60, statusMessage: 'wendkeep: writing session checkpoint' },
87
96
  { event: 'UserPromptSubmit', matcher: null, name: 'session-ensure', timeout: 30, statusMessage: 'wendkeep: ensuring active session' },
97
+ // Capture an interactive decision (AskUserQuestion) — options + the user's choice — into 04-Decisões.
98
+ { event: 'PostToolUse', matcher: 'AskUserQuestion', name: 'decision-capture', timeout: 15, statusMessage: 'wendkeep: recording decision' },
99
+ // Refresh subagent/workflow telemetry as each subagent finishes (resilient to a missed Stop).
100
+ { event: 'SubagentStop', matcher: null, name: 'subagent-stop', timeout: 20, statusMessage: 'wendkeep: subagent telemetry' },
101
+ // Log plan/task progress into the active session note when a task is marked complete.
102
+ { event: 'TaskCompleted', matcher: null, name: 'task-log', timeout: 10, statusMessage: 'wendkeep: plan progress' },
88
103
  ];
89
104
 
90
105
  export function hookCommand(name) {
@@ -108,8 +123,10 @@ export const COMPANIONS = [
108
123
  default: false,
109
124
  marketplace: { source: 'git', url: 'https://github.com/mksglu/context-mode.git' },
110
125
  plugin: 'context-mode',
111
- // Agent-agnostic: MCP server, self-updating via unpinned npx.
112
- mcp: { key: 'context-mode', entry: { type: 'stdio', command: 'npx', args: ['-y', 'context-mode'] } },
126
+ // NO .mcp.json entry on purpose: the plugin ships its OWN MCP server, so wiring both
127
+ // double-registered it (two concurrent `npx context-mode` cold-starts at session start —
128
+ // both timed out in a real log). Plugin is the single source; on non-Claude agents add the
129
+ // MCP manually (`npx -y context-mode`).
113
130
  },
114
131
  {
115
132
  id: 'understand-anything',