@smartmemory/compose 0.4.1 → 0.5.1

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 (127) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +15 -1
  5. package/bin/compose.js +57 -17
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/agent-string.js +9 -4
  61. package/lib/build-cancel.js +205 -0
  62. package/lib/build-stream-writer.js +6 -0
  63. package/lib/build.js +1189 -165
  64. package/lib/canon-guard.js +3 -24
  65. package/lib/canon-registry.js +2 -71
  66. package/lib/codex-preflight.js +8 -0
  67. package/lib/colleague/context.js +123 -0
  68. package/lib/consumer-fanout.js +427 -17
  69. package/lib/decision-blocks.js +38 -0
  70. package/lib/dispatch-ledger.js +7 -0
  71. package/lib/experiment-pricing.js +5 -1
  72. package/lib/flow-state.js +38 -0
  73. package/lib/fluid/factory.js +112 -1
  74. package/lib/fluid/ideabox-manifest.js +203 -0
  75. package/lib/fluid/ideabox-migrate.js +177 -29
  76. package/lib/fluid/ideabox-preamble.js +155 -0
  77. package/lib/fluid/ideabox-readable.js +83 -0
  78. package/lib/fluid/ideabox-recover.js +393 -0
  79. package/lib/fluid/import-ideabox.js +188 -45
  80. package/lib/fluid/local-provider.js +6 -0
  81. package/lib/fluid/portfolio.js +255 -0
  82. package/lib/fluid/record-shape.js +7 -0
  83. package/lib/fluid/render-ideabox.js +153 -7
  84. package/lib/fluid/smartmemory-provider.js +6 -0
  85. package/lib/gate-prompt.js +14 -7
  86. package/lib/gsd.js +95 -48
  87. package/lib/ideabox-cli.js +68 -0
  88. package/lib/ideabox.js +209 -9
  89. package/lib/maya-identity.js +16 -2
  90. package/lib/model-pricing.js +4 -1
  91. package/lib/output-gate.js +81 -0
  92. package/lib/pipeline-profiles.js +200 -0
  93. package/lib/process-termination.js +121 -3
  94. package/lib/receipts-gate.js +268 -0
  95. package/lib/result-normalizer.js +41 -1
  96. package/lib/smartmemory-client.js +68 -1
  97. package/lib/stratum-mcp-client.js +104 -5
  98. package/lib/team-flag.js +1 -1
  99. package/lib/tool-inventory.js +0 -1
  100. package/lib/version-check.js +9 -3
  101. package/lib/wave-checkpoint.js +100 -0
  102. package/package.json +7 -5
  103. package/presets/team-fable-astra.profiles.json +18 -0
  104. package/presets/team-fable-astra.stratum.yaml +236 -0
  105. package/server/build-stream-bridge.js +43 -1
  106. package/server/cc-session-watcher.js +54 -5
  107. package/server/compose-mcp-tools.js +48 -50
  108. package/server/compose-mcp.js +0 -2
  109. package/server/design-routes.js +1 -1
  110. package/server/file-watcher.js +14 -0
  111. package/server/ideabox-routes.js +10 -0
  112. package/server/index.js +5 -1
  113. package/server/lifecycle-guard.js +13 -0
  114. package/server/maya-routes.js +111 -7
  115. package/server/mcp-tool-defs.js +0 -25
  116. package/server/mcp-tool-policy.js +6 -13
  117. package/server/model-tiers.js +14 -6
  118. package/server/stratum-client.js +61 -15
  119. package/server/supervisor.js +18 -4
  120. package/server/vision-routes.js +9 -3
  121. package/dist/assets/channel-SnZzzh7k.js +0 -1
  122. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  123. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  124. package/dist/assets/clone-DgklGjHm.js +0 -1
  125. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  126. package/lib/append-integrity.js +0 -81
  127. package/lib/canon-override.js +0 -196
@@ -21,6 +21,8 @@ import { execFileSync } from 'node:child_process';
21
21
  import { createHash, randomUUID } from 'node:crypto';
22
22
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
23
23
  import { tmpdir } from 'node:os';
24
+ import { normalizeOwnedPath, profilesDigest as digestJson } from './pipeline-profiles.js';
25
+ import { worktreeBaseFor, readCheckpointRef, prepareCheckpoint, publishCheckpoint, reconcileCheckpoint, WaveCheckpointError } from './wave-checkpoint.js';
24
26
 
25
27
  const JOURNAL_VERSION = 1;
26
28
  let tempIndexSequence = 0;
@@ -96,7 +98,7 @@ function canonicalPath(path) {
96
98
  return resolve(existing, ...missing);
97
99
  }
98
100
 
99
- function withTemporaryIndex(cwd, fn) {
101
+ export function withTemporaryIndex(cwd, fn) {
100
102
  const indexPath = join(
101
103
  tmpdir(),
102
104
  `compose-consumer-index-${process.pid}-${tempIndexSequence++}-${randomUUID()}`,
@@ -143,6 +145,56 @@ function cumulativeDiff(cwd, baseCommit) {
143
145
  });
144
146
  }
145
147
 
148
+ /** Enumerate the exact retained patch, never the worker's reported files_changed. */
149
+ function retainedPatchEvidence(cwd, baseCommit, diff) {
150
+ return withTemporaryIndex(cwd, env => {
151
+ git(cwd, ['read-tree', baseCommit], { env });
152
+ if (diff) git(cwd, ['apply', '--cached', '--binary', '-'], { env, input: diff });
153
+ const capturedTree = git(cwd, ['write-tree'], { env });
154
+ // --no-renames gives delete/add endpoints; -z preserves whitespace and newlines.
155
+ const changedPaths = git(cwd, ['diff', '--cached', '--name-only', '-z', '--no-renames', baseCommit, '--'],
156
+ { env, trim: false }).split('\0').filter(Boolean);
157
+ return { baseCommit, capturedTree, changedPaths };
158
+ });
159
+ }
160
+ function checkOwnership(cwd, entry, { capture = false } = {}) {
161
+ if (!entry.ownership && !Object.hasOwn(entry.itemBinding?.item ?? {}, 'files_owned')) return null;
162
+ let allowedFiles = [];
163
+ let evidence;
164
+ let code = 'FILES_OWNED_VIOLATION';
165
+ let files = [];
166
+ try {
167
+ if (!entry.itemBinding || entry.itemBinding.itemDigest !== digestJson(entry.itemBinding.item)) {
168
+ code = 'OWNERSHIP_EVIDENCE_MISMATCH'; throw new Error('Item binding missing or digest differs');
169
+ }
170
+ const owned = entry.itemBinding?.item?.files_owned;
171
+ if (!Array.isArray(owned)) throw new Error('files_owned must be an array');
172
+ allowedFiles = owned.map(normalizeOwnedPath);
173
+ if (typeof entry.diff !== 'string' || entry.diffDigest !== sha256(entry.diff) || !entry.ownership?.baseCommit) {
174
+ code = 'OWNERSHIP_EVIDENCE_MISMATCH';
175
+ throw new Error('Retained patch/base/digest missing or changed');
176
+ }
177
+ evidence = retainedPatchEvidence(cwd, entry.ownership.baseCommit, entry.diff);
178
+ if (!capture && (evidence.capturedTree !== entry.ownership.capturedTree
179
+ || digestJson(evidence.changedPaths) !== digestJson(entry.ownership.changedPaths))) {
180
+ code = 'OWNERSHIP_EVIDENCE_MISMATCH';
181
+ throw new Error('Retained patch tree/paths differ from capture');
182
+ }
183
+ files = evidence.changedPaths.filter(file => !allowedFiles.includes(file));
184
+ if (!files.length) { if (capture) entry.ownership = evidence; return null; }
185
+ } catch (error) {
186
+ if (code !== 'FILES_OWNED_VIOLATION' || evidence === undefined && allowedFiles.length) code = 'OWNERSHIP_EVIDENCE_MISMATCH';
187
+ files = evidence?.changedPaths ?? [];
188
+ return { code, taskId: entry.itemBinding?.item?.id, stepId: entry.scopedId, itemIndex: entry.itemIndex,
189
+ generation: entry.generation, dispatchToken: entry.dispatchToken, severity: 'error', files, allowedFiles,
190
+ diffDigest: entry.diffDigest, message: error.message };
191
+ }
192
+ if (capture) entry.ownership = evidence;
193
+ return { code, taskId: entry.itemBinding?.item?.id, stepId: entry.scopedId, itemIndex: entry.itemIndex,
194
+ generation: entry.generation, dispatchToken: entry.dispatchToken, severity: 'error', files, allowedFiles,
195
+ diffDigest: entry.diffDigest, message: `Task ${entry.itemBinding?.item?.id ?? entry.scopedId} changed unowned paths: ${files.join(', ')}` };
196
+ }
197
+
146
198
  /**
147
199
  * Apply one lane's cumulative diff to a temporary index.
148
200
  *
@@ -286,8 +338,16 @@ export class ConsumerMergeDecisionError extends ConsumerArtifactError {
286
338
  }
287
339
  }
288
340
 
341
+ /** Cancellation must never enter the merge-repair/revise decision channel. */
342
+ export class MergeAfterCancelError extends ConsumerArtifactError {
343
+ constructor(message) {
344
+ super('MERGE_AFTER_CANCEL', message);
345
+ this.name = 'MergeAfterCancelError';
346
+ }
347
+ }
348
+
289
349
  export class ConsumerFanoutArtifacts {
290
- constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest }) {
350
+ constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest, profilesDigest }) {
291
351
  this.runId = runId;
292
352
  this.targetCwd = canonicalPath(targetCwd);
293
353
  this.artifactRoot = canonicalPath(artifactRoot ?? defaultArtifactRoot(this.targetCwd));
@@ -303,6 +363,7 @@ export class ConsumerFanoutArtifacts {
303
363
  // a crash between journal creation and the first bind cannot leave an
304
364
  // unpinned journal that a drifted spec could later re-pin as the truth.
305
365
  this._initialRevisionDigest = typeof revisionDigest === 'string' && revisionDigest.length > 0 ? revisionDigest : null;
366
+ this._initialProfilesDigest = profilesDigest;
306
367
  this._initialSpecDigest = typeof specDigest === 'string' && specDigest.length > 0 ? specDigest : null;
307
368
  this.runRoot = join(this.artifactRoot, runDirectoryName(runId));
308
369
  this.journalPath = join(this.runRoot, 'journal.json');
@@ -411,6 +472,15 @@ export class ConsumerFanoutArtifacts {
411
472
  );
412
473
  }
413
474
  }
475
+ if (this._initialProfilesDigest !== undefined) {
476
+ if (journal.profilesDigest !== undefined && journal.profilesDigest !== this._initialProfilesDigest) {
477
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles changed');
478
+ }
479
+ if (journal.profilesDigest === undefined && (journal.issuances.length || journal.wave)) {
480
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Cannot add profiles to an active legacy run');
481
+ }
482
+ journal.profilesDigest = this._initialProfilesDigest;
483
+ }
414
484
  return journal;
415
485
  }
416
486
 
@@ -418,6 +488,7 @@ export class ConsumerFanoutArtifacts {
418
488
  if (!existsSync(this.journalPath)) {
419
489
  const journal = {
420
490
  version: JOURNAL_VERSION,
491
+ ...(this._initialProfilesDigest !== undefined ? { profilesDigest: this._initialProfilesDigest } : {}),
421
492
  runId: this.runId,
422
493
  targetCwd: this.targetCwd,
423
494
  createdAt: now(),
@@ -444,7 +515,8 @@ export class ConsumerFanoutArtifacts {
444
515
  const before = readFileSync(this.journalPath, 'utf8');
445
516
  const journal = this.#reload();
446
517
  if ((this._initialRevisionDigest && journal.revisionDigest !== JSON.parse(before).revisionDigest)
447
- || (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)) {
518
+ || (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)
519
+ || (this._initialProfilesDigest !== undefined && journal.profilesDigest !== JSON.parse(before).profilesDigest)) {
448
520
  this.#writeGuarded(journal);
449
521
  }
450
522
  return journal;
@@ -531,7 +603,10 @@ export class ConsumerFanoutArtifacts {
531
603
  itemIndex: descriptor.itemIndex,
532
604
  generation: descriptor.generation,
533
605
  path,
534
- baseCommit: git(this.targetCwd, ['rev-parse', 'HEAD']),
606
+ baseCommit: this.journal.wave
607
+ ? (this.journal.waveAdmissions?.find(a => a.fanoutStepId === descriptor.step && a.epoch === (this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding.epoch ?? descriptor.epoch))?.baseCommit
608
+ ?? worktreeBaseFor({ journal: this.journal, ref: readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) }))
609
+ : git(this.targetCwd, ['rev-parse', 'HEAD']),
535
610
  status: 'creating',
536
611
  superseded: false,
537
612
  createdAt: now(),
@@ -562,8 +637,15 @@ export class ConsumerFanoutArtifacts {
562
637
  * First-write-wins; a later issuance carrying a DIFFERENT engine revision digest
563
638
  * is a mid-run revision the journal must reject rather than silently absorb.
564
639
  */
565
- bindRunRevision({ revisionDigest, specDigest }) {
640
+ bindRunRevision({ revisionDigest, specDigest, profilesDigest }) {
566
641
  return this.#mutate(() => {
642
+ if (profilesDigest !== undefined) {
643
+ if (this.journal.profilesDigest !== undefined && this.journal.profilesDigest !== profilesDigest
644
+ || this.journal.profilesDigest === undefined && (this.journal.issuances.length || this.journal.wave)) {
645
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles changed');
646
+ }
647
+ this.journal.profilesDigest = profilesDigest;
648
+ }
567
649
  if (typeof revisionDigest === 'string' && revisionDigest.length > 0) {
568
650
  if (this.journal.revisionDigest === null) {
569
651
  this.journal.revisionDigest = revisionDigest;
@@ -582,6 +664,236 @@ export class ConsumerFanoutArtifacts {
582
664
  });
583
665
  }
584
666
 
667
+ /** Additive v1 seam records: absent until explicitly enabled. */
668
+ recordWaveAdmission(admission) {
669
+ return this.#mutate(() => {
670
+ if (!Array.isArray(admission.items) || !admission.inputDigest || !admission.baseCommit) {
671
+ throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Admission needs full items, digest and pinned base');
672
+ }
673
+ if (this.journal.wave && !this.journal.waveAdmissions?.some(a => a.fanoutStepId === admission.fanoutStepId && a.epoch === admission.epoch)) {
674
+ const base = worktreeBaseFor({ journal: this.journal, ref: readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) });
675
+ if (base !== admission.baseCommit) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Admission base differs from published tip');
676
+ }
677
+ this.journal.waveAdmissions ??= [];
678
+ const previous = this.journal.waveAdmissions.find(a => a.fanoutStepId === admission.fanoutStepId && a.epoch === admission.epoch);
679
+ if (previous) {
680
+ if (digestJson({ ...previous, validatedAt: null }) !== digestJson({ ...admission, validatedAt: null })) {
681
+ throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Recorded wave admission changed');
682
+ }
683
+ return previous;
684
+ }
685
+ const record = { ...structuredClone(admission), validatedAt: admission.validatedAt ?? now() };
686
+ this.journal.waveAdmissions.push(record);
687
+ return record;
688
+ });
689
+ }
690
+
691
+ recordDispatchBinding({ dispatchToken, itemBinding, resolvedProfile }) {
692
+ return this.#mutate(() => {
693
+ if (this.journal.profilesDigest !== undefined && resolvedProfile?.profilesDigest !== this.journal.profilesDigest) {
694
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Dispatch profile is not pinned to this run');
695
+ }
696
+ if (itemBinding.itemDigest !== digestJson(itemBinding.item)) {
697
+ throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Dispatch item digest differs');
698
+ }
699
+ this.journal.dispatchBindings ??= {};
700
+ const binding = { itemBinding: structuredClone(itemBinding), resolvedProfile: structuredClone(resolvedProfile) };
701
+ const previous = this.journal.dispatchBindings[dispatchToken];
702
+ if (previous && digestJson(previous) !== digestJson(binding)) {
703
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Dispatch binding changed');
704
+ }
705
+ this.journal.dispatchBindings[dispatchToken] = binding;
706
+ return binding;
707
+ });
708
+ }
709
+
710
+ recordPendingUsageReceipt({ dispatchId, receipt }) {
711
+ return this.#mutate(() => {
712
+ this.journal.pendingUsageReceipts ??= [];
713
+ const existing = this.journal.pendingUsageReceipts.find(r => r.dispatchId === dispatchId);
714
+ if (existing) {
715
+ if (digestJson(existing.receipt) !== digestJson(receipt)) {
716
+ throw new ConsumerArtifactError('CONSUMER_EVIDENCE_MISMATCH', 'Receipt payload changed on replay');
717
+ }
718
+ return existing;
719
+ }
720
+ const record = { dispatchId, receipt: structuredClone(receipt), state: 'pending' };
721
+ this.journal.pendingUsageReceipts.push(record);
722
+ return record;
723
+ });
724
+ }
725
+
726
+ acknowledgeUsageReceipt({ dispatchId, seq }) {
727
+ return this.#mutate(() => {
728
+ const receipt = this.journal.pendingUsageReceipts?.find(r => r.dispatchId === dispatchId);
729
+ if (!receipt) throw new ConsumerArtifactError('CONSUMER_EVIDENCE_MISMATCH', 'Receipt intent is missing');
730
+ receipt.state = 'acknowledged';
731
+ if (seq !== undefined) receipt.seq = seq;
732
+ return receipt;
733
+ });
734
+ }
735
+
736
+ initializeWave({ ref, profilesDigest }) {
737
+ return this.#mutate(() => {
738
+ if (this.journal.wave) {
739
+ if (this.journal.wave.ref !== ref || this.journal.wave.profilesDigest !== profilesDigest) {
740
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Wave configuration changed');
741
+ }
742
+ return this.journal.wave;
743
+ }
744
+ if (readCheckpointRef({ cwd: this.targetCwd, ref }) !== null) {
745
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Pre-existing unowned wave ref');
746
+ }
747
+ if (existsSync(resolve(this.targetCwd, git(this.targetCwd, ['rev-parse', '--git-path', 'MERGE_HEAD'])))
748
+ || git(this.targetCwd, ['ls-files', '-u'])) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Unresolved index merge');
749
+ const baseCommit = git(this.targetCwd, ['rev-parse', 'HEAD']);
750
+ this.journal.wave = { ref, baseCommit, baseTree: git(this.targetCwd, ['rev-parse', 'HEAD^{tree}']),
751
+ initialWorkingTree: snapshotWorkingTree(this.targetCwd), profilesDigest, checkpoints: [] };
752
+ return this.journal.wave;
753
+ });
754
+ }
755
+
756
+ recordPreparedCheckpoint(checkpoint) {
757
+ return this.#mutate(() => {
758
+ const wave = this.journal.wave;
759
+ if (!wave) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Wave not initialized');
760
+ const existing = wave.checkpoints.find(c => c.gateToken === checkpoint.gateToken);
761
+ if (existing) {
762
+ if (existing.commit !== checkpoint.commit || existing.tree !== checkpoint.tree) {
763
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint token changed');
764
+ }
765
+ return existing;
766
+ }
767
+ const parent = wave.checkpoints.at(-1)?.commit ?? wave.baseCommit;
768
+ if (checkpoint.parentCommit !== parent || checkpoint.waveNumber !== wave.checkpoints.length + 1
769
+ || !checkpoint.gateToken || !Number.isInteger(checkpoint.gateOrdinal)) {
770
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint order or identity differs');
771
+ }
772
+ const record = { ...structuredClone(checkpoint), state: 'prepared', preparedAt: now() };
773
+ wave.checkpoints.push(record);
774
+ return record;
775
+ });
776
+ }
777
+
778
+ markCheckpointPublished({ gateToken, commit, evidenceReceiptId }) {
779
+ return this.#mutate(() => {
780
+ const checkpoint = this.journal.wave?.checkpoints.find(c => c.gateToken === gateToken);
781
+ if (!checkpoint || checkpoint.commit !== commit
782
+ || readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) !== commit) {
783
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Publication does not match prepared checkpoint');
784
+ }
785
+ this.#verifyCheckpointObject(checkpoint);
786
+ checkpoint.state = 'published';
787
+ checkpoint.publishedAt ??= now();
788
+ checkpoint.materializedTree ??= checkpoint.tree;
789
+ if (evidenceReceiptId) checkpoint.evidenceReceiptId = evidenceReceiptId;
790
+ return checkpoint;
791
+ });
792
+ }
793
+
794
+ /** Recover approved obligations only; never replay their patches into the live parent. */
795
+ recoverCheckpoint(transaction) {
796
+ const wave = this.journal.wave;
797
+ if (!wave) return;
798
+ let checkpoint = wave.checkpoints.find(c => c.gateToken === transaction.gateToken);
799
+ const tip = readCheckpointRef({ cwd: this.targetCwd, ref: wave.ref });
800
+ // A verified newer recorded tip must never be rewound to a historical checkpoint.
801
+ const index = checkpoint ? wave.checkpoints.indexOf(checkpoint) : -1;
802
+ if (checkpoint && (checkpoint.gateOrdinal !== transaction.gateOrdinal
803
+ || checkpoint.gateStepId !== transaction.gateStepId || checkpoint.tree !== transaction.witnessChain.at(-1)
804
+ || checkpoint.parentCommit !== transaction.checkpointParent)) {
805
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint differs from its approved merge obligation');
806
+ }
807
+ for (const [i, recorded] of wave.checkpoints.entries()) {
808
+ if (recorded.parentCommit !== (wave.checkpoints[i - 1]?.commit ?? wave.baseCommit)) {
809
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Recorded checkpoint chain is broken');
810
+ }
811
+ }
812
+ if (index >= 0 && wave.checkpoints.slice(index + 1).some(c => c.state === 'published' && c.commit === tip)) {
813
+ for (const recorded of wave.checkpoints.slice(index)) this.#verifyCheckpointObject(recorded);
814
+ return;
815
+ }
816
+ if (!checkpoint) {
817
+ const previous = wave.checkpoints.at(-1)?.commit ?? null;
818
+ if (reconcileCheckpoint({ journalEntry: { gateOutcome: 'approve', previousCommit: previous }, refValue: tip }) !== 'PREPARE_AND_PUBLISH') {
819
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Unexpected ref before preparation');
820
+ }
821
+ if (!Number.isInteger(transaction.gateOrdinal)) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Missing gate ordinal');
822
+ const tree = transaction.witnessChain.at(-1);
823
+ this.#verifyCheckpointReplay(transaction, tree);
824
+ this.#materializeCheckpoint(transaction, tree);
825
+ const prepared = prepareCheckpoint({ cwd: this.targetCwd, ref: wave.ref,
826
+ parentCommit: transaction.checkpointParent ?? previous ?? wave.baseCommit, tree,
827
+ message: `Compose wave ${wave.checkpoints.length + 1} run ${this.runId} gate ${transaction.gateToken}` });
828
+ checkpoint = this.recordPreparedCheckpoint({ ...prepared, waveNumber: wave.checkpoints.length + 1,
829
+ fanoutStepId: transaction.fanoutStepId, epoch: transaction.epoch, gateStepId: transaction.gateStepId,
830
+ gateToken: transaction.gateToken, gateOrdinal: transaction.gateOrdinal, baselineTree: transaction.baselineTree,
831
+ orderedDispatchTokens: transaction.acceptedDispatchTokens ?? transaction.orderedDiffs.map(d => d.dispatchToken) });
832
+ }
833
+ this.#verifyCheckpointObject(checkpoint);
834
+ const action = reconcileCheckpoint({ journalEntry: checkpoint, refValue: tip });
835
+ if (action === 'REPLAY_AND_PUBLISH') {
836
+ if (checkpoint.payloadsDroppedAt) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint payloads were already cleaned');
837
+ this.#materializeCheckpoint(transaction, checkpoint.tree);
838
+ this.#verifyCheckpointReplay(transaction, checkpoint.tree);
839
+ publishCheckpoint({ cwd: this.targetCwd, ref: wave.ref, expected: tip, commit: checkpoint.commit });
840
+ } else if (!['MARK_PUBLISHED', 'ALREADY_PUBLISHED'].includes(action)) {
841
+ throw new WaveCheckpointError(action, 'Cannot reconcile checkpoint ref');
842
+ } else if (transaction.witnessChain.slice(0, -1).includes(snapshotWorkingTree(this.targetCwd))) {
843
+ this.#materializeCheckpoint(transaction, checkpoint.tree);
844
+ }
845
+ this.markCheckpointPublished({ gateToken: checkpoint.gateToken, commit: checkpoint.commit });
846
+ }
847
+
848
+ #materializeCheckpoint(transaction, tree) {
849
+ const live = snapshotWorkingTree(this.targetCwd);
850
+ if (live === tree) return;
851
+ if (transaction.witnessChain.includes(live)) {
852
+ checkoutTreeDelta(this.targetCwd, live, tree);
853
+ return;
854
+ }
855
+ const changed = git(this.targetCwd, ['diff', '--name-only', '-z', '--no-renames', tree, live, '--'], { trim: false }).split('\0').filter(Boolean);
856
+ const wavePaths = new Set(git(this.targetCwd, ['diff', '--name-only', '-z', '--no-renames', transaction.baselineTree, tree, '--'],
857
+ { trim: false }).split('\0').filter(Boolean));
858
+ if (changed.some(path => wavePaths.has(path))) {
859
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Parent differs ambiguously from the integrated checkpoint');
860
+ }
861
+ // Independent post-wave edits are preserved; publication captures only the witness.
862
+ }
863
+
864
+ #verifyCheckpointReplay(transaction, expectedTree) {
865
+ try {
866
+ const tree = withTemporaryIndex(this.targetCwd, env => {
867
+ git(this.targetCwd, ['read-tree', transaction.baselineTree], { env });
868
+ for (const ordered of transaction.orderedDiffs) {
869
+ if (typeof ordered.diff !== 'string' || sha256(ordered.diff) !== ordered.digest) throw new Error('Missing or changed retained patch');
870
+ if (ordered.diff) applyDiffToIndex(this.targetCwd, ordered.diff, env);
871
+ }
872
+ return git(this.targetCwd, ['write-tree'], { env });
873
+ });
874
+ if (tree !== expectedTree) throw new Error('Replayed tree differs');
875
+ } catch (error) { throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', error.message); }
876
+ }
877
+
878
+ #verifyCheckpointObject(checkpoint) {
879
+ try {
880
+ const object = git(this.targetCwd, ['cat-file', 'commit', checkpoint.commit], { trim: false });
881
+ const boundary = object.indexOf('\n\n');
882
+ const headers = object.slice(0, boundary).split('\n');
883
+ const metadata = checkpoint.commitMetadata;
884
+ const epoch = Math.floor(Date.parse(metadata.date) / 1000);
885
+ const identity = `${metadata.authorName} <${metadata.authorEmail}> ${epoch} +0000`;
886
+ const expectedHeaders = [`tree ${checkpoint.tree}`, `parent ${checkpoint.parentCommit}`,
887
+ `author ${identity}`, `committer ${identity}`];
888
+ if (digestJson(headers) !== digestJson(expectedHeaders)
889
+ || object.slice(boundary + 2).replace(/\n$/, '') !== checkpoint.message.replace(/\n$/, '')) {
890
+ throw new Error('Checkpoint identity/tree/parent/message differs');
891
+ }
892
+ git(this.targetCwd, ['cat-file', '-e', `${checkpoint.parentCommit}^{commit}`]);
893
+ git(this.targetCwd, ['cat-file', '-e', `${checkpoint.tree}^{tree}`]);
894
+ } catch (error) { throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', error.message); }
895
+ }
896
+
585
897
  /** Record which fanout step a merge gate settles, so resume never re-derives it
586
898
  * from a mutable local spec that may have drifted. First-write-wins per gate. */
587
899
  recordGateBinding({ gateStepId, fanoutStepId }) {
@@ -618,7 +930,7 @@ export class ConsumerFanoutArtifacts {
618
930
  || (scope.fanoutStepId === fanoutStepId && scope.itemIndex === itemIndex);
619
931
  let changed = false;
620
932
  for (const issuance of this.journal.issuances) {
621
- if (issuance.state === 'merged' || issuance.state === 'superseded') continue;
933
+ if (issuance.state === 'merged' || issuance.state === 'superseded' || issuance.state === 'failed') continue;
622
934
  if (!inScope(issuance.fanoutStepId, issuance.itemIndex)) continue;
623
935
  const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
624
936
  const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
@@ -670,13 +982,17 @@ export class ConsumerFanoutArtifacts {
670
982
  */
671
983
  reconcileDescriptor(descriptor, audit) {
672
984
  return this.#mutate(() => {
985
+ const binding = this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding;
986
+ if (isNoneIsolation(descriptor) && Object.hasOwn(binding?.item ?? {}, 'files_owned')) {
987
+ throw new ConsumerArtifactError('WAVE_OWNERSHIP_INVALID', 'Ownership requires worktree isolation');
988
+ }
673
989
  // Per-item settlement: the descriptor's audit is a concurrent snapshot that
674
990
  // may be stale for OTHER items, so reconcile only this item's own records.
675
991
  this.#reconcileAuditInto(audit, { fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
676
992
  const prepared = this.journal.issuances.find(
677
993
  (entry) => entry.dispatchToken === descriptor.dispatchToken,
678
994
  );
679
- if (prepared?.state === 'prepared') {
995
+ if (prepared?.state === 'prepared' || prepared?.state === 'failed') {
680
996
  return { action: 'report', envelope: structuredClone(prepared.envelope), issuance: prepared };
681
997
  }
682
998
  if (prepared?.state === 'accepted') {
@@ -739,14 +1055,26 @@ export class ConsumerFanoutArtifacts {
739
1055
  });
740
1056
  }
741
1057
 
742
- prepareIssuance(descriptor, envelope, { finalStage }) {
1058
+ prepareIssuance(descriptor, envelope, { finalStage, itemBinding, resolvedProfile, ownership } = {}) {
743
1059
  return this.#mutate(() => {
744
1060
  const existing = this.journal.issuances.find(
745
1061
  (entry) => entry.dispatchToken === descriptor.dispatchToken,
746
1062
  );
747
1063
  if (existing) return existing;
748
1064
 
1065
+ const binding = itemBinding ?? this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding
1066
+ ?? (ownership ? {
1067
+ item: structuredClone(descriptor.item ?? {}), itemDigest: digestJson(descriptor.item ?? {}),
1068
+ epoch: descriptor.epoch, sourceProvenance: 'descriptor.item',
1069
+ } : undefined);
1070
+ const ownershipEnabled = ownership || Object.hasOwn(binding?.item ?? {}, 'files_owned');
1071
+ const profile = resolvedProfile ?? this.journal.dispatchBindings?.[descriptor.dispatchToken]?.resolvedProfile;
1072
+ const fields = { ...(binding ? { itemBinding: structuredClone(binding) } : {}),
1073
+ ...(profile ? { resolvedProfile: structuredClone(profile) } : {}) };
749
1074
  if (isNoneIsolation(descriptor)) {
1075
+ if (ownership || Object.hasOwn(binding?.item ?? {}, 'files_owned')) {
1076
+ throw new ConsumerArtifactError('WAVE_OWNERSHIP_INVALID', 'Ownership requires worktree isolation');
1077
+ }
750
1078
  // Envelope-only journal entry: no worktree, no witness, no diff. It never
751
1079
  // participates in the merge (prepareMerge filters isolation:none out), so
752
1080
  // its files persist directly in the target cwd.
@@ -760,6 +1088,7 @@ export class ConsumerFanoutArtifacts {
760
1088
  attempt: descriptor.attempt,
761
1089
  revisionDigest: descriptor.revisionDigest,
762
1090
  contractDigest: descriptor.contractDigest,
1091
+ ...fields,
763
1092
  isolation: 'none',
764
1093
  worktreeKey: null,
765
1094
  witnessTree: null,
@@ -796,6 +1125,8 @@ export class ConsumerFanoutArtifacts {
796
1125
  attempt: descriptor.attempt,
797
1126
  revisionDigest: descriptor.revisionDigest,
798
1127
  contractDigest: descriptor.contractDigest,
1128
+ ...fields,
1129
+ ...(ownershipEnabled && finalStage ? { ownership: { baseCommit: worktree.baseCommit } } : {}),
799
1130
  isolation: 'worktree',
800
1131
  worktreeKey: worktree.key,
801
1132
  witnessTree: witness.witnessTree,
@@ -806,11 +1137,39 @@ export class ConsumerFanoutArtifacts {
806
1137
  diffDigest: diff === null ? null : sha256(diff),
807
1138
  preparedAt: now(),
808
1139
  };
1140
+ if (finalStage && ownershipEnabled) {
1141
+ const finding = checkOwnership(this.targetCwd, entry, { capture: true });
1142
+ if (finding) this.#failOwnership(entry, finding);
1143
+ }
809
1144
  this.journal.issuances.push(entry);
810
1145
  return entry;
811
1146
  });
812
1147
  }
813
1148
 
1149
+ #failOwnership(entry, finding) {
1150
+ entry.state = 'failed';
1151
+ entry.findings ??= [];
1152
+ if (!entry.findings.some(f => f.code === finding.code && f.message === finding.message)) entry.findings.push(finding);
1153
+ entry.ownership ??= {};
1154
+ entry.ownership.finding = finding;
1155
+ entry.envelope = { ...entry.envelope, failure: `${finding.code}: ${finding.message}` };
1156
+ delete entry.envelope.output;
1157
+ }
1158
+
1159
+ #verifyOwnership(entry, ordered) {
1160
+ let finding = checkOwnership(this.targetCwd, entry);
1161
+ if (entry.itemBinding && ordered && (ordered.diff !== entry.diff || ordered.digest !== entry.diffDigest
1162
+ || ordered.bindingDigest !== digestJson(entry.itemBinding))) {
1163
+ finding = { code: 'OWNERSHIP_EVIDENCE_MISMATCH', message: 'Ordered patch/binding differs from captured issuance',
1164
+ dispatchToken: entry.dispatchToken, severity: 'error', files: [], allowedFiles: entry.itemBinding.item?.files_owned ?? [] };
1165
+ }
1166
+ if (finding) {
1167
+ this.#failOwnership(entry, finding);
1168
+ this.#save();
1169
+ throw new ConsumerMergeDecisionError(finding.code, finding.message, { finding });
1170
+ }
1171
+ }
1172
+
814
1173
  prepareArtifactFailure(descriptor, envelope, error) {
815
1174
  return this.#mutate(() => {
816
1175
  const existing = this.journal.issuances.find(
@@ -861,13 +1220,20 @@ export class ConsumerFanoutArtifacts {
861
1220
  { gateToken },
862
1221
  );
863
1222
  }
864
- if (existing) return existing;
1223
+ if (existing) {
1224
+ for (const ordered of existing.orderedDiffs ?? []) {
1225
+ const issuance = this.journal.issuances.find(e => e.dispatchToken === ordered.dispatchToken);
1226
+ if (issuance) this.#verifyOwnership(issuance, ordered);
1227
+ }
1228
+ return existing;
1229
+ }
865
1230
 
866
1231
  const accepted = this.acceptedEntriesFor(fanoutStepId);
867
1232
  // Only isolation:worktree items own a diff and participate in the merge.
868
1233
  // isolation:none items already wrote in the target cwd; they are accepted
869
1234
  // evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
870
1235
  const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
1236
+ for (const entry of worktreeAccepted) this.#verifyOwnership(entry);
871
1237
  const baselineTree = snapshotWorkingTree(this.targetCwd);
872
1238
  // A worker that changed NOTHING owes an empty diff, not a missing one, and it
873
1239
  // must not join the merge: applying nothing leaves the tree identical, so its
@@ -888,6 +1254,7 @@ export class ConsumerFanoutArtifacts {
888
1254
  itemIndex: entry.itemIndex,
889
1255
  generation: entry.generation,
890
1256
  digest: entry.diffDigest,
1257
+ ...(entry.itemBinding ? { bindingDigest: digestJson(entry.itemBinding) } : {}),
891
1258
  diff: entry.diff,
892
1259
  }));
893
1260
  const recordBlocked = (code, message, witnessChain = [baselineTree]) => {
@@ -965,6 +1332,12 @@ export class ConsumerFanoutArtifacts {
965
1332
  gateToken,
966
1333
  fanoutStepId,
967
1334
  state: 'prepared',
1335
+ ...(this.journal.wave ? {
1336
+ epoch: audit?.steps?.[fanoutStepId]?.epoch,
1337
+ checkpointParent: this.journal.wave.checkpoints.at(-1)?.commit ?? this.journal.wave.baseCommit,
1338
+ acceptedDispatchTokens: worktreeAccepted.map(e => e.dispatchToken),
1339
+ gateOrdinal: (audit?.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId).length,
1340
+ } : {}),
968
1341
  baselineTree,
969
1342
  witnessChain,
970
1343
  orderedDiffs,
@@ -1079,6 +1452,10 @@ export class ConsumerFanoutArtifacts {
1079
1452
  // not a lockfile in this slice.
1080
1453
  const live = this.#currentTransaction(gateToken);
1081
1454
  if (live) this.#assertMergeNotDecided(live, gateToken);
1455
+ this.#mutate(() => {
1456
+ const issuance = this.journal.issuances.find(e => e.dispatchToken === ordered.dispatchToken);
1457
+ if (issuance) this.#verifyOwnership(issuance, ordered);
1458
+ });
1082
1459
  // Same merge algorithm as the witness precompute (temporary index,
1083
1460
  // three-way), then the merged tree is checked out. Applying straight to
1084
1461
  // the working tree cannot use --3way (it requires the real index to match).
@@ -1165,7 +1542,7 @@ export class ConsumerFanoutArtifacts {
1165
1542
  });
1166
1543
  }
1167
1544
 
1168
- restoreMergeBaseline(transaction, audit) {
1545
+ restoreMergeBaseline(transaction, audit, opts = {}) {
1169
1546
  if (!transaction) return undefined;
1170
1547
  return this.#mutate(() => {
1171
1548
  // Re-find against the fresh journal — the passed reference may predate a
@@ -1176,6 +1553,7 @@ export class ConsumerFanoutArtifacts {
1176
1553
  restoreWorkingTree(this.targetCwd, tx.baselineTree);
1177
1554
  tx.state = wasBlocked ? 'blocked' : 'rolled_back';
1178
1555
  tx.rolledBackAt = now();
1556
+ tx.rollbackReason = opts.reason ?? null;
1179
1557
  tx.recovery ??= { baselineRestores: 0 };
1180
1558
  tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
1181
1559
  tx.recovery.baselineVerifiedTree = tx.baselineTree;
@@ -1214,6 +1592,20 @@ export class ConsumerFanoutArtifacts {
1214
1592
  });
1215
1593
  }
1216
1594
 
1595
+ /** C40: a failed restore callback writes nothing. Persist its finding through
1596
+ * a separate guarded mutation, reloading the durable transaction first. */
1597
+ markRollbackFailed(transaction, error) {
1598
+ if (!transaction) return undefined;
1599
+ return this.#mutate(() => {
1600
+ const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === transaction.gateToken);
1601
+ if (!tx) return undefined;
1602
+ tx.state = 'rollback_failed';
1603
+ tx.failureCode = 'merge_revert_failed';
1604
+ tx.failure = error?.message ?? String(error);
1605
+ return tx;
1606
+ });
1607
+ }
1608
+
1217
1609
  markGateResolved(transaction, outcome) {
1218
1610
  if (!transaction) return undefined;
1219
1611
  return this.#mutate(() => {
@@ -1238,10 +1630,18 @@ export class ConsumerFanoutArtifacts {
1238
1630
  });
1239
1631
  }
1240
1632
 
1241
- cleanupWorktrees(reason) {
1633
+ cleanupWorktrees(reason, { dispatchTokens } = {}) {
1242
1634
  this.#mutate(() => {
1635
+ const selected = dispatchTokens ? new Set(dispatchTokens) : null;
1636
+ const eligible = token => {
1637
+ if (selected && !selected.has(token)) return false;
1638
+ if (!this.journal.wave) return true;
1639
+ return this.journal.wave.checkpoints.some(c => c.state === 'published' && c.evidenceReceiptId
1640
+ && c.orderedDispatchTokens.includes(token));
1641
+ };
1642
+ const worktreeKeys = new Set(this.journal.issuances.filter(e => eligible(e.dispatchToken)).map(e => e.worktreeKey));
1243
1643
  for (const record of this.journal.worktrees) {
1244
- if (record.cleanedAt) continue;
1644
+ if (record.cleanedAt || (selected || this.journal.wave) && !worktreeKeys.has(record.key)) continue;
1245
1645
  if (existsSync(record.path)) {
1246
1646
  try {
1247
1647
  git(this.targetCwd, ['worktree', 'remove', '--force', record.path], { timeout: 60_000 });
@@ -1254,16 +1654,20 @@ export class ConsumerFanoutArtifacts {
1254
1654
  record.cleanupReason = reason;
1255
1655
  }
1256
1656
  for (const issuance of this.journal.issuances) {
1257
- if (typeof issuance.diff === 'string') {
1657
+ if (eligible(issuance.dispatchToken) && typeof issuance.diff === 'string') {
1258
1658
  issuance.diff = null;
1259
1659
  issuance.diffDroppedAt = now();
1260
1660
  }
1261
1661
  }
1662
+ for (const checkpoint of this.journal.wave?.checkpoints ?? []) {
1663
+ if (checkpoint.state === 'published' && checkpoint.evidenceReceiptId
1664
+ && checkpoint.orderedDispatchTokens.every(eligible)) checkpoint.payloadsDroppedAt ??= now();
1665
+ }
1262
1666
  for (const transaction of this.journal.mergeTransactions) {
1263
1667
  for (const ordered of transaction.orderedDiffs ?? []) {
1264
- if (typeof ordered.diff === 'string') ordered.diff = null;
1668
+ if (eligible(ordered.dispatchToken) && typeof ordered.diff === 'string') ordered.diff = null;
1265
1669
  }
1266
- if (transaction.orderedDiffs?.length > 0 && !transaction.diffPayloadsDroppedAt) {
1670
+ if (transaction.orderedDiffs?.length > 0 && transaction.orderedDiffs.every(d => d.diff === null) && !transaction.diffPayloadsDroppedAt) {
1267
1671
  transaction.diffPayloadsDroppedAt = now();
1268
1672
  }
1269
1673
  }
@@ -1297,10 +1701,15 @@ export function verifyConsumerRunRevision({
1297
1701
  artifactRoot,
1298
1702
  specDigest,
1299
1703
  resumeRevisionDigest,
1704
+ profilesDigest,
1300
1705
  }) {
1301
1706
  if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return null;
1302
1707
  const { journal } = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
1303
1708
 
1709
+ if ((journal.profilesDigest !== undefined || profilesDigest !== undefined) && journal.profilesDigest !== profilesDigest) {
1710
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles differ from the pinned run',
1711
+ { recorded: journal.profilesDigest, current: profilesDigest });
1712
+ }
1304
1713
  const fullyPinned = Boolean(journal.revisionDigest) && Boolean(journal.specDigest);
1305
1714
  if (!fullyPinned) {
1306
1715
  const hasWork = journal.issuances.length > 0
@@ -1382,13 +1791,14 @@ export function recoverAdvancedConsumerArtifacts({
1382
1791
  const matchingGateEvents = gateEvents.filter(
1383
1792
  (event) => event.stepId === transaction.gateStepId,
1384
1793
  );
1385
- const eventOffset = gateEventOffsets.get(transaction.gateStepId) ?? 0;
1794
+ const eventOffset = transaction.gateOrdinal ?? gateEventOffsets.get(transaction.gateStepId) ?? 0;
1386
1795
  const resolvedEvent = matchingGateEvents[eventOffset];
1387
1796
  gateEventOffsets.set(transaction.gateStepId, eventOffset + 1);
1388
1797
  const resolvedOutcome = transaction.gateOutcome ?? resolvedEvent?.detail?.decision;
1389
1798
  const approvedComplete = transaction.state === 'complete' && resolvedOutcome === 'approve';
1390
1799
 
1391
- if (approvedComplete && gateState?.status !== 'waiting_gate') {
1800
+ if (approvedComplete && (artifacts.journal.wave || gateState?.status !== 'waiting_gate')) {
1801
+ if (artifacts.journal.wave) artifacts.recoverCheckpoint(transaction);
1392
1802
  artifacts.markGateAdvanced({ gateToken: transaction.gateToken, outcome: resolvedOutcome, at: resolvedEvent?.at });
1393
1803
  recoveredDecision = true;
1394
1804
  approvedAdvanced = true;