wendkeep 0.2.5 → 0.2.7

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/README.md CHANGED
@@ -38,11 +38,13 @@ npx wendkeep init
38
38
  1. Create the vault folder taxonomy and a templated `README.md` (default vault: `<project>/.<project-name>-vault`, e.g. `.MyApp-vault`; override with `--vault`).
39
39
  2. **Merge** the three session hooks and `OBSIDIAN_VAULT_PATH` into `.claude/settings.json` — without clobbering your existing settings (a `.bak` is saved; an unparseable file is left untouched and a `.new` is written for you to merge).
40
40
  3. Add the `wendkeep-vault` MCP server to `.mcp.json` (skip with `--no-mcp`).
41
- 4. Offer to pin **companion** plugins/MCP (multi-choice; `context-mode` pre-checked). Each is wired through the most agent-agnostic path it supports — `context-mode` as an `.mcp.json` MCP server (any agent), `understand-anything` via a `understand-inject` SessionStart hook that injects its domain graph when generated, and the Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) as a bonus. Control with `--companions <csv>` or `--no-companions`. (`caveman` additionally runs its own cross-agent installer on non-Claude agents.)
41
+ 4. Offer to pin **companion** plugins/MCP (multi-choice; `context-mode` + `dotcontext` pre-checked). Each is wired through the most agent-agnostic path it supports — `context-mode` and `dotcontext` as `.mcp.json` MCP servers (any agent), `understand-anything` via a `understand-inject` SessionStart hook that injects its domain graph when generated, and the Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) as a bonus. **`dotcontext`** (execution/PREVC harness) also gets its lifecycle hooks wired (SessionStart-last + Stop + PostToolUse, pinned CLI) and a starter `.context/config/sensors.json` seeded with a `wendkeep validate-memory` sensor plus one per detected `package.json` script (test/typecheck/lint/build) — add the `.context/runtime|cache|logs|plans|agents|skills/` paths to `.gitignore` (wendkeep doesn't touch git). If dotcontext is already configured globally (`~/.claude.json`), the project MCP entry is **skipped automatically** to avoid a duplicate; tune with `--dotcontext-mcp <auto|project|none>` and `--dotcontext-hooks <full|light|none>` (light drops PostToolUse). Control selection with `--companions <csv>` or `--no-companions`. (`caveman` additionally runs its own cross-agent installer on non-Claude agents.)
42
42
  5. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
43
+ 6. Seed the **curated memory layer**: `.brain/CORE.md` (the hand-curated hot layer, with the 3 required sections) and `.brain/COMPACTION_PROTOCOL.md` (the protocol guide). The auto layers (`DIGEST.md`, `index.jsonl`) are generated by the hooks. Validate the curated layer with `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
44
+ 7. Seed the **definitions layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth for custom agents/skills, with a README + one example each). Run `wendkeep sync-defs` to copy them into the project's agent dirs (`.brain/agents/*.toml` → `.codex/agents/`, `.brain/skills/<name>/` → `.claude/skills/`).
43
45
 
44
46
  ```bash
45
- npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (companions: context-mode)
47
+ npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (companions: context-mode,dotcontext)
46
48
  npx wendkeep init --companions "context-mode,caveman" --yes
47
49
  npx wendkeep init --no-companions --yes
48
50
  ```
package/bin/wendkeep.mjs CHANGED
@@ -29,10 +29,13 @@ Usage:
29
29
  --vault <path> Obsidian vault folder (default: <project>/.<project-name>-vault).
30
30
  --project <path> Project root to wire (default: current directory).
31
31
  --no-mcp Do not add the mcpvault MCP server to .mcp.json.
32
- --companions <csv> Companion plugins/MCP to pin: context-mode,caveman,understand-anything
33
- (default when interactive/--yes: context-mode).
32
+ --companions <csv> Companion plugins/MCP to pin: context-mode,dotcontext,caveman,understand-anything
33
+ (default when interactive/--yes: context-mode,dotcontext).
34
34
  --no-companions Skip companion plugins/MCP entirely.
35
35
  --no-colors Skip the Obsidian color system (.obsidian snippet + graph groups).
36
+ --dotcontext-mcp <v> dotcontext MCP placement: auto (default; skip project entry
37
+ if already global), project, or none.
38
+ --dotcontext-hooks <v> dotcontext hooks: full (default), light (no PostToolUse), none.
36
39
  --yes, -y Non-interactive; accept defaults.
37
40
  --force Overwrite existing wendkeep config blocks.
38
41
 
@@ -40,6 +43,12 @@ Usage:
40
43
  agent's JSON on stdin. Names: ${RUNNABLE_HOOKS.join(', ')}.
41
44
 
42
45
  wendkeep doctor [--vault P] Run a vault health check.
46
+ wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
47
+ protocol (cap 25, 3 sections, no secrets/PII). Uses
48
+ --vault <path> or OBSIDIAN_VAULT_PATH if no path given.
49
+ wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
50
+ project: .brain/agents/*.toml -> .codex/agents,
51
+ .brain/skills/<name> -> .claude/skills. --vault P --project P.
43
52
  wendkeep --version Print version.
44
53
  wendkeep --help Show this help.
45
54
  `;
@@ -80,6 +89,16 @@ async function main() {
80
89
  runDoctor(rest);
81
90
  break;
82
91
  }
92
+ case 'validate-memory': {
93
+ const { runValidateMemory } = await import('../src/validate-core.mjs');
94
+ runValidateMemory(rest);
95
+ break;
96
+ }
97
+ case 'sync-defs': {
98
+ const { runSyncDefs } = await import('../src/sync-defs.mjs');
99
+ runSyncDefs(rest);
100
+ break;
101
+ }
83
102
  case '--version':
84
103
  case '-v':
85
104
  process.stdout.write(`${version()}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Automatically capture AI coding agent sessions (Claude Code, Codex) as local Markdown in your Obsidian vault — turn-by-turn history, cost/token tracking, auto-extracted decisions/bugs/learnings, rendered in the graph. Local-first.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,83 @@
1
+ // dotcontext .context/config seeding + MCP-placement resolution.
2
+ // Seeds `memory-validation` (`wendkeep validate-memory`, valid in any wendkeep
3
+ // project) plus a sensor for each detected package.json script (test/typecheck/
4
+ // lint/build). Project-specific commands beyond that are left to `context init`.
5
+ // Non-destructive. Also resolves whether to add the project MCP entry (skip it
6
+ // when dotcontext is already configured globally in ~/.claude.json).
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ // package.json script -> sensor (only seeded if the script exists in the project).
12
+ const SCRIPT_SENSORS = [
13
+ { script: 'typecheck', id: 'typecheck', name: 'Typecheck', severity: 'critical' },
14
+ { script: 'test', id: 'tests', name: 'Tests', severity: 'critical' },
15
+ { script: 'lint', id: 'lint', name: 'Lint', severity: 'warning' },
16
+ { script: 'build', id: 'build', name: 'Build', severity: 'warning' },
17
+ ];
18
+
19
+ export function renderSensorsJson(scripts = {}) {
20
+ const sensors = [
21
+ {
22
+ id: 'memory-validation',
23
+ name: 'CORE validation',
24
+ description: 'valida .brain/CORE.md (cap 25 + 3 seções, sem segredos) — wendkeep',
25
+ severity: 'critical',
26
+ command: 'npx wendkeep validate-memory',
27
+ },
28
+ ];
29
+ for (const m of SCRIPT_SENSORS) {
30
+ if (scripts && scripts[m.script]) {
31
+ sensors.push({
32
+ id: m.id,
33
+ name: m.name,
34
+ description: `npm run ${m.script}`,
35
+ severity: m.severity,
36
+ command: `npm run ${m.script}`,
37
+ });
38
+ }
39
+ }
40
+ return `${JSON.stringify({ version: 1, source: 'manual', sensors }, null, 2)}\n`;
41
+ }
42
+
43
+ function readProjectScripts(projectPath) {
44
+ try {
45
+ return JSON.parse(readFileSync(join(projectPath, 'package.json'), 'utf8')).scripts || {};
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ // Create <project>/.context/config/sensors.json if absent (sensors auto-detected
52
+ // from the project's package.json scripts). Returns paths created.
53
+ export function seedDotcontext(projectPath) {
54
+ const created = [];
55
+ const configDir = join(projectPath, '.context', 'config');
56
+ mkdirSync(configDir, { recursive: true });
57
+ const sensorsPath = join(configDir, 'sensors.json');
58
+ if (!existsSync(sensorsPath)) {
59
+ writeFileSync(sensorsPath, renderSensorsJson(readProjectScripts(projectPath)), 'utf8');
60
+ created.push(sensorsPath);
61
+ }
62
+ return created;
63
+ }
64
+
65
+ // True if ~/.claude.json already declares a global dotcontext MCP server. Best-effort.
66
+ export function globalHasDotcontext(claudeJsonPath = join(homedir(), '.claude.json')) {
67
+ try {
68
+ const data = JSON.parse(readFileSync(claudeJsonPath, 'utf8'));
69
+ return !!data?.mcpServers?.dotcontext;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // Whether to SKIP the project-scoped dotcontext MCP entry.
76
+ // 'none' -> always skip
77
+ // 'project' -> never skip
78
+ // 'auto'/-- -> skip iff dotcontext is already global (avoid a duplicate server)
79
+ export function resolveDotcontextSkipMcp(flag, globalHas) {
80
+ if (flag === 'none') return true;
81
+ if (flag === 'project') return false;
82
+ return !!globalHas;
83
+ }
package/src/init.mjs CHANGED
@@ -3,8 +3,7 @@
3
3
  // OBSIDIAN_VAULT_PATH into .claude/settings.json, and adds the mcpvault server to
4
4
  // .mcp.json. Idempotent: re-running only adds what is missing.
5
5
  import { spawnSync } from 'node:child_process';
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
7
  import { basename, isAbsolute, join, resolve } from 'node:path';
9
8
  import { createInterface } from 'node:readline/promises';
10
9
  import {
@@ -19,8 +18,8 @@ import {
19
18
  companionSettingsPatch,
20
19
  companionMcpPatch,
21
20
  companionHookSpecs,
22
- cavemanInstallerCommand,
23
- cavemanInstallerUrl,
21
+ cavemanInstallCommand,
22
+ DOTCONTEXT_GITIGNORE,
24
23
  } from './taxonomy.mjs';
25
24
  import { renderVaultReadme } from './vault-readme.mjs';
26
25
  import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
@@ -31,6 +30,9 @@ import {
31
30
  graphColorGroups,
32
31
  mergeGraphColorGroups,
33
32
  } from './vault-theme.mjs';
33
+ import { renderCoreSkeleton, renderCompactionProtocol } from './validate-core.mjs';
34
+ import { seedDefinitions } from './sync-defs.mjs';
35
+ import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp } from './dotcontext-seed.mjs';
34
36
 
35
37
  function parseArgs(argv) {
36
38
  const args = { mcp: true, yes: false, force: false };
@@ -43,6 +45,10 @@ function parseArgs(argv) {
43
45
  else if (a === '--force') args.force = true;
44
46
  else if (a === '--no-companions') args.noCompanions = true;
45
47
  else if (a === '--no-colors') args.noColors = true;
48
+ else if (a === '--dotcontext-mcp') args.dotcontextMcp = argv[++i];
49
+ else if (a.startsWith('--dotcontext-mcp=')) args.dotcontextMcp = a.slice(17);
50
+ else if (a === '--dotcontext-hooks') args.dotcontextHooks = argv[++i];
51
+ else if (a.startsWith('--dotcontext-hooks=')) args.dotcontextHooks = a.slice(19);
46
52
  else if (a === '--companions') args.companions = argv[++i];
47
53
  else if (a.startsWith('--companions=')) args.companions = a.slice(13);
48
54
  else if (a.startsWith('--vault=')) args.vault = a.slice(8);
@@ -80,13 +86,19 @@ function backup(path) {
80
86
 
81
87
  // --- merges -----------------------------------------------------------------
82
88
 
83
- export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [] }) {
89
+ export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full' }) {
84
90
  const s = existing && typeof existing === 'object' ? { ...existing } : {};
85
91
  s.hooks = { ...(s.hooks || {}) };
86
92
  let added = 0;
87
- // wendkeep's own session hooks + any companion-authored hooks (e.g. understand-inject).
88
- for (const h of [...SESSION_HOOKS, ...companionHookSpecs(companions)]) {
89
- const command = hookCommand(h.name);
93
+ // wendkeep's own session hooks + any companion-authored hooks. Stable-sort by
94
+ // `order` (default 0) so higher-order companion hooks (e.g. dotcontext's order 100)
95
+ // fold AFTER wendkeep's own within each event. A spec may carry its own `command`
96
+ // (dotcontext dispatch) instead of a wendkeep hook `name`.
97
+ const allSpecs = [...SESSION_HOOKS, ...companionHookSpecs(companions, { dotcontextHookLevel })].sort(
98
+ (a, b) => (a.order ?? 0) - (b.order ?? 0),
99
+ );
100
+ for (const h of allSpecs) {
101
+ const command = h.command ?? hookCommand(h.name);
90
102
  const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
91
103
  const present = groups.some((g) => (g.hooks || []).some((x) => x.command === command));
92
104
  if (present && !force) {
@@ -110,7 +122,7 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
110
122
  s.enabledPlugins = { ...(s.enabledPlugins || {}), ...cp.enabledPlugins };
111
123
  }
112
124
 
113
- const companionMcp = Object.keys(companionMcpPatch(companions));
125
+ const companionMcp = Object.keys(companionMcpPatch(companions, skipMcp));
114
126
  if (withMcp || companionMcp.length) {
115
127
  const set = new Set(Array.isArray(s.enabledMcpjsonServers) ? s.enabledMcpjsonServers : []);
116
128
  if (withMcp) set.add(MCP_SERVER_KEY);
@@ -120,11 +132,11 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
120
132
  return { settings: s, added };
121
133
  }
122
134
 
123
- export function mergeMcp(existing, { vaultPath, withVault = true, companions = [] }) {
135
+ export function mergeMcp(existing, { vaultPath, withVault = true, companions = [], skipMcp = [] }) {
124
136
  const m = existing && typeof existing === 'object' ? { ...existing } : {};
125
137
  m.mcpServers = { ...(m.mcpServers || {}) };
126
138
  if (withVault) m.mcpServers[MCP_SERVER_KEY] = mcpServerEntry(vaultPath);
127
- Object.assign(m.mcpServers, companionMcpPatch(companions));
139
+ Object.assign(m.mcpServers, companionMcpPatch(companions, skipMcp));
128
140
  return m;
129
141
  }
130
142
 
@@ -132,25 +144,16 @@ export function mergeMcp(existing, { vaultPath, withVault = true, companions = [
132
144
  // script to a temp file and runs it as a FILE — piping to iex/bash leaves the
133
145
  // script's self-path null and the installer aborts. Best-effort, fail-soft: the
134
146
  // Claude Code plugin entry is already wired regardless.
135
- async function runCavemanInstaller(log) {
136
- const url = cavemanInstallerUrl();
137
- const ext = process.platform === 'win32' ? 'ps1' : 'sh';
138
- const tmp = join(tmpdir(), `wendkeep-caveman-install-${process.pid}.${ext}`);
139
- log(`\n caveman: fetching cross-agent installer (best-effort): ${url}`);
147
+ function runCavemanInstaller(log) {
148
+ const cmd = cavemanInstallCommand();
149
+ log(`\n caveman: running cross-agent installer (best-effort, Gemini skipped):\n ${cmd}`);
140
150
  try {
141
- const res = await fetch(url);
142
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
143
- writeFileSync(tmp, await res.text(), 'utf8');
144
- const cmd = cavemanInstallerCommand(process.platform, tmp);
145
- log(` caveman: running ${cmd.command} ${cmd.args.join(' ')}`);
146
- const r = spawnSync(cmd.command, cmd.args, { stdio: 'inherit' });
151
+ const r = spawnSync(cmd, { stdio: 'inherit', shell: true });
147
152
  if (r.status !== 0) {
148
153
  log(` [!] caveman installer exited ${r.status ?? '?'} — Claude Code plugin entry still wired; rerun manually if needed.`);
149
154
  }
150
155
  } catch (e) {
151
156
  log(` [!] caveman installer skipped (${e.message}) — Claude Code plugin entry still wired.`);
152
- } finally {
153
- try { rmSync(tmp, { force: true }); } catch { /* temp cleanup best-effort */ }
154
157
  }
155
158
  }
156
159
 
@@ -238,6 +241,16 @@ export async function runInit(argv) {
238
241
  log(` companions : ${companions.length ? companions.join(', ') : 'none'}`);
239
242
  log(` colors : ${args.noColors ? 'skipped' : 'wendkeep-colors (snippet + graph groups)'}\n`);
240
243
 
244
+ // dotcontext controls (only relevant when selected): hook level + MCP placement.
245
+ // Skip the project MCP entry when dotcontext is already global (avoids a duplicate
246
+ // server / version clash); --dotcontext-mcp project|none and --dotcontext-hooks
247
+ // full|light|none override.
248
+ const dotcontextHookLevel = args.dotcontextHooks || 'full';
249
+ const skipMcp = [];
250
+ if (companions.includes('dotcontext') && resolveDotcontextSkipMcp(args.dotcontextMcp, globalHasDotcontext())) {
251
+ skipMcp.push('dotcontext');
252
+ }
253
+
241
254
  // 1. Vault taxonomy ---------------------------------------------------------
242
255
  if (!existsSync(vaultPath)) mkdirSync(vaultPath, { recursive: true });
243
256
  let created = 0;
@@ -255,19 +268,31 @@ export async function runInit(argv) {
255
268
  writeFileSync(readmePath, renderVaultReadme({ projectName: basename(projectPath), vaultPath, withMcp: args.mcp }), 'utf8');
256
269
  readmeNote = ', README.md created';
257
270
  }
258
- log(` [1/4] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)${readmeNote}`);
271
+ // Seed the curated memory layer (CORE.md) + the compaction-protocol doc. The
272
+ // DIGEST/index are auto-generated by the hooks; CORE is hand-curated, so we
273
+ // bootstrap it with the 3 required sections. Non-destructive.
274
+ const brainDir = join(vaultPath, '.brain');
275
+ mkdirSync(brainDir, { recursive: true });
276
+ const corePath = join(brainDir, 'CORE.md');
277
+ if (!existsSync(corePath)) writeFileSync(corePath, renderCoreSkeleton(), 'utf8');
278
+ const protoPath = join(brainDir, 'COMPACTION_PROTOCOL.md');
279
+ if (!existsSync(protoPath)) writeFileSync(protoPath, renderCompactionProtocol(), 'utf8');
280
+ // Seed the definitions layer (.brain/agents + .brain/skills): versioned source of
281
+ // truth for custom agents/skills. `wendkeep sync-defs` copies them to the agent dirs.
282
+ seedDefinitions(brainDir);
283
+ log(` [1/4] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)${readmeNote}, .brain seeded (CORE + agents/skills)`);
259
284
 
260
285
  // 2. .claude/settings.json --------------------------------------------------
261
286
  const settingsPath = join(projectPath, '.claude', 'settings.json');
262
287
  const settingsRead = readJsonSafe(settingsPath);
263
288
  if (!settingsRead.ok) {
264
289
  // Unparseable existing file — never clobber. Drop a .new for manual merge.
265
- const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions }).settings;
290
+ const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions, skipMcp, dotcontextHookLevel }).settings;
266
291
  writeJson(`${settingsPath}.new`, fresh);
267
292
  log(` [2/4] settings.json exists but is not valid JSON -> wrote ${settingsPath}.new (merge by hand)`);
268
293
  } else {
269
294
  const hadFile = settingsRead.data !== null;
270
- const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions });
295
+ const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions, skipMcp, dotcontextHookLevel });
271
296
  if (hadFile) backup(settingsPath);
272
297
  writeJson(settingsPath, settings);
273
298
  log(` [2/4] settings.json ${hadFile ? 'merged' : 'created'} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${hadFile ? ', .bak saved' : ''})`);
@@ -275,11 +300,11 @@ export async function runInit(argv) {
275
300
 
276
301
  // 3. .mcp.json --------------------------------------------------------------
277
302
  // Written when mcpvault is wanted OR a selected companion ships an MCP server.
278
- const companionMcp = companionMcpPatch(companions);
303
+ const companionMcp = companionMcpPatch(companions, skipMcp);
279
304
  if (args.mcp || Object.keys(companionMcp).length) {
280
305
  const mcpPath = join(projectPath, '.mcp.json');
281
306
  const mcpRead = readJsonSafe(mcpPath);
282
- const mcpOpts = { vaultPath, withVault: args.mcp, companions };
307
+ const mcpOpts = { vaultPath, withVault: args.mcp, companions, skipMcp };
283
308
  const names = [args.mcp && MCP_SERVER_KEY, ...Object.keys(companionMcp)].filter(Boolean).join(', ');
284
309
  if (!mcpRead.ok) {
285
310
  writeJson(`${mcpPath}.new`, mergeMcp(null, mcpOpts));
@@ -304,7 +329,16 @@ export async function runInit(argv) {
304
329
  // caveman has no MCP / agnostic-hook path: on non-Claude agents its skills come
305
330
  // from its own cross-agent installer. Best-effort, fail-soft — the Claude Code
306
331
  // plugin entry is already wired in settings.json regardless.
307
- if (companions.includes('caveman')) await runCavemanInstaller(log);
332
+ if (companions.includes('caveman')) runCavemanInstaller(log);
333
+
334
+ // dotcontext: MCP + lifecycle hooks are wired by mergeSettings/mergeMcp above;
335
+ // here we seed the versioned .context/config starter and print the gitignore note.
336
+ if (companions.includes('dotcontext')) {
337
+ const seeded = seedDotcontext(projectPath);
338
+ const mcpNote = skipMcp.includes('dotcontext') ? 'MCP global (project entry skipped)' : 'MCP (project)';
339
+ log(`\n dotcontext: ${mcpNote} + hooks(${dotcontextHookLevel}); .context/config seeded (${seeded.length} file(s)).`);
340
+ log(` [!] add to .gitignore (wendkeep não toca git): ${DOTCONTEXT_GITIGNORE.join(' ')}`);
341
+ }
308
342
 
309
343
  log('\nNext steps:');
310
344
  log(` 1. Open the vault in Obsidian: "Open folder as vault" -> ${vaultPath}`);
@@ -0,0 +1,123 @@
1
+ // Definitions layer: versioned custom agents/skills live in the vault's .brain so
2
+ // they travel with the project in git. They have no automatic consumer — agents
3
+ // read them from their own dirs — so `wendkeep sync-defs` copies them there:
4
+ // .brain/agents/*.toml -> <project>/.codex/agents/ (Codex agent format)
5
+ // .brain/skills/<name>/ -> <project>/.claude/skills/ (skill format)
6
+ // .brain is the source of truth; re-run sync after editing. Copy (not symlink) for
7
+ // cross-platform robustness.
8
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
9
+ import { isAbsolute, join, resolve } from 'node:path';
10
+
11
+ export function syncDefs(vaultBase, projectPath) {
12
+ const out = { agents: [], skills: [] };
13
+
14
+ const agentsSrc = join(vaultBase, '.brain', 'agents');
15
+ if (existsSync(agentsSrc)) {
16
+ const dest = join(projectPath, '.codex', 'agents');
17
+ for (const f of readdirSync(agentsSrc)) {
18
+ if (!f.endsWith('.toml')) continue; // README.md etc. are docs, not defs
19
+ mkdirSync(dest, { recursive: true });
20
+ copyFileSync(join(agentsSrc, f), join(dest, f));
21
+ out.agents.push(f);
22
+ }
23
+ }
24
+
25
+ const skillsSrc = join(vaultBase, '.brain', 'skills');
26
+ if (existsSync(skillsSrc)) {
27
+ for (const name of readdirSync(skillsSrc)) {
28
+ const dir = join(skillsSrc, name);
29
+ if (!statSync(dir).isDirectory()) continue; // skip skills/README.md
30
+ const dest = join(projectPath, '.claude', 'skills', name);
31
+ mkdirSync(dest, { recursive: true });
32
+ cpSync(dir, dest, { recursive: true });
33
+ out.skills.push(name);
34
+ }
35
+ }
36
+
37
+ return out;
38
+ }
39
+
40
+ // CLI entry for `wendkeep sync-defs`.
41
+ export function runSyncDefs(argv) {
42
+ let vault;
43
+ let project;
44
+ for (let i = 0; i < argv.length; i += 1) {
45
+ const a = argv[i];
46
+ if (a === '--vault') vault = argv[++i];
47
+ else if (a.startsWith('--vault=')) vault = a.slice(8);
48
+ else if (a === '--project') project = argv[++i];
49
+ else if (a.startsWith('--project=')) project = a.slice(10);
50
+ }
51
+ const base = vault || process.env.OBSIDIAN_VAULT_PATH;
52
+ if (!base) {
53
+ process.stderr.write('wendkeep sync-defs: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
54
+ process.exit(2);
55
+ }
56
+ const vaultBase = isAbsolute(base) ? base : resolve(process.cwd(), base);
57
+ const projectPath = resolve(project || process.cwd());
58
+ const r = syncDefs(vaultBase, projectPath);
59
+ process.stdout.write(
60
+ `wendkeep sync-defs: ${r.agents.length} agent(s) -> .codex/agents, ${r.skills.length} skill(s) -> .claude/skills\n`,
61
+ );
62
+ if (r.agents.length) process.stdout.write(` agents: ${r.agents.join(', ')}\n`);
63
+ if (r.skills.length) process.stdout.write(` skills: ${r.skills.join(', ')}\n`);
64
+ process.exit(0);
65
+ }
66
+
67
+ // --- seeding (init) ---------------------------------------------------------
68
+
69
+ const AGENTS_README = `# .brain/agents — versioned custom agent definitions
70
+
71
+ Canonical, versioned source for your project's custom agents (Codex \`.toml\` format).
72
+ \`wendkeep sync-defs\` copies each \`*.toml\` here into \`<project>/.codex/agents/\` so the
73
+ agent loads them. Edit here (source of truth); re-run \`wendkeep sync-defs\` after changes.
74
+ `;
75
+
76
+ const EXAMPLE_AGENT = `# Example custom agent (Codex format). Replace with your own.
77
+ name = "example-agent"
78
+ description = "An example custom agent. Describe when to use it."
79
+ developer_instructions = "You are an example agent. State the role, rules, and output format here."
80
+ nickname_candidates = [ "example" ]
81
+ model = "gpt-5.5"
82
+ `;
83
+
84
+ const SKILLS_README = `# .brain/skills — versioned custom skill definitions
85
+
86
+ Canonical, versioned source for your project's custom skills (\`<name>/SKILL.md\`).
87
+ \`wendkeep sync-defs\` copies each skill folder here into \`<project>/.claude/skills/\` so
88
+ the agent loads them. Edit here (source of truth); re-run \`wendkeep sync-defs\` after changes.
89
+ `;
90
+
91
+ const EXAMPLE_SKILL = `---
92
+ name: example-skill
93
+ description: An example custom skill. Replace with your own — describe the trigger here.
94
+ ---
95
+ Use this skill when ... (describe when it applies).
96
+
97
+ Rules:
98
+ - (what the skill should do)
99
+ `;
100
+
101
+ function writeIfAbsent(path, content, created) {
102
+ if (!existsSync(path)) {
103
+ writeFileSync(path, content, 'utf8');
104
+ created.push(path);
105
+ }
106
+ }
107
+
108
+ // Seed the definitions layer in the vault's .brain (folders + README + one example
109
+ // each). Non-destructive. Returns the list of paths created.
110
+ export function seedDefinitions(brainDir) {
111
+ const created = [];
112
+ const agentsDir = join(brainDir, 'agents');
113
+ mkdirSync(agentsDir, { recursive: true });
114
+ writeIfAbsent(join(agentsDir, 'README.md'), AGENTS_README, created);
115
+ writeIfAbsent(join(agentsDir, 'example-agent.toml'), EXAMPLE_AGENT, created);
116
+
117
+ const exampleSkillDir = join(brainDir, 'skills', 'example-skill');
118
+ mkdirSync(exampleSkillDir, { recursive: true });
119
+ writeIfAbsent(join(brainDir, 'skills', 'README.md'), SKILLS_README, created);
120
+ writeIfAbsent(join(exampleSkillDir, 'SKILL.md'), EXAMPLE_SKILL, created);
121
+
122
+ return created;
123
+ }
package/src/taxonomy.mjs CHANGED
@@ -110,11 +110,19 @@ export const COMPANIONS = [
110
110
  default: false,
111
111
  marketplace: { source: 'git', url: 'https://github.com/JuliusBrussee/caveman.git' },
112
112
  plugin: 'caveman',
113
- // No MCP, no agnostic hook: on non-Claude agents, run its own installer.
114
- installer: {
115
- sh: 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh',
116
- ps1: 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1',
117
- },
113
+ // No MCP, no agnostic hook: on non-Claude agents, run its own installer
114
+ // (see cavemanInstallCommand — npx, non-interactive, Gemini excluded).
115
+ },
116
+ {
117
+ id: 'dotcontext',
118
+ label: 'dotcontext — orquestração de execução (PREVC + sensores + gate E→V)',
119
+ default: true,
120
+ // MCP-only (no Claude Code plugin). Agent-agnostic server, @latest surface.
121
+ mcp: { key: 'dotcontext', entry: { type: 'stdio', command: 'npx', args: ['-y', '@dotcontext/mcp@latest'], env: {} } },
122
+ // Lifecycle hooks via the pinned CLI; wired by wendkeep (single writer).
123
+ dotcontextHooks: { cliVersion: '1.1.1', source: 'claude-code' },
124
+ // Seed a starter .context/config (one neutral sensor).
125
+ contextSeed: true,
118
126
  },
119
127
  ];
120
128
 
@@ -142,6 +150,7 @@ export function companionSettingsPatch(ids) {
142
150
  for (const id of ids) {
143
151
  const c = COMPANION_BY_ID[id];
144
152
  if (!c) continue;
153
+ if (!c.marketplace || !c.plugin) continue; // MCP-only companions (e.g. dotcontext) have no plugin layer
145
154
  extraKnownMarketplaces[c.id] = { source: c.marketplace };
146
155
  enabledPlugins[`${c.plugin}@${c.id}`] = true;
147
156
  }
@@ -149,9 +158,13 @@ export function companionSettingsPatch(ids) {
149
158
  }
150
159
 
151
160
  // The .mcp.json fragment (agent-agnostic layer) for MCP-capable companions only.
152
- export function companionMcpPatch(ids) {
161
+ // `skip` omits ids whose MCP is already configured elsewhere (e.g. dotcontext set
162
+ // globally in ~/.claude.json — avoids a duplicate project-scoped server).
163
+ export function companionMcpPatch(ids, skip = []) {
164
+ const skipSet = new Set(skip);
153
165
  const servers = {};
154
166
  for (const id of ids) {
167
+ if (skipSet.has(id)) continue;
155
168
  const c = COMPANION_BY_ID[id];
156
169
  if (c?.mcp) servers[c.mcp.key] = c.mcp.entry;
157
170
  }
@@ -161,7 +174,10 @@ export function companionMcpPatch(ids) {
161
174
  // SessionStart hook specs wendkeep must author for companions that lack a native
162
175
  // one (only Understand-Anything's domain-graph injector today). Same shape as
163
176
  // SESSION_HOOKS so the same merge logic wires them.
164
- export function companionHookSpecs(ids) {
177
+ // `dotcontextHookLevel`: 'full' (SessionStart+Stop+PostToolUse), 'light' (drops the
178
+ // per-tool PostToolUse trace hook — cuts latency when other PostToolUse hooks exist),
179
+ // or 'none' (MCP only, no lifecycle hooks).
180
+ export function companionHookSpecs(ids, { dotcontextHookLevel = 'full' } = {}) {
165
181
  const specs = [];
166
182
  for (const id of ids) {
167
183
  const c = COMPANION_BY_ID[id];
@@ -171,28 +187,54 @@ export function companionHookSpecs(ids) {
171
187
  matcher: null,
172
188
  name: c.wendkeepHook,
173
189
  timeout: 15,
190
+ order: 0,
174
191
  statusMessage: `wendkeep: ${c.id} domain graph`,
175
192
  });
176
193
  }
194
+ // dotcontext: lifecycle hooks (SessionStart last + Stop + PostToolUse) that
195
+ // dispatch to its pinned CLI. They carry an explicit `command` (not a wendkeep
196
+ // hook name) and order 100 so they fold AFTER wendkeep's own session hooks.
197
+ if (c?.dotcontextHooks && dotcontextHookLevel !== 'none') {
198
+ const command = dotcontextHookCommand(c.dotcontextHooks.cliVersion, c.dotcontextHooks.source);
199
+ const base = { command, timeout: 8, order: 100, statusMessage: `${c.id}: harness hook` };
200
+ specs.push({ event: 'SessionStart', matcher: null, ...base });
201
+ specs.push({ event: 'Stop', matcher: null, ...base });
202
+ if (dotcontextHookLevel === 'full') {
203
+ specs.push({ event: 'PostToolUse', matcher: 'Write|Edit|Bash', ...base });
204
+ }
205
+ }
177
206
  }
178
207
  return specs;
179
208
  }
180
209
 
181
- // The caveman installer URL for this platform (.ps1 on Windows, .sh elsewhere).
182
- export function cavemanInstallerUrl(platform = process.platform) {
183
- const { sh, ps1 } = COMPANION_BY_ID.caveman.installer;
184
- return platform === 'win32' ? ps1 : sh;
210
+ // The pinned dotcontext CLI hook-dispatch command (runs on each lifecycle event).
211
+ export function dotcontextHookCommand(cliVersion, source) {
212
+ return `npx -y @dotcontext/cli@${cliVersion} hook dispatch --source ${source}`;
185
213
  }
186
214
 
187
- // Command to run caveman's installer FROM A LOCAL FILE. We download the script to a
188
- // temp file and run it as a file (not `irm|iex` / `curl|bash`) so the script gets a
189
- // real $PSCommandPath/$0 piping to iex leaves that null and the installer aborts
190
- // ("cannot bind argument to parameter 'Path'"). Pure: returns {command, args}.
191
- export function cavemanInstallerCommand(platform = process.platform, scriptPath) {
192
- if (platform === 'win32') {
193
- return { command: 'powershell', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath] };
194
- }
195
- return { command: 'bash', args: [scriptPath] };
215
+ // .context/ subpaths that should be gitignored (runtime/cache/regenerable). wendkeep
216
+ // does not touch git init prints these as a note for the user to add.
217
+ export const DOTCONTEXT_GITIGNORE = [
218
+ '.context/runtime/',
219
+ '.context/cache/',
220
+ '.context/logs/',
221
+ '.context/plans/',
222
+ '.context/agents/',
223
+ '.context/skills/',
224
+ ];
225
+
226
+ // Agents wendkeep installs caveman into via its cross-agent installer. Gemini is
227
+ // excluded on purpose: its CLI rejects caveman's agent tool names and crashes
228
+ // (libuv assertion) mid-install. caveman has no --exclude flag — only an --only
229
+ // allow-list — so we enumerate the agents we want by their stable slugs.
230
+ export const CAVEMAN_AGENTS = ['claude', 'codex', 'cursor', 'copilot', 'amp', 'antigravity'];
231
+
232
+ // Non-interactive command installing caveman to CAVEMAN_AGENTS (never Gemini). Runs
233
+ // the published installer directly via npx (no install.ps1, so no $PSCommandPath
234
+ // issue). Returned as a shell string; the caller spawns it with shell: true.
235
+ export function cavemanInstallCommand(agents = CAVEMAN_AGENTS) {
236
+ const only = agents.flatMap((a) => ['--only', a]).join(' ');
237
+ return `npx -y github:JuliusBrussee/caveman -- --non-interactive ${only}`;
196
238
  }
197
239
 
198
240
  // Derive the default vault folder name from the project folder: `.<project>-vault`.
@@ -0,0 +1,156 @@
1
+ // Memory-compaction protocol for the curated .brain/CORE.md layer.
2
+ // Ported from NutriGym-Vision's scripts/validate-brain-core.js to ESM:
3
+ // - cap 25 lines (hard), 22 (soft warning) — 1 durable item per line
4
+ // - 3 required sections
5
+ // - no secrets / no real-provider PII emails
6
+ // Plus the seeded skeleton and the protocol reference doc.
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { isAbsolute, join, resolve } from 'node:path';
10
+
11
+ const HARD_LIMIT = 25;
12
+ const SOFT_LIMIT = 22;
13
+
14
+ const REQUIRED_SECTIONS = [
15
+ { label: 'Preferências do Usuário', regex: /^##\s+Prefer[êe]ncias\s+do\s+Usu[áa]rio\s*$/im },
16
+ { label: 'Padrões Ativos', regex: /^##\s+Padr[õo]es\s+Ativos\s*$/im },
17
+ { label: 'Pendências Abertas', regex: /^##\s+Pend[êe]ncias\s+Abertas\s*$/im },
18
+ ];
19
+
20
+ // Secret patterns reject only "real" values (length floor); abstract mentions like
21
+ // `sk_*` / `whsec_*` (trailing asterisk) are allowed.
22
+ const SECRET_PATTERNS = [
23
+ { name: 'Stripe secret key', regex: /\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b/ },
24
+ { name: 'Stripe webhook secret', regex: /\bwhsec_[A-Za-z0-9]{20,}\b/ },
25
+ { name: 'JWT token', regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
26
+ { name: 'Bearer token', regex: /\bBearer\s+[A-Za-z0-9._-]{20,}\b/i },
27
+ { name: 'OpenAI API key', regex: /\bsk-[A-Za-z0-9]{40,}\b/ },
28
+ { name: 'Anthropic API key', regex: /\bsk-ant-[A-Za-z0-9_-]{40,}\b/ },
29
+ { name: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
30
+ ];
31
+
32
+ const PII_EMAIL_REGEX = /\b[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net)\b)(?:gmail|hotmail|yahoo|outlook|live|icloud|protonmail)\.[A-Za-z]{2,}\b/i;
33
+
34
+ // Validate CORE.md content. Returns { ok, errors, warnings, lineCount }.
35
+ export function validateCore(content) {
36
+ const text = String(content ?? '');
37
+ const lines = text.split('\n');
38
+ const lineCount = text.endsWith('\n') ? lines.length - 1 : lines.length;
39
+ const errors = [];
40
+
41
+ if (lineCount > HARD_LIMIT) {
42
+ errors.push(`Tamanho ${lineCount} > ${HARD_LIMIT} linhas (hard limit). Curar: remover itens resolvidos (detalhe vive no vault/git).`);
43
+ }
44
+ for (const { label, regex } of REQUIRED_SECTIONS) {
45
+ if (!regex.test(text)) errors.push(`Seção obrigatória ausente: ## ${label}`);
46
+ }
47
+ for (const { name, regex } of SECRET_PATTERNS) {
48
+ const m = text.match(regex);
49
+ if (m) errors.push(`Possível ${name} detectado: "${m[0].slice(0, 30)}..." — substituir por [REDACTED_SECRET].`);
50
+ }
51
+ const em = text.match(PII_EMAIL_REGEX);
52
+ if (em) errors.push(`Email real detectado: "${em[0]}" — usar user@example.com.`);
53
+
54
+ const warnings = [];
55
+ if (lineCount >= SOFT_LIMIT && lineCount <= HARD_LIMIT) {
56
+ warnings.push(`Tamanho ${lineCount}/${HARD_LIMIT} linhas — perto do limite; remover itens resolvidos (≥${SOFT_LIMIT}).`);
57
+ }
58
+
59
+ return { ok: errors.length === 0, errors, warnings, lineCount };
60
+ }
61
+
62
+ // The seeded CORE.md (must pass validateCore). Bootstraps the 3 sections so the
63
+ // curated hot layer exists with the right shape from day one.
64
+ export function renderCoreSkeleton() {
65
+ return `# CORE — núcleo curado da memória (.brain)
66
+
67
+ > REGRA #1 — memória canônica do projeto. Curado à mão, cap 25 linhas (valide: \`wendkeep validate-memory\`). Volátil vive no DIGEST.md (auto). Profundidade: /brain-recall <tópico>.
68
+
69
+ ## Preferências do Usuário
70
+ - (preferências duráveis: idioma, estilo, convenções)
71
+
72
+ ## Padrões Ativos
73
+ - (padrões/arquitetura ativos que outro agente precise saber)
74
+
75
+ ## Pendências Abertas
76
+ - (pendências/decisões em aberto — remova quando resolvidas)
77
+ `;
78
+ }
79
+
80
+ // The compaction-protocol reference doc dropped into the vault.
81
+ export function renderCompactionProtocol() {
82
+ return `# Protocolo de Memória — núcleo curado + digest automático (.brain)
83
+
84
+ > Como cada agente recebe, consulta e persiste memória entre sessões no seu vault.
85
+
86
+ ## 1. Duas camadas
87
+
88
+ - **QUENTE** (auto-injetada por sessão, budget ~45 linhas):
89
+ - \`.brain/CORE.md\` — curado à mão, **≤25 linhas** (1 item/linha): preferências, padrões, pendências.
90
+ - \`.brain/DIGEST.md\` — auto-gerado (0 token LLM, ≤15 linhas): decisões/sessões/bugs/aprendizados recentes.
91
+ - **FRIA** (sob demanda):
92
+ - \`.brain/index.jsonl\` — índice de todas as sessões (1/linha, frontmatter).
93
+ - Vault: \`02-Sessões/**\`, \`04-Decisões/**\`, \`05-Bugs/**\`, \`06-Aprendizados/**\`. Desce via \`/brain-recall <tópico>\`.
94
+
95
+ ## 2. Compactação = regra de geração (sem trabalho manual)
96
+
97
+ - **DIGEST se auto-compacta**: caps determinísticos (5 decisões, 4 sessões, 2 bugs, 2 aprendizados + \`+N mais\`). O velho cai do quente sozinho e permanece no índice/vault. **NUNCA editar** \`DIGEST.md\`/\`index.jsonl\`.
98
+ - **CORE**: quando ≥22 linhas (soft warning), remover itens resolvidos/obsoletos — o detalhe já vive no vault e no histórico do git.
99
+
100
+ ## 3. O que escrever no CORE
101
+
102
+ Só estado **durável** que outro agente precise saber — preferência, padrão ativo, pendência aberta. 1 linha por item. Nunca log de sessão (isso é automático no vault).
103
+
104
+ 3 seções fixas (obrigatórias): \`## Preferências do Usuário\`, \`## Padrões Ativos\`, \`## Pendências Abertas\`.
105
+
106
+ ## 4. Sem segredos / PII
107
+
108
+ \`CORE.md\` nunca contém tokens (\`sk_*\`, \`whsec_*\`, JWT, Bearer), API keys, senhas ou email/telefone real. Use \`[REDACTED_SECRET]\` / \`user@example.com\`.
109
+
110
+ ## 5. Validação
111
+
112
+ \`\`\`bash
113
+ wendkeep validate-memory # valida <vault>/.brain/CORE.md
114
+ wendkeep validate-memory <path> # valida outro arquivo
115
+ \`\`\`
116
+
117
+ Checa: cap 25 (soft 22), 3 seções, sem segredos/PII. Exit 0 = OK, 1 = falha.
118
+ `;
119
+ }
120
+
121
+ // CLI entry for `wendkeep validate-memory [path]`. Resolves the target from an
122
+ // explicit path, else <vault>/.brain/CORE.md (--vault or OBSIDIAN_VAULT_PATH).
123
+ export function runValidateMemory(argv) {
124
+ let target;
125
+ let vault;
126
+ for (let i = 0; i < argv.length; i += 1) {
127
+ const a = argv[i];
128
+ if (a === '--vault') vault = argv[++i];
129
+ else if (a.startsWith('--vault=')) vault = a.slice(8);
130
+ else if (!a.startsWith('-')) target = a;
131
+ }
132
+ if (!target) {
133
+ const base = vault || process.env.OBSIDIAN_VAULT_PATH;
134
+ if (!base) {
135
+ process.stderr.write('wendkeep validate-memory: no target. Pass a path, --vault <path>, or set OBSIDIAN_VAULT_PATH.\n');
136
+ process.exit(2);
137
+ }
138
+ target = join(base, '.brain', 'CORE.md');
139
+ }
140
+ const abs = isAbsolute(target) ? target : resolve(process.cwd(), target);
141
+ if (!existsSync(abs)) {
142
+ process.stderr.write(`wendkeep validate-memory: not found: ${abs}\n`);
143
+ process.exit(2);
144
+ }
145
+ const res = validateCore(readFileSync(abs, 'utf8'));
146
+ if (!res.ok) {
147
+ process.stderr.write(`❌ CORE.md viola protocolo (${res.errors.length} erro${res.errors.length > 1 ? 's' : ''}):\n`);
148
+ for (const e of res.errors) process.stderr.write(` - ${e}\n`);
149
+ process.stderr.write('\nProtocolo: .brain/COMPACTION_PROTOCOL.md\n');
150
+ process.exit(1);
151
+ }
152
+ let msg = `✅ CORE.md OK (${res.lineCount} linhas, 3/3 seções, sem segredos).`;
153
+ for (const w of res.warnings) msg += `\n ⚠ ${w}`;
154
+ process.stdout.write(`${msg}\n`);
155
+ process.exit(0);
156
+ }