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.
- package/CHANGELOG.md +103 -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
|
@@ -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='));
|
package/src/task-contracts.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
+
}
|