driftseal 3.2.0 → 3.3.5

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.
package/README.md CHANGED
@@ -216,7 +216,7 @@ driftseal decision update 1 --status accepted --note "Confirmed by the final imp
216
216
  | `driftseal lane add\|switch\|assign\|show` | Partition history by long-lived capability. |
217
217
  | `driftseal reclaim [id ...] --reason "..." [--force]` | Hide meaningless closed records with append-only markers. |
218
218
  | `driftseal unreclaim <id> --reason "..."` | Restore a reclaimed record. |
219
- | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs\|--abandon-ours]` | Merge another lineage and remap colliding outcome or MADR ids. |
219
+ | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs\|--abandon-ours]` | Merge another lineage, remap colliding ids, and repair stale MADR Decision History references. |
220
220
  | `driftseal decision add\|update\|list\|show` | Manage MADR records. |
221
221
  | `driftseal migrate v1-to-v2 inspect --json [migration paths]` | Normalize v1 state for model-assisted grouping. |
222
222
  | `driftseal migrate v1-to-v2 apply --plan <file> [migration paths]` | Validate a grouping plan and stage the v2 seal beside v1. |
@@ -314,6 +314,16 @@ ids, rebinds affected contract hashes, and refuses concurrent edits of a shared
314
314
  MADR. If both lineages remain open, choose explicitly with `--abandon-theirs` or
315
315
  `--abandon-ours`.
316
316
 
317
+ `absorb` also repairs managed MADR Decision History outcome references by joining
318
+ their reconciliation IDs to the merged log, and rebinds matching content hashes.
319
+ It uses the local seal's `madr/` directory by default; when importing another log,
320
+ its sibling `../madr/` is the default source (`--decisions` overrides that source).
321
+ Run `driftseal absorb --dry-run` to preview repairs, including stale references
322
+ left by an older merge whose outcome IDs are already unique. Entries without a
323
+ matching reconciliation ID are left unchanged. If the Git merge driver reports
324
+ that decision history requires worktree repair, run `driftseal absorb`, stage the
325
+ repaired log and MADRs, then finish the merge.
326
+
317
327
  ## Node API and MCP
318
328
 
319
329
  ```js
package/README.zh-CN.md CHANGED
@@ -196,7 +196,7 @@ driftseal decision update 1 --status accepted --note "Confirmed by the final imp
196
196
  | `driftseal lane add\|switch\|assign\|show` | 按长期能力切分历史。 |
197
197
  | `driftseal reclaim [id ...] --reason "..." [--force]` | 通过 append-only marker 隐藏无意义的已关闭记录。 |
198
198
  | `driftseal unreclaim <id> --reason "..."` | 恢复 reclaimed record。 |
199
- | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs\|--abandon-ours]` | 合并另一条 lineage 并处理撞号。 |
199
+ | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs\|--abandon-ours]` | 合并另一条 lineage,处理撞号,并修复 MADR Decision History 中的过期 outcome 引用。 |
200
200
  | `driftseal decision add\|update\|list\|show` | 管理 MADR。 |
201
201
  | `driftseal migrate v1-to-v2 inspect --json [migration paths]` | 规范化 v1 状态,供模型分组。 |
202
202
  | `driftseal migrate v1-to-v2 apply --plan <file> [migration paths]` | 校验分组计划,并在 v1 旁边创建 v2 seal。 |
@@ -284,6 +284,14 @@ driftseal absorb
284
284
  contract hash,并拒绝自动合并 shared MADR 的并发编辑。若两条 lineage 都处于 open
285
285
  状态,必须显式选择 `--abandon-theirs` 或 `--abandon-ours`。
286
286
 
287
+ `absorb` 还会通过 reconciliation ID 将 MADR 的 Decision History 条目与合并后的 log
288
+ 对应,修正其中的 outcome 引用,并同步匹配的内容 hash。默认处理当前 seal 的 `madr/`;
289
+ 导入其他 log 时,默认从该 log 的 `../madr/` 读取 MADR,可用 `--decisions` 指定其他来源。
290
+ 运行 `driftseal absorb --dry-run` 可预览修复;旧版本合并后留下的错链,即使 outcome ID
291
+ 已经没有冲突,也可以修复。找不到对应 reconciliation ID 的条目会保持原样。若 Git merge
292
+ driver 提示 decision history 需要修复,运行 `driftseal absorb`,将修复后的 log 和 MADR
293
+ 加入 staging area,再完成 merge。
294
+
287
295
  ## Node API 与 MCP
288
296
 
289
297
  ```js
@@ -135,6 +135,7 @@ function registerTools(server, api, z) {
135
135
  abandoned: z.string().nullable(),
136
136
  copies: z.array(z.string()),
137
137
  outputFile: z.string(),
138
+ repairs: z.number().int().nonnegative(),
138
139
  exitCode: z.number().int(),
139
140
  });
140
141
  const closedStatus = z.enum(END_STATUSES);
@@ -416,7 +417,7 @@ function registerTools(server, api, z) {
416
417
  {
417
418
  title: 'Absorb another DriftSeal lineage',
418
419
  description:
419
- 'Repair the fixed repository after a merge collision or absorb another worktree\'s outcome and MADR logs, remapping colliding IDs.',
420
+ 'Repair the fixed repository after a merge collision or absorb another worktree\'s outcome and MADR logs, remapping colliding IDs and repairing stale Decision History outcome references. Run with no incoming log after Git stops for decision-id or decision-history worktree repair.',
420
421
  inputSchema: {
421
422
  otherLog: z
422
423
  .string()
@@ -439,9 +440,16 @@ function registerTools(server, api, z) {
439
440
  guarded(() => {
440
441
  const result = api.absorb(input);
441
442
  const action = input.dryRun ? 'Absorb dry run' : 'Absorb';
443
+ const parts = [];
444
+ if (result.mappings.length > 0) {
445
+ parts.push(`${result.mappings.length} ID remapping(s)`);
446
+ }
447
+ if (result.repairs > 0) {
448
+ parts.push(`${result.repairs} decision history repair(s)`);
449
+ }
442
450
  return success(
443
451
  { root: api.root, result },
444
- `${action} completed with ${result.mappings.length} ID remapping(s).`
452
+ `${action} completed with ${parts.join(' and ') || 'nothing to apply'}.`
445
453
  );
446
454
  })
447
455
  );
package/bin/driftseal.js CHANGED
@@ -2636,7 +2636,10 @@ Seal root: \`.seal/\` (override with \`$DRIFTSEAL_HOME\`); outcome log:
2636
2636
  ${INTENT_PROTOCOL_END}`;
2637
2637
  }
2638
2638
 
2639
- function intentProtocolBlockV21(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2639
+ function intentProtocolBlockV21Text(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false, { historyRepair = true } = {}) {
2640
+ const absorbWhen = historyRepair
2641
+ ? 'collisions or when Decision History outcome references are stale'
2642
+ : 'collisions';
2640
2643
  return `${INTENT_PROTOCOL_MARKER}
2641
2644
  <!-- driftseal-version: ${version} -->
2642
2645
  <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
@@ -2688,13 +2691,21 @@ ${outcomeLogLanguageParagraph(language)}
2688
2691
  **Log access goes only through DriftSeal.** Never read, edit, move, or delete
2689
2692
  \`.seal/outcomes/events.jsonl\` (or its configured equivalent) directly. Use
2690
2693
  \`reclaim\`/\`unreclaim\` for visibility markers and \`absorb\` after merge
2691
- collisions. These operations preserve append-only single-lineage history.
2694
+ ${absorbWhen}. These operations preserve append-only single-lineage history.
2692
2695
 
2693
2696
  Seal root: \`.seal/\` (override with \`$DRIFTSEAL_HOME\`); outcome log:
2694
2697
  \`.seal/outcomes/events.jsonl\`; ${localLog ? 'keep `.seal/` local and untracked.' : 'commit `.seal/` with the code.'}
2695
2698
  ${INTENT_PROTOCOL_END}`;
2696
2699
  }
2697
2700
 
2701
+ function intentProtocolBlockV21(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2702
+ return intentProtocolBlockV21Text(version, language, localLog, { historyRepair: true });
2703
+ }
2704
+
2705
+ function intentProtocolBlockV21AbsorbCollisions(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2706
+ return intentProtocolBlockV21Text(version, language, localLog, { historyRepair: false });
2707
+ }
2708
+
2698
2709
  function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2699
2710
  if (String(version) === '2.0') return intentProtocolBlockV20(language, localLog);
2700
2711
  return intentProtocolBlockV21(version, language, localLog);
@@ -2991,7 +3002,19 @@ function protocolEol(content, eol) {
2991
3002
  return eol === '\n' ? content : content.replace(/\n/g, eol);
2992
3003
  }
2993
3004
 
3005
+ function decisionProtocolBlockAbsorbCollisions(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
3006
+ return decisionProtocolBlockText(version, language, localLog, { historyRepair: false });
3007
+ }
3008
+
2994
3009
  function decisionProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
3010
+ if (String(version) === '2.0') return decisionProtocolBlockAbsorbCollisions(version, language, localLog);
3011
+ return decisionProtocolBlockText(version, language, localLog, { historyRepair: true });
3012
+ }
3013
+
3014
+ function decisionProtocolBlockText(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false, { historyRepair = true } = {}) {
3015
+ const absorbSentence = historyRepair
3016
+ ? 'After a\nmerge, `driftseal absorb` remaps colliding ids and repairs managed Decision History\noutcome references; it never auto-merges concurrent edits of a shared MADR.'
3017
+ : 'After a\nmerge, `driftseal absorb` remaps colliding ids; it never auto-merges concurrent\nedits of a shared MADR.';
2995
3018
  return `${DECISION_PROTOCOL_MARKER}
2996
3019
  <!-- driftseal-decisions-version: ${version} -->
2997
3020
  <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
@@ -3009,9 +3032,7 @@ ${decisionLogLanguageParagraph(language)}
3009
3032
 
3010
3033
  Use \`proposed|accepted|rejected|deferred|deprecated|superseded\` statuses. Link
3011
3034
  existing MADRs from \`begin\` or \`extend\`, then reconcile each linked record
3012
- with \`driftseal decision update\` before successful or partial closure. After a
3013
- merge, \`driftseal absorb\` remaps colliding ids; it never auto-merges concurrent
3014
- edits of a shared MADR.
3035
+ with \`driftseal decision update\` before successful or partial closure. ${absorbSentence}
3015
3036
  ${localLog ? 'Keep `.seal/madr/` local and untracked.' : 'Commit `.seal/madr/` with the code.'}
3016
3037
  ${DECISION_PROTOCOL_END}`;
3017
3038
  }
@@ -3111,6 +3132,14 @@ function previousDecisionProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE,
3111
3132
  return v8.replace(' --driver "<decision driver>"', '');
3112
3133
  }
3113
3134
 
3135
+ function customizedProtocolBlockError(marker) {
3136
+ return (
3137
+ `cannot safely upgrade customized protocol block beginning with ${marker}; ` +
3138
+ 'the block may come from a newer DriftSeal release even when its protocol version matches ' +
3139
+ `(current client: ${PACKAGE_VERSION}); upgrade DriftSeal and retry`
3140
+ );
3141
+ }
3142
+
3114
3143
  function upgradeManagedBlock({
3115
3144
  content,
3116
3145
  marker,
@@ -3161,7 +3190,7 @@ function upgradeManagedBlock({
3161
3190
  key === protocolBlockKey(replacement) ||
3162
3191
  knownManagedBlocks.some((known) => key === protocolBlockKey(known));
3163
3192
  if (!recognized) {
3164
- fail(`cannot safely upgrade customized protocol block beginning with ${marker}`);
3193
+ fail(customizedProtocolBlockError(marker));
3165
3194
  }
3166
3195
  return {
3167
3196
  content: content.slice(0, start) + replacement + content.slice(after),
@@ -3176,7 +3205,7 @@ function upgradeManagedBlock({
3176
3205
  found: true,
3177
3206
  };
3178
3207
  }
3179
- fail(`cannot safely upgrade customized protocol block beginning with ${marker}`);
3208
+ fail(customizedProtocolBlockError(marker));
3180
3209
  }
3181
3210
 
3182
3211
  const MCP_TARGETS = ['codex', 'kimi-code', 'opencode', 'claude-code', 'cursor'];
@@ -3421,6 +3450,7 @@ const SKILL_RELEASE_DIGESTS = new Set([
3421
3450
  'b4b2cc27ea71c5777b5eb9b67d861fbe1da43f31ab5d422d6a877e0cad592493', // 2.1.0 lane re-anchor recovery
3422
3451
  '6c49523c2f4a36ea1cd9d0b4e793ea3cdbd9d299174cf1f9a0a54f3d9a7cd331', // workspace park sidecar wording
3423
3452
  '678cbaa8380f75f7a45d634010b500e51a59b06ea759eaf23cff2980142662f0', // default-seal parking help wording
3453
+ 'b5f2379fba311e19a5139dd6eb80795473fa6754fa959f90d4a36cc273b56e8f', // absorb Decision History repair
3424
3454
  ]);
3425
3455
 
3426
3456
  function skillInstallUsage() {
@@ -4226,6 +4256,75 @@ function rewriteDecisionId(content, newId) {
4226
4256
  return content.replace(/^# [0-9]+\. /, `# ${String(BigInt(newId))}. `);
4227
4257
  }
4228
4258
 
4259
+ function decisionIdFromFile(file) {
4260
+ const match = path.basename(file).match(/^(\d{4,})-/);
4261
+ if (!match) fail(`invalid decision filename: ${file}`);
4262
+ return normalizeDecisionId(match[1]);
4263
+ }
4264
+
4265
+ function reconciliationOutcomes(records) {
4266
+ return new Map(records
4267
+ .filter((record) => record.event.type === 'decision_reconcile_prepare')
4268
+ .map((record) => [record.event.reconciliationId, record.event]));
4269
+ }
4270
+
4271
+ function rewriteDecisionHistory(content, decisionId, outcomes) {
4272
+ let repairs = 0;
4273
+ const rewritten = content.replace(
4274
+ /^(<!-- [a-z][a-z0-9-]*-reconciliation: ([^>\r\n]+) -->\r?\n### [^\r\n]+ — (?:Outcome|Intent) `)(\d{4}-\d{2}-\d{2}-\d{3,})(`\r?$)/gm,
4275
+ (entry, prefix, reconciliationId, outcomeId, suffix) => {
4276
+ const prepare = outcomes.get(reconciliationId);
4277
+ if (!prepare || normalizeDecisionId(prepare.decisionId) !== decisionId || prepare.id === outcomeId) {
4278
+ return entry;
4279
+ }
4280
+ repairs++;
4281
+ return `${prefix}${prepare.id}${suffix}`;
4282
+ }
4283
+ );
4284
+ return { content: rewritten, repairs };
4285
+ }
4286
+
4287
+ function planDecisionHistoryRepair(records, copies, entries) {
4288
+ const outcomes = reconciliationOutcomes(records);
4289
+ const targets = new Map(entries.map((entry) => [entry.file, entry]));
4290
+ const plannedCopies = new Map(copies.map((copy) => [copy.toFile, copy]));
4291
+ const hashMap = new Map();
4292
+ let repairs = 0;
4293
+ for (const copy of copies) {
4294
+ if (copy.removeFile) targets.delete(copy.removeFile);
4295
+ }
4296
+ for (const copy of copies) {
4297
+ targets.set(copy.toFile, {
4298
+ file: copy.toFile,
4299
+ id: decisionIdFromFile(copy.toFile),
4300
+ content: copy.content,
4301
+ });
4302
+ }
4303
+ for (const entry of targets.values()) {
4304
+ const content = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
4305
+ const rewritten = rewriteDecisionHistory(content, entry.id, outcomes);
4306
+ if (rewritten.repairs === 0) continue;
4307
+ repairs += rewritten.repairs;
4308
+ // Rebind only hashes that attest the exact pre-repair bytes. Unrelated edits
4309
+ // must still fail reconciliation recovery or closure after this repair.
4310
+ hashMap.set(contentHash(content), contentHash(rewritten.content));
4311
+ plannedCopies.set(entry.file, {
4312
+ fromFile: entry.file,
4313
+ toFile: entry.file,
4314
+ removeFile: null,
4315
+ ...plannedCopies.get(entry.file),
4316
+ content: rewritten.content,
4317
+ });
4318
+ }
4319
+ const unchangedIds = new Map();
4320
+ const rewrittenRecords = records.map((record) =>
4321
+ ['oldHash', 'newHash', 'fileHash'].some((field) => hashMap.has(record.event[field]))
4322
+ ? { event: remapEvent(record.event, unchangedIds, unchangedIds, hashMap) }
4323
+ : record
4324
+ );
4325
+ return { records: rewrittenRecords, copies: [...plannedCopies.values()], repairs };
4326
+ }
4327
+
4229
4328
  function splitDuplicateDecisions(entries) {
4230
4329
  const ours = [];
4231
4330
  const theirs = [];
@@ -4676,6 +4775,7 @@ function finishAbsorb({
4676
4775
  outcomeCount,
4677
4776
  allowConflict = false,
4678
4777
  followupMessage = null,
4778
+ repairDecisionHistory = true,
4679
4779
  }) {
4680
4780
  // A parked outcome is local even though the tracked log never saw it.
4681
4781
  const park = shouldAttachInProgress(outputFile)
@@ -4703,33 +4803,52 @@ function finishAbsorb({
4703
4803
  // A parked overlay with nothing left open in it belongs in the tracked log, whether the
4704
4804
  // abandon flag just closed it or an interrupted end left it closed.
4705
4805
  const flushOverlay = parkedClosed || (overlay.length > 0 && !parkedOpen);
4706
- const merged = flushOverlay ? [...result, ...overlay] : result;
4707
4806
  const effective = [...result, ...overlay].map((record) => record.event);
4708
4807
  fold(effective);
4709
4808
  if (!conflict) openOutcome(fold(effective));
4809
+ const history = repairDecisionHistory
4810
+ ? planDecisionHistoryRepair(
4811
+ [...result, ...overlay], copies, listDecisionEntries(decisionDir(), { allowDuplicates: true })
4812
+ )
4813
+ : { records: [...result, ...overlay], copies, repairs: 0 };
4814
+ const repairedResult = history.records.slice(0, result.length);
4815
+ const repairedOverlay = history.records.slice(result.length);
4816
+ const merged = flushOverlay ? history.records : repairedResult;
4817
+ fold(history.records.map((record) => record.event));
4710
4818
  if (!dryRun) {
4711
4819
  writeJsonl(outputFile, merged);
4712
- applyDecisionCopies(copies, dryRun);
4820
+ applyDecisionCopies(history.copies, dryRun);
4713
4821
  if (plan) {
4714
4822
  if (plan.alreadyCommitted || flushOverlay) discardInProgressLog(park);
4715
- else if (plan.mappings.length > 0) writeJsonl(park, overlay);
4823
+ else if (plan.mappings.length > 0 || repairedOverlay.some((record, i) => record !== overlay[i])) {
4824
+ writeJsonl(park, repairedOverlay);
4825
+ }
4716
4826
  }
4717
4827
  }
4828
+ const historyOnly =
4829
+ history.repairs > 0 &&
4830
+ outcomeCount === 0 &&
4831
+ allMappings.length === 0 &&
4832
+ !abandoned &&
4833
+ !flushOverlay;
4718
4834
  if (
4719
4835
  outcomeCount === 0 &&
4720
4836
  allMappings.length === 0 &&
4721
- copies.length === 0 &&
4837
+ history.copies.length === 0 &&
4722
4838
  !abandoned &&
4723
4839
  !flushOverlay
4724
4840
  ) {
4725
4841
  printLine('nothing to absorb');
4726
- } else {
4842
+ } else if (!historyOnly) {
4727
4843
  printAbsorbReport({
4728
4844
  mappings: allMappings,
4729
4845
  abandoned,
4730
4846
  outcomeCount,
4731
4847
  });
4732
4848
  }
4849
+ if (history.repairs > 0) {
4850
+ printLine(`${dryRun ? 'would repair' : 'repaired'} ${history.repairs} decision history reference(s)`);
4851
+ }
4733
4852
  if (conflict) {
4734
4853
  printLine('multiple outcomes remain in progress; re-run with --abandon-theirs or --abandon-ours');
4735
4854
  }
@@ -4737,13 +4856,14 @@ function finishAbsorb({
4737
4856
  return {
4738
4857
  mappings: allMappings,
4739
4858
  abandoned,
4740
- copies: copies.map((item) => item.toFile),
4859
+ copies: history.copies.map((item) => item.toFile),
4741
4860
  outputFile,
4861
+ repairs: history.repairs,
4742
4862
  exitCode: conflict || followupMessage ? 1 : 0,
4743
4863
  };
4744
4864
  }
4745
4865
 
4746
- function absorbFromStreams(ours, theirs, baseRecords, options) {
4866
+ function planAbsorbMerge(ours, theirs, baseRecords, options = {}) {
4747
4867
  const streams = mergeRecordStreams(ours, theirs, baseRecords);
4748
4868
  const decisionPlan = planDecisionAbsorb({
4749
4869
  oursEntries: options.oursDecisionEntries || [],
@@ -4751,28 +4871,65 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
4751
4871
  baseEntries: options.baseDecisionEntries || [],
4752
4872
  baseIds: options.baseDecisionIds || new Set(),
4753
4873
  });
4874
+ const oursRecords = [...streams.base, ...streams.oursNew];
4754
4875
  const remapped = remapTheirsRecords(
4755
4876
  dropDuplicateLaneAdds(
4756
- [...streams.base, ...streams.oursNew].map((record) => record.event),
4877
+ oursRecords.map((record) => record.event),
4757
4878
  streams.theirsNew
4758
4879
  ),
4759
- [...streams.base, ...streams.oursNew].map((record) => record.event),
4880
+ oursRecords.map((record) => record.event),
4760
4881
  decisionPlan.decisionMap,
4761
4882
  decisionPlan.hashMap
4762
4883
  );
4763
- const result = [...streams.base, ...streams.oursNew, ...remapped.records];
4764
- return finishAbsorb({
4765
- result,
4766
- oursRecords: [...streams.base, ...streams.oursNew],
4884
+ return {
4885
+ decisionPlan,
4886
+ remapped,
4887
+ result: [...oursRecords, ...remapped.records],
4888
+ oursRecords,
4767
4889
  theirsRecords: [...streams.base, ...remapped.records],
4768
4890
  mappings: [...remapped.mappings, ...decisionPlan.mappings],
4769
- copies: decisionPlan.copies,
4891
+ outcomeCount: streams.theirsNew.filter((record) => isOutcomeStart(record.event)).length,
4892
+ };
4893
+ }
4894
+
4895
+ function previewParkedOverlay(result, outputFile) {
4896
+ // Git merge drivers often receive a temporary %A that is not the live WAL, so
4897
+ // parked history still has to be previewed from the worktree sidecar.
4898
+ const park = shouldAttachInProgress(outputFile)
4899
+ ? existingInProgressFileForLog(outputFile)
4900
+ : existingInProgressFile();
4901
+ if (!park) return [];
4902
+ const plan = planInProgressOverlay(
4903
+ result.map((record) => record.event),
4904
+ park,
4905
+ { repairTail: true, readOnly: true }
4906
+ );
4907
+ return plan && !plan.alreadyCommitted ? plan.records : [];
4908
+ }
4909
+
4910
+ function decisionHistoryNeedsRepair(records, entries) {
4911
+ const outcomes = reconciliationOutcomes(records);
4912
+ return entries.some((entry) => {
4913
+ const content = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
4914
+ return rewriteDecisionHistory(content, entry.id, outcomes).repairs > 0;
4915
+ });
4916
+ }
4917
+
4918
+ function absorbFromStreams(ours, theirs, baseRecords, options) {
4919
+ const merge = planAbsorbMerge(ours, theirs, baseRecords, options);
4920
+ return finishAbsorb({
4921
+ result: merge.result,
4922
+ oursRecords: merge.oursRecords,
4923
+ theirsRecords: merge.theirsRecords,
4924
+ mappings: merge.mappings,
4925
+ copies: merge.decisionPlan.copies,
4770
4926
  abandon: options.abandon,
4771
4927
  dryRun: options.dryRun,
4772
4928
  outputFile: options.outputFile,
4773
4929
  allowConflict: options.allowConflict,
4774
4930
  followupMessage: options.followupMessage,
4775
- outcomeCount: streams.theirsNew.filter((record) => ['begin', 'import'].includes(record.event.type)).length,
4931
+ repairDecisionHistory: options.repairDecisionHistory,
4932
+ outcomeCount: merge.outcomeCount,
4776
4933
  });
4777
4934
  }
4778
4935
 
@@ -4920,14 +5077,19 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
4920
5077
  gitFindCommitForFile(theirsFile, '.seal/outcomes/events.jsonl') ||
4921
5078
  gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
4922
5079
  const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
5080
+ // Driver merge remaps the jsonl only. Decision copies stay with a later
5081
+ // worktree absorb so Git can finish merging MADR files first.
5082
+ const merge = planAbsorbMerge(ours.records, theirs.records, base.records, {});
4923
5083
  let followupMessage = null;
4924
5084
  if (!otherHead) {
4925
5085
  followupMessage =
4926
5086
  'incoming Git tree could not be identified safely; re-run driftseal absorb after Git stops the merge';
4927
5087
  } else {
5088
+ const oursDecisionEntries = gitDecisionEntries('HEAD');
5089
+ const theirsDecisionEntries = gitDecisionEntries(otherHead);
4928
5090
  const decisionPlan = planDecisionAbsorb({
4929
- oursEntries: gitDecisionEntries('HEAD'),
4930
- theirsEntries: gitDecisionEntries(otherHead),
5091
+ oursEntries: oursDecisionEntries,
5092
+ theirsEntries: theirsDecisionEntries,
4931
5093
  baseEntries: mergeBase ? gitDecisionEntries(mergeBase) : [],
4932
5094
  baseIds: mergeBase
4933
5095
  ? gitDecisionIds(mergeBase)
@@ -4939,14 +5101,33 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
4939
5101
  if (requiresDecisionRepair) {
4940
5102
  followupMessage =
4941
5103
  'decision ids require worktree repair; run driftseal absorb, then stage the repaired logs';
5104
+ } else {
5105
+ const overlay = previewParkedOverlay(merge.result, oursFile);
5106
+ const worktreeEntries = listDecisionEntries(decisionDir(), { allowDuplicates: true });
5107
+ if (decisionHistoryNeedsRepair(
5108
+ [...merge.result, ...overlay],
5109
+ [...oursDecisionEntries, ...theirsDecisionEntries, ...worktreeEntries]
5110
+ )) {
5111
+ followupMessage =
5112
+ 'decision history requires worktree repair; run driftseal absorb, then stage the repaired logs';
5113
+ }
4942
5114
  }
4943
5115
  }
4944
- return absorbFromStreams(ours.records, theirs.records, base.records, {
5116
+ return finishAbsorb({
5117
+ result: merge.result,
5118
+ oursRecords: merge.oursRecords,
5119
+ theirsRecords: merge.theirsRecords,
5120
+ mappings: merge.mappings,
5121
+ copies: merge.decisionPlan.copies,
4945
5122
  abandon,
4946
5123
  dryRun,
4947
5124
  outputFile: oursFile,
4948
5125
  allowConflict: !abandon,
4949
5126
  followupMessage,
5127
+ // Git owns the worktree MADR merge. Stop for a normal absorb when history
5128
+ // needs repair rather than racing Git by writing other paths from its driver.
5129
+ repairDecisionHistory: false,
5130
+ outcomeCount: merge.outcomeCount,
4950
5131
  });
4951
5132
  }
4952
5133
 
@@ -6672,6 +6853,8 @@ const commands = {
6672
6853
  ...sourceLanguages.flatMap((source) => [
6673
6854
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
6674
6855
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6856
+ protocolEol(intentProtocolBlockV21AbsorbCollisions(PROTOCOL_VERSION, source), eol),
6857
+ protocolEol(intentProtocolBlockV21AbsorbCollisions(PROTOCOL_VERSION, source, true), eol),
6675
6858
  protocolEol(intentProtocolBlockV20(source), eol),
6676
6859
  protocolEol(intentProtocolBlockV20(source, true), eol),
6677
6860
  protocolEol(v1IntentProtocolBlock(14, source), eol),
@@ -6706,6 +6889,8 @@ const commands = {
6706
6889
  ...sourceLanguages.flatMap((source) => [
6707
6890
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
6708
6891
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6892
+ protocolEol(decisionProtocolBlockAbsorbCollisions(PROTOCOL_VERSION, source), eol),
6893
+ protocolEol(decisionProtocolBlockAbsorbCollisions(PROTOCOL_VERSION, source, true), eol),
6709
6894
  protocolEol(decisionProtocolBlock('2.0', source), eol),
6710
6895
  protocolEol(decisionProtocolBlock('2.0', source, true), eol),
6711
6896
  protocolEol(v1DecisionProtocolBlock(14, source), eol),
@@ -6807,6 +6992,7 @@ usage:
6807
6992
  driftseal absorb [other-events.jsonl] [--decisions <dir>]
6808
6993
  [--abandon-theirs | --abandon-ours] [--dry-run]
6809
6994
  merge another outcome log, remapping colliding ids
6995
+ and repairing stale MADR Decision History references
6810
6996
  driftseal absorb --git <base> <ours> <theirs>
6811
6997
  git merge driver for .seal/outcomes/events.jsonl
6812
6998
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "3.2.0",
3
+ "version": "3.3.5",
4
4
  "description": "Seal outcomes, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",
@@ -44,8 +44,12 @@ driftseal help
44
44
  ## After a merge
45
45
 
46
46
  If `status` or `log` fails with a duplicate id or with `multiple outcomes in
47
- progress`, or the outcome log has conflict markers, run `driftseal absorb`
48
- instead of editing `.seal/outcomes/events.jsonl`. When both sides still have an
47
+ progress`, or the outcome log has conflict markers, or Git stops because
48
+ decision ids or Decision History need worktree repair, run `driftseal absorb`
49
+ instead of editing `.seal/outcomes/events.jsonl`. A merge can leave the log
50
+ healthy while MADR Decision History still quotes pre-merge outcome ids; `absorb`
51
+ (and `absorb --dry-run`) repairs those references even when ids are already
52
+ unique. When both sides still have an
49
53
  open outcome, add `--abandon-theirs` or `--abandon-ours`; this works whether your
50
54
  open outcome sits in the log or is parked beside the WAL. `driftseal init` also
51
55
  configures the local git merge driver; clones need `init` again for that driver.