wendkeep 0.77.0 → 0.79.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 +64 -0
- package/README.en.md +58 -3
- package/README.md +58 -3
- package/docs/en/commands/changes-and-verification.md +74 -2
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/verify.md +67 -5
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +73 -2
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/verify.md +67 -6
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +203 -123
- package/hooks/harness-doctor.mjs +51 -1
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/spec-core.mjs +118 -36
- package/package.json +2 -2
- package/packages/harness/src/sensors-core.mjs +57 -3
- package/packages/vault/src/evidence-envelope.mjs +73 -0
- package/packages/vault/src/index.mjs +1 -0
- package/packages/vault/src/memory-handoff.mjs +46 -5
- package/packages/vault/src/vault-path-safety.mjs +11 -0
- package/schema/wendkeep.evidence-envelope-v2.schema.json +92 -0
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1832 -48
- package/src/delivery.mjs +724 -67
- package/src/evidence-envelope.mjs +288 -0
- package/src/memory.mjs +2 -1
- package/src/provenance-gate.mjs +575 -0
- package/src/provenance-sources.mjs +547 -0
- package/src/receipt-ledger.mjs +841 -0
- package/src/release-provenance.mjs +48 -0
- package/src/skills-seed.mjs +11 -5
- package/src/verify.mjs +85 -22
- package/src/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
3
3
|
import { LOCK_BUSY, mutateSessionNote, withPathLock } from './session-note-io.mjs';
|
|
4
|
-
import { basename, dirname, join, relative } from 'path';
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from 'path';
|
|
5
5
|
import { getLocale } from './locale.mjs';
|
|
6
6
|
import { resolveProjectVault } from '../src/project-vault.mjs';
|
|
7
7
|
import {
|
|
@@ -288,17 +288,183 @@ export function readSessionRegistry(vaultBase) {
|
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
|
|
291
|
+
function cleanupReservations(registry) {
|
|
292
|
+
return registry?.cleanup_reservations
|
|
293
|
+
&& typeof registry.cleanup_reservations === 'object'
|
|
294
|
+
&& !Array.isArray(registry.cleanup_reservations)
|
|
295
|
+
? registry.cleanup_reservations : {};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function cleanupTombstones(registry) {
|
|
299
|
+
return registry?.cleanup_tombstones
|
|
300
|
+
&& typeof registry.cleanup_tombstones === 'object'
|
|
301
|
+
&& !Array.isArray(registry.cleanup_tombstones)
|
|
302
|
+
? registry.cleanup_tombstones : {};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function comparableCleanupPath(value) {
|
|
306
|
+
const raw = String(value || '').trim().replaceAll('\\', '/').replace(/\/+$/, '');
|
|
307
|
+
const normalized = raw ? resolve(raw).replaceAll('\\', '/') : '';
|
|
308
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sessionsForCleanupReservation(registry, reservation) {
|
|
312
|
+
const workSessionId = String(reservation?.work_session_id || '');
|
|
313
|
+
const worktreePath = comparableCleanupPath(reservation?.worktree_path);
|
|
314
|
+
return Object.entries(registry?.sessions || {})
|
|
315
|
+
.filter(([, session]) => {
|
|
316
|
+
if (session?.status !== 'active') return false;
|
|
317
|
+
const sameSession = workSessionId && String(session.work_session_id || '') === workSessionId;
|
|
318
|
+
const samePath = worktreePath && comparableCleanupPath(session?.project_scope?.repoRoot) === worktreePath;
|
|
319
|
+
return sameSession || samePath;
|
|
320
|
+
})
|
|
321
|
+
.map(([key, session]) => [String(key), structuredClone(session)])
|
|
322
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function cleanupReservationForWorktree(registry, worktreeId, repositoryId = '') {
|
|
326
|
+
const key = String(worktreeId || '').trim();
|
|
327
|
+
const repository = String(repositoryId || '').trim();
|
|
328
|
+
const reservations = cleanupReservations(registry);
|
|
329
|
+
const composite = repository ? `${repository}:${key}` : '';
|
|
330
|
+
const reservation = (composite && reservations[composite])
|
|
331
|
+
|| reservations[key]
|
|
332
|
+
|| (!repository
|
|
333
|
+
? Object.values(reservations).find((item) => String(item?.worktree_id || '') === key)
|
|
334
|
+
: null);
|
|
335
|
+
return reservation?.state === 'cleaning' ? reservation : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function cleanupTombstoneForWorktree(registry, worktreeId, repositoryId = '') {
|
|
339
|
+
const key = String(worktreeId || '').trim();
|
|
340
|
+
const repository = String(repositoryId || '').trim();
|
|
341
|
+
const tombstones = cleanupTombstones(registry);
|
|
342
|
+
const composite = repository ? `${repository}:${key}` : '';
|
|
343
|
+
const tombstone = (composite && tombstones[composite])
|
|
344
|
+
|| tombstones[key]
|
|
345
|
+
|| (!repository
|
|
346
|
+
? Object.values(tombstones).find((item) => String(item?.worktree_id || '') === key)
|
|
347
|
+
: null);
|
|
348
|
+
return tombstone?.state === 'cleaned' ? tombstone : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function activeContextsForCleanupWorktree(registry, worktreeId) {
|
|
352
|
+
return Object.entries(registry?.active_contexts || {})
|
|
353
|
+
.filter(([, context]) => context?.state === 'active'
|
|
354
|
+
&& String(context?.worktree_id || '') === String(worktreeId || ''));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function assertCleanupReservationMutation(previous, next, cleanupOperationId = '') {
|
|
358
|
+
const reservations = cleanupReservations(previous);
|
|
359
|
+
const operationId = String(cleanupOperationId || '');
|
|
360
|
+
for (const [worktreeId, reservation] of Object.entries(reservations)) {
|
|
361
|
+
if (reservation?.state !== 'cleaning') continue;
|
|
362
|
+
const reservedWorktreeId = String(reservation?.worktree_id || worktreeId);
|
|
363
|
+
const repositoryId = String(reservation?.repository_id || '');
|
|
364
|
+
const before = activeContextsForCleanupWorktree(previous, reservedWorktreeId);
|
|
365
|
+
const after = activeContextsForCleanupWorktree(next, reservedWorktreeId);
|
|
366
|
+
const contextsChanged = JSON.stringify(before) !== JSON.stringify(after);
|
|
367
|
+
const sessionsChanged = JSON.stringify(
|
|
368
|
+
sessionsForCleanupReservation(previous, reservation),
|
|
369
|
+
) !== JSON.stringify(sessionsForCleanupReservation(next, reservation));
|
|
370
|
+
const nextReservation = cleanupReservations(next)[worktreeId];
|
|
371
|
+
const reservationChanged = JSON.stringify(reservation) !== JSON.stringify(nextReservation);
|
|
372
|
+
if ((contextsChanged || sessionsChanged || reservationChanged)
|
|
373
|
+
&& operationId !== String(reservation.operation_id || '')) {
|
|
374
|
+
const error = new Error('active context está reservado por um cleanup em andamento');
|
|
375
|
+
error.code = 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_RESERVED';
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function assertCleanupTombstoneMutation(previous, next, cleanupOperationId = '') {
|
|
382
|
+
const operationId = String(cleanupOperationId || '');
|
|
383
|
+
const nextTombstones = cleanupTombstones(next);
|
|
384
|
+
for (const [worktreeId, tombstone] of Object.entries(cleanupTombstones(previous))) {
|
|
385
|
+
if (tombstone?.state !== 'cleaned') continue;
|
|
386
|
+
const reservedWorktreeId = String(tombstone?.worktree_id || worktreeId);
|
|
387
|
+
const before = activeContextsForCleanupWorktree(previous, reservedWorktreeId);
|
|
388
|
+
const after = activeContextsForCleanupWorktree(next, reservedWorktreeId);
|
|
389
|
+
const sessionsChanged = JSON.stringify(
|
|
390
|
+
sessionsForCleanupReservation(previous, tombstone),
|
|
391
|
+
) !== JSON.stringify(sessionsForCleanupReservation(next, tombstone));
|
|
392
|
+
const nextTombstone = nextTombstones[worktreeId];
|
|
393
|
+
const tombstoneChanged = JSON.stringify(tombstone) !== JSON.stringify(nextTombstone);
|
|
394
|
+
const sameTerminalSubject = nextTombstone
|
|
395
|
+
&& String(nextTombstone.state || '') === 'cleaned'
|
|
396
|
+
&& String(nextTombstone.operation_id || '') === String(tombstone.operation_id || '')
|
|
397
|
+
&& String(nextTombstone.project_id || '') === String(tombstone.project_id || '')
|
|
398
|
+
&& String(nextTombstone.repository_id || '') === String(tombstone.repository_id || '')
|
|
399
|
+
&& String(nextTombstone.worktree_id || '') === String(tombstone.worktree_id || '')
|
|
400
|
+
&& String(nextTombstone.work_session_id || '') === String(tombstone.work_session_id || '')
|
|
401
|
+
&& String(nextTombstone.change_slug || '') === String(tombstone.change_slug || '')
|
|
402
|
+
&& JSON.stringify(nextTombstone.target_context_ids || [])
|
|
403
|
+
=== JSON.stringify(tombstone.target_context_ids || [])
|
|
404
|
+
&& JSON.stringify(nextTombstone.target_change_slugs || [])
|
|
405
|
+
=== JSON.stringify(tombstone.target_change_slugs || [])
|
|
406
|
+
&& JSON.stringify(nextTombstone.target_context_snapshot || [])
|
|
407
|
+
=== JSON.stringify(tombstone.target_context_snapshot || [])
|
|
408
|
+
&& String(nextTombstone.worktree_path || '') === String(tombstone.worktree_path || '')
|
|
409
|
+
&& String(nextTombstone.actor_context_id || '') === String(tombstone.actor_context_id || '')
|
|
410
|
+
&& String(nextTombstone.mode || '') === String(tombstone.mode || '')
|
|
411
|
+
&& String(nextTombstone.authority || '') === String(tombstone.authority || '')
|
|
412
|
+
&& String(nextTombstone.head || '') === String(tombstone.head || '')
|
|
413
|
+
&& String(nextTombstone.slug || '') === String(tombstone.slug || '')
|
|
414
|
+
&& String(nextTombstone.pull_request_number || '') === String(tombstone.pull_request_number || '')
|
|
415
|
+
&& String(nextTombstone.pull_request_repository || '') === String(tombstone.pull_request_repository || '')
|
|
416
|
+
&& String(nextTombstone.head_ref_oid || '') === String(tombstone.head_ref_oid || '')
|
|
417
|
+
&& String(nextTombstone.merge_commit_oid || '') === String(tombstone.merge_commit_oid || '')
|
|
418
|
+
&& String(nextTombstone.subject_hash || '') === String(tombstone.subject_hash || '');
|
|
419
|
+
const nextReservation = next?.cleanup_reservations?.[
|
|
420
|
+
`${String(tombstone.repository_id || '')}:${String(tombstone.worktree_id || '')}`
|
|
421
|
+
];
|
|
422
|
+
const adoptedTerminalIdentity = sameTerminalSubject
|
|
423
|
+
&& String(nextTombstone.attempt_token || '')
|
|
424
|
+
&& String(nextTombstone.attempt_token || '') !== String(tombstone.attempt_token || '')
|
|
425
|
+
&& nextReservation
|
|
426
|
+
&& String(nextReservation.operation_id || '') === String(tombstone.operation_id || '')
|
|
427
|
+
&& String(nextReservation.attempt_token || '') === String(nextTombstone.attempt_token || '');
|
|
428
|
+
const sameTerminalIdentity = (sameTerminalSubject
|
|
429
|
+
&& String(nextTombstone.attempt_token || '') === String(tombstone.attempt_token || ''))
|
|
430
|
+
|| adoptedTerminalIdentity;
|
|
431
|
+
if ((JSON.stringify(before) !== JSON.stringify(after) || sessionsChanged
|
|
432
|
+
|| tombstoneChanged)
|
|
433
|
+
&& (!sameTerminalIdentity || operationId !== String(tombstone.operation_id || ''))) {
|
|
434
|
+
const error = new Error('worktree cleaned permanece sob tombstone terminal');
|
|
435
|
+
error.code = 'WENDKEEP_ACTIVE_CONTEXT_CLEANUP_TERMINAL';
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function writeSessionRegistry(vaultBase, registry, {
|
|
442
|
+
cleanupOperationId = '', timeoutMs = 2000, locked = false,
|
|
443
|
+
} = {}) {
|
|
292
444
|
const path = registryPath(vaultBase);
|
|
445
|
+
const write = () => {
|
|
446
|
+
const previous = readSessionRegistry(vaultBase);
|
|
447
|
+
assertCleanupReservationMutation(previous, registry, cleanupOperationId);
|
|
448
|
+
assertCleanupTombstoneMutation(previous, registry, cleanupOperationId);
|
|
449
|
+
mkdirVaultPath(vaultBase, dirname(path), { label: 'diretório do SESSION_REGISTRY' });
|
|
450
|
+
// Escrita atômica: grava em tmp e renomeia (rename é atômico no mesmo volume),
|
|
451
|
+
// evitando registry truncado/corrompido quando dois hooks gravam ao mesmo tempo.
|
|
452
|
+
writeVaultFileAtomic(vaultBase, path, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8', {
|
|
453
|
+
label: 'SESSION_REGISTRY.json',
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
if (locked) return write();
|
|
293
457
|
mkdirVaultPath(vaultBase, dirname(path), { label: 'diretório do SESSION_REGISTRY' });
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
458
|
+
const outcome = withPathLock(path, write, { timeoutMs, vaultBase });
|
|
459
|
+
if (outcome === LOCK_BUSY) {
|
|
460
|
+
throw new Error('SESSION_REGISTRY lock indisponível: lock ocupado até o timeout.');
|
|
461
|
+
}
|
|
462
|
+
return outcome;
|
|
299
463
|
}
|
|
300
464
|
|
|
301
|
-
export function mutateSessionRegistry(vaultBase, mutator, {
|
|
465
|
+
export function mutateSessionRegistry(vaultBase, mutator, {
|
|
466
|
+
timeoutMs = 2000, cleanupOperationId = '',
|
|
467
|
+
} = {}) {
|
|
302
468
|
const path = registryPath(vaultBase);
|
|
303
469
|
mkdirVaultPath(vaultBase, dirname(path), { label: 'diretório do SESSION_REGISTRY' });
|
|
304
470
|
const outcome = withPathLock(path, () => {
|
|
@@ -307,7 +473,7 @@ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } =
|
|
|
307
473
|
registry.version = 2;
|
|
308
474
|
const result = mutator(registry);
|
|
309
475
|
if (JSON.stringify(registry) !== before) {
|
|
310
|
-
writeSessionRegistry(vaultBase, registry);
|
|
476
|
+
writeSessionRegistry(vaultBase, registry, { cleanupOperationId, locked: true });
|
|
311
477
|
}
|
|
312
478
|
return result;
|
|
313
479
|
}, { timeoutMs, vaultBase });
|
package/hooks/spec-core.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
|
|
2
|
-
// Pure parsing/merge +
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
2
|
+
// Pure parsing/merge + read-only promotion planning. No import from change-core (avoids a cycle).
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { join, relative } from 'node:path';
|
|
6
6
|
import { getLocale } from './locale.mjs';
|
|
7
7
|
import {
|
|
8
|
-
assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath,
|
|
8
|
+
assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath,
|
|
9
|
+
writeVaultFileAtomic, writeVaultFileSync,
|
|
9
10
|
} from './vault-path-safety.mjs';
|
|
10
11
|
|
|
11
|
-
//
|
|
12
|
+
// Canonical SHA-256 fingerprint of tarefas.md — freshness binding between package/verdict and gate.
|
|
12
13
|
export function tasksHashOf(md) {
|
|
13
|
-
return createHash('
|
|
14
|
+
return `sha256:${createHash('sha256').update(String(md).replace(/\r\n?/g, '\n')).digest('hex')}`;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function contentHashOf(value) {
|
|
@@ -148,9 +149,20 @@ export function checkSpecsState(vaultBase) {
|
|
|
148
149
|
return { ok: changed.length === 0, missing: false, changed, current, recorded };
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
function recordPromotedSpecs(vaultBase, capabilities) {
|
|
152
|
+
function recordPromotedSpecs(vaultBase, capabilities, { writeAtomic = writeVaultFileSync } = {}) {
|
|
152
153
|
const existing = readSpecsState(vaultBase);
|
|
153
|
-
if (!existing)
|
|
154
|
+
if (!existing) {
|
|
155
|
+
const state = { version: 1, generatedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
|
|
156
|
+
mkdirVaultPath(vaultBase, join(vaultBase, '.brain'), { label: 'raiz do estado de specs' });
|
|
157
|
+
writeAtomic(
|
|
158
|
+
vaultBase,
|
|
159
|
+
join(vaultBase, SPECS_STATE_FILE),
|
|
160
|
+
`${JSON.stringify(state, null, 2)}\n`,
|
|
161
|
+
'utf8',
|
|
162
|
+
{ label: 'estado consolidado de specs' },
|
|
163
|
+
);
|
|
164
|
+
return state;
|
|
165
|
+
}
|
|
154
166
|
const current = readLivingSpecs(vaultBase);
|
|
155
167
|
const specs = { ...(existing.specs || {}) };
|
|
156
168
|
for (const capability of capabilities) {
|
|
@@ -158,7 +170,7 @@ function recordPromotedSpecs(vaultBase, capabilities) {
|
|
|
158
170
|
else delete specs[capability];
|
|
159
171
|
}
|
|
160
172
|
const state = { version: 1, generatedAt: new Date().toISOString(), specs };
|
|
161
|
-
|
|
173
|
+
writeAtomic(
|
|
162
174
|
vaultBase,
|
|
163
175
|
join(vaultBase, SPECS_STATE_FILE),
|
|
164
176
|
`${JSON.stringify(state, null, 2)}\n`,
|
|
@@ -168,7 +180,11 @@ function recordPromotedSpecs(vaultBase, capabilities) {
|
|
|
168
180
|
return state;
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
export function captureSpecBaseline(vaultBase, changeDir, {
|
|
183
|
+
export function captureSpecBaseline(vaultBase, changeDir, {
|
|
184
|
+
refresh = false,
|
|
185
|
+
writeAtomic = writeVaultFileAtomic,
|
|
186
|
+
beforeRename,
|
|
187
|
+
} = {}) {
|
|
172
188
|
const path = join(changeDir, SPEC_BASELINE_FILE);
|
|
173
189
|
const checked = assertVaultPathSafe(vaultBase, path, {
|
|
174
190
|
expectedType: 'file', label: 'baseline de specs da change',
|
|
@@ -177,12 +193,12 @@ export function captureSpecBaseline(vaultBase, changeDir, { refresh = false } =
|
|
|
177
193
|
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { /* rebuild malformed baseline */ }
|
|
178
194
|
}
|
|
179
195
|
const baseline = { version: 1, capturedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
|
|
180
|
-
|
|
196
|
+
writeAtomic(
|
|
181
197
|
vaultBase,
|
|
182
198
|
path,
|
|
183
199
|
`${JSON.stringify(baseline, null, 2)}\n`,
|
|
184
200
|
'utf8',
|
|
185
|
-
{ label: 'baseline de specs da change' },
|
|
201
|
+
{ scopeRoot: changeDir, label: 'baseline de specs da change', beforeRename },
|
|
186
202
|
);
|
|
187
203
|
return baseline;
|
|
188
204
|
}
|
|
@@ -344,12 +360,10 @@ export function discoverSpecDeltas(changeDir) {
|
|
|
344
360
|
// per-change (many changes promote into the same file; the per-change record lives in
|
|
345
361
|
// _arquivo). Generated + read-only. Written on init and refreshed on every archive so
|
|
346
362
|
// existing vaults self-heal. Bilingual by vault locale.
|
|
347
|
-
|
|
363
|
+
function specsReadmeBody(vaultBase) {
|
|
348
364
|
const loc = getLocale(vaultBase);
|
|
349
365
|
const en = loc.id === 'en';
|
|
350
|
-
|
|
351
|
-
mkdirVaultPath(vaultBase, dir, { label: 'raiz de specs consolidadas' });
|
|
352
|
-
const body = en
|
|
366
|
+
return en
|
|
353
367
|
? `# Specs — generated living contract
|
|
354
368
|
|
|
355
369
|
**One file per _capability_, not per change.** Each file is the current, cumulative contract
|
|
@@ -378,7 +392,18 @@ Pense como código-fonte vs commits: esta pasta é o *código atual* de cada cap
|
|
|
378
392
|
\`wendkeep change archive\` promove para esta pasta.
|
|
379
393
|
- Histórico por mudança → \`${loc.folders.changes}/_arquivo/\`. Contrato atual → aqui.
|
|
380
394
|
`;
|
|
381
|
-
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function renderSpecsReadme(vaultBase) {
|
|
398
|
+
return specsReadmeBody(vaultBase);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function ensureSpecsReadme(vaultBase, { writeAtomic = writeVaultFileSync } = {}) {
|
|
402
|
+
const loc = getLocale(vaultBase);
|
|
403
|
+
const dir = join(vaultBase, loc.folders.specs);
|
|
404
|
+
mkdirVaultPath(vaultBase, dir, { label: 'raiz de specs consolidadas' });
|
|
405
|
+
const body = specsReadmeBody(vaultBase);
|
|
406
|
+
writeAtomic(vaultBase, join(dir, 'README.md'), body, 'utf8', { label: 'README de specs' });
|
|
382
407
|
}
|
|
383
408
|
|
|
384
409
|
export function assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs) {
|
|
@@ -422,8 +447,14 @@ export function assertSpecPromotionTargetsSafe(vaultBase, changeDir, specs) {
|
|
|
422
447
|
return { specsRoot: checkedRoot.target };
|
|
423
448
|
}
|
|
424
449
|
|
|
425
|
-
//
|
|
426
|
-
|
|
450
|
+
// Derive the immutable before/postimage plan used by the archive CLI. This helper
|
|
451
|
+
// is deliberately read-only: publication is private to src/change.mjs, after the
|
|
452
|
+
// provenance gate, receipt, recapture, and operation lock have all succeeded.
|
|
453
|
+
export function buildSpecPromotionPlan(vaultBase, changeDir, specs, {
|
|
454
|
+
changeWikilink,
|
|
455
|
+
dateStr,
|
|
456
|
+
recoveryRoot,
|
|
457
|
+
} = {}) {
|
|
427
458
|
const loc = getLocale(vaultBase);
|
|
428
459
|
const specsDir = loc.folders.specs;
|
|
429
460
|
const promoted = [];
|
|
@@ -455,29 +486,80 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
|
|
|
455
486
|
content: renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }),
|
|
456
487
|
});
|
|
457
488
|
}
|
|
458
|
-
|
|
489
|
+
const statePath = join(vaultBase, SPECS_STATE_FILE);
|
|
490
|
+
const readmePath = join(specsRoot, 'README.md');
|
|
491
|
+
const existingState = readSpecsState(vaultBase);
|
|
492
|
+
const nextSpecs = existingState ? { ...(existingState.specs || {}) } : readLivingSpecs(vaultBase);
|
|
459
493
|
for (const item of materialized) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
item.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
{ label: `spec consolidada ${item.capability}` },
|
|
466
|
-
);
|
|
467
|
-
promoted.push(item.capability);
|
|
494
|
+
nextSpecs[item.capability] = {
|
|
495
|
+
hash: contentHashOf(item.content),
|
|
496
|
+
requirements: Object.fromEntries(parseRequirements(item.content)
|
|
497
|
+
.map((requirement) => [requirement.id || requirement.name, contentHashOf(JSON.stringify(requirement))])),
|
|
498
|
+
};
|
|
468
499
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
500
|
+
const stateContent = `${JSON.stringify({
|
|
501
|
+
version: 1,
|
|
502
|
+
generatedAt: new Date().toISOString(),
|
|
503
|
+
specs: nextSpecs,
|
|
504
|
+
}, null, 2)}\n`;
|
|
505
|
+
const planned = [
|
|
506
|
+
...materialized.map((item) => ({
|
|
507
|
+
kind: 'capability', capability: item.capability, path: item.livePath, content: item.content,
|
|
508
|
+
})),
|
|
509
|
+
{ kind: 'state', path: statePath, content: stateContent },
|
|
510
|
+
{ kind: 'readme', path: readmePath, content: specsReadmeBody(vaultBase) },
|
|
511
|
+
];
|
|
512
|
+
const image = (content, exists = true) => ({
|
|
513
|
+
exists,
|
|
514
|
+
content_base64: Buffer.from(content, 'utf8').toString('base64'),
|
|
515
|
+
digest: `sha256:${contentHashOf(content)}`,
|
|
516
|
+
});
|
|
517
|
+
const promotionRecoveryRoot = recoveryRoot
|
|
518
|
+
|| join(vaultBase, '.brain', 'runtime', 'spec-promotion-plans', randomUUID());
|
|
519
|
+
const plan = {
|
|
520
|
+
schema_version: 1,
|
|
521
|
+
entries: planned.map((entry, index) => {
|
|
522
|
+
const beforeExists = existsSync(entry.path);
|
|
523
|
+
const beforeContent = beforeExists ? readFileSync(entry.path, 'utf8') : '';
|
|
524
|
+
return {
|
|
525
|
+
kind: entry.kind,
|
|
526
|
+
capability: entry.capability || null,
|
|
527
|
+
target: relative(vaultBase, entry.path).replaceAll('\\', '/'),
|
|
528
|
+
claim_target: relative(vaultBase, join(promotionRecoveryRoot, `${index}-${randomUUID()}.before`)).replaceAll('\\', '/'),
|
|
529
|
+
candidate_target: relative(vaultBase, join(promotionRecoveryRoot, `${index}-${randomUUID()}.candidate`)).replaceAll('\\', '/'),
|
|
530
|
+
before: image(beforeContent, beforeExists),
|
|
531
|
+
after: image(entry.content, true),
|
|
532
|
+
};
|
|
533
|
+
}),
|
|
534
|
+
};
|
|
535
|
+
const changes = materialized.map((item) => ({
|
|
536
|
+
capability: item.capability,
|
|
537
|
+
before_digest: plan.entries.find((entry) => entry.capability === item.capability).before.digest,
|
|
538
|
+
after_digest: `sha256:${contentHashOf(item.content)}`,
|
|
539
|
+
}));
|
|
540
|
+
plan.changes = changes;
|
|
541
|
+
promoted.push(...materialized.map((item) => item.capability));
|
|
542
|
+
return { promoted, warnings, changes, plan };
|
|
472
543
|
}
|
|
473
544
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
545
|
+
export function evaluateVerdict(verdict, reqIds, {
|
|
546
|
+
tasksHash,
|
|
547
|
+
effectiveSpecHash,
|
|
548
|
+
evidenceEnvelopeId,
|
|
549
|
+
evidenceBinding,
|
|
550
|
+
} = {}) {
|
|
478
551
|
const ids = reqIds || [];
|
|
479
|
-
if (ids.length === 0) return { ok: true, missing: [] };
|
|
552
|
+
if (ids.length === 0 && !evidenceEnvelopeId) return { ok: true, missing: [] };
|
|
480
553
|
if (!verdict || verdict.ok !== true) return { ok: false, missing: [] };
|
|
554
|
+
if (evidenceEnvelopeId && verdict.evidenceEnvelopeId !== evidenceEnvelopeId) {
|
|
555
|
+
return { ok: false, missing: [], stale: true };
|
|
556
|
+
}
|
|
557
|
+
if (evidenceBinding && (!verdict.evidenceBinding || Object.entries(evidenceBinding).some(
|
|
558
|
+
([key, value]) => verdict.evidenceBinding[key] !== value,
|
|
559
|
+
))) {
|
|
560
|
+
return { ok: false, missing: [], stale: true };
|
|
561
|
+
}
|
|
562
|
+
if (ids.length === 0) return { ok: true, missing: [] };
|
|
481
563
|
// Freshness (G3/#6): a verdict minted against a different tarefas.md is stale. Verdicts
|
|
482
564
|
// without a hash (pre-0.6.1) are accepted for backward compat.
|
|
483
565
|
if (tasksHash && verdict.tasksHash && verdict.tasksHash !== tasksHash) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.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
6
|
"workspaces": [
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"node": ">=18"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
|
-
"precheck": "node --check src/worktree.mjs && node --check src/worktree-cleanup.mjs && node --check src/context.mjs && node --check src/active-context-health.mjs && node --check src/active-context-runtime.mjs && node --check hooks/active-context-store.mjs && node --check hooks/change-core.mjs && node --check hooks/brain-inject.mjs && node --check hooks/change-context.mjs && node --check packages/vault/src/worktree-metadata.mjs",
|
|
44
|
+
"precheck": "node --check src/change.mjs && node --check src/archive-operation-lock.mjs && node --check src/worktree.mjs && node --check src/worktree-cleanup.mjs && node --check src/provenance-gate.mjs && node --check src/provenance-sources.mjs && node --check src/receipt-ledger.mjs && node --check src/evidence-envelope.mjs && node --check src/context.mjs && node --check src/active-context-health.mjs && node --check src/active-context-runtime.mjs && node --check hooks/active-context-store.mjs && node --check hooks/change-core.mjs && node --check hooks/brain-inject.mjs && node --check hooks/change-context.mjs && node --check packages/vault/src/worktree-metadata.mjs && node --check packages/vault/src/evidence-envelope.mjs",
|
|
45
45
|
"check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check scripts/release-provenance.mjs && node --check scripts/run-scope.mjs && node --check src/release-provenance.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/active-context-health.mjs && node --check src/project-vault.mjs && node --check src/observer-auth.mjs && node --check src/observer-privacy.mjs && node --check src/observer-snapshot.mjs && node --check src/observer-store.mjs && node --check src/observer-memory.mjs && node --check src/observer-memory-publish.mjs && node --check src/observer-sql-store.mjs && node --check src/observer-sql-migrate.mjs && node --check src/observer-sql-publish.mjs && node --check src/observer-transcript-store.mjs && node --check src/observer-server.mjs && node --check src/observer.mjs && node --check src/observer-publish.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check src/work-kind.mjs && node --check src/delivery.mjs && node --check web/observer/app.mjs && node --check hooks/observer-publish.mjs && node --check hooks/evidence-context.mjs && node --check hooks/active-context-handoff-evidence.mjs && node --check hooks/evidence-recall.mjs && node --check hooks/memory-scope.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.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/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.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 && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-scope.mjs && node --check packages/vault/src/memory-candidate-policy.mjs && node --check packages/vault/src/evidence-recall.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
|
|
46
46
|
"test": "node --test --test-concurrency=2",
|
|
47
47
|
"test:core": "node scripts/run-scope.mjs core",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Pure-ish: `spawn` is injectable so runs are testable without a shell. Config lives
|
|
3
3
|
// at the PROJECT ROOT (wendkeep.sensors.json); evidence lives per-change in the vault.
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
7
|
import { dirname, join, resolve } from 'node:path';
|
|
7
8
|
|
|
@@ -22,6 +23,22 @@ function sanitizeSensorDiagnostic(value) {
|
|
|
22
23
|
.trim();
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function sha256(value) {
|
|
27
|
+
return `sha256:${createHash('sha256').update(String(value || '')).digest('hex')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sensorNow(now) {
|
|
31
|
+
const value = typeof now === 'function' ? now() : (now || new Date().toISOString());
|
|
32
|
+
if (value instanceof Date) return value.toISOString();
|
|
33
|
+
if (typeof value === 'string') return value;
|
|
34
|
+
return new Date(value).toISOString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function elapsedMilliseconds(startedAt, finishedAt) {
|
|
38
|
+
const elapsed = Date.parse(finishedAt) - Date.parse(startedAt);
|
|
39
|
+
return Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
function sensorFailureNote(result = {}) {
|
|
26
43
|
const status = result.status ?? 'null';
|
|
27
44
|
const header = [
|
|
@@ -86,11 +103,29 @@ export function requiredSensors(tasks) {
|
|
|
86
103
|
|
|
87
104
|
export function runSensors(sensors, ids, { spawn = spawnSync, cwd, env, now } = {}) {
|
|
88
105
|
const byId = Object.fromEntries((sensors || []).map((s) => [s.id, s]));
|
|
89
|
-
const ts = now || new Date().toISOString();
|
|
90
106
|
const evidence = [];
|
|
91
107
|
for (const id of ids) {
|
|
108
|
+
const startedAt = sensorNow(now);
|
|
92
109
|
const s = byId[id];
|
|
93
|
-
if (!s) {
|
|
110
|
+
if (!s) {
|
|
111
|
+
const finishedAt = sensorNow(now);
|
|
112
|
+
evidence.push({
|
|
113
|
+
id,
|
|
114
|
+
status: 'red',
|
|
115
|
+
ts: startedAt,
|
|
116
|
+
severity: 'critical',
|
|
117
|
+
started_at: startedAt,
|
|
118
|
+
finished_at: finishedAt,
|
|
119
|
+
duration_ms: elapsedMilliseconds(startedAt, finishedAt),
|
|
120
|
+
exit_code: null,
|
|
121
|
+
command: '',
|
|
122
|
+
command_sha256: sha256(''),
|
|
123
|
+
output_sha256: sha256(''),
|
|
124
|
+
output_tail: '',
|
|
125
|
+
note: 'sensor não definido',
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
94
129
|
const r = spawn(s.command, [], {
|
|
95
130
|
cwd,
|
|
96
131
|
shell: true,
|
|
@@ -99,7 +134,26 @@ export function runSensors(sensors, ids, { spawn = spawnSync, cwd, env, now } =
|
|
|
99
134
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
135
|
...(env ? { env } : {}),
|
|
101
136
|
});
|
|
102
|
-
const
|
|
137
|
+
const finishedAt = sensorNow(now);
|
|
138
|
+
const rawOutput = [r.stdout, r.stderr].filter(Boolean).join('\n');
|
|
139
|
+
const outputTail = sanitizeSensorDiagnostic(rawOutput);
|
|
140
|
+
const boundedOutputTail = outputTail.length > SENSOR_DIAGNOSTIC_MAX_LENGTH
|
|
141
|
+
? `…${outputTail.slice(-(SENSOR_DIAGNOSTIC_MAX_LENGTH - 1))}`
|
|
142
|
+
: outputTail;
|
|
143
|
+
const entry = {
|
|
144
|
+
id,
|
|
145
|
+
status: (r.status ?? 1) === 0 ? 'green' : 'red',
|
|
146
|
+
ts: startedAt,
|
|
147
|
+
severity: s.severity || 'critical',
|
|
148
|
+
command: sanitizeSensorDiagnostic(s.command),
|
|
149
|
+
command_sha256: sha256(s.command),
|
|
150
|
+
started_at: startedAt,
|
|
151
|
+
finished_at: finishedAt,
|
|
152
|
+
duration_ms: elapsedMilliseconds(startedAt, finishedAt),
|
|
153
|
+
exit_code: Number.isInteger(r.status) ? r.status : null,
|
|
154
|
+
output_sha256: sha256(rawOutput),
|
|
155
|
+
output_tail: boundedOutputTail,
|
|
156
|
+
};
|
|
103
157
|
if (entry.status === 'red') entry.note = sensorFailureNote(r);
|
|
104
158
|
if (s.type === 'mutation' && s.report) {
|
|
105
159
|
// Delegated mutation (Wave B): read the tool's mutation-testing-elements report and
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
function stableValue(value) {
|
|
4
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
5
|
+
if (value && typeof value === 'object' && !Buffer.isBuffer(value)) {
|
|
6
|
+
return Object.fromEntries(
|
|
7
|
+
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function canonicalSha256(value) {
|
|
14
|
+
const bytes = Buffer.isBuffer(value) || value instanceof Uint8Array
|
|
15
|
+
? value
|
|
16
|
+
: JSON.stringify(stableValue(value));
|
|
17
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function evidenceSensors(evidence) {
|
|
21
|
+
if (Array.isArray(evidence)) return evidence;
|
|
22
|
+
return Array.isArray(evidence?.sensors) ? evidence.sensors : [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CHECKOUT_BINDING_KEYS = [
|
|
26
|
+
'project_id', 'repository_id', 'worktree_id', 'head_sha', 'index_tree_sha', 'worktree_digest',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function evidenceCheckoutBinding(evidence = {}) {
|
|
30
|
+
return Object.fromEntries(CHECKOUT_BINDING_KEYS.map((key) => [key, evidence[key]]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function evidenceCheckoutBindingMatches(actual, expected) {
|
|
34
|
+
return Boolean(actual && expected) && CHECKOUT_BINDING_KEYS.every(
|
|
35
|
+
(key) => actual[key] === expected[key] && expected[key] != null,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function evaluateEvidenceBinding(evidence, expected = {}) {
|
|
40
|
+
if (!evidence) return { state: 'unproven', reasons: ['evidence missing'] };
|
|
41
|
+
if (Array.isArray(evidence) || evidence.schema_version !== 2) {
|
|
42
|
+
return { state: 'legacy-unbound', reasons: ['evidence schema v1 has no checkout binding'] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const contextReasons = [];
|
|
46
|
+
if (expected.change_slug != null && evidence.change_slug !== expected.change_slug) {
|
|
47
|
+
contextReasons.push('change_slug mismatch');
|
|
48
|
+
}
|
|
49
|
+
for (const key of ['project_id', 'repository_id', 'worktree_id', 'work_session_id']) {
|
|
50
|
+
if (expected.identity?.[key] != null && evidence[key] !== expected.identity[key]) {
|
|
51
|
+
contextReasons.push(`${key} mismatch`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (contextReasons.length) return { state: 'context-mismatch', reasons: contextReasons };
|
|
55
|
+
|
|
56
|
+
const staleReasons = [];
|
|
57
|
+
const unsigned = { ...evidence };
|
|
58
|
+
delete unsigned.envelope_id;
|
|
59
|
+
if (!evidence.envelope_id || canonicalSha256(unsigned) !== evidence.envelope_id) {
|
|
60
|
+
staleReasons.push('envelope_id invalid');
|
|
61
|
+
}
|
|
62
|
+
for (const key of ['branch', 'base_sha', 'head_sha', 'index_tree_sha', 'worktree_digest', 'dirty']) {
|
|
63
|
+
if (expected.snapshot?.[key] != null && evidence[key] !== expected.snapshot[key]) {
|
|
64
|
+
staleReasons.push(`${key} changed`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const key of ['tasks_sha256', 'effective_spec_sha256', 'sensor_config_sha256']) {
|
|
68
|
+
if (expected[key] != null && evidence[key] !== expected[key]) staleReasons.push(`${key} changed`);
|
|
69
|
+
}
|
|
70
|
+
return staleReasons.length
|
|
71
|
+
? { state: 'stale', reasons: staleReasons }
|
|
72
|
+
: { state: 'bound', reasons: [] };
|
|
73
|
+
}
|
|
@@ -8,5 +8,6 @@ export * from './memory-handoff.mjs';
|
|
|
8
8
|
export * from './memory-store.mjs';
|
|
9
9
|
export * from './memory-scope.mjs';
|
|
10
10
|
export * from './evidence-recall.mjs';
|
|
11
|
+
export * from './evidence-envelope.mjs';
|
|
11
12
|
export * from './validate-core.mjs';
|
|
12
13
|
export * from './validate-memory.mjs';
|