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 +14 -1
- package/README.zh-CN.md +12 -1
- package/bin/driftseal-mcp.js +10 -2
- package/bin/driftseal.js +203 -23
- package/package.json +2 -1
- package/skills/use-driftseal/SKILL.md +6 -2
- package/test/package-smoke.js +26 -9
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
|
|
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
|
package/bin/driftseal-mcp.js
CHANGED
|
@@ -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 ${
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
4869
|
+
oursRecords.map((record) => record.event),
|
|
4757
4870
|
streams.theirsNew
|
|
4758
4871
|
),
|
|
4759
|
-
|
|
4872
|
+
oursRecords.map((record) => record.event),
|
|
4760
4873
|
decisionPlan.decisionMap,
|
|
4761
4874
|
decisionPlan.hashMap
|
|
4762
4875
|
);
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
4930
|
-
theirsEntries:
|
|
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
|
|
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.
|
|
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,
|
|
48
|
-
|
|
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.
|
package/test/package-smoke.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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');
|