@openprd/cli 0.1.19 → 0.1.22

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 (102) hide show
  1. package/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
  2. package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
  3. package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
  4. package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
  6. package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
  7. package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
  8. package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
  9. package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
  10. package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
  11. package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
  12. package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
  13. package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
  14. package/.openprd/design/active/asset-spec.md +19 -14
  15. package/.openprd/design/active/direction-plan.md +19 -3
  16. package/.openprd/design/active/facts-sheet.md +16 -7
  17. package/.openprd/design/active/image-preflight.md +6 -5
  18. package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
  19. package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
  20. package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
  21. package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
  22. package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
  23. package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
  24. package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
  25. package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
  26. package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
  27. package/.openprd/design/active/selected-direction.md +23 -9
  28. package/.openprd/engagements/active/control-plane-architecture.json +203 -0
  29. package/.openprd/engagements/active/control-plane-intake.json +418 -0
  30. package/.openprd/engagements/active/prd.md +183 -119
  31. package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
  32. package/.openprd/i18n-config.json +12 -0
  33. package/.openprd/ledger/events.jsonl +6 -0
  34. package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
  35. package/.openprd/manifest.json +21 -0
  36. package/AGENTS.md +7 -6
  37. package/README.md +31 -29
  38. package/README_EN.md +36 -39
  39. package/package.json +1 -1
  40. package/skills/openprd-frontend-design/SKILL.md +16 -0
  41. package/skills/openprd-harness/SKILL.md +7 -7
  42. package/skills/openprd-quality/SKILL.md +1 -1
  43. package/skills/openprd-requirement-intake/SKILL.md +1 -1
  44. package/skills/openprd-shared/SKILL.md +6 -6
  45. package/src/adapters/adapter-spi.js +193 -0
  46. package/src/adapters/capability-envelope.js +98 -0
  47. package/src/adapters/event-normalizer.js +55 -0
  48. package/src/adapters/index.js +4 -0
  49. package/src/adapters/install-safety.js +249 -0
  50. package/src/agent-canonical-content.js +4 -2
  51. package/src/agent-integration.js +169 -46
  52. package/src/cli/args.js +63 -4
  53. package/src/cli/gate-print.js +17 -0
  54. package/src/cli/quality-commands.js +18 -0
  55. package/src/cli/quality-print.js +10 -0
  56. package/src/cli/runtime-print.js +24 -0
  57. package/src/codex-hook-runner-template.mjs +129 -199
  58. package/src/codex-runtime.js +48 -5
  59. package/src/context/cache.js +245 -0
  60. package/src/context/compiler.js +438 -0
  61. package/src/context/constants.js +30 -0
  62. package/src/context/index.js +39 -0
  63. package/src/context/redaction.js +84 -0
  64. package/src/context/stable.js +69 -0
  65. package/src/context/telemetry.js +42 -0
  66. package/src/dev-standards.js +57 -0
  67. package/src/fleet.js +112 -95
  68. package/src/gates/index.js +2 -0
  69. package/src/gates/scoped-gates.js +256 -0
  70. package/src/gates/store.js +126 -0
  71. package/src/gates/workspace.js +41 -0
  72. package/src/html-artifacts.js +725 -28
  73. package/src/kernel/atomic-store.js +299 -0
  74. package/src/kernel/event-envelope.js +166 -0
  75. package/src/kernel/index.js +4 -0
  76. package/src/kernel/project-ledger.js +467 -0
  77. package/src/kernel/project-manifest.js +205 -0
  78. package/src/knowledge-v3/index.js +1 -0
  79. package/src/knowledge-v3/lifecycle.js +290 -0
  80. package/src/knowledge.js +14 -7
  81. package/src/openprd.js +71 -2
  82. package/src/review-model.js +413 -0
  83. package/src/review-presentation.js +1 -1
  84. package/src/run-harness.js +432 -38
  85. package/src/runtime/cli_runtime_README.md +28 -0
  86. package/src/runtime/errors.js +66 -0
  87. package/src/runtime/index.js +44 -0
  88. package/src/runtime/lane-schema.js +141 -0
  89. package/src/runtime/lane-store.js +279 -0
  90. package/src/runtime/task-runtime.js +449 -0
  91. package/src/runtime/workspace.js +179 -0
  92. package/src/runtime/write-set.js +206 -0
  93. package/src/session-binding.js +16 -3
  94. package/src/session-registry.js +59 -1
  95. package/src/upgrade/fleet-mutation.js +166 -0
  96. package/src/upgrade/fleet-transaction.js +398 -0
  97. package/src/upgrade/transaction-store.js +416 -0
  98. package/src/visual-compare-core.js +66 -27
  99. package/src/visual-compare.js +18 -12
  100. package/src/workspace-core.js +109 -7
  101. package/src/workspace-registry.js +39 -1
  102. package/src/workspace-workflow.js +18 -15
@@ -0,0 +1,299 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ class AtomicStoreConflictError extends Error {
7
+ constructor(message, details = {}) {
8
+ super(message);
9
+ this.name = 'AtomicStoreConflictError';
10
+ this.code = 'OPENPRD_ATOMIC_STORE_CONFLICT';
11
+ Object.assign(this, details);
12
+ }
13
+ }
14
+
15
+ class AtomicStoreLockTimeoutError extends Error {
16
+ constructor(lockPath, timeoutMs) {
17
+ super(`Timed out after ${timeoutMs}ms waiting for lock ${lockPath}`);
18
+ this.name = 'AtomicStoreLockTimeoutError';
19
+ this.code = 'OPENPRD_LOCK_TIMEOUT';
20
+ this.lockPath = lockPath;
21
+ this.timeoutMs = timeoutMs;
22
+ }
23
+ }
24
+
25
+ class JsonlCorruptionError extends Error {
26
+ constructor(filePath, line, cause) {
27
+ super(`JSONL corruption in ${filePath} at line ${line}: ${cause.message}`);
28
+ this.name = 'JsonlCorruptionError';
29
+ this.code = 'OPENPRD_JSONL_CORRUPTION';
30
+ this.filePath = filePath;
31
+ this.line = line;
32
+ this.cause = cause;
33
+ }
34
+ }
35
+
36
+ function timeValue(clock = () => new Date()) {
37
+ const value = clock();
38
+ const date = value instanceof Date ? value : new Date(value);
39
+ return Number.isNaN(date.getTime()) ? new Date() : date;
40
+ }
41
+
42
+ async function fileExists(filePath) {
43
+ return fs.access(filePath).then(() => true, () => false);
44
+ }
45
+
46
+ async function syncDirectory(directory) {
47
+ let handle;
48
+ try {
49
+ handle = await fs.open(directory, 'r');
50
+ await handle.sync();
51
+ } catch {
52
+ // Directory fsync is not available on every supported filesystem.
53
+ } finally {
54
+ await handle?.close().catch(() => {});
55
+ }
56
+ }
57
+
58
+ async function atomicWriteText(filePath, text, options = {}) {
59
+ const directory = path.dirname(filePath);
60
+ await fs.mkdir(directory, { recursive: true });
61
+ const temporaryPath = path.join(
62
+ directory,
63
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
64
+ );
65
+ let handle;
66
+ try {
67
+ handle = await fs.open(temporaryPath, 'wx', options.mode ?? 0o600);
68
+ await handle.writeFile(text, 'utf8');
69
+ await handle.sync();
70
+ await handle.close();
71
+ handle = null;
72
+ await fs.rename(temporaryPath, filePath);
73
+ await syncDirectory(directory);
74
+ } catch (error) {
75
+ await handle?.close().catch(() => {});
76
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ async function atomicWriteJson(filePath, value, options = {}) {
82
+ const serialized = JSON.stringify(value, null, options.indent ?? 2);
83
+ if (serialized === undefined) throw new TypeError('atomicWriteJson value must be JSON-serializable');
84
+ await atomicWriteText(filePath, `${serialized}\n`, options);
85
+ return value;
86
+ }
87
+
88
+ async function readJsonIfExists(filePath, fallback = null) {
89
+ try {
90
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
91
+ } catch (error) {
92
+ if (error.code === 'ENOENT') return fallback;
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ async function isStaleLock(lockPath, staleLockMs, now) {
98
+ try {
99
+ const [stat, raw] = await Promise.all([
100
+ fs.stat(lockPath),
101
+ fs.readFile(lockPath, 'utf8').catch(() => ''),
102
+ ]);
103
+ let recordedAt = Number.NaN;
104
+ try {
105
+ recordedAt = Date.parse(JSON.parse(raw).createdAt);
106
+ } catch {
107
+ // A truncated lock is judged by its filesystem timestamp.
108
+ }
109
+ const newestTimestamp = Math.max(stat.mtimeMs, Number.isNaN(recordedAt) ? 0 : recordedAt);
110
+ return now.getTime() - newestTimestamp > staleLockMs;
111
+ } catch (error) {
112
+ if (error.code === 'ENOENT') return false;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ async function releaseOwnedLock(lockPath, token) {
118
+ let lock;
119
+ try {
120
+ lock = JSON.parse(await fs.readFile(lockPath, 'utf8'));
121
+ } catch (error) {
122
+ if (error.code === 'ENOENT') return;
123
+ return;
124
+ }
125
+ if (lock.token === token) await fs.rm(lockPath, { force: true });
126
+ }
127
+
128
+ async function withFileLock(lockPath, operation, options = {}) {
129
+ const timeoutMs = options.timeoutMs ?? 5_000;
130
+ const staleLockMs = options.staleLockMs ?? 30_000;
131
+ const retryDelayMs = options.retryDelayMs ?? 10;
132
+ const clock = options.clock ?? (() => new Date());
133
+ const token = options.token ?? `${process.pid}:${randomUUID()}`;
134
+ const startedAt = Date.now();
135
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
136
+
137
+ while (true) {
138
+ let handle;
139
+ let created = false;
140
+ try {
141
+ handle = await fs.open(lockPath, 'wx', 0o600);
142
+ created = true;
143
+ await handle.writeFile(`${JSON.stringify({
144
+ token,
145
+ pid: process.pid,
146
+ createdAt: timeValue(clock).toISOString(),
147
+ })}\n`, 'utf8');
148
+ await handle.sync();
149
+ await handle.close();
150
+ handle = null;
151
+ break;
152
+ } catch (error) {
153
+ await handle?.close().catch(() => {});
154
+ if (created) await fs.rm(lockPath, { force: true }).catch(() => {});
155
+ if (error.code !== 'EEXIST') throw error;
156
+ const now = timeValue(clock);
157
+ if (await isStaleLock(lockPath, staleLockMs, now)) {
158
+ await fs.rm(lockPath, { force: true }).catch(() => {});
159
+ continue;
160
+ }
161
+ if (Date.now() - startedAt >= timeoutMs) throw new AtomicStoreLockTimeoutError(lockPath, timeoutMs);
162
+ await delay(retryDelayMs);
163
+ }
164
+ }
165
+
166
+ try {
167
+ return await operation({ token, lockPath });
168
+ } finally {
169
+ await releaseOwnedLock(lockPath, token);
170
+ }
171
+ }
172
+
173
+ async function compareAndSwapJson(filePath, options = {}) {
174
+ const revisionField = options.revisionField ?? 'revision';
175
+ const expectedRevision = options.expectedRevision ?? 0;
176
+ const lockPath = options.lockPath ?? `${filePath}.lock`;
177
+ return withFileLock(lockPath, async () => {
178
+ const currentValue = await readJsonIfExists(filePath, null);
179
+ const actualRevision = currentValue === null ? 0 : currentValue?.[revisionField];
180
+ if (!Number.isSafeInteger(actualRevision) || actualRevision < 0) {
181
+ throw new AtomicStoreConflictError(`Stored revision at ${filePath} is invalid`, {
182
+ filePath,
183
+ expectedRevision,
184
+ actualRevision,
185
+ });
186
+ }
187
+ if (actualRevision !== expectedRevision) {
188
+ throw new AtomicStoreConflictError(
189
+ `Revision conflict at ${filePath}: expected ${expectedRevision}, found ${actualRevision}`,
190
+ { filePath, expectedRevision, actualRevision, currentValue },
191
+ );
192
+ }
193
+ const nextValue = typeof options.nextValue === 'function'
194
+ ? await options.nextValue(currentValue)
195
+ : options.nextValue;
196
+ if (nextValue === undefined) throw new TypeError('compareAndSwapJson requires nextValue');
197
+ await atomicWriteJson(filePath, nextValue, options);
198
+ return { previousValue: currentValue, value: nextValue };
199
+ }, options);
200
+ }
201
+
202
+ function parseJsonl(filePath, text) {
203
+ const records = [];
204
+ const endsWithNewline = text.endsWith('\n');
205
+ const lines = text.split('\n');
206
+ let characterOffset = 0;
207
+ for (let index = 0; index < lines.length; index += 1) {
208
+ const physicalLine = lines[index];
209
+ const line = physicalLine.endsWith('\r') ? physicalLine.slice(0, -1) : physicalLine;
210
+ const lineCharacterLength = physicalLine.length + (index < lines.length - 1 ? 1 : 0);
211
+ if (line.trim()) {
212
+ try {
213
+ records.push(JSON.parse(line));
214
+ } catch (error) {
215
+ const isIncompleteTail = !endsWithNewline && index === lines.length - 1;
216
+ if (!isIncompleteTail) throw new JsonlCorruptionError(filePath, index + 1, error);
217
+ return {
218
+ records,
219
+ cleanText: text.slice(0, characterOffset),
220
+ recovered: true,
221
+ recoveredBytes: Buffer.byteLength(text.slice(characterOffset), 'utf8'),
222
+ missingFinalNewline: false,
223
+ };
224
+ }
225
+ }
226
+ characterOffset += lineCharacterLength;
227
+ }
228
+ return {
229
+ records,
230
+ cleanText: text,
231
+ recovered: Boolean(text) && !endsWithNewline,
232
+ recoveredBytes: 0,
233
+ missingFinalNewline: Boolean(text) && !endsWithNewline,
234
+ };
235
+ }
236
+
237
+ async function readJsonlRecovered(filePath, options = {}) {
238
+ let text;
239
+ try {
240
+ text = await fs.readFile(filePath, 'utf8');
241
+ } catch (error) {
242
+ if (error.code === 'ENOENT') {
243
+ return { records: [], recovered: false, recoveredBytes: 0, repaired: false };
244
+ }
245
+ throw error;
246
+ }
247
+ const result = parseJsonl(filePath, text);
248
+ let repaired = false;
249
+ if (options.repair && result.recovered) {
250
+ const repairedText = result.cleanText
251
+ ? `${result.cleanText.replace(/\n?$/, '\n')}`
252
+ : '';
253
+ await atomicWriteText(filePath, repairedText, options);
254
+ repaired = true;
255
+ }
256
+ return {
257
+ records: result.records,
258
+ recovered: result.recovered,
259
+ recoveredBytes: result.recoveredBytes,
260
+ repaired,
261
+ };
262
+ }
263
+
264
+ async function recoverJsonlTail(filePath, options = {}) {
265
+ return readJsonlRecovered(filePath, { ...options, repair: true });
266
+ }
267
+
268
+ async function appendJsonlAtomic(filePath, value, options = {}) {
269
+ const lockPath = options.lockPath ?? `${filePath}.lock`;
270
+ return withFileLock(lockPath, async () => {
271
+ await recoverJsonlTail(filePath, options);
272
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
273
+ const serialized = JSON.stringify(value);
274
+ if (serialized === undefined) throw new TypeError('appendJsonlAtomic value must be JSON-serializable');
275
+ const handle = await fs.open(filePath, 'a', options.mode ?? 0o600);
276
+ try {
277
+ await handle.writeFile(`${serialized}\n`, 'utf8');
278
+ await handle.sync();
279
+ } finally {
280
+ await handle.close();
281
+ }
282
+ return value;
283
+ }, options);
284
+ }
285
+
286
+ export {
287
+ AtomicStoreConflictError,
288
+ AtomicStoreLockTimeoutError,
289
+ JsonlCorruptionError,
290
+ appendJsonlAtomic,
291
+ atomicWriteJson,
292
+ atomicWriteText,
293
+ compareAndSwapJson,
294
+ fileExists,
295
+ readJsonIfExists,
296
+ readJsonlRecovered,
297
+ recoverJsonlTail,
298
+ withFileLock,
299
+ };
@@ -0,0 +1,166 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const EVENT_ENVELOPE_SCHEMA = 'openprd.event';
4
+ const EVENT_ENVELOPE_VERSION = 1;
5
+
6
+ class EventEnvelopeValidationError extends Error {
7
+ constructor(errors) {
8
+ super(`Invalid OpenPrd event envelope: ${errors.map((item) => `${item.path} ${item.message}`).join('; ')}`);
9
+ this.name = 'EventEnvelopeValidationError';
10
+ this.code = 'OPENPRD_INVALID_EVENT_ENVELOPE';
11
+ this.errors = errors;
12
+ }
13
+ }
14
+
15
+ function isPlainObject(value) {
16
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
17
+ const prototype = Object.getPrototypeOf(value);
18
+ return prototype === Object.prototype || prototype === null;
19
+ }
20
+
21
+ function inspectJsonValue(value, seen = new Set()) {
22
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return null;
23
+ if (typeof value === 'number') return Number.isFinite(value) ? null : 'must not contain non-finite numbers';
24
+ if (typeof value !== 'object') return `must not contain ${typeof value} values`;
25
+ if (seen.has(value)) return 'must not contain circular references';
26
+ seen.add(value);
27
+ if (Array.isArray(value)) {
28
+ for (const item of value) {
29
+ const error = inspectJsonValue(item, seen);
30
+ if (error) return error;
31
+ }
32
+ seen.delete(value);
33
+ return null;
34
+ }
35
+ if (!isPlainObject(value)) return 'must contain only plain JSON objects';
36
+ for (const item of Object.values(value)) {
37
+ const error = inspectJsonValue(item, seen);
38
+ if (error) return error;
39
+ }
40
+ seen.delete(value);
41
+ return null;
42
+ }
43
+
44
+ function canonicalize(value) {
45
+ if (value === undefined) return undefined;
46
+ if (value === null || typeof value !== 'object') return value;
47
+ if (Array.isArray(value)) return value.map((item) => canonicalize(item));
48
+ return Object.fromEntries(
49
+ Object.keys(value)
50
+ .sort()
51
+ .filter((key) => value[key] !== undefined)
52
+ .map((key) => [key, canonicalize(value[key])]),
53
+ );
54
+ }
55
+
56
+ function canonicalJson(value) {
57
+ const error = inspectJsonValue(value);
58
+ if (error) throw new TypeError(`Value is not canonical JSON: ${error}`);
59
+ return JSON.stringify(canonicalize(value));
60
+ }
61
+
62
+ function normalizeTimestamp(value) {
63
+ const date = value instanceof Date ? value : new Date(value);
64
+ return Number.isNaN(date.getTime()) ? String(value ?? '') : date.toISOString();
65
+ }
66
+
67
+ function eventIdentitySeed(value) {
68
+ const { eventId: _eventId, ...seed } = value ?? {};
69
+ return seed;
70
+ }
71
+
72
+ function createStableEventId(value) {
73
+ const digest = createHash('sha256')
74
+ .update(canonicalJson(eventIdentitySeed(value)))
75
+ .digest('hex');
76
+ return `evt_${digest.slice(0, 32)}`;
77
+ }
78
+
79
+ function validateEventEnvelope(envelope) {
80
+ const errors = [];
81
+ const add = (path, message) => errors.push({ path, message });
82
+ if (!isPlainObject(envelope)) {
83
+ add('$', 'must be a plain object');
84
+ return { ok: false, errors };
85
+ }
86
+ if (envelope.schema !== EVENT_ENVELOPE_SCHEMA) add('schema', `must equal ${EVENT_ENVELOPE_SCHEMA}`);
87
+ if (envelope.version !== EVENT_ENVELOPE_VERSION) add('version', `must equal ${EVENT_ENVELOPE_VERSION}`);
88
+ if (typeof envelope.eventId !== 'string' || !envelope.eventId.trim()) add('eventId', 'must be a non-empty string');
89
+ if (typeof envelope.eventType !== 'string' || !/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/i.test(envelope.eventType)) {
90
+ add('eventType', 'must be a dotted, underscored, or dashed event name');
91
+ }
92
+ if (typeof envelope.projectId !== 'string' || !envelope.projectId.trim()) add('projectId', 'must be a non-empty string');
93
+ if (
94
+ typeof envelope.streamId !== 'string'
95
+ || !envelope.streamId.trim()
96
+ || envelope.streamId.includes('..')
97
+ || /[\0\r\n]/.test(envelope.streamId)
98
+ ) {
99
+ add('streamId', 'must be a safe non-empty logical stream ID');
100
+ }
101
+ if (!Number.isSafeInteger(envelope.revision) || envelope.revision < 1) add('revision', 'must be a positive safe integer');
102
+ if (typeof envelope.occurredAt !== 'string' || Number.isNaN(Date.parse(envelope.occurredAt))) {
103
+ add('occurredAt', 'must be an ISO-compatible timestamp');
104
+ }
105
+ if (!isPlainObject(envelope.actor) || typeof envelope.actor.type !== 'string' || !envelope.actor.type.trim()) {
106
+ add('actor', 'must be an object with a non-empty type');
107
+ } else if (envelope.actor.id !== undefined && (typeof envelope.actor.id !== 'string' || !envelope.actor.id.trim())) {
108
+ add('actor.id', 'must be a non-empty string when present');
109
+ } else {
110
+ const actorError = inspectJsonValue(envelope.actor);
111
+ if (actorError) add('actor', actorError);
112
+ }
113
+ const payloadError = inspectJsonValue(envelope.payload);
114
+ if (!isPlainObject(envelope.payload) || payloadError) add('payload', payloadError ?? 'must be a plain object');
115
+ const metadataError = inspectJsonValue(envelope.metadata);
116
+ if (!isPlainObject(envelope.metadata) || metadataError) add('metadata', metadataError ?? 'must be a plain object');
117
+ for (const field of ['causationId', 'correlationId']) {
118
+ if (envelope[field] !== null && envelope[field] !== undefined && (typeof envelope[field] !== 'string' || !envelope[field].trim())) {
119
+ add(field, 'must be a non-empty string or null');
120
+ }
121
+ }
122
+ return { ok: errors.length === 0, errors };
123
+ }
124
+
125
+ function assertEventEnvelope(envelope) {
126
+ const validation = validateEventEnvelope(envelope);
127
+ if (!validation.ok) throw new EventEnvelopeValidationError(validation.errors);
128
+ return envelope;
129
+ }
130
+
131
+ function createEventEnvelope(input, options = {}) {
132
+ const clock = options.clock ?? (() => new Date());
133
+ const occurredAt = normalizeTimestamp(input?.occurredAt ?? clock());
134
+ const envelopeWithoutId = {
135
+ schema: EVENT_ENVELOPE_SCHEMA,
136
+ version: EVENT_ENVELOPE_VERSION,
137
+ eventType: input?.eventType,
138
+ projectId: input?.projectId,
139
+ streamId: input?.streamId,
140
+ revision: input?.revision,
141
+ occurredAt,
142
+ actor: input?.actor ?? { type: 'system' },
143
+ causationId: input?.causationId ?? null,
144
+ correlationId: input?.correlationId ?? null,
145
+ payload: input?.payload ?? {},
146
+ metadata: input?.metadata ?? {},
147
+ };
148
+ const idFactory = options.idFactory ?? createStableEventId;
149
+ assertEventEnvelope({ ...envelopeWithoutId, eventId: input?.eventId ?? 'evt_pending' });
150
+ const envelope = {
151
+ ...envelopeWithoutId,
152
+ eventId: input?.eventId ?? idFactory(envelopeWithoutId),
153
+ };
154
+ return assertEventEnvelope(envelope);
155
+ }
156
+
157
+ export {
158
+ EVENT_ENVELOPE_SCHEMA,
159
+ EVENT_ENVELOPE_VERSION,
160
+ EventEnvelopeValidationError,
161
+ assertEventEnvelope,
162
+ canonicalJson,
163
+ createEventEnvelope,
164
+ createStableEventId,
165
+ validateEventEnvelope,
166
+ };
@@ -0,0 +1,4 @@
1
+ export * from './atomic-store.js';
2
+ export * from './event-envelope.js';
3
+ export * from './project-ledger.js';
4
+ export * from './project-manifest.js';