engineering-memory 0.1.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 (40) hide show
  1. package/bin/engineering-memory.mjs +120 -0
  2. package/dispatcher/managed-section.mjs +59 -0
  3. package/dispatcher/sections.mjs +14 -0
  4. package/install/api-url.mjs +39 -0
  5. package/install/cli.mjs +93 -0
  6. package/install/commands.mjs +140 -0
  7. package/install/files.mjs +416 -0
  8. package/install/git-hook.mjs +270 -0
  9. package/install/installer.mjs +279 -0
  10. package/install/mcp-registration.mjs +457 -0
  11. package/package.json +28 -0
  12. package/runtime/dist/src/auth/browser-auth.js +184 -0
  13. package/runtime/dist/src/auth/credential-store.js +181 -0
  14. package/runtime/dist/src/cache/etag-cache.js +123 -0
  15. package/runtime/dist/src/config.js +59 -0
  16. package/runtime/dist/src/git/git-inspector.js +375 -0
  17. package/runtime/dist/src/git/pre-commit.js +44 -0
  18. package/runtime/dist/src/git/verification-gate.js +221 -0
  19. package/runtime/dist/src/index.js +60 -0
  20. package/runtime/dist/src/journal/journal-store.js +1300 -0
  21. package/runtime/dist/src/mcp/server.js +11 -0
  22. package/runtime/dist/src/mcp/tool-definitions.js +405 -0
  23. package/runtime/dist/src/project/repository.js +79 -0
  24. package/runtime/dist/src/runtime/active-context-store.js +356 -0
  25. package/runtime/dist/src/runtime/api-client.js +229 -0
  26. package/runtime/dist/src/runtime/bridge-service.js +2226 -0
  27. package/runtime/dist/src/runtime/offline-outbox.js +274 -0
  28. package/runtime/dist/src/runtime/principal-state.js +97 -0
  29. package/runtime/dist/src/types.js +2 -0
  30. package/runtime/dist/src/utilities/files.js +189 -0
  31. package/runtime/dist/src/utilities/hash.js +19 -0
  32. package/runtime/dist/src/utilities/process.js +32 -0
  33. package/runtime/package-lock.json +137 -0
  34. package/runtime/package.json +32 -0
  35. package/skill/SKILL.md +29 -0
  36. package/skill/agents/openai.yaml +6 -0
  37. package/skill/references/lifecycle.md +102 -0
  38. package/skill/references/memory-updates.md +25 -0
  39. package/skill/references/questionnaires.md +98 -0
  40. package/skill/references/scaffolding.md +38 -0
@@ -0,0 +1,1300 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { assertManagedPath, atomicWrite, ensureManagedDirectory, ensureWithinRoot, readJson, safeSegment, writeJson, } from '../utilities/files.js';
4
+ import { sha256, stableStringify } from '../utilities/hash.js';
5
+ import { assertSafeToPersist } from '../runtime/offline-outbox.js';
6
+ const canonicalJournalSchemaVersion = 3;
7
+ const supportedJournalSchemaVersions = new Set([1, 2, 3]);
8
+ const absentJournalToken = '(none)';
9
+ const emptyJournalToken = '(empty)';
10
+ const journalTypeSeparator = ' · ';
11
+ const journalTextSeparator = ' — ';
12
+ const journalDetailsPrefix = ' details: ';
13
+ export class JournalStore {
14
+ root;
15
+ queues = new Map();
16
+ constructor(stateRoot) {
17
+ this.root = join(stateRoot, 'task-memory');
18
+ }
19
+ async apply(input) {
20
+ const result = await this.stage(input, null);
21
+ return { directory: result.directory, applied: result.applied };
22
+ }
23
+ async stage(input, deliveryBase) {
24
+ assertSafeToPersist(JSON.parse(JSON.stringify({ input, deliveryBase })));
25
+ const directory = this.taskDirectory(input.projectId, input.taskSlug);
26
+ return await this.exclusive(directory, async () => {
27
+ await ensureManagedDirectory(this.root, this.eventsDirectory(directory));
28
+ const eventPath = this.eventPath(directory, input.eventId);
29
+ const existingRecord = await readJson(eventPath, this.root);
30
+ let record = existingRecord;
31
+ let applied = false;
32
+ if (record) {
33
+ this.assertSameEvent(record, input, deliveryBase);
34
+ }
35
+ else {
36
+ record = {
37
+ schemaVersion: 1,
38
+ input,
39
+ deliveryBase,
40
+ delivery: null,
41
+ deliveryStatus: deliveryBase ? 'pending' : 'local_only',
42
+ synchronizedAt: null,
43
+ lastDeliveryError: null,
44
+ };
45
+ await writeJson(eventPath, record, this.root);
46
+ applied = true;
47
+ }
48
+ await this.recoverDirectory(directory, input);
49
+ record =
50
+ (await readJson(eventPath, this.root)) ??
51
+ this.missingEventRecord(input.eventId);
52
+ return {
53
+ directory,
54
+ applied,
55
+ delivery: record.delivery
56
+ ? this.pendingDelivery(record, input.projectId, input.taskSlug)
57
+ : null,
58
+ };
59
+ });
60
+ }
61
+ async load(projectId, taskSlug) {
62
+ const directory = this.taskDirectory(projectId, taskSlug);
63
+ return await this.exclusive(directory, async () => {
64
+ const projection = await this.recoverDirectory(directory);
65
+ const records = await this.readEventRecords(directory);
66
+ return {
67
+ directory,
68
+ projection,
69
+ pendingDeliveryCount: records.filter((record) => record.deliveryBase && record.deliveryStatus === 'pending').length,
70
+ };
71
+ });
72
+ }
73
+ async readDocuments(projectId, taskSlug) {
74
+ const directory = this.taskDirectory(projectId, taskSlug);
75
+ return await this.exclusive(directory, async () => {
76
+ const projection = await this.recoverDirectory(directory);
77
+ if (!projection) {
78
+ throw new Error('Task journal does not exist');
79
+ }
80
+ return this.renderDocuments(projection);
81
+ });
82
+ }
83
+ async canonicalResumeState(projectId, taskSlug) {
84
+ const directory = this.taskDirectory(projectId, taskSlug);
85
+ return await this.exclusive(directory, async () => {
86
+ const projection = await this.recoverDirectory(directory);
87
+ if (!projection) {
88
+ throw new Error('Task journal does not exist');
89
+ }
90
+ const documents = this.renderDocuments(projection);
91
+ return {
92
+ events: projection.appliedEventIds.map((idempotencyKey, index) => ({
93
+ sequence: index + 1,
94
+ idempotencyKey,
95
+ })),
96
+ documents: Object.entries(documents).map(([documentType, content]) => ({
97
+ documentType,
98
+ content,
99
+ contentHash: sha256(content),
100
+ })),
101
+ };
102
+ });
103
+ }
104
+ async assertEventContent(input) {
105
+ const directory = this.taskDirectory(input.projectId, input.taskSlug);
106
+ return await this.exclusive(directory, async () => {
107
+ const record = await readJson(this.eventPath(directory, input.eventId), this.root);
108
+ if (!record) {
109
+ return false;
110
+ }
111
+ const storedInput = { ...record.input, createdAt: null };
112
+ const receivedInput = { ...input, createdAt: null };
113
+ if (stableStringify(storedInput) !== stableStringify(receivedInput)) {
114
+ throw new Error('Journal idempotency key was reused with different content');
115
+ }
116
+ return true;
117
+ });
118
+ }
119
+ async hydrateFromBackend(input) {
120
+ const directory = this.taskDirectory(input.projectId, input.taskSlug);
121
+ return await this.exclusive(directory, async () => {
122
+ const current = await this.recoverDirectory(directory);
123
+ const records = await this.readEventRecords(directory);
124
+ const events = input.events
125
+ .map(backendEvent)
126
+ .sort((left, right) => left.sequence - right.sequence);
127
+ if (events.length !== input.lastSequence ||
128
+ events.some((event, index) => event.sequence !== index + 1)) {
129
+ throw new Error('Backend journal event sequence is incomplete');
130
+ }
131
+ const documents = backendDocuments(input.documents);
132
+ const state = parseStateDocument(documents.state);
133
+ if (state.projectId !== input.projectId ||
134
+ state.taskId !== input.taskId ||
135
+ state.taskSlug !== input.taskSlug) {
136
+ throw new Error('Backend journal documents do not match the active task identity');
137
+ }
138
+ const eventIds = events.map((event) => event.idempotencyKey);
139
+ const baseline = {
140
+ schemaVersion: state.schemaVersion,
141
+ projectId: input.projectId,
142
+ taskId: input.taskId,
143
+ taskSlug: input.taskSlug,
144
+ appliedEventIds: eventIds,
145
+ state: state.state,
146
+ decisions: parseLineDocument(documents.decisions, eventIds, state.schemaVersion),
147
+ discovery: parseLineDocument(documents.discovery, eventIds, state.schemaVersion),
148
+ validation: parseLineDocument(documents.validation, eventIds, state.schemaVersion),
149
+ handoff: parseHandoffDocument(documents.handoff, state.schemaVersion),
150
+ lastCheckpoint: state.lastCheckpoint,
151
+ };
152
+ assertSafeToPersist(JSON.parse(JSON.stringify(baseline)));
153
+ const rendered = this.renderDocuments(baseline);
154
+ for (const [type, content] of Object.entries(documents)) {
155
+ if (rendered[type] !== content) {
156
+ throw new Error(`Backend ${type} document is not a canonical journal projection`);
157
+ }
158
+ }
159
+ const backendIds = new Set(eventIds);
160
+ const recordById = new Map(records.map((record) => [record.input.eventId, record]));
161
+ const pendingRecords = records
162
+ .filter((record) => record.deliveryStatus === 'pending' && !backendIds.has(record.input.eventId))
163
+ .sort((left, right) => (left.deliveryBase?.expectedTaskVersion ?? 0) -
164
+ (right.deliveryBase?.expectedTaskVersion ?? 0));
165
+ const pendingIds = new Set(pendingRecords.map((record) => record.input.eventId));
166
+ const unmatchedLocalIds = (current?.appliedEventIds ?? []).filter((eventId) => !backendIds.has(eventId) && !pendingIds.has(eventId));
167
+ if (unmatchedLocalIds.length > 0) {
168
+ throw new Error('Local journal history diverges from the authoritative backend baseline');
169
+ }
170
+ const synchronizedEventIds = [];
171
+ for (const eventId of eventIds) {
172
+ const record = recordById.get(eventId);
173
+ if (record?.deliveryBase && record.deliveryStatus === 'pending') {
174
+ const updated = {
175
+ ...record,
176
+ deliveryStatus: 'synchronized',
177
+ synchronizedAt: new Date().toISOString(),
178
+ lastDeliveryError: null,
179
+ };
180
+ await writeJson(this.eventPath(directory, eventId), updated, this.root);
181
+ synchronizedEventIds.push(eventId);
182
+ }
183
+ }
184
+ await ensureManagedDirectory(this.root, this.eventsDirectory(directory));
185
+ await writeJson(join(directory, '.backend-baseline.json'), baseline, this.root);
186
+ let projection = baseline;
187
+ const pendingDeliveries = [];
188
+ const blockedEventIds = [];
189
+ for (const [index, record] of pendingRecords.entries()) {
190
+ if (!record.deliveryBase) {
191
+ throw new Error('Pending journal delta is missing its delivery contract');
192
+ }
193
+ projection = this.project(projection, record.input);
194
+ if (requiresExplicitResolution(record.lastDeliveryError)) {
195
+ blockedEventIds.push(record.input.eventId);
196
+ pendingDeliveries.push(this.pendingDelivery(record, input.projectId, input.taskSlug));
197
+ continue;
198
+ }
199
+ const expectedTaskVersion = input.taskVersion + index;
200
+ const deliveryBase = { ...record.deliveryBase, expectedTaskVersion };
201
+ const updated = {
202
+ ...record,
203
+ deliveryBase,
204
+ delivery: {
205
+ ...deliveryBase,
206
+ body: this.deliveryBody(record.input, deliveryBase, this.renderDocuments(projection)),
207
+ },
208
+ lastDeliveryError: null,
209
+ };
210
+ await writeJson(this.eventPath(directory, record.input.eventId), updated, this.root);
211
+ pendingDeliveries.push(this.pendingDelivery(updated, input.projectId, input.taskSlug));
212
+ }
213
+ await writeJson(join(directory, '.projection.json'), projection, this.root);
214
+ await this.writeDocuments(directory, projection);
215
+ return { synchronizedEventIds, pendingDeliveries, blockedEventIds };
216
+ });
217
+ }
218
+ async listPendingDeliveries(projectId, taskSlug) {
219
+ await ensureManagedDirectory(this.root, this.root);
220
+ const directories = await this.taskDirectories(projectId, taskSlug);
221
+ const pending = [];
222
+ for (const directory of directories) {
223
+ await this.exclusive(directory.path, async () => {
224
+ await this.recoverDirectory(directory.path);
225
+ const records = await this.readEventRecords(directory.path);
226
+ for (const record of records) {
227
+ if (record.delivery && record.deliveryStatus === 'pending') {
228
+ pending.push(this.pendingDelivery(record, directory.projectId, directory.taskSlug));
229
+ }
230
+ }
231
+ });
232
+ }
233
+ return pending.sort((left, right) => left.eventId.localeCompare(right.eventId));
234
+ }
235
+ async markDeliverySynchronized(projectId, taskSlug, eventId) {
236
+ await this.updateDelivery(projectId, taskSlug, eventId, (record) => ({
237
+ ...record,
238
+ deliveryStatus: 'synchronized',
239
+ synchronizedAt: new Date().toISOString(),
240
+ lastDeliveryError: null,
241
+ }));
242
+ }
243
+ async markDeliveryAttempt(projectId, taskSlug, eventId, errorKind) {
244
+ await this.updateDelivery(projectId, taskSlug, eventId, (record) => ({
245
+ ...record,
246
+ lastDeliveryError: safeErrorKind(errorKind),
247
+ }));
248
+ }
249
+ async rebaseDelivery(projectId, taskSlug, eventId, expectedTaskVersion) {
250
+ if (!Number.isInteger(expectedTaskVersion) || expectedTaskVersion < 1) {
251
+ throw new Error('Rebased task version must be a positive integer');
252
+ }
253
+ const directory = this.taskDirectory(projectId, taskSlug);
254
+ return await this.exclusive(directory, async () => {
255
+ const path = this.eventPath(directory, eventId);
256
+ const record = await readJson(path, this.root);
257
+ if (!record?.deliveryBase || !record.delivery || record.deliveryStatus !== 'pending') {
258
+ throw new Error('Pending journal delivery could not be rebased');
259
+ }
260
+ const records = await this.readEventRecords(directory);
261
+ const targetIndex = records.findIndex((value) => value.input.eventId === eventId);
262
+ if (records
263
+ .slice(0, targetIndex)
264
+ .some((value) => value.deliveryStatus === 'pending' && value.deliveryBase)) {
265
+ throw new Error('Earlier pending journal deliveries must be resolved first');
266
+ }
267
+ const deliveryBase = { ...record.deliveryBase, expectedTaskVersion };
268
+ const projection = await this.projectionThroughEvent(directory, records, eventId);
269
+ const body = this.deliveryBody(record.input, deliveryBase, this.renderDocuments(projection));
270
+ const updated = {
271
+ ...record,
272
+ deliveryBase,
273
+ delivery: { ...record.delivery, expectedTaskVersion, body },
274
+ lastDeliveryError: null,
275
+ };
276
+ await writeJson(path, updated, this.root);
277
+ return this.pendingDelivery(updated, projectId, taskSlug);
278
+ });
279
+ }
280
+ async discardDelivery(projectId, taskSlug, eventId) {
281
+ const directory = this.taskDirectory(projectId, taskSlug);
282
+ await this.exclusive(directory, async () => {
283
+ const path = this.eventPath(directory, eventId);
284
+ const record = await readJson(path, this.root);
285
+ if (!record?.deliveryBase || record.deliveryStatus !== 'pending') {
286
+ throw new Error('Pending journal delivery could not be discarded');
287
+ }
288
+ await writeJson(path, {
289
+ ...record,
290
+ deliveryStatus: 'discarded',
291
+ lastDeliveryError: null,
292
+ }, this.root);
293
+ await this.recoverDirectory(directory, undefined, true);
294
+ });
295
+ }
296
+ async updateDelivery(projectId, taskSlug, eventId, update) {
297
+ const directory = this.taskDirectory(projectId, taskSlug);
298
+ await this.exclusive(directory, async () => {
299
+ const path = this.eventPath(directory, eventId);
300
+ const record = await readJson(path, this.root);
301
+ if (!record || !record.deliveryBase) {
302
+ throw new Error('Pending journal delivery could not be found');
303
+ }
304
+ await writeJson(path, update(record), this.root);
305
+ });
306
+ }
307
+ async recoverDirectory(directory, identity, rebuild = false) {
308
+ const statePath = join(directory, '.projection.json');
309
+ const baseline = await readJson(join(directory, '.backend-baseline.json'), this.root);
310
+ let projection = await readJson(statePath, this.root);
311
+ const records = await this.readEventRecords(directory);
312
+ if (!projection && !baseline && records.length === 0) {
313
+ return null;
314
+ }
315
+ if (rebuild) {
316
+ const persistedIds = new Set(projection?.appliedEventIds ?? []);
317
+ const eventIds = new Set(records.map((record) => record.input.eventId));
318
+ const baselineIds = new Set(baseline?.appliedEventIds ?? []);
319
+ if ([...persistedIds].some((eventId) => !eventIds.has(eventId) && !baselineIds.has(eventId))) {
320
+ throw new Error('Legacy journal projection cannot discard an event automatically');
321
+ }
322
+ projection = baseline ?? this.emptyProjection(identity ?? records[0].input);
323
+ }
324
+ else {
325
+ projection ??= baseline ?? this.emptyProjection(identity ?? records[0].input);
326
+ }
327
+ for (const record of records) {
328
+ if (record.deliveryStatus === 'discarded') {
329
+ continue;
330
+ }
331
+ if (projection.taskId !== record.input.taskId ||
332
+ projection.projectId !== record.input.projectId ||
333
+ projection.taskSlug !== record.input.taskSlug) {
334
+ throw new Error('Task journal identity does not match its event log');
335
+ }
336
+ if (!projection.appliedEventIds.includes(record.input.eventId)) {
337
+ projection = this.project(projection, record.input);
338
+ }
339
+ if (rebuild && record.deliveryBase && record.deliveryStatus === 'pending') {
340
+ const updated = {
341
+ ...record,
342
+ delivery: {
343
+ ...record.deliveryBase,
344
+ body: this.deliveryBody(record.input, record.deliveryBase, this.renderDocuments(projection)),
345
+ },
346
+ };
347
+ await writeJson(this.eventPath(directory, record.input.eventId), updated, this.root);
348
+ }
349
+ }
350
+ await writeJson(statePath, projection, this.root);
351
+ await this.writeDocuments(directory, projection);
352
+ const documents = this.renderDocuments(projection);
353
+ for (const record of records) {
354
+ if (!rebuild && record.deliveryBase && !record.delivery) {
355
+ const updated = {
356
+ ...record,
357
+ delivery: {
358
+ ...record.deliveryBase,
359
+ body: this.deliveryBody(record.input, record.deliveryBase, documents),
360
+ },
361
+ };
362
+ await writeJson(this.eventPath(directory, record.input.eventId), updated, this.root);
363
+ }
364
+ }
365
+ return projection;
366
+ }
367
+ async projectionThroughEvent(directory, records, eventId) {
368
+ const baseline = await readJson(join(directory, '.backend-baseline.json'), this.root);
369
+ const identity = records[0]?.input;
370
+ if (!baseline && !identity) {
371
+ throw new Error('Task journal cannot reconstruct the delivery projection');
372
+ }
373
+ let projection = baseline ?? this.emptyProjection(identity);
374
+ for (const record of records) {
375
+ if (record.deliveryStatus === 'discarded') {
376
+ continue;
377
+ }
378
+ if (!projection.appliedEventIds.includes(record.input.eventId)) {
379
+ projection = this.project(projection, record.input);
380
+ }
381
+ if (record.input.eventId === eventId) {
382
+ return projection;
383
+ }
384
+ }
385
+ throw new Error('Task journal cannot find the rebased delivery event');
386
+ }
387
+ deliveryBody(input, delivery, documents) {
388
+ return JSON.parse(JSON.stringify({
389
+ taskId: input.taskId,
390
+ idempotencyKey: input.eventId,
391
+ expectedTaskVersion: delivery.expectedTaskVersion,
392
+ ...(delivery.correction
393
+ ? {}
394
+ : { eventType: 'checkpoint', checkpointType: input.checkpointType }),
395
+ payload: {
396
+ summary: input.summary,
397
+ ...(input.details !== undefined ? { details: input.details } : {}),
398
+ createdAt: input.createdAt,
399
+ },
400
+ documents,
401
+ }));
402
+ }
403
+ async readEventRecords(directory) {
404
+ const eventDirectory = this.eventsDirectory(directory);
405
+ try {
406
+ await assertManagedPath(this.root, eventDirectory, false);
407
+ }
408
+ catch (error) {
409
+ if (isMissing(error)) {
410
+ return [];
411
+ }
412
+ throw error;
413
+ }
414
+ const entries = await readdir(eventDirectory, { withFileTypes: true });
415
+ const files = entries.filter((entry) => entry.name.endsWith('.json'));
416
+ for (const entry of files) {
417
+ if (!entry.isFile() || entry.isSymbolicLink()) {
418
+ throw new Error(`Unsafe journal event entry: ${entry.name}`);
419
+ }
420
+ }
421
+ const records = await Promise.all(files.map(async (entry) => {
422
+ const path = await assertManagedPath(this.root, join(eventDirectory, entry.name), false);
423
+ return await readJson(path, this.root);
424
+ }));
425
+ return records
426
+ .filter((record) => record !== null)
427
+ .sort((left, right) => left.input.createdAt === right.input.createdAt
428
+ ? left.input.eventId.localeCompare(right.input.eventId)
429
+ : left.input.createdAt.localeCompare(right.input.createdAt));
430
+ }
431
+ async taskDirectories(projectId, taskSlug) {
432
+ const projectNames = projectId
433
+ ? [safeSegment(projectId)]
434
+ : await this.directoryNames(this.root, 'project');
435
+ const directories = [];
436
+ for (const projectName of projectNames) {
437
+ const projectDirectory = join(this.root, projectName);
438
+ try {
439
+ await assertManagedPath(this.root, projectDirectory, false);
440
+ }
441
+ catch (error) {
442
+ if (isMissing(error) && projectId) {
443
+ continue;
444
+ }
445
+ throw error;
446
+ }
447
+ const taskNames = taskSlug
448
+ ? [safeSegment(taskSlug)]
449
+ : await this.directoryNames(projectDirectory, 'task');
450
+ for (const taskName of taskNames) {
451
+ const path = join(projectDirectory, taskName);
452
+ try {
453
+ await assertManagedPath(this.root, path, false);
454
+ }
455
+ catch (error) {
456
+ if (isMissing(error) && taskSlug) {
457
+ continue;
458
+ }
459
+ throw error;
460
+ }
461
+ directories.push({ path, projectId: projectName, taskSlug: taskName });
462
+ }
463
+ }
464
+ return directories;
465
+ }
466
+ async directoryNames(directory, label) {
467
+ const entries = await readdir(directory, { withFileTypes: true });
468
+ const names = [];
469
+ for (const entry of entries) {
470
+ if (entry.name.startsWith('.')) {
471
+ continue;
472
+ }
473
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
474
+ throw new Error(`Unsafe ${label} journal directory: ${entry.name}`);
475
+ }
476
+ names.push(entry.name);
477
+ }
478
+ return names.sort();
479
+ }
480
+ emptyProjection(input) {
481
+ return {
482
+ schemaVersion: canonicalJournalSchemaVersion,
483
+ projectId: input.projectId,
484
+ taskId: input.taskId,
485
+ taskSlug: input.taskSlug,
486
+ appliedEventIds: [],
487
+ state: {},
488
+ decisions: [],
489
+ discovery: [],
490
+ validation: [],
491
+ handoff: {},
492
+ lastCheckpoint: null,
493
+ };
494
+ }
495
+ project(current, input) {
496
+ const line = {
497
+ eventId: input.eventId,
498
+ createdAt: input.createdAt,
499
+ checkpointType: input.checkpointType,
500
+ summary: input.summary,
501
+ ...(input.details !== undefined ? { details: input.details } : {}),
502
+ };
503
+ const entry = {
504
+ eventId: input.eventId,
505
+ createdAt: input.createdAt,
506
+ checkpointType: input.checkpointType,
507
+ summary: input.summary,
508
+ };
509
+ const documents = input.documents ?? {};
510
+ const decisions = [
511
+ ...current.decisions,
512
+ ...(documents.decisions ?? []).map((summary) => ({ ...entry, summary })),
513
+ ];
514
+ const discovery = [
515
+ ...current.discovery,
516
+ ...(documents.discovery ?? []).map((summary) => ({ ...entry, summary })),
517
+ ];
518
+ const validation = [
519
+ ...current.validation,
520
+ ...(documents.validation ?? []).map((summary) => ({ ...entry, summary })),
521
+ ];
522
+ return {
523
+ ...current,
524
+ schemaVersion: canonicalJournalSchemaVersion,
525
+ appliedEventIds: [...current.appliedEventIds, input.eventId],
526
+ state: mergeState(current.state, documents.state),
527
+ decisions: uniqueLines(decisions),
528
+ discovery: uniqueLines(discovery),
529
+ validation: uniqueLines(validation),
530
+ handoff: mergeHandoff(current.handoff, documents.handoff),
531
+ lastCheckpoint: line,
532
+ };
533
+ }
534
+ async writeDocuments(directory, projection) {
535
+ const documents = this.renderDocuments(projection);
536
+ await Promise.all(Object.entries(documents).map(async ([name, content]) => {
537
+ const fileName = `${name.toUpperCase()}.md`;
538
+ await atomicWrite(join(directory, fileName), content, this.root);
539
+ }));
540
+ }
541
+ renderDocuments(projection) {
542
+ const lineRenderer = projection.schemaVersion === 1
543
+ ? renderLegacyLines
544
+ : projection.schemaVersion === 2
545
+ ? renderLinesV2
546
+ : renderLines;
547
+ return {
548
+ state: renderState(projection),
549
+ decisions: lineRenderer('Decisions', projection.decisions),
550
+ discovery: lineRenderer('Discovery', projection.discovery),
551
+ validation: lineRenderer('Validation', projection.validation),
552
+ handoff: renderHandoff(projection),
553
+ };
554
+ }
555
+ assertSameEvent(record, input, deliveryBase) {
556
+ const storedInput = { ...record.input, createdAt: null };
557
+ const receivedInput = { ...input, createdAt: null };
558
+ if (stableStringify(storedInput) !== stableStringify(receivedInput) ||
559
+ stableStringify(record.deliveryBase) !== stableStringify(deliveryBase)) {
560
+ throw new Error('Journal idempotency key was reused with different content');
561
+ }
562
+ }
563
+ pendingDelivery(record, projectId, taskSlug) {
564
+ if (!record.delivery) {
565
+ throw new Error('Journal delivery has not been materialized');
566
+ }
567
+ return {
568
+ projectId,
569
+ taskSlug,
570
+ eventId: record.input.eventId,
571
+ operation: record.delivery.operation,
572
+ method: record.delivery.method,
573
+ path: record.delivery.path,
574
+ body: record.delivery.body,
575
+ idempotencyKey: record.input.eventId,
576
+ };
577
+ }
578
+ missingEventRecord(eventId) {
579
+ throw new Error(`Journal event disappeared during projection: ${eventId}`);
580
+ }
581
+ eventPath(directory, eventId) {
582
+ return join(this.eventsDirectory(directory), `${safeSegment(eventId)}.json`);
583
+ }
584
+ eventsDirectory(directory) {
585
+ return join(directory, '.events');
586
+ }
587
+ taskDirectory(projectId, taskSlug) {
588
+ return ensureWithinRoot(this.root, join(this.root, safeSegment(projectId), safeSegment(taskSlug)));
589
+ }
590
+ async exclusive(key, action) {
591
+ const previous = this.queues.get(key) ?? Promise.resolve();
592
+ let release = () => undefined;
593
+ const current = new Promise((resolvePromise) => {
594
+ release = resolvePromise;
595
+ });
596
+ const tail = previous.then(() => current);
597
+ this.queues.set(key, tail);
598
+ await previous;
599
+ try {
600
+ return await action();
601
+ }
602
+ finally {
603
+ release();
604
+ if (this.queues.get(key) === tail) {
605
+ this.queues.delete(key);
606
+ }
607
+ }
608
+ }
609
+ }
610
+ function mergeState(current, update) {
611
+ if (!update) {
612
+ return current;
613
+ }
614
+ return {
615
+ ...current,
616
+ ...update,
617
+ completed: uniqueStrings([...(current.completed ?? []), ...(update.completed ?? [])]),
618
+ blockers: update.blockers ?? current.blockers,
619
+ forbiddenActions: uniqueStrings([
620
+ ...(current.forbiddenActions ?? []),
621
+ ...(update.forbiddenActions ?? []),
622
+ ]),
623
+ };
624
+ }
625
+ function mergeHandoff(current, update) {
626
+ if (!update) {
627
+ return current;
628
+ }
629
+ return {
630
+ ...current,
631
+ ...update,
632
+ completed: uniqueStrings([...(current.completed ?? []), ...(update.completed ?? [])]),
633
+ };
634
+ }
635
+ function uniqueStrings(values) {
636
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
637
+ }
638
+ function uniqueLines(lines) {
639
+ const seen = new Set();
640
+ return lines.filter((line) => {
641
+ const key = `${line.eventId}:${line.summary}`;
642
+ if (seen.has(key)) {
643
+ return false;
644
+ }
645
+ seen.add(key);
646
+ return true;
647
+ });
648
+ }
649
+ function renderState(projection) {
650
+ if (projection.schemaVersion === 1) {
651
+ return renderLegacyState(projection);
652
+ }
653
+ if (projection.schemaVersion === 2) {
654
+ return renderStateV2(projection);
655
+ }
656
+ assertCanonicalJournalSchema(projection.schemaVersion);
657
+ const state = projection.state;
658
+ const last = projection.lastCheckpoint;
659
+ return [
660
+ '# Task State',
661
+ '',
662
+ `- Format: ${canonicalJournalSchemaVersion}`,
663
+ `- Task: ${encodeJournalText(projection.taskSlug)}`,
664
+ `- Task ID: ${encodeJournalText(projection.taskId)}`,
665
+ `- Project ID: ${encodeJournalText(projection.projectId)}`,
666
+ `- Objective: ${encodeOptionalJournalText(state.objective)}`,
667
+ `- Active phase: ${encodeOptionalJournalText(state.phase)}`,
668
+ `- Next action: ${encodeOptionalJournalText(state.nextAction)}`,
669
+ '',
670
+ '## Completed',
671
+ '',
672
+ renderTextList(state.completed),
673
+ '',
674
+ '## Blockers',
675
+ '',
676
+ renderTextList(state.blockers),
677
+ '',
678
+ '## Forbidden Actions',
679
+ '',
680
+ renderTextList(state.forbiddenActions),
681
+ '',
682
+ '## Last Checkpoint',
683
+ '',
684
+ ...(last ? renderJournalEntry(last, true) : [`- ${absentJournalToken}`]),
685
+ '',
686
+ ].join('\n');
687
+ }
688
+ function renderStateV2(projection) {
689
+ const state = projection.state;
690
+ const last = projection.lastCheckpoint;
691
+ return [
692
+ '# Task State',
693
+ '',
694
+ '- Format: 2',
695
+ `- Task: ${stableStringify(projection.taskSlug)}`,
696
+ `- Task ID: ${stableStringify(projection.taskId)}`,
697
+ `- Project ID: ${stableStringify(projection.projectId)}`,
698
+ `- Objective: ${stableStringify(state.objective ?? null)}`,
699
+ `- Active phase: ${stableStringify(state.phase ?? null)}`,
700
+ `- Next action: ${stableStringify(state.nextAction ?? null)}`,
701
+ '',
702
+ '## Completed',
703
+ '',
704
+ renderCanonicalList(state.completed),
705
+ '',
706
+ '## Blockers',
707
+ '',
708
+ renderCanonicalList(state.blockers),
709
+ '',
710
+ '## Forbidden Actions',
711
+ '',
712
+ renderCanonicalList(state.forbiddenActions),
713
+ '',
714
+ '## Last Checkpoint',
715
+ '',
716
+ `- ${stableStringify(last ? journalLineValue(last) : null)}`,
717
+ '',
718
+ ].join('\n');
719
+ }
720
+ function renderLines(title, lines) {
721
+ const values = lines.length
722
+ ? lines.flatMap((line) => renderJournalEntry(line))
723
+ : [`- ${absentJournalToken}`];
724
+ return [`# ${title}`, '', ...values, ''].join('\n');
725
+ }
726
+ function renderLinesV2(title, lines) {
727
+ const values = lines.length
728
+ ? lines.map((line) => `- ${stableStringify(journalLineValue(line))}`)
729
+ : ['- null'];
730
+ return [`# ${title}`, '', ...values, ''].join('\n');
731
+ }
732
+ function renderHandoff(projection) {
733
+ if (projection.schemaVersion === 1) {
734
+ return renderLegacyHandoff(projection);
735
+ }
736
+ if (projection.schemaVersion === 2) {
737
+ return renderHandoffV2(projection);
738
+ }
739
+ assertCanonicalJournalSchema(projection.schemaVersion);
740
+ return [
741
+ '# Handoff',
742
+ '',
743
+ '## Completed',
744
+ '',
745
+ renderTextList(projection.handoff.completed),
746
+ '',
747
+ '## Remaining',
748
+ '',
749
+ renderTextList(projection.handoff.remaining),
750
+ '',
751
+ '## Restart From',
752
+ '',
753
+ `- ${encodeOptionalJournalText(projection.handoff.restartFrom ?? projection.state.nextAction)}`,
754
+ '',
755
+ ].join('\n');
756
+ }
757
+ function renderHandoffV2(projection) {
758
+ return [
759
+ '# Handoff',
760
+ '',
761
+ '## Completed',
762
+ '',
763
+ renderCanonicalList(projection.handoff.completed),
764
+ '',
765
+ '## Remaining',
766
+ '',
767
+ renderCanonicalList(projection.handoff.remaining),
768
+ '',
769
+ '## Restart From',
770
+ '',
771
+ stableStringify(projection.handoff.restartFrom ?? projection.state.nextAction ?? null),
772
+ '',
773
+ ].join('\n');
774
+ }
775
+ function renderLegacyState(projection) {
776
+ const state = projection.state;
777
+ const last = projection.lastCheckpoint;
778
+ return [
779
+ '# Task State',
780
+ '',
781
+ `- Task: ${projection.taskSlug}`,
782
+ `- Task ID: ${projection.taskId}`,
783
+ `- Project ID: ${projection.projectId}`,
784
+ `- Objective: ${state.objective ?? 'Not recorded'}`,
785
+ `- Active phase: ${state.phase ?? 'Not recorded'}`,
786
+ `- Next action: ${state.nextAction ?? 'Not recorded'}`,
787
+ '',
788
+ '## Completed',
789
+ '',
790
+ renderLegacyBullets(state.completed),
791
+ '',
792
+ '## Blockers',
793
+ '',
794
+ renderLegacyBullets(state.blockers),
795
+ '',
796
+ '## Forbidden Actions',
797
+ '',
798
+ renderLegacyBullets(state.forbiddenActions),
799
+ '',
800
+ '## Last Checkpoint',
801
+ '',
802
+ last ? `- ${last.createdAt} | ${last.checkpointType} | ${last.summary}` : '- Not recorded',
803
+ '',
804
+ ].join('\n');
805
+ }
806
+ function renderLegacyLines(title, lines) {
807
+ return [
808
+ `# ${title}`,
809
+ '',
810
+ ...(lines.length === 0
811
+ ? ['- Not recorded']
812
+ : lines.map((line) => `- ${line.createdAt} | ${line.checkpointType} | ${line.summary}${line.details ? ` — ${line.details}` : ''}`)),
813
+ '',
814
+ ].join('\n');
815
+ }
816
+ function renderLegacyHandoff(projection) {
817
+ return [
818
+ '# Handoff',
819
+ '',
820
+ '## Completed',
821
+ '',
822
+ renderLegacyBullets(projection.handoff.completed),
823
+ '',
824
+ '## Remaining',
825
+ '',
826
+ renderLegacyBullets(projection.handoff.remaining),
827
+ '',
828
+ '## Restart From',
829
+ '',
830
+ projection.handoff.restartFrom ?? projection.state.nextAction ?? 'Not recorded',
831
+ '',
832
+ ].join('\n');
833
+ }
834
+ function journalLineValue(line) {
835
+ return {
836
+ checkpointType: line.checkpointType,
837
+ createdAt: line.createdAt,
838
+ ...(line.details !== undefined ? { details: line.details } : {}),
839
+ summary: line.summary,
840
+ };
841
+ }
842
+ function renderCanonicalList(values) {
843
+ return `- ${stableStringify(values ?? [])}`;
844
+ }
845
+ function encodeJournalText(value) {
846
+ if (value === '') {
847
+ return emptyJournalToken;
848
+ }
849
+ const escaped = value.replace(/\\/g, '\\\\').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
850
+ return escaped === absentJournalToken || escaped === emptyJournalToken ? `\\${escaped}` : escaped;
851
+ }
852
+ function encodeOptionalJournalText(value) {
853
+ return value === undefined ? absentJournalToken : encodeJournalText(value);
854
+ }
855
+ function decodeJournalText(value, label) {
856
+ if (value === emptyJournalToken) {
857
+ return '';
858
+ }
859
+ const source = value === `\\${absentJournalToken}` || value === `\\${emptyJournalToken}`
860
+ ? value.slice(1)
861
+ : value;
862
+ let result = '';
863
+ for (let index = 0; index < source.length; index += 1) {
864
+ const character = source[index];
865
+ if (character !== '\\') {
866
+ result += character;
867
+ continue;
868
+ }
869
+ const escape = source[index + 1];
870
+ if (escape === '\\') {
871
+ result += '\\';
872
+ }
873
+ else if (escape === 'r') {
874
+ result += '\r';
875
+ }
876
+ else if (escape === 'n') {
877
+ result += '\n';
878
+ }
879
+ else {
880
+ throw new Error(`Backend journal ${label} is not canonical`);
881
+ }
882
+ index += 1;
883
+ }
884
+ return result;
885
+ }
886
+ function decodeOptionalJournalText(value, label) {
887
+ return value === absentJournalToken ? undefined : decodeJournalText(value, label);
888
+ }
889
+ function renderTextList(values) {
890
+ return values?.length
891
+ ? values.map((value) => `- ${encodeJournalText(value)}`).join('\n')
892
+ : `- ${absentJournalToken}`;
893
+ }
894
+ function renderJournalEntry(line, includeDetails = false) {
895
+ const entry = `- ${line.createdAt}${journalTypeSeparator}${line.checkpointType}${journalTextSeparator}${encodeJournalText(line.summary)}`;
896
+ return includeDetails && line.details !== undefined
897
+ ? [entry, `${journalDetailsPrefix}${encodeJournalText(line.details)}`]
898
+ : [entry];
899
+ }
900
+ function renderLegacyBullets(values) {
901
+ return values?.length ? values.map((value) => `- ${value}`).join('\n') : '- None';
902
+ }
903
+ function backendEvent(value) {
904
+ const event = objectBody(value);
905
+ const sequence = Number(event.sequence);
906
+ if (!Number.isSafeInteger(sequence) ||
907
+ sequence < 1 ||
908
+ typeof event.idempotencyKey !== 'string' ||
909
+ !event.idempotencyKey) {
910
+ throw new Error('Backend journal event identity is invalid');
911
+ }
912
+ return { sequence, idempotencyKey: event.idempotencyKey };
913
+ }
914
+ function backendDocuments(values) {
915
+ const documents = {};
916
+ for (const value of values) {
917
+ const document = objectBody(value);
918
+ if (typeof document.documentType !== 'string' ||
919
+ typeof document.content !== 'string' ||
920
+ typeof document.contentHash !== 'string' ||
921
+ sha256(document.content) !== document.contentHash) {
922
+ throw new Error('Backend journal document integrity is invalid');
923
+ }
924
+ if (documents[document.documentType] !== undefined) {
925
+ throw new Error('Backend journal contains a duplicate document type');
926
+ }
927
+ documents[document.documentType] = document.content;
928
+ }
929
+ const required = ['state', 'decisions', 'discovery', 'validation', 'handoff'];
930
+ if (required.some((type) => documents[type] === undefined)) {
931
+ throw new Error('Backend journal does not contain all canonical documents');
932
+ }
933
+ return {
934
+ state: documents.state,
935
+ decisions: documents.decisions,
936
+ discovery: documents.discovery,
937
+ validation: documents.validation,
938
+ handoff: documents.handoff,
939
+ };
940
+ }
941
+ function parseStateDocument(content) {
942
+ const format = optionalLineValue(content, '- Format: ');
943
+ if (format === undefined) {
944
+ return parseLegacyStateDocument(content);
945
+ }
946
+ const schemaVersion = parseCanonicalJson(format, 'journal format');
947
+ if (typeof schemaVersion !== 'number' || !supportedJournalSchemaVersions.has(schemaVersion)) {
948
+ throw new Error('Backend journal format is unsupported');
949
+ }
950
+ if (schemaVersion === canonicalJournalSchemaVersion) {
951
+ return parseStateDocumentV3(content);
952
+ }
953
+ const taskSlug = parseCanonicalString(lineValue(content, '- Task: '), 'task slug');
954
+ const taskId = parseCanonicalString(lineValue(content, '- Task ID: '), 'task id');
955
+ const projectId = parseCanonicalString(lineValue(content, '- Project ID: '), 'project id');
956
+ const objective = parseCanonicalOptionalString(lineValue(content, '- Objective: '), 'objective');
957
+ const phase = parseCanonicalOptionalString(lineValue(content, '- Active phase: '), 'active phase');
958
+ const nextAction = parseCanonicalOptionalString(lineValue(content, '- Next action: '), 'next action');
959
+ const lastValue = singleSectionValue(content, '## Last Checkpoint', null, true);
960
+ return {
961
+ schemaVersion,
962
+ projectId,
963
+ taskId,
964
+ taskSlug,
965
+ state: {
966
+ ...(objective !== undefined ? { objective } : {}),
967
+ ...(phase !== undefined ? { phase } : {}),
968
+ ...(nextAction !== undefined ? { nextAction } : {}),
969
+ completed: parseCanonicalList(content, '## Completed', '## Blockers'),
970
+ blockers: parseCanonicalList(content, '## Blockers', '## Forbidden Actions'),
971
+ forbiddenActions: parseCanonicalList(content, '## Forbidden Actions', '## Last Checkpoint'),
972
+ },
973
+ lastCheckpoint: parseCanonicalJournalLine(lastValue, 'hydrated-last-checkpoint', true),
974
+ };
975
+ }
976
+ function parseStateDocumentV3(content) {
977
+ const objective = decodeOptionalJournalText(rawLineValue(content, '- Objective: '), 'objective');
978
+ const phase = decodeOptionalJournalText(rawLineValue(content, '- Active phase: '), 'phase');
979
+ const nextAction = decodeOptionalJournalText(rawLineValue(content, '- Next action: '), 'next action');
980
+ return {
981
+ schemaVersion: canonicalJournalSchemaVersion,
982
+ projectId: decodeJournalText(rawLineValue(content, '- Project ID: '), 'project id'),
983
+ taskId: decodeJournalText(rawLineValue(content, '- Task ID: '), 'task id'),
984
+ taskSlug: decodeJournalText(rawLineValue(content, '- Task: '), 'task slug'),
985
+ state: {
986
+ ...(objective !== undefined ? { objective } : {}),
987
+ ...(phase !== undefined ? { phase } : {}),
988
+ ...(nextAction !== undefined ? { nextAction } : {}),
989
+ completed: parseTextList(content, '## Completed', '## Blockers'),
990
+ blockers: parseTextList(content, '## Blockers', '## Forbidden Actions'),
991
+ forbiddenActions: parseTextList(content, '## Forbidden Actions', '## Last Checkpoint'),
992
+ },
993
+ lastCheckpoint: parseLastCheckpoint(content),
994
+ };
995
+ }
996
+ function parseLastCheckpoint(content) {
997
+ const lines = rawSectionLines(content, '## Last Checkpoint', null).filter(Boolean);
998
+ const entry = lines[0];
999
+ if (entry === undefined) {
1000
+ throw new Error('Backend journal section is not canonical: ## Last Checkpoint');
1001
+ }
1002
+ if (entry === `- ${absentJournalToken}`) {
1003
+ return null;
1004
+ }
1005
+ const line = parseJournalEntry(entry, 'hydrated-last-checkpoint');
1006
+ const details = lines[1];
1007
+ if (details === undefined) {
1008
+ return line;
1009
+ }
1010
+ if (lines.length !== 2 || !details.startsWith(journalDetailsPrefix)) {
1011
+ throw new Error('Backend journal section is not canonical: ## Last Checkpoint');
1012
+ }
1013
+ return {
1014
+ ...line,
1015
+ details: decodeJournalText(details.slice(journalDetailsPrefix.length), 'details'),
1016
+ };
1017
+ }
1018
+ function parseJournalEntry(value, eventId) {
1019
+ if (!value.startsWith('- ')) {
1020
+ throw new Error('Backend journal line is not canonical');
1021
+ }
1022
+ const body = value.slice(2);
1023
+ const typeIndex = body.indexOf(journalTypeSeparator);
1024
+ if (typeIndex <= 0) {
1025
+ throw new Error('Backend journal line is not canonical');
1026
+ }
1027
+ const remainder = body.slice(typeIndex + journalTypeSeparator.length);
1028
+ const textIndex = remainder.indexOf(journalTextSeparator);
1029
+ if (textIndex <= 0) {
1030
+ throw new Error('Backend journal line is not canonical');
1031
+ }
1032
+ return {
1033
+ eventId,
1034
+ createdAt: body.slice(0, typeIndex),
1035
+ checkpointType: remainder.slice(0, textIndex),
1036
+ summary: decodeJournalText(remainder.slice(textIndex + journalTextSeparator.length), 'summary'),
1037
+ };
1038
+ }
1039
+ function rawSectionLines(content, start, end) {
1040
+ const lines = content.split('\n');
1041
+ const startIndex = lines.indexOf(start);
1042
+ if (startIndex < 0) {
1043
+ throw new Error(`Backend journal section is missing: ${start}`);
1044
+ }
1045
+ const endIndex = end ? lines.indexOf(end, startIndex + 1) : lines.length;
1046
+ if (end && endIndex < 0) {
1047
+ throw new Error(`Backend journal section is missing: ${end}`);
1048
+ }
1049
+ return lines.slice(startIndex + 1, endIndex);
1050
+ }
1051
+ function rawLineValue(content, prefix) {
1052
+ const line = content.split('\n').find((value) => value.startsWith(prefix));
1053
+ if (line === undefined) {
1054
+ throw new Error(`Backend journal field is missing: ${prefix}`);
1055
+ }
1056
+ return line.slice(prefix.length);
1057
+ }
1058
+ function parseTextList(content, start, end) {
1059
+ const values = rawSectionLines(content, start, end)
1060
+ .filter((line) => line.startsWith('- '))
1061
+ .map((line) => line.slice(2));
1062
+ if (values.length === 1 && values[0] === absentJournalToken) {
1063
+ return [];
1064
+ }
1065
+ return values.map((value) => decodeJournalText(value, `${start} list`));
1066
+ }
1067
+ function parseLineDocument(content, eventIds, schemaVersion) {
1068
+ if (schemaVersion === 1) {
1069
+ return parseLegacyLineDocument(content, eventIds);
1070
+ }
1071
+ if (schemaVersion === canonicalJournalSchemaVersion) {
1072
+ return parseLineDocumentV3(content, eventIds);
1073
+ }
1074
+ const lines = content
1075
+ .split(/\r?\n/)
1076
+ .filter((line) => line.startsWith('- '))
1077
+ .map((line) => line.slice(2));
1078
+ if (lines.length === 1 && lines[0] === 'null') {
1079
+ return [];
1080
+ }
1081
+ return lines.map((line, index) => parseCanonicalJournalLine(line, eventIds[Math.min(index, eventIds.length - 1)] ?? `hydrated-${index + 1}`, false));
1082
+ }
1083
+ function parseLegacyStateDocument(content) {
1084
+ const taskSlug = lineValue(content, '- Task: ');
1085
+ const taskId = lineValue(content, '- Task ID: ');
1086
+ const projectId = lineValue(content, '- Project ID: ');
1087
+ const objective = optionalLegacyRecorded(lineValue(content, '- Objective: '));
1088
+ const phase = optionalLegacyRecorded(lineValue(content, '- Active phase: '));
1089
+ const nextAction = optionalLegacyRecorded(lineValue(content, '- Next action: '));
1090
+ const lastLine = sectionLines(content, '## Last Checkpoint', null)[0];
1091
+ return {
1092
+ schemaVersion: 1,
1093
+ projectId,
1094
+ taskId,
1095
+ taskSlug,
1096
+ state: {
1097
+ ...(objective ? { objective } : {}),
1098
+ ...(phase ? { phase } : {}),
1099
+ ...(nextAction ? { nextAction } : {}),
1100
+ completed: sectionLegacyBullets(content, '## Completed', '## Blockers'),
1101
+ blockers: sectionLegacyBullets(content, '## Blockers', '## Forbidden Actions'),
1102
+ forbiddenActions: sectionLegacyBullets(content, '## Forbidden Actions', '## Last Checkpoint'),
1103
+ },
1104
+ lastCheckpoint: lastLine && lastLine !== '- Not recorded'
1105
+ ? parseLegacyJournalLine(lastLine, 'hydrated-last-checkpoint')
1106
+ : null,
1107
+ };
1108
+ }
1109
+ function parseLegacyLineDocument(content, eventIds) {
1110
+ const lines = content
1111
+ .split(/\r?\n/)
1112
+ .filter((line) => line.startsWith('- ') && line !== '- Not recorded');
1113
+ return lines.map((line, index) => parseLegacyJournalLine(line, eventIds[Math.min(index, eventIds.length - 1)] ?? `hydrated-${index + 1}`));
1114
+ }
1115
+ function parseLegacyJournalLine(line, eventId) {
1116
+ const match = /^- ([^|]+) \| ([^|]+) \| (.*)$/.exec(line);
1117
+ if (!match) {
1118
+ throw new Error('Backend journal line is not canonical');
1119
+ }
1120
+ const [summary, details] = match[3].split(' — ', 2);
1121
+ return {
1122
+ eventId,
1123
+ createdAt: match[1].trim(),
1124
+ checkpointType: match[2].trim(),
1125
+ summary: summary.trim(),
1126
+ ...(details ? { details: details.trim() } : {}),
1127
+ };
1128
+ }
1129
+ function parseLineDocumentV3(content, eventIds) {
1130
+ const values = content.split('\n').filter((line) => line.startsWith('- '));
1131
+ if (values.length === 1 && values[0] === `- ${absentJournalToken}`) {
1132
+ return [];
1133
+ }
1134
+ return values.map((value, index) => parseJournalEntry(value, eventIds[Math.min(index, eventIds.length - 1)] ?? `hydrated-${index + 1}`));
1135
+ }
1136
+ function parseHandoffDocument(content, schemaVersion) {
1137
+ if (schemaVersion === 1) {
1138
+ return parseLegacyHandoffDocument(content);
1139
+ }
1140
+ if (schemaVersion === canonicalJournalSchemaVersion) {
1141
+ return parseHandoffDocumentV3(content);
1142
+ }
1143
+ const restartFrom = parseCanonicalOptionalString(singleSectionValue(content, '## Restart From', null, false), 'restart from');
1144
+ return {
1145
+ completed: parseCanonicalList(content, '## Completed', '## Remaining'),
1146
+ remaining: parseCanonicalList(content, '## Remaining', '## Restart From'),
1147
+ ...(restartFrom !== undefined ? { restartFrom } : {}),
1148
+ };
1149
+ }
1150
+ function parseHandoffDocumentV3(content) {
1151
+ const restart = rawSectionLines(content, '## Restart From', null).filter(Boolean);
1152
+ const value = restart[0];
1153
+ if (restart.length !== 1 || value === undefined || !value.startsWith('- ')) {
1154
+ throw new Error('Backend journal section is not canonical: ## Restart From');
1155
+ }
1156
+ const restartFrom = decodeOptionalJournalText(value.slice(2), 'restart from');
1157
+ return {
1158
+ completed: parseTextList(content, '## Completed', '## Remaining'),
1159
+ remaining: parseTextList(content, '## Remaining', '## Restart From'),
1160
+ ...(restartFrom !== undefined ? { restartFrom } : {}),
1161
+ };
1162
+ }
1163
+ function parseLegacyHandoffDocument(content) {
1164
+ const restartFrom = sectionLines(content, '## Restart From', null)[0];
1165
+ return {
1166
+ completed: sectionLegacyBullets(content, '## Completed', '## Remaining'),
1167
+ remaining: sectionLegacyBullets(content, '## Remaining', '## Restart From'),
1168
+ ...(restartFrom && restartFrom !== 'Not recorded' ? { restartFrom } : {}),
1169
+ };
1170
+ }
1171
+ function sectionLegacyBullets(content, start, end) {
1172
+ return sectionLines(content, start, end)
1173
+ .filter((line) => line.startsWith('- ') && line !== '- None')
1174
+ .map((line) => line.slice(2));
1175
+ }
1176
+ function sectionLines(content, start, end) {
1177
+ const lines = content.split(/\r?\n/);
1178
+ const startIndex = lines.indexOf(start);
1179
+ if (startIndex < 0) {
1180
+ throw new Error(`Backend journal section is missing: ${start}`);
1181
+ }
1182
+ const endIndex = end ? lines.indexOf(end, startIndex + 1) : lines.length;
1183
+ if (end && endIndex < 0) {
1184
+ throw new Error(`Backend journal section is missing: ${end}`);
1185
+ }
1186
+ return lines
1187
+ .slice(startIndex + 1, endIndex)
1188
+ .map((line) => line.trim())
1189
+ .filter(Boolean);
1190
+ }
1191
+ function lineValue(content, prefix) {
1192
+ const line = content.split(/\r?\n/).find((value) => value.startsWith(prefix));
1193
+ if (!line) {
1194
+ throw new Error(`Backend journal field is missing: ${prefix}`);
1195
+ }
1196
+ return line.slice(prefix.length).trim();
1197
+ }
1198
+ function optionalLineValue(content, prefix) {
1199
+ const line = content.split(/\r?\n/).find((value) => value.startsWith(prefix));
1200
+ return line?.slice(prefix.length).trim();
1201
+ }
1202
+ function optionalLegacyRecorded(value) {
1203
+ return value === 'Not recorded' ? undefined : value;
1204
+ }
1205
+ function parseCanonicalList(content, start, end) {
1206
+ const value = singleSectionValue(content, start, end, true);
1207
+ const parsed = parseCanonicalJson(value, `${start} list`);
1208
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string')) {
1209
+ throw new Error(`Backend journal ${start} list is not canonical`);
1210
+ }
1211
+ return [...parsed];
1212
+ }
1213
+ function singleSectionValue(content, start, end, bullet) {
1214
+ const lines = sectionLines(content, start, end);
1215
+ if (lines.length !== 1 || (bullet && !lines[0].startsWith('- '))) {
1216
+ throw new Error(`Backend journal section is not canonical: ${start}`);
1217
+ }
1218
+ return bullet ? lines[0].slice(2) : lines[0];
1219
+ }
1220
+ function parseCanonicalJournalLine(value, eventId, nullable) {
1221
+ const parsed = parseCanonicalJson(value, 'journal line');
1222
+ if (nullable && parsed === null) {
1223
+ return null;
1224
+ }
1225
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
1226
+ throw new Error('Backend journal line is not canonical');
1227
+ }
1228
+ const line = parsed;
1229
+ if (typeof line.createdAt !== 'string' ||
1230
+ typeof line.checkpointType !== 'string' ||
1231
+ typeof line.summary !== 'string' ||
1232
+ (Object.hasOwn(line, 'details') && typeof line.details !== 'string')) {
1233
+ throw new Error('Backend journal line is not canonical');
1234
+ }
1235
+ return {
1236
+ eventId,
1237
+ createdAt: line.createdAt,
1238
+ checkpointType: line.checkpointType,
1239
+ summary: line.summary,
1240
+ ...(Object.hasOwn(line, 'details') ? { details: line.details } : {}),
1241
+ };
1242
+ }
1243
+ function parseCanonicalString(value, label) {
1244
+ const parsed = parseCanonicalJson(value, label);
1245
+ if (typeof parsed !== 'string') {
1246
+ throw new Error(`Backend journal ${label} is not canonical`);
1247
+ }
1248
+ return parsed;
1249
+ }
1250
+ function parseCanonicalOptionalString(value, label) {
1251
+ const parsed = parseCanonicalJson(value, label);
1252
+ if (parsed === null) {
1253
+ return undefined;
1254
+ }
1255
+ if (typeof parsed !== 'string') {
1256
+ throw new Error(`Backend journal ${label} is not canonical`);
1257
+ }
1258
+ return parsed;
1259
+ }
1260
+ function parseCanonicalJson(value, label) {
1261
+ try {
1262
+ return JSON.parse(value);
1263
+ }
1264
+ catch {
1265
+ throw new Error(`Backend journal ${label} is not canonical`);
1266
+ }
1267
+ }
1268
+ function assertCanonicalJournalSchema(schemaVersion) {
1269
+ if (schemaVersion !== canonicalJournalSchemaVersion) {
1270
+ throw new Error('Journal projection format is unsupported');
1271
+ }
1272
+ }
1273
+ function safeErrorKind(value) {
1274
+ const normalized = value
1275
+ .toLowerCase()
1276
+ .replace(/[^a-z0-9._-]+/g, '_')
1277
+ .slice(0, 80);
1278
+ return normalized || 'unknown_error';
1279
+ }
1280
+ function isMissing(error) {
1281
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
1282
+ }
1283
+ function objectBody(value) {
1284
+ if (value === null || Array.isArray(value) || typeof value !== 'object') {
1285
+ throw new Error('Pending journal delivery body is invalid');
1286
+ }
1287
+ return { ...value };
1288
+ }
1289
+ function requiresExplicitResolution(errorKind) {
1290
+ if (!errorKind || errorKind === 'backend_unavailable') {
1291
+ return false;
1292
+ }
1293
+ const status = /^api_(\d{3})_/.exec(errorKind)?.[1];
1294
+ if (!status) {
1295
+ return true;
1296
+ }
1297
+ const value = Number(status);
1298
+ return value >= 400 && value < 500 && value !== 408 && value !== 429;
1299
+ }
1300
+ //# sourceMappingURL=journal-store.js.map