wendkeep 0.80.2 → 0.85.1

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 (75) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.en.md +28 -11
  3. package/README.md +28 -11
  4. package/docs/en/commands/capabilities.md +82 -0
  5. package/docs/en/commands/getting-started.md +3 -1
  6. package/docs/en/commands/mcp.md +99 -0
  7. package/docs/en/commands/portable.md +88 -0
  8. package/docs/en/commands/sync-protocol.md +58 -0
  9. package/docs/en/commands/tdd.md +96 -0
  10. package/docs/en/commands/verify.md +5 -0
  11. package/docs/pt-BR/commands/capabilities.md +82 -0
  12. package/docs/pt-BR/commands/getting-started.md +3 -2
  13. package/docs/pt-BR/commands/mcp.md +99 -0
  14. package/docs/pt-BR/commands/portable.md +87 -0
  15. package/docs/pt-BR/commands/sync-protocol.md +58 -0
  16. package/docs/pt-BR/commands/tdd.md +96 -0
  17. package/docs/pt-BR/commands/verify.md +5 -0
  18. package/hooks/active-context-store.mjs +2 -0
  19. package/hooks/change-core.mjs +5 -0
  20. package/hooks/project-scope.mjs +2 -1
  21. package/hooks/session-ensure.mjs +23 -7
  22. package/hooks/session-start.mjs +20 -5
  23. package/package.json +3 -3
  24. package/packages/cli/src/index.mjs +42 -2
  25. package/packages/harness/src/sensors-core.mjs +16 -3
  26. package/packages/integrations/src/capabilities.mjs +220 -0
  27. package/packages/integrations/src/index.mjs +1 -0
  28. package/packages/mcp/src/audit.mjs +49 -0
  29. package/packages/mcp/src/cli.mjs +78 -0
  30. package/packages/mcp/src/config.mjs +22 -1
  31. package/packages/mcp/src/effects.mjs +115 -0
  32. package/packages/mcp/src/executor.mjs +354 -0
  33. package/packages/mcp/src/index.mjs +7 -0
  34. package/packages/mcp/src/server.mjs +342 -0
  35. package/packages/mcp/src/stdio.mjs +38 -0
  36. package/packages/mcp/src/sync.mjs +56 -0
  37. package/packages/pi/package.json +2 -1
  38. package/packages/pi/src/index.mjs +29 -0
  39. package/schema/handoff-contract-v1.schema.json +4 -0
  40. package/schema/host-capability-manifest-v1.schema.json +46 -0
  41. package/schema/host-coverage-v1.schema.json +55 -0
  42. package/schema/mcp-effect-manifest-v1.schema.json +36 -0
  43. package/schema/mcp-tool-input-v1.schema.json +32 -0
  44. package/schema/mcp-tool-result-v1.schema.json +22 -0
  45. package/schema/portable-active-work-v1.schema.json +38 -0
  46. package/schema/portable-state-v1.schema.json +36 -0
  47. package/schema/sync-event-v1.schema.json +25 -0
  48. package/schema/sync-private-envelope-v1.schema.json +16 -0
  49. package/schema/sync-state-v1.schema.json +18 -0
  50. package/schema/task-contract-v1.schema.json +2 -0
  51. package/schema/tdd-attestation-v1.schema.json +39 -0
  52. package/schema/wendkeep.evidence-envelope-v2.schema.json +17 -0
  53. package/schema/wendkeep.sensors.schema.json +19 -0
  54. package/src/active-context-runtime.mjs +1 -0
  55. package/src/capabilities.mjs +50 -0
  56. package/src/doctor.mjs +28 -0
  57. package/src/evidence-envelope.mjs +12 -6
  58. package/src/host-capabilities.mjs +34 -0
  59. package/src/init.mjs +3 -3
  60. package/src/mcp.mjs +7 -0
  61. package/src/observer-snapshot.mjs +25 -0
  62. package/src/portable.mjs +558 -0
  63. package/src/skills-seed.mjs +26 -0
  64. package/src/sync-adapters.mjs +188 -0
  65. package/src/sync-outbox.mjs +155 -0
  66. package/src/sync-protocol-cli.mjs +277 -0
  67. package/src/sync-protocol.mjs +368 -0
  68. package/src/sync.mjs +8 -0
  69. package/src/task-contracts.mjs +67 -2
  70. package/src/task.mjs +5 -1
  71. package/src/tdd-attestation-store.mjs +98 -0
  72. package/src/tdd-attestation.mjs +254 -0
  73. package/src/tdd.mjs +198 -0
  74. package/src/vault-readme.mjs +4 -4
  75. package/src/verify.mjs +24 -0
@@ -0,0 +1,558 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import {
4
+ appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync,
5
+ rmSync, statSync, writeFileSync,
6
+ } from 'node:fs';
7
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
+
9
+ import { readSessionRegistry } from '../hooks/obsidian-common.mjs';
10
+ import { discoverWorktreeRepository, readWorktreeRegistry } from '../packages/vault/src/worktree-metadata.mjs';
11
+ import { resolveProjectVault } from './project-vault.mjs';
12
+
13
+ const SCHEMA_VERSION = 1;
14
+ const DEFAULT_RELATIVE_PATH = '.wendkeep/portable/state.json';
15
+ const RUNTIME_STATE = '.brain/runtime/PORTABLE_ACTIVE_WORK.json';
16
+ const PROVENANCE_LEDGER = '.brain/runtime/PORTABLE_PROVENANCE.jsonl';
17
+ const HASH = /^sha256:[a-f0-9]{64}$/;
18
+ const MAX_STATE_BYTES = 16 * 1024 * 1024;
19
+ const MAX_ARTIFACT_BYTES = 1024 * 1024;
20
+ const MAX_ARTIFACTS = 4096;
21
+ const SHAREABLE_ROOTS = new Set(['07-Specs', '08-Mudanças', '08-Changes', '04-Decisões', '04-Decisions']);
22
+ const CHANGE_AUTHORED = new Set([
23
+ 'proposta.md', 'proposal.md', 'design.md', 'tarefas.md', 'tasks.md', 'artifacts.json',
24
+ '.spec-impact-v1', '.spec-impact-v1.json', '.spec-base.json', 'flow-origin.json',
25
+ ]);
26
+ const CHANGE_DERIVED = new Set([
27
+ 'evidencia.json', 'evidence.json', 'verificacao.json', 'verification.json', 'verdict.json',
28
+ ]);
29
+
30
+ function portableError(code, message) {
31
+ return Object.assign(new Error(message), { code });
32
+ }
33
+
34
+ function stableValue(value) {
35
+ if (Array.isArray(value)) return value.map(stableValue);
36
+ if (value && typeof value === 'object') {
37
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function canonicalJson(value) {
43
+ return JSON.stringify(stableValue(value));
44
+ }
45
+
46
+ function sha256(value) {
47
+ return `sha256:${createHash('sha256').update(String(value), 'utf8').digest('hex')}`;
48
+ }
49
+
50
+ function portablePath(value) {
51
+ return String(value || '').replaceAll('\\', '/').replace(/^\.\//, '');
52
+ }
53
+
54
+ function boundedString(value, field, maxLength = 512) {
55
+ if (typeof value !== 'string' || value.length > maxLength) {
56
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', `${field} must be a bounded string`);
57
+ }
58
+ if (/\0/.test(value) || /\b[A-Za-z]:\\/.test(value)
59
+ || /(^|\s)\/(?:home|Users|private|var\/folders|tmp)\//.test(value)
60
+ || /\b(?:gh[opsu]_|sk-)[A-Za-z0-9_-]{20,}/.test(value)) {
61
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', `${field} contains private data`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ function pathParts(value) {
67
+ return portablePath(value).split('/').filter(Boolean);
68
+ }
69
+
70
+ function safeRelativePath(value) {
71
+ const normalized = portablePath(value);
72
+ const parts = pathParts(normalized);
73
+ if (!normalized || isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized)
74
+ || parts.includes('..') || parts.includes('.') || normalized.includes('\0')) {
75
+ throw portableError('WENDKEEP_PORTABLE_PATH_UNSAFE', `unsafe portable path: ${value}`);
76
+ }
77
+ return normalized;
78
+ }
79
+
80
+ export function classifyPortableArtifact(logicalPath) {
81
+ const path = portablePath(logicalPath);
82
+ const parts = pathParts(path);
83
+ if (!parts.length) return 'secret';
84
+ if (path === '.brain/CORE.md') return 'authored';
85
+ if (parts[0] === '.brain') return 'runtime';
86
+ if (['02-Sessões', '02-Sessions'].includes(parts[0])) return 'secret';
87
+ if (['04-Decisões', '04-Decisions'].includes(parts[0])) return 'authored';
88
+ if (parts[0] === '07-Specs') return 'derived';
89
+ if (['08-Mudanças', '08-Changes'].includes(parts[0])) {
90
+ if (parts.includes('_arquivo') || parts.includes('_archive')) return 'derived';
91
+ if (parts[2] === 'specs') return 'authored';
92
+ if (CHANGE_DERIVED.has(parts.at(-1))) return 'derived';
93
+ if (parts.length === 3 && CHANGE_AUTHORED.has(parts[2])) return 'authored';
94
+ return 'runtime';
95
+ }
96
+ return 'secret';
97
+ }
98
+
99
+ function shareable(path) {
100
+ const category = classifyPortableArtifact(path);
101
+ return category === 'authored' || (category === 'derived' && portablePath(path).startsWith('07-Specs/'));
102
+ }
103
+
104
+ function redactPortableText(input) {
105
+ let text = String(input).replace(/\r\n?/g, '\n');
106
+ text = text.replace(/\b[A-Za-z]:\\(?:[^\s<>:"|?*\\]+\\)*[^\s<>:"|?*]*/g, '[REDACTED_PATH]');
107
+ text = text.replace(/(^|[\s=("'`])\/(?:home|Users|private|var\/folders|tmp)\/[^\s)"'`<>]*/g, '$1[REDACTED_PATH]');
108
+ text = text.replace(/\b(?:gh[opsu]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g, '[REDACTED_SECRET]');
109
+ text = text.replace(/(Authorization\s*:\s*Bearer\s+)[^\s]+/gi, '$1[REDACTED_SECRET]');
110
+ text = text.replace(/\b(token|password|secret|api[_-]?key)\s*=\s*[^\s]+/gi, '$1=[REDACTED_SECRET]');
111
+ return text;
112
+ }
113
+
114
+ function walkFiles(vaultBase, logicalRoot) {
115
+ const root = join(vaultBase, ...pathParts(logicalRoot));
116
+ if (!existsSync(root)) return [];
117
+ const results = [];
118
+ const visit = (absolute, logical) => {
119
+ const stat = lstatSync(absolute);
120
+ if (stat.isSymbolicLink()) return;
121
+ if (stat.isDirectory()) {
122
+ for (const name of readdirSync(absolute).sort()) visit(join(absolute, name), `${logical}/${name}`);
123
+ return;
124
+ }
125
+ if (stat.isFile() && stat.nlink === 1 && shareable(logical)) results.push({ absolute, logical: portablePath(logical) });
126
+ };
127
+ visit(root, logicalRoot);
128
+ return results;
129
+ }
130
+
131
+ function artifactsOf(vaultBase) {
132
+ const candidates = [];
133
+ const core = join(vaultBase, '.brain', 'CORE.md');
134
+ if (existsSync(core) && lstatSync(core).isFile() && lstatSync(core).nlink === 1) {
135
+ candidates.push({ absolute: core, logical: '.brain/CORE.md' });
136
+ }
137
+ for (const root of [...SHAREABLE_ROOTS].sort()) candidates.push(...walkFiles(vaultBase, root));
138
+ const unique = new Map(candidates.map((item) => [item.logical, item]));
139
+ return [...unique.values()].sort((left, right) => left.logical.localeCompare(right.logical)).map((item) => {
140
+ const content = redactPortableText(readFileSync(item.absolute, 'utf8'));
141
+ return {
142
+ path: item.logical,
143
+ category: classifyPortableArtifact(item.logical),
144
+ content_sha256: sha256(content),
145
+ content,
146
+ };
147
+ });
148
+ }
149
+
150
+ function parseTasks(content = '') {
151
+ const rows = [];
152
+ for (const line of String(content).split(/\r?\n/)) {
153
+ const match = line.match(/^\s*-\s*\[([ xX])\]\s+([A-Za-z0-9._-]+)\s+(.+?)\s*$/);
154
+ if (match) rows.push({ id: match[2], title: match[3], completed: match[1].toLowerCase() === 'x' });
155
+ }
156
+ return rows;
157
+ }
158
+
159
+ function digestArtifacts(artifacts, predicate) {
160
+ return sha256(canonicalJson(artifacts.filter(predicate).map(({ path, content_sha256 }) => ({ path, content_sha256 }))));
161
+ }
162
+
163
+ function activeWorkOf({ activeContexts, artifacts, repositoryId, baseSha = '', headSha = '', now }) {
164
+ return activeContexts.filter((context) => context?.state === 'active'
165
+ && (!repositoryId || context.repository_id === repositoryId)).map((context) => {
166
+ const slug = String(context.change_slug || '');
167
+ const rootCandidates = [`08-Mudanças/${slug}/`, `08-Changes/${slug}/`];
168
+ const changeArtifacts = artifacts.filter((item) => rootCandidates.some((root) => item.path.startsWith(root)));
169
+ const tasksArtifact = changeArtifacts.find((item) => /\/(?:tarefas|tasks)\.md$/.test(item.path));
170
+ const tasks = parseTasks(tasksArtifact?.content || '');
171
+ const completed = tasks.filter((task) => task.completed).map((task) => task.id);
172
+ const pending = tasks.filter((task) => !task.completed);
173
+ const current = pending[0] || null;
174
+ const branch = String(context.branch || '');
175
+ const revision = Number.isSafeInteger(Number(context.revision)) ? Number(context.revision) : 0;
176
+ return {
177
+ schema_version: 1,
178
+ active_work_id: sha256(`${context.repository_id || repositoryId}\0${branch}\0${slug}`).slice(7, 31),
179
+ project_id: String(context.project_id || ''),
180
+ repository_id: String(context.repository_id || repositoryId || ''),
181
+ change_slug: slug,
182
+ task_id: current?.id || '',
183
+ branch,
184
+ base_sha: String(context.base_sha || baseSha || ''),
185
+ head_sha: String(context.head_sha || headSha || ''),
186
+ spec_sha256: digestArtifacts(artifacts, (item) => item.path.startsWith('07-Specs/') || item.path.includes(`/${slug}/specs/`)),
187
+ tasks_sha256: tasksArtifact?.content_sha256 || sha256(''),
188
+ status: pending.length ? 'in_progress' : 'completed',
189
+ completed,
190
+ current_action: current ? { task_id: current.id, title: current.title } : {},
191
+ next_actions: pending.slice(1).map((task) => task.id),
192
+ blockers: [],
193
+ evidence_refs: [],
194
+ updated_at: now,
195
+ revision,
196
+ };
197
+ }).sort((left, right) => left.active_work_id.localeCompare(right.active_work_id));
198
+ }
199
+
200
+ function projectIdOf(vaultBase) {
201
+ try { return String(JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId || ''); }
202
+ catch { return ''; }
203
+ }
204
+
205
+ export function buildPortableState({
206
+ vaultBase, projectRoot = process.cwd(), repositoryId = '', activeContexts = [], baseSha = '', headSha = '',
207
+ now = new Date().toISOString(),
208
+ } = {}) {
209
+ if (!vaultBase) throw portableError('WENDKEEP_PORTABLE_VAULT_MISSING', 'vault is required');
210
+ const resolvedVault = resolve(vaultBase);
211
+ const artifacts = artifactsOf(resolvedVault);
212
+ const projectId = projectIdOf(vaultBase);
213
+ const priorProjection = activeContexts.length ? null : resumeState(resolvedVault);
214
+ const contexts = activeContexts.length ? activeContexts : (priorProjection?.active_work || []).map((item) => ({
215
+ ...item, state: 'active', project_id: item.project_id, repository_id: item.repository_id,
216
+ }));
217
+ const effectiveRepositoryId = String(repositoryId || priorProjection?.repository_id || '');
218
+ const active_work = activeWorkOf({
219
+ activeContexts: contexts, artifacts, repositoryId: effectiveRepositoryId, baseSha, headSha, now,
220
+ });
221
+ return {
222
+ schema_version: SCHEMA_VERSION,
223
+ kind: 'wendkeep-portable-state',
224
+ project_id: projectId,
225
+ repository_id: String(effectiveRepositoryId || active_work[0]?.repository_id || ''),
226
+ authored_sha256: digestArtifacts(artifacts, () => true),
227
+ artifacts,
228
+ active_work,
229
+ };
230
+ }
231
+
232
+ function atomicWrite(path, content) {
233
+ mkdirSync(dirname(path), { recursive: true });
234
+ const temporary = `${path}.${process.pid}.tmp`;
235
+ try {
236
+ writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx' });
237
+ renameSync(temporary, path);
238
+ } finally {
239
+ if (existsSync(temporary)) rmSync(temporary, { force: true });
240
+ }
241
+ }
242
+
243
+ function stateBytes(state) {
244
+ return `${JSON.stringify(stableValue(state), null, 2)}\n`;
245
+ }
246
+
247
+ function appendProvenance(vaultBase, operation, state, now = new Date().toISOString()) {
248
+ const path = join(vaultBase, ...pathParts(PROVENANCE_LEDGER));
249
+ mkdirSync(resolve(path, '..'), { recursive: true });
250
+ const record = {
251
+ schema_version: 1, operation, occurred_at: now,
252
+ project_id: String(state.project_id || ''), repository_id: String(state.repository_id || ''),
253
+ authored_sha256: String(state.authored_sha256 || ''), state_sha256: sha256(stateBytes(state)),
254
+ active_work: (state.active_work || []).map((item) => ({
255
+ active_work_id: item.active_work_id, revision: item.revision,
256
+ snapshot_sha256: sha256(canonicalJson(item)),
257
+ })),
258
+ };
259
+ appendFileSync(path, `${canonicalJson(record)}\n`, 'utf8');
260
+ }
261
+
262
+ export function exportPortableState(options = {}) {
263
+ const state = buildPortableState(options);
264
+ if (!state.project_id || !state.repository_id) {
265
+ throw portableError(
266
+ 'WENDKEEP_PORTABLE_IDENTITY_UNAVAILABLE',
267
+ 'PROJECT.json and the worktree registry must prove project/repository identity',
268
+ );
269
+ }
270
+ const output = resolve(options.output || join(options.projectRoot || process.cwd(), ...pathParts(DEFAULT_RELATIVE_PATH)));
271
+ const bytes = stateBytes(state);
272
+ const previous = existsSync(output) ? readFileSync(output, 'utf8') : '';
273
+ if (previous !== bytes) atomicWrite(output, bytes);
274
+ appendProvenance(options.vaultBase, 'export', state, options.now);
275
+ return { ok: true, output, changed: previous !== bytes, state_sha256: sha256(bytes), state };
276
+ }
277
+
278
+ function validateArtifact(artifact) {
279
+ exactKeys(artifact, new Set(['path', 'category', 'content_sha256', 'content']), 'portable artifact');
280
+ const path = safeRelativePath(artifact?.path);
281
+ if (!shareable(path)) throw portableError('WENDKEEP_PORTABLE_PATH_UNSAFE', `non-shareable path: ${path}`);
282
+ if (artifact.category !== classifyPortableArtifact(path)) {
283
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', `artifact category mismatch: ${path}`);
284
+ }
285
+ if (typeof artifact?.content !== 'string' || Buffer.byteLength(artifact.content, 'utf8') > MAX_ARTIFACT_BYTES
286
+ || !HASH.test(String(artifact?.content_sha256 || ''))
287
+ || sha256(artifact.content) !== artifact.content_sha256) {
288
+ throw portableError('WENDKEEP_PORTABLE_INTEGRITY', `content hash mismatch: ${path}`);
289
+ }
290
+ return { ...artifact, path };
291
+ }
292
+
293
+ function exactKeys(value, allowed, label) {
294
+ if (!value || typeof value !== 'object' || Array.isArray(value)
295
+ || Object.keys(value).some((key) => !allowed.has(key))) {
296
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', `${label} contains unknown fields`);
297
+ }
298
+ }
299
+
300
+ function validateStringArray(value, field) {
301
+ if (!Array.isArray(value) || value.length > 1024) {
302
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', `${field} must be a bounded array`);
303
+ }
304
+ const normalized = value.map((item) => boundedString(item, field, 512));
305
+ if (new Set(normalized).size !== normalized.length) {
306
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', `${field} contains duplicates`);
307
+ }
308
+ return normalized;
309
+ }
310
+
311
+ const ACTIVE_WORK_KEYS = new Set([
312
+ 'schema_version', 'active_work_id', 'project_id', 'repository_id', 'change_slug', 'task_id',
313
+ 'branch', 'base_sha', 'head_sha', 'spec_sha256', 'tasks_sha256', 'status', 'completed',
314
+ 'current_action', 'next_actions', 'blockers', 'evidence_refs', 'updated_at', 'revision',
315
+ ]);
316
+
317
+ function validateActiveWork(item) {
318
+ exactKeys(item, ACTIVE_WORK_KEYS, 'active-work');
319
+ if (item.schema_version !== 1 || !/^[a-f0-9]{24}$/.test(String(item.active_work_id || ''))
320
+ || !HASH.test(String(item.spec_sha256 || '')) || !HASH.test(String(item.tasks_sha256 || ''))
321
+ || !['in_progress', 'completed', 'blocked'].includes(item.status)
322
+ || !Number.isSafeInteger(item.revision) || item.revision < 0
323
+ || Number.isNaN(Date.parse(item.updated_at))) {
324
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', 'invalid active-work fields');
325
+ }
326
+ for (const field of ['project_id', 'repository_id', 'change_slug', 'task_id', 'branch', 'base_sha', 'head_sha']) {
327
+ boundedString(item[field], `active-work.${field}`, field === 'branch' ? 240 : 160);
328
+ }
329
+ if (!item.project_id || !item.repository_id || !/^(|[a-f0-9]{40,64})$/.test(item.base_sha)
330
+ || !/^(|[a-f0-9]{40,64})$/.test(item.head_sha)) {
331
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', 'invalid active-work identity or commit hash');
332
+ }
333
+ exactKeys(item.current_action, new Set(['task_id', 'title']), 'active-work.current_action');
334
+ for (const [key, value] of Object.entries(item.current_action)) boundedString(value, `current_action.${key}`, 512);
335
+ for (const field of ['completed', 'next_actions', 'blockers', 'evidence_refs']) validateStringArray(item[field], field);
336
+ return item;
337
+ }
338
+
339
+ function validateState(state, vaultBase) {
340
+ let size;
341
+ try { size = Buffer.byteLength(JSON.stringify(state), 'utf8'); } catch { size = MAX_STATE_BYTES + 1; }
342
+ if (state?.schema_version !== SCHEMA_VERSION || state?.kind !== 'wendkeep-portable-state'
343
+ || !Array.isArray(state?.artifacts) || state.artifacts.length > MAX_ARTIFACTS
344
+ || !Array.isArray(state?.active_work) || size > MAX_STATE_BYTES) {
345
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', 'invalid portable state schema');
346
+ }
347
+ exactKeys(state, new Set([
348
+ 'schema_version', 'kind', 'project_id', 'repository_id', 'authored_sha256', 'artifacts', 'active_work',
349
+ ]), 'portable state');
350
+ boundedString(state.project_id, 'project_id', 160);
351
+ boundedString(state.repository_id, 'repository_id', 160);
352
+ if (!state.project_id || !state.repository_id || !HASH.test(String(state.authored_sha256 || ''))) {
353
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', 'portable identity or digest is invalid');
354
+ }
355
+ const projectId = projectIdOf(vaultBase);
356
+ if (projectId && state.project_id && projectId !== state.project_id) {
357
+ throw portableError('WENDKEEP_PORTABLE_PROJECT_MISMATCH', 'portable state belongs to another project');
358
+ }
359
+ const artifacts = state.artifacts.map(validateArtifact);
360
+ if (new Set(artifacts.map((item) => item.path)).size !== artifacts.length
361
+ || new Set(state.active_work.map((item) => item.active_work_id)).size !== state.active_work.length) {
362
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', 'portable state contains duplicate identities');
363
+ }
364
+ state.active_work.forEach(validateActiveWork);
365
+ if (digestArtifacts(artifacts, () => true) !== state.authored_sha256) {
366
+ throw portableError('WENDKEEP_PORTABLE_INTEGRITY', 'authored state digest mismatch');
367
+ }
368
+ return { ...state, artifacts };
369
+ }
370
+
371
+ function resumeState(vaultBase) {
372
+ const path = join(vaultBase, ...pathParts(RUNTIME_STATE));
373
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
374
+ }
375
+
376
+ function activeWorkHash(item) {
377
+ return sha256(canonicalJson(item));
378
+ }
379
+
380
+ function assertNotStale(localState, incoming) {
381
+ const local = new Map((localState?.active_work || []).map((item) => [item.active_work_id, item]));
382
+ const received = new Set(incoming.active_work.map((item) => item.active_work_id));
383
+ for (const activeWorkId of local.keys()) {
384
+ if (!received.has(activeWorkId)) {
385
+ throw portableError('WENDKEEP_PORTABLE_STALE', 'portable state omits local active-work');
386
+ }
387
+ }
388
+ for (const item of incoming.active_work) {
389
+ const current = local.get(item.active_work_id);
390
+ if (!current) continue;
391
+ const incomingRevision = Number(item.revision || 0);
392
+ const localRevision = Number(current.revision || 0);
393
+ if (incomingRevision < localRevision) {
394
+ throw portableError('WENDKEEP_PORTABLE_STALE', `portable revision ${incomingRevision} is older than local ${localRevision}`);
395
+ }
396
+ if (incomingRevision === localRevision && activeWorkHash(item) !== activeWorkHash(current)) {
397
+ throw portableError('WENDKEEP_PORTABLE_CONFLICT', `portable revision ${incomingRevision} has a different hash`);
398
+ }
399
+ }
400
+ }
401
+
402
+ function safeImportTarget(vaultBase, logicalPath) {
403
+ const target = join(vaultBase, ...pathParts(safeRelativePath(logicalPath)));
404
+ const root = resolve(vaultBase);
405
+ const resolved = resolve(target);
406
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
407
+ throw portableError('WENDKEEP_PORTABLE_PATH_UNSAFE', `path escapes vault: ${logicalPath}`);
408
+ }
409
+ let cursor = root;
410
+ for (const part of pathParts(relative(root, resolved))) {
411
+ cursor = join(cursor, part);
412
+ if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink()) {
413
+ throw portableError('WENDKEEP_PORTABLE_PATH_UNSAFE', `symlink in import path: ${logicalPath}`);
414
+ }
415
+ }
416
+ if (existsSync(resolved) && lstatSync(resolved).isFile() && lstatSync(resolved).nlink !== 1) {
417
+ throw portableError('WENDKEEP_PORTABLE_PATH_UNSAFE', `hardlink import target: ${logicalPath}`);
418
+ }
419
+ return resolved;
420
+ }
421
+
422
+ function readStateFile(path) {
423
+ const target = resolve(path);
424
+ if (statSync(target).size > MAX_STATE_BYTES) {
425
+ throw portableError('WENDKEEP_PORTABLE_SCHEMA', 'portable state exceeds the byte limit');
426
+ }
427
+ return JSON.parse(readFileSync(target, 'utf8'));
428
+ }
429
+
430
+ export function importPortableState({ vaultBase, projectRoot = process.cwd(), state, input, now } = {}) {
431
+ const raw = state || readStateFile(input || join(projectRoot, ...pathParts(DEFAULT_RELATIVE_PATH)));
432
+ const validated = validateState(raw, vaultBase);
433
+ const registryContexts = Object.values(readSessionRegistry(vaultBase).active_contexts || {});
434
+ const current = buildPortableState({
435
+ vaultBase, projectRoot, repositoryId: validated.repository_id,
436
+ activeContexts: registryContexts,
437
+ now: validated.active_work[0]?.updated_at || now || new Date().toISOString(),
438
+ });
439
+ assertNotStale(current, validated);
440
+ const targets = validated.artifacts.map((artifact) => ({ artifact, target: safeImportTarget(vaultBase, artifact.path) }));
441
+ let imported = 0;
442
+ for (const { artifact, target } of targets) {
443
+ const previous = existsSync(target) ? readFileSync(target, 'utf8') : null;
444
+ if (previous !== artifact.content) {
445
+ atomicWrite(target, artifact.content);
446
+ imported += 1;
447
+ }
448
+ }
449
+ const projection = {
450
+ schema_version: 1, project_id: validated.project_id, repository_id: validated.repository_id,
451
+ authored_sha256: validated.authored_sha256, active_work: validated.active_work,
452
+ };
453
+ atomicWrite(join(vaultBase, ...pathParts(RUNTIME_STATE)), stateBytes(projection));
454
+ appendProvenance(vaultBase, 'import', validated, now);
455
+ return { ok: true, imported, unchanged: targets.length - imported, active_work: validated.active_work };
456
+ }
457
+
458
+ function artifactMap(state) {
459
+ return new Map((state?.artifacts || []).map((item) => [item.path, item.content_sha256]));
460
+ }
461
+
462
+ function inferredBuildOptions({ vaultBase, projectRoot, state }) {
463
+ return {
464
+ vaultBase, projectRoot, repositoryId: state.repository_id,
465
+ activeContexts: state.active_work.map((item) => ({
466
+ ...item, state: 'active', repository_id: item.repository_id, project_id: item.project_id,
467
+ })),
468
+ now: state.active_work[0]?.updated_at || new Date().toISOString(),
469
+ };
470
+ }
471
+
472
+ export function diffPortableState({ vaultBase, projectRoot = process.cwd(), input, state } = {}) {
473
+ const expected = validateState(state || readStateFile(input || join(projectRoot, ...pathParts(DEFAULT_RELATIVE_PATH))), vaultBase);
474
+ const actual = buildPortableState(inferredBuildOptions({ vaultBase, projectRoot, state: expected }));
475
+ const left = artifactMap(expected);
476
+ const right = artifactMap(actual);
477
+ const added = [...right.keys()].filter((path) => !left.has(path)).sort();
478
+ const removed = [...left.keys()].filter((path) => !right.has(path)).sort();
479
+ const changed = [...left.keys()].filter((path) => right.has(path) && left.get(path) !== right.get(path)).sort();
480
+ return { equal: !added.length && !removed.length && !changed.length, added, removed, changed, expected, actual };
481
+ }
482
+
483
+ export function inspectPortableState(options = {}) {
484
+ const input = resolve(options.input || join(options.projectRoot || process.cwd(), ...pathParts(DEFAULT_RELATIVE_PATH)));
485
+ if (!existsSync(input)) return { status: 'not_configured', input, issues: [] };
486
+ try {
487
+ const diff = diffPortableState({ ...options, input });
488
+ return { status: diff.equal ? 'current' : 'diverged', input, issues: [...diff.added, ...diff.removed, ...diff.changed], diff };
489
+ } catch (error) {
490
+ return { status: 'invalid', input, issues: [error.code || error.message], error };
491
+ }
492
+ }
493
+
494
+ function option(argv, name) {
495
+ const index = argv.indexOf(name);
496
+ if (index >= 0) return argv[index + 1] || '';
497
+ return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
498
+ }
499
+
500
+ function runtimeOptions(argv) {
501
+ const projectRoot = resolve(option(argv, '--project') || process.cwd());
502
+ const resolution = resolveProjectVault({ startDir: projectRoot, explicitVault: option(argv, '--vault') });
503
+ const vaultBase = resolution.base;
504
+ let repositoryId = '';
505
+ let headSha = '';
506
+ let baseSha = '';
507
+ try {
508
+ const repository = discoverWorktreeRepository({ startDir: projectRoot });
509
+ repositoryId = readWorktreeRegistry(repository).registry?.repositoryId || '';
510
+ headSha = repository.worktrees.find((item) => resolve(item.path) === resolve(repository.repoRoot))?.head || '';
511
+ const mergeBase = spawnSync('git', ['merge-base', 'HEAD', 'origin/main'], {
512
+ cwd: repository.repoRoot, encoding: 'utf8', windowsHide: true,
513
+ });
514
+ if (mergeBase.status === 0) baseSha = String(mergeBase.stdout || '').trim();
515
+ } catch { /* status/import can still operate before worktree metadata exists */ }
516
+ const registry = readSessionRegistry(vaultBase);
517
+ return {
518
+ vaultBase, projectRoot, repositoryId, headSha, baseSha,
519
+ activeContexts: Object.values(registry.active_contexts || {}),
520
+ };
521
+ }
522
+
523
+ export const PORTABLE_HELP = `wendkeep portable <status|export|import|diff> [options]
524
+
525
+ --project <path> project root (default: current directory)
526
+ --vault <path> explicit local Vault
527
+ --input <path> portable JSON to import or compare
528
+ --output <path> export destination (default: .wendkeep/portable/state.json)
529
+ --json structured output
530
+ `;
531
+
532
+ export function runPortable(argv = []) {
533
+ const sub = argv[0];
534
+ if (!sub || ['help', '--help', '-h'].includes(sub)) {
535
+ process.stdout.write(PORTABLE_HELP);
536
+ return 0;
537
+ }
538
+ const json = argv.includes('--json');
539
+ try {
540
+ const common = runtimeOptions(argv);
541
+ let result;
542
+ if (sub === 'status') result = inspectPortableState({ ...common, input: option(argv, '--input') });
543
+ else if (sub === 'export') result = exportPortableState({ ...common, output: option(argv, '--output') });
544
+ else if (sub === 'import') result = importPortableState({ ...common, input: option(argv, '--input') });
545
+ else if (sub === 'diff') result = diffPortableState({ ...common, input: option(argv, '--input') });
546
+ else throw portableError('WENDKEEP_PORTABLE_SUBCOMMAND_UNKNOWN', `unknown subcommand: ${sub}`);
547
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
548
+ else if (sub === 'status') process.stdout.write(`portable: ${result.status}${result.issues?.length ? ` (${result.issues.length} difference(s))` : ''}\n`);
549
+ else if (sub === 'export') process.stdout.write(`portable export: ${result.output}${result.changed ? ' (updated)' : ' (unchanged)'}\n`);
550
+ else if (sub === 'import') process.stdout.write(`portable import: ${result.imported} imported, ${result.unchanged} unchanged\n`);
551
+ else process.stdout.write(`portable diff: ${result.equal ? 'equal' : `${result.added.length} added, ${result.removed.length} removed, ${result.changed.length} changed`}\n`);
552
+ return sub === 'diff' && !result.equal ? 1 : sub === 'status' && ['invalid'].includes(result.status) ? 1 : 0;
553
+ } catch (error) {
554
+ const payload = { ok: false, code: error?.code || 'WENDKEEP_PORTABLE_FAILED', error: String(error?.message || error) };
555
+ process.stderr.write(json ? `${JSON.stringify(payload)}\n` : `wendkeep portable: ${payload.code}: ${payload.error}\n`);
556
+ return 2;
557
+ }
558
+ }
@@ -119,6 +119,19 @@ vermelho pedindo por ele — e nunca escreva um teste que passaria sob a impleme
119
119
  3. **Green** — o código mínimo pra passar.
120
120
  4. **Refactor** — limpe com os verdes te protegendo.
121
121
 
122
+ ## Atestação causal no WendKeep
123
+
124
+ Quando a tarefa declarar \`[tdd]\`, registre o ciclo no mesmo contexto causal:
125
+
126
+ 1. \`wendkeep tdd red <task> --requirement <ID> --test <path> --command "<cmd>" --session <id>\`.
127
+ 2. Confirme \`red-observed\`; \`invalid\` por teste já verde, import, sintaxe ou configuração não prova RED.
128
+ 3. Implemente e rode \`wendkeep tdd green <task> --command "<cmd>" --session <id>\`.
129
+ 4. Depois de refactor ou commit, consulte \`tdd status\` e revalide o GREEN se ficou stale.
130
+
131
+ GREEN de outra worktree/tarefa/requisito não fecha. Mudança de path do teste exige revisão. Waiver
132
+ somente com \`--reason\` e \`--authority\` humanos explícitos. A atestação não substitui cobertura,
133
+ mutação, sensores ou revisão independente.
134
+
122
135
  ## Derive do spec, não do código
123
136
 
124
137
  Escreva a asserção a partir do *critério de aceite* da spec efetiva
@@ -382,6 +395,19 @@ would pass under the wrong implementation.
382
395
  3. **Green** — the minimal code to pass.
383
396
  4. **Refactor** with the greens protecting you.
384
397
 
398
+ ## Causal attestation in WendKeep
399
+
400
+ When a task declares \`[tdd]\`, record the cycle in the same causal context:
401
+
402
+ 1. \`wendkeep tdd red <task> --requirement <ID> --test <path> --command "<cmd>" --session <id>\`.
403
+ 2. Confirm \`red-observed\`; \`invalid\` from an already-green test, import, syntax, or configuration does not prove RED.
404
+ 3. Implement and run \`wendkeep tdd green <task> --command "<cmd>" --session <id>\`.
405
+ 4. After a refactor or commit, check \`tdd status\` and revalidate GREEN when it is stale.
406
+
407
+ GREEN from another worktree/task/requirement cannot close. A changed test path requires review. A
408
+ waiver requires explicit human \`--reason\` and \`--authority\`. Attestation does not replace
409
+ coverage, mutation, sensors, or independent review.
410
+
385
411
  ## Derive from the spec, not the code
386
412
  Write assertions from the effective requirement (\`wendkeep spec effective --change <slug>\`), not by reading the
387
413
  implementation. Reading the code to write the test = it only confirms what the code already does.