driftseal 3.1.0 → 3.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.
package/README.md CHANGED
@@ -47,6 +47,9 @@ npm install --global driftseal
47
47
  driftseal --version
48
48
  ```
49
49
 
50
+ The package also installs `ds` as a short alias for `driftseal`. Both commands use
51
+ the same CLI; for example, `ds status` is equivalent to `driftseal status`.
52
+
50
53
  From a source checkout:
51
54
 
52
55
  ```sh
@@ -213,7 +216,7 @@ driftseal decision update 1 --status accepted --note "Confirmed by the final imp
213
216
  | `driftseal lane add\|switch\|assign\|show` | Partition history by long-lived capability. |
214
217
  | `driftseal reclaim [id ...] --reason "..." [--force]` | Hide meaningless closed records with append-only markers. |
215
218
  | `driftseal unreclaim <id> --reason "..."` | Restore a reclaimed record. |
216
- | `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. |
217
220
  | `driftseal decision add\|update\|list\|show` | Manage MADR records. |
218
221
  | `driftseal migrate v1-to-v2 inspect --json [migration paths]` | Normalize v1 state for model-assisted grouping. |
219
222
  | `driftseal migrate v1-to-v2 apply --plan <file> [migration paths]` | Validate a grouping plan and stage the v2 seal beside v1. |
@@ -311,6 +314,16 @@ ids, rebinds affected contract hashes, and refuses concurrent edits of a shared
311
314
  MADR. If both lineages remain open, choose explicitly with `--abandon-theirs` or
312
315
  `--abandon-ours`.
313
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
+
314
327
  ## Node API and MCP
315
328
 
316
329
  ```js
package/README.zh-CN.md CHANGED
@@ -41,6 +41,9 @@ npm install --global driftseal
41
41
  driftseal --version
42
42
  ```
43
43
 
44
+ 安装后也可以用短别名 `ds`,它与 `driftseal` 使用同一个 CLI 入口。
45
+ 例如,`ds status` 等同于 `driftseal status`。
46
+
44
47
  在源码 checkout 中使用:
45
48
 
46
49
  ```sh
@@ -193,7 +196,7 @@ driftseal decision update 1 --status accepted --note "Confirmed by the final imp
193
196
  | `driftseal lane add\|switch\|assign\|show` | 按长期能力切分历史。 |
194
197
  | `driftseal reclaim [id ...] --reason "..." [--force]` | 通过 append-only marker 隐藏无意义的已关闭记录。 |
195
198
  | `driftseal unreclaim <id> --reason "..."` | 恢复 reclaimed record。 |
196
- | `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 引用。 |
197
200
  | `driftseal decision add\|update\|list\|show` | 管理 MADR。 |
198
201
  | `driftseal migrate v1-to-v2 inspect --json [migration paths]` | 规范化 v1 状态,供模型分组。 |
199
202
  | `driftseal migrate v1-to-v2 apply --plan <file> [migration paths]` | 校验分组计划,并在 v1 旁边创建 v2 seal。 |
@@ -281,6 +284,14 @@ driftseal absorb
281
284
  contract hash,并拒绝自动合并 shared MADR 的并发编辑。若两条 lineage 都处于 open
282
285
  状态,必须显式选择 `--abandon-theirs` 或 `--abandon-ours`。
283
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
+
284
295
  ## Node API 与 MCP
285
296
 
286
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
  }
@@ -3421,6 +3442,7 @@ const SKILL_RELEASE_DIGESTS = new Set([
3421
3442
  'b4b2cc27ea71c5777b5eb9b67d861fbe1da43f31ab5d422d6a877e0cad592493', // 2.1.0 lane re-anchor recovery
3422
3443
  '6c49523c2f4a36ea1cd9d0b4e793ea3cdbd9d299174cf1f9a0a54f3d9a7cd331', // workspace park sidecar wording
3423
3444
  '678cbaa8380f75f7a45d634010b500e51a59b06ea759eaf23cff2980142662f0', // default-seal parking help wording
3445
+ 'b5f2379fba311e19a5139dd6eb80795473fa6754fa959f90d4a36cc273b56e8f', // absorb Decision History repair
3424
3446
  ]);
3425
3447
 
3426
3448
  function skillInstallUsage() {
@@ -4226,6 +4248,75 @@ function rewriteDecisionId(content, newId) {
4226
4248
  return content.replace(/^# [0-9]+\. /, `# ${String(BigInt(newId))}. `);
4227
4249
  }
4228
4250
 
4251
+ function decisionIdFromFile(file) {
4252
+ const match = path.basename(file).match(/^(\d{4,})-/);
4253
+ if (!match) fail(`invalid decision filename: ${file}`);
4254
+ return normalizeDecisionId(match[1]);
4255
+ }
4256
+
4257
+ function reconciliationOutcomes(records) {
4258
+ return new Map(records
4259
+ .filter((record) => record.event.type === 'decision_reconcile_prepare')
4260
+ .map((record) => [record.event.reconciliationId, record.event]));
4261
+ }
4262
+
4263
+ function rewriteDecisionHistory(content, decisionId, outcomes) {
4264
+ let repairs = 0;
4265
+ const rewritten = content.replace(
4266
+ /^(<!-- [a-z][a-z0-9-]*-reconciliation: ([^>\r\n]+) -->\r?\n### [^\r\n]+ — (?:Outcome|Intent) `)(\d{4}-\d{2}-\d{2}-\d{3,})(`\r?$)/gm,
4267
+ (entry, prefix, reconciliationId, outcomeId, suffix) => {
4268
+ const prepare = outcomes.get(reconciliationId);
4269
+ if (!prepare || normalizeDecisionId(prepare.decisionId) !== decisionId || prepare.id === outcomeId) {
4270
+ return entry;
4271
+ }
4272
+ repairs++;
4273
+ return `${prefix}${prepare.id}${suffix}`;
4274
+ }
4275
+ );
4276
+ return { content: rewritten, repairs };
4277
+ }
4278
+
4279
+ function planDecisionHistoryRepair(records, copies, entries) {
4280
+ const outcomes = reconciliationOutcomes(records);
4281
+ const targets = new Map(entries.map((entry) => [entry.file, entry]));
4282
+ const plannedCopies = new Map(copies.map((copy) => [copy.toFile, copy]));
4283
+ const hashMap = new Map();
4284
+ let repairs = 0;
4285
+ for (const copy of copies) {
4286
+ if (copy.removeFile) targets.delete(copy.removeFile);
4287
+ }
4288
+ for (const copy of copies) {
4289
+ targets.set(copy.toFile, {
4290
+ file: copy.toFile,
4291
+ id: decisionIdFromFile(copy.toFile),
4292
+ content: copy.content,
4293
+ });
4294
+ }
4295
+ for (const entry of targets.values()) {
4296
+ const content = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
4297
+ const rewritten = rewriteDecisionHistory(content, entry.id, outcomes);
4298
+ if (rewritten.repairs === 0) continue;
4299
+ repairs += rewritten.repairs;
4300
+ // Rebind only hashes that attest the exact pre-repair bytes. Unrelated edits
4301
+ // must still fail reconciliation recovery or closure after this repair.
4302
+ hashMap.set(contentHash(content), contentHash(rewritten.content));
4303
+ plannedCopies.set(entry.file, {
4304
+ fromFile: entry.file,
4305
+ toFile: entry.file,
4306
+ removeFile: null,
4307
+ ...plannedCopies.get(entry.file),
4308
+ content: rewritten.content,
4309
+ });
4310
+ }
4311
+ const unchangedIds = new Map();
4312
+ const rewrittenRecords = records.map((record) =>
4313
+ ['oldHash', 'newHash', 'fileHash'].some((field) => hashMap.has(record.event[field]))
4314
+ ? { event: remapEvent(record.event, unchangedIds, unchangedIds, hashMap) }
4315
+ : record
4316
+ );
4317
+ return { records: rewrittenRecords, copies: [...plannedCopies.values()], repairs };
4318
+ }
4319
+
4229
4320
  function splitDuplicateDecisions(entries) {
4230
4321
  const ours = [];
4231
4322
  const theirs = [];
@@ -4676,6 +4767,7 @@ function finishAbsorb({
4676
4767
  outcomeCount,
4677
4768
  allowConflict = false,
4678
4769
  followupMessage = null,
4770
+ repairDecisionHistory = true,
4679
4771
  }) {
4680
4772
  // A parked outcome is local even though the tracked log never saw it.
4681
4773
  const park = shouldAttachInProgress(outputFile)
@@ -4703,33 +4795,52 @@ function finishAbsorb({
4703
4795
  // A parked overlay with nothing left open in it belongs in the tracked log, whether the
4704
4796
  // abandon flag just closed it or an interrupted end left it closed.
4705
4797
  const flushOverlay = parkedClosed || (overlay.length > 0 && !parkedOpen);
4706
- const merged = flushOverlay ? [...result, ...overlay] : result;
4707
4798
  const effective = [...result, ...overlay].map((record) => record.event);
4708
4799
  fold(effective);
4709
4800
  if (!conflict) openOutcome(fold(effective));
4801
+ const history = repairDecisionHistory
4802
+ ? planDecisionHistoryRepair(
4803
+ [...result, ...overlay], copies, listDecisionEntries(decisionDir(), { allowDuplicates: true })
4804
+ )
4805
+ : { records: [...result, ...overlay], copies, repairs: 0 };
4806
+ const repairedResult = history.records.slice(0, result.length);
4807
+ const repairedOverlay = history.records.slice(result.length);
4808
+ const merged = flushOverlay ? history.records : repairedResult;
4809
+ fold(history.records.map((record) => record.event));
4710
4810
  if (!dryRun) {
4711
4811
  writeJsonl(outputFile, merged);
4712
- applyDecisionCopies(copies, dryRun);
4812
+ applyDecisionCopies(history.copies, dryRun);
4713
4813
  if (plan) {
4714
4814
  if (plan.alreadyCommitted || flushOverlay) discardInProgressLog(park);
4715
- else if (plan.mappings.length > 0) writeJsonl(park, overlay);
4815
+ else if (plan.mappings.length > 0 || repairedOverlay.some((record, i) => record !== overlay[i])) {
4816
+ writeJsonl(park, repairedOverlay);
4817
+ }
4716
4818
  }
4717
4819
  }
4820
+ const historyOnly =
4821
+ history.repairs > 0 &&
4822
+ outcomeCount === 0 &&
4823
+ allMappings.length === 0 &&
4824
+ !abandoned &&
4825
+ !flushOverlay;
4718
4826
  if (
4719
4827
  outcomeCount === 0 &&
4720
4828
  allMappings.length === 0 &&
4721
- copies.length === 0 &&
4829
+ history.copies.length === 0 &&
4722
4830
  !abandoned &&
4723
4831
  !flushOverlay
4724
4832
  ) {
4725
4833
  printLine('nothing to absorb');
4726
- } else {
4834
+ } else if (!historyOnly) {
4727
4835
  printAbsorbReport({
4728
4836
  mappings: allMappings,
4729
4837
  abandoned,
4730
4838
  outcomeCount,
4731
4839
  });
4732
4840
  }
4841
+ if (history.repairs > 0) {
4842
+ printLine(`${dryRun ? 'would repair' : 'repaired'} ${history.repairs} decision history reference(s)`);
4843
+ }
4733
4844
  if (conflict) {
4734
4845
  printLine('multiple outcomes remain in progress; re-run with --abandon-theirs or --abandon-ours');
4735
4846
  }
@@ -4737,13 +4848,14 @@ function finishAbsorb({
4737
4848
  return {
4738
4849
  mappings: allMappings,
4739
4850
  abandoned,
4740
- copies: copies.map((item) => item.toFile),
4851
+ copies: history.copies.map((item) => item.toFile),
4741
4852
  outputFile,
4853
+ repairs: history.repairs,
4742
4854
  exitCode: conflict || followupMessage ? 1 : 0,
4743
4855
  };
4744
4856
  }
4745
4857
 
4746
- function absorbFromStreams(ours, theirs, baseRecords, options) {
4858
+ function planAbsorbMerge(ours, theirs, baseRecords, options = {}) {
4747
4859
  const streams = mergeRecordStreams(ours, theirs, baseRecords);
4748
4860
  const decisionPlan = planDecisionAbsorb({
4749
4861
  oursEntries: options.oursDecisionEntries || [],
@@ -4751,28 +4863,65 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
4751
4863
  baseEntries: options.baseDecisionEntries || [],
4752
4864
  baseIds: options.baseDecisionIds || new Set(),
4753
4865
  });
4866
+ const oursRecords = [...streams.base, ...streams.oursNew];
4754
4867
  const remapped = remapTheirsRecords(
4755
4868
  dropDuplicateLaneAdds(
4756
- [...streams.base, ...streams.oursNew].map((record) => record.event),
4869
+ oursRecords.map((record) => record.event),
4757
4870
  streams.theirsNew
4758
4871
  ),
4759
- [...streams.base, ...streams.oursNew].map((record) => record.event),
4872
+ oursRecords.map((record) => record.event),
4760
4873
  decisionPlan.decisionMap,
4761
4874
  decisionPlan.hashMap
4762
4875
  );
4763
- const result = [...streams.base, ...streams.oursNew, ...remapped.records];
4764
- return finishAbsorb({
4765
- result,
4766
- oursRecords: [...streams.base, ...streams.oursNew],
4876
+ return {
4877
+ decisionPlan,
4878
+ remapped,
4879
+ result: [...oursRecords, ...remapped.records],
4880
+ oursRecords,
4767
4881
  theirsRecords: [...streams.base, ...remapped.records],
4768
4882
  mappings: [...remapped.mappings, ...decisionPlan.mappings],
4769
- copies: decisionPlan.copies,
4883
+ outcomeCount: streams.theirsNew.filter((record) => isOutcomeStart(record.event)).length,
4884
+ };
4885
+ }
4886
+
4887
+ function previewParkedOverlay(result, outputFile) {
4888
+ // Git merge drivers often receive a temporary %A that is not the live WAL, so
4889
+ // parked history still has to be previewed from the worktree sidecar.
4890
+ const park = shouldAttachInProgress(outputFile)
4891
+ ? existingInProgressFileForLog(outputFile)
4892
+ : existingInProgressFile();
4893
+ if (!park) return [];
4894
+ const plan = planInProgressOverlay(
4895
+ result.map((record) => record.event),
4896
+ park,
4897
+ { repairTail: true, readOnly: true }
4898
+ );
4899
+ return plan && !plan.alreadyCommitted ? plan.records : [];
4900
+ }
4901
+
4902
+ function decisionHistoryNeedsRepair(records, entries) {
4903
+ const outcomes = reconciliationOutcomes(records);
4904
+ return entries.some((entry) => {
4905
+ const content = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
4906
+ return rewriteDecisionHistory(content, entry.id, outcomes).repairs > 0;
4907
+ });
4908
+ }
4909
+
4910
+ function absorbFromStreams(ours, theirs, baseRecords, options) {
4911
+ const merge = planAbsorbMerge(ours, theirs, baseRecords, options);
4912
+ return finishAbsorb({
4913
+ result: merge.result,
4914
+ oursRecords: merge.oursRecords,
4915
+ theirsRecords: merge.theirsRecords,
4916
+ mappings: merge.mappings,
4917
+ copies: merge.decisionPlan.copies,
4770
4918
  abandon: options.abandon,
4771
4919
  dryRun: options.dryRun,
4772
4920
  outputFile: options.outputFile,
4773
4921
  allowConflict: options.allowConflict,
4774
4922
  followupMessage: options.followupMessage,
4775
- outcomeCount: streams.theirsNew.filter((record) => ['begin', 'import'].includes(record.event.type)).length,
4923
+ repairDecisionHistory: options.repairDecisionHistory,
4924
+ outcomeCount: merge.outcomeCount,
4776
4925
  });
4777
4926
  }
4778
4927
 
@@ -4920,14 +5069,19 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
4920
5069
  gitFindCommitForFile(theirsFile, '.seal/outcomes/events.jsonl') ||
4921
5070
  gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
4922
5071
  const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
5072
+ // Driver merge remaps the jsonl only. Decision copies stay with a later
5073
+ // worktree absorb so Git can finish merging MADR files first.
5074
+ const merge = planAbsorbMerge(ours.records, theirs.records, base.records, {});
4923
5075
  let followupMessage = null;
4924
5076
  if (!otherHead) {
4925
5077
  followupMessage =
4926
5078
  'incoming Git tree could not be identified safely; re-run driftseal absorb after Git stops the merge';
4927
5079
  } else {
5080
+ const oursDecisionEntries = gitDecisionEntries('HEAD');
5081
+ const theirsDecisionEntries = gitDecisionEntries(otherHead);
4928
5082
  const decisionPlan = planDecisionAbsorb({
4929
- oursEntries: gitDecisionEntries('HEAD'),
4930
- theirsEntries: gitDecisionEntries(otherHead),
5083
+ oursEntries: oursDecisionEntries,
5084
+ theirsEntries: theirsDecisionEntries,
4931
5085
  baseEntries: mergeBase ? gitDecisionEntries(mergeBase) : [],
4932
5086
  baseIds: mergeBase
4933
5087
  ? gitDecisionIds(mergeBase)
@@ -4939,14 +5093,33 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
4939
5093
  if (requiresDecisionRepair) {
4940
5094
  followupMessage =
4941
5095
  'decision ids require worktree repair; run driftseal absorb, then stage the repaired logs';
5096
+ } else {
5097
+ const overlay = previewParkedOverlay(merge.result, oursFile);
5098
+ const worktreeEntries = listDecisionEntries(decisionDir(), { allowDuplicates: true });
5099
+ if (decisionHistoryNeedsRepair(
5100
+ [...merge.result, ...overlay],
5101
+ [...oursDecisionEntries, ...theirsDecisionEntries, ...worktreeEntries]
5102
+ )) {
5103
+ followupMessage =
5104
+ 'decision history requires worktree repair; run driftseal absorb, then stage the repaired logs';
5105
+ }
4942
5106
  }
4943
5107
  }
4944
- return absorbFromStreams(ours.records, theirs.records, base.records, {
5108
+ return finishAbsorb({
5109
+ result: merge.result,
5110
+ oursRecords: merge.oursRecords,
5111
+ theirsRecords: merge.theirsRecords,
5112
+ mappings: merge.mappings,
5113
+ copies: merge.decisionPlan.copies,
4945
5114
  abandon,
4946
5115
  dryRun,
4947
5116
  outputFile: oursFile,
4948
5117
  allowConflict: !abandon,
4949
5118
  followupMessage,
5119
+ // Git owns the worktree MADR merge. Stop for a normal absorb when history
5120
+ // needs repair rather than racing Git by writing other paths from its driver.
5121
+ repairDecisionHistory: false,
5122
+ outcomeCount: merge.outcomeCount,
4950
5123
  });
4951
5124
  }
4952
5125
 
@@ -6672,6 +6845,8 @@ const commands = {
6672
6845
  ...sourceLanguages.flatMap((source) => [
6673
6846
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
6674
6847
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6848
+ protocolEol(intentProtocolBlockV21AbsorbCollisions(PROTOCOL_VERSION, source), eol),
6849
+ protocolEol(intentProtocolBlockV21AbsorbCollisions(PROTOCOL_VERSION, source, true), eol),
6675
6850
  protocolEol(intentProtocolBlockV20(source), eol),
6676
6851
  protocolEol(intentProtocolBlockV20(source, true), eol),
6677
6852
  protocolEol(v1IntentProtocolBlock(14, source), eol),
@@ -6706,6 +6881,8 @@ const commands = {
6706
6881
  ...sourceLanguages.flatMap((source) => [
6707
6882
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
6708
6883
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6884
+ protocolEol(decisionProtocolBlockAbsorbCollisions(PROTOCOL_VERSION, source), eol),
6885
+ protocolEol(decisionProtocolBlockAbsorbCollisions(PROTOCOL_VERSION, source, true), eol),
6709
6886
  protocolEol(decisionProtocolBlock('2.0', source), eol),
6710
6887
  protocolEol(decisionProtocolBlock('2.0', source, true), eol),
6711
6888
  protocolEol(v1DecisionProtocolBlock(14, source), eol),
@@ -6781,6 +6958,8 @@ const commands = {
6781
6958
 
6782
6959
  Outcome-level write-ahead log for agent sessions.
6783
6960
 
6961
+ The ds command is an alias for driftseal; all commands and options are identical.
6962
+
6784
6963
  usage:
6785
6964
  driftseal begin "<outcome>" [--accept "<observable result>"] [--verify "<command>"]
6786
6965
  [--decision <id>] [--force]
@@ -6805,6 +6984,7 @@ usage:
6805
6984
  driftseal absorb [other-events.jsonl] [--decisions <dir>]
6806
6985
  [--abandon-theirs | --abandon-ours] [--dry-run]
6807
6986
  merge another outcome log, remapping colliding ids
6987
+ and repairing stale MADR Decision History references
6808
6988
  driftseal absorb --git <base> <ours> <theirs>
6809
6989
  git merge driver for .seal/outcomes/events.jsonl
6810
6990
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Seal outcomes, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",
@@ -26,6 +26,7 @@
26
26
  "main": "index.js",
27
27
  "bin": {
28
28
  "driftseal": "bin/driftseal.js",
29
+ "ds": "bin/driftseal.js",
29
30
  "driftseal-mcp": "bin/driftseal-mcp.js"
30
31
  },
31
32
  "directories": {
@@ -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.
@@ -112,16 +112,32 @@ try {
112
112
  DRIFTSEAL_HOME: home,
113
113
  DRIFTSEAL_DECISION_HOME: path.join(home, 'madr'),
114
114
  };
115
+ for (const name of ['driftseal', 'ds', 'driftseal-mcp']) {
116
+ const executable = process.platform === 'win32' ? `${name}.cmd` : name;
117
+ assert.equal(
118
+ fs.existsSync(path.join(consumer, 'node_modules', '.bin', executable)),
119
+ true,
120
+ `${name} is installed as an executable`
121
+ );
122
+ }
123
+ function runInstalled(name, args) {
124
+ return runNpm(['exec', '--offline', '--prefix', consumer, '--', name, ...args], {
125
+ cwd: sandbox,
126
+ env,
127
+ });
128
+ }
129
+ const metadata = require(path.join(installed, 'package.json'));
130
+ for (const name of ['driftseal', 'ds']) {
131
+ assert.equal(runInstalled(name, ['--version']), `${metadata.version}\n`);
132
+ }
133
+ const help = runInstalled('driftseal', ['--help']);
134
+ assert.match(help, /The ds command is an alias for driftseal/);
135
+ assert.equal(runInstalled('ds', ['--help']), help);
136
+
115
137
  const cli = path.join(installed, 'bin', 'driftseal.js');
116
- run(process.execPath, [cli, 'begin', 'packaged sqlite smoke'], {
117
- cwd: sandbox,
118
- env,
119
- });
120
- run(
121
- process.execPath,
122
- [cli, 'end', '--status', 'abandoned', '--note', 'packaged smoke'],
123
- { cwd: sandbox, env }
124
- );
138
+ runInstalled('ds', ['begin', 'packaged sqlite smoke']);
139
+ assert.match(runInstalled('driftseal', ['status']), /packaged sqlite smoke/);
140
+ runInstalled('driftseal', ['end', '--status', 'abandoned', '--note', 'packaged smoke']);
125
141
  const logged = spawnSync(process.execPath, [cli, 'log', '--last', '1'], {
126
142
  cwd: sandbox,
127
143
  env,
@@ -130,6 +146,7 @@ try {
130
146
  });
131
147
  assert.equal(logged.status, 0, logged.stderr);
132
148
  assert.match(logged.stdout, /packaged sqlite smoke/);
149
+ assert.equal(runInstalled('ds', ['log', '--last', '1']), logged.stdout);
133
150
  assert.doesNotMatch(logged.stderr, /SQLite is an experimental feature/);
134
151
  assert.equal(fs.existsSync(indexFile), true);
135
152
  fs.writeFileSync(indexFile, 'corrupt package smoke index');