wendkeep 0.77.0 → 0.78.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.
@@ -5,6 +5,12 @@ import { basename, join, relative } from 'node:path';
5
5
 
6
6
  import { sanitizeMemoryText } from './memory-schema.mjs';
7
7
  import { scopeForMemoryKey } from './memory-scope.mjs';
8
+ import {
9
+ evaluateEvidenceBinding,
10
+ evidenceCheckoutBinding,
11
+ evidenceCheckoutBindingMatches,
12
+ evidenceSensors,
13
+ } from './evidence-envelope.mjs';
8
14
 
9
15
  const SHARED_HANDOFF_FIELDS = Object.freeze([
10
16
  ['objective', 'objective.current'],
@@ -177,9 +183,33 @@ export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary =
177
183
  };
178
184
  }
179
185
  const sensorPath = join(archivedDir, 'evidencia.json');
180
- const sensors = readJson(sensorPath);
181
- if (Array.isArray(sensors) && sensors.length && sensors.every((item) => item?.status === 'green')) {
186
+ const sensorEnvelope = readJson(sensorPath);
187
+ const sensors = evidenceSensors(sensorEnvelope);
188
+ if (sensors.length && sensors.every((item) => item?.status === 'green')) {
182
189
  evidence.sensors = [...new Set(sensors.map((item) => String(item.id || '')).filter(Boolean))].sort();
190
+ evidence.sensors_path = vaultRel(vaultBase, sensorPath);
191
+ const assessed = evaluateEvidenceBinding(sensorEnvelope, { change_slug: slug });
192
+ evidence.sensors_binding = assessed.state;
193
+ if (sensorEnvelope?.schema_version === 2 && assessed.state === 'bound') {
194
+ const checkoutBinding = evidenceCheckoutBinding(sensorEnvelope);
195
+ const verification = readJson(join(archivedDir, 'verificacao.json'));
196
+ const crossBound = verification?.evidenceEnvelopeId === sensorEnvelope.envelope_id
197
+ && verdict?.evidenceEnvelopeId === sensorEnvelope.envelope_id
198
+ && evidenceCheckoutBindingMatches(verification?.evidenceBinding, checkoutBinding)
199
+ && evidenceCheckoutBindingMatches(verdict?.evidenceBinding, checkoutBinding);
200
+ if (!crossBound) {
201
+ evidence.sensors_binding = 'stale';
202
+ evidence.sensors_binding_reasons = ['archived verification/verdict binding mismatch'];
203
+ }
204
+ } else if (assessed.reasons.length) {
205
+ evidence.sensors_binding_reasons = assessed.reasons;
206
+ }
207
+ if (sensorEnvelope?.schema_version === 2) {
208
+ evidence.sensors_envelope_id = sensorEnvelope.envelope_id;
209
+ evidence.sensors_tasks_hash = sensorEnvelope.tasks_sha256;
210
+ evidence.sensors_repository_id = sensorEnvelope.repository_id;
211
+ evidence.sensors_worktree_id = sensorEnvelope.worktree_id;
212
+ }
183
213
  }
184
214
  }
185
215
  }
@@ -279,12 +309,23 @@ export function buildSessionMemoryEvents({
279
309
  if (Array.isArray(evidence.sensors) && evidence.sensors.length) {
280
310
  events.push(makeEvent(context, {
281
311
  memoryKey: 'quality.latest-sensors',
282
- value: [...new Set(evidence.sensors.map(String))].sort(),
283
- authority: 'verified',
284
- evidence: evidence.sensors,
312
+ value: {
313
+ ids: [...new Set(evidence.sensors.map(String))].sort(),
314
+ evidence_state: evidence.sensors_binding || 'legacy-unbound',
315
+ ...(evidence.sensors_envelope_id ? { envelope_id: evidence.sensors_envelope_id } : {}),
316
+ ...(evidence.sensors_binding && !['bound', 'legacy-unbound'].includes(evidence.sensors_binding)
317
+ ? { recovery: 'reabra a change e rode wendkeep verify --deep + wk-verify' }
318
+ : {}),
319
+ },
320
+ authority: evidence.sensors_binding === 'bound' ? 'verified' : 'reported',
321
+ evidence: [evidence.sensors_path].filter(Boolean),
285
322
  scopeContext: {
286
323
  changeSlug: evidence.change?.slug || normalizedShared?.change_slug,
287
324
  tasksHash: evidence.sensors_tasks_hash || normalizedShared?.tasks_hash,
325
+ repositoryId: evidence.sensors_repository_id,
326
+ worktreeId: evidence.sensors_worktree_id,
327
+ evidenceEnvelopeId: evidence.sensors_envelope_id,
328
+ evidenceState: evidence.sensors_binding,
288
329
  },
289
330
  }));
290
331
  }
@@ -225,10 +225,20 @@ export function writeVaultFileSync(vaultBase, targetPath, content, encoding = 'u
225
225
  export function writeVaultFileAtomic(vaultBase, targetPath, content, encoding = 'utf8', {
226
226
  label = 'arquivo atômico do Vault',
227
227
  code = 'VAULT_PATH_UNSAFE',
228
+ scopeRoot = '',
229
+ beforeRename,
228
230
  } = {}) {
229
231
  const checked = assertVaultPathSafe(vaultBase, targetPath, {
230
232
  expectedType: 'file', label, code,
231
233
  });
234
+ if (scopeRoot) {
235
+ const scope = assertVaultPathSafe(vaultBase, scopeRoot, {
236
+ allowMissing: false, expectedType: 'directory', label: `escopo de ${label}`, code,
237
+ });
238
+ if (!containedBy(scope.target, checked.target)) {
239
+ throw unsafe(`${label} escapa do escopo autorizado: ${checked.target}`, code);
240
+ }
241
+ }
232
242
  assertVaultPathSafe(vaultBase, dirname(checked.target), {
233
243
  allowMissing: false, expectedType: 'directory', label: `ancestral de ${label}`, code,
234
244
  });
@@ -245,6 +255,7 @@ export function writeVaultFileAtomic(vaultBase, targetPath, content, encoding =
245
255
  allowMissing: false, expectedType: 'file', label: `temporário de ${label}`, code,
246
256
  });
247
257
  assertVaultPathSafe(vaultBase, checked.target, { expectedType: 'file', label, code });
258
+ if (typeof beforeRename === 'function') beforeRename({ temporary: tmp, target: checked.target });
248
259
  renameSync(tmp, checked.target);
249
260
  tmpCreated = false;
250
261
  assertVaultPathSafe(vaultBase, checked.target, {
@@ -0,0 +1,92 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/rogersialves/wendkeep/schema/wendkeep.evidence-envelope-v2.schema.json",
4
+ "title": "WendKeep Evidence Envelope v2",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_version",
9
+ "project_id",
10
+ "repository_id",
11
+ "worktree_id",
12
+ "work_session_id",
13
+ "change_slug",
14
+ "branch",
15
+ "base_sha",
16
+ "head_sha",
17
+ "index_tree_sha",
18
+ "worktree_digest",
19
+ "dirty",
20
+ "tasks_sha256",
21
+ "effective_spec_sha256",
22
+ "sensor_config_sha256",
23
+ "wendkeep_version",
24
+ "platform",
25
+ "started_at",
26
+ "finished_at",
27
+ "sensors",
28
+ "envelope_id"
29
+ ],
30
+ "properties": {
31
+ "schema_version": { "const": 2 },
32
+ "project_id": { "type": "string", "minLength": 1 },
33
+ "repository_id": { "type": "string", "minLength": 1 },
34
+ "worktree_id": { "type": "string", "minLength": 1 },
35
+ "work_session_id": { "type": "string", "minLength": 1 },
36
+ "change_slug": { "type": "string", "minLength": 1 },
37
+ "branch": { "type": "string", "minLength": 1 },
38
+ "base_sha": { "$ref": "#/$defs/gitObject" },
39
+ "head_sha": { "$ref": "#/$defs/gitObject" },
40
+ "index_tree_sha": { "$ref": "#/$defs/gitObject" },
41
+ "worktree_digest": { "$ref": "#/$defs/sha256" },
42
+ "dirty": { "type": "boolean" },
43
+ "tasks_sha256": { "$ref": "#/$defs/sha256" },
44
+ "effective_spec_sha256": { "$ref": "#/$defs/sha256" },
45
+ "sensor_config_sha256": { "$ref": "#/$defs/sha256" },
46
+ "wendkeep_version": { "type": "string", "minLength": 1 },
47
+ "platform": { "type": "string", "minLength": 1 },
48
+ "started_at": { "type": "string", "format": "date-time" },
49
+ "finished_at": { "type": "string", "format": "date-time" },
50
+ "envelope_id": { "$ref": "#/$defs/sha256" },
51
+ "sensors": {
52
+ "type": "array",
53
+ "items": { "$ref": "#/$defs/sensor" }
54
+ }
55
+ },
56
+ "$defs": {
57
+ "gitObject": { "type": "string", "pattern": "^[a-f0-9]{40,64}$" },
58
+ "sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
59
+ "sensor": {
60
+ "type": "object",
61
+ "required": [
62
+ "id",
63
+ "status",
64
+ "severity",
65
+ "command",
66
+ "command_sha256",
67
+ "started_at",
68
+ "finished_at",
69
+ "duration_ms",
70
+ "exit_code",
71
+ "output_sha256",
72
+ "output_tail"
73
+ ],
74
+ "properties": {
75
+ "id": { "type": "string", "minLength": 1 },
76
+ "status": { "enum": ["green", "red"] },
77
+ "severity": { "enum": ["critical", "warning"] },
78
+ "command": { "type": "string" },
79
+ "command_sha256": { "$ref": "#/$defs/sha256" },
80
+ "started_at": { "type": "string", "format": "date-time" },
81
+ "finished_at": { "type": "string", "format": "date-time" },
82
+ "duration_ms": { "type": "number", "minimum": 0 },
83
+ "exit_code": { "type": ["integer", "null"] },
84
+ "output_sha256": { "$ref": "#/$defs/sha256" },
85
+ "output_tail": { "type": "string", "maxLength": 2000 },
86
+ "note": { "type": "string", "maxLength": 2000 },
87
+ "survivors": { "type": "array" },
88
+ "ts": { "type": "string", "format": "date-time" }
89
+ }
90
+ }
91
+ }
92
+ }
package/src/change.mjs CHANGED
@@ -19,13 +19,24 @@ import {
19
19
  isGuideCompactChange,
20
20
  setActiveChange,
21
21
  } from '../hooks/change-core.mjs';
22
- import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
22
+ import { evaluateGate, loadSensorsDetailed, requiredSensors } from '../hooks/sensors-core.mjs';
23
23
  import { buildEffectiveRequirementPackage, evaluateVerdict, formatOrphanReqs, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
24
24
  import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
25
25
  import { getLocale } from '../hooks/locale.mjs';
26
26
  import { enqueueObserverDocumentChange } from './observer-sql-publish.mjs';
27
27
  import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
28
28
  import { resolveCommandActiveContext } from './active-context-runtime.mjs';
29
+ import {
30
+ captureGitSnapshot,
31
+ resolveEvidenceIdentity,
32
+ sensorConfigSha256,
33
+ } from './evidence-envelope.mjs';
34
+ import {
35
+ evaluateEvidenceBinding,
36
+ evidenceCheckoutBinding,
37
+ evidenceCheckoutBindingMatches,
38
+ evidenceSensors,
39
+ } from '../packages/vault/src/evidence-envelope.mjs';
29
40
 
30
41
  function observerMarkdownUnder(vaultBase, relativeRoot) {
31
42
  const output = [];
@@ -74,15 +85,15 @@ export function runChange(argv) {
74
85
  const VALUE_FLAGS = new Set(['--vault', '--change', '--project', '--session']);
75
86
  const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
76
87
  const projectRoot = resolve(opt(rest, '--project') || process.cwd());
88
+ const sessionId = opt(rest, '--session')
89
+ || process.env.CODEX_THREAD_ID
90
+ || process.env.CLAUDE_SESSION_ID
91
+ || '';
77
92
  let contextResolved = false;
78
93
  let resolvedContext = null;
79
94
  const context = () => {
80
95
  if (contextResolved) return resolvedContext;
81
96
  contextResolved = true;
82
- const sessionId = opt(rest, '--session')
83
- || process.env.CODEX_THREAD_ID
84
- || process.env.CLAUDE_SESSION_ID
85
- || '';
86
97
  try {
87
98
  resolvedContext = resolveCommandActiveContext({ vaultBase, projectRoot, sessionId });
88
99
  return resolvedContext;
@@ -196,19 +207,48 @@ export function runChange(argv) {
196
207
  }
197
208
  let evidence = null;
198
209
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* sem evidência */ }
199
- if (evidence) for (const e of evidence) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id} (${e.severity || 'critical'})\n`);
210
+ if (evidence) for (const e of evidenceSensors(evidence)) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id} (${e.severity || 'critical'})\n`);
200
211
  else process.stdout.write('evidencia: ausente\n');
201
212
  const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
202
213
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
203
214
  if (effective.errors.length || effective.missing.length) {
204
215
  process.stdout.write(`spec efetiva: inválida (${[...effective.errors, ...effective.missing.map((id) => `req órfão ${id}`)].join('; ')})\n`);
205
216
  }
217
+ if (evidence) {
218
+ let expected = {
219
+ change_slug: slug,
220
+ tasks_sha256: tasksHashOf(tarefasMd),
221
+ effective_spec_sha256: `sha256:${effective.hash}`,
222
+ };
223
+ let unavailable = '';
224
+ try {
225
+ const ids = requiredSensors(tasks);
226
+ const loaded = loadSensorsDetailed(projectRoot);
227
+ expected = {
228
+ ...expected,
229
+ identity: resolveEvidenceIdentity({
230
+ vaultBase, projectRoot, changeSlug: slug, sessionId, context: context(),
231
+ }),
232
+ snapshot: captureGitSnapshot(projectRoot),
233
+ sensor_config_sha256: sensorConfigSha256(loaded.sensors, ids),
234
+ };
235
+ } catch (error) {
236
+ unavailable = error.code || error.message;
237
+ }
238
+ const binding = evaluateEvidenceBinding(evidence, expected);
239
+ process.stdout.write(`evidence-binding: ${binding.state}${binding.reasons.length ? ` (${binding.reasons.join('; ')})` : ''}${unavailable ? ` [current snapshot unavailable: ${unavailable}]` : ''}\n`);
240
+ }
206
241
  let verdict = null;
207
242
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
208
243
  if (!verdict) process.stdout.write(`verdict: ausente — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ' (verdict trivial automático)'}\n`);
209
244
  else if (!reqIds.length) process.stdout.write(`verdict: ${verdict.ok === true ? 'ok (trivial)' : 'não-ok — re-verifique'}\n`);
210
245
  else {
211
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd), effectiveSpecHash: effective.hash });
246
+ const v = evaluateVerdict(verdict, reqIds, {
247
+ tasksHash: tasksHashOf(tarefasMd),
248
+ effectiveSpecHash: effective.hash,
249
+ evidenceEnvelopeId: evidence?.schema_version === 2 ? evidence.envelope_id : undefined,
250
+ evidenceBinding: evidence?.schema_version === 2 ? evidenceCheckoutBinding(evidence) : undefined,
251
+ });
212
252
  process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
213
253
  }
214
254
  try { process.stdout.write(`mutation-round: ${readFileSync(join(dir, '.mutation-round'), 'utf8').trim()}/3\n`); } catch { /* sem rodadas */ }
@@ -289,9 +329,34 @@ export function runChange(argv) {
289
329
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
290
330
  if (effective.errors.length) return { ok: false, failing: [`spec efetiva inválida: ${effective.errors.join('; ')}`] };
291
331
  if (effective.missing.length) return { ok: false, failing: [formatOrphanReqs(effective.missing)] };
292
- let evidence = [];
332
+ let evidence = null;
293
333
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
294
- const s = evaluateGate(evidence, required);
334
+ const sensorEvidence = evidenceSensors(evidence);
335
+ if (required.length && (!evidence || evidence.schema_version !== 2)) {
336
+ return { ok: false, failing: ['evidência legacy-unbound não satisfaz autoridade v2 — rode `wendkeep verify` novamente'] };
337
+ }
338
+ if (evidence?.schema_version === 2) {
339
+ let currentBinding;
340
+ try {
341
+ const loaded = loadSensorsDetailed(projectRoot);
342
+ currentBinding = evaluateEvidenceBinding(evidence, {
343
+ change_slug: slug,
344
+ identity: resolveEvidenceIdentity({
345
+ vaultBase, projectRoot, changeSlug: slug, sessionId, context: selectedContext,
346
+ }),
347
+ snapshot: captureGitSnapshot(projectRoot),
348
+ tasks_sha256: tasksHashOf(tarefasMd),
349
+ effective_spec_sha256: `sha256:${effective.hash}`,
350
+ sensor_config_sha256: sensorConfigSha256(loaded.sensors, required),
351
+ });
352
+ } catch (error) {
353
+ return { ok: false, failing: [`binding atual indisponível (${error.code || error.message}) — recupere o contexto e rode \`wendkeep verify\` novamente`] };
354
+ }
355
+ if (currentBinding.state !== 'bound') {
356
+ return { ok: false, failing: [`evidência ${currentBinding.state} (${currentBinding.reasons.join('; ')}) — rode \`wendkeep verify\` novamente`] };
357
+ }
358
+ }
359
+ const s = evaluateGate(sensorEvidence, required);
295
360
  if (!s.ok) return s;
296
361
  // Verdict SEMPRE exigido (0.31.0) — a exigência universal vive AQUI no gate; a semântica
297
362
  // reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
@@ -310,6 +375,19 @@ export function runChange(argv) {
310
375
  }
311
376
  let verification = null;
312
377
  try { verification = JSON.parse(readFileSync(join(dir, 'verificacao.json'), 'utf8')); } catch { /* none */ }
378
+ const checkoutBinding = evidence?.schema_version === 2 ? evidenceCheckoutBinding(evidence) : null;
379
+ if (evidence?.schema_version === 2 && verification?.evidenceEnvelopeId !== evidence.envelope_id) {
380
+ return { ok: false, failing: ['pacote de verificação não está ligado ao envelope atual — rode `wendkeep verify --deep` novamente'] };
381
+ }
382
+ if (checkoutBinding && !evidenceCheckoutBindingMatches(verification?.evidenceBinding, checkoutBinding)) {
383
+ return { ok: false, failing: ['binding do pacote de verificação diverge do checkout provado — rode `wendkeep verify --deep` novamente'] };
384
+ }
385
+ if (evidence?.schema_version === 2 && verdict.evidenceEnvelopeId !== evidence.envelope_id) {
386
+ return { ok: false, failing: [`verdict não está ligado ao envelope atual — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
387
+ }
388
+ if (checkoutBinding && !evidenceCheckoutBindingMatches(verdict.evidenceBinding, checkoutBinding)) {
389
+ return { ok: false, failing: [`binding do verdict diverge do checkout provado — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
390
+ }
313
391
  if (verification?.effectiveSpecHash && verification.effectiveSpecHash !== effective.hash) {
314
392
  return { ok: false, failing: ['pacote de verificação stale (spec efetiva mudou) — rode `wendkeep verify --deep` novamente'] };
315
393
  }
@@ -317,7 +395,12 @@ export function runChange(argv) {
317
395
  return { ok: false, failing: ['verdict sem effectiveSpecHash — rode a skill wk-verify novamente'] };
318
396
  }
319
397
  if (reqIds.length) {
320
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash, effectiveSpecHash: effective.hash });
398
+ const v = evaluateVerdict(verdict, reqIds, {
399
+ tasksHash: hash,
400
+ effectiveSpecHash: effective.hash,
401
+ evidenceEnvelopeId: evidence?.schema_version === 2 ? evidence.envelope_id : undefined,
402
+ evidenceBinding: checkoutBinding || undefined,
403
+ });
321
404
  if (!v.ok) {
322
405
  if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
323
406
  return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
@@ -0,0 +1,288 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { isUtf8 } from 'node:buffer';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
7
+ import {
8
+ discoverWorktreeRepository,
9
+ readWorktreeRegistry,
10
+ worktreeIdentity,
11
+ } from '../packages/vault/src/worktree-metadata.mjs';
12
+ import {
13
+ canonicalSha256,
14
+ evaluateEvidenceBinding,
15
+ evidenceSensors,
16
+ } from '../packages/vault/src/evidence-envelope.mjs';
17
+
18
+ export { canonicalSha256, evaluateEvidenceBinding, evidenceSensors };
19
+
20
+ const PACKAGE_VERSION = JSON.parse(readFileSync(
21
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
22
+ 'utf8',
23
+ )).version;
24
+
25
+ export function sensorConfigSha256(sensors, ids) {
26
+ const selected = new Set(ids || []);
27
+ const canonical = (sensors || [])
28
+ .filter((sensor) => selected.has(sensor.id))
29
+ .map((sensor) => sensor)
30
+ .sort((left, right) => String(left.id || '').localeCompare(String(right.id || '')));
31
+ return canonicalSha256(canonical);
32
+ }
33
+
34
+ function normalizedPath(value) {
35
+ return String(value || '').replaceAll('\\', '/');
36
+ }
37
+
38
+ const BINARY_EXTENSIONS = new Set([
39
+ '.7z', '.a', '.avi', '.bin', '.bmp', '.class', '.dll', '.doc', '.docx', '.dylib', '.eot',
40
+ '.exe', '.gif', '.gz', '.ico', '.jar', '.jpeg', '.jpg', '.mov', '.mp3', '.mp4', '.o',
41
+ '.ogg', '.otf', '.pdf', '.png', '.so', '.tar', '.tif', '.tiff', '.ttf', '.wav', '.webm',
42
+ '.webp', '.woff', '.woff2', '.xls', '.xlsx', '.zip',
43
+ ]);
44
+
45
+ function normalizedContent(content, binary = false) {
46
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content || '');
47
+ if (binary || bytes.includes(0) || !isUtf8(bytes)) return bytes;
48
+ return Buffer.from(bytes.toString('utf8').replace(/\r\n?/g, '\n'), 'utf8');
49
+ }
50
+
51
+ export function digestWorktreeEntries(entries) {
52
+ const canonical = (entries || []).map((entry) => ({
53
+ layer: String(entry.layer || ''),
54
+ status: String(entry.status || ''),
55
+ path: normalizedPath(entry.path),
56
+ ...(entry.oldPath ? { old_path: normalizedPath(entry.oldPath) } : {}),
57
+ content_mode: entry.binary ? 'binary' : 'text',
58
+ content_sha256: entry.content == null ? null : canonicalSha256(normalizedContent(entry.content, entry.binary)),
59
+ })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
60
+ return canonicalSha256(canonical);
61
+ }
62
+
63
+ function gitResult(projectRoot, args, { spawn = spawnSync, allowFailure = false } = {}) {
64
+ const result = spawn('git', args, {
65
+ cwd: projectRoot,
66
+ encoding: null,
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ windowsHide: true,
69
+ });
70
+ if (!allowFailure && (result.error || result.status !== 0)) {
71
+ const detail = Buffer.from(result.stderr || '').toString('utf8').trim();
72
+ const error = new Error(`git ${args.join(' ')} falhou${detail ? `: ${detail}` : ''}`);
73
+ error.code = 'WENDKEEP_EVIDENCE_GIT_FAILED';
74
+ throw error;
75
+ }
76
+ return result;
77
+ }
78
+
79
+ function gitText(projectRoot, args, options) {
80
+ const result = gitResult(projectRoot, args, options);
81
+ if (result.status !== 0) return '';
82
+ return Buffer.from(result.stdout || '').toString('utf8').trim();
83
+ }
84
+
85
+ function gitBuffer(projectRoot, args, options) {
86
+ const result = gitResult(projectRoot, args, options);
87
+ return result.status === 0 ? Buffer.from(result.stdout || '') : Buffer.alloc(0);
88
+ }
89
+
90
+ function parseNameStatus(buffer, layer) {
91
+ const tokens = buffer.toString('utf8').split('\0');
92
+ if (tokens.at(-1) === '') tokens.pop();
93
+ const entries = [];
94
+ for (let index = 0; index < tokens.length;) {
95
+ const status = tokens[index++];
96
+ const renamed = /^[RC]/.test(status);
97
+ const oldPath = renamed ? tokens[index++] : '';
98
+ const path = tokens[index++];
99
+ if (!path) continue;
100
+ entries.push({ layer, status, path, ...(oldPath ? { oldPath } : {}) });
101
+ }
102
+ return entries;
103
+ }
104
+
105
+ function binaryAttributes(projectRoot, paths, options) {
106
+ const unique = [...new Set(paths.filter(Boolean))];
107
+ if (!unique.length) return new Map();
108
+ const tokens = gitBuffer(projectRoot, ['check-attr', '-z', 'binary', 'text', '--', ...unique], {
109
+ ...options, allowFailure: true,
110
+ }).toString('utf8').split('\0');
111
+ if (tokens.at(-1) === '') tokens.pop();
112
+ const attributes = new Map();
113
+ for (let index = 0; index + 2 < tokens.length; index += 3) {
114
+ const [path, attribute, value] = tokens.slice(index, index + 3);
115
+ const entry = attributes.get(path) || {};
116
+ entry[attribute] = value;
117
+ attributes.set(path, entry);
118
+ }
119
+ return attributes;
120
+ }
121
+
122
+ function pathIsBinary(path, attributes) {
123
+ const values = attributes.get(path) || {};
124
+ if (values.binary === 'set' || values.text === 'unset') return true;
125
+ if (values.binary === 'unset' || values.text === 'set' || values.text === 'auto') return false;
126
+ const normalized = normalizedPath(path).toLowerCase();
127
+ const dot = normalized.lastIndexOf('.');
128
+ return dot >= 0 && BINARY_EXTENSIONS.has(normalized.slice(dot));
129
+ }
130
+
131
+ function readWorkingPath(projectRoot, path) {
132
+ const root = resolve(projectRoot);
133
+ const absolute = resolve(root, ...normalizedPath(path).split('/'));
134
+ const scoped = relative(root, absolute);
135
+ if (scoped === '..' || scoped.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(scoped)) {
136
+ return null;
137
+ }
138
+ try {
139
+ if (!existsSync(absolute) || !statSync(absolute).isFile()) return null;
140
+ return readFileSync(absolute);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function changedEntries(projectRoot, options) {
147
+ const staged = parseNameStatus(gitBuffer(projectRoot, [
148
+ 'diff', '--cached', '--name-status', '-z', '--find-renames', '--no-ext-diff',
149
+ ], options), 'index');
150
+ const unstaged = parseNameStatus(gitBuffer(projectRoot, [
151
+ 'diff', '--name-status', '-z', '--find-renames', '--no-ext-diff',
152
+ ], options), 'worktree');
153
+ const untracked = gitBuffer(projectRoot, [
154
+ 'ls-files', '--others', '--exclude-standard', '-z',
155
+ ], options).toString('utf8').split('\0').filter(Boolean).map((path) => ({
156
+ layer: 'untracked', status: '?', path,
157
+ }));
158
+ const attributes = binaryAttributes(projectRoot, [
159
+ ...staged.map((entry) => entry.path),
160
+ ...unstaged.map((entry) => entry.path),
161
+ ...untracked.map((entry) => entry.path),
162
+ ], options);
163
+
164
+ for (const entry of staged) {
165
+ entry.binary = pathIsBinary(entry.path, attributes);
166
+ entry.content = /^D/.test(entry.status)
167
+ ? null
168
+ : gitBuffer(projectRoot, ['show', `:${entry.path}`], { ...options, allowFailure: true });
169
+ }
170
+ for (const entry of [...unstaged, ...untracked]) {
171
+ entry.binary = pathIsBinary(entry.path, attributes);
172
+ entry.content = /^D/.test(entry.status) ? null : readWorkingPath(projectRoot, entry.path);
173
+ }
174
+ return [...staged, ...unstaged, ...untracked];
175
+ }
176
+
177
+ function resolveBaseSha(projectRoot, headSha, options) {
178
+ const upstream = gitText(projectRoot, ['rev-parse', '--verify', '@{upstream}'], {
179
+ ...options, allowFailure: true,
180
+ });
181
+ let candidate = upstream;
182
+ if (!candidate) {
183
+ candidate = gitText(projectRoot, ['rev-parse', '--verify', 'refs/heads/main'], {
184
+ ...options, allowFailure: true,
185
+ });
186
+ }
187
+ if (!candidate) return headSha;
188
+ return gitText(projectRoot, ['merge-base', headSha, candidate], {
189
+ ...options, allowFailure: true,
190
+ }) || headSha;
191
+ }
192
+
193
+ export function captureGitSnapshot(projectRoot, { spawn = spawnSync } = {}) {
194
+ const options = { spawn };
195
+ const headSha = gitText(projectRoot, ['rev-parse', 'HEAD'], options);
196
+ const branch = gitText(projectRoot, ['symbolic-ref', '--short', '-q', 'HEAD'], {
197
+ ...options, allowFailure: true,
198
+ }) || 'HEAD';
199
+ const indexTreeSha = gitText(projectRoot, ['write-tree'], options);
200
+ const entries = changedEntries(projectRoot, options);
201
+ return {
202
+ branch,
203
+ base_sha: resolveBaseSha(projectRoot, headSha, options),
204
+ head_sha: headSha,
205
+ index_tree_sha: indexTreeSha,
206
+ worktree_digest: digestWorktreeEntries(entries),
207
+ dirty: entries.length > 0,
208
+ };
209
+ }
210
+
211
+ export function resolveEvidenceIdentity({
212
+ vaultBase,
213
+ projectRoot,
214
+ changeSlug,
215
+ sessionId = '',
216
+ context = null,
217
+ spawn = spawnSync,
218
+ } = {}) {
219
+ const project = readProjectForValidation(vaultBase);
220
+ const repository = discoverWorktreeRepository({ startDir: projectRoot, spawn });
221
+ const { registry } = readWorktreeRegistry(repository);
222
+ if (registry && project.ok && registry.projectId !== project.projectId) {
223
+ const error = new Error('PROJECT.json e registry de worktrees pertencem a projetos diferentes');
224
+ error.code = 'WENDKEEP_EVIDENCE_IDENTITY_MISMATCH';
225
+ throw error;
226
+ }
227
+ const repositoryId = context?.repositoryId
228
+ || registry?.repositoryId
229
+ || canonicalSha256({ git_common_dir: normalizedPath(repository.commonDir).toLowerCase() });
230
+ const projectId = context?.projectId
231
+ || (project.ok ? project.projectId : canonicalSha256({ repository_id: repositoryId }));
232
+ const worktreeId = context?.worktreeId || worktreeIdentity(repositoryId, repository.gitDir);
233
+ const requestedSession = String(sessionId || '').trim();
234
+ const workSessionId = context?.workSessionId
235
+ || requestedSession
236
+ || canonicalSha256({ project_id: projectId, worktree_id: worktreeId, change_slug: changeSlug });
237
+ return {
238
+ project_id: projectId,
239
+ repository_id: repositoryId,
240
+ worktree_id: worktreeId,
241
+ work_session_id: workSessionId,
242
+ };
243
+ }
244
+
245
+ export function assertStableHead(startSnapshot, finishSnapshot) {
246
+ if (startSnapshot?.head_sha !== finishSnapshot?.head_sha) {
247
+ const error = new Error(
248
+ `HEAD mudou durante verify (${startSnapshot?.head_sha || 'ausente'} -> ${finishSnapshot?.head_sha || 'ausente'}); rode novamente no commit estável`,
249
+ );
250
+ error.code = 'WENDKEEP_EVIDENCE_HEAD_CHANGED';
251
+ throw error;
252
+ }
253
+ }
254
+
255
+ export function buildEvidenceEnvelope({
256
+ identity,
257
+ changeSlug,
258
+ snapshot,
259
+ tasksSha256,
260
+ effectiveSpecSha256,
261
+ sensorConfigSha256: configSha256,
262
+ sensors,
263
+ startedAt,
264
+ finishedAt,
265
+ version = PACKAGE_VERSION,
266
+ runtimePlatform = `${process.platform}-${process.arch}`,
267
+ } = {}) {
268
+ const envelope = {
269
+ schema_version: 2,
270
+ ...identity,
271
+ change_slug: changeSlug,
272
+ branch: snapshot.branch,
273
+ base_sha: snapshot.base_sha,
274
+ head_sha: snapshot.head_sha,
275
+ index_tree_sha: snapshot.index_tree_sha,
276
+ worktree_digest: snapshot.worktree_digest,
277
+ dirty: snapshot.dirty,
278
+ tasks_sha256: tasksSha256,
279
+ effective_spec_sha256: effectiveSpecSha256,
280
+ sensor_config_sha256: configSha256,
281
+ wendkeep_version: version,
282
+ platform: runtimePlatform,
283
+ started_at: startedAt,
284
+ finished_at: finishedAt,
285
+ sensors,
286
+ };
287
+ return { ...envelope, envelope_id: canonicalSha256(envelope) };
288
+ }