wendkeep 0.58.3 → 0.60.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.en.md +45 -3
  3. package/README.md +45 -3
  4. package/bin/wendkeep.mjs +54 -6
  5. package/docs/en/commands/changes-and-verification.md +9 -3
  6. package/docs/en/commands/getting-started.md +7 -3
  7. package/docs/en/commands/memory.md +20 -2
  8. package/docs/en/commands/operating-profiles.md +173 -0
  9. package/docs/en/commands/sessions-and-import.md +8 -4
  10. package/docs/en/commands/verify.md +12 -6
  11. package/docs/pt-BR/commands/changes-and-verification.md +9 -4
  12. package/docs/pt-BR/commands/getting-started.md +7 -3
  13. package/docs/pt-BR/commands/memory.md +18 -2
  14. package/docs/pt-BR/commands/operating-profiles.md +171 -0
  15. package/docs/pt-BR/commands/sessions-and-import.md +7 -3
  16. package/docs/pt-BR/commands/verify.md +11 -5
  17. package/hooks/brain-core.mjs +159 -159
  18. package/hooks/brain-inject.mjs +83 -26
  19. package/hooks/brain-recall.mjs +32 -32
  20. package/hooks/brain-reindex.mjs +13 -13
  21. package/hooks/change-context.mjs +24 -10
  22. package/hooks/change-core.mjs +174 -37
  23. package/hooks/change-guard.mjs +115 -16
  24. package/hooks/change-nag.mjs +20 -5
  25. package/hooks/change-warn.mjs +27 -9
  26. package/hooks/decision-capture.mjs +1 -1
  27. package/hooks/derived-sections.mjs +1 -1
  28. package/hooks/flow-core.mjs +891 -0
  29. package/hooks/flow-protected-policy.mjs +218 -0
  30. package/hooks/frontmatter-repair.mjs +3 -1
  31. package/hooks/git-snapshot.mjs +722 -0
  32. package/hooks/import-sessions.mjs +10 -5
  33. package/hooks/memory-mode.mjs +63 -13
  34. package/hooks/memory-store.mjs +309 -69
  35. package/hooks/obsidian-common.mjs +39 -55
  36. package/hooks/operating-profile-runtime.mjs +157 -0
  37. package/hooks/plan-capture.mjs +14 -3
  38. package/hooks/sensors-core.mjs +15 -3
  39. package/hooks/session-backfill.mjs +7 -2
  40. package/hooks/session-ensure.mjs +6 -4
  41. package/hooks/session-iteration.mjs +65 -0
  42. package/hooks/session-memory-lifecycle.mjs +10 -5
  43. package/hooks/session-note-io.mjs +130 -15
  44. package/hooks/session-observability.mjs +4 -2
  45. package/hooks/session-stop.mjs +65 -19
  46. package/hooks/spec-core.mjs +91 -12
  47. package/hooks/subagent-stop.mjs +4 -1
  48. package/hooks/subagent-usage.mjs +2 -2
  49. package/hooks/task-log.mjs +3 -1
  50. package/hooks/token-usage.mjs +1 -1
  51. package/hooks/vault-health.mjs +183 -37
  52. package/hooks/vault-path-safety.mjs +2 -0
  53. package/hooks/vault-runtime-store.mjs +558 -0
  54. package/package.json +10 -3
  55. package/packages/cli/package.json +5 -0
  56. package/packages/harness/package.json +5 -0
  57. package/packages/integrations/package.json +5 -0
  58. package/packages/mcp/package.json +5 -0
  59. package/packages/pi/package.json +5 -0
  60. package/packages/vault/package.json +6 -0
  61. package/packages/vault/src/index.mjs +2 -0
  62. package/packages/vault/src/project-vault.mjs +327 -0
  63. package/packages/vault/src/vault-path-safety.mjs +558 -0
  64. package/src/change.mjs +2 -1
  65. package/src/flow.mjs +232 -0
  66. package/src/init.mjs +26 -3
  67. package/src/memory.mjs +785 -35
  68. package/src/operating-profile.mjs +133 -0
  69. package/src/profile.mjs +224 -0
  70. package/src/project-vault.mjs +2 -221
  71. package/src/rebuild-costs.mjs +11 -4
  72. package/src/skills-seed.mjs +38 -16
  73. package/src/sync-defs.mjs +16 -7
  74. package/src/sync.mjs +9 -1
  75. package/src/taxonomy.mjs +8 -0
  76. package/src/validate-memory.mjs +21 -8
  77. package/src/verify.mjs +12 -2
@@ -1,159 +1,159 @@
1
- // .agent/hooks/brain-core.mjs
2
- // Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
3
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { basename, join } from 'node:path';
5
- import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
- import { getLocale } from './locale.mjs';
7
-
8
- export function brainDir(vaultBase) {
9
- return join(vaultBase, '.brain');
10
- }
11
-
12
- // Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
13
- export function parseFrontmatter(content) {
14
- const m = content.match(/^---\n([\s\S]*?)\n---/);
15
- if (!m) return {};
16
- const data = {};
17
- const lines = m[1].split('\n');
18
- for (let i = 0; i < lines.length; i++) {
19
- const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
20
- if (!kv) continue;
21
- const key = kv[1];
22
- const val = kv[2];
23
- if (val === '') {
24
- const list = [];
25
- while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
26
- list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
27
- }
28
- data[key] = list.length ? list : '';
29
- } else {
30
- data[key] = stripYamlQuotes(val.trim());
31
- }
32
- }
33
- return data;
34
- }
35
-
36
- function walkMd(dir) {
37
- const out = [];
38
- let entries;
39
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
40
- for (const e of entries) {
41
- const fp = join(dir, e.name);
42
- if (e.isDirectory()) out.push(...walkMd(fp));
43
- else if (e.name.endsWith('.md')) out.push(fp);
44
- }
45
- return out;
46
- }
47
-
48
- const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
49
- function derivedLinks(content) {
50
- const dec = new Set(), bug = new Set(), lea = new Set();
51
- let m;
52
- while ((m = DERIVED_RE.exec(content))) {
53
- const t = m[1];
54
- if (t.startsWith('04-')) dec.add(t);
55
- else if (t.startsWith('05-')) bug.add(t);
56
- else if (t.startsWith('06-')) lea.add(t);
57
- }
58
- return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
59
- }
60
-
61
- // Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
62
- export function buildBrainIndex(vaultBase) {
63
- const rows = [];
64
- for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
65
- let content;
66
- try { content = readFileSync(fp, 'utf8'); } catch { continue; }
67
- const fm = parseFrontmatter(content);
68
- if (fm.type && fm.type !== 'session') continue;
69
- const der = derivedLinks(content);
70
- rows.push({
71
- session_id: fm.session_id || '',
72
- date: fm.date || '',
73
- provider: fm.provider || '',
74
- status: fm.status || '',
75
- summary: fm.summary || '',
76
- file: toVaultRelative(vaultBase, fp),
77
- tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
78
- decisions: der.decisions,
79
- bugs: der.bugs,
80
- learnings: der.learnings,
81
- });
82
- }
83
- rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
84
- ensureDir(brainDir(vaultBase));
85
- const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
86
- writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
87
- return rows;
88
- }
89
-
90
- // Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
91
- export function loadIndex(vaultBase) {
92
- try {
93
- return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
94
- .split('\n').filter(Boolean).map((l) => JSON.parse(l));
95
- } catch {
96
- return [];
97
- }
98
- }
99
-
100
- const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
101
-
102
- function adrNumber(path) {
103
- const m = path.match(/ADR-(\d+)/);
104
- return m ? Number(m[1]) : -1;
105
- }
106
-
107
- // Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
108
- // Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
109
- export function buildBrainDigest(vaultBase, rows = null) {
110
- const data = rows ?? loadIndex(vaultBase);
111
- const byDateDesc = [...data].sort((a, b) =>
112
- String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
113
-
114
- const seen = new Set();
115
- const pick = (kind, max) => {
116
- const out = [];
117
- for (const r of byDateDesc) {
118
- for (const p of r[kind] || []) {
119
- if (out.length >= max) return out;
120
- if (!seen.has(p)) { seen.add(p); out.push(p); }
121
- }
122
- }
123
- return out;
124
- };
125
-
126
- // The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
127
- // context. Keep only targets that resolve to a real note (by vault-relative path or basename)
128
- // and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
129
- // caps still fill after filtering.
130
- const known = new Set();
131
- for (const r of data) {
132
- const rel = String(r.file || '').replace(/\.md$/i, '');
133
- if (rel) { known.add(rel); known.add(basename(rel)); }
134
- }
135
- const resolves = (p) => {
136
- const t = String(p || '').replace(/\.md$/i, '').trim();
137
- if (!t || t.includes('...') || t.includes('…')) return false;
138
- return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
139
- };
140
- const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
141
-
142
- const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
143
- const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
144
- const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
145
- const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
146
-
147
- const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
148
- for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
149
- for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
150
- for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
151
- for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
152
-
153
- const shown = sessions.length;
154
- if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
155
-
156
- ensureDir(brainDir(vaultBase));
157
- writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
158
- return lines;
159
- }
1
+ // .agent/hooks/brain-core.mjs
2
+ // Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
3
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { basename, join } from 'node:path';
5
+ import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
+ import { getLocale } from './locale.mjs';
7
+
8
+ export function brainDir(vaultBase) {
9
+ return join(vaultBase, '.brain');
10
+ }
11
+
12
+ // Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
13
+ export function parseFrontmatter(content) {
14
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
15
+ if (!m) return {};
16
+ const data = {};
17
+ const lines = m[1].split('\n');
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
20
+ if (!kv) continue;
21
+ const key = kv[1];
22
+ const val = kv[2];
23
+ if (val === '') {
24
+ const list = [];
25
+ while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
26
+ list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
27
+ }
28
+ data[key] = list.length ? list : '';
29
+ } else {
30
+ data[key] = stripYamlQuotes(val.trim());
31
+ }
32
+ }
33
+ return data;
34
+ }
35
+
36
+ function walkMd(dir) {
37
+ const out = [];
38
+ let entries;
39
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
40
+ for (const e of entries) {
41
+ const fp = join(dir, e.name);
42
+ if (e.isDirectory()) out.push(...walkMd(fp));
43
+ else if (e.name.endsWith('.md')) out.push(fp);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
49
+ function derivedLinks(content) {
50
+ const dec = new Set(), bug = new Set(), lea = new Set();
51
+ let m;
52
+ while ((m = DERIVED_RE.exec(content))) {
53
+ const t = m[1];
54
+ if (t.startsWith('04-')) dec.add(t);
55
+ else if (t.startsWith('05-')) bug.add(t);
56
+ else if (t.startsWith('06-')) lea.add(t);
57
+ }
58
+ return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
59
+ }
60
+
61
+ // Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
62
+ export function buildBrainIndex(vaultBase) {
63
+ const rows = [];
64
+ for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
65
+ let content;
66
+ try { content = readFileSync(fp, 'utf8'); } catch { continue; }
67
+ const fm = parseFrontmatter(content);
68
+ if (fm.type && fm.type !== 'session') continue;
69
+ const der = derivedLinks(content);
70
+ rows.push({
71
+ session_id: fm.session_id || '',
72
+ date: fm.date || '',
73
+ provider: fm.provider || '',
74
+ status: fm.status || '',
75
+ summary: fm.summary || '',
76
+ file: toVaultRelative(vaultBase, fp),
77
+ tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
78
+ decisions: der.decisions,
79
+ bugs: der.bugs,
80
+ learnings: der.learnings,
81
+ });
82
+ }
83
+ rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
84
+ ensureDir(brainDir(vaultBase));
85
+ const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
86
+ writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
87
+ return rows;
88
+ }
89
+
90
+ // Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
91
+ export function loadIndex(vaultBase) {
92
+ try {
93
+ return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
94
+ .split('\n').filter(Boolean).map((l) => JSON.parse(l));
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
101
+
102
+ function adrNumber(path) {
103
+ const m = path.match(/ADR-(\d+)/);
104
+ return m ? Number(m[1]) : -1;
105
+ }
106
+
107
+ // Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
108
+ // Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
109
+ export function buildBrainDigest(vaultBase, rows = null) {
110
+ const data = rows ?? loadIndex(vaultBase);
111
+ const byDateDesc = [...data].sort((a, b) =>
112
+ String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
113
+
114
+ const seen = new Set();
115
+ const pick = (kind, max) => {
116
+ const out = [];
117
+ for (const r of byDateDesc) {
118
+ for (const p of r[kind] || []) {
119
+ if (out.length >= max) return out;
120
+ if (!seen.has(p)) { seen.add(p); out.push(p); }
121
+ }
122
+ }
123
+ return out;
124
+ };
125
+
126
+ // The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
127
+ // context. Keep only targets that resolve to a real note (by vault-relative path or basename)
128
+ // and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
129
+ // caps still fill after filtering.
130
+ const known = new Set();
131
+ for (const r of data) {
132
+ const rel = String(r.file || '').replace(/\.md$/i, '');
133
+ if (rel) { known.add(rel); known.add(basename(rel)); }
134
+ }
135
+ const resolves = (p) => {
136
+ const t = String(p || '').replace(/\.md$/i, '').trim();
137
+ if (!t || t.includes('...') || t.includes('…')) return false;
138
+ return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
139
+ };
140
+ const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
141
+
142
+ const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
143
+ const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
144
+ const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
145
+ const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
146
+
147
+ const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
148
+ for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
149
+ for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
150
+ for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
151
+ for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
152
+
153
+ const shown = sessions.length;
154
+ if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
155
+
156
+ ensureDir(brainDir(vaultBase));
157
+ writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
158
+ return lines;
159
+ }
@@ -2,10 +2,10 @@
2
2
  // Injeção da camada quente no SessionStart (Claude/Codex/Copilot): CORE curado +
3
3
  // SHARED operacional no v2; DIGEST fica só no fallback legado/recall. Nunca derruba o hook.
4
4
  // Uso (hook): node .agent/hooks/brain-inject.mjs (input JSON via stdin)
5
- import { existsSync, readFileSync } from 'node:fs';
5
+ import { readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
- import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
8
+ import { readHookInput, writeHookOutput } from './obsidian-common.mjs';
9
9
  import { brainDir } from './brain-core.mjs';
10
10
  import { buildActiveChangeInjection, changeCtxState, writeSentinel } from './change-core.mjs';
11
11
  import { buildLessonsInjection } from './lessons-core.mjs';
@@ -13,6 +13,12 @@ import { getLocale } from './locale.mjs';
13
13
  import { resolveSessionEntry } from './session-identity.mjs';
14
14
  import { sanitizeMemoryText, validateSharedMemory } from './memory-schema.mjs';
15
15
  import { detectMemoryMode } from './memory-mode.mjs';
16
+ import {
17
+ hookProfilePolicy,
18
+ profileSentinelId,
19
+ resolveHookOperatingProfile,
20
+ } from './operating-profile-runtime.mjs';
21
+ import { assertVaultPathSafe } from './vault-path-safety.mjs';
16
22
  import { validateCore } from '../src/validate-core.mjs';
17
23
 
18
24
  // The process ROUTER — the enforcement layer. The wk-* skills are passive files; without a
@@ -53,9 +59,27 @@ const INJECTION_LIMITS = Object.freeze({
53
59
  recallBytes: 512,
54
60
  });
55
61
 
56
- function readMemoryFile(dir, name) {
57
- try { return readFileSync(join(dir, name), 'utf8').replace(/\r\n/g, '\n').trim(); }
58
- catch { return ''; }
62
+ function aliasBoundaryError(error) {
63
+ return error?.code === 'VAULT_PATH_UNSAFE'
64
+ && /link simbólico|junction|reparse|hardlink|nlink|redirecion|escapa logicamente/i
65
+ .test(String(error?.message || error));
66
+ }
67
+
68
+ function readMemoryFile(vaultBase, name) {
69
+ const path = join(brainDir(vaultBase), name);
70
+ const label = `camada de memória ${name}`;
71
+ try {
72
+ let checked = assertVaultPathSafe(vaultBase, path, { expectedType: 'file', label });
73
+ if (!checked.exists) return '';
74
+ checked = assertVaultPathSafe(vaultBase, checked.target, {
75
+ allowMissing: false, expectedType: 'file', label,
76
+ });
77
+ return readFileSync(checked.target, 'utf8').replace(/\r\n/g, '\n').trim();
78
+ }
79
+ catch (error) {
80
+ if (aliasBoundaryError(error)) throw error;
81
+ return '';
82
+ }
59
83
  }
60
84
 
61
85
  function byteLength(value) {
@@ -78,6 +102,17 @@ function safeError(layer, reasons) {
78
102
  return `<wk_memory_error layer="${layer}" repair="wendkeep memory status --gate">${visible}</wk_memory_error>`;
79
103
  }
80
104
 
105
+ export function profileRuntimeError(diagnostic) {
106
+ if (!diagnostic) return '';
107
+ const code = diagnostic.code || 'WENDKEEP_PROFILE_RESOLUTION_ERROR';
108
+ const detail = sanitizeMemoryText(diagnostic.message || String(diagnostic))
109
+ .replace(/\s+/g, ' ')
110
+ .trim();
111
+ const max = 320;
112
+ const visible = detail.length <= max ? detail : `${detail.slice(0, max - 20)} … [erro resumido]`;
113
+ return `<wk_profile_error code="${xmlAttr(code)}" fallback="GOVERN" repair="wendkeep doctor">${visible}</wk_profile_error>`;
114
+ }
115
+
81
116
  function validateCoreLayer(raw) {
82
117
  if (!raw) return { ok: false, rendered: safeError('core', ['CORE.md ausente']) };
83
118
  const sanitized = sanitizeMemoryText(raw);
@@ -105,9 +140,9 @@ function validateSharedLayer(raw) {
105
140
  : { ok: false, rendered: safeError('shared', validation.errors), metadata: validation.metadata };
106
141
  }
107
142
 
108
- function buildV2Memory(dir) {
109
- const core = validateCoreLayer(readMemoryFile(dir, 'CORE.md'));
110
- const shared = validateSharedLayer(readMemoryFile(dir, 'SHARED_MEMORY.md'));
143
+ function buildV2Memory(vaultBase) {
144
+ const core = validateCoreLayer(readMemoryFile(vaultBase, 'CORE.md'));
145
+ const shared = validateSharedLayer(readMemoryFile(vaultBase, 'SHARED_MEMORY.md'));
111
146
  const revision = shared.metadata?.revision ?? 'unknown';
112
147
  const stateHash = shared.metadata?.state_hash ?? 'unknown';
113
148
  const attention = [core, shared].every((layer) => layer.ok)
@@ -156,10 +191,10 @@ function validateLegacyLayer(raw, layer, { maxLines, maxBytes }) {
156
191
  return errors.length ? safeError(layer.toLowerCase(), errors) : sanitized;
157
192
  }
158
193
 
159
- function buildLegacyMemory(dir) {
160
- const coreRaw = readMemoryFile(dir, 'CORE.md');
194
+ function buildLegacyMemory(vaultBase) {
195
+ const coreRaw = readMemoryFile(vaultBase, 'CORE.md');
161
196
  const coreValidation = coreRaw ? validateCoreLayer(coreRaw) : { ok: true, rendered: '' };
162
- const digest = validateLegacyLayer(readMemoryFile(dir, 'DIGEST.md'), 'DIGEST', { maxLines: 15, maxBytes: 4096 });
197
+ const digest = validateLegacyLayer(readMemoryFile(vaultBase, 'DIGEST.md'), 'DIGEST', { maxLines: 15, maxBytes: 4096 });
163
198
  const pointer = 'Memória profunda sob demanda: /brain-recall <tópico> (índice .brain/index.jsonl).';
164
199
  return [
165
200
  '<brain_memory>',
@@ -185,24 +220,34 @@ function budgetNotice(priority, layer, message) {
185
220
  return `<wk_budget_notice priority="${priority}" layer="${layer}">${message}</wk_budget_notice>`;
186
221
  }
187
222
 
188
- export function buildInjection(vaultBase, input = {}) {
189
- const dir = brainDir(vaultBase);
190
- const brain = detectMemoryMode(vaultBase).mode === 'v2' ? buildV2Memory(dir) : buildLegacyMemory(dir);
191
- const router = processRouter(getLocale(vaultBase).id);
223
+ export function buildInjection(vaultBase, input = {}, { profile = 'GOVERN', bindingError = null } = {}) {
224
+ const brain = detectMemoryMode(vaultBase).mode === 'v2'
225
+ ? buildV2Memory(vaultBase)
226
+ : buildLegacyMemory(vaultBase);
227
+ const policy = hookProfilePolicy(profile);
228
+ const lessons = buildLessonsInjection(vaultBase, { maxLineChars: INJECTION_LIMITS.lineChars });
229
+ const profileNotice = profileRuntimeError(bindingError);
230
+
231
+ // OFF keeps only the persistent project memory layers. No process/change/gate state crosses
232
+ // the vault -> harness boundary in this profile.
233
+ if (!policy.harness) return joinInjection([brain, profileNotice, lessons]);
234
+
235
+ // FLOW executes and validates without demanding a change. Explicitly opened changes remain
236
+ // visible below, but the standing change router would contradict that contract.
237
+ const router = policy.requiresChange ? processRouter(getLocale(vaultBase).id) : '';
192
238
  const { identity, entry } = resolveSessionEntry(vaultBase, input);
193
239
  const focus = identity.state === 'resolved' && entry?.change_slug
194
240
  ? `<session_change>${boundAncillaryText(`Change vinculada a esta sessão: ${entry.change_slug}. Este vínculo prevalece para writes automáticos; todas as pendências continuam visíveis acima.`, '<session_change></session_change>'.length)}</session_change>`
195
241
  : '';
196
242
  const allChanges = buildActiveChangeInjection(vaultBase, { maxLineChars: INJECTION_LIMITS.lineChars });
197
- const lessons = buildLessonsInjection(vaultBase, { maxLineChars: INJECTION_LIMITS.lineChars });
198
243
 
199
244
  // Global priority is deterministic: memory/router/focus, then changes, then lessons.
200
- let output = joinInjection([brain, router, focus, allChanges, lessons]);
245
+ let output = joinInjection([brain, profileNotice, router, focus, allChanges, lessons]);
201
246
  if (byteLength(output) <= INJECTION_LIMITS.totalBytes) return output;
202
247
 
203
248
  // First pressure step: lessons are fully removable and remain available in the vault.
204
249
  const lessonsEvicted = budgetNotice(1, 'lessons', 'Lessons omitidas primeiro pelo budget global.');
205
- output = joinInjection([brain, router, focus, allChanges, lessonsEvicted]);
250
+ output = joinInjection([brain, profileNotice, router, focus, allChanges, lessonsEvicted]);
206
251
  if (byteLength(output) <= INJECTION_LIMITS.totalBytes) return output;
207
252
 
208
253
  // Second pressure step: non-current changes leave the hot context before the current one.
@@ -211,12 +256,12 @@ export function buildInjection(vaultBase, input = {}) {
211
256
  currentOnly: true,
212
257
  maxLineChars: INJECTION_LIMITS.lineChars,
213
258
  });
214
- output = joinInjection([brain, router, focus, currentChange, lessonsEvicted, nonCurrentEvicted]);
259
+ output = joinInjection([brain, profileNotice, router, focus, currentChange, lessonsEvicted, nonCurrentEvicted]);
215
260
  if (byteLength(output) <= INJECTION_LIMITS.totalBytes) return output;
216
261
 
217
262
  // Last step caps only the current change block, with an explicit marker and closed wrapper.
218
263
  const currentSummarized = budgetNotice(3, 'current-change', 'Change atual resumida por último; blocker e início da fila foram preservados.');
219
- const fixed = joinInjection([brain, router, focus, lessonsEvicted, nonCurrentEvicted, currentSummarized]);
264
+ const fixed = joinInjection([brain, profileNotice, router, focus, lessonsEvicted, nonCurrentEvicted, currentSummarized]);
220
265
  const remaining = Math.max(512, INJECTION_LIMITS.totalBytes - byteLength(fixed) - 1);
221
266
  const boundedCurrent = buildActiveChangeInjection(vaultBase, {
222
267
  currentOnly: true,
@@ -225,6 +270,7 @@ export function buildInjection(vaultBase, input = {}) {
225
270
  });
226
271
  return joinInjection([
227
272
  brain,
273
+ profileNotice,
228
274
  router,
229
275
  focus,
230
276
  lessonsEvicted,
@@ -237,23 +283,34 @@ export function buildInjection(vaultBase, input = {}) {
237
283
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
238
284
  try {
239
285
  const input = readHookInput();
240
- const vaultBase = getVaultBase(input);
286
+ const runtime = resolveHookOperatingProfile({ input });
287
+ const vaultBase = runtime.vaultBase;
241
288
  writeHookOutput({
242
289
  hookSpecificOutput: {
243
290
  hookEventName: 'SessionStart',
244
- additionalContext: buildInjection(vaultBase, input),
291
+ additionalContext: buildInjection(vaultBase, input, {
292
+ profile: runtime.profile,
293
+ bindingError: runtime.bindingError,
294
+ }),
245
295
  },
246
296
  });
247
297
  // Sentinela do change-context: o backlog completo acabou de ser injetado aqui, então o hook
248
298
  // UserPromptSubmit não precisa re-pingar no 1º prompt. Bônus — nunca derruba a injeção.
249
- try {
299
+ if (runtime.policy.harness && !runtime.bindingError) try {
250
300
  const st = changeCtxState(vaultBase);
251
- const { identity } = resolveSessionEntry(vaultBase, input);
301
+ const { identity } = runtime;
252
302
  const sid = identity.state === 'resolved' ? identity.canonicalConversationId : (input.session_id || input.sessionId || '');
253
- if (st) writeSentinel(vaultBase, 'ctx', sid, st.hash);
303
+ if (st) {
304
+ writeSentinel(vaultBase, 'ctx', profileSentinelId(sid, runtime.profile), st.hash);
305
+ }
254
306
  } catch { /* sentinela é bônus */ }
255
307
  } catch (error) {
256
308
  process.stderr.write(`[brain] inject falhou: ${error.message}\n`);
257
- writeHookOutput({});
309
+ writeHookOutput({
310
+ hookSpecificOutput: {
311
+ hookEventName: 'SessionStart',
312
+ additionalContext: profileRuntimeError(error),
313
+ },
314
+ });
258
315
  }
259
316
  }
@@ -1,32 +1,32 @@
1
- // .agent/hooks/brain-recall.mjs
2
- // Query engine read-only: pontua o índice por tópico. Token só no resultado.
3
- // Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
4
- import { pathToFileURL } from 'node:url';
5
- import { getVaultBase } from './obsidian-common.mjs';
6
- import { loadIndex } from './brain-core.mjs';
7
-
8
- export { loadIndex };
9
-
10
- export function scoreRows(rows, query, topK = 5) {
11
- const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
12
- if (!terms.length) return [];
13
- return rows
14
- .map((r) => {
15
- // Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
16
- // os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
17
- const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
18
- let score = 0;
19
- for (const t of terms) if (hay.includes(t)) score++;
20
- return { row: r, score };
21
- })
22
- .filter((s) => s.score > 0)
23
- .sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
24
- .slice(0, topK)
25
- .map((s) => s.row);
26
- }
27
-
28
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
29
- const vaultBase = getVaultBase();
30
- const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
31
- process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
32
- }
1
+ // .agent/hooks/brain-recall.mjs
2
+ // Query engine read-only: pontua o índice por tópico. Token só no resultado.
3
+ // Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getVaultBase } from './obsidian-common.mjs';
6
+ import { loadIndex } from './brain-core.mjs';
7
+
8
+ export { loadIndex };
9
+
10
+ export function scoreRows(rows, query, topK = 5) {
11
+ const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
12
+ if (!terms.length) return [];
13
+ return rows
14
+ .map((r) => {
15
+ // Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
16
+ // os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
17
+ const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
18
+ let score = 0;
19
+ for (const t of terms) if (hay.includes(t)) score++;
20
+ return { row: r, score };
21
+ })
22
+ .filter((s) => s.score > 0)
23
+ .sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
24
+ .slice(0, topK)
25
+ .map((s) => s.row);
26
+ }
27
+
28
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
29
+ const vaultBase = getVaultBase();
30
+ const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
31
+ process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
32
+ }
@@ -1,13 +1,13 @@
1
- // .agent/hooks/brain-reindex.mjs
2
- // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
- // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
- import { pathToFileURL } from 'node:url';
5
- import { getVaultBase } from './obsidian-common.mjs';
6
- import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
-
8
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
- const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
- const rows = buildBrainIndex(vaultBase);
11
- const digest = buildBrainDigest(vaultBase, rows);
12
- process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
- }
1
+ // .agent/hooks/brain-reindex.mjs
2
+ // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
+ // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getVaultBase } from './obsidian-common.mjs';
6
+ import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
+
8
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
+ const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
+ const rows = buildBrainIndex(vaultBase);
11
+ const digest = buildBrainDigest(vaultBase, rows);
12
+ process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
+ }