wendkeep 0.72.0 → 0.73.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 +66 -3
- package/README.en.md +28 -11
- package/README.md +28 -11
- package/docs/en/commands/changes-and-verification.md +10 -5
- package/docs/en/commands/maintenance-and-diagnostics.md +17 -9
- package/docs/en/commands/observer.md +18 -12
- package/docs/en/commands/operating-profiles.md +28 -3
- package/docs/en/commands/sessions-and-import.md +4 -4
- package/docs/pt-BR/commands/changes-and-verification.md +10 -5
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -5
- package/docs/pt-BR/commands/observer.md +18 -12
- package/docs/pt-BR/commands/operating-profiles.md +28 -3
- package/docs/pt-BR/commands/sessions-and-import.md +4 -4
- package/hooks/brain-inject.mjs +6 -6
- package/hooks/change-context.mjs +11 -0
- package/hooks/change-core.mjs +53 -21
- package/hooks/change-warn.mjs +2 -0
- package/hooks/harness-doctor.mjs +13 -5
- package/hooks/understand-inject.mjs +1 -1
- package/hooks/vault-health.mjs +2 -2
- package/package.json +5 -4
- package/packages/cli/src/index.mjs +12 -2
- package/packages/integrations/src/host-hooks.mjs +1 -1
- package/packages/vault/src/memory-store.mjs +17 -4
- package/src/change.mjs +10 -4
- package/src/delivery.mjs +303 -0
- package/src/doctor.mjs +47 -10
- package/src/init.mjs +2 -2
- package/src/observer-auth.mjs +10 -0
- package/src/observer-memory-publish.mjs +13 -8
- package/src/observer-privacy.mjs +23 -0
- package/src/observer-publish.mjs +10 -7
- package/src/observer-server.mjs +51 -0
- package/src/observer-sql-publish.mjs +72 -31
- package/src/observer-sql-store.mjs +33 -2
- package/src/observer.mjs +10 -3
- package/src/release-changelog.mjs +1 -1
- package/src/release-provenance.mjs +115 -0
- package/src/skills-seed.mjs +25 -9
- package/src/sync-defs.mjs +5 -2
- package/src/sync.mjs +2 -2
- package/src/taxonomy.mjs +1 -1
- package/src/vault-readme.mjs +2 -2
- package/src/work-kind.mjs +62 -0
|
@@ -48,9 +48,9 @@ Usage:
|
|
|
48
48
|
package first (npm i -D wendkeep@latest); a running process
|
|
49
49
|
cannot replace itself. · --vault P · --profile <name> · --yes.
|
|
50
50
|
|
|
51
|
-
wendkeep doctor [--vault P]
|
|
51
|
+
wendkeep doctor [--vault P] Health check. --scope core|runtime · --strict for CI/release.
|
|
52
52
|
wendkeep observer <sub> Local multi-project Observer: serve | register | publish | status.
|
|
53
|
-
wendkeep change <sub> Change lifecycle: new [--simple] | use | bind <slug> --session <id> | continue | list | show |
|
|
53
|
+
wendkeep change <sub> Change lifecycle: new [--simple|--guide] | use | bind <slug> --session <id> | continue | list | show |
|
|
54
54
|
status | done <id> | undone <id> | diff | archive [--force] | abandon | relink | backlink.
|
|
55
55
|
archive exige verdict (rode verify --deep); abandon descarta sem ADR.
|
|
56
56
|
backlink [--apply]: injeta o backlink pro proposta em design/tarefas/spec órfãos (open + _arquivo).
|
|
@@ -62,6 +62,8 @@ Usage:
|
|
|
62
62
|
the project default. The Vault/session/memory core is always active.
|
|
63
63
|
wendkeep flow <sub> Low-ceremony E -> V contract: start | status | show | finish | promote.
|
|
64
64
|
FLOW records scope, sensors and a receipt without creating a change.
|
|
65
|
+
wendkeep delivery <sub> Operational delivery: start | status | finish | abandon.
|
|
66
|
+
Records authorization and an append-only receipt; never creates a change/spec/ADR.
|
|
65
67
|
wendkeep spec <sub> Specs: list | show | effective [--change] [--json] | migrate | rebase.
|
|
66
68
|
wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
|
|
67
69
|
wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
|
|
@@ -184,6 +186,9 @@ async function main(argv) {
|
|
|
184
186
|
if (cmd === 'flow') {
|
|
185
187
|
const { FLOW_HELP } = await import('../../../src/flow.mjs');
|
|
186
188
|
process.stdout.write(FLOW_HELP);
|
|
189
|
+
} else if (cmd === 'delivery') {
|
|
190
|
+
const { DELIVERY_HELP } = await import('../../../src/delivery.mjs');
|
|
191
|
+
process.stdout.write(DELIVERY_HELP);
|
|
187
192
|
} else if (cmd === 'profile') {
|
|
188
193
|
const { PROFILE_HELP } = await import('../../../src/profile.mjs');
|
|
189
194
|
process.stdout.write(PROFILE_HELP);
|
|
@@ -273,6 +278,11 @@ async function main(argv) {
|
|
|
273
278
|
process.exit(await runFlow(rest));
|
|
274
279
|
break;
|
|
275
280
|
}
|
|
281
|
+
case 'delivery': {
|
|
282
|
+
const { runDelivery } = await import('../../../src/delivery.mjs');
|
|
283
|
+
process.exit(runDelivery(rest));
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
276
286
|
case 'theme': {
|
|
277
287
|
const { runTheme } = await import('../../../src/theme.mjs');
|
|
278
288
|
runTheme(rest);
|
|
@@ -32,7 +32,7 @@ export const SESSION_HOOKS = [
|
|
|
32
32
|
];
|
|
33
33
|
|
|
34
34
|
export function hookCommand(name) {
|
|
35
|
-
return `npx wendkeep hook ${name}`;
|
|
35
|
+
return `npx --no-install wendkeep hook ${name}`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Forma node-direta do comando de hook: 1 processo (~100-250ms) em vez dos 3 do npx (cold-start
|
|
@@ -112,12 +112,25 @@ function readCheckedMemoryFile(vaultBase, path, encoding, label, { allowMissing
|
|
|
112
112
|
return readFileSync(checked.target, encoding);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
export function memoryFileIdentityMatches(descriptor, target, {
|
|
116
|
+
platform = process.platform,
|
|
117
|
+
} = {}) {
|
|
118
|
+
if (descriptor.ino !== target.ino) return false;
|
|
119
|
+
// libuv before 1.51 can report an inconsistent Windows volume serial number
|
|
120
|
+
// between stat(path) and fstat(fd). The inode is still the file index; path
|
|
121
|
+
// containment/reparse checks and nlink validation remain independent guards.
|
|
122
|
+
return platform === 'win32' || descriptor.dev === target.dev;
|
|
123
|
+
}
|
|
124
|
+
|
|
115
125
|
function assertOpenedMemoryFile(vaultBase, path, fd, label) {
|
|
116
126
|
const checked = checkedMemoryFile(vaultBase, path, label, { allowMissing: false });
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
// Windows file identities can exceed Number's safe integer range. Node 22.13 may
|
|
128
|
+
// round stat(path) and fstat(fd) differently for the same file, so compare the
|
|
129
|
+
// exact bigint values and keep nlink as the independent hardlink guard.
|
|
130
|
+
const descriptor = fstatSync(fd, { bigint: true });
|
|
131
|
+
const target = statSync(checked.target, { bigint: true });
|
|
132
|
+
if (!descriptor.isFile() || descriptor.nlink > 1n || target.nlink > 1n
|
|
133
|
+
|| !memoryFileIdentityMatches(descriptor, target)) {
|
|
121
134
|
throw unsafeMemoryPath(`${label} mudou de inode ou possui hardlink antes da mutação: ${checked.target}`);
|
|
122
135
|
}
|
|
123
136
|
return checked.target;
|
package/src/change.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
relinkChanges,
|
|
17
17
|
backfillArtifactLinks,
|
|
18
18
|
scaffoldPlaceholders,
|
|
19
|
+
isGuideCompactChange,
|
|
19
20
|
} from '../hooks/change-core.mjs';
|
|
20
21
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
21
22
|
import { buildEffectiveRequirementPackage, evaluateVerdict, formatOrphanReqs, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
|
|
@@ -61,7 +62,9 @@ export function runChange(argv) {
|
|
|
61
62
|
// G2: link the active session into the proposta's source: (graph edge proposta->sessão).
|
|
62
63
|
let sessionRel = '';
|
|
63
64
|
try { sessionRel = readControl(vaultBase).session_file || ''; } catch { /* sem control */ }
|
|
64
|
-
const r = newChange(vaultBase, slug, {
|
|
65
|
+
const r = newChange(vaultBase, slug, {
|
|
66
|
+
dateStr: today(), simple: rest.includes('--simple'), guide: rest.includes('--guide'), sessionRel,
|
|
67
|
+
});
|
|
65
68
|
process.stdout.write(`change ${r.created ? 'created' : 'exists'}: ${r.rel} (active)\n`);
|
|
66
69
|
process.exit(0);
|
|
67
70
|
}
|
|
@@ -97,7 +100,7 @@ export function runChange(argv) {
|
|
|
97
100
|
let sessionRel = '';
|
|
98
101
|
try { sessionRel = readControl(vaultBase).session_file || ''; } catch { /* no control */ }
|
|
99
102
|
const r = continueChange(vaultBase, archivedSlug, newSlug, {
|
|
100
|
-
dateStr: today(), simple: rest.includes('--simple'), sessionRel,
|
|
103
|
+
dateStr: today(), simple: rest.includes('--simple'), guide: rest.includes('--guide'), sessionRel,
|
|
101
104
|
});
|
|
102
105
|
if (!r.ok) { process.stderr.write(`wendkeep change continue: ${r.error}\n`); process.exit(2); }
|
|
103
106
|
process.stdout.write(`change created: ${r.rel} (continues ${r.archived}; active)\n`);
|
|
@@ -287,13 +290,16 @@ export function runChange(argv) {
|
|
|
287
290
|
try { tasks = parseTasks(readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
|
|
288
291
|
const forced = rest.includes('--force') && tasks.some((t) => !t.done);
|
|
289
292
|
const trivial = !tasks.some((t) => t.req) && !tasks.some((t) => t.sensor);
|
|
290
|
-
|
|
293
|
+
const compactGuide = isGuideCompactChange(join(vaultBase, getLocale(vaultBase).folders.changes, slug));
|
|
294
|
+
if (trivial) process.stderr.write(compactGuide
|
|
295
|
+
? 'aviso: GUIDE compacta sem [req:]/[sensor:] — resultado permanece auditável no archive, sem ADR automático\n'
|
|
296
|
+
: 'aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
|
|
291
297
|
const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate, adrFlags: { forced, trivial } });
|
|
292
298
|
if (!r.ok) {
|
|
293
299
|
process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
|
|
294
300
|
process.exit(1);
|
|
295
301
|
}
|
|
296
|
-
process.stdout.write(`archived: ${r.archivedRel}
|
|
302
|
+
process.stdout.write(`archived: ${r.archivedRel}${r.adrRel ? `; ADR: ${r.adrRel}` : '; GUIDE compacta: sem ADR'}\n`);
|
|
297
303
|
if (r.promoted && r.promoted.length) process.stdout.write(`specs promovidas: ${r.promoted.join(', ')}\n`);
|
|
298
304
|
if (r.specWarnings && r.specWarnings.length) for (const w of r.specWarnings) process.stderr.write(` aviso spec: ${w}\n`);
|
|
299
305
|
process.exit(0);
|
package/src/delivery.mjs
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
assertVaultPathSafe, mkdirVaultPath, writeVaultFileSync,
|
|
6
|
+
} from '../hooks/vault-path-safety.mjs';
|
|
7
|
+
import { resolveProjectVault } from './project-vault.mjs';
|
|
8
|
+
import { createWorkRoute } from './work-kind.mjs';
|
|
9
|
+
|
|
10
|
+
export const DELIVERY_HELP = `wendkeep delivery <subcommand>
|
|
11
|
+
|
|
12
|
+
start [id] --allow <capability> [--source-change <slug>] [--source-commit <sha>]
|
|
13
|
+
status [id]
|
|
14
|
+
finish [id] [--target <ref>] [--ci-url <url>] [--version <x.y.z>]
|
|
15
|
+
[--npm-integrity <sha512-...>] [--release-url <url>]
|
|
16
|
+
abandon [id] --reason <text>
|
|
17
|
+
|
|
18
|
+
Common options: --project <path> --vault <path> --json
|
|
19
|
+
Delivery authorizes operational risk and creates an append-only receipt. It never creates a change,
|
|
20
|
+
spec, or ADR. If code/config must change, abandon or pause delivery and resume an implementation.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export const DELIVERY_CAPABILITIES = Object.freeze([
|
|
24
|
+
'git:merge', 'git:pull', 'git:push', 'git:tag', 'publish',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const VALUE_OPTIONS = new Set([
|
|
28
|
+
'--project', '--vault', '--allow', '--source-change', '--source-commit', '--target',
|
|
29
|
+
'--ci-url', '--version', '--npm-integrity', '--release-url', '--reason',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function parseArgv(argv) {
|
|
33
|
+
const values = new Map();
|
|
34
|
+
const positionals = [];
|
|
35
|
+
let json = false;
|
|
36
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
37
|
+
const item = argv[index];
|
|
38
|
+
if (item === '--json') { json = true; continue; }
|
|
39
|
+
if (item.startsWith('--')) {
|
|
40
|
+
const eq = item.indexOf('=');
|
|
41
|
+
const name = eq > 0 ? item.slice(0, eq) : item;
|
|
42
|
+
if (!VALUE_OPTIONS.has(name)) throw new Error(`opção desconhecida: ${name}`);
|
|
43
|
+
const value = eq > 0 ? item.slice(eq + 1) : argv[++index];
|
|
44
|
+
if (!value || value.startsWith('--')) throw new Error(`${name} requer um valor`);
|
|
45
|
+
const list = values.get(name) || [];
|
|
46
|
+
list.push(value);
|
|
47
|
+
values.set(name, list);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
positionals.push(item);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
json,
|
|
54
|
+
positionals,
|
|
55
|
+
value: (name) => values.get(name)?.at(-1) || '',
|
|
56
|
+
all: (name) => values.get(name) || [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function git(projectRoot, args, optional = false) {
|
|
61
|
+
try {
|
|
62
|
+
return execFileSync('git', args, {
|
|
63
|
+
cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
}).trim();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (optional) return '';
|
|
67
|
+
throw new Error(`git ${args.join(' ')} falhou: ${String(error.stderr || error.message).trim()}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function deliveryPaths(vaultBase, id = '') {
|
|
72
|
+
const runtime = join(vaultBase, '.brain', 'runtime');
|
|
73
|
+
const deliveries = join(runtime, 'deliveries');
|
|
74
|
+
return {
|
|
75
|
+
runtime,
|
|
76
|
+
deliveries,
|
|
77
|
+
pointer: join(runtime, 'CURRENT_DELIVERY'),
|
|
78
|
+
receipts: join(runtime, 'delivery-receipts.jsonl'),
|
|
79
|
+
state: id ? join(deliveries, `${id}.json`) : '',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeId(value) {
|
|
84
|
+
const id = String(value || '').trim();
|
|
85
|
+
if (!/^[a-z0-9][a-z0-9._-]{1,100}$/i.test(id)) throw new Error('id de delivery inválido');
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function generatedId(now = new Date()) {
|
|
90
|
+
return `delivery-${now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').toLowerCase()}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readPointer(vaultBase) {
|
|
94
|
+
const { pointer } = deliveryPaths(vaultBase);
|
|
95
|
+
try { return safeId(readFileSync(pointer, 'utf8').trim()); } catch { return ''; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function activeDelivery(vaultBase) {
|
|
99
|
+
const id = readPointer(vaultBase);
|
|
100
|
+
if (!id) return null;
|
|
101
|
+
try {
|
|
102
|
+
const state = readState(vaultBase, id);
|
|
103
|
+
return state.state === 'active' ? state : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readState(vaultBase, id) {
|
|
110
|
+
const path = deliveryPaths(vaultBase, id).state;
|
|
111
|
+
const checked = assertVaultPathSafe(vaultBase, path, {
|
|
112
|
+
allowMissing: false, expectedType: 'file', label: `delivery ${id}`,
|
|
113
|
+
});
|
|
114
|
+
return JSON.parse(readFileSync(checked.target, 'utf8'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeState(vaultBase, state) {
|
|
118
|
+
const paths = deliveryPaths(vaultBase, state.id);
|
|
119
|
+
mkdirVaultPath(vaultBase, paths.runtime, { label: 'runtime de delivery' });
|
|
120
|
+
mkdirVaultPath(vaultBase, paths.deliveries, { label: 'estados de delivery' });
|
|
121
|
+
writeVaultFileSync(vaultBase, paths.state, `${JSON.stringify(state, null, 2)}\n`, 'utf8', {
|
|
122
|
+
label: `delivery ${state.id}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setPointer(vaultBase, id = '') {
|
|
127
|
+
const paths = deliveryPaths(vaultBase);
|
|
128
|
+
mkdirVaultPath(vaultBase, paths.runtime, { label: 'runtime de delivery' });
|
|
129
|
+
writeVaultFileSync(vaultBase, paths.pointer, id ? `${id}\n` : '', 'utf8', {
|
|
130
|
+
label: 'ponteiro de delivery',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function appendReceipt(vaultBase, receipt) {
|
|
135
|
+
const paths = deliveryPaths(vaultBase);
|
|
136
|
+
mkdirVaultPath(vaultBase, paths.runtime, { label: 'runtime de delivery' });
|
|
137
|
+
let previous = '';
|
|
138
|
+
if (existsSync(paths.receipts)) {
|
|
139
|
+
const checked = assertVaultPathSafe(vaultBase, paths.receipts, {
|
|
140
|
+
allowMissing: false, expectedType: 'file', label: 'ledger de receipts de delivery',
|
|
141
|
+
});
|
|
142
|
+
previous = readFileSync(checked.target, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
writeVaultFileSync(vaultBase, paths.receipts, `${previous}${JSON.stringify(receipt)}\n`, 'utf8', {
|
|
145
|
+
label: 'ledger de receipts de delivery',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function context(parsed) {
|
|
150
|
+
const projectRoot = resolve(parsed.value('--project') || process.cwd());
|
|
151
|
+
const resolved = resolveProjectVault({
|
|
152
|
+
startDir: projectRoot,
|
|
153
|
+
explicitVault: parsed.value('--vault'),
|
|
154
|
+
});
|
|
155
|
+
const repoRoot = git(projectRoot, ['rev-parse', '--show-toplevel']);
|
|
156
|
+
return { projectRoot, repoRoot, vaultBase: resolved.base };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function currentId(parsed, vaultBase) {
|
|
160
|
+
return safeId(parsed.positionals[1] || readPointer(vaultBase));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ensureClean(repoRoot) {
|
|
164
|
+
const dirty = git(repoRoot, ['status', '--porcelain']);
|
|
165
|
+
if (dirty) {
|
|
166
|
+
const error = new Error('delivery requer working tree limpa; alterações de código/config exigem implementation.');
|
|
167
|
+
error.code = 'WENDKEEP_DELIVERY_IMPLEMENTATION_REQUIRED';
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function startDelivery({ vaultBase, repoRoot, id, capabilities, sourceChange = '', sourceCommit = '', now = new Date() }) {
|
|
173
|
+
ensureClean(repoRoot);
|
|
174
|
+
const deliveryId = safeId(id || generatedId(now));
|
|
175
|
+
const paths = deliveryPaths(vaultBase, deliveryId);
|
|
176
|
+
if (existsSync(paths.state)) throw new Error(`delivery já existe: ${deliveryId}`);
|
|
177
|
+
const commit = sourceCommit || git(repoRoot, ['rev-parse', 'HEAD']);
|
|
178
|
+
git(repoRoot, ['cat-file', '-e', `${commit}^{commit}`]);
|
|
179
|
+
const requestedCapabilities = [...new Set((capabilities || []).map((item) => String(item).trim()).filter(Boolean))];
|
|
180
|
+
const invalidCapabilities = requestedCapabilities.filter((item) => !DELIVERY_CAPABILITIES.includes(item));
|
|
181
|
+
if (invalidCapabilities.length) {
|
|
182
|
+
throw new Error(`capability inválida: ${invalidCapabilities.join(', ')}. Use ${DELIVERY_CAPABILITIES.join(', ')}.`);
|
|
183
|
+
}
|
|
184
|
+
const route = createWorkRoute({
|
|
185
|
+
workKind: 'delivery', profile: 'ASSURE', contractImpact: 'none',
|
|
186
|
+
operationRisk: requestedCapabilities, sourceChange, sourceCommit: commit,
|
|
187
|
+
});
|
|
188
|
+
if (!route.operation_risk.length) throw new Error('delivery start requer ao menos um --allow <capability>');
|
|
189
|
+
const state = {
|
|
190
|
+
schema_version: 1,
|
|
191
|
+
id: deliveryId,
|
|
192
|
+
state: 'active',
|
|
193
|
+
route,
|
|
194
|
+
repository: repoRoot,
|
|
195
|
+
worktree: repoRoot,
|
|
196
|
+
branch: git(repoRoot, ['branch', '--show-current'], true),
|
|
197
|
+
source_commit: commit,
|
|
198
|
+
started_at: now.toISOString(),
|
|
199
|
+
};
|
|
200
|
+
writeState(vaultBase, state);
|
|
201
|
+
setPointer(vaultBase, deliveryId);
|
|
202
|
+
return state;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function finishDelivery({ vaultBase, repoRoot, id, target = 'HEAD', evidence = {}, now = new Date() }) {
|
|
206
|
+
ensureClean(repoRoot);
|
|
207
|
+
const state = readState(vaultBase, safeId(id));
|
|
208
|
+
if (state.state !== 'active') throw new Error(`delivery ${id} não está ativa`);
|
|
209
|
+
const targetCommit = git(repoRoot, ['rev-parse', `${target}^{commit}`]);
|
|
210
|
+
git(repoRoot, ['merge-base', '--is-ancestor', state.source_commit, targetCommit]);
|
|
211
|
+
const capabilities = state.route?.operation_risk || [];
|
|
212
|
+
if (capabilities.includes('git:tag') || capabilities.includes('publish')) {
|
|
213
|
+
if (!evidence.version) throw new Error('delivery com tag/publicação requer --version');
|
|
214
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
|
|
215
|
+
if (pkg.version !== evidence.version) throw new Error(`package.json ${pkg.version} diverge de ${evidence.version}`);
|
|
216
|
+
const tagCommit = git(repoRoot, ['rev-list', '-n', '1', `refs/tags/v${evidence.version}`]);
|
|
217
|
+
if (tagCommit !== targetCommit) throw new Error(`v${evidence.version} não aponta para o target comprovado`);
|
|
218
|
+
}
|
|
219
|
+
if (capabilities.includes('publish')) {
|
|
220
|
+
for (const [key, label] of [
|
|
221
|
+
['ci_url', '--ci-url'], ['npm_integrity', '--npm-integrity'], ['release_url', '--release-url'],
|
|
222
|
+
]) if (!evidence[key]) throw new Error(`delivery com publish requer ${label}`);
|
|
223
|
+
}
|
|
224
|
+
const receipt = {
|
|
225
|
+
schema_version: 1,
|
|
226
|
+
delivery_id: state.id,
|
|
227
|
+
outcome: 'completed',
|
|
228
|
+
work_kind: 'delivery',
|
|
229
|
+
source_change: state.route.source_change || '',
|
|
230
|
+
source_commit: state.source_commit,
|
|
231
|
+
target,
|
|
232
|
+
target_commit: targetCommit,
|
|
233
|
+
capabilities,
|
|
234
|
+
evidence,
|
|
235
|
+
finished_at: now.toISOString(),
|
|
236
|
+
};
|
|
237
|
+
appendReceipt(vaultBase, receipt);
|
|
238
|
+
writeState(vaultBase, { ...state, state: 'completed', target, target_commit: targetCommit, finished_at: receipt.finished_at, receipt });
|
|
239
|
+
if (readPointer(vaultBase) === state.id) setPointer(vaultBase);
|
|
240
|
+
return receipt;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function abandonDelivery({ vaultBase, id, reason, now = new Date() }) {
|
|
244
|
+
const state = readState(vaultBase, safeId(id));
|
|
245
|
+
if (state.state !== 'active') throw new Error(`delivery ${id} não está ativa`);
|
|
246
|
+
if (!String(reason || '').trim()) throw new Error('delivery abandon requer --reason <text>');
|
|
247
|
+
const receipt = {
|
|
248
|
+
schema_version: 1, delivery_id: state.id, outcome: 'abandoned',
|
|
249
|
+
reason: String(reason).trim(), abandoned_at: now.toISOString(),
|
|
250
|
+
};
|
|
251
|
+
appendReceipt(vaultBase, receipt);
|
|
252
|
+
writeState(vaultBase, { ...state, state: 'abandoned', reason: receipt.reason, abandoned_at: receipt.abandoned_at });
|
|
253
|
+
if (readPointer(vaultBase) === state.id) setPointer(vaultBase);
|
|
254
|
+
return receipt;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function emit(payload, json) {
|
|
258
|
+
if (json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
259
|
+
else process.stdout.write(`${payload.id || payload.delivery_id}: ${payload.state || payload.outcome}\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function runDelivery(argv = []) {
|
|
263
|
+
try {
|
|
264
|
+
const parsed = parseArgv(argv);
|
|
265
|
+
const sub = parsed.positionals[0] || 'status';
|
|
266
|
+
const { repoRoot, vaultBase } = context(parsed);
|
|
267
|
+
if (sub === 'start') {
|
|
268
|
+
const state = startDelivery({
|
|
269
|
+
vaultBase, repoRoot, id: parsed.positionals[1], capabilities: parsed.all('--allow'),
|
|
270
|
+
sourceChange: parsed.value('--source-change'), sourceCommit: parsed.value('--source-commit'),
|
|
271
|
+
});
|
|
272
|
+
emit(state, parsed.json);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
if (sub === 'status') {
|
|
276
|
+
const state = readState(vaultBase, currentId(parsed, vaultBase));
|
|
277
|
+
emit(state, parsed.json);
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
if (sub === 'finish') {
|
|
281
|
+
const receipt = finishDelivery({
|
|
282
|
+
vaultBase, repoRoot, id: currentId(parsed, vaultBase), target: parsed.value('--target') || 'HEAD',
|
|
283
|
+
evidence: {
|
|
284
|
+
ci_url: parsed.value('--ci-url'), version: parsed.value('--version'),
|
|
285
|
+
npm_integrity: parsed.value('--npm-integrity'), release_url: parsed.value('--release-url'),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
emit(receipt, parsed.json);
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
if (sub === 'abandon') {
|
|
292
|
+
const receipt = abandonDelivery({
|
|
293
|
+
vaultBase, id: currentId(parsed, vaultBase), reason: parsed.value('--reason'),
|
|
294
|
+
});
|
|
295
|
+
emit(receipt, parsed.json);
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`subcomando desconhecido: ${sub}`);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
process.stderr.write(`wendkeep delivery: ${error.message}\n`);
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/doctor.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { checkSyncDefs } from './sync-defs.mjs';
|
|
|
7
7
|
import { resolveProjectVault } from './project-vault.mjs';
|
|
8
8
|
|
|
9
9
|
const healthStatusLabel = (status) => ({
|
|
10
|
-
healthy: 'saudável', warning: 'atenção', blocked: 'bloqueada', legacy: 'legado',
|
|
10
|
+
healthy: 'saudável', warning: 'atenção', degraded: 'degradada', blocked: 'bloqueada', legacy: 'legado',
|
|
11
11
|
}[status] || status || 'desconhecido');
|
|
12
12
|
|
|
13
13
|
const metricValue = (value) => value === null || value === undefined || value === '' ? 'n/a' : value;
|
|
@@ -55,6 +55,8 @@ export function runDoctor(argv) {
|
|
|
55
55
|
let vault;
|
|
56
56
|
let project;
|
|
57
57
|
let session = '';
|
|
58
|
+
let scope = 'all';
|
|
59
|
+
let strict = false;
|
|
58
60
|
for (let i = 0; i < argv.length; i += 1) {
|
|
59
61
|
const a = argv[i];
|
|
60
62
|
if (a === '--vault') vault = argv[++i];
|
|
@@ -63,6 +65,13 @@ export function runDoctor(argv) {
|
|
|
63
65
|
else if (a.startsWith('--project=')) project = a.slice(10);
|
|
64
66
|
else if (a === '--session') session = argv[++i] || '';
|
|
65
67
|
else if (a.startsWith('--session=')) session = a.slice(10);
|
|
68
|
+
else if (a === '--scope') scope = argv[++i] || '';
|
|
69
|
+
else if (a.startsWith('--scope=')) scope = a.slice(8);
|
|
70
|
+
else if (a === '--strict') strict = true;
|
|
71
|
+
}
|
|
72
|
+
if (!['all', 'core', 'runtime'].includes(scope)) {
|
|
73
|
+
process.stderr.write('wendkeep doctor: --scope deve ser all, core ou runtime\n');
|
|
74
|
+
return 2;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
const projectRoot = resolve(project || process.cwd());
|
|
@@ -97,18 +106,29 @@ export function runDoctor(argv) {
|
|
|
97
106
|
memoryStatus: 'blocked',
|
|
98
107
|
};
|
|
99
108
|
}
|
|
100
|
-
process.stdout.write(`${renderVaultHealthLines(health).join('\n')}\n`);
|
|
109
|
+
if (scope !== 'runtime') process.stdout.write(`${renderVaultHealthLines(health).join('\n')}\n`);
|
|
101
110
|
const healthStatus = health.ok ? 0 : 1;
|
|
102
111
|
|
|
112
|
+
if (scope === 'core') {
|
|
113
|
+
const strictDebt = strict && (
|
|
114
|
+
(health.warnings || []).length > 0
|
|
115
|
+
|| !['healthy'].includes(health.memoryStatus)
|
|
116
|
+
);
|
|
117
|
+
process.stdout.write(`\n[core] ${healthStatus ? 'erro estrutural' : health.memoryStatus === 'degraded' ? 'saudável com memória degradada' : 'saudável'}\n`);
|
|
118
|
+
return healthStatus || strictDebt ? 1 : 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
// 2. Harness integrity (Wave B).
|
|
104
|
-
const { errors, warnings } = checkHarness(vaultBase, projectRoot);
|
|
122
|
+
const { errors, warnings, attention, repairable } = checkHarness(vaultBase, projectRoot);
|
|
105
123
|
const defs = checkSyncDefs(vaultBase, projectRoot);
|
|
106
124
|
if (!defs.ok) {
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
repairable.push(...defs.issues.map((issue) => `defs: ${issue}`));
|
|
126
|
+
repairable.push('defs stale — rode `wendkeep sync-defs --reseed` e reinicie Claude Code/Codex');
|
|
109
127
|
}
|
|
110
|
-
process.stdout.write(`\n[
|
|
128
|
+
process.stdout.write(`\n[runtime] ${errors.length} erro(s) estrutural(is), ${attention.length} atenção(ões), ${repairable.length} reparável(is), ${warnings.length} aviso(s)\n`);
|
|
111
129
|
for (const e of errors) process.stdout.write(` ✗ ${e}\n`);
|
|
130
|
+
for (const item of attention) process.stdout.write(` ! ${item}\n`);
|
|
131
|
+
for (const item of repairable) process.stdout.write(` → ${item}\n`);
|
|
112
132
|
for (const w of warnings) process.stdout.write(` ! ${w}\n`);
|
|
113
133
|
|
|
114
134
|
// 3. Link/graph health — órfãos que o grafo do Obsidian mostraria, com o comando de reparo.
|
|
@@ -125,13 +145,16 @@ export function runDoctor(argv) {
|
|
|
125
145
|
process.stdout.write(`\n${renderStackedFrontmatterLines(vaultBase, stacked).join('\n')}\n`);
|
|
126
146
|
|
|
127
147
|
// 3c. Modelo fora de pricing.json fecha a sessão com custo zero, sem erro — só aparece aqui.
|
|
128
|
-
|
|
148
|
+
const unpriced = checkUnpricedModels(vaultBase);
|
|
149
|
+
process.stdout.write(`\n${renderUnpricedModelLines(unpriced).join('\n')}\n`);
|
|
129
150
|
|
|
130
151
|
// 3d. Seções derivadas do corpo que ficaram para trás do Encerramento (notas pré-0.53.0).
|
|
131
|
-
|
|
152
|
+
const staleDerived = checkStaleDerivedSections(vaultBase);
|
|
153
|
+
process.stdout.write(`\n${renderStaleDerivedSectionLines(staleDerived).join('\n')}\n`);
|
|
132
154
|
|
|
133
155
|
// 3e. Observabilidade materializada: schema vigente não basta sem frontier + manifest frescos.
|
|
134
|
-
|
|
156
|
+
const observability = checkSessionObservability(vaultBase);
|
|
157
|
+
process.stdout.write(`\n${renderSessionObservabilityLines(observability).join('\n')}\n`);
|
|
135
158
|
|
|
136
159
|
// 4. Sessão: não mente "inativa" quando há atividade recente (workflow/subagente em background).
|
|
137
160
|
const act = checkSessionActivity(vaultBase);
|
|
@@ -146,5 +169,19 @@ export function runDoctor(argv) {
|
|
|
146
169
|
|
|
147
170
|
// Devolve o código em vez de sair: `wendkeep sync` encadeia este comando, e um
|
|
148
171
|
// process.exit aqui mataria a cadeia. Quem faz o exit é o bin.
|
|
149
|
-
|
|
172
|
+
const strictDebt = strict && (
|
|
173
|
+
(scope !== 'runtime' && (health.warnings || []).length)
|
|
174
|
+
|| (scope !== 'runtime' && health.memoryStatus !== 'healthy')
|
|
175
|
+
|| attention.length
|
|
176
|
+
|| repairable.length
|
|
177
|
+
|| warnings.length
|
|
178
|
+
|| links.derivedOrphans
|
|
179
|
+
|| links.artifactOrphans
|
|
180
|
+
|| links.graphColors === false
|
|
181
|
+
|| stacked.count
|
|
182
|
+
|| (unpriced.models || unpriced.items || []).length
|
|
183
|
+
|| (staleDerived.notes || staleDerived.items || []).length
|
|
184
|
+
|| !observability.ok
|
|
185
|
+
);
|
|
186
|
+
return (scope !== 'runtime' && healthStatus !== 0) || errors.length || strictDebt ? 1 : 0;
|
|
150
187
|
}
|
package/src/init.mjs
CHANGED
|
@@ -323,7 +323,7 @@ const MESSAGES = {
|
|
|
323
323
|
mcpSkipped: ' [4/5] .mcp.json ignorado (--no-mcp, sem companions MCP)',
|
|
324
324
|
colorsSkipped: ' [5/5] cores ignoradas (--no-colors)',
|
|
325
325
|
colors: (r) => ` [5/5] cores: ${r}`,
|
|
326
|
-
runtimeIgnore: ' [!] ignore runtimes locais do wendkeep no Git quando o vault for versionado: .brain/.change-* .brain/runtime/flows/',
|
|
326
|
+
runtimeIgnore: ' [!] ignore runtimes locais do wendkeep no Git quando o vault for versionado: .brain/.change-* .brain/runtime/flows/ .brain/observer-sql-state.json .brain/observer-sql-outbox/',
|
|
327
327
|
merged: 'mesclado', created: 'criado', bakSaved: ', .bak salvo',
|
|
328
328
|
nextSteps: '\nPróximos passos:',
|
|
329
329
|
step1: (v) => ` 1. Abra o vault no Obsidian: "Abrir pasta como cofre" -> ${v}`,
|
|
@@ -352,7 +352,7 @@ const MESSAGES = {
|
|
|
352
352
|
mcpSkipped: ' [4/5] .mcp.json skipped (--no-mcp, no MCP companions)',
|
|
353
353
|
colorsSkipped: ' [5/5] colors skipped (--no-colors)',
|
|
354
354
|
colors: (r) => ` [5/5] colors: ${r}`,
|
|
355
|
-
runtimeIgnore: ' [!] keep local wendkeep runtimes out of Git when the vault is versioned: .brain/.change-* .brain/runtime/flows/',
|
|
355
|
+
runtimeIgnore: ' [!] keep local wendkeep runtimes out of Git when the vault is versioned: .brain/.change-* .brain/runtime/flows/ .brain/observer-sql-state.json .brain/observer-sql-outbox/',
|
|
356
356
|
merged: 'merged', created: 'created', bakSaved: ', .bak saved',
|
|
357
357
|
nextSteps: '\nNext steps:',
|
|
358
358
|
step1: (v) => ` 1. Open the vault in Obsidian: "Open folder as vault" -> ${v}`,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function resolveObserverToken(value = '') {
|
|
2
|
+
return String(value || process.env.WENDKEEP_OBSERVER_TOKEN || '').trim();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function observerAuthHeaders(token, headers = {}) {
|
|
6
|
+
const resolved = resolveObserverToken(token);
|
|
7
|
+
return resolved
|
|
8
|
+
? { ...headers, authorization: `Bearer ${resolved}` }
|
|
9
|
+
: { ...headers };
|
|
10
|
+
}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { MAX_MEMORY_CONTENT_BYTES } from './observer-memory.mjs';
|
|
15
|
+
import { observerAuthHeaders } from './observer-auth.mjs';
|
|
16
|
+
import { sanitizeObserverContent } from './observer-privacy.mjs';
|
|
15
17
|
|
|
16
18
|
export const MEMORY_OUTBOX_REL = '.brain/observer-memory-outbox';
|
|
17
19
|
export const MEMORY_STATE_FILE = '.brain/observer-memory-state.json';
|
|
@@ -103,7 +105,7 @@ function memoryFiles(vaultBase) {
|
|
|
103
105
|
|
|
104
106
|
export function localMemoryManifest(vaultBase) {
|
|
105
107
|
return Object.fromEntries(memoryFiles(vaultBase).map((file) => {
|
|
106
|
-
const content = readFileSync(file.absolute, 'utf8');
|
|
108
|
+
const content = sanitizeObserverContent(readFileSync(file.absolute, 'utf8'));
|
|
107
109
|
return [file.logicalPath, {
|
|
108
110
|
logical_path: file.logicalPath,
|
|
109
111
|
content_hash: hash(content),
|
|
@@ -159,7 +161,7 @@ export function buildMemoryEventBatch({
|
|
|
159
161
|
const events = [];
|
|
160
162
|
|
|
161
163
|
for (const file of currentFiles) {
|
|
162
|
-
const content = readFileSync(file.absolute, 'utf8');
|
|
164
|
+
const content = sanitizeObserverContent(readFileSync(file.absolute, 'utf8'));
|
|
163
165
|
if (Buffer.byteLength(content, 'utf8') > MAX_MEMORY_CONTENT_BYTES) {
|
|
164
166
|
throw new Error('arquivo excede o limite de memória: ' + file.logicalPath);
|
|
165
167
|
}
|
|
@@ -231,12 +233,12 @@ export function listMemoryOutbox(vaultBase) {
|
|
|
231
233
|
.map((name) => join(dir, name));
|
|
232
234
|
}
|
|
233
235
|
|
|
234
|
-
async function postBatch(url, projectId, events, fetchImpl = globalThis.fetch) {
|
|
236
|
+
async function postBatch(url, projectId, events, fetchImpl = globalThis.fetch, token = '') {
|
|
235
237
|
const response = await fetchImpl(
|
|
236
238
|
String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/memory/events',
|
|
237
239
|
{
|
|
238
240
|
method: 'POST',
|
|
239
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
241
|
+
headers: observerAuthHeaders(token, { 'content-type': 'application/json', accept: 'application/json' }),
|
|
240
242
|
body: JSON.stringify({ events }),
|
|
241
243
|
},
|
|
242
244
|
);
|
|
@@ -249,6 +251,7 @@ export async function retryObserverMemoryOutbox({
|
|
|
249
251
|
projectId = projectIdFromVault(vaultBase),
|
|
250
252
|
url,
|
|
251
253
|
fetchImpl = globalThis.fetch,
|
|
254
|
+
token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
|
|
252
255
|
} = {}) {
|
|
253
256
|
const files = listMemoryOutbox(vaultBase);
|
|
254
257
|
if (!url) return { attempted: 0, confirmed: 0, pending: files.length };
|
|
@@ -262,7 +265,7 @@ export async function retryObserverMemoryOutbox({
|
|
|
262
265
|
unlinkSync(path);
|
|
263
266
|
continue;
|
|
264
267
|
}
|
|
265
|
-
await postBatch(url, projectId, batch.events, fetchImpl);
|
|
268
|
+
await postBatch(url, projectId, batch.events, fetchImpl, token);
|
|
266
269
|
unlinkSync(path);
|
|
267
270
|
confirmed += 1;
|
|
268
271
|
} catch {
|
|
@@ -277,10 +280,11 @@ export async function compareMemoryParity({
|
|
|
277
280
|
projectId = projectIdFromVault(vaultBase),
|
|
278
281
|
url,
|
|
279
282
|
fetchImpl = globalThis.fetch,
|
|
283
|
+
token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
|
|
280
284
|
} = {}) {
|
|
281
285
|
const response = await fetchImpl(
|
|
282
286
|
String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/memory/tree',
|
|
283
|
-
{ headers: { accept: 'application/json' } },
|
|
287
|
+
{ headers: observerAuthHeaders(token, { accept: 'application/json' }) },
|
|
284
288
|
);
|
|
285
289
|
if (!response.ok) throw new Error('Observer respondeu HTTP ' + response.status + '.');
|
|
286
290
|
const body = await response.json();
|
|
@@ -309,9 +313,10 @@ export async function publishObserverMemory({
|
|
|
309
313
|
sourceTurnId = '',
|
|
310
314
|
now = new Date(),
|
|
311
315
|
fetchImpl = globalThis.fetch,
|
|
316
|
+
token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
|
|
312
317
|
} = {}) {
|
|
313
318
|
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
314
|
-
await retryObserverMemoryOutbox({ vaultBase, projectId, url, fetchImpl });
|
|
319
|
+
await retryObserverMemoryOutbox({ vaultBase, projectId, url, fetchImpl, token });
|
|
315
320
|
const state = readState(vaultBase);
|
|
316
321
|
const batch = buildMemoryEventBatch({ vaultBase, projectId, sourceSessionId, sourceTurnId, now, state });
|
|
317
322
|
if (batch.events.length === 0) {
|
|
@@ -324,7 +329,7 @@ export async function publishObserverMemory({
|
|
|
324
329
|
return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length, hookExitCode: 0 };
|
|
325
330
|
}
|
|
326
331
|
try {
|
|
327
|
-
await postBatch(url, projectId, batch.events, fetchImpl);
|
|
332
|
+
await postBatch(url, projectId, batch.events, fetchImpl, token);
|
|
328
333
|
commitMemoryPublishState(vaultBase, batch.nextState);
|
|
329
334
|
return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length };
|
|
330
335
|
} catch (error) {
|