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.
- package/CHANGELOG.md +93 -0
- package/README.en.md +45 -3
- package/README.md +45 -3
- package/bin/wendkeep.mjs +54 -6
- package/docs/en/commands/changes-and-verification.md +9 -3
- package/docs/en/commands/getting-started.md +7 -3
- package/docs/en/commands/memory.md +20 -2
- package/docs/en/commands/operating-profiles.md +173 -0
- package/docs/en/commands/sessions-and-import.md +8 -4
- package/docs/en/commands/verify.md +12 -6
- package/docs/pt-BR/commands/changes-and-verification.md +9 -4
- package/docs/pt-BR/commands/getting-started.md +7 -3
- package/docs/pt-BR/commands/memory.md +18 -2
- package/docs/pt-BR/commands/operating-profiles.md +171 -0
- package/docs/pt-BR/commands/sessions-and-import.md +7 -3
- package/docs/pt-BR/commands/verify.md +11 -5
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-inject.mjs +83 -26
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/change-context.mjs +24 -10
- package/hooks/change-core.mjs +174 -37
- package/hooks/change-guard.mjs +115 -16
- package/hooks/change-nag.mjs +20 -5
- package/hooks/change-warn.mjs +27 -9
- package/hooks/decision-capture.mjs +1 -1
- package/hooks/derived-sections.mjs +1 -1
- package/hooks/flow-core.mjs +891 -0
- package/hooks/flow-protected-policy.mjs +218 -0
- package/hooks/frontmatter-repair.mjs +3 -1
- package/hooks/git-snapshot.mjs +722 -0
- package/hooks/import-sessions.mjs +10 -5
- package/hooks/memory-mode.mjs +63 -13
- package/hooks/memory-store.mjs +309 -69
- package/hooks/obsidian-common.mjs +39 -55
- package/hooks/operating-profile-runtime.mjs +157 -0
- package/hooks/plan-capture.mjs +14 -3
- package/hooks/sensors-core.mjs +15 -3
- package/hooks/session-backfill.mjs +7 -2
- package/hooks/session-ensure.mjs +6 -4
- package/hooks/session-iteration.mjs +65 -0
- package/hooks/session-memory-lifecycle.mjs +10 -5
- package/hooks/session-note-io.mjs +130 -15
- package/hooks/session-observability.mjs +4 -2
- package/hooks/session-stop.mjs +65 -19
- package/hooks/spec-core.mjs +91 -12
- package/hooks/subagent-stop.mjs +4 -1
- package/hooks/subagent-usage.mjs +2 -2
- package/hooks/task-log.mjs +3 -1
- package/hooks/token-usage.mjs +1 -1
- package/hooks/vault-health.mjs +183 -37
- package/hooks/vault-path-safety.mjs +2 -0
- package/hooks/vault-runtime-store.mjs +558 -0
- package/package.json +10 -3
- package/packages/cli/package.json +5 -0
- package/packages/harness/package.json +5 -0
- package/packages/integrations/package.json +5 -0
- package/packages/mcp/package.json +5 -0
- package/packages/pi/package.json +5 -0
- package/packages/vault/package.json +6 -0
- package/packages/vault/src/index.mjs +2 -0
- package/packages/vault/src/project-vault.mjs +327 -0
- package/packages/vault/src/vault-path-safety.mjs +558 -0
- package/src/change.mjs +2 -1
- package/src/flow.mjs +232 -0
- package/src/init.mjs +26 -3
- package/src/memory.mjs +785 -35
- package/src/operating-profile.mjs +133 -0
- package/src/profile.mjs +224 -0
- package/src/project-vault.mjs +2 -221
- package/src/rebuild-costs.mjs +11 -4
- package/src/skills-seed.mjs +38 -16
- package/src/sync-defs.mjs +16 -7
- package/src/sync.mjs +9 -1
- package/src/taxonomy.mjs +8 -0
- package/src/validate-memory.mjs +21 -8
- package/src/verify.mjs +12 -2
package/hooks/change-context.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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',
|
|
27
|
-
writeSentinel(vaultBase, 'ctx',
|
|
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',
|
|
34
|
-
writeSentinel(vaultBase, 'gate',
|
|
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
|
|
48
|
-
const
|
|
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 =
|
|
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 {
|
package/hooks/change-core.mjs
CHANGED
|
@@ -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,
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, relative } from 'node:path';
|
|
6
|
-
import {
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
178
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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) {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
577
|
+
mkdirVaultPath(vaultBase, archiveRoot, { label: 'raiz de changes arquivadas' });
|
|
473
578
|
try {
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
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 */ }
|
package/hooks/change-guard.mjs
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/hooks/change-nag.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
}
|