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.
- package/CHANGELOG.md +117 -0
- package/README.en.md +28 -11
- package/README.md +28 -11
- package/docs/en/commands/capabilities.md +82 -0
- package/docs/en/commands/getting-started.md +3 -1
- package/docs/en/commands/mcp.md +99 -0
- package/docs/en/commands/portable.md +88 -0
- package/docs/en/commands/sync-protocol.md +58 -0
- package/docs/en/commands/tdd.md +96 -0
- package/docs/en/commands/verify.md +5 -0
- package/docs/pt-BR/commands/capabilities.md +82 -0
- package/docs/pt-BR/commands/getting-started.md +3 -2
- package/docs/pt-BR/commands/mcp.md +99 -0
- package/docs/pt-BR/commands/portable.md +87 -0
- package/docs/pt-BR/commands/sync-protocol.md +58 -0
- package/docs/pt-BR/commands/tdd.md +96 -0
- package/docs/pt-BR/commands/verify.md +5 -0
- package/hooks/active-context-store.mjs +2 -0
- package/hooks/change-core.mjs +5 -0
- package/hooks/project-scope.mjs +2 -1
- package/hooks/session-ensure.mjs +23 -7
- package/hooks/session-start.mjs +20 -5
- package/package.json +3 -3
- package/packages/cli/src/index.mjs +42 -2
- package/packages/harness/src/sensors-core.mjs +16 -3
- package/packages/integrations/src/capabilities.mjs +220 -0
- package/packages/integrations/src/index.mjs +1 -0
- package/packages/mcp/src/audit.mjs +49 -0
- package/packages/mcp/src/cli.mjs +78 -0
- package/packages/mcp/src/config.mjs +22 -1
- package/packages/mcp/src/effects.mjs +115 -0
- package/packages/mcp/src/executor.mjs +354 -0
- package/packages/mcp/src/index.mjs +7 -0
- package/packages/mcp/src/server.mjs +342 -0
- package/packages/mcp/src/stdio.mjs +38 -0
- package/packages/mcp/src/sync.mjs +56 -0
- package/packages/pi/package.json +2 -1
- package/packages/pi/src/index.mjs +29 -0
- package/schema/handoff-contract-v1.schema.json +4 -0
- package/schema/host-capability-manifest-v1.schema.json +46 -0
- package/schema/host-coverage-v1.schema.json +55 -0
- package/schema/mcp-effect-manifest-v1.schema.json +36 -0
- package/schema/mcp-tool-input-v1.schema.json +32 -0
- package/schema/mcp-tool-result-v1.schema.json +22 -0
- package/schema/portable-active-work-v1.schema.json +38 -0
- package/schema/portable-state-v1.schema.json +36 -0
- package/schema/sync-event-v1.schema.json +25 -0
- package/schema/sync-private-envelope-v1.schema.json +16 -0
- package/schema/sync-state-v1.schema.json +18 -0
- package/schema/task-contract-v1.schema.json +2 -0
- package/schema/tdd-attestation-v1.schema.json +39 -0
- package/schema/wendkeep.evidence-envelope-v2.schema.json +17 -0
- package/schema/wendkeep.sensors.schema.json +19 -0
- package/src/active-context-runtime.mjs +1 -0
- package/src/capabilities.mjs +50 -0
- package/src/doctor.mjs +28 -0
- package/src/evidence-envelope.mjs +12 -6
- package/src/host-capabilities.mjs +34 -0
- package/src/init.mjs +3 -3
- package/src/mcp.mjs +7 -0
- package/src/observer-snapshot.mjs +25 -0
- package/src/portable.mjs +558 -0
- package/src/skills-seed.mjs +26 -0
- package/src/sync-adapters.mjs +188 -0
- package/src/sync-outbox.mjs +155 -0
- package/src/sync-protocol-cli.mjs +277 -0
- package/src/sync-protocol.mjs +368 -0
- package/src/sync.mjs +8 -0
- package/src/task-contracts.mjs +67 -2
- package/src/task.mjs +5 -1
- package/src/tdd-attestation-store.mjs +98 -0
- package/src/tdd-attestation.mjs +254 -0
- package/src/tdd.mjs +198 -0
- package/src/vault-readme.mjs +4 -4
- package/src/verify.mjs +24 -0
package/src/portable.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/skills-seed.mjs
CHANGED
|
@@ -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.
|