wendkeep 0.58.3 → 0.59.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 (68) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.en.md +41 -3
  3. package/README.md +41 -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 +558 -0
  53. package/hooks/vault-runtime-store.mjs +558 -0
  54. package/package.json +3 -3
  55. package/src/change.mjs +2 -1
  56. package/src/flow.mjs +232 -0
  57. package/src/init.mjs +26 -3
  58. package/src/memory.mjs +785 -35
  59. package/src/operating-profile.mjs +133 -0
  60. package/src/profile.mjs +224 -0
  61. package/src/project-vault.mjs +110 -5
  62. package/src/rebuild-costs.mjs +11 -4
  63. package/src/skills-seed.mjs +38 -16
  64. package/src/sync-defs.mjs +16 -7
  65. package/src/sync.mjs +9 -1
  66. package/src/taxonomy.mjs +8 -0
  67. package/src/validate-memory.mjs +21 -8
  68. package/src/verify.mjs +12 -2
@@ -6,9 +6,14 @@
6
6
  // Skill wk-workflow ANTES de editar — 1x por sessão. É o empurrão de ativação da skill.
7
7
  // Fail-open; brain-inject grava a sentinela ctx no SessionStart para não duplicar no 1º prompt.
8
8
  import { pathToFileURL } from 'node:url';
9
- import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
9
+ import { readHookInput, writeHookOutput } from './obsidian-common.mjs';
10
+ import { profileRuntimeError } from './brain-inject.mjs';
10
11
  import { changeCtxState, readSentinel, renderOpenChanges, writeSentinel } from './change-core.mjs';
11
- import { resolveSessionEntry } from './session-identity.mjs';
12
+ import {
13
+ hookProfilePolicy,
14
+ profileSentinelId,
15
+ resolveHookOperatingProfile,
16
+ } from './operating-profile-runtime.mjs';
12
17
 
13
18
  // Conservador de propósito: verbos de tarefa comuns (pt+en) + tamanho mínimo. Falso-negativo
14
19
  // custa só o nudge; falso-positivo em pergunta curta viraria ruído.
@@ -20,18 +25,22 @@ export function looksLikeTask(prompt) {
20
25
  }
21
26
 
22
27
  // Retorna { context, hash? } quando há algo a injetar; null = silêncio.
23
- export function buildChangePing(vaultBase, sessionId, prompt = '', changeSlug = '') {
28
+ export function buildChangePing(vaultBase, sessionId, prompt = '', changeSlug = '', { profile = 'GOVERN' } = {}) {
29
+ const policy = hookProfilePolicy(profile);
30
+ if (!policy.harness) return null;
31
+ const sentinelId = profileSentinelId(sessionId, profile);
24
32
  const st = changeCtxState(vaultBase);
25
33
  if (st) {
26
- if (readSentinel(vaultBase, 'ctx', sessionId) === st.hash) return null;
27
- writeSentinel(vaultBase, 'ctx', sessionId, st.hash);
34
+ if (readSentinel(vaultBase, 'ctx', sentinelId) === st.hash) return null;
35
+ writeSentinel(vaultBase, 'ctx', sentinelId, st.hash);
28
36
  const focus = changeSlug ? `\n<session_change>Change vinculada a esta sessão: ${changeSlug}.</session_change>` : '';
29
37
  return { context: `${renderOpenChanges(st, { tag: 'open_changes_ping' })}${focus}`, hash: st.hash };
30
38
  }
31
39
  // Sem changes abertas: gate de skill para prompt-tarefa, 1x por sessão.
40
+ if (!policy.requiresChange) return null;
32
41
  if (!looksLikeTask(prompt)) return null;
33
- if (readSentinel(vaultBase, 'gate', sessionId)) return null;
34
- writeSentinel(vaultBase, 'gate', sessionId);
42
+ if (readSentinel(vaultBase, 'gate', sentinelId)) return null;
43
+ writeSentinel(vaultBase, 'gate', sentinelId);
35
44
  return {
36
45
  context: [
37
46
  '<wk_skill_gate>',
@@ -44,10 +53,15 @@ export function buildChangePing(vaultBase, sessionId, prompt = '', changeSlug =
44
53
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
45
54
  try {
46
55
  const input = readHookInput();
47
- const vaultBase = getVaultBase(input);
48
- const { identity, entry } = resolveSessionEntry(vaultBase, input);
56
+ const runtime = resolveHookOperatingProfile({ input });
57
+ const vaultBase = runtime.vaultBase;
58
+ const { identity, entry } = runtime;
49
59
  const sid = identity.state === 'resolved' ? identity.canonicalConversationId : (input.session_id || input.sessionId || '');
50
- const ping = buildChangePing(vaultBase, sid, input.prompt || '', entry?.change_slug || '');
60
+ const ping = runtime.bindingError
61
+ ? { context: profileRuntimeError(runtime.bindingError) }
62
+ : buildChangePing(vaultBase, sid, input.prompt || '', entry?.change_slug || '', {
63
+ profile: runtime.profile,
64
+ });
51
65
  if (!ping) { writeHookOutput({}); }
52
66
  else writeHookOutput({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ping.context } });
53
67
  } catch {
@@ -1,11 +1,15 @@
1
1
  // hooks/change-core.mjs
2
2
  // Native change/spec lifecycle in the vault (Pilar B). Vault-facing lib consumed by
3
3
  // the `wendkeep change` CLI (src/change.mjs) and the brain-inject hook. No external deps.
4
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import { dirname, join, relative } from 'node:path';
6
- import { ensureDir, wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
6
+ import { wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
7
7
  import { parseSpecsList, promoteSpecs, discoverSpecDeltas, tasksHashOf, captureSpecBaseline, REQ_ID_RE_SRC } from './spec-core.mjs';
8
8
  import { getLocale, LOCALES } from './locale.mjs';
9
+ import {
10
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, renameVaultPath,
11
+ unlinkVaultFile, writeVaultFileSync,
12
+ } from './vault-path-safety.mjs';
9
13
 
10
14
  export const ARCHIVE_DIR = '_arquivo';
11
15
  const POINTER = '.brain/CURRENT_CHANGE.md';
@@ -97,24 +101,83 @@ export function activeChange(vaultBase) {
97
101
  }
98
102
 
99
103
  export function setActiveChange(vaultBase, slug) {
100
- mkdirSync(join(vaultBase, '.brain'), { recursive: true });
101
- writeFileSync(join(vaultBase, POINTER), `change: ${slug}\n`, 'utf8');
104
+ mkdirVaultPath(vaultBase, join(vaultBase, '.brain'), { label: 'raiz de controle da change' });
105
+ writeVaultFileSync(
106
+ vaultBase,
107
+ join(vaultBase, POINTER),
108
+ `change: ${slug}\n`,
109
+ 'utf8',
110
+ { label: 'ponteiro CURRENT_CHANGE.md' },
111
+ );
102
112
  }
103
113
 
104
114
  export function clearActiveChange(vaultBase) {
105
115
  const p = join(vaultBase, POINTER);
106
- if (existsSync(p)) writeFileSync(p, 'change:\n', 'utf8');
116
+ const checked = assertVaultPathSafe(vaultBase, p, {
117
+ expectedType: 'file', label: 'ponteiro CURRENT_CHANGE.md',
118
+ });
119
+ if (checked.exists) {
120
+ writeVaultFileSync(vaultBase, p, 'change:\n', 'utf8', { label: 'ponteiro CURRENT_CHANGE.md' });
121
+ }
107
122
  }
108
123
 
109
- export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple = false }) {
124
+ export function assertChangeScaffoldTargetsSafe(vaultBase, slug, {
125
+ simple = false,
126
+ mustNotExist = false,
127
+ includeSessionControl = false,
128
+ code = 'VAULT_PATH_UNSAFE',
129
+ } = {}) {
110
130
  const loc = getLocale(vaultBase);
111
131
  const dir = join(vaultBase, loc.folders.changes, slug);
132
+ const fileNames = [
133
+ 'proposta.md',
134
+ 'tarefas.md',
135
+ '.spec-impact-v1',
136
+ '.spec-impact-v1.json',
137
+ '.spec-base.json',
138
+ 'flow-origin.json',
139
+ ...(!simple ? ['design.md'] : []),
140
+ ];
141
+ assertVaultPathsSafe(vaultBase, [
142
+ { path: join(vaultBase, loc.folders.changes), expectedType: 'directory', label: 'raiz de changes', code },
143
+ {
144
+ path: dir,
145
+ expectedType: 'directory',
146
+ mustNotExist,
147
+ label: 'destino da change',
148
+ code,
149
+ },
150
+ ...fileNames.map((name) => ({
151
+ path: join(dir, name), expectedType: 'file', label: `artefato ${name} da change`, code,
152
+ })),
153
+ { path: join(vaultBase, '.brain'), expectedType: 'directory', label: 'raiz de controle da change', code },
154
+ {
155
+ path: join(vaultBase, POINTER), expectedType: 'file', label: 'ponteiro CURRENT_CHANGE.md', code,
156
+ },
157
+ ...(includeSessionControl ? [{
158
+ path: join(vaultBase, '.brain', 'CURRENT_SESSION.md'),
159
+ expectedType: 'file',
160
+ label: 'projeção CURRENT_SESSION.md',
161
+ code,
162
+ }] : []),
163
+ ]);
164
+ return { dir, rel: changeDirRel(slug, vaultBase) };
165
+ }
166
+
167
+ export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple = false }) {
168
+ const loc = getLocale(vaultBase);
169
+ const { dir } = assertChangeScaffoldTargetsSafe(vaultBase, slug, { simple });
112
170
  const existed = existsSync(join(dir, 'proposta.md'));
113
- mkdirSync(dir, { recursive: true });
171
+ mkdirVaultPath(vaultBase, dir, { label: 'destino da change' });
114
172
  const files = renderChangeScaffold({ slug, sessionRel, dateStr, locale: loc.id, simple });
115
173
  const write = (name, content) => {
116
174
  const f = join(dir, name);
117
- if (!existsSync(f)) writeFileSync(f, content, 'utf8');
175
+ const checked = assertVaultPathSafe(vaultBase, f, {
176
+ expectedType: 'file', label: `artefato ${name} da change`,
177
+ });
178
+ if (!checked.exists) {
179
+ writeVaultFileSync(vaultBase, f, content, 'utf8', { label: `artefato ${name} da change` });
180
+ }
118
181
  };
119
182
  write('proposta.md', files.proposta);
120
183
  write('tarefas.md', files.tarefas);
@@ -160,26 +223,27 @@ export function continueChange(vaultBase, archivedSlug, newSlug, options = {}) {
160
223
  ? `Continues ${wikilinkFromRel(archivedProposal)}. Archived evidence and verdict are not inherited.`
161
224
  : `Continua ${wikilinkFromRel(archivedProposal)}. Evidências e verdict da change arquivada não são herdados.`;
162
225
  proposal = `${proposal.trimEnd()}\n\n${heading}\n\n${note}\n`;
163
- writeFileSync(proposalPath, proposal, 'utf8');
226
+ writeVaultFileSync(vaultBase, proposalPath, proposal, 'utf8', { label: 'proposta da change continuada' });
164
227
  return { ok: true, ...result, archived: archivedName };
165
228
  }
166
229
 
167
230
  export function parseTasks(md) {
168
231
  const tasks = [];
169
232
  const re = /^-\s+\[( |x)\]\s+(\S+)\s+(.*)$/gm;
170
- const sensorRe = /\[sensor:\s*([\w.-]+)\]/;
233
+ const sensorReG = /\[sensor:\s*([\w.-]+)\]/g;
171
234
  const reqReG = new RegExp(`\\[req:\\s*(${REQ_ID_RE_SRC})\\]`, 'g');
172
235
  let m;
173
236
  while ((m = re.exec(String(md))) !== null) {
174
237
  let text = m[3].trim();
175
- const sm = text.match(sensorRe);
238
+ const sensors = [...new Set([...text.matchAll(sensorReG)].map((entry) => entry[1]))];
176
239
  const reqs = [...text.matchAll(reqReG)].map((r) => r[1]);
177
- const sensor = sm ? sm[1] : undefined;
178
- if (sm) text = text.replace(sensorRe, '');
240
+ const sensor = sensors[0];
241
+ if (sensors.length) text = text.replace(sensorReG, '');
179
242
  if (reqs.length) text = text.replace(reqReG, '');
180
243
  text = text.replace(/\s+/g, ' ').trim();
244
+ // `sensor` stays as alias of the first id — older consumers keep working.
181
245
  // `req` stays as alias of the first id — older consumers keep working.
182
- tasks.push({ id: m[2], text, done: m[1] === 'x', ...(sensor ? { sensor } : {}), ...(reqs.length ? { req: reqs[0], reqs } : {}) });
246
+ tasks.push({ id: m[2], text, done: m[1] === 'x', ...(sensor ? { sensor, sensors } : {}), ...(reqs.length ? { req: reqs[0], reqs } : {}) });
183
247
  }
184
248
  return tasks;
185
249
  }
@@ -191,7 +255,14 @@ export function setTaskDone(changeDir, taskId, done = true) {
191
255
  const esc = String(taskId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
192
256
  const re = new RegExp(`^(-\\s+\\[)( |x)(\\]\\s+${esc}\\s)`, 'm');
193
257
  if (!re.test(md)) return false;
194
- writeFileSync(path, md.replace(re, `$1${done ? 'x' : ' '}$3`), 'utf8');
258
+ const vaultBase = dirname(dirname(changeDir));
259
+ writeVaultFileSync(
260
+ vaultBase,
261
+ path,
262
+ md.replace(re, `$1${done ? 'x' : ' '}$3`),
263
+ 'utf8',
264
+ { label: 'tarefas.md da change' },
265
+ );
195
266
  return true;
196
267
  }
197
268
 
@@ -350,8 +421,14 @@ export function readSentinel(vaultBase, kind, sid) {
350
421
 
351
422
  export function writeSentinel(vaultBase, kind, sid, value = '1') {
352
423
  try {
353
- mkdirSync(join(vaultBase, '.brain'), { recursive: true });
354
- writeFileSync(sentinelPath(vaultBase, kind, sid), value, 'utf8');
424
+ mkdirVaultPath(vaultBase, join(vaultBase, '.brain'), { label: 'raiz de sentinelas de change' });
425
+ writeVaultFileSync(
426
+ vaultBase,
427
+ sentinelPath(vaultBase, kind, sid),
428
+ value,
429
+ 'utf8',
430
+ { label: 'sentinela de change' },
431
+ );
355
432
  } catch { /* fail-open: pior caso = aviso repetido */ }
356
433
  }
357
434
 
@@ -386,7 +463,10 @@ export function pruneChangeSentinels(vaultBase, { now = Date.now() } = {}) {
386
463
  .filter(Boolean);
387
464
  } catch { return []; }
388
465
  const stale = staleSentinelNames(entries, now);
389
- for (const name of stale) { try { unlinkSync(join(dir, name)); } catch { /* fail-quiet */ } }
466
+ for (const name of stale) {
467
+ try { unlinkVaultFile(vaultBase, join(dir, name), { label: 'sentinela stale de change' }); }
468
+ catch { /* fail-quiet */ }
469
+ }
390
470
  return stale;
391
471
  }
392
472
 
@@ -412,7 +492,14 @@ export function appendFixTasks(changeDir, mutants, sensorId) {
412
492
  }
413
493
  if (!lines.length) return 0;
414
494
  const sep = md === '' || md.endsWith('\n') ? '' : '\n';
415
- writeFileSync(path, `${md}${sep}${lines.join('\n')}\n`, 'utf8');
495
+ const vaultBase = dirname(dirname(changeDir));
496
+ writeVaultFileSync(
497
+ vaultBase,
498
+ path,
499
+ `${md}${sep}${lines.join('\n')}\n`,
500
+ 'utf8',
501
+ { label: 'tarefas.md com mutantes sobreviventes' },
502
+ );
416
503
  return lines.length;
417
504
  }
418
505
 
@@ -431,11 +518,29 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
431
518
  const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}`);
432
519
  const destAbs = join(vaultBase, destRel);
433
520
  const changeWikilink = wikilinkFromRel(join(destRel, 'proposta'));
521
+ const archiveRoot = join(vaultBase, chDir, ARCHIVE_DIR);
522
+ const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
523
+ const num = String(adrNum).padStart(4, '0');
524
+ const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
525
+
526
+ // Validate every later mutation target before spec promotion can change living state.
527
+ const [checkedSource, checkedDestination] = assertVaultPathsSafe(vaultBase, [
528
+ { path: src, allowMissing: false, expectedType: 'directory', label: 'change a arquivar' },
529
+ { path: destAbs, expectedType: 'directory', label: 'destino da change arquivada' },
530
+ { path: archiveRoot, expectedType: 'directory', label: 'raiz de changes arquivadas' },
531
+ { path: join(vaultBase, adrDirRel), expectedType: 'directory', label: 'pasta mensal de ADR' },
532
+ { path: join(vaultBase, adrRel), expectedType: 'file', label: 'ADR da change arquivada' },
533
+ { path: join(vaultBase, POINTER), expectedType: 'file', label: 'ponteiro CURRENT_CHANGE.md' },
534
+ ]);
535
+ assertVaultPathsSafe(vaultBase, [
536
+ { path: join(checkedSource.target, 'proposta.md'), expectedType: 'file', label: 'proposta da change' },
537
+ { path: join(checkedSource.target, 'tarefas.md'), expectedType: 'file', label: 'tarefas da change' },
538
+ ]);
434
539
 
435
540
  // Atomicity guard: fail BEFORE promoting specs if the destination already exists (e.g. a slug
436
541
  // reused after a same-day archive). Otherwise promoteSpecs would commit to 07-Specs and the
437
542
  // later renameSync would fail, leaving a half-archived state.
438
- if (existsSync(destAbs)) {
543
+ if (checkedDestination.exists) {
439
544
  return { ok: false, failing: [`destino de arquivo já existe: ${destRel} — renomeie o slug ou remova o arquivo antigo`] };
440
545
  }
441
546
 
@@ -469,9 +574,11 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
469
574
  // abaixo retargeta o wikilink pro _arquivo junto com os demais. Fail-quiet.
470
575
  try { healSpecBacklinks(src, vaultBase); } catch { /* heal é bônus */ }
471
576
 
472
- ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
577
+ mkdirVaultPath(vaultBase, archiveRoot, { label: 'raiz de changes arquivadas' });
473
578
  try {
474
- renameSync(src, destAbs);
579
+ renameVaultPath(vaultBase, src, destAbs, {
580
+ sourceType: 'directory', label: 'archive da change',
581
+ });
475
582
  } catch (error) {
476
583
  return { ok: false, failing: [`falha ao mover a mudança para ${destRel}: ${error.message} (07-Specs pode ter sido promovido — verifique)`] };
477
584
  }
@@ -480,7 +587,7 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
480
587
  try {
481
588
  const pp = join(destAbs, 'proposta.md');
482
589
  const c = readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: archived');
483
- writeFileSync(pp, c, 'utf8');
590
+ writeVaultFileSync(vaultBase, pp, c, 'utf8', { label: 'proposta arquivada' });
484
591
  } catch { /* proposta ilegível — segue */ }
485
592
 
486
593
  // O move quebrava TODO wikilink gravado antes (sessões fechadas, decisões, outras changes —
@@ -490,10 +597,7 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
490
597
 
491
598
  // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
492
599
  // — not the year root — so all ADRs sit together in the vault's convention.
493
- const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
494
- ensureDir(join(vaultBase, adrDirRel));
495
- const num = String(adrNum).padStart(4, '0');
496
- const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
600
+ mkdirVaultPath(vaultBase, join(vaultBase, adrDirRel), { label: 'pasta mensal de ADR' });
497
601
  const capLine = promoted.length
498
602
  ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
499
603
  : '';
@@ -501,7 +605,7 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
501
605
  // Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
502
606
  const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
503
607
  const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
504
- writeFileSync(join(vaultBase, adrRel), `---
608
+ writeVaultFileSync(vaultBase, join(vaultBase, adrRel), `---
505
609
  type: decision
506
610
  status: accepted
507
611
  date: ${dateStr}${flagLines}
@@ -516,7 +620,7 @@ tags:
516
620
  ## Decisão
517
621
 
518
622
  Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
519
- `, 'utf8');
623
+ `, 'utf8', { label: 'ADR da change arquivada' });
520
624
 
521
625
  // Only clear the pointer when the archived change IS the active one — archiving some other
522
626
  // slug explicitly must not blank the pointer of a different, still-active change.
@@ -530,6 +634,11 @@ function allVaultMarkdown(vaultBase) {
530
634
  const out = [];
531
635
  const skip = new Set(['.git', '.obsidian', 'node_modules']);
532
636
  const walk = (dir) => {
637
+ try {
638
+ assertVaultPathSafe(vaultBase, dir, {
639
+ allowMissing: false, expectedType: 'directory', label: 'diretório varrido para wikilinks',
640
+ });
641
+ } catch { return; }
533
642
  let entries;
534
643
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
535
644
  for (const e of entries) {
@@ -556,7 +665,10 @@ function rewriteChangeLinks(vaultBase, fromRel, toRel) {
556
665
  .split(`[[${fromRel}]]`).join(`[[${toRel}]]`)
557
666
  .split(`[[${fromRel}|`).join(`[[${toRel}|`);
558
667
  if (next !== content) {
559
- try { writeFileSync(abs, next, 'utf8'); touched += 1; } catch { /* nota readonly — segue */ }
668
+ try {
669
+ writeVaultFileSync(vaultBase, abs, next, 'utf8', { label: 'nota com wikilink reescrito' });
670
+ touched += 1;
671
+ } catch { /* nota readonly/unsafe — segue */ }
560
672
  }
561
673
  }
562
674
  return touched;
@@ -604,7 +716,10 @@ export function healSpecBacklinks(changeDir, vaultBase) {
604
716
  let c;
605
717
  try { c = readFileSync(p, 'utf8'); } catch { continue; }
606
718
  if (c.includes(link)) continue;
607
- try { writeFileSync(p, insertBacklink(c, line), 'utf8'); healed += 1; } catch { /* readonly — segue */ }
719
+ try {
720
+ writeVaultFileSync(vaultBase, p, insertBacklink(c, line), 'utf8', { label: 'delta de spec com backlink' });
721
+ healed += 1;
722
+ } catch { /* readonly/unsafe — segue */ }
608
723
  }
609
724
  return healed;
610
725
  }
@@ -645,7 +760,11 @@ export function backfillArtifactLinks(vaultBase, { apply = false } = {}) {
645
760
  let c;
646
761
  try { c = readFileSync(p, 'utf8'); } catch { continue; }
647
762
  if (c.includes(link)) continue;
648
- if (apply) { try { writeFileSync(p, insertBacklink(c, line), 'utf8'); } catch { continue; } }
763
+ if (apply) {
764
+ try {
765
+ writeVaultFileSync(vaultBase, p, insertBacklink(c, line), 'utf8', { label: 'artefato com backlink' });
766
+ } catch { continue; }
767
+ }
649
768
  changed.push(relative(vaultBase, p).replaceAll('\\', '/'));
650
769
  }
651
770
  }
@@ -701,15 +820,33 @@ export function relinkChanges(vaultBase, { apply = false } = {}) {
701
820
  export function abandonChange(vaultBase, slug, { dateStr }) {
702
821
  const chDir = getLocale(vaultBase).folders.changes;
703
822
  const src = join(vaultBase, chDir, slug);
704
- if (!existsSync(join(src, 'proposta.md'))) return { ok: false, failing: [`change não encontrada: ${slug}`] };
823
+ const checkedProposal = assertVaultPathSafe(vaultBase, join(src, 'proposta.md'), {
824
+ expectedType: 'file', label: 'proposta da change abandonada',
825
+ });
826
+ if (!checkedProposal.exists) return { ok: false, failing: [`change não encontrada: ${slug}`] };
705
827
  const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}-abandonada`);
706
828
  const destAbs = join(vaultBase, destRel);
707
- if (existsSync(destAbs)) return { ok: false, failing: [`destino já existe: ${destRel}`] };
708
- ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
709
- try { renameSync(src, destAbs); } catch (e) { return { ok: false, failing: [`falha ao mover: ${e.message}`] }; }
829
+ const checkedDestination = assertVaultPathSafe(vaultBase, destAbs, {
830
+ expectedType: 'directory', label: 'destino da change abandonada',
831
+ });
832
+ if (checkedDestination.exists) return { ok: false, failing: [`destino já existe: ${destRel}`] };
833
+ mkdirVaultPath(vaultBase, join(vaultBase, chDir, ARCHIVE_DIR), {
834
+ label: 'raiz de changes arquivadas',
835
+ });
836
+ try {
837
+ renameVaultPath(vaultBase, src, destAbs, {
838
+ sourceType: 'directory', label: 'abandono da change',
839
+ });
840
+ } catch (e) { return { ok: false, failing: [`falha ao mover: ${e.message}`] }; }
710
841
  try {
711
842
  const pp = join(destAbs, 'proposta.md');
712
- writeFileSync(pp, readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'), 'utf8');
843
+ writeVaultFileSync(
844
+ vaultBase,
845
+ pp,
846
+ readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'),
847
+ 'utf8',
848
+ { label: 'proposta abandonada' },
849
+ );
713
850
  } catch { /* proposta sem frontmatter — segue */ }
714
851
  let linksRewritten = 0;
715
852
  try { linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/')); } catch { /* abandono já íntegro */ }
@@ -5,21 +5,105 @@
5
5
  // no ambiente do processo — env inline no texto do comando NÃO conta).
6
6
  // R2 — `git commit` com change ativa E (--no-verify OU sensor crítico vermelho) vira `ask`
7
7
  // (o usuário decide com 1 clique; falso-positivo custa pouco).
8
- // Fast-path: comando sem wendkeep/wk/git sai sem NENHUM I/O. Fail-open.
8
+ // Fast-path: comando sem wendkeep/wk/git sai sem NENHUM I/O. Ausência normal continua
9
+ // fail-open; corrupção do binding é diagnóstico visível e fail-closed.
9
10
  import { pathToFileURL } from 'node:url';
10
- import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
11
+ import { readHookInput, writeHookOutput } from './obsidian-common.mjs';
11
12
  import { activeChange, quickGateState } from './change-core.mjs';
13
+ import { hookProfilePolicy, resolveHookOperatingProfile } from './operating-profile-runtime.mjs';
14
+ import { isProjectVaultIntegrityError } from '../src/project-vault.mjs';
12
15
 
13
- const FORCE_RE = /\b(?:wendkeep|wk)\s+change\s+archive\b[^|&;\n]*--force\b/;
14
- const GIT_SEG_RE = /(^|&&|;|\|)\s*git\b[^|&;]*\bcommit\b/;
15
- const FAST_RE = /\b(?:wendkeep|wk|git)\b/;
16
+ const WK_EXECUTABLES = new Set([
17
+ 'wendkeep', 'wendkeep.cmd', 'wendkeep.exe', 'wendkeep.ps1', 'wendkeep.mjs',
18
+ 'wk', 'wk.cmd', 'wk.exe', 'wk.ps1',
19
+ ]);
20
+ const NODE_EXECUTABLES = new Set(['node', 'node.exe']);
21
+ const NPX_EXECUTABLES = new Set(['npx', 'npx.cmd', 'npx.exe']);
22
+ const GIT_EXECUTABLES = new Set(['git', 'git.exe', 'git.cmd']);
16
23
 
17
- export function guardDecision(command, { vaultBase, env = process.env } = {}) {
24
+ function shellSegments(command) {
25
+ const tokens = String(command || '').match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|&&|\|\||[;|\n]|&|[^\s;&|]+/g) || [];
26
+ const segments = [];
27
+ let current = [];
28
+ const flush = () => {
29
+ if (current.length) segments.push(current);
30
+ current = [];
31
+ };
32
+ for (const token of tokens) {
33
+ if (['&&', '||', ';', '|', '\n'].includes(token) || (token === '&' && current.length)) {
34
+ flush();
35
+ continue;
36
+ }
37
+ current.push(token);
38
+ }
39
+ flush();
40
+ return segments;
41
+ }
42
+
43
+ function unquote(token) {
44
+ const value = String(token || '');
45
+ if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"')
46
+ || (value[0] === "'" && value.at(-1) === "'"))) return value.slice(1, -1);
47
+ return value;
48
+ }
49
+
50
+ function executableName(token) {
51
+ return unquote(token).replaceAll('\\', '/').split('/').at(-1).toLowerCase();
52
+ }
53
+
54
+ function invocationOf(segment) {
55
+ let index = 0;
56
+ while (segment[index] === '&' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[index] || '')) index += 1;
57
+ const executable = executableName(segment[index]);
58
+ if (WK_EXECUTABLES.has(executable)) {
59
+ return { kind: 'wendkeep', args: segment.slice(index + 1).map(unquote) };
60
+ }
61
+ if (NODE_EXECUTABLES.has(executable)) {
62
+ let scriptIndex = index + 1;
63
+ while (String(segment[scriptIndex] || '').startsWith('-')) scriptIndex += 1;
64
+ if (executableName(segment[scriptIndex]) === 'wendkeep.mjs') {
65
+ return { kind: 'wendkeep', args: segment.slice(scriptIndex + 1).map(unquote) };
66
+ }
67
+ }
68
+ if (NPX_EXECUTABLES.has(executable)) {
69
+ let packageIndex = index + 1;
70
+ while (String(segment[packageIndex] || '').startsWith('-')) packageIndex += 1;
71
+ if (WK_EXECUTABLES.has(executableName(segment[packageIndex]))) {
72
+ return { kind: 'wendkeep', args: segment.slice(packageIndex + 1).map(unquote) };
73
+ }
74
+ }
75
+ if (GIT_EXECUTABLES.has(executable)) {
76
+ return { kind: 'git', args: segment.slice(index + 1).map(unquote) };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function commandInvocations(command) {
82
+ return shellSegments(command).map(invocationOf).filter(Boolean);
83
+ }
84
+
85
+ function bindingFailureDecision(diagnostic) {
86
+ const code = diagnostic?.code || 'WENDKEEP_VAULT_CONFIG_INVALID';
87
+ const raw = diagnostic?.message || String(diagnostic || 'Configuração WendKeep inválida.');
88
+ const detail = raw.replace(/\s+/g, ' ').trim().slice(0, 420);
89
+ return {
90
+ permissionDecision: 'deny',
91
+ permissionDecisionReason: `${code}: ${detail} Corrija o binding antes de executar uma ação mutável.`,
92
+ };
93
+ }
94
+
95
+ export function guardDecision(command, { vaultBase, env = process.env, profile = 'GOVERN' } = {}) {
96
+ if (!hookProfilePolicy(profile).harness) return null;
18
97
  const cmd = String(command || '');
19
- if (!FAST_RE.test(cmd)) return null; // fast-path: zero I/O para o caso comum
98
+ const invocations = commandInvocations(cmd);
99
+ if (!invocations.length) return null; // fast-path: parsing puro, zero I/O para o caso comum
20
100
 
21
- // R1: archive --force — puro regex, ainda sem I/O. Reason fala com o AGENTE (deny).
22
- if (FORCE_RE.test(cmd)) {
101
+ // R1: archive --force — parser puro, ainda sem I/O. Reason fala com o AGENTE (deny).
102
+ const forcedArchive = invocations.find(({ kind, args }) => kind === 'wendkeep'
103
+ && args[0]?.toLowerCase() === 'change'
104
+ && args[1]?.toLowerCase() === 'archive'
105
+ && args.some((arg) => /^--force(?:=|$)/i.test(arg)));
106
+ if (forcedArchive) {
23
107
  if (env.WENDKEEP_ALLOW_FORCE === '1') return null;
24
108
  return {
25
109
  permissionDecision: 'deny',
@@ -28,12 +112,12 @@ export function guardDecision(command, { vaultBase, env = process.env } = {}) {
28
112
  }
29
113
 
30
114
  // R2: git commit — 1ª leitura de fs só acontece aqui. Reason fala com o USUÁRIO (ask).
31
- const m = cmd.match(GIT_SEG_RE);
32
- if (m) {
115
+ const gitCommit = invocations.find(({ kind, args }) => kind === 'git'
116
+ && args.some((arg) => arg.toLowerCase() === 'commit'));
117
+ if (gitCommit) {
33
118
  const slug = activeChange(vaultBase);
34
119
  if (!slug) return null;
35
- const seg = cmd.slice(m.index + m[1].length).split(/&&|;|\|/)[0];
36
- const noVerify = /\s--no-verify\b/.test(seg);
120
+ const noVerify = gitCommit.args.some((arg) => /^--no-verify(?:=|$)/i.test(arg));
37
121
  const gate = noVerify ? null : quickGateState(vaultBase);
38
122
  if (noVerify || (gate && gate.redCritical)) {
39
123
  return {
@@ -50,10 +134,25 @@ export function guardDecision(command, { vaultBase, env = process.env } = {}) {
50
134
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
51
135
  try {
52
136
  const input = readHookInput();
53
- const d = guardDecision(input.tool_input?.command, { vaultBase: getVaultBase(input) });
137
+ const runtime = resolveHookOperatingProfile({ input });
138
+ const d = runtime.bindingError
139
+ ? bindingFailureDecision(runtime.bindingError)
140
+ : guardDecision(input.tool_input?.command, {
141
+ vaultBase: runtime.vaultBase,
142
+ profile: runtime.profile,
143
+ });
54
144
  if (d) writeHookOutput({ hookSpecificOutput: { hookEventName: 'PreToolUse', ...d } });
55
145
  // allow implícito: exit 0 sem output
56
- } catch {
57
- writeHookOutput({}); // fail-open = allow
146
+ } catch (error) {
147
+ if (isProjectVaultIntegrityError(error)) {
148
+ writeHookOutput({
149
+ hookSpecificOutput: {
150
+ hookEventName: 'PreToolUse',
151
+ ...bindingFailureDecision(error),
152
+ },
153
+ });
154
+ } else {
155
+ writeHookOutput({}); // ausência/erro não-corrupto preserva compatibilidade fail-open
156
+ }
58
157
  }
59
158
  }
@@ -5,16 +5,25 @@
5
5
  // ela o modelo é incentivado a marcar done falso só para conseguir parar).
6
6
  // Anti-loop absoluto: stop_hook_active é o PRIMEIRO check, antes de qualquer I/O.
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
+ import { profileRuntimeError } from './brain-inject.mjs';
9
10
  import { quickGateState, readSentinel, writeSentinel } from './change-core.mjs';
11
+ import {
12
+ hookProfilePolicy,
13
+ profileSentinelId,
14
+ resolveHookOperatingProfile,
15
+ } from './operating-profile-runtime.mjs';
10
16
 
11
- export function nagDecision(input, vaultBase) {
17
+ export function nagDecision(input, vaultBase, { profile = 'GOVERN' } = {}) {
12
18
  if (input && input.stop_hook_active) return null; // anti-loop: sempre primeiro
19
+ const policy = hookProfilePolicy(profile);
20
+ if (!policy.harness) return null;
13
21
  const gate = quickGateState(vaultBase);
14
22
  if (!gate || !gate.openTasks) return null;
15
23
  const sid = input?.session_id || input?.sessionId || '';
16
- if (readSentinel(vaultBase, 'nag', sid)) return null;
17
- writeSentinel(vaultBase, 'nag', sid);
24
+ const sentinelId = profileSentinelId(sid, profile);
25
+ if (readSentinel(vaultBase, 'nag', sentinelId)) return null;
26
+ writeSentinel(vaultBase, 'nag', sentinelId);
18
27
  return {
19
28
  decision: 'block',
20
29
  reason: `A change ativa "${gate.slug}" tem ${gate.openTasks} tarefa(s) aberta(s). Antes de encerrar: marque as concluídas com \`wendkeep change done <id>\`, rode \`wendkeep verify\`, ou informe a pendência ao usuário e encerre.`,
@@ -24,7 +33,13 @@ export function nagDecision(input, vaultBase) {
24
33
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
25
34
  try {
26
35
  const input = readHookInput();
27
- writeHookOutput(nagDecision(input, getVaultBase(input)) || {});
36
+ const runtime = resolveHookOperatingProfile({ input });
37
+ const decision = input?.stop_hook_active
38
+ ? null
39
+ : runtime.bindingError
40
+ ? { decision: 'block', reason: profileRuntimeError(runtime.bindingError) }
41
+ : nagDecision(input, runtime.vaultBase, { profile: runtime.profile });
42
+ writeHookOutput(decision || {});
28
43
  } catch {
29
44
  writeHookOutput({});
30
45
  }