@smartmemory/compose 0.2.58-beta → 0.3.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 (99) hide show
  1. package/README.md +2 -2
  2. package/bin/compose.js +55 -54
  3. package/dist/assets/{App-s4CulNkO.js → App-DJ5xk_Wx.js} +218 -218
  4. package/dist/assets/{abnfDiagram-VRR7QNED-CDwzQ5wr.js → abnfDiagram-VRR7QNED-BPWGdCFx.js} +1 -1
  5. package/dist/assets/{arc-kIU8DDth.js → arc-DX5jqmcO.js} +1 -1
  6. package/dist/assets/{architectureDiagram-ZJ3FMSHR-CleorO_0.js → architectureDiagram-ZJ3FMSHR-B6jmNVw7.js} +1 -1
  7. package/dist/assets/{blockDiagram-677ZJIJ3-NvEFfoMk.js → blockDiagram-677ZJIJ3-DAx2i-sB.js} +1 -1
  8. package/dist/assets/{c4Diagram-LMCZKHZV-BcDQdTVB.js → c4Diagram-LMCZKHZV-zmsZCplj.js} +1 -1
  9. package/dist/assets/channel-vsDnTvkh.js +1 -0
  10. package/dist/assets/{chunk-2Q5K7J3B-BseRkpm4.js → chunk-2Q5K7J3B-3QSh6sI7.js} +1 -1
  11. package/dist/assets/{chunk-32BRIVSS-DCHhvgFV.js → chunk-32BRIVSS-BiH5Di_y.js} +1 -1
  12. package/dist/assets/{chunk-5VM5RSS4-DcTpMdJ4.js → chunk-5VM5RSS4-C0EfjFLd.js} +1 -1
  13. package/dist/assets/{chunk-EX3LRPZG-BdN8fvWs.js → chunk-EX3LRPZG-BGyfmVm-.js} +1 -1
  14. package/dist/assets/{chunk-JWPE2WC7-DmZJP-8E.js → chunk-JWPE2WC7-Ba0mrNlg.js} +1 -1
  15. package/dist/assets/{chunk-MOJQB5TN-V74uYM17.js → chunk-MOJQB5TN-D3QO9EzH.js} +1 -1
  16. package/dist/assets/{chunk-RYQCIY6F-CoXUXbn_.js → chunk-RYQCIY6F-D1uvNA6d.js} +1 -1
  17. package/dist/assets/{chunk-V7JOEXUC-DUscIydR.js → chunk-V7JOEXUC-fihlnodT.js} +1 -1
  18. package/dist/assets/{chunk-VR4S4FIN-C4UDHq_a.js → chunk-VR4S4FIN-nmRUEo1H.js} +1 -1
  19. package/dist/assets/{chunk-XXDRQBXY-DIH9i8mZ.js → chunk-XXDRQBXY-CM693yEg.js} +1 -1
  20. package/dist/assets/classDiagram-OUVF2IWQ-DO-wdSFZ.js +1 -0
  21. package/dist/assets/classDiagram-v2-EOCWNBFH-DO-wdSFZ.js +1 -0
  22. package/dist/assets/{cose-bilkent-JH36ORCC-Dn6Gy6l1.js → cose-bilkent-JH36ORCC-CkacPn8W.js} +1 -1
  23. package/dist/assets/{cynefin-VYW2F7L2-C2LW5fzB.js → cynefin-VYW2F7L2-BiHaIptG.js} +1 -1
  24. package/dist/assets/{cynefinDiagram-TSTJHNR4-CGoAq7C9.js → cynefinDiagram-TSTJHNR4-CdKxMmiy.js} +1 -1
  25. package/dist/assets/{dagre-VKFMJZFB-BaOKtuBE.js → dagre-VKFMJZFB-BQLY5y_W.js} +1 -1
  26. package/dist/assets/{diagram-FQU43EPY-BdQwqt4x.js → diagram-FQU43EPY-D7uMBHvq.js} +1 -1
  27. package/dist/assets/{diagram-G47NLZAW-Dkcf0o5z.js → diagram-G47NLZAW-B3Z1cuH7.js} +1 -1
  28. package/dist/assets/{diagram-NH7WQ7WH-C_fDi59r.js → diagram-NH7WQ7WH-BZyRD45e.js} +1 -1
  29. package/dist/assets/{diagram-OA4YK3LP-jaTQcOxt.js → diagram-OA4YK3LP-DcThOTt7.js} +1 -1
  30. package/dist/assets/{diagram-WEI45ONY-dEtqVj3s.js → diagram-WEI45ONY-BcgRAkqY.js} +1 -1
  31. package/dist/assets/{ebnfDiagram-CCIWWBDH-s8ImqsSs.js → ebnfDiagram-CCIWWBDH-CGwfO_xH.js} +1 -1
  32. package/dist/assets/{erDiagram-Q63AITRT-5Zy29-SA.js → erDiagram-Q63AITRT-pV58-Ncc.js} +1 -1
  33. package/dist/assets/{flowDiagram-23GEKE2U-DV74V9he.js → flowDiagram-23GEKE2U-DJ_SqE8h.js} +1 -1
  34. package/dist/assets/{ganttDiagram-NO4QXBWP-BND-CiLO.js → ganttDiagram-NO4QXBWP-Dgy0Iyss.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-IHSO6WYX-B_-Mf9Jo.js → gitGraphDiagram-IHSO6WYX-CbiZw9fb.js} +1 -1
  36. package/dist/assets/{index-CBWbmG7W.js → index-DZTJEk-y.js} +2 -2
  37. package/dist/assets/{infoDiagram-FWYZ7A6U-Duyz8rZM.js → infoDiagram-FWYZ7A6U-CtsyyEc-.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-FXEZZL3T-D2Qft0e5.js → ishikawaDiagram-FXEZZL3T-BJilNFkK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-5HDEW3XC-q6rG4Rig.js → journeyDiagram-5HDEW3XC-C2UCMP4t.js} +1 -1
  40. package/dist/assets/{kanban-definition-HUTT4EX6-CsPX7gBL.js → kanban-definition-HUTT4EX6-DmLDJBRy.js} +1 -1
  41. package/dist/assets/{linear-CiccpAAd.js → linear-C1paCqE7.js} +1 -1
  42. package/dist/assets/{mindmap-definition-LN4V7U3C-DRW2njpA.js → mindmap-definition-LN4V7U3C-CX-RxKVn.js} +1 -1
  43. package/dist/assets/{pegDiagram-2B236MQR-DoDgm2S_.js → pegDiagram-2B236MQR-CTU32H2W.js} +1 -1
  44. package/dist/assets/{pieDiagram-ENE6RG2P-CNSYLVAF.js → pieDiagram-ENE6RG2P-D8L1aYoo.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-ABIIQ3AL-8-Nd6ony.js → quadrantDiagram-ABIIQ3AL-hQ3bXosy.js} +1 -1
  46. package/dist/assets/{railroadDiagram-RFXS5EU6-bfdg6Bo4.js → railroadDiagram-RFXS5EU6--_vuYcda.js} +1 -1
  47. package/dist/assets/{requirementDiagram-TGXJPOKE-slDkxCQy.js → requirementDiagram-TGXJPOKE-C6h_m3Az.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-HTMAVEWB-CR3CboiK.js → sankeyDiagram-HTMAVEWB-Dpc7CfIQ.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-DBY2YBRQ-BIXw1JS1.js → sequenceDiagram-DBY2YBRQ-CK5ZzbvJ.js} +1 -1
  50. package/dist/assets/{sizeCapture-X5ZJPWSS-BdKI7JyF.js → sizeCapture-X5ZJPWSS-BAxVvM9G.js} +1 -1
  51. package/dist/assets/{stateDiagram-2N3HPSRC-ktVgpBZS.js → stateDiagram-2N3HPSRC-7j33_2PY.js} +1 -1
  52. package/dist/assets/stateDiagram-v2-6OUMAXLB-GviN0FpJ.js +1 -0
  53. package/dist/assets/{swimlanes-5IMT3BWC-CaXpC3FM.js → swimlanes-5IMT3BWC-CBG2yOob.js} +2 -2
  54. package/dist/assets/swimlanesDiagram-G3AALYLV-D0wMoxl1.js +8 -0
  55. package/dist/assets/{timeline-definition-FHXFAJF6-CKWSlRUl.js → timeline-definition-FHXFAJF6-BOnZkQvc.js} +1 -1
  56. package/dist/assets/{vennDiagram-L72KCM5P-CwOhxDyv.js → vennDiagram-L72KCM5P-D52NhIfj.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-EHGQE667-DGavMpEB.js → wardleyDiagram-EHGQE667-YoPY2ZU-.js} +1 -1
  58. package/dist/assets/{xychartDiagram-FW5EYKEG-CWmnmLZz.js → xychartDiagram-FW5EYKEG-BanNfgtO.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/build-all.js +0 -5
  61. package/lib/build-stream-schema.js +1 -1
  62. package/lib/build.js +1762 -2614
  63. package/lib/consumer-fanout.js +1317 -0
  64. package/lib/feature-validator.js +6 -4
  65. package/lib/flow-state.js +15 -14
  66. package/lib/gsd-budget.js +48 -10
  67. package/lib/gsd-prompt.js +3 -4
  68. package/lib/gsd-stuck.js +1 -1
  69. package/lib/gsd.js +303 -149
  70. package/lib/local-claude-connector.js +149 -0
  71. package/lib/new.js +162 -307
  72. package/lib/result-normalizer.js +233 -18
  73. package/lib/review-lenses.js +1 -1
  74. package/lib/step-prompt.js +41 -119
  75. package/lib/stratum-engine.js +297 -0
  76. package/lib/stratum-mcp-client.js +224 -213
  77. package/lib/vocabulary-compliance.js +268 -0
  78. package/lib/vocabulary-inject.js +1 -36
  79. package/package.json +2 -1
  80. package/pipelines/build-quick.stratum.yaml +5 -17
  81. package/pipelines/build.profiles.json +11 -0
  82. package/pipelines/build.stratum.yaml +288 -467
  83. package/pipelines/gsd.stratum.yaml +73 -125
  84. package/pipelines/new.stratum.yaml +68 -149
  85. package/server/build-routes.js +4 -1
  86. package/server/design-routes.js +37 -21
  87. package/server/index.js +13 -21
  88. package/server/lifecycle-guard.js +1 -1
  89. package/server/pipeline-routes.js +113 -31
  90. package/server/stratum-client.js +33 -47
  91. package/server/stratum-sync.js +3 -4
  92. package/server/vision-server.js +1 -1
  93. package/dist/assets/channel-DiJkE9og.js +0 -1
  94. package/dist/assets/classDiagram-OUVF2IWQ-DS76VBEL.js +0 -1
  95. package/dist/assets/classDiagram-v2-EOCWNBFH-DS76VBEL.js +0 -1
  96. package/dist/assets/stateDiagram-v2-6OUMAXLB-CzPQnlan.js +0 -1
  97. package/dist/assets/swimlanesDiagram-G3AALYLV-rIDv6EuQ.js +0 -8
  98. package/lib/connector-factory-shim.js +0 -167
  99. package/server/agent-mcp.js +0 -10
@@ -0,0 +1,1317 @@
1
+ /**
2
+ * Crash-safe consumer-dispatch artifacts for the TS-native ready[] pump.
3
+ *
4
+ * All metadata and item worktrees live outside the merge target. Journal writes
5
+ * are fsync + atomic rename so a process kill cannot expose a half-record.
6
+ */
7
+
8
+ import {
9
+ closeSync,
10
+ existsSync,
11
+ fsyncSync,
12
+ mkdirSync,
13
+ openSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ renameSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { execFileSync } from 'node:child_process';
21
+ import { createHash, randomUUID } from 'node:crypto';
22
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
23
+ import { tmpdir } from 'node:os';
24
+
25
+ const JOURNAL_VERSION = 1;
26
+ let tempIndexSequence = 0;
27
+
28
+ function sha256(value) {
29
+ return createHash('sha256').update(String(value)).digest('hex');
30
+ }
31
+
32
+ function now() {
33
+ return new Date().toISOString();
34
+ }
35
+
36
+ function git(cwd, args, opts = {}) {
37
+ const output = execFileSync('git', args, {
38
+ cwd,
39
+ encoding: opts.encoding ?? 'utf8',
40
+ input: opts.input,
41
+ env: opts.env ?? process.env,
42
+ timeout: opts.timeout ?? 30_000,
43
+ // A cumulative diff (git diff --cached --binary) can dwarf execFileSync's
44
+ // 1 MiB default stdout buffer. Without headroom a >1 MiB diff throws ENOBUFS
45
+ // at capture AND identically on every recovery re-run — a permanent wedge.
46
+ // Every git call in this module routes through here, so one ceiling covers
47
+ // diff capture, snapshots, and merges alike.
48
+ maxBuffer: opts.maxBuffer ?? 512 * 1024 * 1024,
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ });
51
+ return opts.trim === false ? output : output.trim();
52
+ }
53
+
54
+ function fsyncDirectory(path) {
55
+ let fd;
56
+ try {
57
+ fd = openSync(path, 'r');
58
+ fsyncSync(fd);
59
+ } finally {
60
+ if (fd !== undefined) closeSync(fd);
61
+ }
62
+ }
63
+
64
+ function durableWriteJson(path, value) {
65
+ mkdirSync(dirname(path), { recursive: true });
66
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
67
+ let fd;
68
+ try {
69
+ fd = openSync(temp, 'wx', 0o600);
70
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
71
+ fsyncSync(fd);
72
+ } finally {
73
+ if (fd !== undefined) closeSync(fd);
74
+ }
75
+ renameSync(temp, path);
76
+ fsyncDirectory(dirname(path));
77
+ }
78
+
79
+ // Module-level, journal-path-keyed one-writer guard shared by every
80
+ // ConsumerFanoutArtifacts instance on the same file. Combined with the
81
+ // mutate-against-fresh primitive (#mutate reloads the on-disk journal before
82
+ // applying a change), it guarantees no write is ever based on a stale snapshot.
83
+ const journalWriters = new Set();
84
+
85
+ /** Resolve symlinks in every existing prefix, including for a not-yet-created leaf. */
86
+ function canonicalPath(path) {
87
+ let cursor = resolve(path);
88
+ const missing = [];
89
+ while (!existsSync(cursor)) {
90
+ const parent = dirname(cursor);
91
+ if (parent === cursor) break;
92
+ missing.unshift(basename(cursor));
93
+ cursor = parent;
94
+ }
95
+ const existing = existsSync(cursor) ? realpathSync(cursor) : cursor;
96
+ return resolve(existing, ...missing);
97
+ }
98
+
99
+ function withTemporaryIndex(cwd, fn) {
100
+ const indexPath = join(
101
+ tmpdir(),
102
+ `compose-consumer-index-${process.pid}-${tempIndexSequence++}-${randomUUID()}`,
103
+ );
104
+ const env = { ...process.env, GIT_INDEX_FILE: indexPath };
105
+ try {
106
+ return fn(env);
107
+ } finally {
108
+ rmSync(indexPath, { force: true });
109
+ }
110
+ }
111
+
112
+ /** Snapshot tracked + untracked, non-ignored working content without touching the real index. */
113
+ export function snapshotWorkingTree(cwd) {
114
+ return withTemporaryIndex(cwd, (env) => {
115
+ git(cwd, ['read-tree', 'HEAD'], { env });
116
+ git(cwd, ['add', '-A'], { env });
117
+ return git(cwd, ['write-tree'], { env });
118
+ });
119
+ }
120
+
121
+ function restoreWorkingTree(cwd, treeId) {
122
+ // This intentionally normalizes the staged/unstaged split, matching Compose's
123
+ // existing snapshot restore semantics. The final reset leaves HEAD/index alone
124
+ // while retaining the restored tree as working content.
125
+ try { git(cwd, ['clean', '-fd']); } catch { /* nothing untracked to remove */ }
126
+ git(cwd, ['read-tree', '--reset', '-u', treeId]);
127
+ git(cwd, ['reset', '-q', 'HEAD']);
128
+ const actual = snapshotWorkingTree(cwd);
129
+ if (actual !== treeId) {
130
+ throw new Error(`consumer snapshot restore verification failed: expected ${treeId}, got ${actual}`);
131
+ }
132
+ }
133
+
134
+ function cumulativeDiff(cwd, baseCommit) {
135
+ return withTemporaryIndex(cwd, (env) => {
136
+ git(cwd, ['read-tree', baseCommit], { env });
137
+ git(cwd, ['add', '-A'], { env });
138
+ return git(cwd, ['diff', '--cached', '--binary', baseCommit, '--'], {
139
+ env,
140
+ timeout: 60_000,
141
+ trim: false,
142
+ });
143
+ });
144
+ }
145
+
146
+ function computeWitnessChain(cwd, orderedEntries) {
147
+ return withTemporaryIndex(cwd, (env) => {
148
+ git(cwd, ['read-tree', 'HEAD'], { env });
149
+ git(cwd, ['add', '-A'], { env });
150
+ const chain = [git(cwd, ['write-tree'], { env })];
151
+ for (const entry of orderedEntries) {
152
+ if (entry.diff.length > 0) {
153
+ git(cwd, ['apply', '--cached', '--binary', '-'], {
154
+ env,
155
+ input: entry.diff,
156
+ timeout: 60_000,
157
+ });
158
+ }
159
+ chain.push(git(cwd, ['write-tree'], { env }));
160
+ }
161
+ return chain;
162
+ });
163
+ }
164
+
165
+ function defaultArtifactRoot(targetCwd) {
166
+ const workspaceKey = sha256(resolve(targetCwd)).slice(0, 24);
167
+ return join(tmpdir(), 'compose-consumer-fanout', workspaceKey);
168
+ }
169
+
170
+ function runDirectoryName(runId) {
171
+ return `${String(runId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48)}-${sha256(runId).slice(0, 12)}`;
172
+ }
173
+
174
+ function journalLocation({ runId, targetCwd, artifactRoot }) {
175
+ const target = canonicalPath(targetCwd);
176
+ const root = canonicalPath(artifactRoot ?? defaultArtifactRoot(target));
177
+ return join(root, runDirectoryName(runId), 'journal.json');
178
+ }
179
+
180
+ function worktreeKey(descriptor) {
181
+ return `${descriptor.id}@${descriptor.generation}`;
182
+ }
183
+
184
+ /**
185
+ * Python-parity: a fanout with `isolation: "none"` runs its items in the shared
186
+ * target cwd (no per-item worktree, no diff capture, no merge participation).
187
+ * Only `isolation: "worktree"` items own a detached worktree and owe a diff.
188
+ */
189
+ function isNoneIsolation(descriptor) {
190
+ return descriptor?.policy?.isolation === 'none';
191
+ }
192
+
193
+ function terminalItem(item) {
194
+ return ['succeeded', 'failed', 'skipped', 'cancelled'].includes(item?.status);
195
+ }
196
+
197
+ export function isConsumerDescriptor(entry) {
198
+ return Boolean(
199
+ entry
200
+ && typeof entry === 'object'
201
+ && typeof entry.dispatchToken === 'string'
202
+ && entry.dispatchToken.length > 0
203
+ && Number.isInteger(entry.itemIndex)
204
+ && Number.isInteger(entry.stage)
205
+ && Number.isInteger(entry.generation)
206
+ && typeof entry.step === 'string'
207
+ && entry.policy
208
+ && typeof entry.policy === 'object',
209
+ );
210
+ }
211
+
212
+ export class ConsumerArtifactError extends Error {
213
+ constructor(code, message, detail = {}) {
214
+ super(message);
215
+ this.name = 'ConsumerArtifactError';
216
+ this.code = code;
217
+ this.detail = detail;
218
+ }
219
+ }
220
+
221
+ export class ConsumerMergeDecisionError extends ConsumerArtifactError {
222
+ constructor(code, message, detail = {}) {
223
+ super(code, message, detail);
224
+ this.name = 'ConsumerMergeDecisionError';
225
+ }
226
+ }
227
+
228
+ export class ConsumerFanoutArtifacts {
229
+ constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest }) {
230
+ this.runId = runId;
231
+ this.targetCwd = canonicalPath(targetCwd);
232
+ this.artifactRoot = canonicalPath(artifactRoot ?? defaultArtifactRoot(this.targetCwd));
233
+ const artifactRelative = relative(this.targetCwd, this.artifactRoot);
234
+ if (artifactRelative === ''
235
+ || (!artifactRelative.startsWith('..') && !isAbsolute(artifactRelative))) {
236
+ throw new ConsumerArtifactError(
237
+ 'ARTIFACT_ROOT_INSIDE_TARGET',
238
+ `consumer artifact root must be outside the merge target: ${this.artifactRoot}`,
239
+ );
240
+ }
241
+ // Revision pins are written into the FIRST durable journal write (below), so
242
+ // a crash between journal creation and the first bind cannot leave an
243
+ // unpinned journal that a drifted spec could later re-pin as the truth.
244
+ this._initialRevisionDigest = typeof revisionDigest === 'string' && revisionDigest.length > 0 ? revisionDigest : null;
245
+ this._initialSpecDigest = typeof specDigest === 'string' && specDigest.length > 0 ? specDigest : null;
246
+ this.runRoot = join(this.artifactRoot, runDirectoryName(runId));
247
+ this.journalPath = join(this.runRoot, 'journal.json');
248
+ this.hooks = hooks ?? {};
249
+ mkdirSync(this.runRoot, { recursive: true });
250
+ this.journal = this.#load();
251
+ }
252
+
253
+ #writeGuarded(value) {
254
+ // Module-level one-writer guard keyed by journal PATH — coordinates every
255
+ // ConsumerFanoutArtifacts instance on the same file, not just this instance.
256
+ if (journalWriters.has(this.journalPath)) {
257
+ throw new ConsumerArtifactError(
258
+ 'JOURNAL_WRITER_REENTRY',
259
+ `consumer journal already has an active writer: ${this.journalPath}`,
260
+ );
261
+ }
262
+ journalWriters.add(this.journalPath);
263
+ try {
264
+ durableWriteJson(this.journalPath, value);
265
+ } finally {
266
+ journalWriters.delete(this.journalPath);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Single-source-of-truth mutation: apply a synchronous change to the CURRENT
272
+ * on-disk journal, never to a possibly-stale in-memory base. Under the one-writer
273
+ * guard: reload fresh from disk, run `fn` (which mutates `this.journal`), durably
274
+ * write, and leave `this.journal` as exactly what was written. The in-memory model
275
+ * is thereby a read cache only. Re-entrant: a nested mutation just runs against the
276
+ * already-fresh `this.journal` and lets the outer write persist it.
277
+ */
278
+ #mutate(fn) {
279
+ if (this._inMutation) return fn();
280
+ if (journalWriters.has(this.journalPath)) {
281
+ throw new ConsumerArtifactError(
282
+ 'JOURNAL_WRITER_REENTRY',
283
+ `consumer journal already has an active writer: ${this.journalPath}`,
284
+ );
285
+ }
286
+ journalWriters.add(this.journalPath);
287
+ this._inMutation = true;
288
+ try {
289
+ this.journal = this.#reload();
290
+ const result = fn();
291
+ this.journal.updatedAt = now();
292
+ durableWriteJson(this.journalPath, this.journal);
293
+ return result;
294
+ } finally {
295
+ this._inMutation = false;
296
+ journalWriters.delete(this.journalPath);
297
+ }
298
+ }
299
+
300
+ #save() {
301
+ this.journal.updatedAt = now();
302
+ if (this._inMutation) {
303
+ // Interim durable write within an active mutation (the guard is already held).
304
+ durableWriteJson(this.journalPath, this.journal);
305
+ } else {
306
+ this.#writeGuarded(this.journal);
307
+ }
308
+ }
309
+
310
+ /** Read + validate + default + adopt-pins the existing on-disk journal. No write. */
311
+ #reload() {
312
+ const journal = JSON.parse(readFileSync(this.journalPath, 'utf8'));
313
+ if (journal.version !== JOURNAL_VERSION || journal.runId !== this.runId) {
314
+ throw new ConsumerArtifactError('JOURNAL_IDENTITY_MISMATCH', 'consumer artifact journal identity mismatch');
315
+ }
316
+ if (resolve(journal.targetCwd) !== this.targetCwd) {
317
+ throw new ConsumerArtifactError(
318
+ 'JOURNAL_TARGET_MISMATCH',
319
+ `consumer artifact journal targets ${journal.targetCwd}, not ${this.targetCwd}`,
320
+ );
321
+ }
322
+ journal.revisionDigest ??= null;
323
+ journal.specDigest ??= null;
324
+ journal.gateBinding ??= null;
325
+ journal.worktrees ??= [];
326
+ journal.witnesses ??= [];
327
+ journal.issuances ??= [];
328
+ journal.mergeTransactions ??= [];
329
+ // Adopt constructor-supplied pins onto an EXISTING unpinned journal — a legacy
330
+ // pre-pinning journal, or one first created at the gate path before this
331
+ // manager was constructed. An already-pinned journal keeps its pins; a
332
+ // constructor pin that DIFFERS from a persisted one is a real revision drift.
333
+ if (this._initialRevisionDigest) {
334
+ if (!journal.revisionDigest) journal.revisionDigest = this._initialRevisionDigest;
335
+ else if (journal.revisionDigest !== this._initialRevisionDigest) {
336
+ throw new ConsumerArtifactError(
337
+ 'CONSUMER_RUN_REVISION_MISMATCH',
338
+ `consumer journal revision ${journal.revisionDigest} does not match run revision ${this._initialRevisionDigest}`,
339
+ { journaledRevisionDigest: journal.revisionDigest, runRevisionDigest: this._initialRevisionDigest },
340
+ );
341
+ }
342
+ }
343
+ if (this._initialSpecDigest) {
344
+ if (!journal.specDigest) journal.specDigest = this._initialSpecDigest;
345
+ else if (journal.specDigest !== this._initialSpecDigest) {
346
+ throw new ConsumerArtifactError(
347
+ 'CONSUMER_RUN_REVISION_MISMATCH',
348
+ `consumer journal spec ${journal.specDigest} does not match local spec ${this._initialSpecDigest}`,
349
+ { journaledSpecDigest: journal.specDigest, currentSpecDigest: this._initialSpecDigest },
350
+ );
351
+ }
352
+ }
353
+ return journal;
354
+ }
355
+
356
+ #load() {
357
+ if (!existsSync(this.journalPath)) {
358
+ const journal = {
359
+ version: JOURNAL_VERSION,
360
+ runId: this.runId,
361
+ targetCwd: this.targetCwd,
362
+ createdAt: now(),
363
+ updatedAt: now(),
364
+ // The run revision this journal belongs to. `revisionDigest` is the
365
+ // engine's effective-spec digest (echoed by every descriptor); `specDigest`
366
+ // is Compose's fingerprint of the local pipeline spec it derives final-stage
367
+ // and merge-gate ownership from. Both are pinned in THIS initial write when
368
+ // available, and re-checked on resume so a mid-run spec edit cannot silently
369
+ // strand diffs.
370
+ revisionDigest: this._initialRevisionDigest,
371
+ specDigest: this._initialSpecDigest,
372
+ gateBinding: null,
373
+ worktrees: [],
374
+ witnesses: [],
375
+ issuances: [],
376
+ mergeTransactions: [],
377
+ };
378
+ this.#writeGuarded(journal);
379
+ return journal;
380
+ }
381
+ // Existing journal: reload (validates + adopts pins). Persist an adoption so a
382
+ // legacy/gate-first-created journal is pinned even if no mutation follows.
383
+ const before = readFileSync(this.journalPath, 'utf8');
384
+ const journal = this.#reload();
385
+ if ((this._initialRevisionDigest && journal.revisionDigest !== JSON.parse(before).revisionDigest)
386
+ || (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)) {
387
+ this.#writeGuarded(journal);
388
+ }
389
+ return journal;
390
+ }
391
+
392
+ #worktreeFor(descriptor) {
393
+ return this.journal.worktrees.find((entry) => entry.key === worktreeKey(descriptor));
394
+ }
395
+
396
+ #ensureWorktree(descriptor) {
397
+ const key = worktreeKey(descriptor);
398
+ let record = this.#worktreeFor(descriptor);
399
+ if (record) {
400
+ if (existsSync(record.path)) {
401
+ try {
402
+ if (git(record.path, ['rev-parse', '--is-inside-work-tree']) === 'true') {
403
+ if (record.status !== 'ready') {
404
+ record.status = 'ready';
405
+ record.readyAt = now();
406
+ this.#save();
407
+ }
408
+ return record;
409
+ }
410
+ } catch { /* partial worktree creation; rebuild below */ }
411
+ }
412
+
413
+ if (record.status === 'creating') {
414
+ try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
415
+ rmSync(record.path, { recursive: true, force: true });
416
+ mkdirSync(dirname(record.path), { recursive: true });
417
+ git(this.targetCwd, ['worktree', 'add', '--detach', record.path, record.baseCommit], { timeout: 60_000 });
418
+ record.status = 'ready';
419
+ record.readyAt = now();
420
+ record.recoveredAt = now();
421
+ this.#save();
422
+ return record;
423
+ }
424
+
425
+ // Only the CURRENT issuance's pre-stage witness is a safe reconstruction
426
+ // point. An older stage witness predates that stage's filesystem mutation
427
+ // and would silently discard persistent `${prev}` state.
428
+ const witness = this.journal.witnesses.find(
429
+ (entry) => entry.worktreeKey === key
430
+ && entry.dispatchToken === descriptor.dispatchToken,
431
+ );
432
+ if (!witness) {
433
+ throw new ConsumerArtifactError(
434
+ 'ITEM_WORKTREE_LOST',
435
+ `consumer worktree artifact was lost for ${descriptor.id} generation ${descriptor.generation}`,
436
+ { descriptor },
437
+ );
438
+ }
439
+ try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
440
+ rmSync(record.path, { recursive: true, force: true });
441
+ git(this.targetCwd, ['worktree', 'add', '--detach', record.path, record.baseCommit], { timeout: 60_000 });
442
+ restoreWorkingTree(record.path, witness.witnessTree);
443
+ record.status = 'ready';
444
+ record.recoveredAt = now();
445
+ this.#save();
446
+ return record;
447
+ }
448
+
449
+ for (const prior of this.journal.worktrees) {
450
+ if (prior.scopedId === descriptor.id && prior.generation !== descriptor.generation && !prior.superseded) {
451
+ prior.superseded = true;
452
+ prior.supersededAt = now();
453
+ }
454
+ }
455
+ for (const issuance of this.journal.issuances) {
456
+ if (issuance.scopedId === descriptor.id
457
+ && issuance.generation !== descriptor.generation
458
+ && issuance.state !== 'merged'
459
+ && issuance.state !== 'superseded') {
460
+ issuance.state = 'superseded';
461
+ issuance.supersededAt = now();
462
+ }
463
+ }
464
+
465
+ const path = join(this.runRoot, 'worktrees', sha256(key).slice(0, 32));
466
+ record = {
467
+ key,
468
+ scopedId: descriptor.id,
469
+ fanoutStepId: descriptor.step,
470
+ itemIndex: descriptor.itemIndex,
471
+ generation: descriptor.generation,
472
+ path,
473
+ baseCommit: git(this.targetCwd, ['rev-parse', 'HEAD']),
474
+ status: 'creating',
475
+ superseded: false,
476
+ createdAt: now(),
477
+ };
478
+ this.journal.worktrees.push(record);
479
+ this.#save();
480
+
481
+ mkdirSync(dirname(path), { recursive: true });
482
+ try {
483
+ git(this.targetCwd, ['worktree', 'add', '--detach', path, record.baseCommit], { timeout: 60_000 });
484
+ } catch (error) {
485
+ try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
486
+ rmSync(path, { recursive: true, force: true });
487
+ throw error;
488
+ }
489
+ record.status = 'ready';
490
+ record.readyAt = now();
491
+ this.#save();
492
+ return record;
493
+ }
494
+
495
+ #auditItem(audit, fanoutStepId, itemIndex) {
496
+ return audit?.steps?.[fanoutStepId]?.fanout?.items?.[itemIndex] ?? null;
497
+ }
498
+
499
+ /**
500
+ * Pin the run revision + local-spec fingerprint at the first consumer issuance.
501
+ * First-write-wins; a later issuance carrying a DIFFERENT engine revision digest
502
+ * is a mid-run revision the journal must reject rather than silently absorb.
503
+ */
504
+ bindRunRevision({ revisionDigest, specDigest }) {
505
+ return this.#mutate(() => {
506
+ if (typeof revisionDigest === 'string' && revisionDigest.length > 0) {
507
+ if (this.journal.revisionDigest === null) {
508
+ this.journal.revisionDigest = revisionDigest;
509
+ } else if (this.journal.revisionDigest !== revisionDigest) {
510
+ throw new ConsumerArtifactError(
511
+ 'CONSUMER_RUN_REVISION_MISMATCH',
512
+ `consumer run revision changed mid-run: journaled ${this.journal.revisionDigest}, descriptor ${revisionDigest}`,
513
+ { journaledRevisionDigest: this.journal.revisionDigest, descriptorRevisionDigest: revisionDigest },
514
+ );
515
+ }
516
+ }
517
+ if (typeof specDigest === 'string' && specDigest.length > 0 && this.journal.specDigest === null) {
518
+ this.journal.specDigest = specDigest;
519
+ }
520
+ return this.journal;
521
+ });
522
+ }
523
+
524
+ /** Record which fanout step a merge gate settles, so resume never re-derives it
525
+ * from a mutable local spec that may have drifted. First-write-wins per gate. */
526
+ recordGateBinding({ gateStepId, fanoutStepId }) {
527
+ if (!gateStepId || !fanoutStepId) return;
528
+ this.#mutate(() => {
529
+ const existing = this.journal.gateBinding ?? {};
530
+ if (existing[gateStepId] === fanoutStepId) return;
531
+ if (existing[gateStepId] && existing[gateStepId] !== fanoutStepId) {
532
+ throw new ConsumerArtifactError(
533
+ 'CONSUMER_GATE_BINDING_MISMATCH',
534
+ `merge gate ${gateStepId} was journaled against fanout ${existing[gateStepId]}, not ${fanoutStepId}`,
535
+ { gateStepId, journaledFanoutStepId: existing[gateStepId], fanoutStepId },
536
+ );
537
+ }
538
+ this.journal.gateBinding = { ...existing, [gateStepId]: fanoutStepId };
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Reconcile journal records against an audit. `scope` bounds which records may
544
+ * be mutated: a per-item settlement (concurrent, possibly-stale snapshot) must
545
+ * pass `{ fanoutStepId, itemIndex }` so it touches ONLY its own item's records —
546
+ * an unordered stale snapshot from one item must never supersede another item's
547
+ * newer issuance (C1). Global reconciliation (merge-gate discovery, resume
548
+ * recovery) passes no scope and runs only at ordered, single-threaded points
549
+ * with a fresh audit.
550
+ */
551
+ reconcileAudit(audit, scope = null) {
552
+ return this.#mutate(() => this.#reconcileAuditInto(audit, scope));
553
+ }
554
+
555
+ #reconcileAuditInto(audit, scope) {
556
+ const inScope = (fanoutStepId, itemIndex) => !scope
557
+ || (scope.fanoutStepId === fanoutStepId && scope.itemIndex === itemIndex);
558
+ let changed = false;
559
+ for (const issuance of this.journal.issuances) {
560
+ if (issuance.state === 'merged' || issuance.state === 'superseded') continue;
561
+ if (!inScope(issuance.fanoutStepId, issuance.itemIndex)) continue;
562
+ const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
563
+ const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
564
+ if (!item) {
565
+ // The fanout has no item at this index. If the fanout IS present in the
566
+ // audit (re-enumeration produced FEWER items), the issuance's evidence is
567
+ // stale ground-truth — supersede it so a later merge never resurrects a
568
+ // diff for an item index that no longer exists. If the fanout is absent
569
+ // from the audit entirely, there is nothing to reconcile against; leave it.
570
+ if (Array.isArray(fanoutItems)) {
571
+ issuance.state = 'superseded';
572
+ issuance.supersededAt = now();
573
+ changed = true;
574
+ }
575
+ continue;
576
+ }
577
+ if (item.generation !== issuance.generation) {
578
+ issuance.state = 'superseded';
579
+ issuance.supersededAt = now();
580
+ changed = true;
581
+ } else if (item.status === 'succeeded'
582
+ && item.acceptedDispatchToken === issuance.dispatchToken) {
583
+ issuance.state = 'accepted';
584
+ issuance.acceptedAt = now();
585
+ changed = true;
586
+ } else if (terminalItem(item)
587
+ || (typeof item.dispatchToken === 'string' && item.dispatchToken !== issuance.dispatchToken)) {
588
+ issuance.state = 'superseded';
589
+ issuance.supersededAt = now();
590
+ changed = true;
591
+ }
592
+ }
593
+
594
+ for (const worktree of this.journal.worktrees) {
595
+ if (!inScope(worktree.fanoutStepId, worktree.itemIndex)) continue;
596
+ const item = this.#auditItem(audit, worktree.fanoutStepId, worktree.itemIndex);
597
+ if (item && item.generation !== worktree.generation && !worktree.superseded) {
598
+ worktree.superseded = true;
599
+ worktree.supersededAt = now();
600
+ changed = true;
601
+ }
602
+ }
603
+ if (changed) this.#save();
604
+ }
605
+
606
+ /**
607
+ * Reconcile a ready issuance. Returns either a stored envelope to re-report,
608
+ * or a verified worktree path in which the stage may execute.
609
+ */
610
+ reconcileDescriptor(descriptor, audit) {
611
+ return this.#mutate(() => {
612
+ // Per-item settlement: the descriptor's audit is a concurrent snapshot that
613
+ // may be stale for OTHER items, so reconcile only this item's own records.
614
+ this.#reconcileAuditInto(audit, { fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
615
+ const prepared = this.journal.issuances.find(
616
+ (entry) => entry.dispatchToken === descriptor.dispatchToken,
617
+ );
618
+ if (prepared?.state === 'prepared') {
619
+ return { action: 'report', envelope: structuredClone(prepared.envelope), issuance: prepared };
620
+ }
621
+ if (prepared?.state === 'accepted') {
622
+ return { action: 'accepted', issuance: prepared };
623
+ }
624
+
625
+ if (isNoneIsolation(descriptor)) {
626
+ // In-cwd execution: no worktree, no pre-stage witness, no diff. A crash
627
+ // before the prepared envelope simply re-executes in the target on retry
628
+ // (Python parity), and the envelope journaling below still guards a crash
629
+ // between prepare and step_done.
630
+ return { action: 'execute', worktree: this.targetCwd, witness: null, isolation: 'none' };
631
+ }
632
+
633
+ const worktree = this.#ensureWorktree(descriptor);
634
+ const witness = this.journal.witnesses.find(
635
+ (entry) => entry.dispatchToken === descriptor.dispatchToken,
636
+ );
637
+ if (witness) {
638
+ restoreWorkingTree(worktree.path, witness.witnessTree);
639
+ witness.restoredAt = now();
640
+ witness.restoreCount = (witness.restoreCount ?? 0) + 1;
641
+ return { action: 'execute', worktree: worktree.path, witness, restored: true };
642
+ }
643
+
644
+ const newWitness = {
645
+ dispatchToken: descriptor.dispatchToken,
646
+ scopedId: descriptor.id,
647
+ fanoutStepId: descriptor.step,
648
+ itemIndex: descriptor.itemIndex,
649
+ generation: descriptor.generation,
650
+ stage: descriptor.stage,
651
+ attempt: descriptor.attempt,
652
+ worktreeKey: worktree.key,
653
+ witnessTree: snapshotWorkingTree(worktree.path),
654
+ createdAt: now(),
655
+ };
656
+ this.journal.witnesses.push(newWitness);
657
+ return { action: 'execute', worktree: worktree.path, witness: newWitness, restored: false };
658
+ });
659
+ }
660
+
661
+ /**
662
+ * Restore a still-ready issuance's worktree to its pre-stage witness. Used when
663
+ * the agent/connector threw mid-stage: the retry (or the failure report) must
664
+ * start from the clean pre-stage snapshot, not a half-mutated worktree.
665
+ * Returns false when there is nothing to restore (no worktree or no witness).
666
+ */
667
+ restoreToPreStageWitness(descriptor) {
668
+ return this.#mutate(() => {
669
+ const worktree = this.#worktreeFor(descriptor);
670
+ const witness = this.journal.witnesses.find(
671
+ (entry) => entry.dispatchToken === descriptor.dispatchToken,
672
+ );
673
+ if (!worktree || !witness || !existsSync(worktree.path)) return false;
674
+ restoreWorkingTree(worktree.path, witness.witnessTree);
675
+ witness.restoredAt = now();
676
+ witness.restoreCount = (witness.restoreCount ?? 0) + 1;
677
+ return true;
678
+ });
679
+ }
680
+
681
+ prepareIssuance(descriptor, envelope, { finalStage }) {
682
+ return this.#mutate(() => {
683
+ const existing = this.journal.issuances.find(
684
+ (entry) => entry.dispatchToken === descriptor.dispatchToken,
685
+ );
686
+ if (existing) return existing;
687
+
688
+ if (isNoneIsolation(descriptor)) {
689
+ // Envelope-only journal entry: no worktree, no witness, no diff. It never
690
+ // participates in the merge (prepareMerge filters isolation:none out), so
691
+ // its files persist directly in the target cwd.
692
+ const entry = {
693
+ dispatchToken: descriptor.dispatchToken,
694
+ scopedId: descriptor.id,
695
+ fanoutStepId: descriptor.step,
696
+ itemIndex: descriptor.itemIndex,
697
+ generation: descriptor.generation,
698
+ stage: descriptor.stage,
699
+ attempt: descriptor.attempt,
700
+ revisionDigest: descriptor.revisionDigest,
701
+ contractDigest: descriptor.contractDigest,
702
+ isolation: 'none',
703
+ worktreeKey: null,
704
+ witnessTree: null,
705
+ state: 'prepared',
706
+ envelope: structuredClone(envelope),
707
+ diff: null,
708
+ hadCumulativeDiff: false,
709
+ diffDigest: null,
710
+ finalStage: finalStage === true,
711
+ preparedAt: now(),
712
+ };
713
+ this.journal.issuances.push(entry);
714
+ return entry;
715
+ }
716
+
717
+ const worktree = this.#worktreeFor(descriptor);
718
+ const witness = this.journal.witnesses.find(
719
+ (entry) => entry.dispatchToken === descriptor.dispatchToken,
720
+ );
721
+ if (!worktree || !witness) {
722
+ throw new ConsumerArtifactError(
723
+ 'ISSUANCE_WITNESS_MISSING',
724
+ `cannot prepare ${descriptor.id}: durable pre-stage witness is missing`,
725
+ );
726
+ }
727
+ const diff = finalStage ? cumulativeDiff(worktree.path, worktree.baseCommit) : null;
728
+ const entry = {
729
+ dispatchToken: descriptor.dispatchToken,
730
+ scopedId: descriptor.id,
731
+ fanoutStepId: descriptor.step,
732
+ itemIndex: descriptor.itemIndex,
733
+ generation: descriptor.generation,
734
+ stage: descriptor.stage,
735
+ attempt: descriptor.attempt,
736
+ revisionDigest: descriptor.revisionDigest,
737
+ contractDigest: descriptor.contractDigest,
738
+ isolation: 'worktree',
739
+ worktreeKey: worktree.key,
740
+ witnessTree: witness.witnessTree,
741
+ state: 'prepared',
742
+ envelope: structuredClone(envelope),
743
+ diff,
744
+ hadCumulativeDiff: diff !== null,
745
+ diffDigest: diff === null ? null : sha256(diff),
746
+ preparedAt: now(),
747
+ };
748
+ this.journal.issuances.push(entry);
749
+ return entry;
750
+ });
751
+ }
752
+
753
+ prepareArtifactFailure(descriptor, envelope, error) {
754
+ return this.#mutate(() => {
755
+ const existing = this.journal.issuances.find(
756
+ (entry) => entry.dispatchToken === descriptor.dispatchToken,
757
+ );
758
+ if (existing) return existing;
759
+ const entry = {
760
+ dispatchToken: descriptor.dispatchToken,
761
+ scopedId: descriptor.id,
762
+ fanoutStepId: descriptor.step,
763
+ itemIndex: descriptor.itemIndex,
764
+ generation: descriptor.generation,
765
+ stage: descriptor.stage,
766
+ attempt: descriptor.attempt,
767
+ revisionDigest: descriptor.revisionDigest,
768
+ contractDigest: descriptor.contractDigest,
769
+ worktreeKey: worktreeKey(descriptor),
770
+ witnessTree: null,
771
+ state: 'prepared',
772
+ envelope: structuredClone(envelope),
773
+ diff: null,
774
+ hadCumulativeDiff: false,
775
+ diffDigest: null,
776
+ artifactFailure: { code: error.code, message: error.message },
777
+ preparedAt: now(),
778
+ };
779
+ this.journal.issuances.push(entry);
780
+ return entry;
781
+ });
782
+ }
783
+
784
+ acceptedEntriesFor(fanoutStepId) {
785
+ return this.journal.issuances
786
+ .filter((entry) => entry.fanoutStepId === fanoutStepId && entry.state === 'accepted')
787
+ .sort((a, b) => a.itemIndex - b.itemIndex
788
+ || a.scopedId.localeCompare(b.scopedId)
789
+ || a.generation - b.generation);
790
+ }
791
+
792
+ prepareMerge({ gateStepId, gateToken, fanoutStepId, audit }) {
793
+ return this.#mutate(() => {
794
+ this.#reconcileAuditInto(audit, null);
795
+ const existing = this.journal.mergeTransactions.find((entry) => entry.gateToken === gateToken);
796
+ if (existing?.state === 'blocked') {
797
+ throw new ConsumerMergeDecisionError(
798
+ existing.failureCode ?? 'MERGE_TRANSACTION_BLOCKED',
799
+ existing.failure ?? 'consumer merge transaction is blocked',
800
+ { gateToken },
801
+ );
802
+ }
803
+ if (existing) return existing;
804
+
805
+ const accepted = this.acceptedEntriesFor(fanoutStepId);
806
+ // Only isolation:worktree items own a diff and participate in the merge.
807
+ // isolation:none items already wrote in the target cwd; they are accepted
808
+ // evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
809
+ const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
810
+ const baselineTree = snapshotWorkingTree(this.targetCwd);
811
+ const orderedDiffs = worktreeAccepted.map((entry) => ({
812
+ dispatchToken: entry.dispatchToken,
813
+ scopedId: entry.scopedId,
814
+ itemIndex: entry.itemIndex,
815
+ generation: entry.generation,
816
+ digest: entry.diffDigest,
817
+ diff: entry.diff,
818
+ }));
819
+ const recordBlocked = (code, message, witnessChain = [baselineTree]) => {
820
+ const transaction = {
821
+ gateStepId,
822
+ gateToken,
823
+ fanoutStepId,
824
+ state: 'blocked',
825
+ baselineTree,
826
+ witnessChain,
827
+ orderedDiffs,
828
+ recovery: { baselineRestores: 0 },
829
+ failureCode: code,
830
+ failure: `${code}: ${message}`,
831
+ preparedAt: now(),
832
+ };
833
+ this.journal.mergeTransactions.push(transaction);
834
+ this.#save();
835
+ return transaction;
836
+ };
837
+ const auditItems = audit?.steps?.[fanoutStepId]?.fanout?.items ?? [];
838
+ const succeeded = auditItems.filter((item) => item?.status === 'succeeded');
839
+ // Every succeeded item must have an accepted issuance; only worktree items
840
+ // must additionally carry a captured diff.
841
+ if (accepted.length !== succeeded.length || worktreeAccepted.some((entry) => entry.diff === null)) {
842
+ const detail = `merge gate ${gateStepId} has ${succeeded.length} succeeded items but `
843
+ + `${accepted.length} accepted (${worktreeAccepted.length} worktree diffs)`;
844
+ recordBlocked('ACCEPTED_ARTIFACTS_INCOMPLETE', detail);
845
+ throw new ConsumerMergeDecisionError(
846
+ 'ACCEPTED_ARTIFACTS_INCOMPLETE', detail, { gateStepId, fanoutStepId },
847
+ );
848
+ }
849
+
850
+ let witnessChain;
851
+ try {
852
+ witnessChain = computeWitnessChain(this.targetCwd, worktreeAccepted);
853
+ } catch (error) {
854
+ recordBlocked(
855
+ 'MERGE_WITNESS_PRECOMPUTE_FAILED',
856
+ `consumer merge witness precompute failed: ${error.message}`,
857
+ );
858
+ throw new ConsumerMergeDecisionError(
859
+ 'MERGE_WITNESS_PRECOMPUTE_FAILED',
860
+ `consumer merge witness precompute failed: ${error.message}`,
861
+ { cause: error },
862
+ );
863
+ }
864
+ if (witnessChain[0] !== baselineTree) {
865
+ recordBlocked(
866
+ 'MERGE_BASELINE_CHANGED_DURING_PRECOMPUTE',
867
+ `consumer merge baseline changed from ${baselineTree} to ${witnessChain[0]}`,
868
+ witnessChain,
869
+ );
870
+ throw new ConsumerMergeDecisionError(
871
+ 'MERGE_BASELINE_CHANGED_DURING_PRECOMPUTE',
872
+ 'consumer merge baseline changed during witness precompute',
873
+ { witnessChain },
874
+ );
875
+ }
876
+ if (new Set(witnessChain).size !== witnessChain.length) {
877
+ recordBlocked(
878
+ 'MERGE_WITNESS_NOT_UNIQUE',
879
+ 'consumer merge aborted: expected tree-witness chain is not unique',
880
+ witnessChain,
881
+ );
882
+ throw new ConsumerMergeDecisionError(
883
+ 'MERGE_WITNESS_NOT_UNIQUE',
884
+ 'consumer merge aborted: expected tree-witness chain is not unique',
885
+ { witnessChain },
886
+ );
887
+ }
888
+
889
+ const transaction = {
890
+ gateStepId,
891
+ gateToken,
892
+ fanoutStepId,
893
+ state: 'prepared',
894
+ baselineTree,
895
+ witnessChain,
896
+ orderedDiffs,
897
+ recovery: { baselineRestores: 0 },
898
+ preparedAt: now(),
899
+ };
900
+ this.journal.mergeTransactions.push(transaction);
901
+ // One durable record contains the entire token/order/diff/witness chain
902
+ // before any target working-tree mutation occurs.
903
+ return transaction;
904
+ });
905
+ }
906
+
907
+ /** A merge round is "decided" once another party has recorded its gate outcome
908
+ * or rolled its baseline back. applyMerge must never write over that. */
909
+ #assertMergeNotDecided(tx, gateToken) {
910
+ if (tx.gateOutcome !== undefined || tx.state === 'rolled_back') {
911
+ throw new ConsumerMergeDecisionError(
912
+ 'MERGE_TRANSACTION_DECIDED',
913
+ `consumer merge ${gateToken} was already resolved (${tx.gateOutcome ?? tx.state}) `
914
+ + `by another party; refusing to apply/record over a decided round`,
915
+ { gateToken, gateOutcome: tx.gateOutcome, state: tx.state },
916
+ );
917
+ }
918
+ }
919
+
920
+ #currentTransaction(gateToken) {
921
+ if (existsSync(this.journalPath)) this.journal = this.#reload();
922
+ return this.journal.mergeTransactions.find((entry) => entry.gateToken === gateToken);
923
+ }
924
+
925
+ async applyMerge(transaction) {
926
+ const gateToken = transaction.gateToken;
927
+ let current = this.#currentTransaction(gateToken);
928
+ if (!current) {
929
+ throw new ConsumerMergeDecisionError(
930
+ 'MERGE_TRANSACTION_MISSING', `consumer merge transaction ${gateToken} is missing`, { gateToken },
931
+ );
932
+ }
933
+ if (current.state === 'blocked') {
934
+ throw new ConsumerMergeDecisionError(
935
+ current.failureCode ?? 'MERGE_TRANSACTION_BLOCKED',
936
+ current.failure ?? 'consumer merge transaction is blocked', { gateToken },
937
+ );
938
+ }
939
+ this.#assertMergeNotDecided(current, gateToken);
940
+ // The plan (baseline, witness chain, ordered diffs) is immutable after
941
+ // prepareMerge; the mutable lifecycle state is re-read from the fresh journal
942
+ // at every write below so applyMerge never writes over a concurrent decision.
943
+ const { witnessChain, baselineTree, orderedDiffs } = current;
944
+
945
+ if (current.state === 'complete') {
946
+ const completedTree = snapshotWorkingTree(this.targetCwd);
947
+ if (completedTree === witnessChain.at(-1)) return current;
948
+ // A completed journal with a non-final working tree means the process died
949
+ // before gate resolution and the target changed afterward. Re-enter the same
950
+ // prefix/unmatched recovery algorithm instead of trusting state.
951
+ this.#mutate(() => {
952
+ const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
953
+ if (!tx) return;
954
+ this.#assertMergeNotDecided(tx, gateToken);
955
+ tx.state = 'prepared';
956
+ tx.recovery ??= { baselineRestores: 0 };
957
+ tx.recovery.completedTreeMismatch = completedTree;
958
+ tx.recovery.completedTreeMismatchAt = now();
959
+ });
960
+ }
961
+ let currentTree = snapshotWorkingTree(this.targetCwd);
962
+ let prefix = witnessChain.indexOf(currentTree);
963
+ if (prefix < 0) {
964
+ restoreWorkingTree(this.targetCwd, baselineTree);
965
+ this.#mutate(() => {
966
+ const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
967
+ if (!tx) return;
968
+ this.#assertMergeNotDecided(tx, gateToken);
969
+ tx.recovery ??= { baselineRestores: 0 };
970
+ tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
971
+ tx.recovery.lastUnmatchedTree = currentTree;
972
+ tx.recovery.lastRestoredAt = now();
973
+ });
974
+ currentTree = snapshotWorkingTree(this.targetCwd);
975
+ if (currentTree !== baselineTree) {
976
+ throw new ConsumerMergeDecisionError(
977
+ 'MERGE_BASELINE_RESTORE_FAILED',
978
+ `consumer merge baseline verification failed: expected ${baselineTree}, got ${currentTree}`,
979
+ );
980
+ }
981
+ prefix = 0;
982
+ }
983
+
984
+ for (let orderedIndex = prefix; orderedIndex < orderedDiffs.length; orderedIndex += 1) {
985
+ const ordered = orderedDiffs[orderedIndex];
986
+ try {
987
+ if (typeof this.hooks.insideDiffApply === 'function') {
988
+ await this.hooks.insideDiffApply({
989
+ cwd: this.targetCwd,
990
+ orderedIndex,
991
+ orderedDiff: structuredClone(ordered),
992
+ transaction: structuredClone(current),
993
+ });
994
+ }
995
+ // Re-read the transaction fresh and refuse if the round was decided
996
+ // (rolled back / gate resolved) by another party — checked AFTER the await
997
+ // above and immediately BEFORE mutating the working tree, so a stale diff is
998
+ // NEVER applied over a decided round (which the gate rollback would then
999
+ // clobber). In-process this is fully sealed: production applyMerge has no
1000
+ // awaits between this check and the apply. Two truly concurrent PROCESSES
1001
+ // retain an inherent TOCTOU window here — there is no OS-level journal lock;
1002
+ // cross-process concurrent merge relies on the run's single-owner assumption,
1003
+ // not a lockfile in this slice.
1004
+ const live = this.#currentTransaction(gateToken);
1005
+ if (live) this.#assertMergeNotDecided(live, gateToken);
1006
+ git(this.targetCwd, ['apply', '--binary', '-'], { input: ordered.diff, timeout: 60_000 });
1007
+ const landedTree = snapshotWorkingTree(this.targetCwd);
1008
+ const expectedTree = witnessChain[orderedIndex + 1];
1009
+ if (landedTree !== expectedTree) {
1010
+ throw new ConsumerArtifactError(
1011
+ 'MERGE_PREFIX_WITNESS_MISMATCH',
1012
+ `consumer diff ${orderedIndex} produced ${landedTree}, expected ${expectedTree}`,
1013
+ );
1014
+ }
1015
+ } catch (error) {
1016
+ // An injected crash represents abrupt process death: preserve the partial
1017
+ // target exactly so a fresh loop must exercise unmatched-tree recovery.
1018
+ if (error?.code === 'INJECTED_CONSUMER_CRASH') throw error;
1019
+ // The pre-apply DECIDED guard throws before touching the tree; propagate it
1020
+ // as-is (no baseline restore / block) rather than mislabeling it as an
1021
+ // apply failure over a round another party already owns.
1022
+ if (error instanceof ConsumerMergeDecisionError) throw error;
1023
+ restoreWorkingTree(this.targetCwd, baselineTree);
1024
+ this.#mutate(() => {
1025
+ const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
1026
+ // Never overwrite a concurrent decision, even on the error path.
1027
+ if (!tx || tx.gateOutcome !== undefined || tx.state === 'rolled_back') return;
1028
+ tx.state = 'blocked';
1029
+ tx.failure = error.message;
1030
+ tx.recovery ??= { baselineRestores: 0 };
1031
+ tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
1032
+ tx.recovery.lastRestoredAt = now();
1033
+ });
1034
+ throw new ConsumerMergeDecisionError(
1035
+ 'MERGE_APPLY_FAILED',
1036
+ `consumer merge apply failed and baseline was restored: ${error.message}`,
1037
+ { cause: error },
1038
+ );
1039
+ }
1040
+ }
1041
+
1042
+ const finalTree = snapshotWorkingTree(this.targetCwd);
1043
+ const expectedFinal = witnessChain.at(-1);
1044
+ if (finalTree !== expectedFinal) {
1045
+ throw new ConsumerMergeDecisionError(
1046
+ 'MERGE_FINAL_WITNESS_MISMATCH',
1047
+ `consumer merge final tree ${finalTree} does not match ${expectedFinal}`,
1048
+ );
1049
+ }
1050
+ return this.#mutate(() => {
1051
+ const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
1052
+ if (!tx) {
1053
+ throw new ConsumerMergeDecisionError(
1054
+ 'MERGE_TRANSACTION_MISSING', `consumer merge transaction ${gateToken} is missing`, { gateToken },
1055
+ );
1056
+ }
1057
+ // STOP rather than record a merge over a round decided since we started.
1058
+ this.#assertMergeNotDecided(tx, gateToken);
1059
+ tx.state = 'complete';
1060
+ tx.completedAt = now();
1061
+ for (const ordered of orderedDiffs) {
1062
+ const issuance = this.journal.issuances.find(
1063
+ (entry) => entry.dispatchToken === ordered.dispatchToken,
1064
+ );
1065
+ if (issuance && issuance.state === 'accepted') {
1066
+ issuance.state = 'merged';
1067
+ issuance.mergedAt = now();
1068
+ }
1069
+ }
1070
+ return tx;
1071
+ });
1072
+ }
1073
+
1074
+ restoreMergeBaseline(transaction, audit) {
1075
+ if (!transaction) return undefined;
1076
+ return this.#mutate(() => {
1077
+ // Re-find against the fresh journal — the passed reference may predate a
1078
+ // concurrent instance's writes; we mutate what is CURRENTLY on disk.
1079
+ const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === transaction.gateToken);
1080
+ if (!tx) return undefined;
1081
+ const wasBlocked = tx.state === 'blocked';
1082
+ restoreWorkingTree(this.targetCwd, tx.baselineTree);
1083
+ tx.state = wasBlocked ? 'blocked' : 'rolled_back';
1084
+ tx.rolledBackAt = now();
1085
+ tx.recovery ??= { baselineRestores: 0 };
1086
+ tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
1087
+ tx.recovery.baselineVerifiedTree = tx.baselineTree;
1088
+ tx.recovery.baselineVerifiedAt = now();
1089
+ // Rolling back un-applies this transaction's diffs from the target, so any
1090
+ // issuance it marked `merged` (a crash between applyMerge and gateResolve) is
1091
+ // no longer merged. Restore merge eligibility (`merged` -> `accepted`) so a
1092
+ // non-re-enumerating repair round can merge it again — BUT reconcile each
1093
+ // restored entry against the CURRENT audit (ground truth). An entry whose
1094
+ // item index no longer exists in the current generation, or whose generation
1095
+ // or accepted token is stale, is superseded, NEVER resurrected as an accepted
1096
+ // diff. Without the audit the entry is only restored if it was mid-flight.
1097
+ for (const ordered of tx.orderedDiffs ?? []) {
1098
+ const issuance = this.journal.issuances.find(
1099
+ (entry) => entry.dispatchToken === ordered.dispatchToken,
1100
+ );
1101
+ if (!issuance || issuance.state !== 'merged') continue;
1102
+ const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
1103
+ const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
1104
+ const stale = Array.isArray(fanoutItems) && (
1105
+ !item
1106
+ || item.generation !== issuance.generation
1107
+ || (item.status === 'succeeded' && item.acceptedDispatchToken !== issuance.dispatchToken)
1108
+ || (typeof item.dispatchToken === 'string' && item.dispatchToken !== issuance.dispatchToken)
1109
+ );
1110
+ if (stale) {
1111
+ issuance.state = 'superseded';
1112
+ issuance.supersededAt = now();
1113
+ delete issuance.mergedAt;
1114
+ } else {
1115
+ issuance.state = 'accepted';
1116
+ delete issuance.mergedAt;
1117
+ }
1118
+ }
1119
+ return tx;
1120
+ });
1121
+ }
1122
+
1123
+ markGateResolved(transaction, outcome) {
1124
+ if (!transaction) return undefined;
1125
+ return this.#mutate(() => {
1126
+ const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === transaction.gateToken);
1127
+ if (!tx) return undefined;
1128
+ tx.gateOutcome = outcome;
1129
+ tx.gateResolvedAt = now();
1130
+ // Supersession is driven by re-enumeration, NOT by the gate outcome (see F1).
1131
+ return tx;
1132
+ });
1133
+ }
1134
+
1135
+ /** Recovery-only: record that a completed transaction's gate advanced (approve)
1136
+ * without re-running its merge, expressed as a change to the fresh journal. */
1137
+ markGateAdvanced({ gateToken, outcome, at }) {
1138
+ return this.#mutate(() => {
1139
+ const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === gateToken);
1140
+ if (!tx) return undefined;
1141
+ tx.gateOutcome = outcome;
1142
+ tx.gateResolvedAt ??= at ?? now();
1143
+ return tx;
1144
+ });
1145
+ }
1146
+
1147
+ cleanupWorktrees(reason) {
1148
+ this.#mutate(() => {
1149
+ for (const record of this.journal.worktrees) {
1150
+ if (record.cleanedAt) continue;
1151
+ if (existsSync(record.path)) {
1152
+ try {
1153
+ git(this.targetCwd, ['worktree', 'remove', '--force', record.path], { timeout: 60_000 });
1154
+ } catch {
1155
+ rmSync(record.path, { recursive: true, force: true });
1156
+ try { git(this.targetCwd, ['worktree', 'prune']); } catch { /* best effort */ }
1157
+ }
1158
+ }
1159
+ record.cleanedAt = now();
1160
+ record.cleanupReason = reason;
1161
+ }
1162
+ for (const issuance of this.journal.issuances) {
1163
+ if (typeof issuance.diff === 'string') {
1164
+ issuance.diff = null;
1165
+ issuance.diffDroppedAt = now();
1166
+ }
1167
+ }
1168
+ for (const transaction of this.journal.mergeTransactions) {
1169
+ for (const ordered of transaction.orderedDiffs ?? []) {
1170
+ if (typeof ordered.diff === 'string') ordered.diff = null;
1171
+ }
1172
+ if (transaction.orderedDiffs?.length > 0 && !transaction.diffPayloadsDroppedAt) {
1173
+ transaction.diffPayloadsDroppedAt = now();
1174
+ }
1175
+ }
1176
+ });
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * Resume-time revision guard. Before Compose trusts its (mutable) local pipeline
1182
+ * spec to detect final stages or discover merge-gate ownership, verify the spec
1183
+ * still describes the run being resumed. A spec edited between crash and resume
1184
+ * could otherwise approve the fanout's merge gate WITHOUT applying the accepted
1185
+ * diffs, or exit the pump with diffs stranded.
1186
+ *
1187
+ * Fails loudly (never reconciles, never resolves a gate) on any mismatch:
1188
+ * - the journal holds work but is MISSING its revision pins (fail-closed: a
1189
+ * journal that recorded issuances/worktrees/merges without pins cannot be
1190
+ * verified against the local spec, so it must not be trusted);
1191
+ * - the local-spec fingerprint differs from the one pinned at run start;
1192
+ * - the engine's resume-response revision differs from the journaled revision;
1193
+ * - a journal entry carries a revision other than the run's.
1194
+ *
1195
+ * Returns null (no-op) when the run has no consumer journal, or when the journal
1196
+ * is empty AND unpinned — a pre-first-issuance artifact with nothing staked on
1197
+ * it, safe to let the run recreate and re-pin (new code pins at creation, so this
1198
+ * only covers legacy journals).
1199
+ */
1200
+ export function verifyConsumerRunRevision({
1201
+ runId,
1202
+ targetCwd,
1203
+ artifactRoot,
1204
+ specDigest,
1205
+ resumeRevisionDigest,
1206
+ }) {
1207
+ if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return null;
1208
+ const { journal } = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
1209
+
1210
+ const fullyPinned = Boolean(journal.revisionDigest) && Boolean(journal.specDigest);
1211
+ if (!fullyPinned) {
1212
+ const hasWork = journal.issuances.length > 0
1213
+ || journal.worktrees.length > 0
1214
+ || journal.mergeTransactions.length > 0;
1215
+ if (hasWork) {
1216
+ throw new ConsumerArtifactError(
1217
+ 'CONSUMER_RUN_REVISION_MISMATCH',
1218
+ `consumer journal for run ${runId} recorded work but is missing its revision pins `
1219
+ + `(revisionDigest=${journal.revisionDigest ?? 'null'}, specDigest=${journal.specDigest ?? 'null'}) `
1220
+ + `- cannot verify the local spec still describes it`,
1221
+ { runId, revisionDigest: journal.revisionDigest, specDigest: journal.specDigest, hasWork: true },
1222
+ );
1223
+ }
1224
+ return null;
1225
+ }
1226
+
1227
+ if (journal.specDigest && specDigest && journal.specDigest !== specDigest) {
1228
+ throw new ConsumerArtifactError(
1229
+ 'CONSUMER_RUN_REVISION_MISMATCH',
1230
+ `consumer pipeline spec changed since run ${runId} started: `
1231
+ + `recorded specDigest ${journal.specDigest}, current ${specDigest}`
1232
+ + (journal.revisionDigest ? ` (engine revision ${journal.revisionDigest})` : ''),
1233
+ {
1234
+ runId,
1235
+ recordedSpecDigest: journal.specDigest,
1236
+ currentSpecDigest: specDigest,
1237
+ revisionDigest: journal.revisionDigest,
1238
+ },
1239
+ );
1240
+ }
1241
+
1242
+ if (journal.revisionDigest && resumeRevisionDigest && journal.revisionDigest !== resumeRevisionDigest) {
1243
+ throw new ConsumerArtifactError(
1244
+ 'CONSUMER_RUN_REVISION_MISMATCH',
1245
+ `consumer engine revision changed since run ${runId} started: `
1246
+ + `journaled ${journal.revisionDigest}, resume ${resumeRevisionDigest}`,
1247
+ { runId, journaledRevisionDigest: journal.revisionDigest, resumeRevisionDigest },
1248
+ );
1249
+ }
1250
+
1251
+ const stray = journal.issuances.find(
1252
+ (entry) => entry.revisionDigest
1253
+ && journal.revisionDigest
1254
+ && entry.revisionDigest !== journal.revisionDigest,
1255
+ );
1256
+ if (stray) {
1257
+ throw new ConsumerArtifactError(
1258
+ 'CONSUMER_RUN_REVISION_MISMATCH',
1259
+ `consumer journal entry ${stray.dispatchToken} carries revision ${stray.revisionDigest}, `
1260
+ + `not the run revision ${journal.revisionDigest}`,
1261
+ { runId, entryRevisionDigest: stray.revisionDigest, revisionDigest: journal.revisionDigest },
1262
+ );
1263
+ }
1264
+
1265
+ return journal;
1266
+ }
1267
+
1268
+ /**
1269
+ * Resume-time cleanup for a process that died after gateResolve durably
1270
+ * advanced the engine but before Compose marked/released local artifacts.
1271
+ * Returns false without creating anything when the run has no consumer journal.
1272
+ */
1273
+ export function recoverAdvancedConsumerArtifacts({
1274
+ runId,
1275
+ targetCwd,
1276
+ artifactRoot,
1277
+ audit,
1278
+ }) {
1279
+ if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return false;
1280
+ const artifacts = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
1281
+ let recoveredDecision = false;
1282
+ let approvedAdvanced = false;
1283
+ const gateEventOffsets = new Map();
1284
+ const gateEvents = (audit?.events ?? []).filter((event) => event?.type === 'gate_resolved');
1285
+ const terminal = ['completed', 'failed', 'budget_exhausted', 'killed', 'cancelled'].includes(audit?.status);
1286
+ for (const transaction of artifacts.journal.mergeTransactions) {
1287
+ const gateState = audit?.steps?.[transaction.gateStepId];
1288
+ const matchingGateEvents = gateEvents.filter(
1289
+ (event) => event.stepId === transaction.gateStepId,
1290
+ );
1291
+ const eventOffset = gateEventOffsets.get(transaction.gateStepId) ?? 0;
1292
+ const resolvedEvent = matchingGateEvents[eventOffset];
1293
+ gateEventOffsets.set(transaction.gateStepId, eventOffset + 1);
1294
+ const resolvedOutcome = transaction.gateOutcome ?? resolvedEvent?.detail?.decision;
1295
+ const approvedComplete = transaction.state === 'complete' && resolvedOutcome === 'approve';
1296
+
1297
+ if (approvedComplete && gateState?.status !== 'waiting_gate') {
1298
+ artifacts.markGateAdvanced({ gateToken: transaction.gateToken, outcome: resolvedOutcome, at: resolvedEvent?.at });
1299
+ recoveredDecision = true;
1300
+ approvedAdvanced = true;
1301
+ } else if (!transaction.gateOutcome && (terminal || (resolvedOutcome && resolvedOutcome !== 'approve'))) {
1302
+ // Recovery may only roll back an UNRESOLVED transaction — the round that was
1303
+ // interrupted mid-finalization. A transaction that already carries a
1304
+ // gateOutcome was resolved in a PRIOR round; its rollback/merge is durable
1305
+ // history, and re-restoring its stale baseline here would clobber a later
1306
+ // approved merge (the current target). restoreMergeBaseline reconciles any
1307
+ // restored evidence against the current audit before it is trusted.
1308
+ artifacts.restoreMergeBaseline(transaction, audit);
1309
+ if (resolvedOutcome) artifacts.markGateResolved(transaction, resolvedOutcome);
1310
+ recoveredDecision = recoveredDecision || Boolean(resolvedOutcome);
1311
+ }
1312
+ }
1313
+ if (approvedAdvanced || terminal) {
1314
+ artifacts.cleanupWorktrees(approvedAdvanced ? 'merge gate advanced before restart' : 'run terminalized before restart');
1315
+ }
1316
+ return recoveredDecision || terminal;
1317
+ }