wendkeep 0.78.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.en.md +58 -3
  3. package/README.md +58 -3
  4. package/docs/en/commands/changes-and-verification.md +116 -1
  5. package/docs/en/commands/operating-profiles.md +49 -5
  6. package/docs/en/commands/sessions-and-import.md +6 -0
  7. package/docs/en/commands/verify.md +54 -0
  8. package/docs/en/commands/worktrees.md +39 -4
  9. package/docs/pt-BR/commands/changes-and-verification.md +115 -1
  10. package/docs/pt-BR/commands/operating-profiles.md +51 -5
  11. package/docs/pt-BR/commands/sessions-and-import.md +7 -0
  12. package/docs/pt-BR/commands/verify.md +53 -0
  13. package/docs/pt-BR/commands/worktrees.md +38 -3
  14. package/hooks/active-context-store.mjs +530 -2
  15. package/hooks/change-core.mjs +220 -123
  16. package/hooks/obsidian-common.mjs +175 -9
  17. package/hooks/session-stop.mjs +40 -1
  18. package/hooks/spec-core.mjs +93 -29
  19. package/package.json +2 -2
  20. package/packages/cli/src/index.mjs +7 -0
  21. package/packages/vault/src/memory-handoff.mjs +15 -0
  22. package/schema/artifact-manifest-v1.schema.json +35 -0
  23. package/schema/handoff-contract-v1.schema.json +37 -0
  24. package/schema/task-contract-v1.schema.json +57 -0
  25. package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
  26. package/src/archive-operation-lock.mjs +235 -0
  27. package/src/change.mjs +1780 -79
  28. package/src/delivery.mjs +724 -67
  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/task-contracts.mjs +510 -0
  35. package/src/task-leases.mjs +105 -0
  36. package/src/task.mjs +115 -0
  37. package/src/verify.mjs +32 -0
  38. package/src/worktree-cleanup.mjs +1733 -118
  39. package/src/worktree.mjs +94 -5
package/src/change.mjs CHANGED
@@ -1,27 +1,37 @@
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
32
  import { evaluateGate, loadSensorsDetailed, 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';
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';
@@ -32,11 +42,364 @@ import {
32
42
  sensorConfigSha256,
33
43
  } from './evidence-envelope.mjs';
34
44
  import {
45
+ canonicalSha256,
35
46
  evaluateEvidenceBinding,
36
47
  evidenceCheckoutBinding,
37
48
  evidenceCheckoutBindingMatches,
38
49
  evidenceSensors,
39
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
+ }
40
403
 
41
404
  function observerMarkdownUnder(vaultBase, relativeRoot) {
42
405
  const output = [];
@@ -79,6 +442,1027 @@ function today() {
79
442
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
80
443
  }
81
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
+
82
1466
  export function runChange(argv) {
83
1467
  const [sub, ...rest] = argv;
84
1468
  const vaultBase = resolveVault(rest);
@@ -292,11 +1676,49 @@ export function runChange(argv) {
292
1676
  }
293
1677
 
294
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
+ }
295
1713
  const selectedContext = context();
296
1714
  const slug = slugArg() || activeChange(vaultBase, { context: selectedContext });
297
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;
298
1719
  // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
299
1720
  const gate = (dir) => {
1721
+ operationLock.assertOwned();
300
1722
  // G0: um scaffold nunca preenchido não é uma mudança concluída — arquivar geraria um
301
1723
  // ADR falso. INESCAPÁVEL desde 0.31.0 (--force não pula — visto em produção: change
302
1724
  // 100% placeholder arquivada via --force mintou ADR falso). Saída legítima: abandon.
@@ -316,15 +1738,6 @@ export function runChange(argv) {
316
1738
  return { ok: false, failing: [`${open.length} tarefa(s) aberta(s) (ex.: ${open[0].id} ${open[0].text}) — conclua ou use --force`] };
317
1739
  }
318
1740
  const required = requiredSensors(tasks);
319
- // Evidence freshness: block if tarefas.md changed since verify sealed the evidence
320
- // (e.g. a sensor task added/edited after the last green verify).
321
- if (required.length) {
322
- let evHash = '';
323
- try { evHash = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim(); } catch { /* pre-seal evidence */ }
324
- if (evHash && evHash !== tasksHashOf(tarefasMd)) {
325
- return { ok: false, failing: ['evidência stale (tarefas.md mudou desde o último verify) — rode `wendkeep verify` de novo'] };
326
- }
327
- }
328
1741
  const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
329
1742
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
330
1743
  if (effective.errors.length) return { ok: false, failing: [`spec efetiva inválida: ${effective.errors.join('; ')}`] };
@@ -332,79 +1745,207 @@ export function runChange(argv) {
332
1745
  let evidence = null;
333
1746
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
334
1747
  const sensorEvidence = evidenceSensors(evidence);
335
- if (required.length && (!evidence || evidence.schema_version !== 2)) {
336
- return { ok: false, failing: ['evidência legacy-unbound não satisfaz autoridade v2 rode `wendkeep verify` novamente'] };
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)] };
337
1777
  }
338
- if (evidence?.schema_version === 2) {
339
- let currentBinding;
340
- try {
341
- const loaded = loadSensorsDetailed(projectRoot);
342
- currentBinding = evaluateEvidenceBinding(evidence, {
343
- change_slug: slug,
344
- identity: resolveEvidenceIdentity({
345
- vaultBase, projectRoot, changeSlug: slug, sessionId, context: selectedContext,
346
- }),
347
- snapshot: captureGitSnapshot(projectRoot),
348
- tasks_sha256: tasksHashOf(tarefasMd),
349
- effective_spec_sha256: `sha256:${effective.hash}`,
350
- sensor_config_sha256: sensorConfigSha256(loaded.sensors, required),
351
- });
352
- } catch (error) {
353
- return { ok: false, failing: [`binding atual indisponível (${error.code || error.message}) — recupere o contexto e rode \`wendkeep verify\` novamente`] };
354
- }
355
- if (currentBinding.state !== 'bound') {
356
- return { ok: false, failing: [`evidência ${currentBinding.state} (${currentBinding.reasons.join('; ')}) — rode \`wendkeep verify\` novamente`] };
357
- }
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)] };
358
1792
  }
359
- const s = evaluateGate(sensorEvidence, required);
360
- if (!s.ok) return s;
361
1793
  // Verdict SEMPRE exigido (0.31.0) — a exigência universal vive AQUI no gate; a semântica
362
1794
  // reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
363
1795
  // status` dependem dela. Change sem [req:] destrava com o auto-verdict do verify --deep.
364
- let verdict = null;
365
- try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
366
- const hash = tasksHashOf(tarefasMd);
367
1796
  if (!verdict) {
368
1797
  return { ok: false, failing: [reqIds.length
369
1798
  ? 'sem verdict — rode `wendkeep verify --deep` + skill wk-verify'
370
1799
  : 'sem verdict — rode `wendkeep verify --deep` (verdict trivial automático)'] };
371
1800
  }
372
1801
  if (verdict.ok !== true) return { ok: false, failing: ['verdict não-ok — re-verifique a change antes de arquivar'] };
373
- if (verdict.tasksHash && verdict.tasksHash !== hash) {
374
- return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
375
- }
376
- let verification = null;
377
- try { verification = JSON.parse(readFileSync(join(dir, 'verificacao.json'), 'utf8')); } catch { /* none */ }
378
- const checkoutBinding = evidence?.schema_version === 2 ? evidenceCheckoutBinding(evidence) : null;
379
- if (evidence?.schema_version === 2 && verification?.evidenceEnvelopeId !== evidence.envelope_id) {
380
- return { ok: false, failing: ['pacote de verificação não está ligado ao envelope atual — rode `wendkeep verify --deep` novamente'] };
381
- }
382
- if (checkoutBinding && !evidenceCheckoutBindingMatches(verification?.evidenceBinding, checkoutBinding)) {
383
- return { ok: false, failing: ['binding do pacote de verificação diverge do checkout provado — rode `wendkeep verify --deep` novamente'] };
384
- }
385
- if (evidence?.schema_version === 2 && verdict.evidenceEnvelopeId !== evidence.envelope_id) {
386
- return { ok: false, failing: [`verdict não está ligado ao envelope atual — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
387
- }
388
- if (checkoutBinding && !evidenceCheckoutBindingMatches(verdict.evidenceBinding, checkoutBinding)) {
389
- return { ok: false, failing: [`binding do verdict diverge do checkout provado — rode \`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(', ')}`] };
390
1813
  }
391
- if (verification?.effectiveSpecHash && verification.effectiveSpecHash !== effective.hash) {
392
- 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)] };
393
1870
  }
394
- if (reqIds.length && verification?.effectiveSpecHash && !verdict.effectiveSpecHash) {
395
- 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)] };
396
1897
  }
397
- if (reqIds.length) {
398
- const v = evaluateVerdict(verdict, reqIds, {
399
- tasksHash: hash,
400
- effectiveSpecHash: effective.hash,
401
- evidenceEnvelopeId: evidence?.schema_version === 2 ? evidence.envelope_id : undefined,
402
- evidenceBinding: checkoutBinding || undefined,
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,
403
1911
  });
404
- if (!v.ok) {
405
- if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
406
- return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
407
- }
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)] };
408
1949
  }
409
1950
  return { ok: true, failing: [] };
410
1951
  };
@@ -418,12 +1959,150 @@ export function runChange(argv) {
418
1959
  if (trivial) process.stderr.write(compactGuide
419
1960
  ? 'aviso: GUIDE compacta sem [req:]/[sensor:] — resultado permanece auditável no archive, sem ADR automático\n'
420
1961
  : 'aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
421
- const r = archiveChange(vaultBase, slug, {
422
- dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate,
423
- adrFlags: { forced, trivial }, context: selectedContext,
424
- });
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
+ }
425
2069
  if (!r.ok) {
426
- 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
+ }
427
2106
  process.exit(1);
428
2107
  }
429
2108
  try {
@@ -450,8 +2129,30 @@ export function runChange(argv) {
450
2129
  }
451
2130
  }
452
2131
  } catch { /* Observer é fail-open; reconcile recupera qualquer enqueue perdido. */ }
453
- process.stdout.write(`archived: ${r.archivedRel}${r.adrRel ? `; ADR: ${r.adrRel}` : '; GUIDE compacta: sem ADR'}\n`);
454
- 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
+ }
455
2156
  if (r.specWarnings && r.specWarnings.length) for (const w of r.specWarnings) process.stderr.write(` aviso spec: ${w}\n`);
456
2157
  process.exit(0);
457
2158
  }