wendkeep 0.78.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.en.md +57 -2
- package/README.md +57 -2
- package/docs/en/commands/changes-and-verification.md +66 -1
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/verify.md +45 -0
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +65 -1
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/verify.md +45 -0
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +201 -122
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/spec-core.mjs +93 -29
- package/package.json +2 -2
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1780 -79
- package/src/delivery.mjs +724 -67
- package/src/memory.mjs +2 -1
- package/src/provenance-gate.mjs +575 -0
- package/src/provenance-sources.mjs +547 -0
- package/src/receipt-ledger.mjs +841 -0
- package/src/release-provenance.mjs +48 -0
- package/src/worktree-cleanup.mjs +1733 -118
- 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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return { ok: false, failing: [
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
}
|