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.
Files changed (37) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.en.md +58 -3
  3. package/README.md +58 -3
  4. package/docs/en/commands/changes-and-verification.md +74 -2
  5. package/docs/en/commands/operating-profiles.md +49 -5
  6. package/docs/en/commands/verify.md +67 -5
  7. package/docs/en/commands/worktrees.md +39 -4
  8. package/docs/pt-BR/commands/changes-and-verification.md +73 -2
  9. package/docs/pt-BR/commands/operating-profiles.md +51 -5
  10. package/docs/pt-BR/commands/verify.md +67 -6
  11. package/docs/pt-BR/commands/worktrees.md +38 -3
  12. package/hooks/active-context-store.mjs +530 -2
  13. package/hooks/change-core.mjs +203 -123
  14. package/hooks/harness-doctor.mjs +51 -1
  15. package/hooks/obsidian-common.mjs +175 -9
  16. package/hooks/spec-core.mjs +118 -36
  17. package/package.json +2 -2
  18. package/packages/harness/src/sensors-core.mjs +57 -3
  19. package/packages/vault/src/evidence-envelope.mjs +73 -0
  20. package/packages/vault/src/index.mjs +1 -0
  21. package/packages/vault/src/memory-handoff.mjs +46 -5
  22. package/packages/vault/src/vault-path-safety.mjs +11 -0
  23. package/schema/wendkeep.evidence-envelope-v2.schema.json +92 -0
  24. package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
  25. package/src/archive-operation-lock.mjs +235 -0
  26. package/src/change.mjs +1832 -48
  27. package/src/delivery.mjs +724 -67
  28. package/src/evidence-envelope.mjs +288 -0
  29. package/src/memory.mjs +2 -1
  30. package/src/provenance-gate.mjs +575 -0
  31. package/src/provenance-sources.mjs +547 -0
  32. package/src/receipt-ledger.mjs +841 -0
  33. package/src/release-provenance.mjs +48 -0
  34. package/src/skills-seed.mjs +11 -5
  35. package/src/verify.mjs +85 -22
  36. package/src/worktree-cleanup.mjs +1733 -118
  37. package/src/worktree.mjs +94 -5
package/src/change.mjs CHANGED
@@ -1,31 +1,405 @@
1
1
  // `wendkeep change <sub>` — native change lifecycle CLI (Pilar B).
2
- import { readFileSync, readdirSync } from 'node:fs';
3
- import { isAbsolute, join, resolve } from 'node:path';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import {
5
+ closeSync, cpSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync,
6
+ readdirSync, rmSync, unlinkSync,
7
+ } from 'node:fs';
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
9
  import {
5
10
  newChange,
6
11
  useChange,
7
12
  continueChange,
8
13
  activeChange,
14
+ clearActiveChange,
9
15
  allChangesState,
10
16
  listChanges,
11
17
  renderOpenChanges,
12
18
  parseTasks,
13
19
  setTaskDone,
14
- archiveChange,
20
+ archiveSourceDigest,
21
+ finalizeArchiveTransaction,
22
+ inspectArchiveRecovery,
23
+ pendingArchiveRecovery,
15
24
  abandonChange,
16
25
  relinkChanges,
17
26
  backfillArtifactLinks,
18
27
  scaffoldPlaceholders,
19
28
  isGuideCompactChange,
29
+ healSpecBacklinks,
20
30
  setActiveChange,
21
31
  } from '../hooks/change-core.mjs';
22
- import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
23
- import { buildEffectiveRequirementPackage, evaluateVerdict, formatOrphanReqs, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
24
- import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
32
+ import { evaluateGate, loadSensorsDetailed, requiredSensors } from '../hooks/sensors-core.mjs';
33
+ import { buildEffectiveRequirementPackage, buildSpecPromotionPlan, contentHashOf, evaluateVerdict, formatOrphanReqs, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, renderSpec, renderSpecsReadme, validateSpecImpact, assertSpecPromotionTargetsSafe, discoverSpecDeltas } from '../hooks/spec-core.mjs';
34
+ import { getNextAdrNumber, monthFolderRelFromDateStr, readControl, readSessionRegistry, upsertSessionRegistry, wikilinkFromRel } from '../hooks/obsidian-common.mjs';
25
35
  import { getLocale } from '../hooks/locale.mjs';
26
36
  import { enqueueObserverDocumentChange } from './observer-sql-publish.mjs';
27
37
  import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
28
38
  import { resolveCommandActiveContext } from './active-context-runtime.mjs';
39
+ import {
40
+ captureGitSnapshot,
41
+ resolveEvidenceIdentity,
42
+ sensorConfigSha256,
43
+ } from './evidence-envelope.mjs';
44
+ import {
45
+ canonicalSha256,
46
+ evaluateEvidenceBinding,
47
+ evidenceCheckoutBinding,
48
+ evidenceCheckoutBindingMatches,
49
+ evidenceSensors,
50
+ } from '../packages/vault/src/evidence-envelope.mjs';
51
+ import {
52
+ classifyEvidenceEnvelope,
53
+ evaluateProvenanceGate,
54
+ } from './provenance-gate.mjs';
55
+ import {
56
+ appendReceipt,
57
+ createFileReceiptStore,
58
+ } from './receipt-ledger.mjs';
59
+ import { acquireArchiveOperationLock } from './archive-operation-lock.mjs';
60
+ import {
61
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, renameVaultPath,
62
+ unlinkVaultFile, writeVaultFileAtomic, writeVaultFileSync,
63
+ } from '../hooks/vault-path-safety.mjs';
64
+
65
+ const ARCHIVE_DIR = '_arquivo';
66
+ const POINTER = '.brain/CURRENT_CHANGE.md';
67
+
68
+ function proofSchemaProblems(kind, proof) {
69
+ if (!proof || typeof proof !== 'object' || Array.isArray(proof)) return [`${kind} deve ser objeto JSON`];
70
+ const problems = [];
71
+ for (const field of ['slug', 'tasksHash', 'effectiveSpecHash', 'evidenceEnvelopeId']) {
72
+ if (typeof proof[field] !== 'string' || !proof[field]) problems.push(`${field} ausente ou inválido`);
73
+ }
74
+ if (!proof.evidenceBinding || typeof proof.evidenceBinding !== 'object' || Array.isArray(proof.evidenceBinding)) {
75
+ problems.push('evidenceBinding ausente ou inválido');
76
+ }
77
+ if (kind === 'package') {
78
+ for (const field of ['requirements', 'tasks', 'sensors']) {
79
+ if (!Array.isArray(proof[field])) problems.push(`${field} deve ser array`);
80
+ }
81
+ } else {
82
+ if (typeof proof.ok !== 'boolean') problems.push('ok deve ser boolean');
83
+ for (const field of ['coverage', 'notes']) {
84
+ if (!Array.isArray(proof[field])) problems.push(`${field} deve ser array`);
85
+ }
86
+ }
87
+ return problems;
88
+ }
89
+
90
+ function sameCanonical(left, right) {
91
+ return canonicalSha256(left) === canonicalSha256(right);
92
+ }
93
+
94
+ function provenanceAssessment(kind, proof, evidence, expected, contract = {}) {
95
+ if (!proof) {
96
+ return {
97
+ kind,
98
+ ok: false,
99
+ state: 'unproven',
100
+ reasonCodes: ['PROV_REQUIRED_ASSESSMENT_MISSING'],
101
+ diagnostics: [{ kind, state: 'unproven', blocker: `${kind} missing` }],
102
+ receipts: [],
103
+ };
104
+ }
105
+ const schemaProblems = proofSchemaProblems(kind, proof);
106
+ if (schemaProblems.length) {
107
+ return {
108
+ kind,
109
+ ok: false,
110
+ state: 'unproven',
111
+ reasonCodes: [`PROV_${kind === 'package' ? 'PACKAGE' : 'VERDICT'}_SCHEMA_INVALID`],
112
+ diagnostics: schemaProblems.map((blocker) => ({ kind, state: 'unproven', blocker })),
113
+ receipts: [],
114
+ };
115
+ }
116
+ const normalizedProof = {
117
+ ...proof,
118
+ change_slug: proof.slug || proof.change || proof.change_slug,
119
+ ...(proof.evidenceBinding && typeof proof.evidenceBinding === 'object' ? proof.evidenceBinding : {}),
120
+ ...(proof.tasksHash ? { tasks_sha256: proof.tasksHash } : {}),
121
+ ...(proof.effectiveSpecHash ? {
122
+ effective_spec_sha256: String(proof.effectiveSpecHash).startsWith('sha256:')
123
+ ? proof.effectiveSpecHash
124
+ : `sha256:${proof.effectiveSpecHash}`,
125
+ } : {}),
126
+ };
127
+ const result = classifyEvidenceEnvelope({
128
+ evidence,
129
+ expected,
130
+ ...(kind === 'package' ? { verification: normalizedProof } : { verdict: normalizedProof }),
131
+ });
132
+ const reasonCodes = [...(result.reasonCodes || [])];
133
+ const diagnostics = [...(result.diagnostics || [])];
134
+ const conflicts = [];
135
+ const stale = [];
136
+ if (contract.slug && proof.slug !== contract.slug) conflicts.push('slug mismatch');
137
+ if (proof.change != null && proof.change !== contract.slug) conflicts.push('change mismatch');
138
+ if (proof.change_slug != null && proof.change_slug !== contract.slug) conflicts.push('change_slug mismatch');
139
+ if (contract.tasksHash !== undefined && proof.tasksHash !== contract.tasksHash) stale.push('tasksHash mismatch');
140
+ if (contract.effectiveSpecHash !== undefined && proof.effectiveSpecHash !== contract.effectiveSpecHash) stale.push('effectiveSpecHash mismatch');
141
+ if (kind === 'package') {
142
+ for (const field of ['requirements', 'tasks', 'sensors']) {
143
+ if (!sameCanonical(proof[field], contract[field])) stale.push(`${field} mismatch`);
144
+ }
145
+ }
146
+ if (evidence?.schema_version === 2 && proof.evidenceEnvelopeId !== evidence.envelope_id) {
147
+ reasonCodes.push('WENDKEEP_PROVENANCE_BINDING_CONFLICT');
148
+ diagnostics.push({ kind, state: 'conflict', blocker: 'evidence envelope id mismatch' });
149
+ }
150
+ if (evidence?.schema_version === 2
151
+ && !evidenceCheckoutBindingMatches(proof.evidenceBinding, evidenceCheckoutBinding(evidence))) {
152
+ reasonCodes.push('WENDKEEP_PROVENANCE_BINDING_CONFLICT');
153
+ diagnostics.push({ kind, state: 'conflict', blocker: 'evidence checkout binding mismatch' });
154
+ }
155
+ if (conflicts.length) {
156
+ reasonCodes.push('WENDKEEP_PROVENANCE_BINDING_CONFLICT');
157
+ diagnostics.push(...conflicts.map((blocker) => ({ kind, state: 'conflict', blocker })));
158
+ }
159
+ if (reasonCodes.includes('WENDKEEP_PROVENANCE_BINDING_CONFLICT')) {
160
+ return { ...result, kind, ok: false, state: 'conflict', reasonCodes: [...new Set(reasonCodes)], diagnostics };
161
+ }
162
+ if (stale.length) {
163
+ return {
164
+ ...result,
165
+ kind,
166
+ ok: false,
167
+ state: 'stale',
168
+ reasonCodes: [...new Set([...reasonCodes, 'WENDKEEP_PROVENANCE_STALE'])],
169
+ diagnostics: [...diagnostics, ...stale.map((blocker) => ({ kind, state: 'stale', blocker }))],
170
+ };
171
+ }
172
+ return { ...result, kind };
173
+ }
174
+
175
+ function provenanceBlock(assessment) {
176
+ const code = assessment?.code || 'WENDKEEP_PROVENANCE_GATE_BLOCKED';
177
+ const codes = [...new Set(assessment?.reasonCodes || [])];
178
+ const diagnostics = (assessment?.diagnostics || [])
179
+ .map((item) => item?.blocker || item?.reason || item?.message)
180
+ .filter(Boolean);
181
+ const repair = assessment?.repair?.command ? `recuperação: ${assessment.repair.command}` : '';
182
+ return `${code}: operation=archive; state=${assessment?.state || 'unproven'}${codes.length ? `; reason_codes=${codes.join(',')}` : ''}${diagnostics.length ? `; ${diagnostics.join('; ')}` : ''}${repair ? `; ${repair}` : ''}`;
183
+ }
184
+
185
+ function archiveRepair(slug) {
186
+ return {
187
+ command: `wendkeep verify --deep --change ${slug}`,
188
+ explanation: `Recapture package, verdict e evidência fresca para ${slug} antes de arquivar.`,
189
+ };
190
+ }
191
+
192
+ function archiveRetryRepair(slug) {
193
+ return {
194
+ command: `wendkeep change archive ${slug}`,
195
+ explanation: 'Aguarde o owner ativo concluir e tente o archive novamente.',
196
+ };
197
+ }
198
+
199
+ function archiveManualRecovery(slug, published = false, { operationId = null, phase = null } = {}) {
200
+ return {
201
+ command: operationId ? `wendkeep change archive recover ${operationId} --change ${slug}` : null,
202
+ mode: 'manual',
203
+ operation_id: operationId,
204
+ transaction_phase: phase,
205
+ actions: published
206
+ ? ['preserve-published-archive', 'inspect-journal-by-operation-id', 'reconcile-spec-adr-pointer', 'retry-only-after-reconciliation']
207
+ : ['preserve-open-change', 'inspect-journal-by-operation-id', 'reconcile-retained-original', 'retry-only-after-reconciliation'],
208
+ explanation: published
209
+ ? `Publicação de ${slug} requer reconciliação manual antes de qualquer retry.`
210
+ : `Reconcilie a change aberta e o original retido de ${slug} antes de tentar novamente.`,
211
+ };
212
+ }
213
+
214
+ function sanitizeArchiveText(value) {
215
+ return String(value || '')
216
+ .replace(/\bAuthorization\s*:\s*Bearer\s+[^\s,;]+/gi, 'Authorization: Bearer [redacted]')
217
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
218
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[redacted]')
219
+ .replace(/\b(?:token|secret|password|authorization|bearer|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, '[redacted]')
220
+ .replace(/\b(?:ghp_[A-Za-z0-9_]+|npm_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]+)\b/g, '[redacted]')
221
+ .replace(/\\\\[^\\\s,;]+\\[^\s,;]+/g, '[private-path]')
222
+ .replace(/[A-Za-z]:\\[^,;\r\n]+/g, '[private-path]')
223
+ .replace(/\/(?:Users|home)\/[^,;\r\n]+/g, '[private-path]')
224
+ .replace(/(^|[\s(])\/(?:[^\s,;:)]+\/)+[^\s,;:)]+/g, '$1[private-path]')
225
+ .replace(/\(ex\.:\s*[^)]+\)/gi, '(detalhe omitido)')
226
+ .replace(/scaffold não preenchido\s*\([^)]+\)/gi, 'scaffold não preenchido')
227
+ .slice(0, 320);
228
+ }
229
+
230
+ function sanitizeArchiveValue(value, depth = 0) {
231
+ if (depth > 3) return '[bounded]';
232
+ if (value === null || value === undefined) return null;
233
+ if (typeof value === 'string') return sanitizeArchiveText(value);
234
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
235
+ if (Array.isArray(value)) return value.slice(0, 20).map((item) => sanitizeArchiveValue(item, depth + 1));
236
+ if (typeof value !== 'object') return sanitizeArchiveText(value);
237
+ const output = {};
238
+ for (const [key, item] of Object.entries(value)) {
239
+ if (/token|secret|password|authorization|private|content|output|path/i.test(key)) continue;
240
+ output[key] = sanitizeArchiveValue(item, depth + 1);
241
+ }
242
+ return output;
243
+ }
244
+
245
+ function archiveJsonFailure(slug, assessment, failing = []) {
246
+ const current = assessment || {
247
+ state: 'unproven',
248
+ reasonCodes: ['WENDKEEP_ARCHIVE_GATE_BLOCKED'],
249
+ diagnostics: failing.map((blocker) => ({ kind: 'archive', state: 'unproven', blocker })),
250
+ };
251
+ const diagnostics = (current.diagnostics || []).map((item) => sanitizeArchiveValue(item));
252
+ const first = diagnostics[0] || {};
253
+ const blocker = sanitizeArchiveText(first.blocker || first.reason || first.message
254
+ || current.reasonCodes?.[0] || 'WENDKEEP_ARCHIVE_GATE_BLOCKED');
255
+ const rawRepair = current.repair || archiveRepair(slug);
256
+ const repair = sanitizeArchiveValue(rawRepair);
257
+ const recovery = sanitizeArchiveText(current.recovery
258
+ || rawRepair.command
259
+ || rawRepair.explanation
260
+ || archiveRepair(slug).command);
261
+ return {
262
+ ok: false,
263
+ code: current.code || 'WENDKEEP_PROVENANCE_GATE_BLOCKED',
264
+ operation: 'archive',
265
+ state: current.state || 'unproven',
266
+ reason_codes: [...new Set(current.reasonCodes || [])],
267
+ blocker,
268
+ expected: sanitizeArchiveValue(first.expected ?? null),
269
+ observed: sanitizeArchiveValue(first.observed ?? null),
270
+ recovery,
271
+ diagnostics,
272
+ repair,
273
+ };
274
+ }
275
+
276
+ function archiveFailureText(payload) {
277
+ return `${payload.code}: operation=${payload.operation}; state=${payload.state}; blocker=${payload.blocker}; expected=${JSON.stringify(payload.expected)}; observed=${JSON.stringify(payload.observed)}; recovery=${payload.recovery}; reason_codes=${JSON.stringify(payload.reason_codes)}; diagnostics=${JSON.stringify(payload.diagnostics)}; repair=${JSON.stringify(payload.repair)}`;
278
+ }
279
+
280
+ function archiveRuntimeRoot(projectRoot) {
281
+ const raw = execFileSync('git', ['rev-parse', '--git-common-dir'], {
282
+ cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
283
+ }).trim();
284
+ const commonDir = isAbsolute(raw) ? raw : resolve(projectRoot, raw);
285
+ return join(commonDir, 'wendkeep');
286
+ }
287
+
288
+ function archiveReceiptStore(projectRoot) {
289
+ const runtime = archiveRuntimeRoot(projectRoot);
290
+ return createFileReceiptStore({
291
+ ledgerPath: join(runtime, 'change-archive-receipts-v2.jsonl'),
292
+ checkpointPath: join(runtime, 'change-archive-receipts-v2.checkpoint.json'),
293
+ legacyPath: join(runtime, 'change-archive-receipts-v1.jsonl'),
294
+ lockPath: join(runtime, 'change-archive-receipts-v2.lock'),
295
+ });
296
+ }
297
+
298
+ function appendArchiveAuthorization({ projectRoot, slug, expected, contract, evidence, verification, verdict, required, reqIds, forced }) {
299
+ const identity = expected.identity || {};
300
+ const snapshot = expected.snapshot || {};
301
+ return appendReceipt({
302
+ store: archiveReceiptStore(projectRoot),
303
+ draft: {
304
+ kind: 'change-archive-authorization',
305
+ subject: {
306
+ operation: 'archive',
307
+ outcome: 'authorized',
308
+ change_slug: slug,
309
+ project_id: identity.project_id,
310
+ repository_id: identity.repository_id,
311
+ worktree_id: identity.worktree_id,
312
+ work_session_id: identity.work_session_id,
313
+ branch: snapshot.branch,
314
+ head_sha: snapshot.head_sha,
315
+ index_tree_sha: snapshot.index_tree_sha,
316
+ worktree_digest: snapshot.worktree_digest,
317
+ tasks_sha256: contract.tasksHash,
318
+ effective_spec_sha256: contract.effectiveSpecHash,
319
+ evidence_envelope_id: evidence.envelope_id,
320
+ },
321
+ claims: {
322
+ forced,
323
+ requirements: reqIds,
324
+ required_sensors: required,
325
+ },
326
+ observations: {
327
+ package_sha256: canonicalSha256(verification),
328
+ verdict_sha256: canonicalSha256(verdict),
329
+ },
330
+ recorded_at: new Date().toISOString(),
331
+ },
332
+ });
333
+ }
334
+
335
+ function archiveContract({ slug, tarefasMd, tasks, effective, sensorEvidence }) {
336
+ return {
337
+ slug,
338
+ tasksHash: tasksHashOf(tarefasMd),
339
+ effectiveSpecHash: effective.hash,
340
+ requirements: effective.requirements.map((req) => ({
341
+ id: req.id,
342
+ name: req.name,
343
+ capability: req.capability,
344
+ operation: req.operation,
345
+ source: req.source,
346
+ body: req.body,
347
+ })),
348
+ tasks: tasks.map((task) => ({
349
+ id: task.id,
350
+ text: task.text,
351
+ req: task.req || null,
352
+ reqs: task.reqs || [],
353
+ done: task.done,
354
+ })),
355
+ sensors: sensorEvidence,
356
+ };
357
+ }
358
+
359
+ function recaptureArchiveAuthorization({
360
+ dir, vaultBase, projectRoot, slug, sessionId, selectedContext, forced, authorized,
361
+ }) {
362
+ if (!authorized) return { ok: false, failing: ['PROV_ARCHIVE_AUTHORIZATION_MISSING'] };
363
+ try {
364
+ const placeholders = scaffoldPlaceholders(dir);
365
+ const impact = validateSpecImpact(dir);
366
+ const tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8');
367
+ const tasks = parseTasks(tarefasMd);
368
+ const required = requiredSensors(tasks);
369
+ const reqIds = [...new Set(tasks.flatMap((task) => task.reqs ?? []))];
370
+ const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
371
+ const loaded = loadSensorsDetailed(projectRoot);
372
+ const identity = resolveEvidenceIdentity({
373
+ vaultBase, projectRoot, changeSlug: slug, sessionId, context: selectedContext,
374
+ });
375
+ const snapshot = captureGitSnapshot(projectRoot);
376
+ const evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8'));
377
+ const verification = JSON.parse(readFileSync(join(dir, 'verificacao.json'), 'utf8'));
378
+ const verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8'));
379
+ const stable = placeholders.length === 0
380
+ && impact.ok
381
+ && (forced || !tasks.some((task) => !task.done))
382
+ && effective.errors.length === 0
383
+ && effective.missing.length === 0
384
+ && sameCanonical(reqIds, authorized.reqIds)
385
+ && sameCanonical(required, authorized.required)
386
+ && tasksHashOf(tarefasMd) === authorized.contract.tasksHash
387
+ && effective.hash === authorized.contract.effectiveSpecHash
388
+ && sensorConfigSha256(loaded.sensors, required) === authorized.expected.sensor_config_sha256
389
+ && sameCanonical(identity, authorized.expected.identity)
390
+ && sameCanonical(snapshot, authorized.expected.snapshot)
391
+ && sameCanonical(evidence, authorized.evidence)
392
+ && sameCanonical(verification, authorized.verification)
393
+ && sameCanonical(verdict, authorized.verdict);
394
+ return stable
395
+ ? { ok: true, failing: [] }
396
+ : { ok: false, failing: ['PROV_ARCHIVE_INPUT_CHANGED'] };
397
+ } catch (error) {
398
+ const code = typeof error?.code === 'string' && /^[A-Z0-9_]+$/.test(error.code)
399
+ ? error.code : 'PROV_ARCHIVE_FINAL_SNAPSHOT_FAILED';
400
+ return { ok: false, failing: [code] };
401
+ }
402
+ }
29
403
 
30
404
  function observerMarkdownUnder(vaultBase, relativeRoot) {
31
405
  const output = [];
@@ -68,21 +442,1042 @@ function today() {
68
442
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
69
443
  }
70
444
 
445
+ function allVaultMarkdown(vaultBase, { excludeRoots = [] } = {}) {
446
+ const out = [];
447
+ const skip = new Set(['.git', '.obsidian', 'node_modules']);
448
+ const excluded = (target) => excludeRoots.some((root) => {
449
+ const rel = relative(root, target);
450
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
451
+ });
452
+ const walk = (dir) => {
453
+ if (excluded(dir)) return;
454
+ try {
455
+ assertVaultPathSafe(vaultBase, dir, {
456
+ allowMissing: false, expectedType: 'directory', label: 'diretório varrido para wikilinks',
457
+ });
458
+ } catch { return; }
459
+ let entries;
460
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
461
+ for (const e of entries) {
462
+ if (skip.has(e.name)) continue;
463
+ if (e.name.startsWith('.') && e.name !== '.brain') continue;
464
+ const abs = join(dir, e.name);
465
+ if (e.isDirectory()) walk(abs);
466
+ else if (e.name.endsWith('.md')) out.push(abs);
467
+ }
468
+ };
469
+ walk(vaultBase);
470
+ return out;
471
+ }
472
+
473
+ // Reescreve `[[fromRel/...]]`, `[[fromRel]]` e `[[fromRel|alias]]` em todo o vault.
474
+ // NUNCA por basename: `proposta`/`design` existem em toda change — só full-path é seguro.
475
+ function rewriteChangeLinks(vaultBase, fromRel, toRel, options = {}) {
476
+ let touched = 0;
477
+ for (const abs of allVaultMarkdown(vaultBase, options)) {
478
+ let content;
479
+ try { content = readFileSync(abs, 'utf8'); } catch { continue; }
480
+ const next = content
481
+ .split(`[[${fromRel}/`).join(`[[${toRel}/`)
482
+ .split(`[[${fromRel}]]`).join(`[[${toRel}]]`)
483
+ .split(`[[${fromRel}|`).join(`[[${toRel}|`);
484
+ if (next !== content) {
485
+ try {
486
+ writeVaultFileSync(vaultBase, abs, next, 'utf8', { label: 'nota com wikilink reescrito' });
487
+ touched += 1;
488
+ } catch { /* nota readonly/unsafe — segue */ }
489
+ }
490
+ }
491
+ return touched;
492
+ }
493
+
494
+ function decodePromotionImage(image) {
495
+ const content = Buffer.from(String(image?.content_base64 || ''), 'base64').toString('utf8');
496
+ if (image?.digest !== `sha256:${contentHashOf(content)}` || typeof image?.exists !== 'boolean') {
497
+ const error = new Error('imagem de promoção inválida');
498
+ error.code = 'PROV_SPEC_PROMOTION_PLAN_INVALID';
499
+ throw error;
500
+ }
501
+ return { exists: image.exists, content };
502
+ }
503
+
504
+ function validateArchiveSpecPromotionPlan(vaultBase, manifest, {
505
+ operationId,
506
+ slug,
507
+ transactionRoot,
508
+ }) {
509
+ const invalid = () => {
510
+ const error = new Error('plano de promoção não corresponde ao archive autorizado');
511
+ error.code = 'PROV_SPEC_PROMOTION_PLAN_INVALID';
512
+ throw error;
513
+ };
514
+ const exactKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
515
+ && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
516
+ const digestPattern = /^sha256:[a-f0-9]{64}$/;
517
+ const decodeImage = (image) => {
518
+ if (!exactKeys(image, ['exists', 'content_base64', 'digest'])
519
+ || typeof image.exists !== 'boolean'
520
+ || typeof image.content_base64 !== 'string'
521
+ || !digestPattern.test(image.digest)) invalid();
522
+ const bytes = Buffer.from(image.content_base64, 'base64');
523
+ const content = bytes.toString('utf8');
524
+ if (bytes.toString('base64') !== image.content_base64
525
+ || image.digest !== `sha256:${contentHashOf(content)}`
526
+ || (!image.exists && bytes.length !== 0)) invalid();
527
+ return content;
528
+ };
529
+ const plan = manifest?.spec_promotion_plan;
530
+ if (!exactKeys(plan, ['schema_version', 'entries', 'changes'])
531
+ || plan.schema_version !== 1 || !Array.isArray(plan.entries) || !Array.isArray(plan.changes)) invalid();
532
+
533
+ const loc = getLocale(vaultBase);
534
+ const normalizedDestination = String(manifest.destination_rel || '').replaceAll('\\', '/');
535
+ const archivePrefix = `${loc.folders.changes}/_arquivo/`;
536
+ const destinationName = normalizedDestination.slice(archivePrefix.length);
537
+ const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
538
+ if (normalizedDestination !== manifest.destination_rel
539
+ || !normalizedDestination.startsWith(archivePrefix)
540
+ || !new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapedSlug}$`).test(destinationName)) invalid();
541
+ const destination = assertVaultPathSafe(vaultBase, join(vaultBase, normalizedDestination), {
542
+ allowMissing: false, expectedType: 'directory', label: 'archive autorizado para recovery de specs',
543
+ }).target;
544
+ if (!digestPattern.test(manifest.destination_digest || '')
545
+ || archiveSourceDigest(destination) !== manifest.destination_digest) invalid();
546
+ const capabilities = discoverSpecDeltas(destination);
547
+ if (!capabilities.length || capabilities.some((capability) => (
548
+ typeof capability !== 'string'
549
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)
550
+ || capability.normalize('NFC') !== capability
551
+ || /^README$/i.test(capability)
552
+ )) || new Set(capabilities).size !== capabilities.length) invalid();
553
+
554
+ let baseline;
555
+ try { baseline = JSON.parse(readFileSync(join(destination, '.spec-base.json'), 'utf8')); }
556
+ catch { invalid(); }
557
+ if (baseline?.version !== 1 || !baseline.specs || typeof baseline.specs !== 'object'
558
+ || Array.isArray(baseline.specs)) invalid();
559
+
560
+ if (plan.entries.length !== capabilities.length + 2) invalid();
561
+ const capabilityEntries = plan.entries.slice(0, capabilities.length);
562
+ const stateEntry = plan.entries[capabilities.length];
563
+ const readmeEntry = plan.entries[capabilities.length + 1];
564
+ const plannedCapabilities = capabilityEntries.map((entry) => entry?.capability);
565
+ if (JSON.stringify([...plannedCapabilities].sort()) !== JSON.stringify([...capabilities].sort())
566
+ || new Set(plannedCapabilities).size !== plannedCapabilities.length
567
+ || stateEntry?.kind !== 'state' || stateEntry?.capability !== null
568
+ || readmeEntry?.kind !== 'readme' || readmeEntry?.capability !== null) invalid();
569
+
570
+ const transactionRel = relative(vaultBase, transactionRoot).replaceAll('\\', '/');
571
+ const escapedTransaction = transactionRel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
572
+ const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
573
+ const seenPhysicalPaths = new Set();
574
+ const validateEntryEnvelope = (entry, index, expectedKind, expectedCapability, expectedTarget) => {
575
+ if (!exactKeys(entry, [
576
+ 'kind', 'capability', 'target', 'claim_target', 'candidate_target', 'before', 'after',
577
+ ]) || entry.kind !== expectedKind || entry.capability !== expectedCapability
578
+ || entry.target !== expectedTarget.replaceAll('\\', '/')
579
+ || !new RegExp(`^${escapedTransaction}/${index}-${uuid}\\.before$`, 'i').test(entry.claim_target)
580
+ || !new RegExp(`^${escapedTransaction}/${index}-${uuid}\\.candidate$`, 'i').test(entry.candidate_target)
581
+ || seenPhysicalPaths.has(entry.claim_target) || seenPhysicalPaths.has(entry.candidate_target)
582
+ || entry.claim_target === entry.candidate_target) invalid();
583
+ seenPhysicalPaths.add(entry.claim_target);
584
+ seenPhysicalPaths.add(entry.candidate_target);
585
+ return { before: decodeImage(entry.before), after: decodeImage(entry.after) };
586
+ };
587
+
588
+ const dateStr = destinationName.slice(0, 10);
589
+ const changeWikilink = wikilinkFromRel(join(normalizedDestination, 'proposta'));
590
+ const logicalChanges = [];
591
+ const expectedSpecs = {};
592
+ for (let index = 0; index < capabilityEntries.length; index += 1) {
593
+ const entry = capabilityEntries[index];
594
+ const capability = entry.capability;
595
+ const expectedTarget = join(loc.folders.specs, `${capability}.md`);
596
+ const images = validateEntryEnvelope(entry, index, 'capability', capability, expectedTarget);
597
+ const baselineSpec = baseline.specs[capability];
598
+ if (baselineSpec) {
599
+ if (!entry.before.exists || entry.before.digest !== `sha256:${baselineSpec.hash}`) invalid();
600
+ } else if (entry.before.exists || entry.before.digest !== `sha256:${contentHashOf('')}`) invalid();
601
+ let delta;
602
+ try { delta = parseDelta(readFileSync(join(destination, 'specs', capability, 'spec.md'), 'utf8')); }
603
+ catch { invalid(); }
604
+ const applied = applyDelta(parseRequirements(images.before), delta);
605
+ const footer = `Atualizado por ${changeWikilink} em ${dateStr}.`;
606
+ const expectedAfter = renderSpec(capability, applied.reqs, { footer, reqHeading: loc.reqHeading });
607
+ if (!entry.after.exists || images.after !== expectedAfter) invalid();
608
+ expectedSpecs[capability] = {
609
+ hash: contentHashOf(expectedAfter),
610
+ requirements: Object.fromEntries(parseRequirements(expectedAfter)
611
+ .map((requirement) => [requirement.id || requirement.name, contentHashOf(JSON.stringify(requirement))])),
612
+ };
613
+ logicalChanges.push({
614
+ capability,
615
+ before_digest: entry.before.digest,
616
+ after_digest: entry.after.digest,
617
+ });
618
+ }
619
+
620
+ const stateImages = validateEntryEnvelope(
621
+ stateEntry, capabilities.length, 'state', null, '.brain/SPECS_STATE.json',
622
+ );
623
+ let beforeState = null;
624
+ let afterState;
625
+ try {
626
+ beforeState = stateEntry.before.exists ? JSON.parse(stateImages.before) : null;
627
+ afterState = JSON.parse(stateImages.after);
628
+ } catch { invalid(); }
629
+ if ((beforeState && (beforeState.version !== 1 || !beforeState.specs || typeof beforeState.specs !== 'object'))
630
+ || afterState?.version !== 1 || !afterState.specs || typeof afterState.specs !== 'object'
631
+ || typeof afterState.generatedAt !== 'string' || Number.isNaN(Date.parse(afterState.generatedAt))) invalid();
632
+ const expectedStateSpecs = { ...((beforeState?.specs) || baseline.specs), ...expectedSpecs };
633
+ if (JSON.stringify(afterState.specs) !== JSON.stringify(expectedStateSpecs)) invalid();
634
+
635
+ const readmeImages = validateEntryEnvelope(
636
+ readmeEntry, capabilities.length + 1, 'readme', null, join(loc.folders.specs, 'README.md'),
637
+ );
638
+ const canonicalReadme = renderSpecsReadme(vaultBase);
639
+ if (!readmeEntry.after.exists || readmeImages.after !== canonicalReadme
640
+ || (readmeEntry.before.exists
641
+ ? readmeImages.before !== canonicalReadme
642
+ : readmeEntry.before.digest !== `sha256:${contentHashOf('')}`)) invalid();
643
+
644
+ if (plan.changes.length !== logicalChanges.length
645
+ || plan.changes.some((change, index) => !exactKeys(change, [
646
+ 'capability', 'before_digest', 'after_digest',
647
+ ]) || JSON.stringify(change) !== JSON.stringify(logicalChanges[index]))
648
+ || !Array.isArray(manifest.spec_changes)
649
+ || JSON.stringify(manifest.spec_changes) !== JSON.stringify(logicalChanges)) invalid();
650
+ return { plan, logicalChanges };
651
+ }
652
+
653
+ function applySpecPromotionPlan(vaultBase, plan, {
654
+ action,
655
+ faultInjection = {},
656
+ assertOperationLock,
657
+ } = {}) {
658
+ if (plan?.schema_version !== 1 || !Array.isArray(plan.entries)
659
+ || !['resume', 'rollback'].includes(action)
660
+ || typeof assertOperationLock !== 'function') {
661
+ const error = new Error('plano de promoção inválido');
662
+ error.code = 'PROV_SPEC_PROMOTION_PLAN_INVALID';
663
+ throw error;
664
+ }
665
+ const entries = action === 'rollback' ? [...plan.entries].reverse() : plan.entries;
666
+ for (let index = 0; index < entries.length; index += 1) {
667
+ assertOperationLock();
668
+ const entry = entries[index];
669
+ if (!['capability', 'state', 'readme'].includes(entry?.kind)
670
+ || typeof entry.target !== 'string' || !entry.target
671
+ || typeof entry.claim_target !== 'string' || !entry.claim_target
672
+ || typeof entry.candidate_target !== 'string' || !entry.candidate_target) {
673
+ const error = new Error('target de promoção inválido');
674
+ error.code = 'PROV_SPEC_PROMOTION_PLAN_INVALID';
675
+ throw error;
676
+ }
677
+ const rawTarget = join(vaultBase, entry.target);
678
+ const rawCandidate = join(vaultBase, entry.candidate_target);
679
+ assertVaultPathSafe(vaultBase, dirname(rawTarget), {
680
+ allowMissing: false, expectedType: 'directory', label: 'ancestral do target de promoção',
681
+ });
682
+ assertVaultPathSafe(vaultBase, dirname(rawCandidate), {
683
+ expectedType: 'directory', label: 'ancestral do candidate de promoção',
684
+ });
685
+ if (existsSync(rawCandidate) && existsSync(rawTarget)) {
686
+ const candidateStat = lstatSync(rawCandidate);
687
+ const targetStat = lstatSync(rawTarget);
688
+ if (!candidateStat.isFile() || !targetStat.isFile()
689
+ || candidateStat.dev !== targetStat.dev || candidateStat.ino !== targetStat.ino) {
690
+ const error = new Error('candidate órfão divergiu do target');
691
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
692
+ throw error;
693
+ }
694
+ assertOperationLock();
695
+ unlinkSync(rawCandidate);
696
+ assertOperationLock();
697
+ }
698
+ const target = assertVaultPathSafe(vaultBase, rawTarget, {
699
+ expectedType: 'file', label: 'target do plano de promoção',
700
+ }).target;
701
+ const selected = decodePromotionImage(action === 'resume' ? entry.after : entry.before);
702
+ const claim = assertVaultPathSafe(vaultBase, join(vaultBase, entry.claim_target), {
703
+ expectedType: 'file', label: 'claim do plano de promoção',
704
+ }).target;
705
+ const candidate = assertVaultPathSafe(vaultBase, join(vaultBase, entry.candidate_target), {
706
+ expectedType: 'file', label: 'candidate do plano de promoção',
707
+ }).target;
708
+ const observed = (path) => {
709
+ const exists = existsSync(path);
710
+ const content = exists ? readFileSync(path, 'utf8') : '';
711
+ return { exists, content, digest: `sha256:${contentHashOf(content)}` };
712
+ };
713
+ const matchesImage = (value, image) => value.exists === image.exists && value.digest === image.digest;
714
+ mkdirVaultPath(vaultBase, dirname(target), { label: 'ancestral do plano de promoção' });
715
+ mkdirVaultPath(vaultBase, dirname(claim), { label: 'retenção do plano de promoção' });
716
+ let claimed = observed(claim);
717
+ let current = observed(target);
718
+ if (claimed.exists) {
719
+ if (!matchesImage(claimed, entry.before) && !matchesImage(claimed, entry.after)) {
720
+ const error = new Error('claim de promoção divergiu');
721
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
722
+ throw error;
723
+ }
724
+ if (matchesImage(current, action === 'resume' ? entry.after : entry.before)) {
725
+ continue;
726
+ }
727
+ if (current.exists) {
728
+ if (!matchesImage(current, entry.before) && !matchesImage(current, entry.after)) {
729
+ const error = new Error('writer concorrente ocupa target de promoção');
730
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
731
+ throw error;
732
+ }
733
+ const retained = join(dirname(claim), `retained-${randomUUID()}`);
734
+ assertOperationLock();
735
+ renameVaultPath(vaultBase, target, retained, {
736
+ sourceType: 'file', label: 'geração anterior retida da promoção',
737
+ });
738
+ assertOperationLock();
739
+ }
740
+ } else {
741
+ if (!matchesImage(current, entry.before) && !matchesImage(current, entry.after)) {
742
+ const error = new Error('target mudou fora do plano de promoção');
743
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
744
+ throw error;
745
+ }
746
+ if (current.exists) {
747
+ assertOperationLock();
748
+ renameVaultPath(vaultBase, target, claim, {
749
+ sourceType: 'file', label: 'claim físico do target de promoção',
750
+ });
751
+ assertOperationLock();
752
+ claimed = observed(claim);
753
+ if (!matchesImage(claimed, entry.before) && !matchesImage(claimed, entry.after)) {
754
+ if (!existsSync(target)) {
755
+ assertOperationLock();
756
+ renameVaultPath(vaultBase, claim, target, {
757
+ sourceType: 'file', label: 'restaura writer capturado pelo claim de promoção',
758
+ });
759
+ assertOperationLock();
760
+ }
761
+ const error = new Error('writer venceu antes do claim de promoção');
762
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
763
+ throw error;
764
+ }
765
+ }
766
+ }
767
+ faultInjection.beforeTargetCommit?.({ entry, index, action, target });
768
+ if (selected.exists) {
769
+ if (existsSync(candidate)) {
770
+ const stale = observed(candidate);
771
+ if (stale.digest !== (action === 'resume' ? entry.after.digest : entry.before.digest)) {
772
+ const error = new Error('candidate de promoção divergiu');
773
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
774
+ throw error;
775
+ }
776
+ assertOperationLock();
777
+ unlinkVaultFile(vaultBase, candidate, { missingOk: false, label: 'candidate stale de promoção' });
778
+ assertOperationLock();
779
+ }
780
+ assertOperationLock();
781
+ writeVaultFileAtomic(vaultBase, candidate, selected.content, 'utf8', {
782
+ label: `candidate ${action} de promoção de spec`,
783
+ });
784
+ assertOperationLock();
785
+ try { linkSync(candidate, target); }
786
+ catch (cause) {
787
+ const error = new Error('target concorrente impediu commit de promoção');
788
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
789
+ error.cause = cause;
790
+ throw error;
791
+ }
792
+ assertOperationLock();
793
+ faultInjection.afterCandidateLink?.({ entry, index, action, target, candidate });
794
+ assertOperationLock();
795
+ unlinkSync(candidate);
796
+ assertOperationLock();
797
+ }
798
+ current = observed(target);
799
+ if (!matchesImage(current, action === 'resume' ? entry.after : entry.before)) {
800
+ const error = new Error('target divergiu após recovery de promoção');
801
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_DIVERGED';
802
+ throw error;
803
+ }
804
+ if (existsSync(claim)) {
805
+ const retainedClaim = observed(claim);
806
+ if (!matchesImage(retainedClaim, entry.before) && !matchesImage(retainedClaim, entry.after)) {
807
+ const error = new Error('writer por handle alterou geração retida');
808
+ error.code = 'PROV_SPEC_PROMOTION_RECOVERY_CONFLICT';
809
+ throw error;
810
+ }
811
+ }
812
+ faultInjection.afterEntryWrite?.({ entry, index, action });
813
+ if (entry.kind === 'capability') {
814
+ faultInjection.afterCapabilityWrite?.({ capability: entry.capability, index });
815
+ } else if (entry.kind === 'state') faultInjection.afterStateWrite?.();
816
+ else if (entry.kind === 'readme') faultInjection.afterReadmeWrite?.();
817
+ assertOperationLock();
818
+ }
819
+ return { action, changes: plan.changes || [] };
820
+ }
821
+
822
+ function promoteSpecsMutation(vaultBase, changeDir, specs, options = {}) {
823
+ const {
824
+ faultInjection = {}, assertOperationLock, prepared: preparedOption = null, ...planOptions
825
+ } = options;
826
+ const prepared = preparedOption || buildSpecPromotionPlan(vaultBase, changeDir, specs, planOptions);
827
+ const { plan, promoted, warnings, changes } = prepared;
828
+ faultInjection.beforeMutation?.(plan);
829
+ try {
830
+ mkdirVaultPath(vaultBase, join(vaultBase, getLocale(vaultBase).folders.specs), {
831
+ label: 'raiz de specs consolidadas',
832
+ });
833
+ mkdirVaultPath(vaultBase, join(vaultBase, '.brain'), { label: 'raiz do estado de specs' });
834
+ applySpecPromotionPlan(vaultBase, plan, { action: 'resume', faultInjection, assertOperationLock });
835
+ return { promoted, warnings, changes, plan };
836
+ } catch (cause) {
837
+ let rollbackError = null;
838
+ try { applySpecPromotionPlan(vaultBase, plan, { action: 'rollback', assertOperationLock }); }
839
+ catch (error) { rollbackError = error; }
840
+ const ownershipLost = cause?.code === 'WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST';
841
+ const error = new Error(ownershipLost
842
+ ? 'ownership do lock perdido durante promoção de specs'
843
+ : 'promoção de specs falhou; before-images restauradas');
844
+ error.code = ownershipLost ? cause.code : 'PROV_SPEC_PROMOTION_ATOMIC_FAILED';
845
+ error.cause = cause;
846
+ error.changes = changes;
847
+ error.plan = plan;
848
+ error.rollback_failed = Boolean(rollbackError);
849
+ error.rollback_errors = rollbackError ? [rollbackError.code || 'ROLLBACK_FAILED'] : [];
850
+ throw error;
851
+ }
852
+ }
853
+
854
+ function recoverArchiveSpecPromotionUnderLock(vaultBase, {
855
+ operationId,
856
+ slug,
857
+ action,
858
+ assertOperationLock,
859
+ }) {
860
+ if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(operationId || '')
861
+ || !/^[a-z0-9][a-z0-9._-]*$/i.test(slug || '')
862
+ || !['resume', 'rollback'].includes(action)) {
863
+ const error = new Error('recovery de promoção inválido');
864
+ error.code = 'PROV_ARCHIVE_RECOVERY_NOT_FOUND';
865
+ throw error;
866
+ }
867
+ const transactionRoot = join(vaultBase, '.brain', 'runtime', 'archive-transactions', operationId);
868
+ const manifestPath = assertVaultPathSafe(vaultBase, join(transactionRoot, 'archive-transaction.json'), {
869
+ allowMissing: false, expectedType: 'file', label: 'manifest de recovery de specs',
870
+ }).target;
871
+ assertOperationLock();
872
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
873
+ if (manifest?.schema_version !== 1 || manifest.operation !== 'archive'
874
+ || manifest.operation_id !== operationId || manifest.change_slug !== slug
875
+ || manifest.spec_promotion_plan?.schema_version !== 1) {
876
+ const error = new Error('binding do recovery de specs inválido');
877
+ error.code = 'PROV_ARCHIVE_RECOVERY_NOT_FOUND';
878
+ throw error;
879
+ }
880
+ let validated;
881
+ try {
882
+ validated = validateArchiveSpecPromotionPlan(vaultBase, manifest, {
883
+ operationId,
884
+ slug,
885
+ transactionRoot,
886
+ });
887
+ } catch {
888
+ const error = new Error('plano de promoção inválido');
889
+ error.code = 'PROV_SPEC_PROMOTION_PLAN_INVALID';
890
+ throw error;
891
+ }
892
+ applySpecPromotionPlan(vaultBase, validated.plan, {
893
+ action,
894
+ assertOperationLock,
895
+ });
896
+ assertOperationLock();
897
+ const next = {
898
+ ...manifest,
899
+ phase: 'recovery-required',
900
+ blocker: 'PROV_ARCHIVE_RECOVERY_RECONCILIATION_REQUIRED',
901
+ publication_state: 'published-recovery-required',
902
+ spec_promotion_state: action === 'resume' ? 'resumed' : 'rolled-back',
903
+ };
904
+ writeVaultFileAtomic(vaultBase, manifestPath, `${JSON.stringify(next)}\n`, 'utf8', {
905
+ label: 'manifest após recovery de specs', scopeRoot: transactionRoot,
906
+ });
907
+ const fd = openSync(manifestPath, 'r+');
908
+ try { fsyncSync(fd); } finally { closeSync(fd); }
909
+ assertOperationLock();
910
+ return {
911
+ ok: false,
912
+ code: 'PROV_ARCHIVE_RECOVERY_RECONCILIATION_REQUIRED',
913
+ operation: 'archive-recover',
914
+ state: 'conflict',
915
+ operation_id: operationId,
916
+ change_slug: slug,
917
+ transaction_phase: 'recovery-required',
918
+ original_retained: existsSync(join(transactionRoot, 'original')),
919
+ publication_state: 'published-recovery-required',
920
+ spec_promotion_state: next.spec_promotion_state,
921
+ actions: [
922
+ `${action}-spec-promotion-complete`,
923
+ 'reconcile-spec-adr-pointer',
924
+ 'retry-only-after-reconciliation',
925
+ ],
926
+ };
927
+ }
928
+
929
+ function recoverArchiveSpecPromotionMutation(vaultBase, { operationId, slug, action }) {
930
+ const lock = acquireArchiveOperationLock({
931
+ lockPath: join(vaultBase, '.brain', 'runtime', 'change-archive-operation.lock'),
932
+ });
933
+ try {
934
+ return recoverArchiveSpecPromotionUnderLock(vaultBase, {
935
+ operationId,
936
+ slug,
937
+ action,
938
+ assertOperationLock: () => lock.assertOwned(),
939
+ });
940
+ } finally {
941
+ lock.release();
942
+ }
943
+ }
944
+
945
+ function archiveChangeMutation(vaultBase, slug, options = {}) {
946
+ const {
947
+ gate,
948
+ preMutate,
949
+ assertOperationLock,
950
+ authorizationEnvelope,
951
+ faultInjection = {},
952
+ dateStr, adrNum, adrFlags = {}, context,
953
+ } = options;
954
+ if (![gate, preMutate, assertOperationLock, authorizationEnvelope].every((entry) => typeof entry === 'function')) {
955
+ return { ok: false, failing: ['PROV_ARCHIVE_AUTHORIZATION_REQUIRED'] };
956
+ }
957
+ const existingRecovery = pendingArchiveRecovery(vaultBase, slug);
958
+ if (existingRecovery) {
959
+ return {
960
+ ok: false,
961
+ failing: [existingRecovery.invalid
962
+ ? 'PROV_ARCHIVE_RECOVERY_JOURNAL_INVALID'
963
+ : 'PROV_ARCHIVE_RECOVERY_REQUIRED'],
964
+ recovery: existingRecovery,
965
+ };
966
+ }
967
+ const loc = getLocale(vaultBase);
968
+ const chDir = loc.folders.changes;
969
+ const src = join(vaultBase, chDir, slug);
970
+ const createAdr = !isGuideCompactChange(src);
971
+ const operationFailureCode = (error, fallback) => (
972
+ typeof error?.code === 'string' && /^(?:WENDKEEP|PROV|VAULT)_[A-Z0-9_]+$/.test(error.code)
973
+ ? error.code : fallback
974
+ );
975
+ let verdict;
976
+ try {
977
+ assertOperationLock();
978
+ verdict = gate(src);
979
+ assertOperationLock();
980
+ } catch (error) {
981
+ return { ok: false, failing: [operationFailureCode(error, 'WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST')] };
982
+ }
983
+ if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
984
+ let authorizedEnvelope;
985
+ let authorizedEnvelopeJson;
986
+ try {
987
+ authorizedEnvelope = authorizationEnvelope();
988
+ if (authorizedEnvelope?.schema_version !== 2
989
+ || authorizedEnvelope.purpose !== 'archive'
990
+ || authorizedEnvelope.change_slug !== slug
991
+ || !authorizedEnvelope.authorization || typeof authorizedEnvelope.authorization !== 'object') {
992
+ return { ok: false, failing: ['PROV_ARCHIVE_AUTHORIZATION_REQUIRED'] };
993
+ }
994
+ authorizedEnvelopeJson = JSON.stringify(authorizedEnvelope);
995
+ } catch {
996
+ return { ok: false, failing: ['PROV_ARCHIVE_AUTHORIZATION_REQUIRED'] };
997
+ }
998
+
999
+ const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}`);
1000
+ const destAbs = join(vaultBase, destRel);
1001
+ const changeWikilink = wikilinkFromRel(join(destRel, 'proposta'));
1002
+ const archiveRoot = join(vaultBase, chDir, ARCHIVE_DIR);
1003
+ const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
1004
+ const num = String(adrNum).padStart(4, '0');
1005
+ const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
1006
+
1007
+ // Validate every later mutation target before spec promotion can change living state.
1008
+ const mutationTargets = [
1009
+ { path: src, allowMissing: false, expectedType: 'directory', label: 'change a arquivar' },
1010
+ { path: destAbs, expectedType: 'directory', label: 'destino da change arquivada' },
1011
+ { path: archiveRoot, expectedType: 'directory', label: 'raiz de changes arquivadas' },
1012
+ { path: join(vaultBase, POINTER), expectedType: 'file', label: 'ponteiro CURRENT_CHANGE.md' },
1013
+ ...(createAdr ? [
1014
+ { path: join(vaultBase, adrDirRel), expectedType: 'directory', label: 'pasta mensal de ADR' },
1015
+ { path: join(vaultBase, adrRel), expectedType: 'file', label: 'ADR da change arquivada' },
1016
+ ] : []),
1017
+ ];
1018
+ const transactionId = randomUUID();
1019
+ const transactionsRoot = join(vaultBase, '.brain', 'runtime', 'archive-transactions');
1020
+ const transactionRoot = join(transactionsRoot, transactionId);
1021
+ const quarantineAbs = join(transactionRoot, 'original');
1022
+ const snapshotAbs = join(transactionRoot, 'authorized');
1023
+ const manifestAbs = join(transactionRoot, 'archive-transaction.json');
1024
+ mutationTargets.push(
1025
+ { path: quarantineAbs, expectedType: 'directory', label: 'quarentena causal do archive' },
1026
+ { path: snapshotAbs, expectedType: 'directory', label: 'snapshot autorizado do archive' },
1027
+ { path: manifestAbs, expectedType: 'file', label: 'manifest da transação de archive' },
1028
+ );
1029
+ const checkedMutationTargets = assertVaultPathsSafe(vaultBase, mutationTargets);
1030
+ const [checkedSource, checkedDestination] = checkedMutationTargets;
1031
+ const checkedQuarantine = checkedMutationTargets.at(-3);
1032
+ const checkedSnapshot = checkedMutationTargets.at(-2);
1033
+ const checkedManifest = checkedMutationTargets.at(-1);
1034
+ assertVaultPathsSafe(vaultBase, [
1035
+ { path: join(checkedSource.target, 'proposta.md'), expectedType: 'file', label: 'proposta da change' },
1036
+ { path: join(checkedSource.target, 'tarefas.md'), expectedType: 'file', label: 'tarefas da change' },
1037
+ ]);
1038
+
1039
+ // Atomicity guard: fail BEFORE promoting specs if the destination already exists (e.g. a slug
1040
+ // reused after a same-day archive). Otherwise promoteSpecs would commit to 07-Specs and the
1041
+ // later renameSync would fail, leaving a half-archived state.
1042
+ if (checkedDestination.exists) {
1043
+ return { ok: false, failing: [`destino de arquivo já existe: ${destRel} — renomeie o slug ou remova o arquivo antigo`] };
1044
+ }
1045
+
1046
+ // Commit seam: the public gate may perform durable authorization I/O before returning. Inputs
1047
+ // can still change while preflights run, so re-derive authority here, immediately before the
1048
+ // first product mutation. Tests inject a mutation at this exact boundary; production callers
1049
+ // omit faultInjection. A negative/failed recapture is fail-closed and leaves every target intact.
1050
+ let commitVerdict;
1051
+ try {
1052
+ assertOperationLock();
1053
+ if (typeof faultInjection?.afterGateBeforeMutation === 'function') {
1054
+ faultInjection.afterGateBeforeMutation({ source: checkedSource.target, destination: checkedDestination.target });
1055
+ }
1056
+ commitVerdict = preMutate(checkedSource.target);
1057
+ if (!commitVerdict?.ok) return { ok: false, failing: commitVerdict?.failing || ['PROV_ARCHIVE_INPUT_CHANGED'] };
1058
+ if (!/^sha256:[a-f0-9]{64}$/.test(commitVerdict.sourceDigest || '')) {
1059
+ return { ok: false, failing: ['PROV_ARCHIVE_AUTHORIZED_SNAPSHOT_MISSING'] };
1060
+ }
1061
+ if (typeof faultInjection?.afterPreMutateBeforeMutation === 'function') {
1062
+ faultInjection.afterPreMutateBeforeMutation({ source: checkedSource.target, destination: checkedDestination.target });
1063
+ }
1064
+ if (JSON.stringify(authorizationEnvelope()) !== authorizedEnvelopeJson) {
1065
+ return { ok: false, failing: ['PROV_ARCHIVE_AUTHORIZATION_CHANGED'] };
1066
+ }
1067
+ assertOperationLock();
1068
+ } catch (error) {
1069
+ const code = typeof error?.code === 'string' && /^[A-Z0-9_]+$/.test(error.code)
1070
+ ? error.code : 'PROV_ARCHIVE_PRE_MUTATION_CHECK_FAILED';
1071
+ return { ok: false, failing: [code] };
1072
+ }
1073
+
1074
+ let transactionPhase = 'unprepared';
1075
+ const writeTransactionPhase = (phase, extra = {}) => {
1076
+ assertOperationLock();
1077
+ writeVaultFileAtomic(vaultBase, checkedManifest.target, `${JSON.stringify({
1078
+ schema_version: 1,
1079
+ operation: 'archive',
1080
+ operation_id: transactionId,
1081
+ change_slug: slug,
1082
+ phase,
1083
+ source_digest: commitVerdict.sourceDigest,
1084
+ destination_rel: destRel.replaceAll('\\', '/'),
1085
+ ...extra,
1086
+ })}\n`, 'utf8', {
1087
+ label: 'manifest da transação de archive', scopeRoot: transactionRoot,
1088
+ });
1089
+ const manifestFd = openSync(checkedManifest.target, 'r+');
1090
+ try { fsyncSync(manifestFd); } finally { closeSync(manifestFd); }
1091
+ assertOperationLock();
1092
+ transactionPhase = phase;
1093
+ };
1094
+ const markRecoveryRequired = (code, extra = {}) => {
1095
+ if (!existsSync(transactionRoot)) return;
1096
+ try { writeTransactionPhase('recovery-required', { blocker: code, ...extra }); }
1097
+ catch { /* retain the whole transaction even when journaling is unavailable */ }
1098
+ };
1099
+
1100
+ const rollbackSeal = () => {
1101
+ if (existsSync(checkedSnapshot.target)) {
1102
+ try { rmSync(checkedSnapshot.target, { recursive: true, force: true }); } catch { /* quarantine remains recoverable */ }
1103
+ }
1104
+ if (existsSync(checkedQuarantine.target)) {
1105
+ if (existsSync(checkedSource.target)) {
1106
+ markRecoveryRequired('PROV_ARCHIVE_ROLLBACK_COLLISION', {
1107
+ original_state: 'retained', public_change_state: 'recreated',
1108
+ });
1109
+ return {
1110
+ ok: false,
1111
+ code: 'PROV_ARCHIVE_ROLLBACK_COLLISION',
1112
+ recovery: { kind: 'retained-original', operation_id: transactionId, phase: transactionPhase },
1113
+ };
1114
+ }
1115
+ renameVaultPath(vaultBase, checkedQuarantine.target, checkedSource.target, {
1116
+ sourceType: 'directory', label: 'rollback do isolamento causal da change',
1117
+ });
1118
+ }
1119
+ if (existsSync(transactionRoot)) {
1120
+ try { rmSync(transactionRoot, { recursive: true, force: true }); } catch { /* recoverable private runtime */ }
1121
+ }
1122
+ return { ok: true };
1123
+ };
1124
+ const rollbackFailure = (fallbackCode) => {
1125
+ try {
1126
+ const rollback = rollbackSeal();
1127
+ if (!rollback.ok) {
1128
+ return { ok: false, failing: [rollback.code], recovery: rollback.recovery };
1129
+ }
1130
+ } catch {
1131
+ return {
1132
+ ok: false,
1133
+ failing: ['PROV_ARCHIVE_ROLLBACK_FAILED'],
1134
+ recovery: { kind: 'retained-original', operation_id: transactionId, phase: transactionPhase },
1135
+ };
1136
+ }
1137
+ return { ok: false, failing: [fallbackCode] };
1138
+ };
1139
+
1140
+ // Atomic namespace isolation is the first mutation. Writers addressing the public change path
1141
+ // can no longer alter the quarantined tree. A second private copy is hashed and becomes the only
1142
+ // publication source, so even a pre-existing OS handle to the original inode cannot inject bytes.
1143
+ try {
1144
+ assertOperationLock();
1145
+ mkdirVaultPath(vaultBase, transactionsRoot, {
1146
+ label: 'runtime de transações causais do archive',
1147
+ });
1148
+ mkdirVaultPath(vaultBase, transactionRoot, {
1149
+ exclusive: true, label: 'transação causal privada do archive',
1150
+ });
1151
+ writeTransactionPhase('prepared');
1152
+ assertOperationLock();
1153
+ renameVaultPath(vaultBase, checkedSource.target, checkedQuarantine.target, {
1154
+ sourceType: 'directory', label: 'isolamento causal da change',
1155
+ });
1156
+ assertOperationLock();
1157
+ if (archiveSourceDigest(checkedQuarantine.target) !== commitVerdict.sourceDigest) {
1158
+ return rollbackFailure('PROV_ARCHIVE_INPUT_CHANGED');
1159
+ }
1160
+ writeTransactionPhase('isolated');
1161
+ assertOperationLock();
1162
+ cpSync(checkedQuarantine.target, checkedSnapshot.target, {
1163
+ recursive: true, errorOnExist: true, force: false,
1164
+ });
1165
+ if (!existsSync(checkedSnapshot.target)) {
1166
+ const error = new Error('snapshot autorizado ausente após cópia');
1167
+ error.code = 'PROV_ARCHIVE_SNAPSHOT_COPY_MISSING';
1168
+ throw error;
1169
+ }
1170
+ if (archiveSourceDigest(checkedSnapshot.target) !== commitVerdict.sourceDigest) {
1171
+ return rollbackFailure('PROV_ARCHIVE_SNAPSHOT_DIVERGED');
1172
+ }
1173
+ writeTransactionPhase('copied');
1174
+ assertOperationLock();
1175
+ if (typeof faultInjection?.afterSealBeforePromotion === 'function') {
1176
+ faultInjection.afterSealBeforePromotion({
1177
+ source: checkedSource.target,
1178
+ original: checkedQuarantine.target,
1179
+ snapshot: checkedSnapshot.target,
1180
+ });
1181
+ }
1182
+ } catch (error) {
1183
+ const code = operationFailureCode(error, 'PROV_ARCHIVE_SOURCE_SEAL_FAILED');
1184
+ return rollbackFailure(code);
1185
+ }
1186
+ const mutationSource = checkedSnapshot.target;
1187
+
1188
+ // Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
1189
+ // UNIÃO frontmatter + disco (0.31.0): o scaffold deixa `specs: []`, então um delta real
1190
+ // preenchido em specs/<cap>/ mas não listado era silenciosamente ignorado. Deltas ainda em
1191
+ // placeholder (o `exemplo` do scaffold) são filtrados por discoverSpecDeltas.
1192
+ let promoted = [];
1193
+ let specWarnings = [];
1194
+ let specCapabilities = [];
1195
+ let specChanges = [];
1196
+ let specPromotionPlan = null;
1197
+ let preparedSpecPromotion = null;
1198
+ try {
1199
+ assertOperationLock();
1200
+ let listed = [];
1201
+ try { listed = parseSpecsList(readFileSync(join(mutationSource, 'proposta.md'), 'utf8')); } catch { /* proposta ilegível */ }
1202
+ const onDisk = discoverSpecDeltas(mutationSource);
1203
+ specCapabilities = [...new Set([...listed, ...onDisk])];
1204
+ assertSpecPromotionTargetsSafe(vaultBase, mutationSource, specCapabilities);
1205
+ specWarnings = onDisk
1206
+ .filter((c) => !listed.includes(c))
1207
+ .map((c) => `spec no disco não listada no frontmatter da proposta: ${c} — promovida assim mesmo`);
1208
+ } catch (error) {
1209
+ return rollbackFailure(operationFailureCode(error, 'PROV_ARCHIVE_SPEC_DISCOVERY_FAILED'));
1210
+ }
1211
+
1212
+ let reqIds = [];
1213
+ try { reqIds = [...new Set(parseTasks(readFileSync(join(mutationSource, 'tarefas.md'), 'utf8')).flatMap((t) => t.reqs ?? []))]; } catch { /* sem tarefas */ }
1214
+
1215
+ // Backlink dos artefatos escritos à mão (spec.md) ANTES do move — o rewriteChangeLinks
1216
+ // abaixo retargeta o wikilink pro _arquivo junto com os demais. Fail-quiet.
1217
+ try {
1218
+ assertOperationLock();
1219
+ healSpecBacklinks(mutationSource, vaultBase, { proposalChangeDir: src });
1220
+ } catch (error) {
1221
+ if (error?.code === 'WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST') return rollbackFailure(error.code);
1222
+ // heal é bônus
1223
+ }
1224
+
1225
+ // Semantic spec conflicts must fail before the archive is published so the public change can be
1226
+ // restored and `spec rebase --accept-current` remains available. The returned plan is read-only
1227
+ // and is reused after publication; its physical CAS still catches living-state changes that race
1228
+ // this preflight.
1229
+ try {
1230
+ assertOperationLock();
1231
+ if (specCapabilities.length) {
1232
+ preparedSpecPromotion = buildSpecPromotionPlan(vaultBase, mutationSource, specCapabilities, {
1233
+ changeWikilink,
1234
+ dateStr,
1235
+ recoveryRoot: transactionRoot,
1236
+ });
1237
+ specPromotionPlan = preparedSpecPromotion.plan;
1238
+ specChanges = preparedSpecPromotion.changes || [];
1239
+ }
1240
+ assertOperationLock();
1241
+ } catch (error) {
1242
+ return rollbackFailure(error?.message || 'PROV_ARCHIVE_SPEC_PREFLIGHT_FAILED');
1243
+ }
1244
+
1245
+ let publicationDigest;
1246
+ try {
1247
+ assertOperationLock();
1248
+ publicationDigest = archiveSourceDigest(mutationSource);
1249
+ writeTransactionPhase('sealed', { publication_digest: publicationDigest });
1250
+ } catch (error) {
1251
+ return rollbackFailure(operationFailureCode(error, 'PROV_ARCHIVE_PUBLICATION_SEAL_FAILED'));
1252
+ }
1253
+
1254
+ let archivePublished = false;
1255
+ const publishedFailure = (code, extra = {}) => {
1256
+ markRecoveryRequired(code, { publication_state: 'published-recovery-required', ...extra });
1257
+ return {
1258
+ ok: false,
1259
+ failing: [code],
1260
+ published: true,
1261
+ recovery: { kind: 'published-recovery-required', operation_id: transactionId, phase: transactionPhase },
1262
+ };
1263
+ };
1264
+ try {
1265
+ assertOperationLock();
1266
+ mkdirVaultPath(vaultBase, archiveRoot, { label: 'raiz de changes arquivadas' });
1267
+ assertOperationLock();
1268
+ renameVaultPath(vaultBase, mutationSource, destAbs, {
1269
+ sourceType: 'directory', label: 'archive da change',
1270
+ });
1271
+ archivePublished = true;
1272
+ assertOperationLock();
1273
+ if (existsSync(src) || archiveSourceDigest(destAbs) !== publicationDigest) {
1274
+ return publishedFailure('PROV_ARCHIVE_PUBLICATION_DIVERGED');
1275
+ }
1276
+ writeTransactionPhase('published', { destination_digest: publicationDigest });
1277
+ if (typeof faultInjection?.afterPublishBeforeFinalize === 'function') {
1278
+ faultInjection.afterPublishBeforeFinalize({
1279
+ source: src,
1280
+ destination: destAbs,
1281
+ operationId: transactionId,
1282
+ });
1283
+ }
1284
+ } catch (error) {
1285
+ const code = operationFailureCode(error, 'PROV_ARCHIVE_MOVE_FAILED');
1286
+ if (archivePublished) {
1287
+ return publishedFailure(code);
1288
+ }
1289
+ return rollbackFailure(code);
1290
+ }
1291
+
1292
+ // Specs are promoted only after the immutable archive publication exists. A partial promotion
1293
+ // can therefore never trigger a false rollback; the journal retains the original for recovery.
1294
+ try {
1295
+ assertOperationLock();
1296
+ if (specCapabilities.length) {
1297
+ const res = promoteSpecsMutation(vaultBase, destAbs, specCapabilities, {
1298
+ changeWikilink,
1299
+ dateStr,
1300
+ recoveryRoot: transactionRoot,
1301
+ prepared: preparedSpecPromotion,
1302
+ assertOperationLock,
1303
+ faultInjection: {
1304
+ ...(faultInjection?.specPromotion || {}),
1305
+ beforeMutation: (plan) => {
1306
+ specPromotionPlan = plan;
1307
+ specChanges = plan.changes || [];
1308
+ writeTransactionPhase('promotion-prepared', {
1309
+ destination_digest: publicationDigest,
1310
+ spec_changes: specChanges,
1311
+ spec_promotion_plan: specPromotionPlan,
1312
+ spec_promotion_state: 'prepared',
1313
+ });
1314
+ faultInjection?.specPromotion?.beforeMutation?.(plan);
1315
+ },
1316
+ },
1317
+ });
1318
+ promoted = res.promoted;
1319
+ specChanges = res.changes || [];
1320
+ specPromotionPlan = res.plan || specPromotionPlan;
1321
+ specWarnings.push(...res.warnings);
1322
+ writeTransactionPhase('promotion-applied', {
1323
+ destination_digest: publicationDigest,
1324
+ spec_changes: specChanges,
1325
+ spec_promotion_plan: specPromotionPlan,
1326
+ spec_promotion_state: 'applied',
1327
+ });
1328
+ }
1329
+ assertOperationLock();
1330
+ }
1331
+ catch (error) {
1332
+ specChanges = error?.changes || specChanges;
1333
+ specPromotionPlan = error?.plan || specPromotionPlan;
1334
+ return publishedFailure(operationFailureCode(error, 'PROV_ARCHIVE_SPEC_PROMOTION_FAILED'), {
1335
+ spec_changes: specChanges,
1336
+ spec_promotion_plan: specPromotionPlan,
1337
+ spec_promotion_state: error?.rollback_failed ? 'recovery-required' : 'rolled-back',
1338
+ });
1339
+ }
1340
+
1341
+ // Flip the archived proposta's frontmatter status so it no longer reads as active.
1342
+ try {
1343
+ assertOperationLock();
1344
+ const pp = join(destAbs, 'proposta.md');
1345
+ const c = readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: archived');
1346
+ writeVaultFileSync(vaultBase, pp, c, 'utf8', { label: 'proposta arquivada' });
1347
+ } catch (error) {
1348
+ return publishedFailure(operationFailureCode(error, 'PROV_ARCHIVE_PROPOSAL_UPDATE_FAILED'));
1349
+ }
1350
+
1351
+ // O move quebrava TODO wikilink gravado antes (sessões fechadas, decisões, outras changes —
1352
+ // links cinza no grafo, visto em produção). Reescreve vault-wide; fail-quiet.
1353
+ let linksRewritten = 0;
1354
+ try {
1355
+ assertOperationLock();
1356
+ linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/'), {
1357
+ excludeRoots: [transactionRoot],
1358
+ });
1359
+ } catch (error) {
1360
+ return publishedFailure(operationFailureCode(error, 'PROV_ARCHIVE_LINK_REWRITE_FAILED'));
1361
+ }
1362
+
1363
+ // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
1364
+ // — not the year root — so all ADRs sit together in the vault's convention.
1365
+ try {
1366
+ assertOperationLock();
1367
+ if (createAdr) mkdirVaultPath(vaultBase, join(vaultBase, adrDirRel), { label: 'pasta mensal de ADR' });
1368
+ } catch (error) {
1369
+ return publishedFailure(operationFailureCode(error, 'PROV_ARCHIVE_ADR_DIRECTORY_FAILED'));
1370
+ }
1371
+ const capLine = promoted.length
1372
+ ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
1373
+ : '';
1374
+ const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
1375
+ // Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
1376
+ const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
1377
+ const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
1378
+ if (createAdr) {
1379
+ try {
1380
+ assertOperationLock();
1381
+ if (typeof faultInjection?.beforeAdrWrite === 'function') faultInjection.beforeAdrWrite({ destination: destAbs });
1382
+ writeVaultFileSync(vaultBase, join(vaultBase, adrRel), `---
1383
+ type: decision
1384
+ status: accepted
1385
+ date: ${dateStr}${flagLines}
1386
+ cssclasses:
1387
+ - topic-decision
1388
+ tags:
1389
+ - decisao
1390
+ ---
1391
+
1392
+ # ADR-${num} — ${slug}
1393
+
1394
+ ## Decisão
1395
+
1396
+ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
1397
+ `, 'utf8', { label: 'ADR da change arquivada' });
1398
+ assertOperationLock();
1399
+ }
1400
+ catch (error) {
1401
+ return publishedFailure(operationFailureCode(error, 'PROV_ARCHIVE_ADR_WRITE_FAILED'));
1402
+ }
1403
+ }
1404
+
1405
+ // Only clear the pointer when the archived change IS the active one — archiving some other
1406
+ // slug explicitly must not blank the pointer of a different, still-active change.
1407
+ try {
1408
+ assertOperationLock();
1409
+ if (typeof faultInjection?.beforePointerClear === 'function') faultInjection.beforePointerClear({ destination: destAbs });
1410
+ if (activeChange(vaultBase, { context }) === slug) clearActiveChange(vaultBase, { context });
1411
+ assertOperationLock();
1412
+ } catch (error) {
1413
+ return publishedFailure(operationFailureCode(error, 'PROV_ARCHIVE_POINTER_CLEAR_FAILED'));
1414
+ }
1415
+ if (existsSync(src)) {
1416
+ return publishedFailure('PROV_ARCHIVE_PUBLIC_NAMESPACE_RECREATED', { public_change_state: 'recreated' });
1417
+ }
1418
+ let finalDestinationDigest;
1419
+ try {
1420
+ finalDestinationDigest = archiveSourceDigest(destAbs);
1421
+ for (const capability of promoted) {
1422
+ const living = join(vaultBase, loc.folders.specs, `${capability}.md`);
1423
+ if (!existsSync(living)) throw Object.assign(new Error('spec ausente'), { code: 'PROV_ARCHIVE_SPEC_PUBLICATION_MISSING' });
1424
+ }
1425
+ if (createAdr && !existsSync(join(vaultBase, adrRel))) {
1426
+ throw Object.assign(new Error('ADR ausente'), { code: 'PROV_ARCHIVE_ADR_MISSING' });
1427
+ }
1428
+ if (activeChange(vaultBase, { context }) === slug) {
1429
+ throw Object.assign(new Error('pointer ativo'), { code: 'PROV_ARCHIVE_POINTER_NOT_CLEARED' });
1430
+ }
1431
+ if (typeof faultInjection?.beforeCompletedJournal === 'function') {
1432
+ faultInjection.beforeCompletedJournal({ destination: destAbs });
1433
+ }
1434
+ writeTransactionPhase('completed', {
1435
+ destination_digest: finalDestinationDigest,
1436
+ spec_changes: specChanges,
1437
+ spec_promotion_plan: specPromotionPlan,
1438
+ spec_promotion_state: specPromotionPlan ? 'applied' : 'not-required',
1439
+ });
1440
+ assertOperationLock();
1441
+ if (typeof faultInjection?.beforeFinalInvariant === 'function') {
1442
+ faultInjection.beforeFinalInvariant({ destination: destAbs });
1443
+ }
1444
+ if (existsSync(src) || archiveSourceDigest(destAbs) !== finalDestinationDigest) {
1445
+ throw Object.assign(new Error('invariante final divergiu'), { code: 'PROV_ARCHIVE_FINAL_INVARIANT_DIVERGED' });
1446
+ }
1447
+ assertOperationLock();
1448
+ } catch (error) {
1449
+ return publishedFailure(operationFailureCode(error, 'PROV_ARCHIVE_FINALIZATION_FAILED'));
1450
+ }
1451
+ return {
1452
+ ok: true,
1453
+ failing: [],
1454
+ operationId: transactionId,
1455
+ transactionPhase,
1456
+ transactionPendingCleanup: true,
1457
+ archivedRel: destRel,
1458
+ adrRel: createAdr ? adrRel : '',
1459
+ promoted,
1460
+ specWarnings,
1461
+ specChanges,
1462
+ linksRewritten,
1463
+ };
1464
+ }
1465
+
71
1466
  export function runChange(argv) {
72
1467
  const [sub, ...rest] = argv;
73
1468
  const vaultBase = resolveVault(rest);
74
1469
  const VALUE_FLAGS = new Set(['--vault', '--change', '--project', '--session']);
75
1470
  const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
76
1471
  const projectRoot = resolve(opt(rest, '--project') || process.cwd());
1472
+ const sessionId = opt(rest, '--session')
1473
+ || process.env.CODEX_THREAD_ID
1474
+ || process.env.CLAUDE_SESSION_ID
1475
+ || '';
77
1476
  let contextResolved = false;
78
1477
  let resolvedContext = null;
79
1478
  const context = () => {
80
1479
  if (contextResolved) return resolvedContext;
81
1480
  contextResolved = true;
82
- const sessionId = opt(rest, '--session')
83
- || process.env.CODEX_THREAD_ID
84
- || process.env.CLAUDE_SESSION_ID
85
- || '';
86
1481
  try {
87
1482
  resolvedContext = resolveCommandActiveContext({ vaultBase, projectRoot, sessionId });
88
1483
  return resolvedContext;
@@ -196,19 +1591,48 @@ export function runChange(argv) {
196
1591
  }
197
1592
  let evidence = null;
198
1593
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* sem evidência */ }
199
- if (evidence) for (const e of evidence) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id} (${e.severity || 'critical'})\n`);
1594
+ if (evidence) for (const e of evidenceSensors(evidence)) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id} (${e.severity || 'critical'})\n`);
200
1595
  else process.stdout.write('evidencia: ausente\n');
201
1596
  const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
202
1597
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
203
1598
  if (effective.errors.length || effective.missing.length) {
204
1599
  process.stdout.write(`spec efetiva: inválida (${[...effective.errors, ...effective.missing.map((id) => `req órfão ${id}`)].join('; ')})\n`);
205
1600
  }
1601
+ if (evidence) {
1602
+ let expected = {
1603
+ change_slug: slug,
1604
+ tasks_sha256: tasksHashOf(tarefasMd),
1605
+ effective_spec_sha256: `sha256:${effective.hash}`,
1606
+ };
1607
+ let unavailable = '';
1608
+ try {
1609
+ const ids = requiredSensors(tasks);
1610
+ const loaded = loadSensorsDetailed(projectRoot);
1611
+ expected = {
1612
+ ...expected,
1613
+ identity: resolveEvidenceIdentity({
1614
+ vaultBase, projectRoot, changeSlug: slug, sessionId, context: context(),
1615
+ }),
1616
+ snapshot: captureGitSnapshot(projectRoot),
1617
+ sensor_config_sha256: sensorConfigSha256(loaded.sensors, ids),
1618
+ };
1619
+ } catch (error) {
1620
+ unavailable = error.code || error.message;
1621
+ }
1622
+ const binding = evaluateEvidenceBinding(evidence, expected);
1623
+ process.stdout.write(`evidence-binding: ${binding.state}${binding.reasons.length ? ` (${binding.reasons.join('; ')})` : ''}${unavailable ? ` [current snapshot unavailable: ${unavailable}]` : ''}\n`);
1624
+ }
206
1625
  let verdict = null;
207
1626
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
208
1627
  if (!verdict) process.stdout.write(`verdict: ausente — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ' (verdict trivial automático)'}\n`);
209
1628
  else if (!reqIds.length) process.stdout.write(`verdict: ${verdict.ok === true ? 'ok (trivial)' : 'não-ok — re-verifique'}\n`);
210
1629
  else {
211
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd), effectiveSpecHash: effective.hash });
1630
+ const v = evaluateVerdict(verdict, reqIds, {
1631
+ tasksHash: tasksHashOf(tarefasMd),
1632
+ effectiveSpecHash: effective.hash,
1633
+ evidenceEnvelopeId: evidence?.schema_version === 2 ? evidence.envelope_id : undefined,
1634
+ evidenceBinding: evidence?.schema_version === 2 ? evidenceCheckoutBinding(evidence) : undefined,
1635
+ });
212
1636
  process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
213
1637
  }
214
1638
  try { process.stdout.write(`mutation-round: ${readFileSync(join(dir, '.mutation-round'), 'utf8').trim()}/3\n`); } catch { /* sem rodadas */ }
@@ -252,11 +1676,49 @@ export function runChange(argv) {
252
1676
  }
253
1677
 
254
1678
  if (sub === 'archive') {
1679
+ if (rest[0] === 'recover') {
1680
+ const operationId = rest[1] || '';
1681
+ const recoverySlug = opt(rest, '--change') || '';
1682
+ const specAction = opt(rest, '--spec-action');
1683
+ let payload;
1684
+ try {
1685
+ if (specAction) {
1686
+ payload = recoverArchiveSpecPromotionMutation(vaultBase, {
1687
+ operationId,
1688
+ slug: recoverySlug,
1689
+ action: specAction,
1690
+ });
1691
+ } else {
1692
+ payload = inspectArchiveRecovery(vaultBase, { operationId, slug: recoverySlug });
1693
+ }
1694
+ } catch (error) {
1695
+ payload = {
1696
+ ok: false,
1697
+ code: error?.code || 'PROV_ARCHIVE_RECOVERY_NOT_FOUND',
1698
+ operation: 'archive-recover',
1699
+ state: 'unproven',
1700
+ operation_id: /^[0-9a-f-]{36}$/i.test(operationId) ? operationId : null,
1701
+ change_slug: /^[a-z0-9][a-z0-9._-]*$/i.test(recoverySlug) ? recoverySlug : null,
1702
+ transaction_phase: null,
1703
+ blocker: error?.code || 'PROV_ARCHIVE_RECOVERY_NOT_FOUND',
1704
+ original_retained: null,
1705
+ publication_state: 'unknown',
1706
+ actions: ['verify-operation-id-and-change-slug'],
1707
+ };
1708
+ }
1709
+ if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1710
+ else process.stderr.write(`${payload.code}: operation=${payload.operation}; state=${payload.state}; operation_id=${payload.operation_id}; change_slug=${payload.change_slug}; transaction_phase=${payload.transaction_phase}; blocker=${payload.blocker}; original_retained=${payload.original_retained}; publication_state=${payload.publication_state}; actions=${JSON.stringify(payload.actions)}\n`);
1711
+ process.exit(1);
1712
+ }
255
1713
  const selectedContext = context();
256
1714
  const slug = slugArg() || activeChange(vaultBase, { context: selectedContext });
257
1715
  if (!slug) { process.stderr.write('wendkeep change archive: missing <slug> and no active change\n'); process.exit(2); }
1716
+ let archiveGateAssessment = null;
1717
+ let archiveAuthorization = null;
1718
+ let operationLock;
258
1719
  // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
259
1720
  const gate = (dir) => {
1721
+ operationLock.assertOwned();
260
1722
  // G0: um scaffold nunca preenchido não é uma mudança concluída — arquivar geraria um
261
1723
  // ADR falso. INESCAPÁVEL desde 0.31.0 (--force não pula — visto em produção: change
262
1724
  // 100% placeholder arquivada via --force mintou ADR falso). Saída legítima: abandon.
@@ -276,52 +1738,214 @@ export function runChange(argv) {
276
1738
  return { ok: false, failing: [`${open.length} tarefa(s) aberta(s) (ex.: ${open[0].id} ${open[0].text}) — conclua ou use --force`] };
277
1739
  }
278
1740
  const required = requiredSensors(tasks);
279
- // Evidence freshness: block if tarefas.md changed since verify sealed the evidence
280
- // (e.g. a sensor task added/edited after the last green verify).
281
- if (required.length) {
282
- let evHash = '';
283
- try { evHash = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim(); } catch { /* pre-seal evidence */ }
284
- if (evHash && evHash !== tasksHashOf(tarefasMd)) {
285
- return { ok: false, failing: ['evidência stale (tarefas.md mudou desde o último verify) — rode `wendkeep verify` de novo'] };
286
- }
287
- }
288
1741
  const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
289
1742
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
290
1743
  if (effective.errors.length) return { ok: false, failing: [`spec efetiva inválida: ${effective.errors.join('; ')}`] };
291
1744
  if (effective.missing.length) return { ok: false, failing: [formatOrphanReqs(effective.missing)] };
292
- let evidence = [];
1745
+ let evidence = null;
293
1746
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
294
- const s = evaluateGate(evidence, required);
295
- if (!s.ok) return s;
1747
+ const sensorEvidence = evidenceSensors(evidence);
1748
+ let verdict = null;
1749
+ try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
1750
+ let verification = null;
1751
+ try { verification = JSON.parse(readFileSync(join(dir, 'verificacao.json'), 'utf8')); } catch { /* none */ }
1752
+ const checkoutBinding = evidence?.schema_version === 2 ? evidenceCheckoutBinding(evidence) : null;
1753
+ let expected;
1754
+ try {
1755
+ const loaded = loadSensorsDetailed(projectRoot);
1756
+ expected = {
1757
+ change_slug: slug,
1758
+ identity: resolveEvidenceIdentity({
1759
+ vaultBase, projectRoot, changeSlug: slug, sessionId, context: selectedContext,
1760
+ }),
1761
+ snapshot: captureGitSnapshot(projectRoot),
1762
+ tasks_sha256: tasksHashOf(tarefasMd),
1763
+ effective_spec_sha256: `sha256:${effective.hash}`,
1764
+ sensor_config_sha256: sensorConfigSha256(loaded.sensors, required),
1765
+ };
1766
+ } catch (error) {
1767
+ const blocker = typeof error?.code === 'string' && /^[A-Z0-9_]+$/.test(error.code)
1768
+ ? error.code : 'PROV_EXPECTED_CONTEXT_UNAVAILABLE';
1769
+ archiveGateAssessment = {
1770
+ ok: false,
1771
+ state: 'unproven',
1772
+ reasonCodes: ['PROV_EXPECTED_CONTEXT_UNAVAILABLE'],
1773
+ diagnostics: [{ kind: 'archive', state: 'unproven', blocker }],
1774
+ repair: archiveRepair(slug),
1775
+ };
1776
+ return { ok: false, failing: [provenanceBlock(archiveGateAssessment)] };
1777
+ }
1778
+ const contract = archiveContract({ slug, tarefasMd, tasks, effective, sensorEvidence });
1779
+ const provenance = evaluateProvenanceGate({
1780
+ purpose: 'archive',
1781
+ assessments: {
1782
+ envelope: classifyEvidenceEnvelope({ evidence, expected }),
1783
+ package: provenanceAssessment('package', verification, evidence, expected, contract),
1784
+ verdict: provenanceAssessment('verdict', verdict, evidence, expected, contract),
1785
+ },
1786
+ requiredKinds: ['envelope', 'package', 'verdict'],
1787
+ });
1788
+ provenance.repair = archiveRepair(slug);
1789
+ if (!provenance.ok) {
1790
+ archiveGateAssessment = provenance;
1791
+ return { ok: false, failing: [provenanceBlock(provenance)] };
1792
+ }
296
1793
  // Verdict SEMPRE exigido (0.31.0) — a exigência universal vive AQUI no gate; a semântica
297
1794
  // reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
298
1795
  // status` dependem dela. Change sem [req:] destrava com o auto-verdict do verify --deep.
299
- let verdict = null;
300
- try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
301
- const hash = tasksHashOf(tarefasMd);
302
1796
  if (!verdict) {
303
1797
  return { ok: false, failing: [reqIds.length
304
1798
  ? 'sem verdict — rode `wendkeep verify --deep` + skill wk-verify'
305
1799
  : 'sem verdict — rode `wendkeep verify --deep` (verdict trivial automático)'] };
306
1800
  }
307
1801
  if (verdict.ok !== true) return { ok: false, failing: ['verdict não-ok — re-verifique a change antes de arquivar'] };
308
- if (verdict.tasksHash && verdict.tasksHash !== hash) {
309
- return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
1802
+ const s = evaluateGate(sensorEvidence, required);
1803
+ if (!s.ok) return s;
1804
+ const v = evaluateVerdict(verdict, reqIds, {
1805
+ tasksHash: contract.tasksHash,
1806
+ effectiveSpecHash: contract.effectiveSpecHash,
1807
+ evidenceEnvelopeId: evidence?.schema_version === 2 ? evidence.envelope_id : undefined,
1808
+ evidenceBinding: checkoutBinding || undefined,
1809
+ });
1810
+ if (!v.ok) {
1811
+ if (v.stale) return { ok: false, failing: ['verdict stale — re-verifique: `wendkeep verify --deep` + wk-verify'] };
1812
+ return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
310
1813
  }
311
- let verification = null;
312
- try { verification = JSON.parse(readFileSync(join(dir, 'verificacao.json'), 'utf8')); } catch { /* none */ }
313
- if (verification?.effectiveSpecHash && verification.effectiveSpecHash !== effective.hash) {
314
- return { ok: false, failing: ['pacote de verificação stale (spec efetiva mudou) — rode `wendkeep verify --deep` novamente'] };
1814
+
1815
+ // Recapture every mutable input at the last possible point before authorizing the archive.
1816
+ // This narrows the verify→archive TOCTOU window without trusting the first read above.
1817
+ let finalEvidence;
1818
+ let finalVerification;
1819
+ let finalVerdict;
1820
+ let finalExpected;
1821
+ let finalContract;
1822
+ let finalRequired;
1823
+ let finalReqIds;
1824
+ try {
1825
+ const finalPlaceholders = scaffoldPlaceholders(dir);
1826
+ if (finalPlaceholders.length) throw Object.assign(new Error('scaffold mudou durante o gate'), { code: 'PROV_ARCHIVE_INPUT_CHANGED' });
1827
+ const finalImpact = validateSpecImpact(dir);
1828
+ if (!finalImpact.ok) throw Object.assign(new Error(finalImpact.errors.join('; ')), { code: 'PROV_ARCHIVE_INPUT_CHANGED' });
1829
+ const finalTarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8');
1830
+ const finalTasks = parseTasks(finalTarefasMd);
1831
+ if (finalTasks.some((task) => !task.done) && !rest.includes('--force')) {
1832
+ throw Object.assign(new Error('tarefas abertas surgiram durante o gate'), { code: 'PROV_ARCHIVE_INPUT_CHANGED' });
1833
+ }
1834
+ finalRequired = requiredSensors(finalTasks);
1835
+ finalReqIds = [...new Set(finalTasks.flatMap((task) => task.reqs ?? []))];
1836
+ const finalEffective = buildEffectiveRequirementPackage(vaultBase, dir, finalReqIds);
1837
+ if (finalEffective.errors.length || finalEffective.missing.length) {
1838
+ throw Object.assign(new Error('spec efetiva mudou durante o gate'), { code: 'PROV_ARCHIVE_INPUT_CHANGED' });
1839
+ }
1840
+ finalEvidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8'));
1841
+ finalVerification = JSON.parse(readFileSync(join(dir, 'verificacao.json'), 'utf8'));
1842
+ finalVerdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8'));
1843
+ const finalLoaded = loadSensorsDetailed(projectRoot);
1844
+ finalExpected = {
1845
+ change_slug: slug,
1846
+ identity: resolveEvidenceIdentity({
1847
+ vaultBase, projectRoot, changeSlug: slug, sessionId, context: selectedContext,
1848
+ }),
1849
+ snapshot: captureGitSnapshot(projectRoot),
1850
+ tasks_sha256: tasksHashOf(finalTarefasMd),
1851
+ effective_spec_sha256: `sha256:${finalEffective.hash}`,
1852
+ sensor_config_sha256: sensorConfigSha256(finalLoaded.sensors, finalRequired),
1853
+ };
1854
+ finalContract = archiveContract({
1855
+ slug,
1856
+ tarefasMd: finalTarefasMd,
1857
+ tasks: finalTasks,
1858
+ effective: finalEffective,
1859
+ sensorEvidence: evidenceSensors(finalEvidence),
1860
+ });
1861
+ } catch (error) {
1862
+ archiveGateAssessment = {
1863
+ ok: false,
1864
+ state: 'stale',
1865
+ reasonCodes: [error.code || 'PROV_ARCHIVE_FINAL_SNAPSHOT_FAILED'],
1866
+ diagnostics: [{ kind: 'archive', state: 'stale', blocker: error.code || 'final snapshot failed' }],
1867
+ repair: archiveRepair(slug),
1868
+ };
1869
+ return { ok: false, failing: [provenanceBlock(archiveGateAssessment)] };
315
1870
  }
316
- if (reqIds.length && verification?.effectiveSpecHash && !verdict.effectiveSpecHash) {
317
- return { ok: false, failing: ['verdict sem effectiveSpecHash — rode a skill wk-verify novamente'] };
1871
+ const finalProvenance = evaluateProvenanceGate({
1872
+ purpose: 'archive',
1873
+ assessments: {
1874
+ envelope: classifyEvidenceEnvelope({ evidence: finalEvidence, expected: finalExpected }),
1875
+ package: provenanceAssessment('package', finalVerification, finalEvidence, finalExpected, finalContract),
1876
+ verdict: provenanceAssessment('verdict', finalVerdict, finalEvidence, finalExpected, finalContract),
1877
+ },
1878
+ requiredKinds: ['envelope', 'package', 'verdict'],
1879
+ });
1880
+ finalProvenance.repair = archiveRepair(slug);
1881
+ const finalSensors = evaluateGate(evidenceSensors(finalEvidence), finalRequired);
1882
+ const finalVerdictResult = evaluateVerdict(finalVerdict, finalReqIds, {
1883
+ tasksHash: finalContract.tasksHash,
1884
+ effectiveSpecHash: finalContract.effectiveSpecHash,
1885
+ evidenceEnvelopeId: finalEvidence.envelope_id,
1886
+ evidenceBinding: evidenceCheckoutBinding(finalEvidence),
1887
+ });
1888
+ if (!finalProvenance.ok || !finalSensors.ok || !finalVerdictResult.ok) {
1889
+ archiveGateAssessment = finalProvenance.ok ? {
1890
+ ok: false,
1891
+ state: 'stale',
1892
+ reasonCodes: ['PROV_ARCHIVE_FINAL_SNAPSHOT_STALE'],
1893
+ diagnostics: [{ kind: 'archive', state: 'stale', blocker: 'final snapshot diverged' }],
1894
+ repair: archiveRepair(slug),
1895
+ } : finalProvenance;
1896
+ return { ok: false, failing: [provenanceBlock(archiveGateAssessment)] };
318
1897
  }
319
- if (reqIds.length) {
320
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash, effectiveSpecHash: effective.hash });
321
- if (!v.ok) {
322
- if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
323
- return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
324
- }
1898
+ try {
1899
+ operationLock.assertOwned();
1900
+ appendArchiveAuthorization({
1901
+ projectRoot,
1902
+ slug,
1903
+ expected: finalExpected,
1904
+ contract: finalContract,
1905
+ evidence: finalEvidence,
1906
+ verification: finalVerification,
1907
+ verdict: finalVerdict,
1908
+ required: finalRequired,
1909
+ reqIds: finalReqIds,
1910
+ forced,
1911
+ });
1912
+ operationLock.assertOwned();
1913
+ } catch (error) {
1914
+ archiveGateAssessment = {
1915
+ ok: false,
1916
+ code: error?.code || 'WENDKEEP_ARCHIVE_RECEIPT_UNAVAILABLE',
1917
+ state: ['WENDKEEP_RECEIPT_LEDGER_CORRUPT', 'WENDKEEP_RECEIPT_LEDGER_TRUNCATED'].includes(error?.code)
1918
+ ? 'conflict' : 'reported',
1919
+ reasonCodes: [error?.code || 'WENDKEEP_ARCHIVE_RECEIPT_UNAVAILABLE'],
1920
+ diagnostics: [{ kind: 'archive-receipt', state: 'conflict', blocker: error?.code || 'receipt unavailable' }],
1921
+ repair: archiveRepair(slug),
1922
+ };
1923
+ return { ok: false, failing: [provenanceBlock(archiveGateAssessment)] };
1924
+ }
1925
+ // The receipt append is durable I/O and therefore widens the race window. Seal the exact
1926
+ // authorized state, re-read it now, and let archiveChange re-read it again at its commit seam.
1927
+ archiveAuthorization = {
1928
+ expected: finalExpected,
1929
+ contract: finalContract,
1930
+ evidence: finalEvidence,
1931
+ verification: finalVerification,
1932
+ verdict: finalVerdict,
1933
+ required: finalRequired,
1934
+ reqIds: finalReqIds,
1935
+ };
1936
+ const postReceipt = recaptureArchiveAuthorization({
1937
+ dir, vaultBase, projectRoot, slug, sessionId, selectedContext, forced,
1938
+ authorized: archiveAuthorization,
1939
+ });
1940
+ if (!postReceipt.ok) {
1941
+ archiveGateAssessment = {
1942
+ ok: false,
1943
+ state: 'stale',
1944
+ reasonCodes: postReceipt.failing,
1945
+ diagnostics: [{ kind: 'archive', state: 'stale', blocker: postReceipt.failing[0] }],
1946
+ repair: archiveRepair(slug),
1947
+ };
1948
+ return { ok: false, failing: [provenanceBlock(archiveGateAssessment)] };
325
1949
  }
326
1950
  return { ok: true, failing: [] };
327
1951
  };
@@ -335,12 +1959,150 @@ export function runChange(argv) {
335
1959
  if (trivial) process.stderr.write(compactGuide
336
1960
  ? 'aviso: GUIDE compacta sem [req:]/[sensor:] — resultado permanece auditável no archive, sem ADR automático\n'
337
1961
  : 'aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
338
- const r = archiveChange(vaultBase, slug, {
339
- dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate,
340
- adrFlags: { forced, trivial }, context: selectedContext,
341
- });
1962
+ try {
1963
+ operationLock = acquireArchiveOperationLock({
1964
+ lockPath: join(vaultBase, '.brain', 'runtime', 'change-archive-operation.lock'),
1965
+ });
1966
+ } catch (error) {
1967
+ archiveGateAssessment = {
1968
+ ok: false,
1969
+ code: error?.code || 'WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE',
1970
+ state: error?.code === 'WENDKEEP_ARCHIVE_BUSY' ? 'conflict' : 'unproven',
1971
+ reasonCodes: [error?.code || 'WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE'],
1972
+ diagnostics: [{
1973
+ kind: 'archive-lock', state: 'conflict', blocker: error?.code || 'WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE',
1974
+ expected: { owner_state: 'available' },
1975
+ observed: { owner_state: error?.owner_state || 'unavailable' },
1976
+ }],
1977
+ recovery: error?.code === 'WENDKEEP_ARCHIVE_BUSY'
1978
+ ? archiveRetryRepair(slug).command
1979
+ : archiveManualRecovery(slug).explanation,
1980
+ repair: error?.code === 'WENDKEEP_ARCHIVE_BUSY'
1981
+ ? archiveRetryRepair(slug)
1982
+ : archiveManualRecovery(slug),
1983
+ };
1984
+ const payload = archiveJsonFailure(slug, archiveGateAssessment);
1985
+ if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1986
+ else process.stderr.write(`${archiveFailureText(payload)}\n`);
1987
+ process.exit(1);
1988
+ }
1989
+ let r;
1990
+ let releaseError = null;
1991
+ try {
1992
+ r = archiveChangeMutation(vaultBase, slug, {
1993
+ dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate,
1994
+ assertOperationLock: () => operationLock.assertOwned(),
1995
+ authorizationEnvelope: () => ({
1996
+ schema_version: 2,
1997
+ purpose: 'archive',
1998
+ change_slug: slug,
1999
+ authorization: archiveAuthorization,
2000
+ }),
2001
+ preMutate: (dir) => {
2002
+ operationLock.assertOwned();
2003
+ const sourceDigestBefore = archiveSourceDigest(dir);
2004
+ operationLock.assertOwned();
2005
+ const commitCheck = recaptureArchiveAuthorization({
2006
+ dir, vaultBase, projectRoot, slug, sessionId, selectedContext, forced,
2007
+ authorized: archiveAuthorization,
2008
+ });
2009
+ if (commitCheck.ok) {
2010
+ operationLock.assertOwned();
2011
+ const sourceDigestAfter = archiveSourceDigest(dir);
2012
+ operationLock.assertOwned();
2013
+ if (sourceDigestBefore !== sourceDigestAfter) {
2014
+ return { ok: false, failing: ['PROV_ARCHIVE_INPUT_CHANGED'] };
2015
+ }
2016
+ return { ...commitCheck, sourceDigest: sourceDigestBefore };
2017
+ }
2018
+ archiveGateAssessment = {
2019
+ ok: false,
2020
+ state: 'stale',
2021
+ reasonCodes: commitCheck.failing,
2022
+ diagnostics: [{ kind: 'archive', state: 'stale', blocker: commitCheck.failing[0] }],
2023
+ repair: archiveRepair(slug),
2024
+ };
2025
+ return { ok: false, failing: [provenanceBlock(archiveGateAssessment)] };
2026
+ },
2027
+ adrFlags: { forced, trivial }, context: selectedContext,
2028
+ });
2029
+ } catch (error) {
2030
+ r = {
2031
+ ok: false,
2032
+ failing: [error?.code || 'WENDKEEP_ARCHIVE_OPERATION_FAILED'],
2033
+ published: false,
2034
+ };
2035
+ } finally {
2036
+ try { operationLock.release(); }
2037
+ catch (error) { releaseError = error; }
2038
+ }
2039
+ if (releaseError) {
2040
+ const published = Boolean(r?.ok || r?.published);
2041
+ const code = releaseError?.code || 'WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST';
2042
+ archiveGateAssessment = {
2043
+ ok: false,
2044
+ code,
2045
+ state: 'conflict',
2046
+ reasonCodes: [code],
2047
+ diagnostics: [{
2048
+ kind: 'archive-lock', state: 'conflict', blocker: code,
2049
+ expected: { owner_state: 'held' },
2050
+ observed: { owner_state: 'lost', publication_state: published ? 'published-recovery-required' : 'not-published' },
2051
+ }],
2052
+ recovery: archiveManualRecovery(slug, published).explanation,
2053
+ repair: archiveManualRecovery(slug, published),
2054
+ };
2055
+ archiveGateAssessment.diagnostics[0].observed.operation_id = r?.recovery?.operation_id || r?.operationId || null;
2056
+ archiveGateAssessment.diagnostics[0].observed.transaction_phase = r?.recovery?.phase || r?.transactionPhase || null;
2057
+ r = { ...r, ok: false, failing: [code], published };
2058
+ }
2059
+ if (!releaseError && r?.ok && r.transactionPendingCleanup) {
2060
+ try {
2061
+ const finalized = finalizeArchiveTransaction(vaultBase, { operationId: r.operationId, slug });
2062
+ r.transactionPendingCleanup = false;
2063
+ r.transactionRetained = finalized?.retained === true;
2064
+ } catch (error) {
2065
+ const code = error?.code || 'PROV_ARCHIVE_TRANSACTION_CLEANUP_FAILED';
2066
+ r = { ...r, ok: false, failing: [code], published: true };
2067
+ }
2068
+ }
342
2069
  if (!r.ok) {
343
- process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
2070
+ const code = r.failing?.[0];
2071
+ if (r.published || code === 'PROV_ARCHIVE_ROLLBACK_COLLISION' || code === 'PROV_ARCHIVE_ROLLBACK_FAILED'
2072
+ || code === 'PROV_ARCHIVE_RECOVERY_REQUIRED'
2073
+ || code === 'PROV_ARCHIVE_RECOVERY_JOURNAL_INVALID'
2074
+ || code === 'PROV_ARCHIVE_PUBLIC_NAMESPACE_RECREATED'
2075
+ || code === 'WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST') {
2076
+ const published = Boolean(r.published);
2077
+ const operationId = r.recovery?.operation_id || r.operationId || null;
2078
+ const phase = r.recovery?.phase || r.transactionPhase || null;
2079
+ const manualRecovery = archiveManualRecovery(slug, published, { operationId, phase });
2080
+ archiveGateAssessment = {
2081
+ ok: false,
2082
+ code,
2083
+ state: 'conflict',
2084
+ reasonCodes: [code],
2085
+ diagnostics: [{
2086
+ kind: 'archive-transaction', state: 'conflict', blocker: code,
2087
+ expected: { public_change: 'absent', lock_owner: 'held' },
2088
+ observed: {
2089
+ public_change: code === 'PROV_ARCHIVE_ROLLBACK_COLLISION' ? 'recreated' : 'unknown',
2090
+ original_state: r.recovery?.kind === 'retained-original' ? 'retained' : 'unknown',
2091
+ publication_state: published ? 'published-recovery-required' : 'not-published',
2092
+ operation_id: operationId,
2093
+ transaction_phase: phase,
2094
+ },
2095
+ }],
2096
+ recovery: manualRecovery.explanation,
2097
+ repair: manualRecovery,
2098
+ };
2099
+ }
2100
+ const payload = archiveJsonFailure(slug, archiveGateAssessment, r.failing);
2101
+ if (rest.includes('--json')) {
2102
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
2103
+ } else {
2104
+ process.stderr.write(`${archiveFailureText(payload)}\n`);
2105
+ }
344
2106
  process.exit(1);
345
2107
  }
346
2108
  try {
@@ -367,8 +2129,30 @@ export function runChange(argv) {
367
2129
  }
368
2130
  }
369
2131
  } catch { /* Observer é fail-open; reconcile recupera qualquer enqueue perdido. */ }
370
- process.stdout.write(`archived: ${r.archivedRel}${r.adrRel ? `; ADR: ${r.adrRel}` : '; GUIDE compacta: sem ADR'}\n`);
371
- if (r.promoted && r.promoted.length) process.stdout.write(`specs promovidas: ${r.promoted.join(', ')}\n`);
2132
+ const successPayload = {
2133
+ ok: true,
2134
+ code: 'WENDKEEP_CHANGE_ARCHIVED',
2135
+ operation: 'archive',
2136
+ state: 'verified',
2137
+ reason_codes: [],
2138
+ blocker: null,
2139
+ expected: { change_slug: slug },
2140
+ observed: { archived_rel: r.archivedRel, adr_rel: r.adrRel || null, promoted: r.promoted || [] },
2141
+ recovery: null,
2142
+ diagnostics: [],
2143
+ repair: null,
2144
+ archived_rel: r.archivedRel,
2145
+ adr_rel: r.adrRel || null,
2146
+ promoted: r.promoted || [],
2147
+ };
2148
+ if (rest.includes('--json')) {
2149
+ process.stdout.write(`${JSON.stringify(successPayload, null, 2)}\n`);
2150
+ } else {
2151
+ process.stdout.write(`${successPayload.code}: operation=${successPayload.operation}; state=${successPayload.state}; blocker=null; expected=${JSON.stringify(successPayload.expected)}; observed=${JSON.stringify(successPayload.observed)}; recovery=null; reason_codes=[]; diagnostics=[]; repair=null; archived: ${r.archivedRel}${r.adrRel ? `; ADR: ${r.adrRel}` : '; GUIDE compacta: sem ADR'}\n`);
2152
+ }
2153
+ if (!rest.includes('--json') && r.promoted && r.promoted.length) {
2154
+ process.stdout.write(`specs promovidas: ${r.promoted.join(', ')}\n`);
2155
+ }
372
2156
  if (r.specWarnings && r.specWarnings.length) for (const w of r.specWarnings) process.stderr.write(` aviso spec: ${w}\n`);
373
2157
  process.exit(0);
374
2158
  }