wendkeep 0.58.3 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.en.md +45 -3
  3. package/README.md +45 -3
  4. package/bin/wendkeep.mjs +54 -6
  5. package/docs/en/commands/changes-and-verification.md +9 -3
  6. package/docs/en/commands/getting-started.md +7 -3
  7. package/docs/en/commands/memory.md +20 -2
  8. package/docs/en/commands/operating-profiles.md +173 -0
  9. package/docs/en/commands/sessions-and-import.md +8 -4
  10. package/docs/en/commands/verify.md +12 -6
  11. package/docs/pt-BR/commands/changes-and-verification.md +9 -4
  12. package/docs/pt-BR/commands/getting-started.md +7 -3
  13. package/docs/pt-BR/commands/memory.md +18 -2
  14. package/docs/pt-BR/commands/operating-profiles.md +171 -0
  15. package/docs/pt-BR/commands/sessions-and-import.md +7 -3
  16. package/docs/pt-BR/commands/verify.md +11 -5
  17. package/hooks/brain-core.mjs +159 -159
  18. package/hooks/brain-inject.mjs +83 -26
  19. package/hooks/brain-recall.mjs +32 -32
  20. package/hooks/brain-reindex.mjs +13 -13
  21. package/hooks/change-context.mjs +24 -10
  22. package/hooks/change-core.mjs +174 -37
  23. package/hooks/change-guard.mjs +115 -16
  24. package/hooks/change-nag.mjs +20 -5
  25. package/hooks/change-warn.mjs +27 -9
  26. package/hooks/decision-capture.mjs +1 -1
  27. package/hooks/derived-sections.mjs +1 -1
  28. package/hooks/flow-core.mjs +891 -0
  29. package/hooks/flow-protected-policy.mjs +218 -0
  30. package/hooks/frontmatter-repair.mjs +3 -1
  31. package/hooks/git-snapshot.mjs +722 -0
  32. package/hooks/import-sessions.mjs +10 -5
  33. package/hooks/memory-mode.mjs +63 -13
  34. package/hooks/memory-store.mjs +309 -69
  35. package/hooks/obsidian-common.mjs +39 -55
  36. package/hooks/operating-profile-runtime.mjs +157 -0
  37. package/hooks/plan-capture.mjs +14 -3
  38. package/hooks/sensors-core.mjs +15 -3
  39. package/hooks/session-backfill.mjs +7 -2
  40. package/hooks/session-ensure.mjs +6 -4
  41. package/hooks/session-iteration.mjs +65 -0
  42. package/hooks/session-memory-lifecycle.mjs +10 -5
  43. package/hooks/session-note-io.mjs +130 -15
  44. package/hooks/session-observability.mjs +4 -2
  45. package/hooks/session-stop.mjs +65 -19
  46. package/hooks/spec-core.mjs +91 -12
  47. package/hooks/subagent-stop.mjs +4 -1
  48. package/hooks/subagent-usage.mjs +2 -2
  49. package/hooks/task-log.mjs +3 -1
  50. package/hooks/token-usage.mjs +1 -1
  51. package/hooks/vault-health.mjs +183 -37
  52. package/hooks/vault-path-safety.mjs +2 -0
  53. package/hooks/vault-runtime-store.mjs +558 -0
  54. package/package.json +10 -3
  55. package/packages/cli/package.json +5 -0
  56. package/packages/harness/package.json +5 -0
  57. package/packages/integrations/package.json +5 -0
  58. package/packages/mcp/package.json +5 -0
  59. package/packages/pi/package.json +5 -0
  60. package/packages/vault/package.json +6 -0
  61. package/packages/vault/src/index.mjs +2 -0
  62. package/packages/vault/src/project-vault.mjs +327 -0
  63. package/packages/vault/src/vault-path-safety.mjs +558 -0
  64. package/src/change.mjs +2 -1
  65. package/src/flow.mjs +232 -0
  66. package/src/init.mjs +26 -3
  67. package/src/memory.mjs +785 -35
  68. package/src/operating-profile.mjs +133 -0
  69. package/src/profile.mjs +224 -0
  70. package/src/project-vault.mjs +2 -221
  71. package/src/rebuild-costs.mjs +11 -4
  72. package/src/skills-seed.mjs +38 -16
  73. package/src/sync-defs.mjs +16 -7
  74. package/src/sync.mjs +9 -1
  75. package/src/taxonomy.mjs +8 -0
  76. package/src/validate-memory.mjs +21 -8
  77. package/src/verify.mjs +12 -2
@@ -0,0 +1,891 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { readSessionRegistry, redactSecrets, upsertSessionRegistry } from './obsidian-common.mjs';
5
+ import { assertChangeScaffoldTargetsSafe, changeDirRel, newChange } from './change-core.mjs';
6
+ import { loadSensorsDetailed, runSensors, evaluateGate, sensorProcessEnv } from './sensors-core.mjs';
7
+ import {
8
+ assertAllowedPathTopology, captureGitSnapshot, capturePhysicalTreeSnapshot, diffGitSnapshots, normalizeAllowedPaths,
9
+ pathAllowed, runGitDiffCheck,
10
+ } from './git-snapshot.mjs';
11
+ import {
12
+ flowProtectedIgnoredPathspecs,
13
+ flowProtectedPhysicalScanOptions,
14
+ flowProtectedTopologyRoots,
15
+ isProtectedFlowPath as matchesProtectedFlowPolicy,
16
+ } from './flow-protected-policy.mjs';
17
+ import {
18
+ appendFlowAttempt, createFlowContract, findActiveFlow, findFlow, listFlows, readFlow,
19
+ reserveFlowPromotion, withFlowPromotionLock, writeFlowPromotion, writeFlowReceipt,
20
+ } from './vault-runtime-store.mjs';
21
+ import { projectSessionIteration } from './session-iteration.mjs';
22
+ import { hasSessionFrontmatter } from './session-note-io.mjs';
23
+ import {
24
+ assertVaultPathSafe, mkdirVaultPath, writeVaultFileAtomic,
25
+ } from './vault-path-safety.mjs';
26
+
27
+ function flowError(message, code = 'FLOW_INVALID') {
28
+ const error = new Error(message);
29
+ error.code = code;
30
+ return error;
31
+ }
32
+
33
+ function iso(value = new Date()) {
34
+ const date = value instanceof Date ? value : new Date(value);
35
+ if (Number.isNaN(date.getTime())) throw new TypeError('data FLOW inválida');
36
+ return date.toISOString();
37
+ }
38
+
39
+ function uniqueStrings(values) {
40
+ return [...new Set((values || []).map((value) => String(value || '').trim()).filter(Boolean))];
41
+ }
42
+
43
+ function canonical(value) {
44
+ if (Array.isArray(value)) return value.map(canonical);
45
+ if (value && typeof value === 'object') {
46
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function definitionHash(sensors) {
52
+ return createHash('sha256').update(JSON.stringify(canonical(sensors))).digest('hex');
53
+ }
54
+
55
+ function selectedSensors(projectRoot, sensorIds) {
56
+ const loaded = loadSensorsDetailed(projectRoot);
57
+ if (loaded.error) throw flowError(`configuração de sensores inválida: ${loaded.error}`, 'FLOW_SENSOR_CONFIG');
58
+ if (loaded.missing) throw flowError(`wendkeep.sensors.json ausente: ${loaded.path}`, 'FLOW_SENSOR_CONFIG');
59
+ const byId = new Map(loaded.sensors.map((sensor) => [sensor.id, sensor]));
60
+ const missing = sensorIds.filter((id) => !byId.has(id));
61
+ if (missing.length) throw flowError(`sensor não definido: ${missing.join(', ')}`, 'FLOW_SENSOR_CONFIG');
62
+ return sensorIds.map((id) => byId.get(id));
63
+ }
64
+
65
+ export function resolveFlowSession(vaultBase, { sessionId = '', env = process.env } = {}) {
66
+ const registry = readSessionRegistry(vaultBase);
67
+ const active = Object.entries(registry.sessions || {})
68
+ .filter(([, entry]) => entry?.status === 'active' && entry.session_file)
69
+ .map(([id, entry]) => ({ sessionId: id, entry }));
70
+ const requested = String(sessionId || env?.CODEX_THREAD_ID || '').trim();
71
+ if (requested) {
72
+ const match = active.find((item) => item.sessionId === requested);
73
+ if (!match) throw flowError(`sessão ativa não encontrada: ${requested}`, 'FLOW_SESSION_NOT_FOUND');
74
+ return match;
75
+ }
76
+ if (active.length === 1) return active[0];
77
+ if (active.length === 0) throw flowError('nenhuma sessão ativa e inequívoca para FLOW', 'FLOW_SESSION_NOT_FOUND');
78
+ throw flowError(`sessão FLOW ambígua: mais de uma sessão ativa (${active.map((item) => item.sessionId).join(', ')})`, 'FLOW_SESSION_AMBIGUOUS');
79
+ }
80
+
81
+
82
+ function captureFlowGitSnapshot(projectRoot, gitRoot, protectedRoots = [], vaultBase = '') {
83
+ const physicalOptions = flowProtectedPhysicalScanOptions(projectRoot, gitRoot, {
84
+ protectedRoots, vaultBase,
85
+ });
86
+ // Scan before invoking Git's ignored-file discovery so junctions are never used
87
+ // as a traversal path by the protected-surface pass.
88
+ const physical = capturePhysicalTreeSnapshot(projectRoot, physicalOptions);
89
+ const snapshot = captureGitSnapshot(projectRoot, {
90
+ ignoredPathspecs: flowProtectedIgnoredPathspecs(protectedRoots),
91
+ ignoredPathFilter: (path) => !physicalOptions.isExcludedPath(path)
92
+ && matchesProtectedFlowPolicy(path, protectedRoots),
93
+ });
94
+ return {
95
+ ...snapshot,
96
+ protected_physical_fingerprint: physical.fingerprint,
97
+ protected_physical_fingerprints: physical.fingerprints,
98
+ protected_physical_unsafe_paths: physical.unsafe_paths,
99
+ protected_physical_entries_scanned: physical.entries_scanned,
100
+ protected_physical_max_depth_seen: physical.max_depth_seen,
101
+ };
102
+ }
103
+
104
+ function assertBuiltinProtectedTopology(projectRoot, gitRoot) {
105
+ const roots = flowProtectedTopologyRoots(projectRoot, gitRoot).map((root) => `${root}/**`);
106
+ return assertAllowedPathTopology(gitRoot, roots);
107
+ }
108
+
109
+ function canonicalFsPath(path) {
110
+ const normalized = resolve(path).replaceAll('\\', '/');
111
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
112
+ }
113
+
114
+ function projectRelFromGitRoot(projectRoot, gitRoot) {
115
+ const project = realpathSync.native(resolve(projectRoot));
116
+ const root = realpathSync.native(resolve(gitRoot));
117
+ const rel = relative(root, project);
118
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
119
+ throw flowError('projectRoot FLOW fora do repositório Git', 'FLOW_REPOSITORY_CHANGED');
120
+ }
121
+ return rel ? rel.replaceAll('\\', '/') : '.';
122
+ }
123
+
124
+ function resolveContractProjectRoot(contract, requestedProjectRoot) {
125
+ const rel = String(contract?.project_rel || '');
126
+ if (!rel || isAbsolute(rel) || rel.split('/').includes('..')) {
127
+ throw flowError('project_rel ausente ou inválido no contrato FLOW', 'FLOW_STORE_CORRUPT');
128
+ }
129
+ const gitRoot = realpathSync.native(resolve(contract.baseline.root));
130
+ const expected = rel === '.' ? gitRoot : resolve(gitRoot, ...rel.split('/'));
131
+ const expectedPhysical = realpathSync.native(expected);
132
+ const requestedPhysical = realpathSync.native(resolve(requestedProjectRoot));
133
+ const expectedFromGit = relative(gitRoot, expectedPhysical);
134
+ if (expectedFromGit === '..' || expectedFromGit.startsWith(`..${sep}`) || isAbsolute(expectedFromGit)
135
+ || canonicalFsPath(expectedPhysical) !== canonicalFsPath(requestedPhysical)) {
136
+ throw flowError('projectRoot diverge do projeto congelado no contrato FLOW', 'FLOW_REPOSITORY_CHANGED');
137
+ }
138
+ return expectedPhysical;
139
+ }
140
+
141
+ function snapshotSafetyFailures(snapshot, phase = '') {
142
+ const suffix = phase ? ` ${phase}` : '';
143
+ const failures = [];
144
+ if ((snapshot?.hidden_index_paths || []).length) {
145
+ failures.push(`índice Git oculta paths${suffix}: ${snapshot.hidden_index_paths.join(', ')}`);
146
+ }
147
+ if ((snapshot?.unsafe_git_metadata_paths || []).length) {
148
+ failures.push(`metadados Git atravessam alias físico inseguro${suffix}: ${snapshot.unsafe_git_metadata_paths.join(', ')}`);
149
+ }
150
+ if ((snapshot?.unsafe_worktree_paths || []).length) {
151
+ failures.push(`worktree contém path físico inseguro${suffix}: ${snapshot.unsafe_worktree_paths.join(', ')}`);
152
+ }
153
+ if ((snapshot?.protected_physical_unsafe_paths || []).length) {
154
+ failures.push(`scan físico protegido encontrou alias/hardlink inseguro${suffix}: ${snapshot.protected_physical_unsafe_paths.join(', ')}`);
155
+ }
156
+ return failures;
157
+ }
158
+
159
+ function assertSafeStartingSnapshot(snapshot) {
160
+ const failures = snapshotSafetyFailures(snapshot, 'antes do FLOW');
161
+ if (failures.length) throw flowError(failures.join('; '), 'FLOW_GIT_VISIBILITY');
162
+ }
163
+
164
+ export function isProtectedFlowPath(path, protectedRoots = []) {
165
+ return matchesProtectedFlowPolicy(path, protectedRoots);
166
+ }
167
+
168
+ function configuredProtectedFlowRoots(projectRoot, gitRoot) {
169
+ const configPath = join(resolve(projectRoot), '.wendkeep.json');
170
+ if (!existsSync(configPath)) return [];
171
+ let config;
172
+ try {
173
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
174
+ } catch (error) {
175
+ throw flowError(`binding ilegível ao resolver protectedRoots: ${error?.message || error}`, 'FLOW_PROTECTED_ROOTS_CONFIG');
176
+ }
177
+ const configured = config?.harness?.flow?.protectedRoots;
178
+ if (configured === undefined) return [];
179
+ if (!Array.isArray(configured)) {
180
+ throw flowError('harness.flow.protectedRoots deve ser uma lista', 'FLOW_PROTECTED_ROOTS_CONFIG');
181
+ }
182
+ const normalized = [];
183
+ for (const raw of configured) {
184
+ if (typeof raw !== 'string') {
185
+ throw flowError('raiz protegida FLOW deve ser string relativa', 'FLOW_PROTECTED_ROOTS_CONFIG');
186
+ }
187
+ const value = String(raw || '').trim().replace(/[\\/]$/, '');
188
+ const segments = value.replaceAll('\\', '/').split('/');
189
+ if (!value || isAbsolute(value) || segments.includes('..') || /[*?\[\]{}!]/.test(value)) {
190
+ throw flowError(`raiz protegida FLOW inválida: ${raw}`, 'FLOW_PROTECTED_ROOTS_CONFIG');
191
+ }
192
+ try {
193
+ const [root] = normalizeAllowedPaths(projectRoot, gitRoot, [`${value}/**`]);
194
+ if (!root || /^(?:\.git)(?:\/|$)/i.test(root)) {
195
+ throw new TypeError('raiz reservada do repositório');
196
+ }
197
+ normalized.push(root);
198
+ } catch (error) {
199
+ throw flowError(`raiz protegida FLOW inválida (${raw}): ${error?.message || error}`, 'FLOW_PROTECTED_ROOTS_CONFIG');
200
+ }
201
+ }
202
+ const unique = [...new Set(normalized)].sort();
203
+ if (unique.length !== normalized.length) {
204
+ throw flowError('raízes protegidas FLOW sobrepostas ou duplicadas', 'FLOW_PROTECTED_ROOTS_CONFIG');
205
+ }
206
+ for (let left = 0; left < unique.length; left += 1) {
207
+ for (let right = left + 1; right < unique.length; right += 1) {
208
+ const leftRoot = unique[left].replace(/\/\*\*$/, '');
209
+ const rightRoot = unique[right].replace(/\/\*\*$/, '');
210
+ if (pathAllowed(leftRoot, [unique[right]]) || pathAllowed(rightRoot, [unique[left]])) {
211
+ throw flowError('raízes protegidas FLOW sobrepostas', 'FLOW_PROTECTED_ROOTS_CONFIG');
212
+ }
213
+ }
214
+ }
215
+ return unique;
216
+ }
217
+
218
+ export function startFlow({
219
+ vaultBase,
220
+ projectRoot,
221
+ projectId = '',
222
+ slug,
223
+ allowedPaths,
224
+ sensorIds,
225
+ reason,
226
+ sessionId = '',
227
+ env = process.env,
228
+ now = new Date(),
229
+ flowId = randomUUID(),
230
+ }) {
231
+ const cleanSlug = String(slug || '').trim();
232
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(cleanSlug)) throw flowError('slug FLOW inválido', 'FLOW_USAGE');
233
+ if (!String(reason || '').trim()) throw flowError('--reason é obrigatório para FLOW', 'FLOW_USAGE');
234
+ const ids = uniqueStrings(sensorIds);
235
+ if (!ids.length) throw flowError('ao menos um sensor é obrigatório para FLOW', 'FLOW_USAGE');
236
+ if (!Array.isArray(allowedPaths) || !allowedPaths.length) throw flowError('allowlist exige ao menos um path permitido', 'FLOW_USAGE');
237
+
238
+ const session = resolveFlowSession(vaultBase, { sessionId, env });
239
+ assertSessionProjectionTarget(vaultBase, session.entry.session_file);
240
+ const visibleBaseline = captureGitSnapshot(projectRoot);
241
+ const allowlist = normalizeAllowedPaths(projectRoot, visibleBaseline.root, allowedPaths);
242
+ const protectedRoots = configuredProtectedFlowRoots(projectRoot, visibleBaseline.root);
243
+ const baseline = captureFlowGitSnapshot(projectRoot, visibleBaseline.root, protectedRoots, vaultBase);
244
+ assertSafeStartingSnapshot(baseline);
245
+ assertBuiltinProtectedTopology(projectRoot, baseline.root);
246
+ assertAllowedPathTopology(baseline.root, allowlist);
247
+ assertAllowedPathTopology(baseline.root, protectedRoots);
248
+ const preexistingAllowed = (visibleBaseline.dirty_paths || Object.keys(visibleBaseline.fingerprints))
249
+ .filter((path) => pathAllowed(path, allowlist));
250
+ if (preexistingAllowed.length) {
251
+ throw flowError(`path permitido já contém sujeira preexistente: ${preexistingAllowed.join(', ')}`, 'FLOW_DIRTY_ALLOWLIST');
252
+ }
253
+ const definitions = selectedSensors(projectRoot, ids);
254
+ const contract = {
255
+ schema_version: 1,
256
+ flow_id: flowId,
257
+ session_id: session.sessionId,
258
+ session_file: session.entry.session_file,
259
+ project_id: projectId,
260
+ project_rel: projectRelFromGitRoot(projectRoot, baseline.root),
261
+ slug: cleanSlug,
262
+ profile: 'FLOW',
263
+ started_at: iso(now),
264
+ reason: redactSecrets(String(reason).trim()),
265
+ spec_impact: 'none',
266
+ spec_impact_reason: redactSecrets(String(reason).trim()),
267
+ allowed_paths: allowlist,
268
+ protected_roots: protectedRoots,
269
+ sensor_ids: ids,
270
+ sensor_definition_hash: definitionHash(definitions),
271
+ baseline: {
272
+ schema_version: baseline.schema_version,
273
+ root: baseline.root,
274
+ head: baseline.head,
275
+ fingerprints: baseline.fingerprints,
276
+ git_metadata_fingerprint: baseline.git_metadata_fingerprint,
277
+ hidden_index_paths: baseline.hidden_index_paths,
278
+ unsafe_git_metadata_paths: baseline.unsafe_git_metadata_paths,
279
+ unsafe_worktree_paths: baseline.unsafe_worktree_paths,
280
+ protected_physical_fingerprint: baseline.protected_physical_fingerprint,
281
+ protected_physical_fingerprints: baseline.protected_physical_fingerprints,
282
+ protected_physical_unsafe_paths: baseline.protected_physical_unsafe_paths,
283
+ },
284
+ };
285
+ createFlowContract(vaultBase, contract);
286
+ return readFlow(vaultBase, { sessionId: session.sessionId, flowId });
287
+ }
288
+
289
+ export function flowStatus(vaultBase, { flowId = '', sessionId = '', env = process.env } = {}) {
290
+ let state;
291
+ if (flowId) state = findFlow(vaultBase, flowId, { sessionId });
292
+ else {
293
+ const session = resolveFlowSession(vaultBase, { sessionId, env });
294
+ state = findActiveFlow(vaultBase, session.sessionId);
295
+ }
296
+ if (!state) throw flowError(`FLOW não encontrado${flowId ? `: ${flowId}` : ''}`, 'FLOW_NOT_FOUND');
297
+ return state;
298
+ }
299
+
300
+ function sessionPath(vaultBase, relPath) {
301
+ const raw = String(relPath || '');
302
+ if (!raw || isAbsolute(raw)) throw flowError('session_file FLOW inválido', 'FLOW_SESSION_INVALID');
303
+ const path = resolve(vaultBase, raw);
304
+ const fromVault = relative(resolve(vaultBase), path);
305
+ if (fromVault === '..' || fromVault.startsWith(`..${sep}`) || isAbsolute(fromVault)) {
306
+ throw flowError('session_file FLOW fora do Vault', 'FLOW_SESSION_INVALID');
307
+ }
308
+ return path;
309
+ }
310
+
311
+ function assertSessionProjectionTarget(vaultBase, relPath) {
312
+ const path = sessionPath(vaultBase, relPath);
313
+ const checked = assertVaultPathSafe(vaultBase, path, {
314
+ expectedType: 'file',
315
+ label: 'session_file FLOW',
316
+ code: 'FLOW_SESSION_INVALID',
317
+ });
318
+ if (!checked.exists) {
319
+ throw flowError('nota da sessão indisponível para projeção FLOW', 'FLOW_SESSION_PROJECTION');
320
+ }
321
+ if (!hasSessionFrontmatter(readFileSync(path, 'utf8'))) {
322
+ throw flowError('nota da sessão inválida para projeção FLOW', 'FLOW_SESSION_PROJECTION');
323
+ }
324
+ return checked.target;
325
+ }
326
+
327
+ function assertPromotionWriteTarget(vaultBase, targetPath) {
328
+ return assertVaultPathSafe(vaultBase, targetPath, {
329
+ expectedType: 'directory',
330
+ label: 'destino da change promovida',
331
+ code: 'FLOW_VAULT_BOUNDARY',
332
+ });
333
+ }
334
+
335
+ function projectionFailure(projection) {
336
+ if (projection?.written || projection?.reason === 'unchanged') return '';
337
+ return `projeção na nota da sessão falhou: ${projection?.reason || 'estado desconhecido'}`;
338
+ }
339
+
340
+ function renderFinishBlock(contract, receipt) {
341
+ const sensors = receipt.evidence.map((entry) => `\`${entry.id}\` ${entry.status}`).join(', ');
342
+ return `### ${receipt.finished_at.slice(11, 16)} - FLOW concluído: ${contract.slug}\n\n`
343
+ + `- **FLOW:** \`${contract.flow_id}\`\n`
344
+ + `- **Motivo:** ${markdownInline(contract.reason)}\n`
345
+ + `- **Paths:** ${receipt.changed_paths.map((path) => `\`${path}\``).join(', ')}\n`
346
+ + `- **Sensores:** ${sensors}`;
347
+ }
348
+
349
+ function projectReceipt(vaultBase, state) {
350
+ const { contract, receipt } = state;
351
+ let path;
352
+ try {
353
+ path = assertSessionProjectionTarget(vaultBase, contract.session_file);
354
+ } catch (error) {
355
+ return { inserted: false, written: false, reason: error?.message || 'invalid-session-target' };
356
+ }
357
+ return projectSessionIteration(path, {
358
+ markerId: `flow:${contract.flow_id}:finished`,
359
+ block: renderFinishBlock(contract, receipt),
360
+ }, { vaultBase });
361
+ }
362
+
363
+ function recordFailedAttempt(vaultBase, state, { failures, changedPaths, evidence, now }) {
364
+ appendFlowAttempt(vaultBase, state.contract.session_id, state.contract.flow_id, {
365
+ schema_version: 1,
366
+ attempt_id: randomUUID(),
367
+ status: 'red',
368
+ recorded_at: iso(now),
369
+ failures,
370
+ changed_paths: changedPaths,
371
+ evidence,
372
+ });
373
+ return readFlow(vaultBase, { sessionId: state.contract.session_id, flowId: state.contract.flow_id });
374
+ }
375
+
376
+ export function finishFlow({ vaultBase, projectRoot, flowId, sessionId = '', now = new Date() }) {
377
+ const state = findFlow(vaultBase, flowId, { sessionId });
378
+ if (!state) throw flowError(`FLOW não encontrado: ${flowId}`, 'FLOW_NOT_FOUND');
379
+ if (state.state === 'promoted') throw flowError(`FLOW já promovido: ${flowId}`, 'FLOW_TERMINAL');
380
+ if (state.state === 'promoting') throw flowError(`FLOW em promoção: ${flowId}`, 'FLOW_TERMINAL');
381
+ if (state.state === 'finished') {
382
+ const projection = projectReceipt(vaultBase, state);
383
+ const failure = projectionFailure(projection);
384
+ return failure
385
+ ? { ok: false, failures: [failure], state, projection, idempotent: true }
386
+ : { ok: true, state, projection, idempotent: true };
387
+ }
388
+
389
+ let frozenProjectRoot;
390
+ try {
391
+ frozenProjectRoot = resolveContractProjectRoot(state.contract, projectRoot);
392
+ assertBuiltinProtectedTopology(frozenProjectRoot, state.contract.baseline.root);
393
+ assertAllowedPathTopology(state.contract.baseline.root, state.contract.allowed_paths);
394
+ assertAllowedPathTopology(state.contract.baseline.root, state.contract.protected_roots);
395
+ assertSessionProjectionTarget(vaultBase, state.contract.session_file);
396
+ } catch (error) {
397
+ const failures = [error?.message || 'topologia física da allowlist inválida'];
398
+ const latest = recordFailedAttempt(vaultBase, state, {
399
+ failures, changedPaths: [], evidence: [], now,
400
+ });
401
+ return { ok: false, failures, state: latest };
402
+ }
403
+
404
+ let current;
405
+ try {
406
+ current = captureFlowGitSnapshot(
407
+ frozenProjectRoot, state.contract.baseline.root, state.contract.protected_roots, vaultBase,
408
+ );
409
+ } catch (error) {
410
+ const failures = [error?.message || 'scan físico protegido indisponível antes dos sensores'];
411
+ const latest = recordFailedAttempt(vaultBase, state, {
412
+ failures, changedPaths: [], evidence: [], now,
413
+ });
414
+ return { ok: false, failures, state: latest };
415
+ }
416
+ const delta = diffGitSnapshots(state.contract.baseline, current);
417
+ let changedPaths = delta.changedPaths;
418
+ const failures = [];
419
+ if (delta.rootChanged) failures.push('repositório Git mudou durante o FLOW');
420
+ if (delta.headChanged) failures.push('HEAD mudou durante o FLOW');
421
+ if (delta.metadataChanged) failures.push('metadados Git mudaram durante o FLOW');
422
+ failures.push(...snapshotSafetyFailures(current, 'antes dos sensores'));
423
+ if (!changedPaths.length) failures.push('nenhuma alteração atribuível ao FLOW');
424
+ for (const path of changedPaths) {
425
+ if (!pathAllowed(path, state.contract.allowed_paths)) failures.push(`path fora da allowlist: ${path}`);
426
+ if (isProtectedFlowPath(path, state.contract.protected_roots)) failures.push(`superfície protegida: ${path}`);
427
+ }
428
+ const diffCheck = runGitDiffCheck(current.root, { paths: changedPaths });
429
+ if (!diffCheck.ok) failures.push(`git diff --check vermelho${diffCheck.output ? `: ${diffCheck.output}` : ''}`);
430
+
431
+ let evidence = [];
432
+ let definitions = [];
433
+ try {
434
+ definitions = selectedSensors(frozenProjectRoot, state.contract.sensor_ids);
435
+ if (definitionHash(definitions) !== state.contract.sensor_definition_hash) {
436
+ failures.push('definição de sensor mudou durante o FLOW');
437
+ }
438
+ } catch (error) {
439
+ failures.push(error?.message || 'configuração de sensores inválida');
440
+ }
441
+ if (!failures.length) {
442
+ evidence = runSensors(definitions, state.contract.sensor_ids, {
443
+ cwd: frozenProjectRoot,
444
+ env: sensorProcessEnv(vaultBase),
445
+ now: iso(now),
446
+ });
447
+ const gate = evaluateGate(evidence, state.contract.sensor_ids);
448
+ if (!gate.ok) failures.push(`sensor crítico vermelho: ${gate.failing.join(', ')}`);
449
+ try {
450
+ const afterSensors = captureFlowGitSnapshot(
451
+ frozenProjectRoot, state.contract.baseline.root, state.contract.protected_roots, vaultBase,
452
+ );
453
+ const sensorMutation = diffGitSnapshots(current, afterSensors);
454
+ if (sensorMutation.rootChanged || sensorMutation.headChanged
455
+ || sensorMutation.metadataChanged || sensorMutation.changedPaths.length) {
456
+ const detail = sensorMutation.changedPaths.length ? `: ${sensorMutation.changedPaths.join(', ')}` : '';
457
+ failures.push(`sensor modificou o repositório${detail}`);
458
+ changedPaths = diffGitSnapshots(state.contract.baseline, afterSensors).changedPaths;
459
+ }
460
+ failures.push(...snapshotSafetyFailures(afterSensors, 'após sensores'));
461
+ for (const path of changedPaths) {
462
+ if (!pathAllowed(path, state.contract.allowed_paths)) failures.push(`path fora da allowlist após sensores: ${path}`);
463
+ if (isProtectedFlowPath(path, state.contract.protected_roots)) failures.push(`superfície protegida após sensores: ${path}`);
464
+ }
465
+ assertAllowedPathTopology(afterSensors.root, state.contract.allowed_paths, changedPaths);
466
+ assertBuiltinProtectedTopology(frozenProjectRoot, afterSensors.root);
467
+ assertAllowedPathTopology(afterSensors.root, state.contract.protected_roots);
468
+ assertSessionProjectionTarget(vaultBase, state.contract.session_file);
469
+ } catch (error) {
470
+ failures.push(`não foi possível confirmar o estado Git após os sensores: ${error?.message || error}`);
471
+ }
472
+ }
473
+ if (failures.length) {
474
+ const latest = recordFailedAttempt(vaultBase, state, { failures, changedPaths, evidence, now });
475
+ return { ok: false, failures, state: latest };
476
+ }
477
+
478
+ let terminalSnapshot;
479
+ try {
480
+ terminalSnapshot = captureFlowGitSnapshot(
481
+ frozenProjectRoot, state.contract.baseline.root, state.contract.protected_roots, vaultBase,
482
+ );
483
+ const terminalDrift = diffGitSnapshots(current, terminalSnapshot);
484
+ if (terminalDrift.rootChanged || terminalDrift.headChanged
485
+ || terminalDrift.metadataChanged || terminalDrift.changedPaths.length) {
486
+ throw flowError('repositório mudou após a validação dos sensores', 'FLOW_SENSOR_MUTATION');
487
+ }
488
+ const terminalSafety = snapshotSafetyFailures(terminalSnapshot, 'antes do recibo');
489
+ if (terminalSafety.length) throw flowError(terminalSafety.join('; '), 'FLOW_GIT_VISIBILITY');
490
+ changedPaths = diffGitSnapshots(state.contract.baseline, terminalSnapshot).changedPaths;
491
+ for (const path of changedPaths) {
492
+ if (!pathAllowed(path, state.contract.allowed_paths)) throw flowError(`path fora da allowlist antes do recibo: ${path}`);
493
+ if (isProtectedFlowPath(path, state.contract.protected_roots)) throw flowError(`superfície protegida antes do recibo: ${path}`);
494
+ }
495
+ const terminalDiffCheck = runGitDiffCheck(terminalSnapshot.root, { paths: changedPaths });
496
+ if (!terminalDiffCheck.ok) {
497
+ throw flowError(`git diff --check vermelho antes do recibo${terminalDiffCheck.output ? `: ${terminalDiffCheck.output}` : ''}`);
498
+ }
499
+ assertAllowedPathTopology(terminalSnapshot.root, state.contract.allowed_paths, changedPaths);
500
+ assertBuiltinProtectedTopology(frozenProjectRoot, terminalSnapshot.root);
501
+ assertAllowedPathTopology(terminalSnapshot.root, state.contract.protected_roots);
502
+ assertSessionProjectionTarget(vaultBase, state.contract.session_file);
503
+ } catch (error) {
504
+ const finalFailures = [error?.message || 'topologia física da allowlist inválida'];
505
+ const latest = recordFailedAttempt(vaultBase, state, {
506
+ failures: finalFailures, changedPaths, evidence, now,
507
+ });
508
+ return { ok: false, failures: finalFailures, state: latest };
509
+ }
510
+
511
+ const receipt = {
512
+ schema_version: 1,
513
+ flow_id: state.contract.flow_id,
514
+ status: 'finished',
515
+ finished_at: iso(now),
516
+ reason: state.contract.reason,
517
+ allowed_paths: state.contract.allowed_paths,
518
+ sensor_ids: state.contract.sensor_ids,
519
+ changed_paths: changedPaths,
520
+ evidence,
521
+ baseline_head: state.contract.baseline.head,
522
+ final_head: terminalSnapshot.head,
523
+ };
524
+ writeFlowReceipt(vaultBase, state.contract.session_id, state.contract.flow_id, receipt);
525
+ const finished = readFlow(vaultBase, { sessionId: state.contract.session_id, flowId: state.contract.flow_id });
526
+ const projection = projectReceipt(vaultBase, finished);
527
+ const failure = projectionFailure(projection);
528
+ return failure
529
+ ? { ok: false, failures: [failure], state: finished, projection }
530
+ : { ok: true, state: finished, projection };
531
+ }
532
+
533
+ function markdownInline(value) {
534
+ return String(value || '')
535
+ .replace(/\s+/g, ' ')
536
+ .trim()
537
+ .replaceAll('&', '&amp;')
538
+ .replaceAll('<', '&lt;')
539
+ .replaceAll('>', '&gt;')
540
+ .replaceAll('`', 'ˋ');
541
+ }
542
+
543
+ function renderPromotionBlock(contract, promotion) {
544
+ const paths = promotion.changed_paths.length
545
+ ? promotion.changed_paths.map((path) => `\`${markdownInline(path)}\``).join(', ')
546
+ : '(nenhum path alterado observado)';
547
+ return `### ${promotion.promoted_at.slice(11, 16)} - FLOW promovido: ${contract.slug}\n\n`
548
+ + `- **FLOW:** \`${contract.flow_id}\`\n`
549
+ + `- **Change:** \`${promotion.change_slug}\`\n`
550
+ + `- **Motivo:** ${markdownInline(contract.reason)}\n`
551
+ + `- **Paths observados:** ${paths}`;
552
+ }
553
+
554
+ function projectPromotion(vaultBase, state) {
555
+ const { contract, promotion } = state;
556
+ let path;
557
+ try {
558
+ path = assertSessionProjectionTarget(vaultBase, contract.session_file);
559
+ } catch (error) {
560
+ return { inserted: false, written: false, reason: error?.message || 'invalid-session-target' };
561
+ }
562
+ return projectSessionIteration(path, {
563
+ markerId: `flow:${contract.flow_id}:promoted`,
564
+ block: renderPromotionBlock(contract, promotion),
565
+ }, { vaultBase });
566
+ }
567
+
568
+ function promotionResult(vaultBase, state, { idempotent = false } = {}) {
569
+ const projection = projectPromotion(vaultBase, state);
570
+ const failure = projectionFailure(projection);
571
+ return failure
572
+ ? { ok: false, failures: [failure], state, projection, ...(idempotent ? { idempotent: true } : {}) }
573
+ : { ok: true, state, projection, ...(idempotent ? { idempotent: true } : {}) };
574
+ }
575
+
576
+ function promotionConflict(message) {
577
+ return flowError(message, 'FLOW_PROMOTION_CONFLICT');
578
+ }
579
+
580
+ function readOrigin(path) {
581
+ try {
582
+ return JSON.parse(readFileSync(path, 'utf8'));
583
+ } catch {
584
+ throw promotionConflict(`origem FLOW inválida na change: ${path}`);
585
+ }
586
+ }
587
+
588
+ function assertOriginMatches(origin, contract) {
589
+ const sameContract = JSON.stringify(canonical(origin?.contract)) === JSON.stringify(canonical(contract));
590
+ if (origin?.schema_version !== 1
591
+ || origin?.flow_id !== contract.flow_id
592
+ || !sameContract
593
+ || !Array.isArray(origin?.attempts)
594
+ || !Array.isArray(origin?.observed_git?.changed_paths)) {
595
+ throw promotionConflict(`origem FLOW inconsistente para ${contract.flow_id}`);
596
+ }
597
+ return origin;
598
+ }
599
+
600
+ function ensureOrigin(vaultBase, path, origin) {
601
+ const checked = assertVaultPathSafe(vaultBase, path, {
602
+ expectedType: 'file', label: 'flow-origin.json', code: 'FLOW_VAULT_BOUNDARY',
603
+ });
604
+ if (checked.exists) {
605
+ const existing = assertOriginMatches(readOrigin(path), origin.contract);
606
+ if (JSON.stringify(canonical(existing)) !== JSON.stringify(canonical(origin))) {
607
+ throw promotionConflict(`origem FLOW diverge da reserva de ${origin.flow_id}`);
608
+ }
609
+ return existing;
610
+ }
611
+ writeVaultFileAtomic(
612
+ vaultBase,
613
+ path,
614
+ `${JSON.stringify(canonical(origin), null, 2)}\n`,
615
+ 'utf8',
616
+ { label: 'flow-origin.json', code: 'FLOW_VAULT_BOUNDARY' },
617
+ );
618
+ return origin;
619
+ }
620
+
621
+ function renderOriginSummary(origin) {
622
+ const contract = origin.contract;
623
+ const paths = origin.observed_git.changed_paths.length
624
+ ? origin.observed_git.changed_paths.map((path) => `\`${markdownInline(path)}\``).join(', ')
625
+ : '(nenhum path alterado observado)';
626
+ const sensors = contract.sensor_ids.map((id) => `\`${markdownInline(id)}\``).join(', ');
627
+ const attempts = origin.attempts.length
628
+ ? origin.attempts.map((attempt) => {
629
+ const failures = (attempt.failures || []).map(markdownInline).join('; ') || attempt.status;
630
+ return `- \`${attempt.attempt_id}\` (${attempt.recorded_at}): ${failures}`;
631
+ }).join('\n')
632
+ : '- Nenhuma tentativa de finalização anterior.';
633
+ return `<!-- wendkeep:flow-origin:${contract.flow_id} -->\n`
634
+ + '## Origem FLOW\n\n'
635
+ + `- **FLOW:** \`${contract.flow_id}\`\n`
636
+ + `- **Sessão:** \`${contract.session_id}\`\n`
637
+ + `- **Motivo original:** ${markdownInline(contract.reason)}\n`
638
+ + `- **Paths permitidos:** ${contract.allowed_paths.map((path) => `\`${markdownInline(path)}\``).join(', ')}\n`
639
+ + `- **Paths observados:** ${paths}\n`
640
+ + `- **Sensores:** ${sensors}\n`
641
+ + `- **Baseline HEAD:** \`${contract.baseline.head}\`\n\n`
642
+ + '### Tentativas preservadas\n\n'
643
+ + `${attempts}\n\n`
644
+ + 'O escopo deve seguir agora o lifecycle completo de uma change WendKeep. '
645
+ + 'O arquivo `flow-origin.json` é a evidência estruturada e imutável desta promoção.';
646
+ }
647
+
648
+ function enrichProposal(vaultBase, path, origin) {
649
+ let proposal = readFileSync(path, 'utf8');
650
+ const marker = `<!-- wendkeep:flow-origin:${origin.flow_id} -->`;
651
+ if (proposal.includes(marker)) return false;
652
+ const why = `Promovida do FLOW \`${origin.flow_id}\`: ${markdownInline(origin.contract.reason)}`;
653
+ const scope = origin.observed_git.changed_paths.length
654
+ ? `Escopo observado antes da promoção: ${origin.observed_git.changed_paths.map(markdownInline).join(', ')}.`
655
+ : 'Nenhuma alteração foi observada antes da promoção; o escopo será definido nesta change.';
656
+ proposal = proposal
657
+ .replace('(motivo da mudança)', why)
658
+ .replace('(reason for the change)', why)
659
+ .replace('(escopo da mudança)', scope)
660
+ .replace('(scope of the change)', scope);
661
+ proposal = `${proposal.trimEnd()}\n\n${renderOriginSummary(origin)}\n`;
662
+ writeVaultFileAtomic(
663
+ vaultBase,
664
+ path,
665
+ proposal,
666
+ 'utf8',
667
+ { label: 'proposta promovida do FLOW', code: 'FLOW_VAULT_BOUNDARY' },
668
+ );
669
+ return true;
670
+ }
671
+
672
+ function assertSlugOwner(vaultBase, state, changeRel) {
673
+ const foreign = listFlows(vaultBase).find((candidate) => {
674
+ const ownsChange = candidate.reservation?.change_rel === changeRel
675
+ || candidate.promotion?.change_rel === changeRel;
676
+ const sameFlow = candidate.contract.session_id === state.contract.session_id
677
+ && candidate.contract.flow_id === state.contract.flow_id;
678
+ return ownsChange && !sameFlow;
679
+ });
680
+ if (foreign) {
681
+ throw promotionConflict(
682
+ `change ${changeRel.replaceAll('\\', '/')} já pertence ao FLOW ${foreign.contract.flow_id}`,
683
+ );
684
+ }
685
+ }
686
+
687
+ export function promoteFlow({
688
+ vaultBase,
689
+ projectRoot,
690
+ flowId,
691
+ sessionId = '',
692
+ changeSlug = '',
693
+ now = new Date(),
694
+ }) {
695
+ let state = findFlow(vaultBase, flowId, { sessionId });
696
+ if (!state) throw flowError(`FLOW não encontrado: ${flowId}`, 'FLOW_NOT_FOUND');
697
+ if (state.state === 'finished') throw flowError(`FLOW já finalizado: ${flowId}`, 'FLOW_TERMINAL');
698
+ if (state.state === 'promoted') return promotionResult(vaultBase, state, { idempotent: true });
699
+
700
+ assertSessionProjectionTarget(vaultBase, state.contract.session_file);
701
+
702
+ const explicitSlug = String(changeSlug || '').trim();
703
+ const requestedSlug = explicitSlug
704
+ || (state.state === 'promoting' ? state.reservation.change_slug : state.contract.slug)
705
+ || '';
706
+ const slug = state.state === 'promoting' ? state.reservation.change_slug : requestedSlug;
707
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) throw flowError('slug da change promovida inválido', 'FLOW_USAGE');
708
+ if (state.state === 'promoting' && explicitSlug && requestedSlug !== slug) {
709
+ throw promotionConflict(`promoção de ${flowId} já reservada para a change ${slug}`);
710
+ }
711
+
712
+ const locked = withFlowPromotionLock(vaultBase, slug, () => {
713
+ // Re-read only after owning the slug. This is the authoritative state for every
714
+ // ownership decision and for the reservation that follows.
715
+ state = readFlow(vaultBase, {
716
+ sessionId: state.contract.session_id,
717
+ flowId: state.contract.flow_id,
718
+ });
719
+ if (!state) throw flowError(`FLOW não encontrado: ${flowId}`, 'FLOW_NOT_FOUND');
720
+ if (state.state === 'finished') throw flowError(`FLOW já finalizado: ${flowId}`, 'FLOW_TERMINAL');
721
+ if (state.state === 'promoted') return { state, idempotent: true };
722
+ assertSessionProjectionTarget(vaultBase, state.contract.session_file);
723
+
724
+ const lockedSlug = state.state === 'promoting' ? state.reservation.change_slug : slug;
725
+ if (lockedSlug !== slug) {
726
+ throw promotionConflict(`promoção de ${flowId} já reservada para a change ${lockedSlug}`);
727
+ }
728
+ const changeRel = changeDirRel(slug, vaultBase).replaceAll('\\', '/');
729
+ if (state.state === 'promoting' && state.reservation.change_rel !== changeRel) {
730
+ throw promotionConflict(`path da change reservada diverge para ${flowId}`);
731
+ }
732
+ assertSlugOwner(vaultBase, state, changeRel);
733
+ const frozenProjectRoot = resolveContractProjectRoot(state.contract, projectRoot);
734
+
735
+ const changeDir = join(vaultBase, changeRel);
736
+ const proposalPath = join(changeDir, 'proposta.md');
737
+ const originPath = join(changeDir, 'flow-origin.json');
738
+ // Boundary validation is inside the slug lock and precedes both durable ownership
739
+ // reservation and every write through the change scaffold.
740
+ assertChangeScaffoldTargetsSafe(vaultBase, slug, {
741
+ simple: false,
742
+ includeSessionControl: true,
743
+ code: 'FLOW_VAULT_BOUNDARY',
744
+ });
745
+ const checkedChangeDir = assertPromotionWriteTarget(vaultBase, changeDir);
746
+ if (state.state === 'active' && checkedChangeDir.exists) {
747
+ throw promotionConflict(`change preexistente não pode ser reivindicada pelo FLOW: ${slug}`);
748
+ }
749
+ const checkedOrigin = assertVaultPathSafe(vaultBase, originPath, {
750
+ expectedType: 'file', label: 'flow-origin.json', code: 'FLOW_VAULT_BOUNDARY',
751
+ });
752
+ if (state.state === 'promoting' && checkedChangeDir.exists && !checkedOrigin.exists) {
753
+ const retryArtifacts = readdirSync(changeDir);
754
+ for (const name of retryArtifacts) {
755
+ assertVaultPathSafe(vaultBase, join(changeDir, name), {
756
+ expectedType: 'any',
757
+ label: `artefato inesperado de retry FLOW ${name}`,
758
+ code: 'FLOW_VAULT_BOUNDARY',
759
+ });
760
+ }
761
+ const resumableEmptyDir = retryArtifacts.length === 0;
762
+ if (!resumableEmptyDir) {
763
+ throw promotionConflict(`change preexistente sem origem deste FLOW: ${slug}`);
764
+ }
765
+ }
766
+ if (state.state === 'promoting' && checkedChangeDir.exists && checkedOrigin.exists) {
767
+ const expectedArtifacts = new Set([
768
+ 'flow-origin.json', 'proposta.md', 'tarefas.md', 'design.md',
769
+ '.spec-impact-v1', '.spec-base.json',
770
+ ]);
771
+ for (const name of readdirSync(changeDir)) {
772
+ const artifact = join(changeDir, name);
773
+ assertVaultPathSafe(vaultBase, artifact, {
774
+ expectedType: expectedArtifacts.has(name) ? 'file' : 'any',
775
+ label: `artefato de retry FLOW ${name}`,
776
+ code: 'FLOW_VAULT_BOUNDARY',
777
+ });
778
+ if (!expectedArtifacts.has(name)) {
779
+ throw promotionConflict(`artefato inesperado na change reservada: ${name}`);
780
+ }
781
+ }
782
+ }
783
+ const existingOrigin = state.state === 'promoting' && checkedOrigin.exists
784
+ ? assertOriginMatches(readOrigin(originPath), state.contract)
785
+ : null;
786
+ assertBuiltinProtectedTopology(frozenProjectRoot, state.contract.baseline.root);
787
+ assertAllowedPathTopology(state.contract.baseline.root, state.contract.protected_roots);
788
+ const current = captureFlowGitSnapshot(
789
+ frozenProjectRoot, state.contract.baseline.root, state.contract.protected_roots, vaultBase,
790
+ );
791
+ assertBuiltinProtectedTopology(frozenProjectRoot, current.root);
792
+ assertAllowedPathTopology(current.root, state.contract.protected_roots);
793
+ const delta = diffGitSnapshots(state.contract.baseline, current);
794
+ if (delta.rootChanged) {
795
+ throw flowError('repositório Git mudou durante o FLOW', 'FLOW_REPOSITORY_CHANGED');
796
+ }
797
+ if (delta.metadataChanged || snapshotSafetyFailures(current, 'antes da promoção').length) {
798
+ throw flowError(
799
+ ['metadados Git mudaram ou ficaram inseguros durante o FLOW', ...snapshotSafetyFailures(current, 'antes da promoção')].join('; '),
800
+ 'FLOW_GIT_VISIBILITY',
801
+ );
802
+ }
803
+ if (state.state === 'active') {
804
+ const reservedAt = existingOrigin?.promoted_at || iso(now);
805
+ const originCandidate = existingOrigin || {
806
+ schema_version: 1,
807
+ flow_id: state.contract.flow_id,
808
+ promoted_at: reservedAt,
809
+ contract: state.contract,
810
+ attempts: state.attempts,
811
+ observed_git: {
812
+ baseline_head: state.contract.baseline.head,
813
+ current_head: current.head,
814
+ head_changed: delta.headChanged,
815
+ changed_paths: delta.changedPaths,
816
+ },
817
+ };
818
+ reserveFlowPromotion(vaultBase, state.contract.session_id, state.contract.flow_id, {
819
+ schema_version: 1,
820
+ flow_id: state.contract.flow_id,
821
+ status: 'promoting',
822
+ reserved_at: reservedAt,
823
+ change_slug: slug,
824
+ change_rel: changeRel,
825
+ origin: originCandidate,
826
+ });
827
+ state = readFlow(vaultBase, {
828
+ sessionId: state.contract.session_id,
829
+ flowId: state.contract.flow_id,
830
+ });
831
+ }
832
+ const originCandidate = state.reservation.origin;
833
+
834
+ assertPromotionWriteTarget(vaultBase, changeDir);
835
+ mkdirVaultPath(vaultBase, changeDir, {
836
+ label: 'destino da change promovida', code: 'FLOW_VAULT_BOUNDARY',
837
+ });
838
+ assertPromotionWriteTarget(vaultBase, changeDir);
839
+ const origin = ensureOrigin(vaultBase, originPath, originCandidate);
840
+ assertPromotionWriteTarget(vaultBase, changeDir);
841
+ const change = newChange(vaultBase, slug, {
842
+ sessionRel: state.contract.session_file,
843
+ dateStr: state.reservation.reserved_at.slice(0, 10),
844
+ simple: false,
845
+ });
846
+ assertPromotionWriteTarget(vaultBase, changeDir);
847
+ enrichProposal(vaultBase, proposalPath, origin);
848
+ upsertSessionRegistry(vaultBase, state.contract.session_id, { change_slug: slug });
849
+
850
+ const promotion = {
851
+ schema_version: 1,
852
+ flow_id: state.contract.flow_id,
853
+ status: 'promoted',
854
+ promoted_at: origin.promoted_at,
855
+ change_slug: slug,
856
+ change_rel: state.reservation.change_rel,
857
+ origin_file: `${change.rel.replaceAll('\\', '/')}/flow-origin.json`,
858
+ changed_paths: origin.observed_git.changed_paths,
859
+ baseline_head: origin.observed_git.baseline_head,
860
+ current_head: origin.observed_git.current_head,
861
+ };
862
+ const terminalSnapshot = captureFlowGitSnapshot(
863
+ frozenProjectRoot, state.contract.baseline.root, state.contract.protected_roots, vaultBase,
864
+ );
865
+ assertBuiltinProtectedTopology(frozenProjectRoot, terminalSnapshot.root);
866
+ assertAllowedPathTopology(terminalSnapshot.root, state.contract.protected_roots);
867
+ const terminalDrift = diffGitSnapshots(current, terminalSnapshot);
868
+ const terminalSafety = snapshotSafetyFailures(terminalSnapshot, 'antes de terminalizar a promoção');
869
+ if (terminalDrift.rootChanged || terminalDrift.headChanged || terminalDrift.metadataChanged
870
+ || terminalDrift.changedPaths.length || terminalSafety.length) {
871
+ throw flowError(
872
+ [
873
+ 'repositório mudou ou ficou inseguro durante a promoção',
874
+ ...(terminalDrift.changedPaths.length ? [`paths: ${terminalDrift.changedPaths.join(', ')}`] : []),
875
+ ...terminalSafety,
876
+ ].join('; '),
877
+ 'FLOW_GIT_VISIBILITY',
878
+ );
879
+ }
880
+ assertSessionProjectionTarget(vaultBase, state.contract.session_file);
881
+ writeFlowPromotion(vaultBase, state.contract.session_id, state.contract.flow_id, promotion);
882
+ return {
883
+ state: readFlow(vaultBase, {
884
+ sessionId: state.contract.session_id,
885
+ flowId: state.contract.flow_id,
886
+ }),
887
+ idempotent: false,
888
+ };
889
+ });
890
+ return promotionResult(vaultBase, locked.state, { idempotent: locked.idempotent });
891
+ }