wendkeep 0.59.0 → 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 +20 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/hooks/vault-path-safety.mjs +2 -558
- package/package.json +9 -2
- 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/project-vault.mjs +2 -326
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to **wendkeep** are documented here. Format based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.60.0] — 2026-07-27
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **O runtime ganha seis fronteiras físicas internas:** `cli`, `harness`, `vault`, `mcp`,
|
|
12
|
+
`integrations` e `pi` passam a existir como workspaces privados `@wendkeep/*`, mantendo
|
|
13
|
+
`wendkeep` como uma única unidade de instalação e publicação.
|
|
14
|
+
- **O Keep Core inaugura a API programática `wendkeep/vault`.** Binding/resolução do Vault e a
|
|
15
|
+
fronteira segura de paths podem ser importados pelo novo subpath público, incluído e validado no
|
|
16
|
+
tarball.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Binding e segurança física agora pertencem a `packages/vault`.** Os paths históricos em
|
|
21
|
+
`src/project-vault.mjs` e `hooks/vault-path-safety.mjs` permanecem como fachadas compatíveis, sem
|
|
22
|
+
alterar CLI, hooks ou dados existentes.
|
|
23
|
+
- **A direção de dependências virou gate estrutural.** O workspace Vault aceita somente built-ins
|
|
24
|
+
Node e módulos internos, impedindo que Keep Core passe a depender de Harness, perfis, changes,
|
|
25
|
+
FLOW, sensores ou CLI.
|
|
26
|
+
|
|
7
27
|
## [0.59.0] — 2026-07-27
|
|
8
28
|
|
|
9
29
|
### Added
|
package/README.en.md
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
**Persistent memory for AI coding agents, built on your Obsidian vault.** Every Claude Code **and Codex** session is captured turn by turn into local Markdown — `init` wires both (Codex asks you to approve its hooks once; `import` backfills past sessions either way) — with token/cost tracking and automatically extracted decisions, bugs, and learnings. That always-on plane is **Keep Core**. On top of it, **Wend Runtime** provides a native, zero-dependency lifecycle (spec → change → TDD → sensor-gated archive), selected through the `OFF`, `FLOW`, `GUIDE`, `GOVERN`, and `ASSURE` Operating Profiles. 100% local, open-core.
|
|
17
17
|
|
|
18
|
+
The runtime is being separated into six physical boundaries — `cli`, `harness`, `vault`, `mcp`,
|
|
19
|
+
`integrations`, and `pi` — without fragmenting installation. Vault is the first extracted surface
|
|
20
|
+
and is importable through `wendkeep/vault`; see the [modular architecture](docs/en/architecture.md).
|
|
21
|
+
|
|
18
22
|
```bash
|
|
19
23
|
npm i -D wendkeep && npx wendkeep init # captures from the next session on
|
|
20
24
|
npx wendkeep import # backfill past Claude + Codex sessions
|
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
**Persistent memory for AI coding agents, built on your Obsidian vault.** Every Claude Code **and Codex** session is captured turn by turn into local Markdown — `init` wires both (Codex asks you to approve its hooks once; `import` backfills past sessions either way) — with token/cost tracking and automatically extracted decisions, bugs, and learnings. That always-on plane is **Keep Core**. On top of it, **Wend Runtime** provides a native, zero-dependency lifecycle (spec → change → TDD → sensor-gated archive), selected through the `OFF`, `FLOW`, `GUIDE`, `GOVERN`, and `ASSURE` Operating Profiles. 100% local, open-core.
|
|
17
17
|
|
|
18
|
+
The runtime is being separated into six physical boundaries — `cli`, `harness`, `vault`, `mcp`,
|
|
19
|
+
`integrations`, and `pi` — without fragmenting installation. Vault is the first extracted surface
|
|
20
|
+
and is importable through `wendkeep/vault`; see the [modular architecture](docs/en/architecture.md).
|
|
21
|
+
|
|
18
22
|
```bash
|
|
19
23
|
npm i -D wendkeep && npx wendkeep init # captures from the next session on
|
|
20
24
|
npx wendkeep import # backfill past Claude + Codex sessions
|
|
@@ -1,558 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// Node's recursive mkdir/write helpers follow junctions and symbolic links. Every mutator
|
|
4
|
-
// below therefore re-derives the canonical Vault root, checks logical containment, and walks
|
|
5
|
-
// each existing component with lstat immediately before touching the filesystem.
|
|
6
|
-
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import {
|
|
8
|
-
lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync,
|
|
9
|
-
statSync, unlinkSync, writeFileSync,
|
|
10
|
-
} from 'node:fs';
|
|
11
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
12
|
-
|
|
13
|
-
export const VAULT_LOCK_BUSY = Symbol('wendkeep:vault-lock-busy');
|
|
14
|
-
export const VAULT_LOCK_OWNER_FILE = '.owner.json';
|
|
15
|
-
|
|
16
|
-
function pathKey(value) {
|
|
17
|
-
const normalized = resolve(value).replaceAll('\\', '/').replace(/^\\\\\?\//, '');
|
|
18
|
-
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function containedBy(root, path) {
|
|
22
|
-
const rel = relative(root, path);
|
|
23
|
-
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function unsafe(message, code = 'VAULT_PATH_UNSAFE') {
|
|
27
|
-
const error = new Error(message);
|
|
28
|
-
error.code = code;
|
|
29
|
-
return error;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function lstatMaybe(path) {
|
|
33
|
-
try {
|
|
34
|
-
return lstatSync(path);
|
|
35
|
-
} catch (error) {
|
|
36
|
-
if (error?.code === 'ENOENT') return null;
|
|
37
|
-
throw error;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function canonicalVaultRoot(vaultBase, { code = 'VAULT_PATH_UNSAFE' } = {}) {
|
|
42
|
-
const rawRoot = String(vaultBase || '').trim();
|
|
43
|
-
if (!rawRoot) throw unsafe('raiz do Vault vazia', code);
|
|
44
|
-
const logicalRoot = resolve(rawRoot);
|
|
45
|
-
const rootStat = lstatMaybe(logicalRoot);
|
|
46
|
-
if (!rootStat) throw unsafe(`raiz do Vault inexistente: ${logicalRoot}`, code);
|
|
47
|
-
if (rootStat.isSymbolicLink()) {
|
|
48
|
-
throw unsafe(`raiz do Vault atravessa link simbólico/junction/reparse: ${logicalRoot}`, code);
|
|
49
|
-
}
|
|
50
|
-
if (!rootStat.isDirectory()) throw unsafe(`raiz do Vault não é diretório: ${logicalRoot}`, code);
|
|
51
|
-
let physicalRoot;
|
|
52
|
-
try {
|
|
53
|
-
physicalRoot = realpathSync.native(logicalRoot);
|
|
54
|
-
} catch (cause) {
|
|
55
|
-
const error = unsafe(`raiz física do Vault não pode ser resolvida: ${logicalRoot}`, code);
|
|
56
|
-
error.cause = cause;
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
return { logicalRoot, physicalRoot };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Validate one path under an existing Vault root.
|
|
64
|
-
*
|
|
65
|
-
* Missing suffixes are allowed by default so callers can create them. A dangling link is not
|
|
66
|
-
* a missing suffix: lstat sees the link itself and it is rejected. Existing file targets with
|
|
67
|
-
* more than one hardlink are always rejected, including read-before-idempotent-write paths.
|
|
68
|
-
*/
|
|
69
|
-
export function assertVaultPathSafe(vaultBase, targetPath, {
|
|
70
|
-
allowMissing = true,
|
|
71
|
-
expectedType = 'any',
|
|
72
|
-
mustNotExist = false,
|
|
73
|
-
label = 'path do Vault',
|
|
74
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
75
|
-
} = {}) {
|
|
76
|
-
const { logicalRoot, physicalRoot } = canonicalVaultRoot(vaultBase, { code });
|
|
77
|
-
const rawTarget = String(targetPath || '').trim();
|
|
78
|
-
if (!rawTarget) throw unsafe(`${label} vazio`, code);
|
|
79
|
-
const target = resolve(rawTarget);
|
|
80
|
-
if (!containedBy(logicalRoot, target)) {
|
|
81
|
-
throw unsafe(`${label} escapa logicamente do Vault: ${target}`, code);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const rel = relative(logicalRoot, target);
|
|
85
|
-
const segments = rel ? rel.split(sep).filter(Boolean) : [];
|
|
86
|
-
let logicalCursor = logicalRoot;
|
|
87
|
-
let exists = true;
|
|
88
|
-
let targetStat = lstatMaybe(logicalRoot);
|
|
89
|
-
|
|
90
|
-
for (let index = 0; index < segments.length; index += 1) {
|
|
91
|
-
logicalCursor = join(logicalCursor, segments[index]);
|
|
92
|
-
const stat = lstatMaybe(logicalCursor);
|
|
93
|
-
if (!stat) {
|
|
94
|
-
exists = false;
|
|
95
|
-
targetStat = null;
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
targetStat = stat;
|
|
99
|
-
if (stat.isSymbolicLink()) {
|
|
100
|
-
throw unsafe(`${label} atravessa link simbólico/junction/reparse: ${logicalCursor}`, code);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
let actualPhysical;
|
|
104
|
-
try {
|
|
105
|
-
actualPhysical = realpathSync.native(logicalCursor);
|
|
106
|
-
} catch (cause) {
|
|
107
|
-
const error = unsafe(`${label} possui componente dangling ou irresolvível: ${logicalCursor}`, code);
|
|
108
|
-
error.cause = cause;
|
|
109
|
-
throw error;
|
|
110
|
-
}
|
|
111
|
-
const expectedPhysical = join(physicalRoot, ...segments.slice(0, index + 1));
|
|
112
|
-
if (!containedBy(physicalRoot, actualPhysical)
|
|
113
|
-
|| pathKey(actualPhysical) !== pathKey(expectedPhysical)) {
|
|
114
|
-
throw unsafe(`${label} é redirecionado por junction/reparse fora do path canônico: ${logicalCursor}`, code);
|
|
115
|
-
}
|
|
116
|
-
if (index < segments.length - 1 && !stat.isDirectory()) {
|
|
117
|
-
throw unsafe(`${label} possui ancestral que não é diretório: ${logicalCursor}`, code);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (!exists && !allowMissing) throw unsafe(`${label} inexistente: ${target}`, code);
|
|
122
|
-
if (exists && mustNotExist) throw unsafe(`${label} preexistente: ${target}`, code);
|
|
123
|
-
if (exists) {
|
|
124
|
-
if (targetStat?.isFile() && targetStat.nlink > 1) {
|
|
125
|
-
throw unsafe(`${label} preexistente possui hardlink (nlink=${targetStat.nlink}): ${target}`, code);
|
|
126
|
-
}
|
|
127
|
-
if (!targetStat?.isFile() && !targetStat?.isDirectory()) {
|
|
128
|
-
throw unsafe(`${label} possui tipo de filesystem não suportado: ${target}`, code);
|
|
129
|
-
}
|
|
130
|
-
if (expectedType === 'file' && !targetStat?.isFile()) {
|
|
131
|
-
throw unsafe(`${label} preexistente não é arquivo: ${target}`, code);
|
|
132
|
-
}
|
|
133
|
-
if (expectedType === 'directory' && !targetStat?.isDirectory()) {
|
|
134
|
-
throw unsafe(`${label} preexistente não é diretório: ${target}`, code);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
exists,
|
|
140
|
-
logicalRoot,
|
|
141
|
-
physicalRoot,
|
|
142
|
-
relative: rel,
|
|
143
|
-
target,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function assertVaultPathsSafe(vaultBase, targets, common = {}) {
|
|
148
|
-
return targets.map((entry) => {
|
|
149
|
-
if (typeof entry === 'string') return assertVaultPathSafe(vaultBase, entry, common);
|
|
150
|
-
return assertVaultPathSafe(vaultBase, entry.path, { ...common, ...entry });
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export function mkdirVaultPath(vaultBase, targetPath, {
|
|
155
|
-
recursive = true,
|
|
156
|
-
exclusive = false,
|
|
157
|
-
label = 'diretório do Vault',
|
|
158
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
159
|
-
} = {}) {
|
|
160
|
-
let checked = assertVaultPathSafe(vaultBase, targetPath, {
|
|
161
|
-
expectedType: 'directory', label, code,
|
|
162
|
-
});
|
|
163
|
-
if (exclusive || !checked.exists) {
|
|
164
|
-
// Deliberately adjacent to mkdir: this is the last userspace check before mutation.
|
|
165
|
-
checked = assertVaultPathSafe(vaultBase, targetPath, {
|
|
166
|
-
expectedType: 'directory', label, code,
|
|
167
|
-
});
|
|
168
|
-
mkdirSync(checked.target, { recursive: exclusive ? false : recursive });
|
|
169
|
-
}
|
|
170
|
-
return assertVaultPathSafe(vaultBase, targetPath, {
|
|
171
|
-
allowMissing: false, expectedType: 'directory', label, code,
|
|
172
|
-
}).target;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function writeVaultFileSync(vaultBase, targetPath, content, encoding = 'utf8', {
|
|
176
|
-
label = 'arquivo do Vault',
|
|
177
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
178
|
-
} = {}) {
|
|
179
|
-
const checked = assertVaultPathSafe(vaultBase, targetPath, {
|
|
180
|
-
expectedType: 'file', label, code,
|
|
181
|
-
});
|
|
182
|
-
// Deliberately adjacent to write: no policy/read work can happen between validation and I/O.
|
|
183
|
-
assertVaultPathSafe(vaultBase, checked.target, { expectedType: 'file', label, code });
|
|
184
|
-
writeFileSync(checked.target, content, encoding);
|
|
185
|
-
assertVaultPathSafe(vaultBase, checked.target, {
|
|
186
|
-
allowMissing: false, expectedType: 'file', label, code,
|
|
187
|
-
});
|
|
188
|
-
return checked.target;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function writeVaultFileAtomic(vaultBase, targetPath, content, encoding = 'utf8', {
|
|
192
|
-
label = 'arquivo atômico do Vault',
|
|
193
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
194
|
-
} = {}) {
|
|
195
|
-
const checked = assertVaultPathSafe(vaultBase, targetPath, {
|
|
196
|
-
expectedType: 'file', label, code,
|
|
197
|
-
});
|
|
198
|
-
assertVaultPathSafe(vaultBase, dirname(checked.target), {
|
|
199
|
-
allowMissing: false, expectedType: 'directory', label: `ancestral de ${label}`, code,
|
|
200
|
-
});
|
|
201
|
-
const tmp = `${checked.target}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
202
|
-
assertVaultPathSafe(vaultBase, tmp, {
|
|
203
|
-
expectedType: 'file', mustNotExist: true, label: `temporário de ${label}`, code,
|
|
204
|
-
});
|
|
205
|
-
let tmpCreated = false;
|
|
206
|
-
try {
|
|
207
|
-
// wx prevents a pre-created alias from being opened after the last lstat.
|
|
208
|
-
writeFileSync(tmp, content, { encoding, flag: 'wx' });
|
|
209
|
-
tmpCreated = true;
|
|
210
|
-
assertVaultPathSafe(vaultBase, tmp, {
|
|
211
|
-
allowMissing: false, expectedType: 'file', label: `temporário de ${label}`, code,
|
|
212
|
-
});
|
|
213
|
-
assertVaultPathSafe(vaultBase, checked.target, { expectedType: 'file', label, code });
|
|
214
|
-
renameSync(tmp, checked.target);
|
|
215
|
-
tmpCreated = false;
|
|
216
|
-
assertVaultPathSafe(vaultBase, checked.target, {
|
|
217
|
-
allowMissing: false, expectedType: 'file', label, code,
|
|
218
|
-
});
|
|
219
|
-
return checked.target;
|
|
220
|
-
} finally {
|
|
221
|
-
if (tmpCreated) {
|
|
222
|
-
try {
|
|
223
|
-
assertVaultPathSafe(vaultBase, tmp, {
|
|
224
|
-
allowMissing: false, expectedType: 'file', label: `temporário de ${label}`, code,
|
|
225
|
-
});
|
|
226
|
-
unlinkSync(tmp);
|
|
227
|
-
} catch { /* fail closed at the operation site; a leftover private tmp is recoverable */ }
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function renameVaultPath(vaultBase, sourcePath, destinationPath, {
|
|
233
|
-
sourceType = 'any',
|
|
234
|
-
label = 'rename do Vault',
|
|
235
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
236
|
-
} = {}) {
|
|
237
|
-
const source = assertVaultPathSafe(vaultBase, sourcePath, {
|
|
238
|
-
allowMissing: false, expectedType: sourceType, label: `origem de ${label}`, code,
|
|
239
|
-
});
|
|
240
|
-
const destination = assertVaultPathSafe(vaultBase, destinationPath, {
|
|
241
|
-
mustNotExist: true, label: `destino de ${label}`, code,
|
|
242
|
-
});
|
|
243
|
-
assertVaultPathSafe(vaultBase, dirname(destination.target), {
|
|
244
|
-
allowMissing: false, expectedType: 'directory', label: `ancestral de ${label}`, code,
|
|
245
|
-
});
|
|
246
|
-
assertVaultPathSafe(vaultBase, source.target, {
|
|
247
|
-
allowMissing: false, expectedType: sourceType, label: `origem de ${label}`, code,
|
|
248
|
-
});
|
|
249
|
-
assertVaultPathSafe(vaultBase, destination.target, {
|
|
250
|
-
mustNotExist: true, label: `destino de ${label}`, code,
|
|
251
|
-
});
|
|
252
|
-
renameSync(source.target, destination.target);
|
|
253
|
-
return destination.target;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export function unlinkVaultFile(vaultBase, targetPath, {
|
|
257
|
-
missingOk = true,
|
|
258
|
-
label = 'arquivo removido do Vault',
|
|
259
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
260
|
-
} = {}) {
|
|
261
|
-
let checked;
|
|
262
|
-
try {
|
|
263
|
-
checked = assertVaultPathSafe(vaultBase, targetPath, {
|
|
264
|
-
allowMissing: missingOk, expectedType: 'file', label, code,
|
|
265
|
-
});
|
|
266
|
-
} catch (error) {
|
|
267
|
-
if (missingOk && error?.code === 'ENOENT') return false;
|
|
268
|
-
throw error;
|
|
269
|
-
}
|
|
270
|
-
if (!checked.exists) return false;
|
|
271
|
-
assertVaultPathSafe(vaultBase, checked.target, {
|
|
272
|
-
allowMissing: false, expectedType: 'file', label, code,
|
|
273
|
-
});
|
|
274
|
-
unlinkSync(checked.target);
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export function removeVaultLockDirectory(vaultBase, targetPath, {
|
|
279
|
-
missingOk = true,
|
|
280
|
-
label = 'lock do Vault',
|
|
281
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
282
|
-
} = {}) {
|
|
283
|
-
const checked = assertVaultPathSafe(vaultBase, targetPath, {
|
|
284
|
-
allowMissing: missingOk, expectedType: 'directory', label, code,
|
|
285
|
-
});
|
|
286
|
-
if (!checked.exists) return false;
|
|
287
|
-
assertVaultPathSafe(vaultBase, checked.target, {
|
|
288
|
-
allowMissing: false, expectedType: 'directory', label, code,
|
|
289
|
-
});
|
|
290
|
-
try {
|
|
291
|
-
rmdirSync(checked.target);
|
|
292
|
-
return true;
|
|
293
|
-
} catch (error) {
|
|
294
|
-
if (missingOk && error?.code === 'ENOENT') return false;
|
|
295
|
-
throw error;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function waitBriefly(ms) {
|
|
300
|
-
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
301
|
-
Atomics.wait(signal, 0, 0, ms);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// A lock may legitimately disappear between lstat and realpath while another owner
|
|
305
|
-
// releases/replaces that exact canonical directory. Retry only when the original
|
|
306
|
-
// failure is the resulting ENOENT and a fresh walk still resolves to either the
|
|
307
|
-
// canonical directory or a missing suffix. Junctions/reparse points and every other
|
|
308
|
-
// unsafe topology keep failing closed.
|
|
309
|
-
function retryableLockTopologyRace(vaultBase, lock, error, code) {
|
|
310
|
-
if (error?.cause?.code !== 'ENOENT'
|
|
311
|
-
|| (error?.code !== code && error?.code !== 'VAULT_PATH_UNSAFE')) return false;
|
|
312
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
313
|
-
try {
|
|
314
|
-
assertVaultPathSafe(vaultBase, lock, {
|
|
315
|
-
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
316
|
-
});
|
|
317
|
-
return true;
|
|
318
|
-
} catch (recheckError) {
|
|
319
|
-
if (recheckError?.cause?.code !== 'ENOENT') return false;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return false;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function processIsAlive(pid) {
|
|
326
|
-
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
327
|
-
if (pid === process.pid) return true;
|
|
328
|
-
try {
|
|
329
|
-
process.kill(pid, 0);
|
|
330
|
-
return true;
|
|
331
|
-
} catch (error) {
|
|
332
|
-
return error?.code === 'EPERM';
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function vaultLockOwner(vaultBase, lock, code) {
|
|
337
|
-
const path = join(lock, VAULT_LOCK_OWNER_FILE);
|
|
338
|
-
const checked = assertVaultPathSafe(vaultBase, path, {
|
|
339
|
-
expectedType: 'file', label: 'owner do lock de escrita do Vault', code,
|
|
340
|
-
});
|
|
341
|
-
if (!checked.exists) return { owner: null, path: checked.target, raw: '' };
|
|
342
|
-
const raw = readFileSync(checked.target, 'utf8');
|
|
343
|
-
try {
|
|
344
|
-
const owner = JSON.parse(raw);
|
|
345
|
-
const valid = owner?.v === 1
|
|
346
|
-
&& Number.isSafeInteger(owner.pid) && owner.pid > 0
|
|
347
|
-
&& typeof owner.token === 'string' && owner.token.length > 0
|
|
348
|
-
&& typeof owner.created_at === 'string' && owner.created_at.length > 0;
|
|
349
|
-
return { owner: valid ? owner : null, path: checked.target, raw };
|
|
350
|
-
} catch {
|
|
351
|
-
return { owner: null, path: checked.target, raw };
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function vaultLockLease(lock, token) {
|
|
356
|
-
return join(lock, `.lease-${token}`);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function removePreparedVaultLock(vaultBase, lock, token, code) {
|
|
360
|
-
const checked = assertVaultPathSafe(vaultBase, lock, {
|
|
361
|
-
expectedType: 'directory', label: 'lock privado em preparação', code,
|
|
362
|
-
});
|
|
363
|
-
if (!checked.exists) return true;
|
|
364
|
-
const owner = vaultLockOwner(vaultBase, lock, code);
|
|
365
|
-
if (owner.owner
|
|
366
|
-
&& (owner.owner.pid !== process.pid || owner.owner.token !== token)) return false;
|
|
367
|
-
const leasePath = vaultLockLease(lock, token);
|
|
368
|
-
const allowed = new Set([VAULT_LOCK_OWNER_FILE, `.lease-${token}`]);
|
|
369
|
-
const entries = readdirSync(checked.target);
|
|
370
|
-
if (entries.some((name) => !allowed.has(name))) return false;
|
|
371
|
-
unlinkVaultFile(vaultBase, leasePath, {
|
|
372
|
-
label: 'lease do lock de escrita do Vault', code,
|
|
373
|
-
});
|
|
374
|
-
if (owner.raw) {
|
|
375
|
-
const current = vaultLockOwner(vaultBase, lock, code).owner;
|
|
376
|
-
if (current?.pid !== process.pid || current?.token !== token) return false;
|
|
377
|
-
if (!unlinkVaultFile(vaultBase, owner.path, {
|
|
378
|
-
label: 'owner do lock de escrita do Vault', code,
|
|
379
|
-
})) return false;
|
|
380
|
-
}
|
|
381
|
-
return removeVaultLockDirectory(vaultBase, lock, {
|
|
382
|
-
missingOk: false, label: 'lock de escrita do Vault', code,
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function releaseOwnedVaultLock(vaultBase, lock, { pid, token, code }) {
|
|
387
|
-
const observed = vaultLockOwner(vaultBase, lock, code).owner;
|
|
388
|
-
if (observed?.pid !== pid || observed?.token !== token) return false;
|
|
389
|
-
// The token-specific lease is the filesystem CAS. An old finally/reaper can only
|
|
390
|
-
// remove the directory after successfully unlinking the lease it originally saw;
|
|
391
|
-
// a replacement lock never contains that unguessable path.
|
|
392
|
-
const leaseRemoved = unlinkVaultFile(vaultBase, vaultLockLease(lock, token), {
|
|
393
|
-
label: 'lease do lock de escrita do Vault', code,
|
|
394
|
-
});
|
|
395
|
-
if (!leaseRemoved) return false;
|
|
396
|
-
const current = vaultLockOwner(vaultBase, lock, code).owner;
|
|
397
|
-
if (current?.pid !== pid || current?.token !== token) return false;
|
|
398
|
-
const ownerPath = join(lock, VAULT_LOCK_OWNER_FILE);
|
|
399
|
-
if (!unlinkVaultFile(vaultBase, ownerPath, {
|
|
400
|
-
label: 'owner do lock de escrita do Vault', code,
|
|
401
|
-
})) return false;
|
|
402
|
-
return removeVaultLockDirectory(vaultBase, lock, {
|
|
403
|
-
missingOk: false, label: 'lock de escrita do Vault', code,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function reapDeadVaultLock(vaultBase, lock, staleMs, code) {
|
|
408
|
-
const checked = assertVaultPathSafe(vaultBase, lock, {
|
|
409
|
-
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
410
|
-
});
|
|
411
|
-
if (!checked.exists) return true;
|
|
412
|
-
let before;
|
|
413
|
-
try {
|
|
414
|
-
before = statSync(checked.target);
|
|
415
|
-
} catch (error) {
|
|
416
|
-
if (error?.code === 'ENOENT') return true;
|
|
417
|
-
throw error;
|
|
418
|
-
}
|
|
419
|
-
if (Date.now() - before.mtimeMs <= staleMs) return false;
|
|
420
|
-
const observed = vaultLockOwner(vaultBase, checked.target, code);
|
|
421
|
-
if (observed.owner) {
|
|
422
|
-
if (processIsAlive(observed.owner.pid)) return false;
|
|
423
|
-
const lease = assertVaultPathSafe(vaultBase, vaultLockLease(checked.target, observed.owner.token), {
|
|
424
|
-
expectedType: 'file', label: 'lease do lock de escrita do Vault', code,
|
|
425
|
-
});
|
|
426
|
-
if (lease.exists) {
|
|
427
|
-
return releaseOwnedVaultLock(vaultBase, checked.target, {
|
|
428
|
-
pid: observed.owner.pid, token: observed.owner.token, code,
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
// Compatibility with owner-aware locks from 0.58.x, which predate token leases.
|
|
432
|
-
// A dead PID plus byte-identical owner and directory identity is sufficient here;
|
|
433
|
-
// a live legacy owner was returned above and is never reaped by age.
|
|
434
|
-
const entries = readdirSync(checked.target);
|
|
435
|
-
if (entries.some((name) => name !== VAULT_LOCK_OWNER_FILE)) return false;
|
|
436
|
-
const currentStat = statSync(checked.target);
|
|
437
|
-
const currentOwner = vaultLockOwner(vaultBase, checked.target, code);
|
|
438
|
-
if (currentStat.birthtimeMs !== before.birthtimeMs
|
|
439
|
-
|| currentStat.mtimeMs !== before.mtimeMs
|
|
440
|
-
|| currentOwner.raw !== observed.raw) return false;
|
|
441
|
-
if (!unlinkVaultFile(vaultBase, currentOwner.path, {
|
|
442
|
-
label: 'owner legado morto do lock de escrita do Vault', code,
|
|
443
|
-
})) return false;
|
|
444
|
-
return removeVaultLockDirectory(vaultBase, checked.target, {
|
|
445
|
-
missingOk: false, label: 'lock legado de escrita do Vault', code,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Locks are published by atomic directory rename only after owner + lease exist.
|
|
450
|
-
// Thus an old empty/partial directory is legacy or crash residue, never an in-flight
|
|
451
|
-
// live acquisition. Unknown children remain fail-closed.
|
|
452
|
-
const entries = readdirSync(checked.target);
|
|
453
|
-
for (const name of entries) {
|
|
454
|
-
assertVaultPathSafe(vaultBase, join(checked.target, name), {
|
|
455
|
-
allowMissing: false, expectedType: 'file', label: `resíduo do lock do Vault ${name}`, code,
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
if (entries.some((name) => name !== VAULT_LOCK_OWNER_FILE)) return false;
|
|
459
|
-
const currentStat = statSync(checked.target);
|
|
460
|
-
const currentOwner = vaultLockOwner(vaultBase, checked.target, code);
|
|
461
|
-
if (currentStat.birthtimeMs !== before.birthtimeMs
|
|
462
|
-
|| currentStat.mtimeMs !== before.mtimeMs
|
|
463
|
-
|| currentOwner.raw !== observed.raw) return false;
|
|
464
|
-
if (entries.includes(VAULT_LOCK_OWNER_FILE)
|
|
465
|
-
&& !unlinkVaultFile(vaultBase, currentOwner.path, {
|
|
466
|
-
label: 'owner parcial do lock de escrita do Vault', code,
|
|
467
|
-
})) return false;
|
|
468
|
-
return removeVaultLockDirectory(vaultBase, checked.target, {
|
|
469
|
-
missingOk: false, label: 'lock de escrita do Vault', code,
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
export function withVaultPathLock(vaultBase, path, fn, {
|
|
474
|
-
timeoutMs = 2000,
|
|
475
|
-
staleMs = 10_000,
|
|
476
|
-
code = 'VAULT_PATH_UNSAFE',
|
|
477
|
-
} = {}) {
|
|
478
|
-
const lock = `${path}.lock`;
|
|
479
|
-
const deadline = Date.now() + timeoutMs;
|
|
480
|
-
const token = randomUUID();
|
|
481
|
-
while (true) {
|
|
482
|
-
let current;
|
|
483
|
-
try {
|
|
484
|
-
current = assertVaultPathSafe(vaultBase, lock, {
|
|
485
|
-
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
486
|
-
});
|
|
487
|
-
} catch (error) {
|
|
488
|
-
if (retryableLockTopologyRace(vaultBase, lock, error, code)) continue;
|
|
489
|
-
throw error;
|
|
490
|
-
}
|
|
491
|
-
if (current.exists) {
|
|
492
|
-
let reaped = false;
|
|
493
|
-
try { reaped = reapDeadVaultLock(vaultBase, lock, staleMs, code); }
|
|
494
|
-
catch (error) {
|
|
495
|
-
if (retryableLockTopologyRace(vaultBase, lock, error, code)) continue;
|
|
496
|
-
if (error?.code === code || error?.code === 'VAULT_PATH_UNSAFE') throw error;
|
|
497
|
-
}
|
|
498
|
-
if (reaped) continue;
|
|
499
|
-
if (Date.now() >= deadline) return VAULT_LOCK_BUSY;
|
|
500
|
-
waitBriefly(10);
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const pending = `${lock}.${process.pid}.${token}.pending`;
|
|
505
|
-
let pendingCreated = false;
|
|
506
|
-
try {
|
|
507
|
-
mkdirVaultPath(vaultBase, pending, {
|
|
508
|
-
recursive: false, exclusive: true, label: 'lock de escrita do Vault', code,
|
|
509
|
-
});
|
|
510
|
-
pendingCreated = true;
|
|
511
|
-
writeVaultFileAtomic(vaultBase, join(pending, VAULT_LOCK_OWNER_FILE), `${JSON.stringify({
|
|
512
|
-
v: 1,
|
|
513
|
-
pid: process.pid,
|
|
514
|
-
token,
|
|
515
|
-
created_at: new Date().toISOString(),
|
|
516
|
-
})}\n`, 'utf8', { label: 'owner do lock de escrita do Vault', code });
|
|
517
|
-
writeVaultFileAtomic(vaultBase, vaultLockLease(pending, token), `${token}\n`, 'utf8', {
|
|
518
|
-
label: 'lease do lock de escrita do Vault', code,
|
|
519
|
-
});
|
|
520
|
-
const raced = assertVaultPathSafe(vaultBase, lock, {
|
|
521
|
-
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
522
|
-
});
|
|
523
|
-
if (raced.exists) {
|
|
524
|
-
removePreparedVaultLock(vaultBase, pending, token, code);
|
|
525
|
-
} else {
|
|
526
|
-
try {
|
|
527
|
-
renameVaultPath(vaultBase, pending, lock, {
|
|
528
|
-
sourceType: 'directory', label: 'publicação do lock de escrita do Vault', code,
|
|
529
|
-
});
|
|
530
|
-
break;
|
|
531
|
-
} catch (error) {
|
|
532
|
-
const raced = assertVaultPathSafe(vaultBase, lock, {
|
|
533
|
-
expectedType: 'directory', label: 'lock de escrita do Vault', code,
|
|
534
|
-
});
|
|
535
|
-
if (!raced.exists) throw error;
|
|
536
|
-
removePreparedVaultLock(vaultBase, pending, token, code);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
} catch (error) {
|
|
540
|
-
if (pendingCreated) {
|
|
541
|
-
try { removePreparedVaultLock(vaultBase, pending, token, code); }
|
|
542
|
-
catch { /* residue private remains recoverable; never clean through an alias */ }
|
|
543
|
-
}
|
|
544
|
-
throw error;
|
|
545
|
-
}
|
|
546
|
-
if (Date.now() >= deadline) return VAULT_LOCK_BUSY;
|
|
547
|
-
waitBriefly(10);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
try {
|
|
551
|
-
return fn();
|
|
552
|
-
} finally {
|
|
553
|
-
try {
|
|
554
|
-
releaseOwnedVaultLock(vaultBase, lock, { pid: process.pid, token, code });
|
|
555
|
-
}
|
|
556
|
-
catch { /* a failed safe release leaves the lock in place; never remove through an alias */ }
|
|
557
|
-
}
|
|
558
|
-
}
|
|
1
|
+
// Compatibility facade during the physical package migration.
|
|
2
|
+
export * from '../packages/vault/src/vault-path-safety.mjs';
|
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"workspaces": [
|
|
7
|
+
"packages/*"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
"./vault": "./packages/vault/src/index.mjs"
|
|
11
|
+
},
|
|
6
12
|
"bin": {
|
|
7
13
|
"wendkeep": "bin/wendkeep.mjs",
|
|
8
14
|
"wk": "bin/wendkeep.mjs"
|
|
@@ -11,6 +17,7 @@
|
|
|
11
17
|
"bin",
|
|
12
18
|
"src",
|
|
13
19
|
"hooks",
|
|
20
|
+
"packages",
|
|
14
21
|
"schema",
|
|
15
22
|
"docs/pt-BR/commands/*.md",
|
|
16
23
|
"docs/en/commands/*.md",
|
|
@@ -22,7 +29,7 @@
|
|
|
22
29
|
"node": ">=18"
|
|
23
30
|
},
|
|
24
31
|
"scripts": {
|
|
25
|
-
"check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs",
|
|
32
|
+
"check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs",
|
|
26
33
|
"test": "node --test",
|
|
27
34
|
"release": "node scripts/release.mjs",
|
|
28
35
|
"release:dry": "node scripts/release.mjs --dry-run",
|