wendkeep 0.78.0 → 0.80.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 +67 -0
- package/README.en.md +58 -3
- package/README.md +58 -3
- package/docs/en/commands/changes-and-verification.md +116 -1
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/sessions-and-import.md +6 -0
- package/docs/en/commands/verify.md +54 -0
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +115 -1
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/sessions-and-import.md +7 -0
- package/docs/pt-BR/commands/verify.md +53 -0
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +220 -123
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/session-stop.mjs +40 -1
- package/hooks/spec-core.mjs +93 -29
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +7 -0
- package/packages/vault/src/memory-handoff.mjs +15 -0
- package/schema/artifact-manifest-v1.schema.json +35 -0
- package/schema/handoff-contract-v1.schema.json +37 -0
- package/schema/task-contract-v1.schema.json +57 -0
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1780 -79
- package/src/delivery.mjs +724 -67
- 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/task-contracts.mjs +510 -0
- package/src/task-leases.mjs +105 -0
- package/src/task.mjs +115 -0
- package/src/verify.mjs +32 -0
- package/src/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
package/hooks/change-core.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
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,
|
|
@@ -277,18 +283,36 @@ export function parseTasks(md) {
|
|
|
277
283
|
const re = /^-\s+\[( |x)\]\s+(\S+)\s+(.*)$/gm;
|
|
278
284
|
const sensorReG = /\[sensor:\s*([\w.-]+)\]/g;
|
|
279
285
|
const reqReG = new RegExp(`\\[req:\\s*(${REQ_ID_RE_SRC})\\]`, 'g');
|
|
286
|
+
const dependencyReG = /\[depends:\s*([\w.-]+)\]/g;
|
|
287
|
+
const artifactReG = /\[artifact:\s*([\w.-]+)\]/g;
|
|
288
|
+
const phaseReG = /\[phase:\s*([\w.-]+)\]/g;
|
|
280
289
|
let m;
|
|
281
290
|
while ((m = re.exec(String(md))) !== null) {
|
|
282
291
|
let text = m[3].trim();
|
|
283
292
|
const sensors = [...new Set([...text.matchAll(sensorReG)].map((entry) => entry[1]))];
|
|
284
293
|
const reqs = [...text.matchAll(reqReG)].map((r) => r[1]);
|
|
294
|
+
const dependencies = [...new Set([...text.matchAll(dependencyReG)].map((entry) => entry[1]))];
|
|
295
|
+
const artifacts = [...new Set([...text.matchAll(artifactReG)].map((entry) => entry[1]))];
|
|
296
|
+
const phases = [...new Set([...text.matchAll(phaseReG)].map((entry) => entry[1]))];
|
|
285
297
|
const sensor = sensors[0];
|
|
286
298
|
if (sensors.length) text = text.replace(sensorReG, '');
|
|
287
299
|
if (reqs.length) text = text.replace(reqReG, '');
|
|
300
|
+
if (dependencies.length) text = text.replace(dependencyReG, '');
|
|
301
|
+
if (artifacts.length) text = text.replace(artifactReG, '');
|
|
302
|
+
if (phases.length) text = text.replace(phaseReG, '');
|
|
288
303
|
text = text.replace(/\s+/g, ' ').trim();
|
|
289
304
|
// `sensor` stays as alias of the first id — older consumers keep working.
|
|
290
305
|
// `req` stays as alias of the first id — older consumers keep working.
|
|
291
|
-
tasks.push({
|
|
306
|
+
tasks.push({
|
|
307
|
+
id: m[2],
|
|
308
|
+
text,
|
|
309
|
+
done: m[1] === 'x',
|
|
310
|
+
...(sensor ? { sensor, sensors } : {}),
|
|
311
|
+
...(reqs.length ? { req: reqs[0], reqs } : {}),
|
|
312
|
+
...(dependencies.length ? { dependencies } : {}),
|
|
313
|
+
...(artifacts.length ? { artifacts } : {}),
|
|
314
|
+
...(phases.length ? { phase: phases[0] } : {}),
|
|
315
|
+
});
|
|
292
316
|
}
|
|
293
317
|
return tasks;
|
|
294
318
|
}
|
|
@@ -554,138 +578,211 @@ export function gateGreen() {
|
|
|
554
578
|
return { ok: true, failing: [] };
|
|
555
579
|
}
|
|
556
580
|
|
|
557
|
-
export function
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
...(createAdr ? [
|
|
582
|
-
{ path: join(vaultBase, adrDirRel), expectedType: 'directory', label: 'pasta mensal de ADR' },
|
|
583
|
-
{ path: join(vaultBase, adrRel), expectedType: 'file', label: 'ADR da change arquivada' },
|
|
584
|
-
] : []),
|
|
585
|
-
];
|
|
586
|
-
const [checkedSource, checkedDestination] = assertVaultPathsSafe(vaultBase, mutationTargets);
|
|
587
|
-
assertVaultPathsSafe(vaultBase, [
|
|
588
|
-
{ path: join(checkedSource.target, 'proposta.md'), expectedType: 'file', label: 'proposta da change' },
|
|
589
|
-
{ path: join(checkedSource.target, 'tarefas.md'), expectedType: 'file', label: 'tarefas da change' },
|
|
590
|
-
]);
|
|
591
|
-
|
|
592
|
-
// Atomicity guard: fail BEFORE promoting specs if the destination already exists (e.g. a slug
|
|
593
|
-
// reused after a same-day archive). Otherwise promoteSpecs would commit to 07-Specs and the
|
|
594
|
-
// later renameSync would fail, leaving a half-archived state.
|
|
595
|
-
if (checkedDestination.exists) {
|
|
596
|
-
return { ok: false, failing: [`destino de arquivo já existe: ${destRel} — renomeie o slug ou remova o arquivo antigo`] };
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
|
|
600
|
-
// UNIÃO frontmatter + disco (0.31.0): o scaffold deixa `specs: []`, então um delta real
|
|
601
|
-
// preenchido em specs/<cap>/ mas não listado era silenciosamente ignorado. Deltas ainda em
|
|
602
|
-
// placeholder (o `exemplo` do scaffold) são filtrados por discoverSpecDeltas.
|
|
603
|
-
let promoted = [];
|
|
604
|
-
let specWarnings = [];
|
|
605
|
-
try {
|
|
606
|
-
let listed = [];
|
|
607
|
-
try { listed = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8')); } catch { /* proposta ilegível */ }
|
|
608
|
-
const onDisk = discoverSpecDeltas(src);
|
|
609
|
-
const union = [...new Set([...listed, ...onDisk])];
|
|
610
|
-
if (union.length) {
|
|
611
|
-
const res = promoteSpecs(vaultBase, src, union, { changeWikilink, dateStr });
|
|
612
|
-
promoted = res.promoted;
|
|
613
|
-
specWarnings = [
|
|
614
|
-
...onDisk.filter((c) => !listed.includes(c)).map((c) => `spec no disco não listada no frontmatter da proposta: ${c} — promovida assim mesmo`),
|
|
615
|
-
...res.warnings,
|
|
616
|
-
];
|
|
581
|
+
export function pendingArchiveRecovery(vaultBase, slug) {
|
|
582
|
+
const root = join(vaultBase, '.brain', 'runtime', 'archive-transactions');
|
|
583
|
+
const checkedRoot = assertVaultPathSafe(vaultBase, root, {
|
|
584
|
+
expectedType: 'directory', label: 'runtime de recovery do archive',
|
|
585
|
+
});
|
|
586
|
+
if (!checkedRoot.exists) return null;
|
|
587
|
+
const invalid = (entryName) => ({
|
|
588
|
+
kind: 'archive-transaction-recovery',
|
|
589
|
+
operation_id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(entryName) ? entryName : null,
|
|
590
|
+
phase: 'unknown',
|
|
591
|
+
blocker: 'PROV_ARCHIVE_RECOVERY_JOURNAL_INVALID',
|
|
592
|
+
invalid: true,
|
|
593
|
+
});
|
|
594
|
+
for (const entry of readdirSync(checkedRoot.target, { withFileTypes: true })) {
|
|
595
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) return invalid(entry.name);
|
|
596
|
+
const manifestPath = join(checkedRoot.target, entry.name, 'archive-transaction.json');
|
|
597
|
+
let manifest;
|
|
598
|
+
try {
|
|
599
|
+
const checked = assertVaultPathSafe(vaultBase, manifestPath, {
|
|
600
|
+
allowMissing: false, expectedType: 'file', label: 'manifest de recovery do archive',
|
|
601
|
+
});
|
|
602
|
+
manifest = JSON.parse(readFileSync(checked.target, 'utf8'));
|
|
603
|
+
} catch {
|
|
604
|
+
return invalid(entry.name);
|
|
617
605
|
}
|
|
618
|
-
|
|
619
|
-
|
|
606
|
+
if (manifest?.schema_version !== 1 || manifest.operation !== 'archive'
|
|
607
|
+
|| manifest.operation_id !== entry.name
|
|
608
|
+
|| !/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(manifest.operation_id || '')
|
|
609
|
+
|| typeof manifest.change_slug !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/i.test(manifest.change_slug)
|
|
610
|
+
|| typeof manifest.phase !== 'string') return invalid(entry.name);
|
|
611
|
+
if (manifest.change_slug !== slug) continue;
|
|
612
|
+
if (manifest.phase === 'completed') {
|
|
613
|
+
try {
|
|
614
|
+
const original = assertVaultPathSafe(vaultBase, join(checkedRoot.target, entry.name, 'original'), {
|
|
615
|
+
allowMissing: false, expectedType: 'directory', label: 'original completed do archive',
|
|
616
|
+
}).target;
|
|
617
|
+
const destination = assertVaultPathSafe(vaultBase, join(vaultBase, manifest.destination_rel || ''), {
|
|
618
|
+
allowMissing: false, expectedType: 'directory', label: 'destino completed do archive',
|
|
619
|
+
}).target;
|
|
620
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(manifest.source_digest || '')
|
|
621
|
+
|| archiveSourceDigest(original) !== manifest.source_digest
|
|
622
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(manifest.destination_digest || '')
|
|
623
|
+
|| archiveSourceDigest(destination) !== manifest.destination_digest) return invalid(entry.name);
|
|
624
|
+
} catch { return invalid(entry.name); }
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
kind: 'archive-transaction-recovery',
|
|
629
|
+
operation_id: typeof manifest.operation_id === 'string' ? manifest.operation_id : entry.name,
|
|
630
|
+
phase: typeof manifest.phase === 'string' ? manifest.phase : 'unknown',
|
|
631
|
+
blocker: typeof manifest.blocker === 'string' ? manifest.blocker : 'PROV_ARCHIVE_INCOMPLETE_TRANSACTION',
|
|
632
|
+
};
|
|
620
633
|
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
621
636
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
mkdirVaultPath(vaultBase, archiveRoot, { label: 'raiz de changes arquivadas' });
|
|
637
|
+
export function inspectArchiveRecovery(vaultBase, { operationId, slug }) {
|
|
638
|
+
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(operationId || '') || !slug) {
|
|
639
|
+
const error = new Error('recovery de archive inválido');
|
|
640
|
+
error.code = 'PROV_ARCHIVE_RECOVERY_NOT_FOUND';
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
const transactionRoot = join(vaultBase, '.brain', 'runtime', 'archive-transactions', operationId);
|
|
630
644
|
try {
|
|
631
|
-
|
|
632
|
-
|
|
645
|
+
const manifestPath = assertVaultPathSafe(vaultBase, join(transactionRoot, 'archive-transaction.json'), {
|
|
646
|
+
allowMissing: false, expectedType: 'file', label: 'manifest consultado para recovery',
|
|
647
|
+
}).target;
|
|
648
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
649
|
+
if (manifest?.schema_version !== 1 || manifest.operation !== 'archive'
|
|
650
|
+
|| manifest.operation_id !== operationId || manifest.change_slug !== slug) throw new Error('binding inválido');
|
|
651
|
+
const original = assertVaultPathSafe(vaultBase, join(transactionRoot, 'original'), {
|
|
652
|
+
expectedType: 'directory', label: 'original consultado para recovery',
|
|
633
653
|
});
|
|
654
|
+
const published = ['published', 'promotion-prepared', 'promotion-applied', 'completed', 'recovery-required'].includes(manifest.phase)
|
|
655
|
+
&& (manifest.publication_state === 'published-recovery-required'
|
|
656
|
+
|| typeof manifest.destination_digest === 'string');
|
|
657
|
+
return {
|
|
658
|
+
ok: false,
|
|
659
|
+
code: 'PROV_ARCHIVE_RECOVERY_REQUIRED',
|
|
660
|
+
operation: 'archive-recover',
|
|
661
|
+
state: 'recovery-required',
|
|
662
|
+
operation_id: operationId,
|
|
663
|
+
change_slug: slug,
|
|
664
|
+
transaction_phase: manifest.phase,
|
|
665
|
+
blocker: /^PROV_[A-Z0-9_]+$/.test(manifest.blocker || '')
|
|
666
|
+
? manifest.blocker : 'PROV_ARCHIVE_INCOMPLETE_TRANSACTION',
|
|
667
|
+
original_retained: original.exists,
|
|
668
|
+
publication_state: published ? 'published-recovery-required' : 'not-published',
|
|
669
|
+
actions: published
|
|
670
|
+
? ['preserve-published-archive', 'compare-retained-original', 'reconcile-spec-adr-pointer', 'rerun-verify-before-new-archive']
|
|
671
|
+
: ['preserve-open-change', 'compare-retained-original', 'reconcile-concurrent-bytes', 'rerun-verify-before-new-archive'],
|
|
672
|
+
};
|
|
634
673
|
} catch (error) {
|
|
635
|
-
|
|
674
|
+
if (error?.code === 'PROV_ARCHIVE_RECOVERY_NOT_FOUND') throw error;
|
|
675
|
+
const failure = new Error('journal de recovery não encontrado ou inseguro');
|
|
676
|
+
failure.code = 'PROV_ARCHIVE_RECOVERY_NOT_FOUND';
|
|
677
|
+
throw failure;
|
|
636
678
|
}
|
|
679
|
+
}
|
|
637
680
|
|
|
638
|
-
// Flip the archived proposta's frontmatter status so it no longer reads as active.
|
|
639
|
-
try {
|
|
640
|
-
const pp = join(destAbs, 'proposta.md');
|
|
641
|
-
const c = readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: archived');
|
|
642
|
-
writeVaultFileSync(vaultBase, pp, c, 'utf8', { label: 'proposta arquivada' });
|
|
643
|
-
} catch { /* proposta ilegível — segue */ }
|
|
644
|
-
|
|
645
|
-
// O move quebrava TODO wikilink gravado antes (sessões fechadas, decisões, outras changes —
|
|
646
|
-
// links cinza no grafo, visto em produção). Reescreve vault-wide; fail-quiet.
|
|
647
|
-
let linksRewritten = 0;
|
|
648
|
-
try { linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/')); } catch { /* archive já íntegro */ }
|
|
649
|
-
|
|
650
|
-
// ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
|
|
651
|
-
// — not the year root — so all ADRs sit together in the vault's convention.
|
|
652
|
-
if (createAdr) mkdirVaultPath(vaultBase, join(vaultBase, adrDirRel), { label: 'pasta mensal de ADR' });
|
|
653
|
-
const capLine = promoted.length
|
|
654
|
-
? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
|
|
655
|
-
: '';
|
|
656
|
-
const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
|
|
657
|
-
// Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
|
|
658
|
-
const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
|
|
659
|
-
const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
|
|
660
|
-
if (createAdr) writeVaultFileSync(vaultBase, join(vaultBase, adrRel), `---
|
|
661
|
-
type: decision
|
|
662
|
-
status: accepted
|
|
663
|
-
date: ${dateStr}${flagLines}
|
|
664
|
-
cssclasses:
|
|
665
|
-
- topic-decision
|
|
666
|
-
tags:
|
|
667
|
-
- decisao
|
|
668
|
-
---
|
|
669
|
-
|
|
670
|
-
# ADR-${num} — ${slug}
|
|
671
681
|
|
|
672
|
-
|
|
682
|
+
export function archiveSourceDigest(root) {
|
|
683
|
+
const digest = createHash('sha256');
|
|
684
|
+
const walk = (absolute, relativePath = '') => {
|
|
685
|
+
const entries = readdirSync(absolute, { withFileTypes: true })
|
|
686
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
687
|
+
for (const entry of entries) {
|
|
688
|
+
const path = join(absolute, entry.name);
|
|
689
|
+
const rel = join(relativePath, entry.name).replaceAll('\\', '/');
|
|
690
|
+
const stat = lstatSync(path);
|
|
691
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && stat.nlink !== 1)) {
|
|
692
|
+
const error = new Error('Fonte do archive contém alias físico inseguro.');
|
|
693
|
+
error.code = 'PROV_ARCHIVE_SOURCE_UNSAFE';
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
if (stat.isDirectory()) {
|
|
697
|
+
digest.update(`dir\0${rel}\0`);
|
|
698
|
+
walk(path, rel);
|
|
699
|
+
} else if (stat.isFile()) {
|
|
700
|
+
const bytes = readFileSync(path);
|
|
701
|
+
digest.update(`file\0${rel}\0${bytes.length}\0`);
|
|
702
|
+
digest.update(bytes);
|
|
703
|
+
} else {
|
|
704
|
+
const error = new Error('Fonte do archive contém tipo de arquivo inseguro.');
|
|
705
|
+
error.code = 'PROV_ARCHIVE_SOURCE_UNSAFE';
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
walk(root);
|
|
711
|
+
return `sha256:${digest.digest('hex')}`;
|
|
712
|
+
}
|
|
673
713
|
|
|
674
|
-
Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
|
|
675
|
-
`, 'utf8', { label: 'ADR da change arquivada' });
|
|
676
714
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
715
|
+
export function finalizeArchiveTransaction(vaultBase, { operationId, slug }) {
|
|
716
|
+
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(operationId || '') || !slug) {
|
|
717
|
+
const error = new Error('transação de archive inválida');
|
|
718
|
+
error.code = 'PROV_ARCHIVE_TRANSACTION_CLEANUP_FAILED';
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
const transactionsRoot = join(vaultBase, '.brain', 'runtime', 'archive-transactions');
|
|
722
|
+
const transactionRoot = join(transactionsRoot, operationId);
|
|
723
|
+
const validate = (root) => {
|
|
724
|
+
const manifestPath = assertVaultPathSafe(vaultBase, join(root, 'archive-transaction.json'), {
|
|
725
|
+
allowMissing: false, expectedType: 'file', label: 'manifest completed do archive',
|
|
726
|
+
}).target;
|
|
727
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
728
|
+
if (manifest?.schema_version !== 1 || manifest.operation !== 'archive'
|
|
729
|
+
|| manifest.operation_id !== operationId || manifest.change_slug !== slug
|
|
730
|
+
|| manifest.phase !== 'completed') {
|
|
731
|
+
const error = new Error('manifest de archive não está completed');
|
|
732
|
+
error.code = 'PROV_ARCHIVE_RECOVERY_JOURNAL_INVALID';
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
const original = assertVaultPathSafe(vaultBase, join(root, 'original'), {
|
|
736
|
+
allowMissing: false, expectedType: 'directory', label: 'original retido do archive',
|
|
737
|
+
}).target;
|
|
738
|
+
if (archiveSourceDigest(original) !== manifest.source_digest) {
|
|
739
|
+
const error = new Error('original retido divergiu do manifest');
|
|
740
|
+
error.code = 'PROV_ARCHIVE_ORIGINAL_DIVERGED';
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
const publicSource = assertVaultPathSafe(vaultBase,
|
|
744
|
+
join(vaultBase, getLocale(vaultBase).folders.changes, slug), {
|
|
745
|
+
expectedType: 'directory', label: 'namespace público da change completed',
|
|
746
|
+
});
|
|
747
|
+
if (publicSource.exists) {
|
|
748
|
+
const error = new Error('namespace público foi recriado após completed');
|
|
749
|
+
error.code = 'PROV_ARCHIVE_PUBLIC_NAMESPACE_RECREATED';
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
const destination = assertVaultPathSafe(vaultBase, join(vaultBase, manifest.destination_rel || ''), {
|
|
753
|
+
allowMissing: false, expectedType: 'directory', label: 'destino publicado do archive',
|
|
754
|
+
}).target;
|
|
755
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(manifest.destination_digest || '')
|
|
756
|
+
|| archiveSourceDigest(destination) !== manifest.destination_digest) {
|
|
757
|
+
const error = new Error('destino publicado divergiu do manifest completed');
|
|
758
|
+
error.code = 'PROV_ARCHIVE_PUBLICATION_DIVERGED';
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
try {
|
|
763
|
+
validate(transactionRoot);
|
|
764
|
+
// Retain completed authority. Validation followed by recursive deletion cannot be atomic
|
|
765
|
+
// against an uncooperative writer, so normal archive never destroys the original/journal.
|
|
766
|
+
return { retained: true, phase: 'completed' };
|
|
767
|
+
} catch (error) {
|
|
768
|
+
if (error?.code) throw error;
|
|
769
|
+
const failure = new Error('cleanup da transação de archive requer recovery');
|
|
770
|
+
failure.code = 'PROV_ARCHIVE_TRANSACTION_CLEANUP_FAILED';
|
|
771
|
+
throw failure;
|
|
772
|
+
}
|
|
681
773
|
}
|
|
682
774
|
|
|
683
775
|
// --- reescrita de wikilinks pós-move (0.35.0) ----------------------------------
|
|
684
776
|
// Todo .md do vault (inclui .brain e _arquivo — uma change arquivada pode linkar outra).
|
|
685
|
-
function allVaultMarkdown(vaultBase) {
|
|
777
|
+
function allVaultMarkdown(vaultBase, { excludeRoots = [] } = {}) {
|
|
686
778
|
const out = [];
|
|
687
779
|
const skip = new Set(['.git', '.obsidian', 'node_modules']);
|
|
780
|
+
const excluded = (target) => excludeRoots.some((root) => {
|
|
781
|
+
const rel = relative(root, target);
|
|
782
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
|
783
|
+
});
|
|
688
784
|
const walk = (dir) => {
|
|
785
|
+
if (excluded(dir)) return;
|
|
689
786
|
try {
|
|
690
787
|
assertVaultPathSafe(vaultBase, dir, {
|
|
691
788
|
allowMissing: false, expectedType: 'directory', label: 'diretório varrido para wikilinks',
|
|
@@ -707,9 +804,9 @@ function allVaultMarkdown(vaultBase) {
|
|
|
707
804
|
|
|
708
805
|
// Reescreve `[[fromRel/...]]`, `[[fromRel]]` e `[[fromRel|alias]]` em todo o vault.
|
|
709
806
|
// NUNCA por basename: `proposta`/`design` existem em toda change — só full-path é seguro.
|
|
710
|
-
function rewriteChangeLinks(vaultBase, fromRel, toRel) {
|
|
807
|
+
function rewriteChangeLinks(vaultBase, fromRel, toRel, options = {}) {
|
|
711
808
|
let touched = 0;
|
|
712
|
-
for (const abs of allVaultMarkdown(vaultBase)) {
|
|
809
|
+
for (const abs of allVaultMarkdown(vaultBase, options)) {
|
|
713
810
|
let content;
|
|
714
811
|
try { content = readFileSync(abs, 'utf8'); } catch { continue; }
|
|
715
812
|
const next = content
|
|
@@ -754,9 +851,9 @@ function insertBacklink(content, backlinkLine) {
|
|
|
754
851
|
// Auto-heal (0.47): garante o backlink pro proposta em cada specs/<cap>/spec.md do change.
|
|
755
852
|
// Idempotente (pula quem já tem o link exato). Chamado no verify e no archive (antes do move,
|
|
756
853
|
// pra o rewriteChangeLinks retargetar pro _arquivo). Retorna quantos arquivos healou.
|
|
757
|
-
export function healSpecBacklinks(changeDir, vaultBase) {
|
|
854
|
+
export function healSpecBacklinks(changeDir, vaultBase, { proposalChangeDir = changeDir } = {}) {
|
|
758
855
|
const loc = getLocale(vaultBase);
|
|
759
|
-
const propRel = `${relative(vaultBase,
|
|
856
|
+
const propRel = `${relative(vaultBase, proposalChangeDir).replaceAll('\\', '/')}/proposta`;
|
|
760
857
|
const link = `[[${propRel}]]`;
|
|
761
858
|
const line = `> ${backlinkLabel(loc)} ${link}`;
|
|
762
859
|
let caps = [];
|
|
@@ -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 });
|