wendkeep 0.80.2 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +103 -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,368 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+
3
+ const HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
4
+ const OPERATIONS = new Set(['put', 'tombstone', 'resolve']);
5
+ const ID_PATTERN = /^[A-Za-z0-9._:@/-]{1,512}$/;
6
+
7
+ function syncError(code, message) {
8
+ return Object.assign(new Error(message), { code });
9
+ }
10
+
11
+ function stable(value) {
12
+ if (Array.isArray(value)) return value.map(stable);
13
+ if (value && typeof value === 'object') {
14
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
15
+ }
16
+ return value;
17
+ }
18
+
19
+ export function canonicalSyncJson(value) {
20
+ return JSON.stringify(stable(value));
21
+ }
22
+
23
+ export function syncSha256(value) {
24
+ return `sha256:${createHash('sha256').update(
25
+ typeof value === 'string' ? value : canonicalSyncJson(value), 'utf8',
26
+ ).digest('hex')}`;
27
+ }
28
+
29
+ function requiredText(value, field, maximum = 512) {
30
+ const text = String(value || '').trim();
31
+ if (!text || text.length > maximum || /[\0\r\n]/.test(text)) {
32
+ throw syncError('WENDKEEP_SYNC_SCHEMA_INVALID', `${field} is invalid`);
33
+ }
34
+ return text;
35
+ }
36
+
37
+ function encode(value) {
38
+ return encodeURIComponent(requiredText(value, 'record key component', 512));
39
+ }
40
+
41
+ export function canonicalRecordKey({
42
+ projectId, repositoryId, namespace, key, branch = '', worktreeId = '', scope = 'worktree',
43
+ } = {}) {
44
+ const project = encode(projectId);
45
+ const repository = encode(repositoryId);
46
+ const category = encode(namespace);
47
+ const name = encode(key);
48
+ if (!['project', 'branch', 'worktree'].includes(scope)) {
49
+ throw syncError('WENDKEEP_SYNC_SCOPE_INVALID', `unknown record scope: ${scope}`);
50
+ }
51
+ const branchPart = scope === 'project' ? '-' : encode(branch || '-');
52
+ const worktreePart = scope === 'worktree' ? encode(worktreeId || '-') : '-';
53
+ return `v1|p=${project}|r=${repository}|s=${scope}|b=${branchPart}|w=${worktreePart}|n=${category}|k=${name}`;
54
+ }
55
+
56
+ export function createSyncState(projectId) {
57
+ return {
58
+ schema_version: 1,
59
+ project_id: requiredText(projectId, 'project_id', 160),
60
+ records: {},
61
+ conflicts: {},
62
+ pending: {},
63
+ leases: {},
64
+ decisions: [],
65
+ applied_event_ids: [],
66
+ };
67
+ }
68
+
69
+ function validatePrivatePayload(payload) {
70
+ if (!payload || payload.schema_version !== 1 || payload.algorithm !== 'AES-256-GCM'
71
+ || !payload.key_id || !payload.iv || !payload.ciphertext || !payload.auth_tag) {
72
+ throw syncError('WENDKEEP_SYNC_PRIVATE_PLAINTEXT', 'private records require an E2E envelope');
73
+ }
74
+ }
75
+
76
+ export function createSyncEvent({
77
+ projectId, recordKey, revision, baseRevision, payload = null, causalParentIds = [],
78
+ actorId, deviceId, leaseId = '', observedAt, operation = 'put', privacy = 'shared',
79
+ } = {}) {
80
+ const project_id = requiredText(projectId, 'project_id', 160);
81
+ const record_key = requiredText(recordKey, 'record_key', 2048);
82
+ const actor_id = requiredText(actorId, 'actor_id', 160);
83
+ const device_id = requiredText(deviceId, 'device_id', 160);
84
+ const revisionNumber = Number(revision);
85
+ const baseNumber = Number(baseRevision);
86
+ if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1
87
+ || !Number.isSafeInteger(baseNumber) || baseNumber < 0
88
+ || revisionNumber !== baseNumber + 1) {
89
+ throw syncError('WENDKEEP_SYNC_REVISION_INVALID', 'revision must equal base_revision + 1');
90
+ }
91
+ if (!OPERATIONS.has(operation)) throw syncError('WENDKEEP_SYNC_OPERATION_INVALID', `invalid operation: ${operation}`);
92
+ if (!['shared', 'private'].includes(privacy)) throw syncError('WENDKEEP_SYNC_PRIVACY_INVALID', 'invalid privacy policy');
93
+ if (privacy === 'private') validatePrivatePayload(payload);
94
+ const timestamp = new Date(observedAt);
95
+ if (Number.isNaN(timestamp.getTime())) throw syncError('WENDKEEP_SYNC_TIME_INVALID', 'observed_at is invalid');
96
+ const parents = [...new Set(causalParentIds.map((item) => requiredText(item, 'causal_parent_id', 128)))].sort();
97
+ const draft = {
98
+ schema_version: 1,
99
+ project_id,
100
+ record_key,
101
+ revision: revisionNumber,
102
+ base_revision: baseNumber,
103
+ content_hash: syncSha256(operation === 'tombstone' ? null : payload),
104
+ causal_parent_ids: parents,
105
+ actor_id,
106
+ device_id,
107
+ lease_id: String(leaseId || ''),
108
+ observed_at: timestamp.toISOString(),
109
+ operation,
110
+ privacy,
111
+ payload: operation === 'tombstone' ? null : structuredClone(payload),
112
+ };
113
+ return { ...draft, event_id: syncSha256(draft).slice(7) };
114
+ }
115
+
116
+ export function validateSyncEvent(event, { projectId = '' } = {}) {
117
+ if (!event || event.schema_version !== 1 || typeof event !== 'object'
118
+ || !ID_PATTERN.test(String(event.event_id || '')) || !HASH_PATTERN.test(String(event.content_hash || ''))
119
+ || event.event_id !== syncSha256(Object.fromEntries(
120
+ Object.entries(event).filter(([key]) => key !== 'event_id'),
121
+ )).slice(7)) {
122
+ throw syncError('WENDKEEP_SYNC_EVENT_INVALID', 'event integrity validation failed');
123
+ }
124
+ if (projectId && event.project_id !== projectId) {
125
+ throw syncError('WENDKEEP_SYNC_PROJECT_MISMATCH', 'event belongs to another project');
126
+ }
127
+ createSyncEvent({
128
+ projectId: event.project_id,
129
+ recordKey: event.record_key,
130
+ revision: event.revision,
131
+ baseRevision: event.base_revision,
132
+ payload: event.payload,
133
+ causalParentIds: event.causal_parent_ids,
134
+ actorId: event.actor_id,
135
+ deviceId: event.device_id,
136
+ leaseId: event.lease_id,
137
+ observedAt: event.observed_at,
138
+ operation: event.operation,
139
+ privacy: event.privacy,
140
+ });
141
+ return event;
142
+ }
143
+
144
+ function eventCandidate(event) {
145
+ return {
146
+ event_id: event.event_id,
147
+ revision: event.revision,
148
+ base_revision: event.base_revision,
149
+ content_hash: event.content_hash,
150
+ causal_parent_ids: [...event.causal_parent_ids],
151
+ actor_id: event.actor_id,
152
+ device_id: event.device_id,
153
+ observed_at: event.observed_at,
154
+ operation: event.operation,
155
+ privacy: event.privacy,
156
+ payload: structuredClone(event.payload),
157
+ };
158
+ }
159
+
160
+ function recordFromEvent(event, { conflicted = false } = {}) {
161
+ return {
162
+ revision: event.revision,
163
+ content_hash: event.content_hash,
164
+ event_id: event.event_id,
165
+ causal_parent_ids: [...event.causal_parent_ids],
166
+ actor_id: event.actor_id,
167
+ device_id: event.device_id,
168
+ observed_at: event.observed_at,
169
+ operation: event.operation,
170
+ privacy: event.privacy,
171
+ payload: structuredClone(event.payload),
172
+ tombstone: event.operation === 'tombstone',
173
+ conflicted,
174
+ event: eventCandidate(event),
175
+ };
176
+ }
177
+
178
+ function rememberApplied(state, eventId) {
179
+ if (!state.applied_event_ids.includes(eventId)) state.applied_event_ids.push(eventId);
180
+ state.applied_event_ids.sort();
181
+ }
182
+
183
+ function addPending(state, event) {
184
+ const rows = state.pending[event.record_key] || [];
185
+ if (!rows.some((item) => item.event_id === event.event_id)) rows.push(structuredClone(event));
186
+ rows.sort((left, right) => left.revision - right.revision || left.event_id.localeCompare(right.event_id));
187
+ state.pending[event.record_key] = rows;
188
+ }
189
+
190
+ function conflict(state, current, incoming) {
191
+ const key = incoming.record_key;
192
+ const prior = state.conflicts[key]?.candidates || [];
193
+ const candidates = new Map(prior.map((item) => [item.event_id, item]));
194
+ if (current?.event) candidates.set(current.event.event_id, structuredClone(current.event));
195
+ candidates.set(incoming.event_id, eventCandidate(incoming));
196
+ state.conflicts[key] = {
197
+ schema_version: 1,
198
+ record_key: key,
199
+ status: 'open',
200
+ candidates: [...candidates.values()].sort((left, right) => left.event_id.localeCompare(right.event_id)),
201
+ };
202
+ if (current) current.conflicted = true;
203
+ rememberApplied(state, incoming.event_id);
204
+ return { status: 'conflict', event_id: incoming.event_id, record_key: key };
205
+ }
206
+
207
+ function replayPending(state, recordKey) {
208
+ const replayed = [];
209
+ let progress = true;
210
+ while (progress) {
211
+ progress = false;
212
+ const rows = state.pending[recordKey] || [];
213
+ const currentRevision = state.records[recordKey]?.revision || 0;
214
+ const ready = rows.filter((event) => event.base_revision <= currentRevision);
215
+ if (!ready.length) break;
216
+ for (const event of ready) {
217
+ state.pending[recordKey] = (state.pending[recordKey] || []).filter((item) => item.event_id !== event.event_id);
218
+ const outcome = applySyncEvent(state, event, { replay: true });
219
+ if (outcome.status === 'applied') replayed.push(event.event_id);
220
+ progress = true;
221
+ }
222
+ }
223
+ if (!(state.pending[recordKey] || []).length) delete state.pending[recordKey];
224
+ return replayed;
225
+ }
226
+
227
+ export function applySyncEvent(state, event, { replay = false } = {}) {
228
+ validateSyncEvent(event, { projectId: state?.project_id });
229
+ if (state.applied_event_ids.includes(event.event_id)) {
230
+ return { status: 'duplicate', event_id: event.event_id, record_key: event.record_key };
231
+ }
232
+ if ((state.pending[event.record_key] || []).some((item) => item.event_id === event.event_id) && !replay) {
233
+ return { status: 'pending', event_id: event.event_id, record_key: event.record_key };
234
+ }
235
+ const current = state.records[event.record_key] || null;
236
+ const currentRevision = current?.revision || 0;
237
+ if (state.conflicts[event.record_key]?.status === 'open') return conflict(state, current, event);
238
+ if (event.base_revision > currentRevision) {
239
+ addPending(state, event);
240
+ return { status: 'pending', event_id: event.event_id, record_key: event.record_key };
241
+ }
242
+ if (event.base_revision < currentRevision) return conflict(state, current, event);
243
+ if (event.causal_parent_ids.length && current && !event.causal_parent_ids.includes(current.event_id)) {
244
+ return conflict(state, current, event);
245
+ }
246
+ state.records[event.record_key] = recordFromEvent(event);
247
+ rememberApplied(state, event.event_id);
248
+ const replayed = replayPending(state, event.record_key);
249
+ return { status: 'applied', event_id: event.event_id, record_key: event.record_key, replayed };
250
+ }
251
+
252
+ export function resolveSyncConflict(state, {
253
+ recordKey, selectedEventId, actorId, deviceId, reason, observedAt = new Date().toISOString(),
254
+ } = {}) {
255
+ const key = requiredText(recordKey, 'record_key', 2048);
256
+ const set = state.conflicts[key];
257
+ if (!set || set.status !== 'open') throw syncError('WENDKEEP_SYNC_CONFLICT_NOT_FOUND', 'open conflict not found');
258
+ const selected = set.candidates.find((item) => item.event_id === selectedEventId);
259
+ if (!selected) throw syncError('WENDKEEP_SYNC_CONFLICT_SELECTION_INVALID', 'candidate is not in the conflict set');
260
+ const decisionReason = requiredText(reason, 'reason', 500);
261
+ const revision = Math.max(...set.candidates.map((item) => item.revision)) + 1;
262
+ const resolution = createSyncEvent({
263
+ projectId: state.project_id,
264
+ recordKey: key,
265
+ revision,
266
+ baseRevision: revision - 1,
267
+ payload: selected.payload,
268
+ causalParentIds: set.candidates.map((item) => item.event_id),
269
+ actorId,
270
+ deviceId,
271
+ observedAt,
272
+ operation: selected.operation === 'tombstone' ? 'tombstone' : 'resolve',
273
+ privacy: selected.privacy,
274
+ });
275
+ state.records[key] = recordFromEvent(resolution);
276
+ rememberApplied(state, resolution.event_id);
277
+ set.status = 'resolved';
278
+ set.resolution_event_id = resolution.event_id;
279
+ set.selected_event_id = selectedEventId;
280
+ const decision = {
281
+ schema_version: 1, record_key: key, selected_event_id: selectedEventId,
282
+ resolution_event_id: resolution.event_id, actor_id: requiredText(actorId, 'actor_id', 160),
283
+ device_id: requiredText(deviceId, 'device_id', 160), reason: decisionReason,
284
+ observed_at: new Date(observedAt).toISOString(),
285
+ };
286
+ state.decisions.push(decision);
287
+ return { status: 'resolved', event: resolution, decision };
288
+ }
289
+
290
+ export function acquireSyncLease(state, {
291
+ recordKey, leaseId, actorId, deviceId, acquiredAt, expiresAt, now = new Date().toISOString(),
292
+ } = {}) {
293
+ const key = requiredText(recordKey, 'record_key', 2048);
294
+ const acquiredTime = new Date(acquiredAt);
295
+ const expiresTime = new Date(expiresAt);
296
+ const serverTime = new Date(now);
297
+ if ([acquiredTime, expiresTime, serverTime].some((value) => Number.isNaN(value.getTime()))
298
+ || expiresTime.getTime() <= acquiredTime.getTime()) {
299
+ throw syncError('WENDKEEP_SYNC_LEASE_INVALID', 'lease timestamps are invalid');
300
+ }
301
+ const lease = {
302
+ lease_id: requiredText(leaseId, 'lease_id', 160),
303
+ actor_id: requiredText(actorId, 'actor_id', 160),
304
+ device_id: requiredText(deviceId, 'device_id', 160),
305
+ acquired_at: acquiredTime.toISOString(),
306
+ expires_at: expiresTime.toISOString(),
307
+ };
308
+ const existing = state.leases[key];
309
+ if (existing?.active && Date.parse(existing.active.expires_at) > serverTime.getTime()) {
310
+ if (existing.active.lease_id === lease.lease_id) return { status: 'existing', lease: existing.active };
311
+ throw syncError('WENDKEEP_SYNC_LEASE_HELD', 'record has an unexpired lease');
312
+ }
313
+ const history = [...(existing?.history || [])];
314
+ if (existing?.active) history.push({ ...existing.active, ended_as: 'expired' });
315
+ state.leases[key] = { schema_version: 1, active: lease, history };
316
+ return { status: existing?.active ? 'taken_over' : 'acquired', lease };
317
+ }
318
+
319
+ function keyBytes(value) {
320
+ let bytes;
321
+ try { bytes = Buffer.from(requiredText(value, 'encryption key', 256), 'base64'); }
322
+ catch { throw syncError('WENDKEEP_SYNC_KEY_INVALID', 'key must be base64'); }
323
+ if (bytes.length !== 32) throw syncError('WENDKEEP_SYNC_KEY_INVALID', 'key must decode to 32 bytes');
324
+ return bytes;
325
+ }
326
+
327
+ export function encryptPrivatePayload(payload, {
328
+ key, keyId, aad = '', iv = randomBytes(12),
329
+ } = {}) {
330
+ const keyBuffer = keyBytes(key);
331
+ const ivBuffer = Buffer.from(iv);
332
+ if (ivBuffer.length !== 12) throw syncError('WENDKEEP_SYNC_KEY_INVALID', 'AES-GCM IV must be 12 bytes');
333
+ const cipher = createCipheriv('aes-256-gcm', keyBuffer, ivBuffer);
334
+ cipher.setAAD(Buffer.from(String(aad), 'utf8'));
335
+ const encrypted = Buffer.concat([cipher.update(canonicalSyncJson(payload), 'utf8'), cipher.final()]);
336
+ return {
337
+ schema_version: 1,
338
+ algorithm: 'AES-256-GCM',
339
+ key_id: requiredText(keyId, 'key_id', 160),
340
+ iv: ivBuffer.toString('base64'),
341
+ ciphertext: encrypted.toString('base64'),
342
+ auth_tag: cipher.getAuthTag().toString('base64'),
343
+ };
344
+ }
345
+
346
+ export function decryptPrivatePayload(envelope, { key, aad = '' } = {}) {
347
+ try {
348
+ validatePrivatePayload(envelope);
349
+ const decipher = createDecipheriv('aes-256-gcm', keyBytes(key), Buffer.from(envelope.iv, 'base64'));
350
+ decipher.setAAD(Buffer.from(String(aad), 'utf8'));
351
+ decipher.setAuthTag(Buffer.from(envelope.auth_tag, 'base64'));
352
+ const bytes = Buffer.concat([
353
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
354
+ decipher.final(),
355
+ ]);
356
+ return JSON.parse(bytes.toString('utf8'));
357
+ } catch (error) {
358
+ if (error?.code === 'WENDKEEP_SYNC_KEY_INVALID') throw error;
359
+ throw syncError('WENDKEEP_SYNC_DECRYPT_FAILED', 'private payload authentication failed');
360
+ }
361
+ }
362
+
363
+ export function rotatePrivatePayloadKey(envelope, {
364
+ oldKey, newKey, newKeyId, aad = '', iv = randomBytes(12),
365
+ } = {}) {
366
+ const payload = decryptPrivatePayload(envelope, { key: oldKey, aad });
367
+ return encryptPrivatePayload(payload, { key: newKey, keyId: newKeyId, aad, iv });
368
+ }
package/src/sync.mjs CHANGED
@@ -20,6 +20,14 @@ function opt(argv, name) {
20
20
  const step = (n, label) => process.stdout.write(`\n[${n}/3] ${label}\n`);
21
21
 
22
22
  export async function runSync(argv) {
23
+ const { isSyncProtocolCommand, runSyncProtocol, SYNC_PROTOCOL_HELP } = await import('./sync-protocol-cli.mjs');
24
+ if (argv.includes('--help') || argv.includes('-h')) {
25
+ process.stdout.write(`wendkeep sync [--project <dir>] [--vault <dir>] [--yes]\n\n`);
26
+ process.stdout.write('Without a protocol subcommand, runs init -> sync-defs -> doctor.\n\n');
27
+ process.stdout.write(SYNC_PROTOCOL_HELP);
28
+ return 0;
29
+ }
30
+ if (isSyncProtocolCommand(argv)) return runSyncProtocol(argv);
23
31
  const projectRaw = opt(argv, '--project');
24
32
  const vaultRaw = opt(argv, '--vault');
25
33
  const hasProfile = argv.includes('--profile') || argv.some((a) => a.startsWith('--profile='));
@@ -11,6 +11,9 @@ import { parseTasks } from '../hooks/change-core.mjs';
11
11
  import { getLocale } from '../hooks/locale.mjs';
12
12
  import { buildEffectiveRequirementPackage, contentHashOf, tasksHashOf } from '../hooks/spec-core.mjs';
13
13
  import { activeContextKey, resolveActiveContext } from '../hooks/active-context-store.mjs';
14
+ import { evaluateHostCoverage } from '../packages/integrations/src/capabilities.mjs';
15
+ import { evaluateTddAttestation } from './tdd-attestation.mjs';
16
+ import { captureTddSnapshot, readTddAttestationStore } from './tdd-attestation-store.mjs';
14
17
 
15
18
  const IGNORED_DIRECTORIES = new Set(['.git', '.worktrees', 'node_modules', 'dist']);
16
19
  const BINDING_FIELDS = [
@@ -59,6 +62,8 @@ export function deriveTaskContracts(input = {}) {
59
62
  }
60
63
  const binding = bindingFrom(input);
61
64
  const artifactSpecs = new Map((input.artifactSpecs ?? []).map((spec) => [String(spec.name || ''), spec]));
65
+ const profile = String(input.profile || '').trim().toUpperCase();
66
+ const attestations = Array.isArray(input.tddAttestations) ? input.tddAttestations : [];
62
67
  return (input.tasks ?? []).map((task) => {
63
68
  const taskId = String(task.id || '').trim();
64
69
  const phase = String(task.phase || 'execute').trim().toLowerCase();
@@ -70,16 +75,26 @@ export function deriveTaskContracts(input = {}) {
70
75
  ? lease : null;
71
76
  const dependencies = uniqueStrings(task.dependencies);
72
77
  const requiredArtifacts = uniqueStrings(task.artifacts);
78
+ const requirementIds = uniqueStrings(task.reqs);
79
+ const tddRequired = (profile === 'GOVERN' && task.tdd === true)
80
+ || (profile === 'ASSURE' && phase === 'execute'
81
+ && (requirementIds.length > 0 || uniqueStrings(task.sensors).length > 0));
82
+ const tddAttestation = attestations.find((attestation) => (
83
+ String(attestation?.task_id || '') === taskId
84
+ && requirementIds.includes(String(attestation?.requirement_id || ''))
85
+ && ['green-observed', 'waived'].includes(String(attestation?.state || ''))
86
+ )) || null;
73
87
  const authored = {
74
88
  change_slug: changeSlug,
75
89
  task_id: taskId,
76
90
  title: String(task.text || '').trim(),
77
91
  phase,
78
92
  checked: task.done === true,
79
- requirement_ids: uniqueStrings(task.reqs),
93
+ requirement_ids: requirementIds,
80
94
  required_sensors: uniqueStrings(task.sensors ?? (task.sensor ? [task.sensor] : [])),
81
95
  required_artifacts: requiredArtifacts,
82
96
  dependencies,
97
+ tdd_required: tddRequired,
83
98
  binding,
84
99
  artifact_specs: requiredArtifacts.map((name) => artifactSpecs.get(name) ?? { name }),
85
100
  };
@@ -101,6 +116,8 @@ export function deriveTaskContracts(input = {}) {
101
116
  owner: activeLease?.owner_session_id ?? null,
102
117
  work_session_id: activeLease?.owner_work_session_id ?? null,
103
118
  evidence_envelope_id: input.evidenceEnvelopeId ?? null,
119
+ tdd_required: tddRequired,
120
+ tdd_attestation_id: tddAttestation ? String(tddAttestation.attestation_id || '') || null : null,
104
121
  checked: authored.checked,
105
122
  authored_sha256: sha256(canonicalJson(authored)),
106
123
  binding,
@@ -140,11 +157,20 @@ export function evaluateTaskContract(contract, options = {}) {
140
157
  .filter((name) => artifacts.get(name)?.satisfied !== true);
141
158
  const completedTasks = new Set(uniqueStrings(options.completedTaskIds));
142
159
  const openDependencies = uniqueStrings(contract.dependencies).filter((id) => !completedTasks.has(id));
160
+ const tddMissing = contract.tdd_required === true && !String(contract.tdd_attestation_id || '').trim();
143
161
 
144
162
  for (const id of missingRequirements) blockingFindings.push(taskFinding('TASK_REQUIREMENT_MISSING', 'requirement_ids', id, null));
145
163
  for (const id of missingSensors) blockingFindings.push(taskFinding('TASK_SENSOR_MISSING_OR_RED', 'required_sensors', id, sensors.get(id)?.status ?? null));
146
164
  for (const name of missingArtifacts) blockingFindings.push(taskFinding('TASK_ARTIFACT_MISSING', 'required_artifacts', name, null));
147
165
  for (const id of openDependencies) blockingFindings.push(taskFinding('TASK_DEPENDENCY_OPEN', 'dependencies', id, null));
166
+ if (tddMissing) {
167
+ blockingFindings.push(taskFinding(
168
+ 'TASK_TDD_ATTESTATION_MISSING_OR_INVALID',
169
+ 'tdd_attestation_id',
170
+ 'green-observed or waived',
171
+ null,
172
+ ));
173
+ }
148
174
 
149
175
  const canComplete = blockingFindings.length === 0;
150
176
  return {
@@ -290,6 +316,7 @@ export function buildTaskContractSnapshot({
290
316
  context = null,
291
317
  registeredArtifacts = [],
292
318
  artifactLimits,
319
+ profile = 'GOVERN',
293
320
  } = {}) {
294
321
  const slug = String(changeSlug || '').trim();
295
322
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) {
@@ -307,6 +334,10 @@ export function buildTaskContractSnapshot({
307
334
  const manifest = artifactManifest(changeDir);
308
335
  const evidence = readJson(join(changeDir, 'evidencia.json'), null);
309
336
  const causalContext = context || resolveActiveContext(vaultBase, identity);
337
+ const tddSnapshot = captureTddSnapshot(projectRoot);
338
+ const mutationSurvivors = (evidence?.sensors ?? []).flatMap((sensor) => sensor.survivors ?? []);
339
+ const tddAttestations = readTddAttestationStore(vaultBase, slug).attestations
340
+ .map((attestation) => evaluateTddAttestation(attestation, tddSnapshot, { mutationSurvivors }));
310
341
  const binding = {
311
342
  projectId: identity.projectId,
312
343
  activeContextId: activeContextKey(identity),
@@ -322,6 +353,8 @@ export function buildTaskContractSnapshot({
322
353
  artifactSpecs: manifest.specs,
323
354
  taskLeases: causalContext?.task_leases ?? {},
324
355
  evidenceEnvelopeId: evidence?.envelope_id ?? null,
356
+ profile,
357
+ tddAttestations,
325
358
  });
326
359
  const artifactEvaluation = evaluateArtifactSpecs({
327
360
  projectRoot,
@@ -339,6 +372,7 @@ export function buildTaskContractSnapshot({
339
372
  sensor_results: evidence?.sensors ?? [],
340
373
  evidence_envelope_id: evidence?.envelope_id ?? null,
341
374
  artifact_results: artifactEvaluation.results,
375
+ tdd_attestations: tddAttestations,
342
376
  };
343
377
  }
344
378
 
@@ -384,10 +418,16 @@ export function deriveHandoffContract(input = {}) {
384
418
  decisions: uniqueStrings(input.decisions),
385
419
  next_actions: uniqueStrings(input.nextActions),
386
420
  blockers: uniqueStrings(input.blockers),
421
+ tdd_attestation_ids: uniqueStrings(input.tddAttestationIds),
387
422
  head_sha: String(input.headSha || ''),
388
423
  tasks_sha256: String(input.tasksSha256 || ''),
389
424
  spec_sha256: String(input.specSha256 || ''),
390
- authority: 'verified',
425
+ ...(input.hostCoverage ? {
426
+ host_coverage: structuredClone(input.hostCoverage),
427
+ coverage_findings: Array.isArray(input.coverageFindings) ? structuredClone(input.coverageFindings) : [],
428
+ coverage_waivers: Array.isArray(input.coverageWaivers) ? structuredClone(input.coverageWaivers) : [],
429
+ } : {}),
430
+ authority: input.authority === 'reported' ? 'reported' : 'verified',
391
431
  };
392
432
  for (const field of ['from', 'to', 'active_context_id', 'head_sha', 'tasks_sha256', 'spec_sha256']) {
393
433
  if (!contract[field]) {
@@ -421,9 +461,15 @@ export function normalizeHandoffContract(value) {
421
461
  decisions: uniqueStrings(value.decisions),
422
462
  next_actions: uniqueStrings(value.next_actions),
423
463
  blockers: uniqueStrings(value.blockers),
464
+ tdd_attestation_ids: uniqueStrings(value.tdd_attestation_ids),
424
465
  head_sha: String(value.head_sha || ''),
425
466
  tasks_sha256: String(value.tasks_sha256 || ''),
426
467
  spec_sha256: String(value.spec_sha256 || ''),
468
+ ...(value.host_coverage ? {
469
+ host_coverage: structuredClone(value.host_coverage),
470
+ coverage_findings: Array.isArray(value.coverage_findings) ? structuredClone(value.coverage_findings) : [],
471
+ coverage_waivers: Array.isArray(value.coverage_waivers) ? structuredClone(value.coverage_waivers) : [],
472
+ } : {}),
427
473
  authority: value.authority === 'verified' ? 'verified' : 'reported',
428
474
  };
429
475
  if (!normalized.handoff_id || !normalized.active_context_id || !normalized.head_sha
@@ -485,6 +531,18 @@ export function buildStructuredTaskHandoff({
485
531
  ...uniqueStrings(base.blockers),
486
532
  ...(selectedEvaluation?.blocking_findings ?? []).map((finding) => finding.code),
487
533
  ]);
534
+ const hostCoverage = context?.host_coverage || null;
535
+ const coverageEvaluation = hostCoverage
536
+ ? evaluateHostCoverage(hostCoverage, ['session.stop', 'task.completed', 'edit.attribution'], {
537
+ waivers: Array.isArray(base.coverage_waivers) ? base.coverage_waivers : [],
538
+ })
539
+ : { ok: true, findings: [], waived: [] };
540
+ if (String(profile || '').toUpperCase() === 'ASSURE' && !coverageEvaluation.ok) {
541
+ throw Object.assign(new Error('ASSURE requires host capabilities or explicit human waivers'), {
542
+ code: 'HANDOFF_CAPABILITY_UNAVAILABLE',
543
+ findings: coverageEvaluation.findings,
544
+ });
545
+ }
488
546
  const contract = deriveHandoffContract({
489
547
  from: sessionId,
490
548
  to: base.to || 'next-session',
@@ -496,9 +554,16 @@ export function buildStructuredTaskHandoff({
496
554
  decisions: base.decisions,
497
555
  nextActions: base.next_actions,
498
556
  blockers,
557
+ tddAttestationIds: (snapshot.tdd_attestations ?? [])
558
+ .filter((attestation) => ['green-observed', 'waived'].includes(attestation.state))
559
+ .map((attestation) => attestation.attestation_id),
499
560
  headSha: snapshot.binding?.head_sha,
500
561
  tasksSha256: snapshot.binding?.tasks_sha256,
501
562
  specSha256: snapshot.binding?.effective_spec_sha256,
563
+ hostCoverage,
564
+ coverageFindings: coverageEvaluation.findings,
565
+ coverageWaivers: coverageEvaluation.waived,
566
+ authority: coverageEvaluation.ok ? 'verified' : 'reported',
502
567
  });
503
568
  assertStructuredHandoffForProfile(profile, contract);
504
569
  return {
package/src/task.mjs CHANGED
@@ -5,6 +5,7 @@ import { resolveCommandActiveContext } from './active-context-runtime.mjs';
5
5
  import { buildTaskContractSnapshot, evaluateTaskContracts } from './task-contracts.mjs';
6
6
  import { claimTaskLease, releaseTaskLease } from './task-leases.mjs';
7
7
  import { findProjectRoot } from '../packages/harness/src/sensors-core.mjs';
8
+ import { resolveHookOperatingProfile } from '../hooks/operating-profile-runtime.mjs';
8
9
 
9
10
  const HELP = `wendkeep task <list|show|evaluate|claim|release> [task-id]
10
11
 
@@ -46,9 +47,12 @@ function commandState(argv) {
46
47
  if (explicitChange && context.change_slug && explicitChange !== context.change_slug) {
47
48
  throw Object.assign(new Error('requested change differs from active context'), { code: 'TASK_CHANGE_CONTEXT_MISMATCH' });
48
49
  }
50
+ const runtime = resolveHookOperatingProfile({
51
+ input: { cwd: projectRoot, session_id: identity.sessionId || sessionId },
52
+ });
49
53
  return {
50
54
  json, vaultBase, projectRoot, sessionId: identity.sessionId || sessionId,
51
- identity, context, changeSlug,
55
+ identity, context, changeSlug, profile: runtime.profile,
52
56
  };
53
57
  }
54
58
 
@@ -0,0 +1,98 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ import { getLocale } from '../hooks/locale.mjs';
7
+ import {
8
+ withVaultPathLock,
9
+ writeVaultFileAtomic,
10
+ } from '../packages/vault/src/vault-path-safety.mjs';
11
+ import { captureGitSnapshot } from './evidence-envelope.mjs';
12
+
13
+ function git(projectRoot, args, { allowFailure = false } = {}) {
14
+ const result = spawnSync('git', args, {
15
+ cwd: projectRoot,
16
+ encoding: 'utf8',
17
+ windowsHide: true,
18
+ maxBuffer: 8 * 1024 * 1024,
19
+ });
20
+ if (!allowFailure && result.status !== 0) {
21
+ throw Object.assign(new Error(result.stderr || `git ${args.join(' ')} failed`), {
22
+ code: 'TDD_GIT_FAILED',
23
+ });
24
+ }
25
+ return result;
26
+ }
27
+
28
+ function zeroPaths(value) {
29
+ return String(value || '').split('\0').map((path) => path.trim()).filter(Boolean);
30
+ }
31
+
32
+ function sha256(value) {
33
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
34
+ }
35
+
36
+ export function captureTddSnapshot(projectRoot) {
37
+ const snapshot = captureGitSnapshot(projectRoot);
38
+ const changed = zeroPaths(git(projectRoot, ['diff', '--name-only', '-z', 'HEAD']).stdout);
39
+ const untracked = zeroPaths(git(projectRoot, ['ls-files', '--others', '--exclude-standard', '-z']).stdout);
40
+ const paths = [...new Set([...changed, ...untracked])].sort();
41
+ const changeManifest = {};
42
+ for (const path of paths) {
43
+ const target = join(projectRoot, ...path.split('/'));
44
+ if (!existsSync(target)) changeManifest[path] = 'deleted';
45
+ else if (lstatSync(target).isFile()) changeManifest[path] = sha256(readFileSync(target));
46
+ }
47
+ return { ...snapshot, change_manifest: changeManifest };
48
+ }
49
+
50
+ export function committedPathsBetween(projectRoot, from, to) {
51
+ if (!from || !to || from === to) return [];
52
+ return zeroPaths(git(projectRoot, ['diff', '--name-only', '-z', from, to]).stdout).sort();
53
+ }
54
+
55
+ export function isGitAncestor(projectRoot, from, to) {
56
+ if (!from || !to) return false;
57
+ return git(projectRoot, ['merge-base', '--is-ancestor', from, to], { allowFailure: true }).status === 0;
58
+ }
59
+
60
+ export function tddAttestationStorePath(vaultBase, changeSlug) {
61
+ return join(vaultBase, getLocale(vaultBase).folders.changes, changeSlug, 'tdd-attestations.json');
62
+ }
63
+
64
+ export function readTddAttestationStore(vaultBase, changeSlug) {
65
+ const path = tddAttestationStorePath(vaultBase, changeSlug);
66
+ if (!existsSync(path)) return { schema_version: 1, attestations: [] };
67
+ let parsed;
68
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')); }
69
+ catch (cause) {
70
+ throw Object.assign(new Error('tdd-attestations.json is invalid JSON'), {
71
+ code: 'TDD_STORE_INVALID', cause,
72
+ });
73
+ }
74
+ if (parsed?.schema_version !== 1 || !Array.isArray(parsed.attestations)) {
75
+ throw Object.assign(new Error('tdd-attestations.json must use schema_version 1'), {
76
+ code: 'TDD_STORE_INVALID',
77
+ });
78
+ }
79
+ return parsed;
80
+ }
81
+
82
+ export function saveTddAttestation(vaultBase, changeSlug, attestation) {
83
+ const path = tddAttestationStorePath(vaultBase, changeSlug);
84
+ const outcome = withVaultPathLock(vaultBase, path, () => {
85
+ const store = readTddAttestationStore(vaultBase, changeSlug);
86
+ const attestations = store.attestations.filter((item) => item.attestation_id !== attestation.attestation_id);
87
+ attestations.push(attestation);
88
+ attestations.sort((left, right) => String(left.attestation_id).localeCompare(String(right.attestation_id)));
89
+ writeVaultFileAtomic(vaultBase, path, `${JSON.stringify({ schema_version: 1, attestations }, null, 2)}\n`, 'utf8', {
90
+ label: 'TDD attestation store',
91
+ });
92
+ return attestation;
93
+ }, { timeoutMs: 5_000, code: 'TDD_STORE_BUSY' });
94
+ if (typeof outcome === 'symbol') {
95
+ throw Object.assign(new Error('TDD attestation store is busy'), { code: 'TDD_STORE_BUSY' });
96
+ }
97
+ return outcome;
98
+ }