@sublang/playbook 9.0.0 → 10.0.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 (64) hide show
  1. package/docs/cli.md +51 -8
  2. package/docs/embedding.md +38 -12
  3. package/package.json +7 -3
  4. package/reference/sdlc/captain.md +14 -10
  5. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  6. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  7. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  8. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  9. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  10. package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
  11. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  12. package/reference/sdlc/code.md +0 -1
  13. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  14. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  15. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  16. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  17. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  18. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  19. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  20. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  21. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  22. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  23. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  24. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  25. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  26. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  27. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  28. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  29. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  30. package/reference/sdlc/code.playbook/playbook-captain.js +1850 -72
  31. package/reference/sdlc/code.playbook/playbook-captain.ts +2759 -96
  32. package/reference/sdlc/decide.md +0 -1
  33. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  35. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  36. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  37. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  38. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  39. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  40. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  41. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  42. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  44. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  45. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  47. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  48. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  50. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  51. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  52. package/slc/gears2fsm.md +6 -5
  53. package/slc/link.md +544 -41
  54. package/src/accepted-outcome.d.ts +18 -0
  55. package/src/accepted-outcome.js +94 -0
  56. package/src/accepted-outcome.ts +140 -0
  57. package/src/runtime.d.ts +164 -3
  58. package/src/runtime.ts +213 -2
  59. package/src/xstate-playbook-runtime.d.ts +149 -10
  60. package/src/xstate-playbook-runtime.js +2569 -270
  61. package/src/xstate-playbook-runtime.ts +4133 -490
  62. package/src/xstate-runtime.d.ts +59 -1
  63. package/src/xstate-runtime.js +866 -7
  64. package/src/xstate-runtime.ts +1397 -7
@@ -0,0 +1,2930 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import { execFile } from 'node:child_process';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { constants } from 'node:fs';
7
+ import {
8
+ chmod,
9
+ lstat,
10
+ mkdir,
11
+ open,
12
+ readFile,
13
+ readdir,
14
+ readlink,
15
+ realpath,
16
+ rename,
17
+ rm,
18
+ } from 'node:fs/promises';
19
+ import { hostname as systemHostname } from 'node:os';
20
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
21
+ import { isDeepStrictEqual } from 'node:util';
22
+ import {
23
+ assertPlaybookEffectLedger,
24
+ emptyPlaybookEffectLedger,
25
+ snapshotJsonValue,
26
+ } from '../../../../src/xstate-runtime.js';
27
+
28
+ const CLAIM_SCHEMA = 1;
29
+ const CLAIM_OWNER_FILE = 'owner.json';
30
+ const CLAIM_ROOT_NAME = 'playbook-effect-claims';
31
+ const UUID_PATTERN =
32
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
33
+ const OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
34
+ const CLAIM_COLLISION_CODES = new Set(['EEXIST', 'ENOTEMPTY']);
35
+ const processClaims = new Map();
36
+ const capabilityLedgerServices = new WeakMap();
37
+ const processClaimEntries = new WeakMap();
38
+
39
+ export const REPOSITORY_RECEIPT_CLASSIFICATIONS = Object.freeze([
40
+ 'unchanged',
41
+ 'one-descendant-commit',
42
+ 'multiple-commits',
43
+ 'rewritten-or-non-descendant',
44
+ 'worktree-only-change',
45
+ 'concurrent-or-foreign-change',
46
+ 'observation-ambiguous',
47
+ ]);
48
+
49
+ export class RepositoryObservationAmbiguousError extends Error {
50
+ constructor(
51
+ message = 'repository observation changed while it was sampled',
52
+ options,
53
+ ) {
54
+ super(message, options);
55
+ this.name = 'RepositoryObservationAmbiguousError';
56
+ }
57
+ }
58
+
59
+ function errorCode(error) {
60
+ return typeof error === 'object' && error !== null ? error.code : undefined;
61
+ }
62
+
63
+ function processClaimKey(identity) {
64
+ return `${identity.gitDir}\0${identity.worktree}`;
65
+ }
66
+
67
+ function registerProcessClaim(claim) {
68
+ const key = processClaimKey(claim.identity);
69
+ const entry = { claim, key, state: 'active' };
70
+ processClaims.set(key, entry);
71
+ processClaimEntries.set(claim, entry);
72
+ }
73
+
74
+ function quarantineProcessClaim(claim, recovery) {
75
+ const entry = processClaimEntries.get(claim);
76
+ if (entry === undefined) return;
77
+ entry.state = 'quarantined';
78
+ if (recovery !== undefined) entry.recovery = recovery;
79
+ }
80
+
81
+ function processClaimRecovery(claim) {
82
+ return processClaimEntries.get(claim)?.recovery;
83
+ }
84
+
85
+ function sameProcessRecovery(left, right) {
86
+ return (
87
+ left !== undefined &&
88
+ right !== undefined &&
89
+ left.sessionId === right.sessionId &&
90
+ left.playbookId === right.playbookId &&
91
+ sameOrderedSet(left.boundaryIds, right.boundaryIds)
92
+ );
93
+ }
94
+
95
+ function forgetProcessClaim(claim) {
96
+ const entry = processClaimEntries.get(claim);
97
+ if (entry === undefined) return;
98
+ if (processClaims.get(entry.key) === entry) processClaims.delete(entry.key);
99
+ processClaimEntries.delete(claim);
100
+ }
101
+
102
+ async function acquireRecoveryClaim(coordinator, identity, recovery) {
103
+ const key = processClaimKey(identity);
104
+ const entry = processClaims.get(key);
105
+ if (entry === undefined) {
106
+ return coordinator.acquire(identity.worktree);
107
+ }
108
+ if (entry.state !== 'quarantined') {
109
+ throw new Error('repository claim is not available for recovery');
110
+ }
111
+ if (!sameProcessRecovery(entry.recovery, recovery)) {
112
+ throw new Error(
113
+ 'repository claim belongs to another live reconciliation boundary',
114
+ );
115
+ }
116
+ entry.state = 'recovering';
117
+ try {
118
+ await entry.claim.assertOwner();
119
+ return entry.claim;
120
+ } catch (error) {
121
+ if (errorCode(error) === 'ENOENT') {
122
+ forgetProcessClaim(entry.claim);
123
+ return coordinator.acquire(identity.worktree);
124
+ }
125
+ entry.state = 'quarantined';
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ function isPlainObject(value) {
131
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
132
+ return false;
133
+ }
134
+ const prototype = Object.getPrototypeOf(value);
135
+ return prototype === Object.prototype || prototype === null;
136
+ }
137
+
138
+ function deepFreeze(value) {
139
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
140
+ for (const member of Object.values(value)) deepFreeze(member);
141
+ Object.freeze(value);
142
+ }
143
+ return value;
144
+ }
145
+
146
+ function sha256(value) {
147
+ return createHash('sha256').update(value).digest('hex');
148
+ }
149
+
150
+ function assertOid(value, label) {
151
+ if (typeof value !== 'string' || !OID_PATTERN.test(value)) {
152
+ throw new TypeError(`${label} must be a Git commit OID`);
153
+ }
154
+ }
155
+
156
+ function assertSignal(signal) {
157
+ if (signal !== undefined && !(signal instanceof AbortSignal)) {
158
+ throw new TypeError('repository claim signal must be an AbortSignal');
159
+ }
160
+ }
161
+
162
+ function assertAllowedDispositions(value) {
163
+ if (!Array.isArray(value) || value.length === 0) {
164
+ throw new TypeError('allowedDispositions must be a nonempty array');
165
+ }
166
+ const allowed = new Set([
167
+ 'unchanged',
168
+ 'one-descendant-commit',
169
+ 'deferred',
170
+ ]);
171
+ for (const disposition of value) {
172
+ if (typeof disposition !== 'string' || !allowed.has(disposition)) {
173
+ throw new TypeError(
174
+ 'allowedDispositions entries must be unchanged, one-descendant-commit, or deferred',
175
+ );
176
+ }
177
+ }
178
+ return Object.freeze([...new Set(value)]);
179
+ }
180
+
181
+ function runCommand(command, args, options = {}) {
182
+ return new Promise((resolvePromise, rejectPromise) => {
183
+ execFile(
184
+ command,
185
+ args,
186
+ {
187
+ cwd: options.cwd,
188
+ encoding: 'buffer',
189
+ env: {
190
+ ...process.env,
191
+ GIT_OPTIONAL_LOCKS: '0',
192
+ LC_ALL: 'C',
193
+ ...options.env,
194
+ },
195
+ maxBuffer: 64 * 1024 * 1024,
196
+ },
197
+ (error, stdout, stderr) => {
198
+ if (error !== null) {
199
+ Object.defineProperties(error, {
200
+ stdout: { value: stdout },
201
+ stderr: { value: stderr },
202
+ });
203
+ rejectPromise(error);
204
+ return;
205
+ }
206
+ resolvePromise(Buffer.from(stdout));
207
+ },
208
+ );
209
+ });
210
+ }
211
+
212
+ function runGit(cwd, args, options = {}) {
213
+ return runCommand('git', ['--literal-pathspecs', ...args], {
214
+ ...options,
215
+ cwd,
216
+ });
217
+ }
218
+
219
+ async function runGitText(cwd, args) {
220
+ const raw = await runGit(cwd, args);
221
+ const content = raw.at(-1) === 0x0a ? raw.subarray(0, -1) : raw;
222
+ const value = content.toString('utf8');
223
+ if (!Buffer.from(value, 'utf8').equals(content)) {
224
+ throw new RepositoryObservationAmbiguousError(
225
+ 'Git returned text that is not lossless UTF-8',
226
+ );
227
+ }
228
+ return value;
229
+ }
230
+
231
+ async function syncDirectory(path) {
232
+ let directory;
233
+ try {
234
+ directory = await open(path, 'r');
235
+ await directory.sync();
236
+ } finally {
237
+ await directory?.close();
238
+ }
239
+ }
240
+
241
+ function pathInside(root, path) {
242
+ const absolute = resolve(root, path);
243
+ const suffix = relative(root, absolute);
244
+ if (suffix === '' || (!suffix.startsWith(`..${sep}`) && suffix !== '..' && !isAbsolute(suffix))) {
245
+ return absolute;
246
+ }
247
+ throw new RepositoryObservationAmbiguousError(
248
+ `Git reported a path outside the canonical worktree: ${JSON.stringify(path)}`,
249
+ );
250
+ }
251
+
252
+ function splitFixedFields(record, count) {
253
+ const fields = [];
254
+ let offset = 0;
255
+ for (let index = 0; index < count; index += 1) {
256
+ const separator = record.indexOf(' ', offset);
257
+ if (separator < 0) {
258
+ throw new RepositoryObservationAmbiguousError(
259
+ 'Git returned a malformed porcelain-v2 record',
260
+ );
261
+ }
262
+ fields.push(record.slice(offset, separator));
263
+ offset = separator + 1;
264
+ }
265
+ fields.push(record.slice(offset));
266
+ return fields;
267
+ }
268
+
269
+ function splitNulRecords(raw) {
270
+ if (raw.length === 0) return [];
271
+ if (raw.at(-1) !== 0) {
272
+ throw new RepositoryObservationAmbiguousError(
273
+ 'Git returned an unterminated porcelain-v2 record',
274
+ );
275
+ }
276
+ const records = [];
277
+ let offset = 0;
278
+ for (let index = 0; index < raw.length; index += 1) {
279
+ if (raw[index] !== 0) continue;
280
+ records.push(raw.subarray(offset, index));
281
+ offset = index + 1;
282
+ }
283
+ return records;
284
+ }
285
+
286
+ function decodeGitRecord(raw) {
287
+ const value = raw.toString('utf8');
288
+ if (!Buffer.from(value, 'utf8').equals(raw)) {
289
+ throw new RepositoryObservationAmbiguousError(
290
+ 'Git returned a path that is not lossless UTF-8',
291
+ );
292
+ }
293
+ return value;
294
+ }
295
+
296
+ function parseStatusRecords(raw) {
297
+ const tokens = splitNulRecords(raw).map(decodeGitRecord);
298
+ const records = [];
299
+ for (let index = 0; index < tokens.length; index += 1) {
300
+ const record = tokens[index];
301
+ if (record.startsWith('? ')) {
302
+ records.push({ kind: 'untracked', path: record.slice(2) });
303
+ continue;
304
+ }
305
+ if (record.startsWith('1 ')) {
306
+ const fields = splitFixedFields(record, 8);
307
+ records.push({
308
+ kind: 'ordinary',
309
+ xy: fields[1],
310
+ submodule: fields[2],
311
+ headMode: fields[3],
312
+ indexMode: fields[4],
313
+ worktreeMode: fields[5],
314
+ headOid: fields[6],
315
+ indexOid: fields[7],
316
+ path: fields[8],
317
+ });
318
+ continue;
319
+ }
320
+ if (record.startsWith('2 ')) {
321
+ const fields = splitFixedFields(record, 9);
322
+ const originalPath = tokens[index + 1];
323
+ if (originalPath === undefined) {
324
+ throw new RepositoryObservationAmbiguousError(
325
+ 'Git returned a rename without its original path',
326
+ );
327
+ }
328
+ index += 1;
329
+ records.push({
330
+ kind: 'rename-or-copy',
331
+ xy: fields[1],
332
+ submodule: fields[2],
333
+ headMode: fields[3],
334
+ indexMode: fields[4],
335
+ worktreeMode: fields[5],
336
+ headOid: fields[6],
337
+ indexOid: fields[7],
338
+ score: fields[8],
339
+ path: fields[9],
340
+ originalPath,
341
+ });
342
+ continue;
343
+ }
344
+ if (record.startsWith('u ')) {
345
+ const fields = splitFixedFields(record, 10);
346
+ records.push({
347
+ kind: 'unmerged',
348
+ xy: fields[1],
349
+ submodule: fields[2],
350
+ stage1Mode: fields[3],
351
+ stage2Mode: fields[4],
352
+ stage3Mode: fields[5],
353
+ worktreeMode: fields[6],
354
+ stage1Oid: fields[7],
355
+ stage2Oid: fields[8],
356
+ stage3Oid: fields[9],
357
+ path: fields[10],
358
+ });
359
+ continue;
360
+ }
361
+ throw new RepositoryObservationAmbiguousError(
362
+ `Git returned an unsupported porcelain-v2 record ${JSON.stringify(record)}`,
363
+ );
364
+ }
365
+ return records;
366
+ }
367
+
368
+ async function worktreePathIdentity(worktree, path, modeHint, allowMissing) {
369
+ const absolute = pathInside(worktree, path);
370
+ let before;
371
+ try {
372
+ before = await lstat(absolute, { bigint: true });
373
+ } catch (error) {
374
+ if (errorCode(error) === 'ENOENT' && allowMissing) {
375
+ return Object.freeze({ kind: 'missing' });
376
+ }
377
+ throw new RepositoryObservationAmbiguousError(
378
+ `cannot inspect reported repository path: ${JSON.stringify(path)}`,
379
+ { cause: error },
380
+ );
381
+ }
382
+
383
+ let identity;
384
+ if (before.isFile()) {
385
+ let bytes;
386
+ try {
387
+ bytes = await readFile(absolute);
388
+ } catch (error) {
389
+ throw new RepositoryObservationAmbiguousError(
390
+ `cannot read reported repository file: ${JSON.stringify(path)}`,
391
+ { cause: error },
392
+ );
393
+ }
394
+ identity = {
395
+ kind: 'file',
396
+ mode: (before.mode & 0o111n) === 0n ? '100644' : '100755',
397
+ content: `sha256:${sha256(bytes)}`,
398
+ };
399
+ } else if (before.isSymbolicLink()) {
400
+ let target;
401
+ try {
402
+ target = await readlink(absolute, { encoding: 'buffer' });
403
+ } catch (error) {
404
+ throw new RepositoryObservationAmbiguousError(
405
+ `cannot read reported repository symlink: ${JSON.stringify(path)}`,
406
+ { cause: error },
407
+ );
408
+ }
409
+ identity = {
410
+ kind: 'symlink',
411
+ mode: '120000',
412
+ content: `sha256:${sha256(target)}`,
413
+ };
414
+ } else if (before.isDirectory()) {
415
+ let nested;
416
+ try {
417
+ const nestedIdentity = await resolveCanonicalGitWorktree(absolute);
418
+ if (nestedIdentity.worktree !== (await realpath(absolute))) {
419
+ throw new Error('directory is not a nested Git worktree root');
420
+ }
421
+ nested = await observeResolvedWorktree(nestedIdentity);
422
+ } catch (error) {
423
+ throw new RepositoryObservationAmbiguousError(
424
+ `cannot address repository directory content: ${JSON.stringify(path)}`,
425
+ { cause: error },
426
+ );
427
+ }
428
+ identity = {
429
+ kind: 'directory',
430
+ mode: modeHint === '160000' ? '160000' : '040000',
431
+ content: `sha256:${sha256(
432
+ JSON.stringify([nested.head, nested.projectionDigest]),
433
+ )}`,
434
+ };
435
+ } else {
436
+ throw new RepositoryObservationAmbiguousError(
437
+ `cannot address special repository path content: ${JSON.stringify(path)}`,
438
+ );
439
+ }
440
+
441
+ let after;
442
+ try {
443
+ after = await lstat(absolute, { bigint: true });
444
+ } catch (error) {
445
+ throw new RepositoryObservationAmbiguousError(
446
+ `repository path changed while sampled: ${JSON.stringify(path)}`,
447
+ { cause: error },
448
+ );
449
+ }
450
+ if (
451
+ before.dev !== after.dev ||
452
+ before.ino !== after.ino ||
453
+ before.mode !== after.mode ||
454
+ before.size !== after.size ||
455
+ before.mtimeNs !== after.mtimeNs ||
456
+ before.ctimeNs !== after.ctimeNs
457
+ ) {
458
+ throw new RepositoryObservationAmbiguousError(
459
+ `repository path changed while sampled: ${JSON.stringify(path)}`,
460
+ );
461
+ }
462
+ return Object.freeze(identity);
463
+ }
464
+
465
+ async function projectionFromStatus(worktree, rawStatus) {
466
+ const records = parseStatusRecords(rawStatus);
467
+ const entries = [];
468
+ for (const record of records) {
469
+ if (typeof record.path !== 'string' || record.path.length === 0) {
470
+ throw new RepositoryObservationAmbiguousError(
471
+ 'Git returned an empty repository path',
472
+ );
473
+ }
474
+ const base = { ...record };
475
+ delete base.path;
476
+ const needsWorktreeIdentity =
477
+ record.kind === 'untracked' ||
478
+ record.kind === 'unmerged' ||
479
+ record.xy?.[1] !== '.' ||
480
+ (record.submodule !== undefined && record.submodule !== 'N...');
481
+ entries.push([
482
+ record.path,
483
+ {
484
+ ...base,
485
+ ...(needsWorktreeIdentity
486
+ ? {
487
+ worktree: await worktreePathIdentity(
488
+ worktree,
489
+ record.path,
490
+ record.worktreeMode,
491
+ record.xy?.[1] === 'D',
492
+ ),
493
+ }
494
+ : {}),
495
+ },
496
+ ]);
497
+ }
498
+ entries.sort(([left], [right]) =>
499
+ left < right ? -1 : left > right ? 1 : 0,
500
+ );
501
+ const projection = Object.create(null);
502
+ for (const [path, entry] of entries) {
503
+ if (Object.prototype.hasOwnProperty.call(projection, path)) {
504
+ throw new RepositoryObservationAmbiguousError(
505
+ `Git returned duplicate status for ${JSON.stringify(path)}`,
506
+ );
507
+ }
508
+ projection[path] = deepFreeze(entry);
509
+ }
510
+ return Object.freeze(projection);
511
+ }
512
+
513
+ function projectionText(projection) {
514
+ return JSON.stringify(projection);
515
+ }
516
+
517
+ function projectionPreservesBaseline(baseline, after) {
518
+ return Object.entries(baseline).every(
519
+ ([path, entry]) =>
520
+ Object.prototype.hasOwnProperty.call(after, path) &&
521
+ JSON.stringify(after[path]) === JSON.stringify(entry),
522
+ );
523
+ }
524
+
525
+ async function rawRepositoryStatus(worktree) {
526
+ return runGit(worktree, [
527
+ '-c',
528
+ 'core.fileMode=true',
529
+ '-c',
530
+ 'core.fsmonitor=false',
531
+ '-c',
532
+ 'core.ignoreStat=false',
533
+ '-c',
534
+ 'core.trustctime=true',
535
+ '-c',
536
+ 'core.checkStat=default',
537
+ 'status',
538
+ '--porcelain=v2',
539
+ '-z',
540
+ '--untracked-files=all',
541
+ '--ignored=no',
542
+ '--ignore-submodules=none',
543
+ '--no-renames',
544
+ ]);
545
+ }
546
+
547
+ async function rawIndexVisibility(worktree) {
548
+ return runGit(worktree, ['ls-files', '-v', '-z']);
549
+ }
550
+
551
+ function assertIndexVisibility(raw) {
552
+ for (const record of splitNulRecords(raw)) {
553
+ if (record.length < 3 || record[1] !== 0x20) {
554
+ throw new RepositoryObservationAmbiguousError(
555
+ 'Git returned malformed index-visibility data',
556
+ );
557
+ }
558
+ const tag = record[0];
559
+ if (tag === 0x53 || (tag >= 0x61 && tag <= 0x7a)) {
560
+ throw new RepositoryObservationAmbiguousError(
561
+ 'Git index flags suppress exact tracked-worktree observation',
562
+ );
563
+ }
564
+ }
565
+ }
566
+
567
+ export async function resolveCanonicalGitWorktree(cwd) {
568
+ if (typeof cwd !== 'string' || cwd.length === 0) {
569
+ throw new TypeError('repository working directory must be a nonempty string');
570
+ }
571
+ const inside = await runGitText(cwd, ['rev-parse', '--is-inside-work-tree']);
572
+ if (inside !== 'true') {
573
+ throw new Error(`${JSON.stringify(cwd)} is not inside a Git worktree`);
574
+ }
575
+ const [reportedRoot, reportedGitDir] = await Promise.all([
576
+ runGitText(cwd, ['rev-parse', '--show-toplevel']),
577
+ runGitText(cwd, ['rev-parse', '--absolute-git-dir']),
578
+ ]);
579
+ const [worktree, gitDir] = await Promise.all([
580
+ realpath(reportedRoot),
581
+ realpath(reportedGitDir),
582
+ ]);
583
+ return Object.freeze({ worktree, gitDir });
584
+ }
585
+
586
+ async function observeResolvedWorktree(identity, options = {}) {
587
+ const headBefore = await runGitText(identity.worktree, [
588
+ 'rev-parse',
589
+ '--verify',
590
+ 'HEAD^{commit}',
591
+ ]);
592
+ assertOid(headBefore, 'repository HEAD');
593
+ const indexVisibilityBefore = await rawIndexVisibility(identity.worktree);
594
+ assertIndexVisibility(indexVisibilityBefore);
595
+ const statusBefore = await rawRepositoryStatus(identity.worktree);
596
+ const projectionBefore = await projectionFromStatus(
597
+ identity.worktree,
598
+ statusBefore,
599
+ );
600
+ await options.afterFirstSample?.();
601
+ const headAfter = await runGitText(identity.worktree, [
602
+ 'rev-parse',
603
+ '--verify',
604
+ 'HEAD^{commit}',
605
+ ]);
606
+ const indexVisibilityAfter = await rawIndexVisibility(identity.worktree);
607
+ assertIndexVisibility(indexVisibilityAfter);
608
+ const statusAfter = await rawRepositoryStatus(identity.worktree);
609
+ const projectionAfter = await projectionFromStatus(
610
+ identity.worktree,
611
+ statusAfter,
612
+ );
613
+ const beforeText = projectionText(projectionBefore);
614
+ if (
615
+ headBefore !== headAfter ||
616
+ !indexVisibilityBefore.equals(indexVisibilityAfter) ||
617
+ !statusBefore.equals(statusAfter) ||
618
+ beforeText !== projectionText(projectionAfter)
619
+ ) {
620
+ throw new RepositoryObservationAmbiguousError();
621
+ }
622
+ return deepFreeze({
623
+ worktree: identity.worktree,
624
+ gitDir: identity.gitDir,
625
+ head: headBefore,
626
+ projection: projectionBefore,
627
+ projectionDigest: `sha256:${sha256(beforeText)}`,
628
+ });
629
+ }
630
+
631
+ export async function observeGitRepository(cwd, options = {}) {
632
+ const identity = await resolveCanonicalGitWorktree(cwd);
633
+ return observeResolvedWorktree(identity, options);
634
+ }
635
+
636
+ function assertObservation(value, label) {
637
+ if (!isPlainObject(value)) {
638
+ throw new TypeError(`${label} must be a repository observation`);
639
+ }
640
+ if (typeof value.worktree !== 'string' || typeof value.gitDir !== 'string') {
641
+ throw new TypeError(`${label} must carry canonical worktree identity`);
642
+ }
643
+ assertOid(value.head, `${label}.head`);
644
+ if (!isPlainObject(value.projection)) {
645
+ throw new TypeError(`${label}.projection must be path-keyed`);
646
+ }
647
+ if (
648
+ typeof value.projectionDigest !== 'string' ||
649
+ value.projectionDigest !== `sha256:${sha256(projectionText(value.projection))}`
650
+ ) {
651
+ throw new TypeError(`${label}.projectionDigest does not match its projection`);
652
+ }
653
+ }
654
+
655
+ async function isAncestor(worktree, baselineHead, afterHead) {
656
+ try {
657
+ await runGit(worktree, [
658
+ 'merge-base',
659
+ '--is-ancestor',
660
+ baselineHead,
661
+ afterHead,
662
+ ]);
663
+ return true;
664
+ } catch (error) {
665
+ if (typeof error === 'object' && error !== null && error.code === 1) {
666
+ return false;
667
+ }
668
+ throw error;
669
+ }
670
+ }
671
+
672
+ async function descendantCount(worktree, baselineHead, afterHead) {
673
+ const value = await runGitText(worktree, [
674
+ 'rev-list',
675
+ '--count',
676
+ `${baselineHead}..${afterHead}`,
677
+ ]);
678
+ const count = Number(value);
679
+ if (!Number.isSafeInteger(count) || count < 0) {
680
+ throw new RepositoryObservationAmbiguousError(
681
+ 'Git returned an invalid descendant count',
682
+ );
683
+ }
684
+ return count;
685
+ }
686
+
687
+ function receipt(classification, baseline, after, commitOid) {
688
+ return deepFreeze({
689
+ classification,
690
+ baseline,
691
+ ...(after === undefined ? {} : { after }),
692
+ ...(commitOid === undefined ? {} : { commitOid }),
693
+ });
694
+ }
695
+
696
+ export async function classifyRepositoryReceipt(
697
+ baseline,
698
+ after,
699
+ options = {},
700
+ ) {
701
+ assertObservation(baseline, 'baseline');
702
+ assertObservation(after, 'after');
703
+ if (baseline.worktree !== after.worktree || baseline.gitDir !== after.gitDir) {
704
+ throw new TypeError('repository observations name different worktrees');
705
+ }
706
+ const allowed = assertAllowedDispositions(options.allowedDispositions);
707
+ const sameProjection =
708
+ baseline.projectionDigest === after.projectionDigest &&
709
+ projectionText(baseline.projection) === projectionText(after.projection);
710
+ const sameHead = baseline.head === after.head;
711
+ if (sameHead && sameProjection) {
712
+ return receipt('unchanged', baseline, after);
713
+ }
714
+ if (options.cohort === true) {
715
+ return receipt('observation-ambiguous', baseline, after);
716
+ }
717
+ if (allowed.every((value) => value === 'unchanged')) {
718
+ return receipt('concurrent-or-foreign-change', baseline, after);
719
+ }
720
+ if (sameHead) {
721
+ if (!projectionPreservesBaseline(baseline.projection, after.projection)) {
722
+ return receipt('observation-ambiguous', baseline, after);
723
+ }
724
+ return receipt(
725
+ allowed.includes('one-descendant-commit')
726
+ ? 'worktree-only-change'
727
+ : 'observation-ambiguous',
728
+ baseline,
729
+ after,
730
+ );
731
+ }
732
+ if (!(await isAncestor(after.worktree, baseline.head, after.head))) {
733
+ return receipt('rewritten-or-non-descendant', baseline, after);
734
+ }
735
+ const count = await descendantCount(after.worktree, baseline.head, after.head);
736
+ if (count > 1) return receipt('multiple-commits', baseline, after);
737
+ if (count !== 1) {
738
+ return receipt('rewritten-or-non-descendant', baseline, after);
739
+ }
740
+ if (!sameProjection) {
741
+ return receipt('observation-ambiguous', baseline, after);
742
+ }
743
+ return receipt('one-descendant-commit', baseline, after, after.head);
744
+ }
745
+
746
+ export async function captureRepositoryReceipt(baseline, options = {}) {
747
+ assertObservation(baseline, 'baseline');
748
+ try {
749
+ const after = await observeResolvedWorktree(
750
+ Object.freeze({
751
+ worktree: baseline.worktree,
752
+ gitDir: baseline.gitDir,
753
+ }),
754
+ options.observation,
755
+ );
756
+ return classifyRepositoryReceipt(baseline, after, options);
757
+ } catch (error) {
758
+ if (error instanceof RepositoryObservationAmbiguousError) {
759
+ return receipt('observation-ambiguous', baseline);
760
+ }
761
+ throw error;
762
+ }
763
+ }
764
+
765
+ function ownerPath(activePath) {
766
+ return join(activePath, CLAIM_OWNER_FILE);
767
+ }
768
+
769
+ function assertOwnerShape(value) {
770
+ if (!isPlainObject(value)) {
771
+ throw new Error('repository claim owner must be an object');
772
+ }
773
+ const keys = Object.keys(value).sort();
774
+ if (keys.join(',') !== 'hostname,ownerToken,pid,schema') {
775
+ throw new Error('repository claim owner has malformed members');
776
+ }
777
+ if (value.schema !== CLAIM_SCHEMA) {
778
+ throw new Error('repository claim owner schema is not supported');
779
+ }
780
+ if (typeof value.ownerToken !== 'string' || !UUID_PATTERN.test(value.ownerToken)) {
781
+ throw new Error('repository claim owner token is malformed');
782
+ }
783
+ if (!Number.isSafeInteger(value.pid) || value.pid <= 0) {
784
+ throw new Error('repository claim owner PID is malformed');
785
+ }
786
+ if (typeof value.hostname !== 'string' || value.hostname.length === 0) {
787
+ throw new Error('repository claim owner hostname is malformed');
788
+ }
789
+ return Object.freeze({ ...value });
790
+ }
791
+
792
+ async function assertPrivateDirectory(path, label) {
793
+ const info = await lstat(path);
794
+ if (!info.isDirectory() || info.isSymbolicLink()) {
795
+ throw new Error(`${label} is not a private directory`);
796
+ }
797
+ if ((info.mode & 0o7777) !== 0o700) {
798
+ throw new Error(`${label} permissions must be 0700`);
799
+ }
800
+ return info;
801
+ }
802
+
803
+ async function ensureClaimRoot(path) {
804
+ let created = false;
805
+ try {
806
+ await mkdir(path, { mode: 0o700 });
807
+ created = true;
808
+ } catch (error) {
809
+ if (errorCode(error) !== 'EEXIST') throw error;
810
+ }
811
+ if (created) {
812
+ await chmod(path, 0o700);
813
+ await syncDirectory(resolve(path, '..'));
814
+ }
815
+ await assertPrivateDirectory(path, 'repository claim root');
816
+ }
817
+
818
+ async function readClaimOwner(activePath) {
819
+ await assertPrivateDirectory(activePath, 'repository active claim');
820
+ const names = await readdir(activePath);
821
+ if (names.length !== 1 || names[0] !== CLAIM_OWNER_FILE) {
822
+ throw new Error('repository active claim has malformed contents');
823
+ }
824
+ const filePath = ownerPath(activePath);
825
+ const pathInfo = await lstat(filePath);
826
+ if (
827
+ !pathInfo.isFile() ||
828
+ pathInfo.isSymbolicLink() ||
829
+ (pathInfo.mode & 0o7777) !== 0o600
830
+ ) {
831
+ throw new Error('repository claim owner is not a private regular file');
832
+ }
833
+ let handle;
834
+ let source;
835
+ try {
836
+ handle = await open(
837
+ filePath,
838
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
839
+ );
840
+ const info = await handle.stat();
841
+ if (
842
+ !info.isFile() ||
843
+ (info.mode & 0o7777) !== 0o600 ||
844
+ info.dev !== pathInfo.dev ||
845
+ info.ino !== pathInfo.ino
846
+ ) {
847
+ throw new Error('repository claim owner changed during validation');
848
+ }
849
+ source = await handle.readFile('utf8');
850
+ } finally {
851
+ await handle?.close();
852
+ }
853
+ let parsed;
854
+ try {
855
+ parsed = JSON.parse(source);
856
+ } catch (error) {
857
+ throw new Error('repository claim owner is malformed JSON', { cause: error });
858
+ }
859
+ return assertOwnerShape(parsed);
860
+ }
861
+
862
+ async function createClaimStage(root, owner) {
863
+ const stagePath = join(root, `.stage-${owner.ownerToken}`);
864
+ await mkdir(stagePath, { mode: 0o700 });
865
+ await chmod(stagePath, 0o700);
866
+ let handle;
867
+ try {
868
+ handle = await open(ownerPath(stagePath), 'wx', 0o600);
869
+ await handle.chmod(0o600);
870
+ await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
871
+ await handle.sync();
872
+ await handle.close();
873
+ handle = undefined;
874
+ await syncDirectory(stagePath);
875
+ return stagePath;
876
+ } catch (error) {
877
+ await handle?.close().catch(() => undefined);
878
+ await rm(stagePath, { recursive: true, force: true }).catch(() => undefined);
879
+ throw error;
880
+ }
881
+ }
882
+
883
+ function defaultProbeProcess(pid) {
884
+ try {
885
+ process.kill(pid, 0);
886
+ return 'live';
887
+ } catch (error) {
888
+ if (errorCode(error) === 'ESRCH') return 'dead';
889
+ if (errorCode(error) === 'EPERM') return 'unknown';
890
+ throw error;
891
+ }
892
+ }
893
+
894
+ function waitForRetry(milliseconds, signal) {
895
+ assertSignal(signal);
896
+ if (signal?.aborted) return Promise.reject(signal.reason);
897
+ let timeout;
898
+ let onAbort;
899
+ return new Promise((resolvePromise, rejectPromise) => {
900
+ const settle = () => {
901
+ signal?.removeEventListener('abort', onAbort);
902
+ resolvePromise();
903
+ };
904
+ timeout = setTimeout(settle, milliseconds);
905
+ onAbort = () => {
906
+ clearTimeout(timeout);
907
+ signal?.removeEventListener('abort', onAbort);
908
+ rejectPromise(signal.reason);
909
+ };
910
+ signal?.addEventListener('abort', onAbort, { once: true });
911
+ if (signal !== undefined) {
912
+ void Promise.resolve().then(() => {
913
+ if (signal.aborted) onAbort();
914
+ });
915
+ }
916
+ });
917
+ }
918
+
919
+ async function retireClaim(root, activePath, owner) {
920
+ const retiredPath = join(root, `retired-${owner.ownerToken}`);
921
+ try {
922
+ await rename(activePath, retiredPath);
923
+ } catch (error) {
924
+ if (CLAIM_COLLISION_CODES.has(errorCode(error))) return false;
925
+ throw error;
926
+ }
927
+ const retired = await readClaimOwner(retiredPath);
928
+ if (retired.ownerToken !== owner.ownerToken) {
929
+ throw new Error('repository retired claim owner token changed');
930
+ }
931
+ await syncDirectory(root);
932
+ return true;
933
+ }
934
+
935
+ async function assertOwnerTokenAvailable(root, ownerToken) {
936
+ try {
937
+ await lstat(join(root, `retired-${ownerToken}`));
938
+ } catch (error) {
939
+ if (errorCode(error) === 'ENOENT') return;
940
+ throw error;
941
+ }
942
+ throw new Error('repository claim owner token was already retired');
943
+ }
944
+
945
+ function sameOrderedSet(left, right) {
946
+ return (
947
+ left.length === right.length && left.every((value, index) => value === right[index])
948
+ );
949
+ }
950
+
951
+ function validateCohort(options) {
952
+ if (typeof options.invocationId !== 'string' || options.invocationId.length === 0) {
953
+ throw new TypeError('cohort invocationId must be a nonempty string');
954
+ }
955
+ const roleIds = options.roleIds;
956
+ if (
957
+ !Array.isArray(roleIds) ||
958
+ roleIds.length < 2 ||
959
+ roleIds.some((role) => typeof role !== 'string' || role.length === 0) ||
960
+ new Set(roleIds).size !== roleIds.length
961
+ ) {
962
+ throw new TypeError('cohort roleIds must be distinct nonempty strings');
963
+ }
964
+ if (
965
+ !Array.isArray(options.concurrentRoleSets) ||
966
+ !options.concurrentRoleSets.some(
967
+ (candidate) => Array.isArray(candidate) && sameOrderedSet(candidate, roleIds),
968
+ )
969
+ ) {
970
+ throw new TypeError('cohort roleIds are not one declared concurrent role set');
971
+ }
972
+ if (!isPlainObject(options.dispositionsByRole)) {
973
+ throw new TypeError('cohort dispositionsByRole must be an object');
974
+ }
975
+ if (!isPlainObject(options.operations)) {
976
+ throw new TypeError('cohort operations must be an object');
977
+ }
978
+ const dispositionKeys = Object.keys(options.dispositionsByRole).sort();
979
+ const operationKeys = Object.keys(options.operations).sort();
980
+ const expected = [...roleIds].sort();
981
+ if (
982
+ !sameOrderedSet(dispositionKeys, expected) ||
983
+ !sameOrderedSet(operationKeys, expected)
984
+ ) {
985
+ throw new TypeError('cohort members must exactly match roles, dispositions, and operations');
986
+ }
987
+ for (const role of roleIds) {
988
+ const dispositions = assertAllowedDispositions(options.dispositionsByRole[role]);
989
+ if (!dispositions.every((value) => value === 'unchanged')) {
990
+ throw new TypeError('cohort roles must declare exclusively unchanged outcomes');
991
+ }
992
+ if (typeof options.operations[role] !== 'function') {
993
+ throw new TypeError(`cohort operation ${role} must be a function`);
994
+ }
995
+ }
996
+ return Object.freeze([...roleIds]);
997
+ }
998
+
999
+ export function createRepositoryEffectCoordinator(options = {}) {
1000
+ const currentHostname = options.hostname ?? systemHostname();
1001
+ const currentPid = options.pid ?? process.pid;
1002
+ const pollIntervalMs = options.pollIntervalMs ?? 10;
1003
+ const probeProcess = options.probeProcess ?? defaultProbeProcess;
1004
+ const createOwnerToken = options.createOwnerToken ?? randomUUID;
1005
+ const afterClaimPublished = options._testAfterClaimPublished;
1006
+ if (typeof currentHostname !== 'string' || currentHostname.length === 0) {
1007
+ throw new TypeError('repository coordinator hostname must be nonempty');
1008
+ }
1009
+ if (!Number.isSafeInteger(currentPid) || currentPid <= 0) {
1010
+ throw new TypeError('repository coordinator PID must be positive');
1011
+ }
1012
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) {
1013
+ throw new TypeError('repository coordinator poll interval must be nonnegative');
1014
+ }
1015
+ if (typeof probeProcess !== 'function') {
1016
+ throw new TypeError('repository coordinator process probe must be a function');
1017
+ }
1018
+ if (typeof createOwnerToken !== 'function') {
1019
+ throw new TypeError('repository coordinator owner-token generator must be a function');
1020
+ }
1021
+ if (
1022
+ afterClaimPublished !== undefined &&
1023
+ typeof afterClaimPublished !== 'function'
1024
+ ) {
1025
+ throw new TypeError('repository coordinator publication hook must be a function');
1026
+ }
1027
+
1028
+ const acquire = async (cwd, claimOptions = {}) => {
1029
+ assertSignal(claimOptions.signal);
1030
+ const identity = await resolveCanonicalGitWorktree(cwd);
1031
+ const root = join(identity.gitDir, CLAIM_ROOT_NAME);
1032
+ await ensureClaimRoot(root);
1033
+ const activePath = join(root, 'active');
1034
+ const ownerToken = createOwnerToken();
1035
+ if (typeof ownerToken !== 'string' || !UUID_PATTERN.test(ownerToken)) {
1036
+ throw new TypeError('repository coordinator owner-token generator returned an invalid token');
1037
+ }
1038
+ const owner = Object.freeze({
1039
+ schema: CLAIM_SCHEMA,
1040
+ ownerToken,
1041
+ pid: currentPid,
1042
+ hostname: currentHostname,
1043
+ });
1044
+
1045
+ while (true) {
1046
+ if (claimOptions.signal?.aborted) throw claimOptions.signal.reason;
1047
+ let activeOwner;
1048
+ try {
1049
+ activeOwner = await readClaimOwner(activePath);
1050
+ } catch (error) {
1051
+ if (errorCode(error) !== 'ENOENT') throw error;
1052
+ }
1053
+ if (activeOwner !== undefined) {
1054
+ if (activeOwner.ownerToken === owner.ownerToken) {
1055
+ throw new Error('repository claim owner token was reused');
1056
+ }
1057
+ if (activeOwner.hostname !== currentHostname) {
1058
+ throw new Error(
1059
+ `repository claim is owned by foreign host ${JSON.stringify(activeOwner.hostname)}`,
1060
+ );
1061
+ }
1062
+ const state = await probeProcess(activeOwner.pid);
1063
+ if (state === 'unknown') {
1064
+ throw new Error('repository claim owner process cannot be ruled dead');
1065
+ }
1066
+ if (state === 'live') {
1067
+ await waitForRetry(pollIntervalMs, claimOptions.signal);
1068
+ continue;
1069
+ }
1070
+ if (state !== 'dead') {
1071
+ throw new Error('repository process probe returned an invalid state');
1072
+ }
1073
+ if (!(await retireClaim(root, activePath, activeOwner))) continue;
1074
+ continue;
1075
+ }
1076
+
1077
+ await assertOwnerTokenAvailable(root, owner.ownerToken);
1078
+ const stagePath = await createClaimStage(root, owner);
1079
+ let published = false;
1080
+ try {
1081
+ try {
1082
+ await rename(stagePath, activePath);
1083
+ published = true;
1084
+ } catch (error) {
1085
+ if (CLAIM_COLLISION_CODES.has(errorCode(error))) {
1086
+ await rm(stagePath, { recursive: true, force: true });
1087
+ continue;
1088
+ }
1089
+ throw error;
1090
+ }
1091
+ await afterClaimPublished?.({ activePath, identity, owner });
1092
+ await syncDirectory(root);
1093
+ const publishedOwner = await readClaimOwner(activePath);
1094
+ if (publishedOwner.ownerToken !== owner.ownerToken) {
1095
+ throw new Error('repository claim owner token changed during publication');
1096
+ }
1097
+ } catch (error) {
1098
+ let cleanupError;
1099
+ try {
1100
+ if (published) {
1101
+ const activeOwner = await readClaimOwner(activePath);
1102
+ if (activeOwner.ownerToken !== owner.ownerToken) {
1103
+ throw new Error(
1104
+ 'repository claim owner changed before failed publication cleanup',
1105
+ );
1106
+ }
1107
+ if (!(await retireClaim(root, activePath, owner))) {
1108
+ throw new Error(
1109
+ 'repository claim could not retire after publication failure',
1110
+ );
1111
+ }
1112
+ } else {
1113
+ await rm(stagePath, { recursive: true, force: true });
1114
+ }
1115
+ } catch (cleanupCause) {
1116
+ cleanupError = cleanupCause;
1117
+ }
1118
+ if (cleanupError !== undefined) {
1119
+ throw new AggregateError(
1120
+ [error, cleanupError],
1121
+ 'repository claim publication failed and ownership could not be retired',
1122
+ );
1123
+ }
1124
+ throw error;
1125
+ }
1126
+ break;
1127
+ }
1128
+
1129
+ let released = false;
1130
+ let operationInProgress = false;
1131
+ const assertOwnerUnserialized = async () => {
1132
+ if (released) throw new Error('repository claim was already released');
1133
+ const activeOwner = await readClaimOwner(activePath);
1134
+ if (activeOwner.ownerToken !== owner.ownerToken) {
1135
+ throw new Error('repository claim is owned by a different token');
1136
+ }
1137
+ };
1138
+ const runClaimOperation = async (operation) => {
1139
+ if (released) throw new Error('repository claim was already released');
1140
+ if (operationInProgress) {
1141
+ throw new Error('repository claim operation is already in progress');
1142
+ }
1143
+ operationInProgress = true;
1144
+ try {
1145
+ return await operation();
1146
+ } finally {
1147
+ operationInProgress = false;
1148
+ }
1149
+ };
1150
+ const assertOwner = () => runClaimOperation(assertOwnerUnserialized);
1151
+ const observe = (observationOptions = {}) =>
1152
+ runClaimOperation(async () => {
1153
+ await assertOwnerUnserialized();
1154
+ const observation = await observeResolvedWorktree(
1155
+ identity,
1156
+ observationOptions,
1157
+ );
1158
+ await assertOwnerUnserialized();
1159
+ return observation;
1160
+ });
1161
+ const capture = (baseline, receiptOptions = {}) =>
1162
+ runClaimOperation(async () => {
1163
+ await assertOwnerUnserialized();
1164
+ assertObservation(baseline, 'baseline');
1165
+ if (
1166
+ baseline.worktree !== identity.worktree ||
1167
+ baseline.gitDir !== identity.gitDir
1168
+ ) {
1169
+ throw new TypeError(
1170
+ 'repository receipt baseline does not match the active claim',
1171
+ );
1172
+ }
1173
+ const result = await captureRepositoryReceipt(baseline, receiptOptions);
1174
+ await assertOwnerUnserialized();
1175
+ return result;
1176
+ });
1177
+ let claim;
1178
+ const release = () =>
1179
+ runClaimOperation(async () => {
1180
+ await assertOwnerUnserialized();
1181
+ if (!(await retireClaim(root, activePath, owner))) {
1182
+ throw new Error('repository claim retirement target is occupied');
1183
+ }
1184
+ released = true;
1185
+ forgetProcessClaim(claim);
1186
+ });
1187
+ claim = Object.freeze({
1188
+ identity,
1189
+ ownerToken: owner.ownerToken,
1190
+ assertOwner,
1191
+ observe,
1192
+ capture,
1193
+ release,
1194
+ });
1195
+ registerProcessClaim(claim);
1196
+ return claim;
1197
+ };
1198
+
1199
+ const runExclusive = async (runOptions) => {
1200
+ if (!isPlainObject(runOptions) || typeof runOptions.operation !== 'function') {
1201
+ throw new TypeError('exclusive repository operation must be a function');
1202
+ }
1203
+ const allowedDispositions = assertAllowedDispositions(
1204
+ runOptions.allowedDispositions,
1205
+ );
1206
+ const claim = await acquire(runOptions.cwd, { signal: runOptions.signal });
1207
+ let primaryError;
1208
+ try {
1209
+ const baseline = await claim.observe(runOptions.observation);
1210
+ let operation;
1211
+ try {
1212
+ operation = Object.freeze({
1213
+ status: 'fulfilled',
1214
+ value: await runOptions.operation({
1215
+ baseline,
1216
+ identity: claim.identity,
1217
+ }),
1218
+ });
1219
+ } catch (error) {
1220
+ operation = Object.freeze({ status: 'rejected', reason: error });
1221
+ }
1222
+ const effectReceipt = await claim.capture(baseline, {
1223
+ allowedDispositions,
1224
+ observation: runOptions.afterObservation,
1225
+ });
1226
+ return Object.freeze({ baseline, operation, receipt: effectReceipt });
1227
+ } catch (error) {
1228
+ primaryError = error;
1229
+ throw error;
1230
+ } finally {
1231
+ try {
1232
+ await claim.release();
1233
+ } catch (releaseError) {
1234
+ if (primaryError === undefined) throw releaseError;
1235
+ throw new AggregateError(
1236
+ [primaryError, releaseError],
1237
+ 'repository operation failed and its claim could not be released',
1238
+ );
1239
+ }
1240
+ }
1241
+ };
1242
+
1243
+ const runCohort = async (runOptions) => {
1244
+ if (!isPlainObject(runOptions)) {
1245
+ throw new TypeError('repository cohort options must be an object');
1246
+ }
1247
+ const roleIds = validateCohort(runOptions);
1248
+ const claim = await acquire(runOptions.cwd, { signal: runOptions.signal });
1249
+ let primaryError;
1250
+ try {
1251
+ const baseline = await claim.observe(runOptions.observation);
1252
+ const settled = await Promise.allSettled(
1253
+ roleIds.map((roleId) =>
1254
+ Promise.resolve().then(() =>
1255
+ runOptions.operations[roleId]({
1256
+ baseline,
1257
+ identity: claim.identity,
1258
+ invocationId: runOptions.invocationId,
1259
+ roleId,
1260
+ }),
1261
+ ),
1262
+ ),
1263
+ );
1264
+ const effectReceipt = await claim.capture(baseline, {
1265
+ allowedDispositions: ['unchanged'],
1266
+ cohort: true,
1267
+ observation: runOptions.afterObservation,
1268
+ });
1269
+ const operations = Object.create(null);
1270
+ const receipts = Object.create(null);
1271
+ for (const [index, roleId] of roleIds.entries()) {
1272
+ operations[roleId] = settled[index];
1273
+ receipts[roleId] = effectReceipt;
1274
+ }
1275
+ return Object.freeze({
1276
+ baseline,
1277
+ invocationId: runOptions.invocationId,
1278
+ operations: Object.freeze(operations),
1279
+ receipts: Object.freeze(receipts),
1280
+ });
1281
+ } catch (error) {
1282
+ primaryError = error;
1283
+ throw error;
1284
+ } finally {
1285
+ try {
1286
+ await claim.release();
1287
+ } catch (releaseError) {
1288
+ if (primaryError === undefined) throw releaseError;
1289
+ throw new AggregateError(
1290
+ [primaryError, releaseError],
1291
+ 'repository cohort failed and its claim could not be released',
1292
+ );
1293
+ }
1294
+ }
1295
+ };
1296
+
1297
+ return Object.freeze({ acquire, runExclusive, runCohort });
1298
+ }
1299
+
1300
+ function detachedSchema3CatalogEntries(catalog) {
1301
+ if (!isPlainObject(catalog)) {
1302
+ throw new TypeError('repository capability catalog must be an object');
1303
+ }
1304
+ const entries = [];
1305
+ for (const [playbookId, item] of Object.entries(catalog)) {
1306
+ if (!isPlainObject(item) || item.artifactSchema !== 3) {
1307
+ throw new TypeError(
1308
+ `repository capability catalog ${JSON.stringify(playbookId)} must declare artifact schema 3`,
1309
+ );
1310
+ }
1311
+ if (
1312
+ typeof playbookId !== 'string' ||
1313
+ playbookId.length === 0 ||
1314
+ item.id !== playbookId
1315
+ ) {
1316
+ throw new TypeError(
1317
+ 'schema-3 repository capability catalog keys must equal playbook ids',
1318
+ );
1319
+ }
1320
+ if (
1321
+ !Array.isArray(item.requiredRoleIds) ||
1322
+ item.requiredRoleIds.some(
1323
+ (roleId) => typeof roleId !== 'string' || roleId.length === 0,
1324
+ ) ||
1325
+ new Set(item.requiredRoleIds).size !== item.requiredRoleIds.length
1326
+ ) {
1327
+ throw new TypeError(
1328
+ `schema-3 repository capability ${JSON.stringify(playbookId)} has invalid required roles`,
1329
+ );
1330
+ }
1331
+ const requiredRoleIds = Object.freeze([...item.requiredRoleIds]);
1332
+ const required = new Set(requiredRoleIds);
1333
+ if (
1334
+ !Array.isArray(item.concurrentRoleSets) ||
1335
+ item.concurrentRoleSets.some(
1336
+ (set) =>
1337
+ !Array.isArray(set) ||
1338
+ set.length < 2 ||
1339
+ set.some(
1340
+ (roleId) => typeof roleId !== 'string' || !required.has(roleId),
1341
+ ) ||
1342
+ new Set(set).size !== set.length,
1343
+ ) ||
1344
+ new Set(item.concurrentRoleSets.map((set) => JSON.stringify(set))).size !==
1345
+ item.concurrentRoleSets.length
1346
+ ) {
1347
+ throw new TypeError(
1348
+ `schema-3 repository capability ${JSON.stringify(playbookId)} has invalid concurrent roles`,
1349
+ );
1350
+ }
1351
+ entries.push(
1352
+ Object.freeze({
1353
+ playbookId,
1354
+ requiredRoleIds,
1355
+ concurrentRoleSets: Object.freeze(
1356
+ item.concurrentRoleSets.map((set) => Object.freeze([...set])),
1357
+ ),
1358
+ }),
1359
+ );
1360
+ }
1361
+ return Object.freeze(entries);
1362
+ }
1363
+
1364
+ function rejectBoundRepositoryOverride(options, member, forbiddenKeys) {
1365
+ if (!isPlainObject(options)) {
1366
+ throw new TypeError(`repository capability ${member} options must be an object`);
1367
+ }
1368
+ for (const key of forbiddenKeys) {
1369
+ if (Object.prototype.hasOwnProperty.call(options, key)) {
1370
+ throw new TypeError(
1371
+ `repository capability ${member} cannot override host-owned ${key}`,
1372
+ );
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ function assertEffectLedgerService(value) {
1378
+ if (
1379
+ !isPlainObject(value) ||
1380
+ typeof value.snapshot !== 'function' ||
1381
+ typeof value.writeAhead !== 'function'
1382
+ ) {
1383
+ throw new TypeError(
1384
+ 'schema-3 repository capability write-ahead factory must return snapshot and writeAhead operations',
1385
+ );
1386
+ }
1387
+ const initial = assertPlaybookEffectLedger(value.snapshot());
1388
+ let mirror = initial;
1389
+ return Object.freeze({
1390
+ snapshot() {
1391
+ const current = assertPlaybookEffectLedger(value.snapshot());
1392
+ if (!isDeepStrictEqual(current, mirror)) mirror = current;
1393
+ return mirror;
1394
+ },
1395
+ async writeAhead(authority, commands) {
1396
+ const next = assertPlaybookEffectLedger(
1397
+ await value.writeAhead(authority, commands),
1398
+ );
1399
+ mirror = next;
1400
+ return next;
1401
+ },
1402
+ async refresh(authoritative) {
1403
+ const next = assertPlaybookEffectLedger(
1404
+ authoritative ??
1405
+ (typeof value.refresh === 'function'
1406
+ ? await value.refresh()
1407
+ : mirror),
1408
+ );
1409
+ mirror = next;
1410
+ return next;
1411
+ },
1412
+ });
1413
+ }
1414
+
1415
+ function effectBoundarySeed(value, authority, baseline) {
1416
+ if (!isPlainObject(value)) {
1417
+ throw new TypeError(
1418
+ 'repository operation effectBoundary must be an object',
1419
+ );
1420
+ }
1421
+ for (const key of [
1422
+ 'sequence',
1423
+ 'attemptId',
1424
+ 'attemptNumber',
1425
+ 'playbookId',
1426
+ 'canonicalWorktree',
1427
+ 'baseline',
1428
+ 'after',
1429
+ 'physicalReceipt',
1430
+ 'cohortId',
1431
+ ]) {
1432
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
1433
+ throw new TypeError(
1434
+ `repository operation effectBoundary cannot override host-owned ${key}`,
1435
+ );
1436
+ }
1437
+ }
1438
+ return {
1439
+ ...value,
1440
+ playbookId: authority.playbookId,
1441
+ canonicalWorktree: authority.canonicalWorktree,
1442
+ baseline,
1443
+ };
1444
+ }
1445
+
1446
+ function boundaryById(ledger, boundaryId) {
1447
+ const boundary = ledger.boundaries.find(
1448
+ (candidate) => candidate.boundaryId === boundaryId,
1449
+ );
1450
+ if (boundary === undefined) {
1451
+ throw new Error(
1452
+ `effect-ledger write did not publish boundary ${JSON.stringify(boundaryId)}`,
1453
+ );
1454
+ }
1455
+ return boundary;
1456
+ }
1457
+
1458
+ function logicalOperationById(ledger, operationId) {
1459
+ const operation = ledger.logicalOperations.find(
1460
+ (candidate) => candidate.operationId === operationId,
1461
+ );
1462
+ if (operation === undefined) {
1463
+ throw new Error(
1464
+ `effect-ledger write did not publish logical operation ${JSON.stringify(operationId)}`,
1465
+ );
1466
+ }
1467
+ return operation;
1468
+ }
1469
+
1470
+ function withoutDeferredBinding(operation, boundaryIds = operation.boundaryIds) {
1471
+ return {
1472
+ sequence: operation.sequence,
1473
+ operationId: operation.operationId,
1474
+ playbookId: operation.playbookId,
1475
+ runtimeSessionId: operation.runtimeSessionId,
1476
+ boundaryIds,
1477
+ originalBaseline: operation.originalBaseline,
1478
+ checkpointRestorationEligible: false,
1479
+ ...(operation.logicalReceipt === undefined
1480
+ ? {}
1481
+ : { logicalReceipt: operation.logicalReceipt }),
1482
+ };
1483
+ }
1484
+
1485
+ function hasDeferredBinding(operation) {
1486
+ return (
1487
+ operation.checkpoint !== undefined &&
1488
+ operation.pendingQuestion !== undefined &&
1489
+ Object.prototype.hasOwnProperty.call(operation, 'playerContinuation')
1490
+ );
1491
+ }
1492
+
1493
+ function deferredCheckpointEligible(originalBaseline, effectReceipt) {
1494
+ return (
1495
+ effectReceipt.after !== undefined &&
1496
+ effectReceipt.after.head === originalBaseline.head &&
1497
+ (effectReceipt.classification === 'unchanged' ||
1498
+ effectReceipt.classification === 'worktree-only-change')
1499
+ );
1500
+ }
1501
+
1502
+ function checkpointMatches(checkpoint, observation) {
1503
+ return (
1504
+ checkpoint.worktree === observation.worktree &&
1505
+ checkpoint.gitDir === observation.gitDir &&
1506
+ checkpoint.head === observation.head &&
1507
+ checkpoint.projectionDigest === observation.projectionDigest &&
1508
+ projectionText(checkpoint.projection) ===
1509
+ projectionText(observation.projection)
1510
+ );
1511
+ }
1512
+
1513
+ async function cumulativeLogicalReceipt(
1514
+ originalBaseline,
1515
+ physicalReceipt,
1516
+ dispositions,
1517
+ ) {
1518
+ if (physicalReceipt.after === undefined) {
1519
+ return receipt('observation-ambiguous', originalBaseline);
1520
+ }
1521
+ return classifyRepositoryReceipt(originalBaseline, physicalReceipt.after, {
1522
+ allowedDispositions: dispositions,
1523
+ });
1524
+ }
1525
+
1526
+ function completedBoundary(boundary, effectReceipt) {
1527
+ return {
1528
+ ...boundary,
1529
+ ...(effectReceipt.after === undefined
1530
+ ? {}
1531
+ : { after: effectReceipt.after }),
1532
+ physicalReceipt: effectReceipt,
1533
+ };
1534
+ }
1535
+
1536
+ function effectCompletionCallback(options, label) {
1537
+ const callback = options.completeEffectBoundary;
1538
+ if (callback !== undefined && typeof callback !== 'function') {
1539
+ throw new TypeError(`${label} completeEffectBoundary must be a function`);
1540
+ }
1541
+ return callback;
1542
+ }
1543
+
1544
+ async function effectCompletion({
1545
+ callback,
1546
+ boundary,
1547
+ operation,
1548
+ receipt: effectReceipt,
1549
+ outcomeReceipt = effectReceipt,
1550
+ roleId,
1551
+ }) {
1552
+ if (callback === undefined) {
1553
+ return { evidence: {}, commands: [] };
1554
+ }
1555
+ const value = await callback(
1556
+ Object.freeze({
1557
+ boundary,
1558
+ operation,
1559
+ receipt: effectReceipt,
1560
+ outcomeReceipt,
1561
+ ...(roleId === undefined ? {} : { roleId }),
1562
+ }),
1563
+ );
1564
+ if (!isPlainObject(value)) {
1565
+ throw new TypeError(
1566
+ 'repository completeEffectBoundary must return an evidence object',
1567
+ );
1568
+ }
1569
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1570
+ const allowed = new Set([
1571
+ 'finalText',
1572
+ 'semanticCandidate',
1573
+ 'logicalOperationId',
1574
+ 'commands',
1575
+ 'deferred',
1576
+ 'unresolved',
1577
+ ]);
1578
+ for (const [key, descriptor] of Object.entries(descriptors)) {
1579
+ if (
1580
+ !allowed.has(key) ||
1581
+ descriptor.get !== undefined ||
1582
+ descriptor.set !== undefined ||
1583
+ !Object.prototype.hasOwnProperty.call(descriptor, 'value')
1584
+ ) {
1585
+ throw new TypeError(
1586
+ `repository completeEffectBoundary returned unsupported member ${JSON.stringify(key)}`,
1587
+ );
1588
+ }
1589
+ }
1590
+ if (
1591
+ Object.prototype.hasOwnProperty.call(value, 'finalText') &&
1592
+ typeof value.finalText !== 'string'
1593
+ ) {
1594
+ throw new TypeError(
1595
+ 'repository completeEffectBoundary finalText must be a string',
1596
+ );
1597
+ }
1598
+ if (
1599
+ Object.prototype.hasOwnProperty.call(value, 'commands') &&
1600
+ (!Array.isArray(value.commands) || value.commands.length === 0)
1601
+ ) {
1602
+ throw new TypeError(
1603
+ 'repository completeEffectBoundary commands must be a nonempty array',
1604
+ );
1605
+ }
1606
+ if (
1607
+ Object.prototype.hasOwnProperty.call(value, 'unresolved') &&
1608
+ value.unresolved !== true
1609
+ ) {
1610
+ throw new TypeError(
1611
+ 'repository completeEffectBoundary unresolved must be true',
1612
+ );
1613
+ }
1614
+ let deferred;
1615
+ if (Object.prototype.hasOwnProperty.call(value, 'deferred')) {
1616
+ if (!isPlainObject(value.deferred)) {
1617
+ throw new TypeError(
1618
+ 'repository completeEffectBoundary deferred must be an object',
1619
+ );
1620
+ }
1621
+ const deferredKeys = Object.keys(value.deferred);
1622
+ if (
1623
+ deferredKeys.some(
1624
+ (key) =>
1625
+ key !== 'operationId' &&
1626
+ key !== 'pendingQuestion' &&
1627
+ key !== 'playerContinuation',
1628
+ ) ||
1629
+ !Object.prototype.hasOwnProperty.call(value.deferred, 'pendingQuestion') ||
1630
+ !Object.prototype.hasOwnProperty.call(value.deferred, 'playerContinuation')
1631
+ ) {
1632
+ throw new TypeError(
1633
+ 'repository completeEffectBoundary deferred has unsupported or missing members',
1634
+ );
1635
+ }
1636
+ if (
1637
+ Object.prototype.hasOwnProperty.call(value.deferred, 'operationId') &&
1638
+ (typeof value.deferred.operationId !== 'string' ||
1639
+ !UUID_PATTERN.test(value.deferred.operationId))
1640
+ ) {
1641
+ throw new TypeError(
1642
+ 'repository completeEffectBoundary deferred operationId must be a UUID',
1643
+ );
1644
+ }
1645
+ deferred = snapshotJsonValue(
1646
+ value.deferred,
1647
+ 'repository completeEffectBoundary deferred',
1648
+ );
1649
+ }
1650
+ if (
1651
+ deferred !== undefined &&
1652
+ (value.unresolved === true ||
1653
+ Object.prototype.hasOwnProperty.call(value, 'logicalOperationId') ||
1654
+ Object.prototype.hasOwnProperty.call(value, 'commands'))
1655
+ ) {
1656
+ throw new TypeError(
1657
+ 'repository completeEffectBoundary deferred cannot be combined with unresolved, logicalOperationId, or commands',
1658
+ );
1659
+ }
1660
+ const evidence = {
1661
+ ...(Object.prototype.hasOwnProperty.call(value, 'finalText')
1662
+ ? { finalText: value.finalText }
1663
+ : {}),
1664
+ ...(Object.prototype.hasOwnProperty.call(value, 'semanticCandidate')
1665
+ ? { semanticCandidate: value.semanticCandidate }
1666
+ : {}),
1667
+ ...(Object.prototype.hasOwnProperty.call(value, 'logicalOperationId')
1668
+ ? { logicalOperationId: value.logicalOperationId }
1669
+ : {}),
1670
+ };
1671
+ return {
1672
+ evidence,
1673
+ commands: value.commands ?? [],
1674
+ ...(deferred === undefined ? {} : { deferred }),
1675
+ ...(value.unresolved === true ? { unresolved: true } : {}),
1676
+ };
1677
+ }
1678
+
1679
+ function rebaseEffectCompletion(boundary, effectReceipt, evidence) {
1680
+ const candidateChanged =
1681
+ Object.prototype.hasOwnProperty.call(boundary, 'semanticCandidate') &&
1682
+ Object.prototype.hasOwnProperty.call(evidence, 'semanticCandidate') &&
1683
+ !isDeepStrictEqual(
1684
+ snapshotJsonValue(
1685
+ boundary.semanticCandidate,
1686
+ 'durable boundary semanticCandidate',
1687
+ ),
1688
+ snapshotJsonValue(
1689
+ evidence.semanticCandidate,
1690
+ 'completion boundary semanticCandidate',
1691
+ ),
1692
+ );
1693
+ if (
1694
+ candidateChanged &&
1695
+ (boundary.correctionBudget.spent !== true ||
1696
+ Object.prototype.hasOwnProperty.call(
1697
+ boundary,
1698
+ 'initialSemanticCandidate',
1699
+ ))
1700
+ ) {
1701
+ throw new Error(
1702
+ 'repository completion conflicts with durable boundary field semanticCandidate',
1703
+ );
1704
+ }
1705
+ const next = completedBoundary(
1706
+ {
1707
+ ...boundary,
1708
+ ...evidence,
1709
+ ...(candidateChanged
1710
+ ? { initialSemanticCandidate: boundary.semanticCandidate }
1711
+ : {}),
1712
+ },
1713
+ effectReceipt,
1714
+ );
1715
+ for (const key of [
1716
+ 'after',
1717
+ 'physicalReceipt',
1718
+ 'finalText',
1719
+ 'initialSemanticCandidate',
1720
+ 'logicalOperationId',
1721
+ ]) {
1722
+ if (
1723
+ Object.prototype.hasOwnProperty.call(boundary, key) &&
1724
+ !isDeepStrictEqual(
1725
+ snapshotJsonValue(boundary[key], `durable boundary ${key}`),
1726
+ snapshotJsonValue(next[key], `completion boundary ${key}`),
1727
+ )
1728
+ ) {
1729
+ throw new Error(
1730
+ `repository completion conflicts with durable boundary field ${key}`,
1731
+ );
1732
+ }
1733
+ }
1734
+ return next;
1735
+ }
1736
+
1737
+ function assertEffectCommandBatchResult(ledger, commands) {
1738
+ const boundaryResults = new Map();
1739
+ const logicalResults = new Map();
1740
+ for (const command of commands) {
1741
+ if (command.kind === 'start-boundaries') {
1742
+ for (const expected of command.boundaries) {
1743
+ boundaryResults.set(expected.boundaryId, {
1744
+ generated: ['sequence', 'attemptId', 'attemptNumber'],
1745
+ value: expected,
1746
+ });
1747
+ }
1748
+ continue;
1749
+ }
1750
+ if (command.kind === 'replace-boundaries') {
1751
+ for (const { next } of command.replacements) {
1752
+ boundaryResults.set(next.boundaryId, { generated: [], value: next });
1753
+ }
1754
+ continue;
1755
+ }
1756
+ if (command.kind === 'append-logical-operations') {
1757
+ for (const expected of command.operations) {
1758
+ logicalResults.set(expected.operationId, {
1759
+ generated: ['sequence'],
1760
+ value: expected,
1761
+ });
1762
+ }
1763
+ continue;
1764
+ }
1765
+ if (command.kind === 'replace-logical-operations') {
1766
+ for (const { next } of command.replacements) {
1767
+ logicalResults.set(next.operationId, { generated: [], value: next });
1768
+ }
1769
+ continue;
1770
+ }
1771
+ throw new Error('repository recovery completion batch has an unknown command');
1772
+ }
1773
+
1774
+ const assertResults = (values, results, identityKey, label) => {
1775
+ for (const [identity, expected] of results) {
1776
+ const current = values.find((value) => value[identityKey] === identity);
1777
+ if (current === undefined) {
1778
+ throw new Error(
1779
+ `repository recovery completion batch did not publish ${label} ${JSON.stringify(identity)}`,
1780
+ );
1781
+ }
1782
+ const payload = { ...current };
1783
+ for (const key of expected.generated) delete payload[key];
1784
+ if (!isDeepStrictEqual(payload, expected.value)) {
1785
+ throw new Error(
1786
+ `repository recovery completion batch published unexpected ${label} ${JSON.stringify(identity)}`,
1787
+ );
1788
+ }
1789
+ }
1790
+ };
1791
+ assertResults(
1792
+ ledger.boundaries,
1793
+ boundaryResults,
1794
+ 'boundaryId',
1795
+ 'boundary',
1796
+ );
1797
+ assertResults(
1798
+ ledger.logicalOperations,
1799
+ logicalResults,
1800
+ 'operationId',
1801
+ 'logical operation',
1802
+ );
1803
+ }
1804
+
1805
+ async function releaseRepositoryClaim(claim, primaryError) {
1806
+ try {
1807
+ await claim.release();
1808
+ } catch (releaseError) {
1809
+ if (primaryError === undefined) throw releaseError;
1810
+ throw new AggregateError(
1811
+ [primaryError, releaseError],
1812
+ 'repository effect operation failed and its claim could not be released',
1813
+ );
1814
+ }
1815
+ }
1816
+
1817
+ async function runDurableExclusive({
1818
+ coordinator,
1819
+ identity,
1820
+ authority,
1821
+ ledgerService,
1822
+ options,
1823
+ }) {
1824
+ if (!isPlainObject(options) || typeof options.operation !== 'function') {
1825
+ throw new TypeError('exclusive repository operation must be a function');
1826
+ }
1827
+ const completeEffectBoundary = effectCompletionCallback(
1828
+ options,
1829
+ 'exclusive repository operation',
1830
+ );
1831
+ rejectBoundRepositoryOverride(options, 'runExclusive', ['cwd']);
1832
+ const claim = await coordinator.acquire(identity.worktree, {
1833
+ signal: options.signal,
1834
+ });
1835
+ let effectPossible = false;
1836
+ let recovery;
1837
+ try {
1838
+ const baseline = await claim.observe(options.observation);
1839
+ const seed = effectBoundarySeed(
1840
+ options.effectBoundary,
1841
+ authority,
1842
+ baseline,
1843
+ );
1844
+ const startCommands = snapshotJsonValue([
1845
+ {
1846
+ kind: 'start-boundaries',
1847
+ boundaries: [seed],
1848
+ },
1849
+ ], 'exclusive repository start commands');
1850
+ recovery = {
1851
+ sessionId: authority.sessionId,
1852
+ playbookId: authority.playbookId,
1853
+ boundaryIds: [seed.boundaryId],
1854
+ startCommands,
1855
+ operationStarted: false,
1856
+ };
1857
+ effectPossible = true;
1858
+ const started = await ledgerService.writeAhead(authority, startCommands);
1859
+ const current = boundaryById(started, seed.boundaryId);
1860
+ recovery = { ...recovery, operationStarted: true };
1861
+ let operation;
1862
+ try {
1863
+ operation = Object.freeze({
1864
+ status: 'fulfilled',
1865
+ value: await options.operation({
1866
+ baseline,
1867
+ identity: claim.identity,
1868
+ }),
1869
+ });
1870
+ } catch (error) {
1871
+ operation = Object.freeze({ status: 'rejected', reason: error });
1872
+ }
1873
+ const effectReceipt = snapshotJsonValue(
1874
+ await claim.capture(baseline, {
1875
+ allowedDispositions: current.dispositions,
1876
+ observation: options.afterObservation,
1877
+ }),
1878
+ 'exclusive repository receipt',
1879
+ );
1880
+ let latest = boundaryById(
1881
+ ledgerService.snapshot(),
1882
+ seed.boundaryId,
1883
+ );
1884
+ const completion = await effectCompletion({
1885
+ callback: completeEffectBoundary,
1886
+ boundary: latest,
1887
+ operation,
1888
+ receipt: effectReceipt,
1889
+ outcomeReceipt: effectReceipt,
1890
+ });
1891
+ latest = boundaryById(ledgerService.snapshot(), seed.boundaryId);
1892
+ let completedBoundaryValue = rebaseEffectCompletion(
1893
+ latest,
1894
+ effectReceipt,
1895
+ completion.evidence,
1896
+ );
1897
+ let deferredStatus;
1898
+ let deferredCommands = [];
1899
+ if (completion.deferred !== undefined) {
1900
+ if (!current.dispositions.includes('deferred')) {
1901
+ throw new TypeError(
1902
+ 'repository completion cannot bind a deferred operation for a boundary without deferred authority',
1903
+ );
1904
+ }
1905
+ const operationId = completion.deferred.operationId;
1906
+ if (typeof operationId !== 'string' || !UUID_PATTERN.test(operationId)) {
1907
+ throw new TypeError(
1908
+ 'initial deferred repository completion requires an operationId UUID',
1909
+ );
1910
+ }
1911
+ const eligible = deferredCheckpointEligible(
1912
+ latest.baseline,
1913
+ effectReceipt,
1914
+ );
1915
+ completedBoundaryValue = {
1916
+ ...completedBoundaryValue,
1917
+ logicalOperationId: operationId,
1918
+ };
1919
+ deferredCommands = [
1920
+ {
1921
+ kind: 'append-logical-operations',
1922
+ operations: [
1923
+ {
1924
+ operationId,
1925
+ playbookId: authority.playbookId,
1926
+ runtimeSessionId: latest.runtimeSessionId,
1927
+ boundaryIds: [latest.boundaryId],
1928
+ originalBaseline: latest.baseline,
1929
+ ...(eligible
1930
+ ? {
1931
+ checkpoint: effectReceipt.after,
1932
+ pendingQuestion: completion.deferred.pendingQuestion,
1933
+ playerContinuation:
1934
+ completion.deferred.playerContinuation,
1935
+ }
1936
+ : {}),
1937
+ checkpointRestorationEligible: false,
1938
+ },
1939
+ ],
1940
+ },
1941
+ ];
1942
+ deferredStatus = eligible ? 'bound' : 'unresolved';
1943
+ }
1944
+ const commands = snapshotJsonValue([
1945
+ {
1946
+ kind: 'replace-boundaries',
1947
+ replacements: [
1948
+ {
1949
+ expected: latest,
1950
+ next: completedBoundaryValue,
1951
+ },
1952
+ ],
1953
+ },
1954
+ ...deferredCommands,
1955
+ ...completion.commands,
1956
+ ], 'exclusive repository completion commands');
1957
+ recovery = { ...recovery, commands, commandsAcknowledged: false };
1958
+ const completed = await ledgerService.writeAhead(authority, commands);
1959
+ assertEffectCommandBatchResult(completed, commands);
1960
+ const completedReceipt = boundaryById(
1961
+ completed,
1962
+ seed.boundaryId,
1963
+ ).physicalReceipt;
1964
+ recovery = { ...recovery, commandsAcknowledged: true };
1965
+ await releaseRepositoryClaim(claim);
1966
+ return Object.freeze({
1967
+ baseline,
1968
+ operation,
1969
+ receipt: completedReceipt,
1970
+ effectLedger: completed,
1971
+ ...(deferredStatus === undefined ? {} : { deferredStatus }),
1972
+ });
1973
+ } catch (error) {
1974
+ if (!effectPossible) {
1975
+ await releaseRepositoryClaim(claim, error);
1976
+ } else {
1977
+ quarantineProcessClaim(claim, recovery);
1978
+ }
1979
+ throw error;
1980
+ }
1981
+ }
1982
+
1983
+ function deferredOperationForAuthority(ledger, operationId, authority) {
1984
+ if (typeof operationId !== 'string' || !UUID_PATTERN.test(operationId)) {
1985
+ throw new TypeError('deferred repository operationId must be a UUID');
1986
+ }
1987
+ const operation = logicalOperationById(ledger, operationId);
1988
+ if (
1989
+ operation.playbookId !== authority.playbookId ||
1990
+ operation.logicalReceipt !== undefined
1991
+ ) {
1992
+ throw new Error(
1993
+ `deferred logical operation ${JSON.stringify(operationId)} is not open under the current host authority`,
1994
+ );
1995
+ }
1996
+ const firstBoundary = boundaryById(ledger, operation.boundaryIds[0]);
1997
+ if (
1998
+ firstBoundary.runtimeSessionId !== operation.runtimeSessionId ||
1999
+ !isDeepStrictEqual(
2000
+ firstBoundary.canonicalWorktree,
2001
+ authority.canonicalWorktree,
2002
+ )
2003
+ ) {
2004
+ throw new Error(
2005
+ `deferred logical operation ${JSON.stringify(operationId)} does not match the current host worktree`,
2006
+ );
2007
+ }
2008
+ return operation;
2009
+ }
2010
+
2011
+ function replaceLogicalOperationCommand(expected, next) {
2012
+ return {
2013
+ kind: 'replace-logical-operations',
2014
+ replacements: [{ expected, next }],
2015
+ };
2016
+ }
2017
+
2018
+ async function runDurableDeferred({
2019
+ coordinator,
2020
+ identity,
2021
+ authority,
2022
+ ledgerService,
2023
+ options,
2024
+ }) {
2025
+ if (!isPlainObject(options)) {
2026
+ throw new TypeError('deferred repository operation options must be an object');
2027
+ }
2028
+ rejectBoundRepositoryOverride(options, 'runDeferred', ['cwd']);
2029
+ const mode = options.mode;
2030
+ if (mode !== 'continue' && mode !== 'park' && mode !== 'restore') {
2031
+ throw new TypeError(
2032
+ 'deferred repository operation mode must be continue, park, or restore',
2033
+ );
2034
+ }
2035
+ const operationId = options.operationId;
2036
+ deferredOperationForAuthority(
2037
+ ledgerService.snapshot(),
2038
+ operationId,
2039
+ authority,
2040
+ );
2041
+ if (
2042
+ mode === 'continue' &&
2043
+ (typeof options.operation !== 'function' ||
2044
+ typeof options.completeEffectBoundary !== 'function')
2045
+ ) {
2046
+ throw new TypeError(
2047
+ 'deferred repository continuation requires operation and completeEffectBoundary functions',
2048
+ );
2049
+ }
2050
+ if (
2051
+ mode !== 'continue' &&
2052
+ (Object.prototype.hasOwnProperty.call(options, 'operation') ||
2053
+ Object.prototype.hasOwnProperty.call(options, 'effectBoundary') ||
2054
+ Object.prototype.hasOwnProperty.call(options, 'completeEffectBoundary'))
2055
+ ) {
2056
+ throw new TypeError(
2057
+ `deferred repository ${mode} cannot carry continuation members`,
2058
+ );
2059
+ }
2060
+
2061
+ const claim = await coordinator.acquire(identity.worktree, {
2062
+ signal: options.signal,
2063
+ });
2064
+ let effectPossible = false;
2065
+ let recovery;
2066
+ try {
2067
+ let ledger = ledgerService.snapshot();
2068
+ let logicalOperation = deferredOperationForAuthority(
2069
+ ledger,
2070
+ operationId,
2071
+ authority,
2072
+ );
2073
+
2074
+ if (mode === 'park') {
2075
+ if (!hasDeferredBinding(logicalOperation)) {
2076
+ await releaseRepositoryClaim(claim);
2077
+ return Object.freeze({ status: 'parked', effectLedger: ledger });
2078
+ }
2079
+ const next = withoutDeferredBinding(logicalOperation);
2080
+ const commands = snapshotJsonValue(
2081
+ [replaceLogicalOperationCommand(logicalOperation, next)],
2082
+ 'deferred repository park commands',
2083
+ );
2084
+ recovery = {
2085
+ sessionId: authority.sessionId,
2086
+ playbookId: authority.playbookId,
2087
+ boundaryIds: [],
2088
+ commands,
2089
+ logicalOperationReplacement: { operationId, next },
2090
+ };
2091
+ effectPossible = true;
2092
+ ledger = await ledgerService.writeAhead(authority, commands);
2093
+ logicalOperationById(ledger, operationId);
2094
+ await releaseRepositoryClaim(claim);
2095
+ return Object.freeze({ status: 'parked', effectLedger: ledger });
2096
+ }
2097
+
2098
+ if (!hasDeferredBinding(logicalOperation)) {
2099
+ await releaseRepositoryClaim(claim);
2100
+ return Object.freeze({ status: 'ineligible', effectLedger: ledger });
2101
+ }
2102
+ if (
2103
+ mode === 'continue' &&
2104
+ logicalOperation.checkpointRestorationEligible
2105
+ ) {
2106
+ await releaseRepositoryClaim(claim);
2107
+ return Object.freeze({ status: 'ineligible', effectLedger: ledger });
2108
+ }
2109
+ if (
2110
+ mode === 'restore' &&
2111
+ !logicalOperation.checkpointRestorationEligible
2112
+ ) {
2113
+ await releaseRepositoryClaim(claim);
2114
+ return Object.freeze({ status: 'ineligible', effectLedger: ledger });
2115
+ }
2116
+
2117
+ let checkpointObservation;
2118
+ try {
2119
+ checkpointObservation = await claim.observe(options.observation);
2120
+ } catch (error) {
2121
+ if (!(error instanceof RepositoryObservationAmbiguousError)) throw error;
2122
+ }
2123
+ const checkpointExact =
2124
+ checkpointObservation !== undefined &&
2125
+ checkpointMatches(logicalOperation.checkpoint, checkpointObservation);
2126
+
2127
+ if (mode === 'restore') {
2128
+ if (!checkpointExact) {
2129
+ await releaseRepositoryClaim(claim);
2130
+ return Object.freeze({
2131
+ status: 'checkpoint-mismatch',
2132
+ effectLedger: ledger,
2133
+ });
2134
+ }
2135
+ const next = {
2136
+ ...logicalOperation,
2137
+ checkpointRestorationEligible: false,
2138
+ };
2139
+ const commands = snapshotJsonValue(
2140
+ [replaceLogicalOperationCommand(logicalOperation, next)],
2141
+ 'deferred repository restoration commands',
2142
+ );
2143
+ recovery = {
2144
+ sessionId: authority.sessionId,
2145
+ playbookId: authority.playbookId,
2146
+ boundaryIds: [],
2147
+ commands,
2148
+ logicalOperationReplacement: { operationId, next },
2149
+ };
2150
+ effectPossible = true;
2151
+ ledger = await ledgerService.writeAhead(authority, commands);
2152
+ logicalOperationById(ledger, operationId);
2153
+ await releaseRepositoryClaim(claim);
2154
+ return Object.freeze({ status: 'restored', effectLedger: ledger });
2155
+ }
2156
+
2157
+ if (!checkpointExact) {
2158
+ const next = {
2159
+ ...logicalOperation,
2160
+ checkpointRestorationEligible: true,
2161
+ };
2162
+ const commands = snapshotJsonValue(
2163
+ [replaceLogicalOperationCommand(logicalOperation, next)],
2164
+ 'deferred repository checkpoint-mismatch commands',
2165
+ );
2166
+ recovery = {
2167
+ sessionId: authority.sessionId,
2168
+ playbookId: authority.playbookId,
2169
+ boundaryIds: [],
2170
+ commands,
2171
+ logicalOperationReplacement: { operationId, next },
2172
+ };
2173
+ effectPossible = true;
2174
+ ledger = await ledgerService.writeAhead(authority, commands);
2175
+ logicalOperationById(ledger, operationId);
2176
+ await releaseRepositoryClaim(claim);
2177
+ return Object.freeze({
2178
+ status: 'checkpoint-mismatch',
2179
+ effectLedger: ledger,
2180
+ });
2181
+ }
2182
+
2183
+ if (!isPlainObject(options.effectBoundary)) {
2184
+ throw new TypeError(
2185
+ 'deferred repository continuation effectBoundary must be an object',
2186
+ );
2187
+ }
2188
+ if (
2189
+ Object.prototype.hasOwnProperty.call(
2190
+ options.effectBoundary,
2191
+ 'logicalOperationId',
2192
+ )
2193
+ ) {
2194
+ throw new TypeError(
2195
+ 'deferred repository continuation cannot override host-owned logicalOperationId',
2196
+ );
2197
+ }
2198
+ const seed = {
2199
+ ...effectBoundarySeed(
2200
+ options.effectBoundary,
2201
+ authority,
2202
+ checkpointObservation,
2203
+ ),
2204
+ logicalOperationId: operationId,
2205
+ };
2206
+ if (seed.runtimeSessionId !== logicalOperation.runtimeSessionId) {
2207
+ throw new Error(
2208
+ 'deferred repository continuation runtime session does not match its logical operation',
2209
+ );
2210
+ }
2211
+ const boundPlayerContinuation = logicalOperation.playerContinuation;
2212
+ const startingOperation = withoutDeferredBinding(logicalOperation, [
2213
+ ...logicalOperation.boundaryIds,
2214
+ seed.boundaryId,
2215
+ ]);
2216
+ const startCommands = snapshotJsonValue(
2217
+ [
2218
+ { kind: 'start-boundaries', boundaries: [seed] },
2219
+ replaceLogicalOperationCommand(logicalOperation, startingOperation),
2220
+ ],
2221
+ 'deferred repository continuation start commands',
2222
+ );
2223
+ recovery = {
2224
+ sessionId: authority.sessionId,
2225
+ playbookId: authority.playbookId,
2226
+ boundaryIds: [seed.boundaryId],
2227
+ startCommands,
2228
+ operationStarted: false,
2229
+ };
2230
+ effectPossible = true;
2231
+ ledger = await ledgerService.writeAhead(authority, startCommands);
2232
+ const currentBoundary = boundaryById(ledger, seed.boundaryId);
2233
+ logicalOperation = logicalOperationById(ledger, operationId);
2234
+ recovery = { ...recovery, operationStarted: true };
2235
+
2236
+ let operation;
2237
+ try {
2238
+ operation = Object.freeze({
2239
+ status: 'fulfilled',
2240
+ value: await options.operation({
2241
+ baseline: checkpointObservation,
2242
+ identity: claim.identity,
2243
+ // The start transition intentionally clears the bound group. Pass
2244
+ // the detached pre-start continuation captured while the claim was
2245
+ // held, never a later player-ledger selection.
2246
+ playerContinuation: boundPlayerContinuation,
2247
+ }),
2248
+ });
2249
+ } catch (error) {
2250
+ operation = Object.freeze({ status: 'rejected', reason: error });
2251
+ }
2252
+ const effectReceipt = snapshotJsonValue(
2253
+ await claim.capture(checkpointObservation, {
2254
+ allowedDispositions: currentBoundary.dispositions,
2255
+ observation: options.afterObservation,
2256
+ }),
2257
+ 'deferred repository receipt',
2258
+ );
2259
+ const beforeCompletion = ledgerService.snapshot();
2260
+ let latestBoundary = boundaryById(beforeCompletion, seed.boundaryId);
2261
+ let latestOperation = logicalOperationById(beforeCompletion, operationId);
2262
+ const logicalReceipt = await cumulativeLogicalReceipt(
2263
+ latestOperation.originalBaseline,
2264
+ effectReceipt,
2265
+ latestBoundary.dispositions,
2266
+ );
2267
+ const completion = await effectCompletion({
2268
+ callback: options.completeEffectBoundary,
2269
+ boundary: latestBoundary,
2270
+ operation,
2271
+ receipt: effectReceipt,
2272
+ outcomeReceipt: logicalReceipt,
2273
+ });
2274
+ const afterCompletion = ledgerService.snapshot();
2275
+ latestBoundary = boundaryById(afterCompletion, seed.boundaryId);
2276
+ latestOperation = logicalOperationById(afterCompletion, operationId);
2277
+ const completedBoundaryValue = rebaseEffectCompletion(
2278
+ latestBoundary,
2279
+ effectReceipt,
2280
+ completion.evidence,
2281
+ );
2282
+ if (
2283
+ completion.commands.length > 0 ||
2284
+ completedBoundaryValue.logicalOperationId !== operationId ||
2285
+ (completion.deferred?.operationId !== undefined &&
2286
+ completion.deferred.operationId !== operationId)
2287
+ ) {
2288
+ throw new TypeError(
2289
+ 'deferred repository continuation completion cannot override its host-owned logical operation',
2290
+ );
2291
+ }
2292
+ const eligibleDeferred =
2293
+ completion.deferred !== undefined &&
2294
+ deferredCheckpointEligible(
2295
+ latestOperation.originalBaseline,
2296
+ effectReceipt,
2297
+ );
2298
+ const nextOperation =
2299
+ completion.deferred !== undefined && eligibleDeferred
2300
+ ? {
2301
+ ...latestOperation,
2302
+ checkpoint: effectReceipt.after,
2303
+ pendingQuestion: completion.deferred.pendingQuestion,
2304
+ playerContinuation: completion.deferred.playerContinuation,
2305
+ checkpointRestorationEligible: false,
2306
+ }
2307
+ : completion.deferred !== undefined || completion.unresolved === true
2308
+ ? latestOperation
2309
+ : { ...latestOperation, logicalReceipt };
2310
+ const commands = snapshotJsonValue(
2311
+ [
2312
+ {
2313
+ kind: 'replace-boundaries',
2314
+ replacements: [
2315
+ { expected: latestBoundary, next: completedBoundaryValue },
2316
+ ],
2317
+ },
2318
+ replaceLogicalOperationCommand(latestOperation, nextOperation),
2319
+ ],
2320
+ 'deferred repository continuation completion commands',
2321
+ );
2322
+ recovery = { ...recovery, commands, commandsAcknowledged: false };
2323
+ ledger = await ledgerService.writeAhead(authority, commands);
2324
+ assertEffectCommandBatchResult(ledger, commands);
2325
+ const completedReceipt = boundaryById(
2326
+ ledger,
2327
+ seed.boundaryId,
2328
+ ).physicalReceipt;
2329
+ const completedLogicalReceipt =
2330
+ completion.deferred !== undefined || completion.unresolved === true
2331
+ ? undefined
2332
+ : logicalOperationById(ledger, operationId).logicalReceipt;
2333
+ recovery = { ...recovery, commandsAcknowledged: true };
2334
+ await releaseRepositoryClaim(claim);
2335
+ return Object.freeze({
2336
+ status: 'continued',
2337
+ baseline: checkpointObservation,
2338
+ operation,
2339
+ receipt: completedReceipt,
2340
+ ...(completion.deferred !== undefined || completion.unresolved === true
2341
+ ? {}
2342
+ : { logicalReceipt: completedLogicalReceipt }),
2343
+ effectLedger: ledger,
2344
+ ...(completion.deferred === undefined
2345
+ ? {}
2346
+ : {
2347
+ deferredStatus: eligibleDeferred ? 'bound' : 'unresolved',
2348
+ }),
2349
+ });
2350
+ } catch (error) {
2351
+ if (!effectPossible) {
2352
+ await releaseRepositoryClaim(claim, error);
2353
+ } else {
2354
+ quarantineProcessClaim(claim, recovery);
2355
+ }
2356
+ throw error;
2357
+ }
2358
+ }
2359
+
2360
+ async function runDurableCohort({
2361
+ coordinator,
2362
+ identity,
2363
+ authority,
2364
+ concurrentRoleSets,
2365
+ ledgerService,
2366
+ options,
2367
+ }) {
2368
+ rejectBoundRepositoryOverride(options, 'runCohort', [
2369
+ 'cwd',
2370
+ 'concurrentRoleSets',
2371
+ ]);
2372
+ const completeEffectBoundary = effectCompletionCallback(
2373
+ options,
2374
+ 'repository cohort',
2375
+ );
2376
+ const roleIds = validateCohort({ ...options, concurrentRoleSets });
2377
+ if (!isPlainObject(options.effectBoundaries)) {
2378
+ throw new TypeError(
2379
+ 'repository cohort effectBoundaries must be an object',
2380
+ );
2381
+ }
2382
+ const effectKeys = Object.keys(options.effectBoundaries).sort();
2383
+ if (!sameOrderedSet(effectKeys, [...roleIds].sort())) {
2384
+ throw new TypeError(
2385
+ 'repository cohort effect boundaries must exactly match its roles',
2386
+ );
2387
+ }
2388
+ const claim = await coordinator.acquire(identity.worktree, {
2389
+ signal: options.signal,
2390
+ });
2391
+ let effectPossible = false;
2392
+ let recovery;
2393
+ try {
2394
+ const baseline = await claim.observe(options.observation);
2395
+ const cohortId = randomUUID();
2396
+ const seeds = roleIds.map((roleId) => ({
2397
+ ...effectBoundarySeed(
2398
+ options.effectBoundaries[roleId],
2399
+ authority,
2400
+ baseline,
2401
+ ),
2402
+ cohortId,
2403
+ }));
2404
+ const startCommands = snapshotJsonValue([
2405
+ {
2406
+ kind: 'start-boundaries',
2407
+ boundaries: seeds,
2408
+ },
2409
+ ], 'repository cohort start commands');
2410
+ recovery = {
2411
+ sessionId: authority.sessionId,
2412
+ playbookId: authority.playbookId,
2413
+ boundaryIds: seeds.map((seed) => seed.boundaryId),
2414
+ startCommands,
2415
+ operationStarted: false,
2416
+ };
2417
+ effectPossible = true;
2418
+ const started = await ledgerService.writeAhead(authority, startCommands);
2419
+ for (const seed of seeds) boundaryById(started, seed.boundaryId);
2420
+ recovery = { ...recovery, operationStarted: true };
2421
+ const settled = await Promise.allSettled(
2422
+ roleIds.map((roleId) =>
2423
+ Promise.resolve().then(() =>
2424
+ options.operations[roleId]({
2425
+ baseline,
2426
+ identity: claim.identity,
2427
+ invocationId: options.invocationId,
2428
+ roleId,
2429
+ }),
2430
+ ),
2431
+ ),
2432
+ );
2433
+ const effectReceipt = snapshotJsonValue(
2434
+ await claim.capture(baseline, {
2435
+ allowedDispositions: ['unchanged'],
2436
+ cohort: true,
2437
+ observation: options.afterObservation,
2438
+ }),
2439
+ 'repository cohort receipt',
2440
+ );
2441
+ const beforeCompletion = ledgerService.snapshot();
2442
+ const callbackBoundaries = seeds.map((seed) =>
2443
+ boundaryById(beforeCompletion, seed.boundaryId),
2444
+ );
2445
+ const completionSettlements = await Promise.allSettled(
2446
+ callbackBoundaries.map((boundary, index) =>
2447
+ effectCompletion({
2448
+ callback: completeEffectBoundary,
2449
+ boundary,
2450
+ operation: settled[index],
2451
+ receipt: effectReceipt,
2452
+ outcomeReceipt: effectReceipt,
2453
+ roleId: roleIds[index],
2454
+ }),
2455
+ ),
2456
+ );
2457
+ const failedCompletion = completionSettlements.find(
2458
+ (completion) => completion.status === 'rejected',
2459
+ );
2460
+ if (failedCompletion !== undefined) throw failedCompletion.reason;
2461
+ const completions = completionSettlements.map(
2462
+ (completion) => completion.value,
2463
+ );
2464
+ const afterCompletion = ledgerService.snapshot();
2465
+ const latest = seeds.map((seed) =>
2466
+ boundaryById(afterCompletion, seed.boundaryId),
2467
+ );
2468
+ const completedBoundaries = latest.map((boundary, index) =>
2469
+ rebaseEffectCompletion(
2470
+ boundary,
2471
+ effectReceipt,
2472
+ completions[index].evidence,
2473
+ ),
2474
+ );
2475
+ const commands = snapshotJsonValue([
2476
+ {
2477
+ kind: 'replace-boundaries',
2478
+ replacements: latest.map((boundary, index) => ({
2479
+ expected: boundary,
2480
+ next: completedBoundaries[index],
2481
+ })),
2482
+ },
2483
+ ...completions.flatMap((completion) => completion.commands),
2484
+ ], 'repository cohort completion commands');
2485
+ recovery = { ...recovery, commands, commandsAcknowledged: false };
2486
+ const completed = await ledgerService.writeAhead(authority, commands);
2487
+ assertEffectCommandBatchResult(completed, commands);
2488
+ recovery = { ...recovery, commandsAcknowledged: true };
2489
+ await releaseRepositoryClaim(claim);
2490
+ const operations = Object.create(null);
2491
+ const receipts = Object.create(null);
2492
+ for (const [index, roleId] of roleIds.entries()) {
2493
+ operations[roleId] = settled[index];
2494
+ receipts[roleId] = boundaryById(
2495
+ completed,
2496
+ seeds[index].boundaryId,
2497
+ ).physicalReceipt;
2498
+ }
2499
+ return Object.freeze({
2500
+ baseline,
2501
+ invocationId: options.invocationId,
2502
+ operations: Object.freeze(operations),
2503
+ receipts: Object.freeze(receipts),
2504
+ effectLedger: completed,
2505
+ });
2506
+ } catch (error) {
2507
+ if (!effectPossible) {
2508
+ await releaseRepositoryClaim(claim, error);
2509
+ } else {
2510
+ quarantineProcessClaim(claim, recovery);
2511
+ }
2512
+ throw error;
2513
+ }
2514
+ }
2515
+
2516
+ export async function recoverIncompleteRepositoryEffects({
2517
+ catalog,
2518
+ capabilities,
2519
+ } = {}) {
2520
+ const entries = detachedSchema3CatalogEntries(catalog);
2521
+ if (entries.length === 0) return emptyPlaybookEffectLedger();
2522
+ const first = capabilities[entries[0].playbookId];
2523
+ if (first === undefined) {
2524
+ throw new Error('repository recovery has no current-host capability');
2525
+ }
2526
+ const coordinator = createRepositoryEffectCoordinator();
2527
+ let ledger = first.effectLedger.snapshot();
2528
+ for (const capability of Object.values(capabilities)) {
2529
+ const entry = processClaims.get(
2530
+ processClaimKey(capability.authority.canonicalWorktree),
2531
+ );
2532
+ let recovery = entry?.recovery;
2533
+ if (
2534
+ entry?.state !== 'quarantined' ||
2535
+ recovery?.sessionId !== capability.authority.sessionId ||
2536
+ recovery.playbookId !== capability.authority.playbookId
2537
+ ) {
2538
+ continue;
2539
+ }
2540
+ if (recovery.logicalOperationReplacement !== undefined) {
2541
+ const replacement = recovery.logicalOperationReplacement;
2542
+ const current = ledger.logicalOperations.find(
2543
+ (operation) => operation.operationId === replacement.operationId,
2544
+ );
2545
+ const commandReplacement = recovery.commands?.[0]?.replacements?.[0];
2546
+ const claim = await acquireRecoveryClaim(
2547
+ coordinator,
2548
+ capability.authority.canonicalWorktree,
2549
+ recovery,
2550
+ );
2551
+ try {
2552
+ if (!isDeepStrictEqual(current, replacement.next)) {
2553
+ if (
2554
+ commandReplacement === undefined ||
2555
+ !isDeepStrictEqual(current, commandReplacement.expected)
2556
+ ) {
2557
+ throw new Error(
2558
+ 'deferred repository recovery does not match the durable logical operation',
2559
+ );
2560
+ }
2561
+ ledger = await capability.effectLedger.writeAhead(recovery.commands);
2562
+ if (
2563
+ !isDeepStrictEqual(
2564
+ logicalOperationById(ledger, replacement.operationId),
2565
+ replacement.next,
2566
+ )
2567
+ ) {
2568
+ throw new Error(
2569
+ 'deferred repository recovery did not publish its logical operation replacement',
2570
+ );
2571
+ }
2572
+ }
2573
+ await releaseRepositoryClaim(claim);
2574
+ } catch (error) {
2575
+ quarantineProcessClaim(claim, recovery);
2576
+ throw error;
2577
+ }
2578
+ continue;
2579
+ }
2580
+ const savedBoundaries = recovery.boundaryIds.map((boundaryId) =>
2581
+ ledger.boundaries.find((boundary) => boundary.boundaryId === boundaryId),
2582
+ );
2583
+ if (
2584
+ recovery.startCommands !== undefined &&
2585
+ savedBoundaries.some((boundary) => boundary === undefined)
2586
+ ) {
2587
+ const claim = await acquireRecoveryClaim(
2588
+ coordinator,
2589
+ capability.authority.canonicalWorktree,
2590
+ recovery,
2591
+ );
2592
+ try {
2593
+ ledger = await capability.effectLedger.writeAhead(
2594
+ recovery.startCommands,
2595
+ );
2596
+ for (const boundaryId of recovery.boundaryIds) {
2597
+ boundaryById(ledger, boundaryId);
2598
+ }
2599
+ quarantineProcessClaim(claim, recovery);
2600
+ } catch (error) {
2601
+ quarantineProcessClaim(claim, recovery);
2602
+ throw error;
2603
+ }
2604
+ }
2605
+ if (recovery.commands === undefined && recovery.operationStarted === true) {
2606
+ throw new Error(
2607
+ 'live post-operation repository claim has no exact completion batch; process death is required before reconstruction',
2608
+ );
2609
+ }
2610
+ if (recovery.commands !== undefined) {
2611
+ const claim = await acquireRecoveryClaim(
2612
+ coordinator,
2613
+ capability.authority.canonicalWorktree,
2614
+ recovery,
2615
+ );
2616
+ try {
2617
+ if (recovery.commandsAcknowledged !== true) {
2618
+ ledger = await capability.effectLedger.writeAhead(recovery.commands);
2619
+ assertEffectCommandBatchResult(ledger, recovery.commands);
2620
+ recovery = { ...recovery, commandsAcknowledged: true };
2621
+ } else {
2622
+ assertEffectCommandBatchResult(ledger, recovery.commands);
2623
+ }
2624
+ await releaseRepositoryClaim(claim);
2625
+ } catch (error) {
2626
+ quarantineProcessClaim(claim, recovery);
2627
+ throw error;
2628
+ }
2629
+ continue;
2630
+ }
2631
+ const currentBoundaries = recovery.boundaryIds.map((boundaryId) =>
2632
+ ledger.boundaries.find((boundary) => boundary.boundaryId === boundaryId),
2633
+ );
2634
+ if (
2635
+ currentBoundaries.every(
2636
+ (boundary) => boundary?.physicalReceipt !== undefined,
2637
+ )
2638
+ ) {
2639
+ const claim = await acquireRecoveryClaim(
2640
+ coordinator,
2641
+ capability.authority.canonicalWorktree,
2642
+ recovery,
2643
+ );
2644
+ try {
2645
+ await releaseRepositoryClaim(claim);
2646
+ } catch (error) {
2647
+ quarantineProcessClaim(claim, recovery);
2648
+ throw error;
2649
+ }
2650
+ }
2651
+ }
2652
+ ledger = first.effectLedger.snapshot();
2653
+ for (let index = 0; index < ledger.boundaries.length; index += 1) {
2654
+ const saved = ledger.boundaries[index];
2655
+ if (saved.physicalReceipt !== undefined) continue;
2656
+ const capability = capabilities[saved.playbookId];
2657
+ if (capability === undefined) {
2658
+ throw new Error(
2659
+ `effect ledger names unavailable playbook ${JSON.stringify(saved.playbookId)}`,
2660
+ );
2661
+ }
2662
+ const cohort = incompleteRecoveryCohort(
2663
+ ledger.boundaries,
2664
+ index,
2665
+ capability.authority.concurrentRoleSets,
2666
+ );
2667
+ let recovery = {
2668
+ sessionId: capability.authority.sessionId,
2669
+ playbookId: capability.authority.playbookId,
2670
+ boundaryIds: cohort.map((boundary) => boundary.boundaryId),
2671
+ };
2672
+ if (
2673
+ !isDeepStrictEqual(
2674
+ saved.canonicalWorktree,
2675
+ capability.authority.canonicalWorktree,
2676
+ )
2677
+ ) {
2678
+ throw new Error(
2679
+ 'effect ledger canonical worktree does not match current host authority',
2680
+ );
2681
+ }
2682
+ const claim = await acquireRecoveryClaim(
2683
+ coordinator,
2684
+ capability.authority.canonicalWorktree,
2685
+ recovery,
2686
+ );
2687
+ try {
2688
+ if (!isDeepStrictEqual(claim.identity, saved.canonicalWorktree)) {
2689
+ throw new Error(
2690
+ 'effect ledger canonical worktree changed before recovery',
2691
+ );
2692
+ }
2693
+ const pending = processClaimRecovery(claim);
2694
+ if (pending !== undefined) {
2695
+ if (!sameProcessRecovery(pending, recovery)) {
2696
+ throw new Error(
2697
+ 'repository recovery batch does not match the incomplete boundaries',
2698
+ );
2699
+ }
2700
+ recovery = pending;
2701
+ }
2702
+ if (recovery.commands === undefined && recovery.operationStarted === true) {
2703
+ throw new Error(
2704
+ 'live post-operation repository claim has no exact completion batch; process death is required before reconstruction',
2705
+ );
2706
+ }
2707
+ if (recovery.commands === undefined) {
2708
+ const effectReceipt = await claim.capture(saved.baseline, {
2709
+ allowedDispositions:
2710
+ cohort.length > 1 ? ['unchanged'] : saved.dispositions,
2711
+ ...(cohort.length > 1 ? { cohort: true } : {}),
2712
+ });
2713
+ recovery = {
2714
+ ...recovery,
2715
+ commands: [
2716
+ {
2717
+ kind: 'replace-boundaries',
2718
+ replacements: cohort.map((boundary) => ({
2719
+ expected: boundary,
2720
+ next: completedBoundary(boundary, effectReceipt),
2721
+ })),
2722
+ },
2723
+ ],
2724
+ };
2725
+ }
2726
+ ledger = await capability.effectLedger.writeAhead(recovery.commands);
2727
+ for (const boundaryId of recovery.boundaryIds) {
2728
+ if (boundaryById(ledger, boundaryId).physicalReceipt === undefined) {
2729
+ throw new Error(
2730
+ `repository recovery did not complete boundary ${JSON.stringify(boundaryId)}`,
2731
+ );
2732
+ }
2733
+ }
2734
+ await releaseRepositoryClaim(claim);
2735
+ index += cohort.length - 1;
2736
+ } catch (error) {
2737
+ // This boundary was already durably started. Capture or persistence
2738
+ // failure leaves the claim active for authoritative or dead-owner
2739
+ // recovery.
2740
+ quarantineProcessClaim(claim, recovery);
2741
+ throw error;
2742
+ }
2743
+ }
2744
+ return ledger;
2745
+ }
2746
+
2747
+ function incompleteRecoveryCohort(
2748
+ boundaries,
2749
+ startIndex,
2750
+ concurrentRoleSets,
2751
+ ) {
2752
+ const first = boundaries[startIndex];
2753
+ if (first.cohortId === undefined) return [first];
2754
+ const commonKeys = [
2755
+ 'attemptId',
2756
+ 'attemptNumber',
2757
+ 'playbookId',
2758
+ 'runtimeSessionId',
2759
+ 'turnId',
2760
+ 'canonicalWorktree',
2761
+ 'baseline',
2762
+ ];
2763
+ const candidates = [];
2764
+ for (const boundary of boundaries.slice(startIndex)) {
2765
+ if (boundary.cohortId !== first.cohortId) break;
2766
+ candidates.push(boundary);
2767
+ }
2768
+ if (
2769
+ candidates.length < 2 ||
2770
+ candidates.some(
2771
+ (boundary) =>
2772
+ boundary.physicalReceipt !== undefined ||
2773
+ !boundary.dispositions.every((value) => value === 'unchanged') ||
2774
+ !commonKeys.every((key) =>
2775
+ isDeepStrictEqual(boundary[key], first[key]),
2776
+ ),
2777
+ ) ||
2778
+ !concurrentRoleSets.some((roles) =>
2779
+ sameOrderedSet(
2780
+ roles,
2781
+ candidates.map((boundary) => boundary.roleId),
2782
+ ),
2783
+ )
2784
+ ) {
2785
+ throw new Error(
2786
+ `effect ledger cohort ${JSON.stringify(first.cohortId)} does not match current host authority`,
2787
+ );
2788
+ }
2789
+ return candidates;
2790
+ }
2791
+
2792
+ // PBCLI-20/49: assemble live schema-3 facilities only after the caller owns
2793
+ // the durable Captain session and has selected its compatible working
2794
+ // directory.
2795
+ export async function createRepositoryEffectCapabilities({
2796
+ cwd,
2797
+ catalog,
2798
+ sessionId,
2799
+ sessionLease,
2800
+ createWriteAhead,
2801
+ } = {}) {
2802
+ const schema3Entries = detachedSchema3CatalogEntries(catalog);
2803
+
2804
+ if (typeof cwd !== 'string' || cwd.length === 0) {
2805
+ throw new TypeError(
2806
+ 'schema-3 repository capability working directory must be nonempty',
2807
+ );
2808
+ }
2809
+ if (
2810
+ typeof sessionId !== 'string' ||
2811
+ !UUID_PATTERN.test(sessionId) ||
2812
+ !isPlainObject(sessionLease) ||
2813
+ typeof sessionLease.sessionId !== 'string' ||
2814
+ !UUID_PATTERN.test(sessionLease.sessionId) ||
2815
+ typeof sessionLease.ownerToken !== 'string' ||
2816
+ !UUID_PATTERN.test(sessionLease.ownerToken) ||
2817
+ typeof sessionLease.assertOwner !== 'function'
2818
+ ) {
2819
+ throw new TypeError(
2820
+ 'schema-3 repository capability requires an active lease for its logical Captain session',
2821
+ );
2822
+ }
2823
+ if (sessionLease.sessionId !== sessionId) {
2824
+ throw new TypeError(
2825
+ 'schema-3 Captain host lease authority does not match its logical session',
2826
+ );
2827
+ }
2828
+ if (typeof createWriteAhead !== 'function') {
2829
+ throw new TypeError(
2830
+ 'schema-3 repository capability requires an effect-ledger write-ahead factory',
2831
+ );
2832
+ }
2833
+
2834
+ await sessionLease.assertOwner();
2835
+ const createdWriteAhead = await createWriteAhead(sessionLease);
2836
+ const ledgerService = assertEffectLedgerService(createdWriteAhead);
2837
+ const identity = await resolveCanonicalGitWorktree(cwd);
2838
+ await sessionLease.assertOwner();
2839
+ const coordinator = createRepositoryEffectCoordinator();
2840
+
2841
+ const capabilities = schema3Entries.map(
2842
+ ({ playbookId, requiredRoleIds, concurrentRoleSets }) => {
2843
+ const authority = deepFreeze({
2844
+ playbookId,
2845
+ artifactSchema: 3,
2846
+ cwd,
2847
+ sessionId,
2848
+ leaseOwnerToken: sessionLease.ownerToken,
2849
+ canonicalWorktree: identity,
2850
+ requiredRoleIds,
2851
+ concurrentRoleSets,
2852
+ });
2853
+ const observe = async (options = {}) => {
2854
+ rejectBoundRepositoryOverride(options, 'observe', ['cwd']);
2855
+ return observeResolvedWorktree(identity, options);
2856
+ };
2857
+ const acquire = async (options = {}) => {
2858
+ rejectBoundRepositoryOverride(options, 'acquire', ['cwd']);
2859
+ return coordinator.acquire(identity.worktree, options);
2860
+ };
2861
+ const runExclusive = async (options) => {
2862
+ return runDurableExclusive({
2863
+ coordinator,
2864
+ identity,
2865
+ authority,
2866
+ ledgerService,
2867
+ options,
2868
+ });
2869
+ };
2870
+ const runCohort = async (options) => {
2871
+ return runDurableCohort({
2872
+ coordinator,
2873
+ identity,
2874
+ authority,
2875
+ concurrentRoleSets,
2876
+ ledgerService,
2877
+ options,
2878
+ });
2879
+ };
2880
+ const runDeferred = async (options) => {
2881
+ return runDurableDeferred({
2882
+ coordinator,
2883
+ identity,
2884
+ authority,
2885
+ ledgerService,
2886
+ options,
2887
+ });
2888
+ };
2889
+ return [
2890
+ playbookId,
2891
+ deepFreeze({
2892
+ authority,
2893
+ repository: {
2894
+ identity,
2895
+ observe,
2896
+ acquire,
2897
+ runExclusive,
2898
+ runCohort,
2899
+ runDeferred,
2900
+ },
2901
+ effectLedger: {
2902
+ snapshot: () => ledgerService.snapshot(),
2903
+ writeAhead: async (commands) =>
2904
+ ledgerService.writeAhead(authority, commands),
2905
+ },
2906
+ }),
2907
+ ];
2908
+ },
2909
+ );
2910
+ const result = Object.freeze(Object.fromEntries(capabilities));
2911
+ capabilityLedgerServices.set(result, ledgerService);
2912
+ return result;
2913
+ }
2914
+
2915
+ export async function refreshRepositoryEffectCapabilities(capabilities) {
2916
+ const ledgerService = capabilityLedgerServices.get(capabilities);
2917
+ if (ledgerService === undefined) {
2918
+ if (Object.keys(capabilities).length === 0) {
2919
+ return emptyPlaybookEffectLedger();
2920
+ }
2921
+ throw new Error(
2922
+ 'repository capabilities do not belong to the current host',
2923
+ );
2924
+ }
2925
+ return ledgerService.refresh();
2926
+ }
2927
+
2928
+ export const _internal = Object.freeze({
2929
+ claimRootName: CLAIM_ROOT_NAME,
2930
+ });