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
package/hooks/change-core.mjs
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
// hooks/change-core.mjs
|
|
2
2
|
// Native change/spec lifecycle in the vault (Pilar B). Vault-facing lib consumed by
|
|
3
3
|
// the `wendkeep change` CLI (src/change.mjs) and the brain-inject hook. No external deps.
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import {
|
|
6
|
+
existsSync, lstatSync, readFileSync, readdirSync, rmSync, statSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
6
9
|
import { wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
assertSpecPromotionTargetsSafe, captureSpecBaseline, discoverSpecDeltas, parseSpecsList,
|
|
12
|
+
REQ_ID_RE_SRC, tasksHashOf,
|
|
13
|
+
} from './spec-core.mjs';
|
|
8
14
|
import { getLocale, LOCALES } from './locale.mjs';
|
|
9
15
|
import {
|
|
10
16
|
assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, renameVaultPath,
|
|
11
|
-
unlinkVaultFile, writeVaultFileSync,
|
|
17
|
+
unlinkVaultFile, writeVaultFileAtomic, writeVaultFileSync,
|
|
12
18
|
} from './vault-path-safety.mjs';
|
|
13
19
|
import {
|
|
14
20
|
clearActiveContextChange,
|
|
15
21
|
resolveActiveContext,
|
|
16
22
|
setActiveContextChange,
|
|
17
23
|
} from './active-context-store.mjs';
|
|
24
|
+
import { evidenceSensors } from '../packages/vault/src/evidence-envelope.mjs';
|
|
18
25
|
|
|
19
26
|
export const ARCHIVE_DIR = '_arquivo';
|
|
20
27
|
const POINTER = '.brain/CURRENT_CHANGE.md';
|
|
@@ -442,7 +449,7 @@ export function quickGateState(vaultBase, { context } = {}) {
|
|
|
442
449
|
let redCritical = false;
|
|
443
450
|
try {
|
|
444
451
|
const ev = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8'));
|
|
445
|
-
redCritical = (
|
|
452
|
+
redCritical = evidenceSensors(ev).some((e) => e.status !== 'green' && (e.severity || 'critical') !== 'warning');
|
|
446
453
|
} catch { /* sem/ilegível = não conta contra o nudge */ }
|
|
447
454
|
let evidenceStale = false;
|
|
448
455
|
try {
|
|
@@ -553,138 +560,211 @@ export function gateGreen() {
|
|
|
553
560
|
return { ok: true, failing: [] };
|
|
554
561
|
}
|
|
555
562
|
|
|
556
|
-
export function
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
...(createAdr ? [
|
|
581
|
-
{ path: join(vaultBase, adrDirRel), expectedType: 'directory', label: 'pasta mensal de ADR' },
|
|
582
|
-
{ path: join(vaultBase, adrRel), expectedType: 'file', label: 'ADR da change arquivada' },
|
|
583
|
-
] : []),
|
|
584
|
-
];
|
|
585
|
-
const [checkedSource, checkedDestination] = assertVaultPathsSafe(vaultBase, mutationTargets);
|
|
586
|
-
assertVaultPathsSafe(vaultBase, [
|
|
587
|
-
{ path: join(checkedSource.target, 'proposta.md'), expectedType: 'file', label: 'proposta da change' },
|
|
588
|
-
{ path: join(checkedSource.target, 'tarefas.md'), expectedType: 'file', label: 'tarefas da change' },
|
|
589
|
-
]);
|
|
590
|
-
|
|
591
|
-
// Atomicity guard: fail BEFORE promoting specs if the destination already exists (e.g. a slug
|
|
592
|
-
// reused after a same-day archive). Otherwise promoteSpecs would commit to 07-Specs and the
|
|
593
|
-
// later renameSync would fail, leaving a half-archived state.
|
|
594
|
-
if (checkedDestination.exists) {
|
|
595
|
-
return { ok: false, failing: [`destino de arquivo já existe: ${destRel} — renomeie o slug ou remova o arquivo antigo`] };
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
|
|
599
|
-
// UNIÃO frontmatter + disco (0.31.0): o scaffold deixa `specs: []`, então um delta real
|
|
600
|
-
// preenchido em specs/<cap>/ mas não listado era silenciosamente ignorado. Deltas ainda em
|
|
601
|
-
// placeholder (o `exemplo` do scaffold) são filtrados por discoverSpecDeltas.
|
|
602
|
-
let promoted = [];
|
|
603
|
-
let specWarnings = [];
|
|
604
|
-
try {
|
|
605
|
-
let listed = [];
|
|
606
|
-
try { listed = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8')); } catch { /* proposta ilegível */ }
|
|
607
|
-
const onDisk = discoverSpecDeltas(src);
|
|
608
|
-
const union = [...new Set([...listed, ...onDisk])];
|
|
609
|
-
if (union.length) {
|
|
610
|
-
const res = promoteSpecs(vaultBase, src, union, { changeWikilink, dateStr });
|
|
611
|
-
promoted = res.promoted;
|
|
612
|
-
specWarnings = [
|
|
613
|
-
...onDisk.filter((c) => !listed.includes(c)).map((c) => `spec no disco não listada no frontmatter da proposta: ${c} — promovida assim mesmo`),
|
|
614
|
-
...res.warnings,
|
|
615
|
-
];
|
|
563
|
+
export function pendingArchiveRecovery(vaultBase, slug) {
|
|
564
|
+
const root = join(vaultBase, '.brain', 'runtime', 'archive-transactions');
|
|
565
|
+
const checkedRoot = assertVaultPathSafe(vaultBase, root, {
|
|
566
|
+
expectedType: 'directory', label: 'runtime de recovery do archive',
|
|
567
|
+
});
|
|
568
|
+
if (!checkedRoot.exists) return null;
|
|
569
|
+
const invalid = (entryName) => ({
|
|
570
|
+
kind: 'archive-transaction-recovery',
|
|
571
|
+
operation_id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(entryName) ? entryName : null,
|
|
572
|
+
phase: 'unknown',
|
|
573
|
+
blocker: 'PROV_ARCHIVE_RECOVERY_JOURNAL_INVALID',
|
|
574
|
+
invalid: true,
|
|
575
|
+
});
|
|
576
|
+
for (const entry of readdirSync(checkedRoot.target, { withFileTypes: true })) {
|
|
577
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) return invalid(entry.name);
|
|
578
|
+
const manifestPath = join(checkedRoot.target, entry.name, 'archive-transaction.json');
|
|
579
|
+
let manifest;
|
|
580
|
+
try {
|
|
581
|
+
const checked = assertVaultPathSafe(vaultBase, manifestPath, {
|
|
582
|
+
allowMissing: false, expectedType: 'file', label: 'manifest de recovery do archive',
|
|
583
|
+
});
|
|
584
|
+
manifest = JSON.parse(readFileSync(checked.target, 'utf8'));
|
|
585
|
+
} catch {
|
|
586
|
+
return invalid(entry.name);
|
|
616
587
|
}
|
|
617
|
-
|
|
618
|
-
|
|
588
|
+
if (manifest?.schema_version !== 1 || manifest.operation !== 'archive'
|
|
589
|
+
|| manifest.operation_id !== entry.name
|
|
590
|
+
|| !/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(manifest.operation_id || '')
|
|
591
|
+
|| typeof manifest.change_slug !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/i.test(manifest.change_slug)
|
|
592
|
+
|| typeof manifest.phase !== 'string') return invalid(entry.name);
|
|
593
|
+
if (manifest.change_slug !== slug) continue;
|
|
594
|
+
if (manifest.phase === 'completed') {
|
|
595
|
+
try {
|
|
596
|
+
const original = assertVaultPathSafe(vaultBase, join(checkedRoot.target, entry.name, 'original'), {
|
|
597
|
+
allowMissing: false, expectedType: 'directory', label: 'original completed do archive',
|
|
598
|
+
}).target;
|
|
599
|
+
const destination = assertVaultPathSafe(vaultBase, join(vaultBase, manifest.destination_rel || ''), {
|
|
600
|
+
allowMissing: false, expectedType: 'directory', label: 'destino completed do archive',
|
|
601
|
+
}).target;
|
|
602
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(manifest.source_digest || '')
|
|
603
|
+
|| archiveSourceDigest(original) !== manifest.source_digest
|
|
604
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(manifest.destination_digest || '')
|
|
605
|
+
|| archiveSourceDigest(destination) !== manifest.destination_digest) return invalid(entry.name);
|
|
606
|
+
} catch { return invalid(entry.name); }
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
kind: 'archive-transaction-recovery',
|
|
611
|
+
operation_id: typeof manifest.operation_id === 'string' ? manifest.operation_id : entry.name,
|
|
612
|
+
phase: typeof manifest.phase === 'string' ? manifest.phase : 'unknown',
|
|
613
|
+
blocker: typeof manifest.blocker === 'string' ? manifest.blocker : 'PROV_ARCHIVE_INCOMPLETE_TRANSACTION',
|
|
614
|
+
};
|
|
619
615
|
}
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
620
618
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
mkdirVaultPath(vaultBase, archiveRoot, { label: 'raiz de changes arquivadas' });
|
|
619
|
+
export function inspectArchiveRecovery(vaultBase, { operationId, slug }) {
|
|
620
|
+
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(operationId || '') || !slug) {
|
|
621
|
+
const error = new Error('recovery de archive inválido');
|
|
622
|
+
error.code = 'PROV_ARCHIVE_RECOVERY_NOT_FOUND';
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
const transactionRoot = join(vaultBase, '.brain', 'runtime', 'archive-transactions', operationId);
|
|
629
626
|
try {
|
|
630
|
-
|
|
631
|
-
|
|
627
|
+
const manifestPath = assertVaultPathSafe(vaultBase, join(transactionRoot, 'archive-transaction.json'), {
|
|
628
|
+
allowMissing: false, expectedType: 'file', label: 'manifest consultado para recovery',
|
|
629
|
+
}).target;
|
|
630
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
631
|
+
if (manifest?.schema_version !== 1 || manifest.operation !== 'archive'
|
|
632
|
+
|| manifest.operation_id !== operationId || manifest.change_slug !== slug) throw new Error('binding inválido');
|
|
633
|
+
const original = assertVaultPathSafe(vaultBase, join(transactionRoot, 'original'), {
|
|
634
|
+
expectedType: 'directory', label: 'original consultado para recovery',
|
|
632
635
|
});
|
|
636
|
+
const published = ['published', 'promotion-prepared', 'promotion-applied', 'completed', 'recovery-required'].includes(manifest.phase)
|
|
637
|
+
&& (manifest.publication_state === 'published-recovery-required'
|
|
638
|
+
|| typeof manifest.destination_digest === 'string');
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
code: 'PROV_ARCHIVE_RECOVERY_REQUIRED',
|
|
642
|
+
operation: 'archive-recover',
|
|
643
|
+
state: 'recovery-required',
|
|
644
|
+
operation_id: operationId,
|
|
645
|
+
change_slug: slug,
|
|
646
|
+
transaction_phase: manifest.phase,
|
|
647
|
+
blocker: /^PROV_[A-Z0-9_]+$/.test(manifest.blocker || '')
|
|
648
|
+
? manifest.blocker : 'PROV_ARCHIVE_INCOMPLETE_TRANSACTION',
|
|
649
|
+
original_retained: original.exists,
|
|
650
|
+
publication_state: published ? 'published-recovery-required' : 'not-published',
|
|
651
|
+
actions: published
|
|
652
|
+
? ['preserve-published-archive', 'compare-retained-original', 'reconcile-spec-adr-pointer', 'rerun-verify-before-new-archive']
|
|
653
|
+
: ['preserve-open-change', 'compare-retained-original', 'reconcile-concurrent-bytes', 'rerun-verify-before-new-archive'],
|
|
654
|
+
};
|
|
633
655
|
} catch (error) {
|
|
634
|
-
|
|
656
|
+
if (error?.code === 'PROV_ARCHIVE_RECOVERY_NOT_FOUND') throw error;
|
|
657
|
+
const failure = new Error('journal de recovery não encontrado ou inseguro');
|
|
658
|
+
failure.code = 'PROV_ARCHIVE_RECOVERY_NOT_FOUND';
|
|
659
|
+
throw failure;
|
|
635
660
|
}
|
|
661
|
+
}
|
|
636
662
|
|
|
637
|
-
// Flip the archived proposta's frontmatter status so it no longer reads as active.
|
|
638
|
-
try {
|
|
639
|
-
const pp = join(destAbs, 'proposta.md');
|
|
640
|
-
const c = readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: archived');
|
|
641
|
-
writeVaultFileSync(vaultBase, pp, c, 'utf8', { label: 'proposta arquivada' });
|
|
642
|
-
} catch { /* proposta ilegível — segue */ }
|
|
643
|
-
|
|
644
|
-
// O move quebrava TODO wikilink gravado antes (sessões fechadas, decisões, outras changes —
|
|
645
|
-
// links cinza no grafo, visto em produção). Reescreve vault-wide; fail-quiet.
|
|
646
|
-
let linksRewritten = 0;
|
|
647
|
-
try { linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/')); } catch { /* archive já íntegro */ }
|
|
648
|
-
|
|
649
|
-
// ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
|
|
650
|
-
// — not the year root — so all ADRs sit together in the vault's convention.
|
|
651
|
-
if (createAdr) mkdirVaultPath(vaultBase, join(vaultBase, adrDirRel), { label: 'pasta mensal de ADR' });
|
|
652
|
-
const capLine = promoted.length
|
|
653
|
-
? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
|
|
654
|
-
: '';
|
|
655
|
-
const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
|
|
656
|
-
// Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
|
|
657
|
-
const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
|
|
658
|
-
const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
|
|
659
|
-
if (createAdr) writeVaultFileSync(vaultBase, join(vaultBase, adrRel), `---
|
|
660
|
-
type: decision
|
|
661
|
-
status: accepted
|
|
662
|
-
date: ${dateStr}${flagLines}
|
|
663
|
-
cssclasses:
|
|
664
|
-
- topic-decision
|
|
665
|
-
tags:
|
|
666
|
-
- decisao
|
|
667
|
-
---
|
|
668
|
-
|
|
669
|
-
# ADR-${num} — ${slug}
|
|
670
663
|
|
|
671
|
-
|
|
664
|
+
export function archiveSourceDigest(root) {
|
|
665
|
+
const digest = createHash('sha256');
|
|
666
|
+
const walk = (absolute, relativePath = '') => {
|
|
667
|
+
const entries = readdirSync(absolute, { withFileTypes: true })
|
|
668
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
669
|
+
for (const entry of entries) {
|
|
670
|
+
const path = join(absolute, entry.name);
|
|
671
|
+
const rel = join(relativePath, entry.name).replaceAll('\\', '/');
|
|
672
|
+
const stat = lstatSync(path);
|
|
673
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && stat.nlink !== 1)) {
|
|
674
|
+
const error = new Error('Fonte do archive contém alias físico inseguro.');
|
|
675
|
+
error.code = 'PROV_ARCHIVE_SOURCE_UNSAFE';
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
if (stat.isDirectory()) {
|
|
679
|
+
digest.update(`dir\0${rel}\0`);
|
|
680
|
+
walk(path, rel);
|
|
681
|
+
} else if (stat.isFile()) {
|
|
682
|
+
const bytes = readFileSync(path);
|
|
683
|
+
digest.update(`file\0${rel}\0${bytes.length}\0`);
|
|
684
|
+
digest.update(bytes);
|
|
685
|
+
} else {
|
|
686
|
+
const error = new Error('Fonte do archive contém tipo de arquivo inseguro.');
|
|
687
|
+
error.code = 'PROV_ARCHIVE_SOURCE_UNSAFE';
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
walk(root);
|
|
693
|
+
return `sha256:${digest.digest('hex')}`;
|
|
694
|
+
}
|
|
672
695
|
|
|
673
|
-
Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
|
|
674
|
-
`, 'utf8', { label: 'ADR da change arquivada' });
|
|
675
696
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
697
|
+
export function finalizeArchiveTransaction(vaultBase, { operationId, slug }) {
|
|
698
|
+
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(operationId || '') || !slug) {
|
|
699
|
+
const error = new Error('transação de archive inválida');
|
|
700
|
+
error.code = 'PROV_ARCHIVE_TRANSACTION_CLEANUP_FAILED';
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
const transactionsRoot = join(vaultBase, '.brain', 'runtime', 'archive-transactions');
|
|
704
|
+
const transactionRoot = join(transactionsRoot, operationId);
|
|
705
|
+
const validate = (root) => {
|
|
706
|
+
const manifestPath = assertVaultPathSafe(vaultBase, join(root, 'archive-transaction.json'), {
|
|
707
|
+
allowMissing: false, expectedType: 'file', label: 'manifest completed do archive',
|
|
708
|
+
}).target;
|
|
709
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
710
|
+
if (manifest?.schema_version !== 1 || manifest.operation !== 'archive'
|
|
711
|
+
|| manifest.operation_id !== operationId || manifest.change_slug !== slug
|
|
712
|
+
|| manifest.phase !== 'completed') {
|
|
713
|
+
const error = new Error('manifest de archive não está completed');
|
|
714
|
+
error.code = 'PROV_ARCHIVE_RECOVERY_JOURNAL_INVALID';
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
const original = assertVaultPathSafe(vaultBase, join(root, 'original'), {
|
|
718
|
+
allowMissing: false, expectedType: 'directory', label: 'original retido do archive',
|
|
719
|
+
}).target;
|
|
720
|
+
if (archiveSourceDigest(original) !== manifest.source_digest) {
|
|
721
|
+
const error = new Error('original retido divergiu do manifest');
|
|
722
|
+
error.code = 'PROV_ARCHIVE_ORIGINAL_DIVERGED';
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
const publicSource = assertVaultPathSafe(vaultBase,
|
|
726
|
+
join(vaultBase, getLocale(vaultBase).folders.changes, slug), {
|
|
727
|
+
expectedType: 'directory', label: 'namespace público da change completed',
|
|
728
|
+
});
|
|
729
|
+
if (publicSource.exists) {
|
|
730
|
+
const error = new Error('namespace público foi recriado após completed');
|
|
731
|
+
error.code = 'PROV_ARCHIVE_PUBLIC_NAMESPACE_RECREATED';
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
const destination = assertVaultPathSafe(vaultBase, join(vaultBase, manifest.destination_rel || ''), {
|
|
735
|
+
allowMissing: false, expectedType: 'directory', label: 'destino publicado do archive',
|
|
736
|
+
}).target;
|
|
737
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(manifest.destination_digest || '')
|
|
738
|
+
|| archiveSourceDigest(destination) !== manifest.destination_digest) {
|
|
739
|
+
const error = new Error('destino publicado divergiu do manifest completed');
|
|
740
|
+
error.code = 'PROV_ARCHIVE_PUBLICATION_DIVERGED';
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
try {
|
|
745
|
+
validate(transactionRoot);
|
|
746
|
+
// Retain completed authority. Validation followed by recursive deletion cannot be atomic
|
|
747
|
+
// against an uncooperative writer, so normal archive never destroys the original/journal.
|
|
748
|
+
return { retained: true, phase: 'completed' };
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if (error?.code) throw error;
|
|
751
|
+
const failure = new Error('cleanup da transação de archive requer recovery');
|
|
752
|
+
failure.code = 'PROV_ARCHIVE_TRANSACTION_CLEANUP_FAILED';
|
|
753
|
+
throw failure;
|
|
754
|
+
}
|
|
680
755
|
}
|
|
681
756
|
|
|
682
757
|
// --- reescrita de wikilinks pós-move (0.35.0) ----------------------------------
|
|
683
758
|
// Todo .md do vault (inclui .brain e _arquivo — uma change arquivada pode linkar outra).
|
|
684
|
-
function allVaultMarkdown(vaultBase) {
|
|
759
|
+
function allVaultMarkdown(vaultBase, { excludeRoots = [] } = {}) {
|
|
685
760
|
const out = [];
|
|
686
761
|
const skip = new Set(['.git', '.obsidian', 'node_modules']);
|
|
762
|
+
const excluded = (target) => excludeRoots.some((root) => {
|
|
763
|
+
const rel = relative(root, target);
|
|
764
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
|
765
|
+
});
|
|
687
766
|
const walk = (dir) => {
|
|
767
|
+
if (excluded(dir)) return;
|
|
688
768
|
try {
|
|
689
769
|
assertVaultPathSafe(vaultBase, dir, {
|
|
690
770
|
allowMissing: false, expectedType: 'directory', label: 'diretório varrido para wikilinks',
|
|
@@ -706,9 +786,9 @@ function allVaultMarkdown(vaultBase) {
|
|
|
706
786
|
|
|
707
787
|
// Reescreve `[[fromRel/...]]`, `[[fromRel]]` e `[[fromRel|alias]]` em todo o vault.
|
|
708
788
|
// NUNCA por basename: `proposta`/`design` existem em toda change — só full-path é seguro.
|
|
709
|
-
function rewriteChangeLinks(vaultBase, fromRel, toRel) {
|
|
789
|
+
function rewriteChangeLinks(vaultBase, fromRel, toRel, options = {}) {
|
|
710
790
|
let touched = 0;
|
|
711
|
-
for (const abs of allVaultMarkdown(vaultBase)) {
|
|
791
|
+
for (const abs of allVaultMarkdown(vaultBase, options)) {
|
|
712
792
|
let content;
|
|
713
793
|
try { content = readFileSync(abs, 'utf8'); } catch { continue; }
|
|
714
794
|
const next = content
|
|
@@ -753,9 +833,9 @@ function insertBacklink(content, backlinkLine) {
|
|
|
753
833
|
// Auto-heal (0.47): garante o backlink pro proposta em cada specs/<cap>/spec.md do change.
|
|
754
834
|
// Idempotente (pula quem já tem o link exato). Chamado no verify e no archive (antes do move,
|
|
755
835
|
// pra o rewriteChangeLinks retargetar pro _arquivo). Retorna quantos arquivos healou.
|
|
756
|
-
export function healSpecBacklinks(changeDir, vaultBase) {
|
|
836
|
+
export function healSpecBacklinks(changeDir, vaultBase, { proposalChangeDir = changeDir } = {}) {
|
|
757
837
|
const loc = getLocale(vaultBase);
|
|
758
|
-
const propRel = `${relative(vaultBase,
|
|
838
|
+
const propRel = `${relative(vaultBase, proposalChangeDir).replaceAll('\\', '/')}/proposta`;
|
|
759
839
|
const link = `[[${propRel}]]`;
|
|
760
840
|
const line = `> ${backlinkLabel(loc)} ${link}`;
|
|
761
841
|
let caps = [];
|
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -17,6 +17,14 @@ import {
|
|
|
17
17
|
import { parseObservabilityCheckpoint } from './session-observability-state.mjs';
|
|
18
18
|
import { readObservabilityStore } from './session-observability-store.mjs';
|
|
19
19
|
import { assessObservabilityFreshness } from './session-observability-lifecycle.mjs';
|
|
20
|
+
import { evaluateEvidenceBinding } from '../packages/vault/src/evidence-envelope.mjs';
|
|
21
|
+
import { evidenceCheckoutBinding } from '../packages/vault/src/evidence-envelope.mjs';
|
|
22
|
+
import { loadSensorsDetailed, requiredSensors } from './sensors-core.mjs';
|
|
23
|
+
import {
|
|
24
|
+
captureGitSnapshot,
|
|
25
|
+
resolveEvidenceIdentity,
|
|
26
|
+
sensorConfigSha256,
|
|
27
|
+
} from '../src/evidence-envelope.mjs';
|
|
20
28
|
|
|
21
29
|
export function checkSessionObservability(vaultBase, deps = {}) {
|
|
22
30
|
const readRegistry = deps.readRegistry || readSessionRegistry;
|
|
@@ -144,10 +152,52 @@ export function checkHarness(vaultBase, projectRoot) {
|
|
|
144
152
|
const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
|
|
145
153
|
errors.push(...effective.errors.map((e) => `${name}: spec efetiva inválida: ${e}`));
|
|
146
154
|
if (effective.missing.length) errors.push(`req órfão em ${name}: ${effective.missing.map((id) => `[req:${id}]`).join(', ')} não existe na spec efetiva`);
|
|
155
|
+
let evidence = null;
|
|
156
|
+
try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* sem evidência */ }
|
|
157
|
+
if (evidence) {
|
|
158
|
+
const expected = {
|
|
159
|
+
change_slug: name,
|
|
160
|
+
tasks_sha256: tasksHashOf(tarefasMd),
|
|
161
|
+
effective_spec_sha256: `sha256:${effective.hash}`,
|
|
162
|
+
};
|
|
163
|
+
let bindingUnavailable = '';
|
|
164
|
+
if (evidence.schema_version === 2 && projectRoot) {
|
|
165
|
+
try {
|
|
166
|
+
const loaded = loadSensorsDetailed(projectRoot);
|
|
167
|
+
if (loaded.error) throw new Error(`wendkeep.sensors.json inválido: ${loaded.error}`);
|
|
168
|
+
expected.identity = resolveEvidenceIdentity({
|
|
169
|
+
vaultBase,
|
|
170
|
+
projectRoot,
|
|
171
|
+
changeSlug: name,
|
|
172
|
+
sessionId: evidence.work_session_id,
|
|
173
|
+
});
|
|
174
|
+
expected.snapshot = captureGitSnapshot(projectRoot);
|
|
175
|
+
expected.sensor_config_sha256 = sensorConfigSha256(
|
|
176
|
+
loaded.sensors,
|
|
177
|
+
requiredSensors(tasks),
|
|
178
|
+
);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
bindingUnavailable = error.code || error.message;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const binding = evaluateEvidenceBinding(evidence, expected);
|
|
184
|
+
if (binding.state === 'legacy-unbound') {
|
|
185
|
+
attention.push(`${name}: evidence legacy-unbound — rode wendkeep verify novamente`);
|
|
186
|
+
} else if (binding.state !== 'bound') {
|
|
187
|
+
attention.push(`${name}: evidence ${binding.state} (${binding.reasons.join('; ')}) — rode wendkeep verify novamente`);
|
|
188
|
+
} else if (bindingUnavailable) {
|
|
189
|
+
attention.push(`${name}: evidence binding atual indisponível (${bindingUnavailable}) — rode doctor da raiz Git e depois wendkeep verify`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
147
192
|
let verdict = null;
|
|
148
193
|
try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
|
|
149
194
|
if (verdict && reqIds.length) {
|
|
150
|
-
const v = evaluateVerdict(verdict, reqIds, {
|
|
195
|
+
const v = evaluateVerdict(verdict, reqIds, {
|
|
196
|
+
tasksHash: tasksHashOf(tarefasMd),
|
|
197
|
+
effectiveSpecHash: effective.hash,
|
|
198
|
+
evidenceEnvelopeId: evidence?.schema_version === 2 ? evidence.envelope_id : undefined,
|
|
199
|
+
evidenceBinding: evidence?.schema_version === 2 ? evidenceCheckoutBinding(evidence) : undefined,
|
|
200
|
+
});
|
|
151
201
|
if (!v.ok) attention.push(`verdict stale/incompleto em ${name}${v.missing.length ? `: falta cobrir ${v.missing.join(', ')}` : ''}`);
|
|
152
202
|
}
|
|
153
203
|
}
|