driftseal 1.0.0 → 1.1.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
@@ -34,7 +34,9 @@ cd your-project
34
34
  driftseal init
35
35
  ```
36
36
 
37
- `driftseal init` safely adds the protocol to `AGENTS.md` and can be run again without duplicating it. DriftSeal requires Node.js 18+.
37
+ `driftseal init` writes the protocol to `AGENTS.md`, including how to `absorb`
38
+ colliding worktree logs, and configures the local git merge driver. It can be
39
+ run again without duplicating either. DriftSeal requires Node.js 18+.
38
40
 
39
41
  For local development from this checkout:
40
42
 
@@ -207,6 +209,8 @@ Single-step commands that only build, check, or record work already done — com
207
209
  | `driftseal log [-n N] [--all]` | Review intent history (`--all` includes reclaimed records). |
208
210
  | `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | Hide meaningless closed records behind append-only markers. |
209
211
  | `driftseal unreclaim <id> --reason "..."` | Restore a reclaimed record to the visible log. |
212
+ | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | Merge another worktree's logs, remapping colliding intent and decision ids. |
213
+ | `driftseal absorb --git <base> <ours> <theirs>` | Git merge driver for `.intent-log/events.jsonl`. |
210
214
  | `driftseal decision add "<title>" -c "..." -o "..."` | Write a numbered MADR decision. |
211
215
  | `driftseal decision update <id> [-s status] -n "..."` | Reconcile a linked decision in the open intent. |
212
216
  | `driftseal decision list [-s status] [--last N \| --count]` | List or count decision records, optionally filtered by status. |
@@ -215,7 +219,7 @@ Single-step commands that only build, check, or record work already done — com
215
219
  | `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | Install the repository-pinned MCP server into Codex, Kimi Code, OpenCode, Claude Code, or Cursor. |
216
220
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
217
221
  | `driftseal hook prompt\|stop [--format plain\|claude-code]` | Emit the reminder a lifecycle hook injects; never blocks. |
218
- | `driftseal init` | Add the adoption protocol to `AGENTS.md`. |
222
+ | `driftseal init` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. |
219
223
  | `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
220
224
  | `driftseal help` | Print CLI usage. |
221
225
 
@@ -269,9 +273,31 @@ contents. Status-filtered listing and counting parse all records because status
269
273
  is stored in each MADR document; DriftSeal does not maintain a stale-prone sidecar
270
274
  index.
271
275
 
276
+ ## Merging worktrees
277
+
278
+ Two worktrees allocate intent and decision ids from their local logs, so a
279
+ same-day parallel `begin` or `decision add` can collide when the branches
280
+ merge. `driftseal absorb` rebuilds a valid log by keeping our ids and remapping
281
+ the incoming side, then prints the mapping. A single lineage stays append-only;
282
+ absorb is the one cross-lineage rewrite.
283
+
284
+ ```sh
285
+ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
286
+ --decisions ../other-worktree/.decision-log
287
+ ```
288
+
289
+ With no path, `absorb` repairs the current log after a git conflict or a
290
+ concatenated duplicate. If both sides still have an open intent, pass
291
+ `--abandon-theirs` or `--abandon-ours`. Concurrent edits of a decision that
292
+ already existed in the shared base are not auto-merged.
293
+
294
+ `driftseal init` writes that absorb rule into `AGENTS.md`, plus `.gitattributes`
295
+ and the local git merge driver so `events.jsonl` merges through `absorb --git`.
296
+ Clones need `init` again because the driver lives in local git config.
297
+
272
298
  ## Storage
273
299
 
274
- - `.intent-log/events.jsonl` is the append-only intent log. All access goes through `driftseal` (CLI or MCP) — never read, edit, move, or delete it directly; use `driftseal reclaim` to retire meaningless records instead of deleting log lines.
300
+ - `.intent-log/events.jsonl` is the append-only intent log. All access goes through `driftseal` (CLI or MCP) — never read, edit, move, or delete it directly; use `driftseal reclaim` to retire meaningless records instead of deleting log lines. After a merge collision, use `driftseal absorb` instead of editing the file.
275
301
  - `.decision-log/` contains numbered MADR decision records.
276
302
  - Set `DRIFTSEAL_HOME` or `DRIFTSEAL_DECISION_HOME` to store either log outside the current project.
277
303
 
package/README.zh-CN.md CHANGED
@@ -34,7 +34,8 @@ cd your-project
34
34
  driftseal init
35
35
  ```
36
36
 
37
- `driftseal init` 会安全地把协议加入 `AGENTS.md`,重复运行也不会产生副本。DriftSeal 需要 Node.js 18+。
37
+ `driftseal init` 会把协议写入 `AGENTS.md`(包括 worktree 撞号后如何 `absorb`),
38
+ 并配置 local git merge driver。重复运行不会产生副本。DriftSeal 需要 Node.js 18+。
38
39
 
39
40
  从当前 checkout 本地开发时:
40
41
 
@@ -201,6 +202,8 @@ driftseal end \
201
202
  | `driftseal log [-n N] [--all]` | 查看 intent 历史(`--all` 包含已回收的记录)。 |
202
203
  | `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | 用 append-only 标记隐藏已无意义的已关闭记录。 |
203
204
  | `driftseal unreclaim <id> --reason "..."` | 把已回收的记录恢复到可见历史中。 |
205
+ | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | 合并另一条 worktree 的日志,并给撞号的 intent / decision id 重新编号。 |
206
+ | `driftseal absorb --git <base> <ours> <theirs>` | `.intent-log/events.jsonl` 的 git merge driver。 |
204
207
  | `driftseal decision add "<title>" -c "..." -o "..."` | 写入编号化的 MADR decision。 |
205
208
  | `driftseal decision update <id> [-s status] -n "..."` | 在当前 intent 中 reconcile 已关联的 decision。 |
206
209
  | `driftseal decision list [-s status] [--last N \| --count]` | 列出或统计 decision records,也可按 status 筛选。 |
@@ -209,7 +212,7 @@ driftseal end \
209
212
  | `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
210
213
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
211
214
  | `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
212
- | `driftseal init` | 把接入协议写入 `AGENTS.md`。 |
215
+ | `driftseal init` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。 |
213
216
  | `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
214
217
  | `driftseal help` | 查看 CLI 用法。 |
215
218
 
@@ -255,9 +258,24 @@ recovery 只处理当前 intent,因此历史冲突不会阻塞之后的 decisi
255
258
  时仍需解析全部 records,因为 status 保存在各个 MADR 文档中;DriftSeal 不维护容易
256
259
  滞后的 sidecar index。
257
260
 
261
+ ## 合并 worktree
262
+
263
+ 两条 worktree 各自按本地日志分配 intent / decision id,同一天并行 `begin` 或 `decision add`,分支合并时就会撞号。`driftseal absorb` 保留我方编号、给进来的一侧重编号,并打印对照表。单线 WAL 仍然只追加;absorb 是唯一允许的跨谱系重写。
264
+
265
+ ```sh
266
+ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
267
+ --decisions ../other-worktree/.decision-log
268
+ ```
269
+
270
+ 不带 path 时,`absorb` 修复当前日志里的冲突标记或已经拼在一起的重复 id。两边都还有未关闭 intent 时,必须加上 `--abandon-theirs` 或 `--abandon-ours`。共享 base 里已经存在、又被两边同时改过的 decision,不会自动合并。
271
+
272
+ `driftseal init` 会把这条 absorb 规则写入 `AGENTS.md`,同时写入 `.gitattributes`
273
+ 并配置 local git merge driver,让 `events.jsonl` 走 `absorb --git`。clone 之后
274
+ 需要再跑一次 `init`,因为 driver 只存在于 local git config。
275
+
258
276
  ## 数据保存在哪里
259
277
 
260
- - `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim`,而不是删除日志行。
278
+ - `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim`,而不是删除日志行。合并撞号时用 `driftseal absorb`,不要手改这个文件。
261
279
  - `.decision-log/`:编号化的 MADR decision records。
262
280
  - 设置 `DRIFTSEAL_HOME` 或 `DRIFTSEAL_DECISION_HOME`,即可把对应 log 放到当前项目之外。
263
281
 
package/bin/driftseal.js CHANGED
@@ -36,7 +36,7 @@ const DECISION_STATUSES = [
36
36
  'superseded',
37
37
  ];
38
38
  const EVENT_SCHEMA_VERSION = 3;
39
- const PROTOCOL_VERSION = 8;
39
+ const PROTOCOL_VERSION = 9;
40
40
  const LOCK_STALE_MS = 30 * 60 * 1000;
41
41
  const LOCK_INIT_STALE_MS = 5 * 1000;
42
42
  const MAX_DECISION_SLUG_LENGTH = 180;
@@ -213,15 +213,19 @@ function readEvents({ repairTail = false, file = logFile() } = {}) {
213
213
  content = content.slice(0, validLength);
214
214
  }
215
215
  }
216
+ return parseJsonlRecords(content, file).map((record) => record.event);
217
+ }
218
+
219
+ function parseJsonlRecords(content, source = 'log') {
216
220
  return content
217
221
  .split('\n')
218
222
  .filter((line) => line.trim().length > 0)
219
223
  .map((line, i) => {
220
224
  try {
221
- return normalizeEvent(JSON.parse(line), i + 1);
225
+ return { raw: line, event: normalizeEvent(JSON.parse(line), i + 1) };
222
226
  } catch (err) {
223
227
  if (err instanceof DriftSealError) throw err;
224
- fail(`corrupt log line ${i + 1} in ${file}`);
228
+ fail(`corrupt log line ${i + 1} in ${source}`);
225
229
  }
226
230
  });
227
231
  }
@@ -714,16 +718,26 @@ function openIntent(records) {
714
718
  return open[0] || null;
715
719
  }
716
720
 
717
- function nextId(events) {
718
- const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
721
+ function parseIntentId(id) {
722
+ const match = String(id).match(/^(\d{4}-\d{2}-\d{2})-(\d+)$/);
723
+ if (!match) fail(`invalid intent id: ${id}`);
724
+ return { date: match[1], seq: Number.parseInt(match[2], 10) };
725
+ }
726
+
727
+ function nextIdForDate(date, events) {
719
728
  let maxSeq = 0;
729
+ const prefix = `${date}-`;
720
730
  for (const ev of events) {
721
- if (ev.type === 'begin' && typeof ev.id === 'string' && ev.id.startsWith(today + '-')) {
722
- const seq = parseInt(ev.id.slice(today.length + 1), 10);
731
+ if (ev.type === 'begin' && typeof ev.id === 'string' && ev.id.startsWith(prefix)) {
732
+ const seq = Number.parseInt(ev.id.slice(prefix.length), 10);
723
733
  if (Number.isFinite(seq) && seq > maxSeq) maxSeq = seq;
724
734
  }
725
735
  }
726
- return `${today}-${String(maxSeq + 1).padStart(3, '0')}`;
736
+ return `${date}-${String(maxSeq + 1).padStart(3, '0')}`;
737
+ }
738
+
739
+ function nextId(events) {
740
+ return nextIdForDate(new Date().toISOString().slice(0, 10), events);
727
741
  }
728
742
 
729
743
  function normalizeDecisionId(value) {
@@ -771,25 +785,31 @@ function compareDecisionEntries(a, b) {
771
785
  return a.id.length - b.id.length || a.id.localeCompare(b.id) || a.file.localeCompare(b.file);
772
786
  }
773
787
 
774
- function decisionIndex() {
775
- if (!fs.existsSync(decisionDir())) return [];
788
+ function listDecisionEntries(dir, { allowDuplicates = false } = {}) {
789
+ if (!dir || !fs.existsSync(dir)) return [];
776
790
  const entries = [];
777
791
  const ids = new Map();
778
- for (const entry of fs.readdirSync(decisionDir(), { withFileTypes: true })) {
792
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
779
793
  const match = entry.name.match(/^(\d{4,})-.*\.md$/);
780
794
  if (!match) continue;
781
- const fullPath = path.join(decisionDir(), entry.name);
795
+ const fullPath = path.join(dir, entry.name);
782
796
  const stat = fs.lstatSync(fullPath);
783
797
  if (stat.isSymbolicLink()) fail(`decision record must not be a symbolic link: ${entry.name}`);
784
798
  if (!stat.isFile()) fail(`decision record is not a regular file: ${entry.name}`);
785
799
  const id = normalizeDecisionId(match[1]);
786
- if (ids.has(id)) fail(`duplicate decision id ${id}: ${ids.get(id)}, ${entry.name}`);
787
- ids.set(id, entry.name);
800
+ if (ids.has(id) && !allowDuplicates) {
801
+ fail(`duplicate decision id ${id}: ${ids.get(id)}, ${entry.name}`);
802
+ }
803
+ if (!ids.has(id)) ids.set(id, entry.name);
788
804
  entries.push({ id, file: entry.name, path: fullPath });
789
805
  }
790
806
  return entries.sort(compareDecisionEntries);
791
807
  }
792
808
 
809
+ function decisionIndex() {
810
+ return listDecisionEntries(decisionDir());
811
+ }
812
+
793
813
  function readDecision(entry) {
794
814
  const noFollow = fs.constants.O_NOFOLLOW || 0;
795
815
  let fd;
@@ -1146,13 +1166,23 @@ MCP and lifecycle hooks are optional adapters.
1146
1166
  \`driftseal\` commands or the MCP tools. Retire meaningless closed records with
1147
1167
  \`driftseal reclaim [id ...] --reason "<why>"\` — it appends a marker, never
1148
1168
  deletes log lines; \`driftseal unreclaim <id> --reason "<why>"\` restores one.
1169
+ After a merge collision, run \`driftseal absorb\` rather than editing the log;
1170
+ if both sides still have an open intent, add \`--abandon-theirs\` or
1171
+ \`--abandon-ours\`.
1149
1172
 
1150
1173
  Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
1151
1174
  ${INTENT_PROTOCOL_END}`;
1152
1175
  }
1153
1176
 
1154
1177
  function previousIntentProtocolBlock(version) {
1155
- const v7 = intentProtocolBlock(version).replace(
1178
+ const v8 = intentProtocolBlock(version).replace(
1179
+ '\nAfter a merge collision, run `driftseal absorb` rather than editing the log;\n' +
1180
+ 'if both sides still have an open intent, add `--abandon-theirs` or\n' +
1181
+ '`--abandon-ours`.',
1182
+ ''
1183
+ );
1184
+ if (version >= 8) return v8;
1185
+ const v7 = v8.replace(
1156
1186
  '\nThis `AGENTS.md` protocol is the source of truth. Use the `driftseal` CLI by\n' +
1157
1187
  'default; the companion skill only helps discover and resume the workflow, while\n' +
1158
1188
  'MCP and lifecycle hooks are optional adapters.\n',
@@ -1220,6 +1250,8 @@ Count postponed choices with \`driftseal decision list --status deferred --count
1220
1250
  then review them with \`driftseal decision list --status deferred\`.
1221
1251
  When an intent declares an existing decision with \`--decision <id>\`, use
1222
1252
  \`driftseal decision update\` to record its status transition or explicit confirmation.
1253
+ After a merge, colliding decision ids are remapped with \`driftseal absorb\`;
1254
+ concurrent edits of a shared decision are not auto-merged.
1223
1255
  Commit \`.decision-log/\` with the code.
1224
1256
  ${DECISION_PROTOCOL_END}`;
1225
1257
  }
@@ -1276,8 +1308,13 @@ Commit \`.decision-log/\` with the code.`;
1276
1308
  }
1277
1309
 
1278
1310
  function previousDecisionProtocolBlock(version) {
1279
- if (version >= 7) return decisionProtocolBlock(version);
1280
- return decisionProtocolBlock(version).replace(' --driver "<decision driver>"', '');
1311
+ const v8 = decisionProtocolBlock(version).replace(
1312
+ '\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
1313
+ 'concurrent edits of a shared decision are not auto-merged.',
1314
+ ''
1315
+ );
1316
+ if (version >= 7) return v8;
1317
+ return v8.replace(' --driver "<decision driver>"', '');
1281
1318
  }
1282
1319
 
1283
1320
  function upgradeManagedBlock({
@@ -2005,6 +2042,596 @@ function runHookReminder(event, argv) {
2005
2042
  return { changed: true, event, format };
2006
2043
  }
2007
2044
 
2045
+ function gitCapture(args, cwd = process.cwd()) {
2046
+ try {
2047
+ return execFileSync('git', args, {
2048
+ cwd,
2049
+ encoding: 'utf8',
2050
+ stdio: ['ignore', 'pipe', 'ignore'],
2051
+ }).trim();
2052
+ } catch {
2053
+ return null;
2054
+ }
2055
+ }
2056
+
2057
+ function isGitWorkTree(cwd = process.cwd()) {
2058
+ return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
2059
+ }
2060
+
2061
+ function gitOtherHead(cwd = process.cwd()) {
2062
+ return (
2063
+ gitCapture(['rev-parse', '--verify', 'MERGE_HEAD'], cwd) ||
2064
+ gitCapture(['rev-parse', '--verify', 'REBASE_HEAD'], cwd) ||
2065
+ gitCapture(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], cwd)
2066
+ );
2067
+ }
2068
+
2069
+ function gitMergeBase(cwd = process.cwd()) {
2070
+ const other = gitOtherHead(cwd);
2071
+ if (!other) return null;
2072
+ return gitCapture(['merge-base', 'HEAD', other], cwd);
2073
+ }
2074
+
2075
+ function gitDecisionIds(treeish, cwd = process.cwd()) {
2076
+ const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
2077
+ if (!out) return new Set();
2078
+ const ids = new Set();
2079
+ for (const file of out.split('\n')) {
2080
+ const match = path.basename(file).match(/^(\d{4,})-.*\.md$/);
2081
+ if (match) ids.add(normalizeDecisionId(match[1]));
2082
+ }
2083
+ return ids;
2084
+ }
2085
+
2086
+ function gitDecisionEntries(treeish, cwd = process.cwd()) {
2087
+ const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
2088
+ if (!out) return [];
2089
+ const entries = [];
2090
+ for (const file of out.split('\n')) {
2091
+ const name = path.basename(file);
2092
+ const match = name.match(/^(\d{4,})-.*\.md$/);
2093
+ if (!match) continue;
2094
+ const content = gitCapture(['show', `${treeish}:${file}`], cwd);
2095
+ if (content === null) continue;
2096
+ entries.push({
2097
+ id: normalizeDecisionId(match[1]),
2098
+ file: name,
2099
+ path: file,
2100
+ content,
2101
+ });
2102
+ }
2103
+ return entries;
2104
+ }
2105
+
2106
+ function canonicalEvent(event) {
2107
+ return Object.fromEntries(
2108
+ Object.entries(event)
2109
+ .filter(([, value]) => value !== undefined)
2110
+ .sort(([left], [right]) => left.localeCompare(right))
2111
+ );
2112
+ }
2113
+
2114
+ function eventsEqual(left, right) {
2115
+ return JSON.stringify(canonicalEvent(left)) === JSON.stringify(canonicalEvent(right));
2116
+ }
2117
+
2118
+ function commonPrefixLength(ours, theirs) {
2119
+ let index = 0;
2120
+ while (
2121
+ index < ours.length &&
2122
+ index < theirs.length &&
2123
+ eventsEqual(ours[index].event, theirs[index].event)
2124
+ ) {
2125
+ index += 1;
2126
+ }
2127
+ return index;
2128
+ }
2129
+
2130
+ function recordsHavePrefix(records, prefix) {
2131
+ if (prefix.length > records.length) return false;
2132
+ return prefix.every((record, index) => eventsEqual(record.event, records[index].event));
2133
+ }
2134
+
2135
+ function parseConflictContent(content) {
2136
+ if (!/^<<<<<<< /m.test(content)) return null;
2137
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
2138
+ const ours = [];
2139
+ const theirs = [];
2140
+ let mode = 'base';
2141
+ for (const line of content.split(/\r?\n/)) {
2142
+ if (line.startsWith('<<<<<<< ')) {
2143
+ mode = 'ours';
2144
+ continue;
2145
+ }
2146
+ if (line.startsWith('||||||| ')) {
2147
+ mode = 'ancestor';
2148
+ continue;
2149
+ }
2150
+ if (line.startsWith('=======')) {
2151
+ mode = 'theirs';
2152
+ continue;
2153
+ }
2154
+ if (line.startsWith('>>>>>>> ')) {
2155
+ mode = 'base';
2156
+ continue;
2157
+ }
2158
+ if (mode === 'base') {
2159
+ ours.push(line);
2160
+ theirs.push(line);
2161
+ } else if (mode === 'ours') {
2162
+ ours.push(line);
2163
+ } else if (mode === 'theirs') {
2164
+ theirs.push(line);
2165
+ }
2166
+ }
2167
+ return { oursText: ours.join(eol), theirsText: theirs.join(eol) };
2168
+ }
2169
+
2170
+ function collectDecisionIdsFromEvents(events) {
2171
+ const ids = new Set();
2172
+ for (const event of events) {
2173
+ if (Array.isArray(event.decisions)) {
2174
+ for (const id of event.decisions) ids.add(normalizeDecisionId(id));
2175
+ }
2176
+ if (event.decisionId) ids.add(normalizeDecisionId(event.decisionId));
2177
+ }
2178
+ return ids;
2179
+ }
2180
+
2181
+ function nextDecisionIdFromIds(ids) {
2182
+ if (ids.size === 0) return '0001';
2183
+ let max = 0n;
2184
+ for (const id of ids) {
2185
+ const value = BigInt(id);
2186
+ if (value > max) max = value;
2187
+ }
2188
+ return String(max + 1n).padStart(4, '0');
2189
+ }
2190
+
2191
+ function decisionSlugFromFile(file) {
2192
+ return file.replace(/^\d+-/, '').replace(/\.md$/, '');
2193
+ }
2194
+
2195
+ function rewriteDecisionId(content, newId) {
2196
+ return content.replace(/^# [0-9]+\. /, `# ${String(BigInt(newId))}. `);
2197
+ }
2198
+
2199
+ function splitDuplicateDecisions(entries) {
2200
+ const ours = [];
2201
+ const theirs = [];
2202
+ const seen = new Set();
2203
+ for (const entry of entries) {
2204
+ if (seen.has(entry.id)) theirs.push(entry);
2205
+ else {
2206
+ seen.add(entry.id);
2207
+ ours.push(entry);
2208
+ }
2209
+ }
2210
+ return { ours, theirs };
2211
+ }
2212
+
2213
+ function planDecisionAbsorb({ oursEntries, theirsEntries, baseIds }) {
2214
+ const mappings = [];
2215
+ const copies = [];
2216
+ const decisionMap = new Map();
2217
+ const usedIds = new Set(oursEntries.map((entry) => entry.id));
2218
+ const oursById = new Map();
2219
+ for (const entry of oursEntries) {
2220
+ if (!oursById.has(entry.id)) oursById.set(entry.id, entry);
2221
+ }
2222
+
2223
+ for (const entry of theirsEntries) {
2224
+ const ours = oursById.get(entry.id);
2225
+ const theirsContent = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
2226
+ if (!ours) {
2227
+ copies.push({
2228
+ fromFile: entry.file,
2229
+ toFile: entry.file,
2230
+ content: theirsContent,
2231
+ removeFile: null,
2232
+ });
2233
+ usedIds.add(entry.id);
2234
+ continue;
2235
+ }
2236
+ const oursContent = ours.content !== undefined ? ours.content : fs.readFileSync(ours.path, 'utf8');
2237
+ if (contentHash(oursContent) === contentHash(theirsContent)) continue;
2238
+ if (baseIds.has(entry.id)) {
2239
+ fail(
2240
+ `decision ${entry.id} was edited on both sides; resolve ${ours.file} manually before absorbing`
2241
+ );
2242
+ }
2243
+ const newId = nextDecisionIdFromIds(usedIds);
2244
+ usedIds.add(newId);
2245
+ decisionMap.set(entry.id, newId);
2246
+ mappings.push({ kind: 'decision', from: entry.id, to: newId });
2247
+ copies.push({
2248
+ fromFile: entry.file,
2249
+ toFile: `${newId}-${decisionSlugFromFile(entry.file)}.md`,
2250
+ content: rewriteDecisionId(theirsContent, newId),
2251
+ removeFile: entry.file !== ours.file ? entry.file : null,
2252
+ });
2253
+ }
2254
+ return { decisionMap, mappings, copies };
2255
+ }
2256
+
2257
+ function remapEvent(event, intentMap, decisionMap) {
2258
+ const next = { ...event };
2259
+ if (intentMap.has(event.id)) next.id = intentMap.get(event.id);
2260
+ if (Array.isArray(next.decisions) && next.decisions.length > 0) {
2261
+ next.decisions = next.decisions.map((id) => {
2262
+ const normalized = normalizeDecisionId(id);
2263
+ return decisionMap.get(normalized) || id;
2264
+ });
2265
+ }
2266
+ if (next.decisionId) {
2267
+ const normalized = normalizeDecisionId(next.decisionId);
2268
+ if (decisionMap.has(normalized)) next.decisionId = decisionMap.get(normalized);
2269
+ }
2270
+ return next;
2271
+ }
2272
+
2273
+ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
2274
+ const intentMap = new Map();
2275
+ const mappings = [];
2276
+ const used = [...oursUsedEvents];
2277
+ const records = theirsNew.map((record) => {
2278
+ let event = record.event;
2279
+ if (event.type === 'begin' && used.some((item) => item.type === 'begin' && item.id === event.id)) {
2280
+ const { date } = parseIntentId(event.id);
2281
+ const newId = nextIdForDate(date, used);
2282
+ intentMap.set(event.id, newId);
2283
+ mappings.push({ kind: 'intent', from: event.id, to: newId });
2284
+ }
2285
+ event = remapEvent(event, intentMap, decisionMap);
2286
+ used.push(event);
2287
+ return { event };
2288
+ });
2289
+ return { records, mappings };
2290
+ }
2291
+
2292
+ function repairDuplicateIntentRecords(records, decisionMap) {
2293
+ const seenBegins = new Set();
2294
+ const intentMap = new Map();
2295
+ const used = [];
2296
+ const mappings = [];
2297
+ const result = [];
2298
+ for (const record of records) {
2299
+ let event = record.event;
2300
+ if (event.type === 'begin' && seenBegins.has(event.id)) {
2301
+ const { date } = parseIntentId(event.id);
2302
+ const newId = nextIdForDate(date, used);
2303
+ intentMap.set(event.id, newId);
2304
+ mappings.push({ kind: 'intent', from: event.id, to: newId });
2305
+ } else if (event.type === 'begin') {
2306
+ seenBegins.add(event.id);
2307
+ }
2308
+ const remapped = remapEvent(event, intentMap, decisionMap);
2309
+ const changed = remapped !== event && JSON.stringify(remapped) !== JSON.stringify(event);
2310
+ result.push(changed ? { event: remapped } : record);
2311
+ used.push(result.at(-1).event);
2312
+ }
2313
+ return { records: result, mappings };
2314
+ }
2315
+
2316
+ function serializeRecords(records) {
2317
+ if (records.length === 0) return '';
2318
+ return `${records.map((record) => record.raw || JSON.stringify(record.event)).join('\n')}\n`;
2319
+ }
2320
+
2321
+ function writeJsonl(file, records) {
2322
+ ensureDirectoryDurable(path.dirname(file));
2323
+ atomicWriteFile(file, serializeRecords(records));
2324
+ }
2325
+
2326
+ function applyDecisionCopies(copies, dryRun) {
2327
+ if (dryRun || copies.length === 0) return;
2328
+ ensureDirectoryDurable(decisionDir());
2329
+ for (const item of copies) {
2330
+ atomicWriteFile(path.join(decisionDir(), item.toFile), item.content);
2331
+ if (item.removeFile && item.removeFile !== item.toFile) {
2332
+ const stale = path.join(decisionDir(), item.removeFile);
2333
+ if (fs.existsSync(stale)) {
2334
+ fs.unlinkSync(stale);
2335
+ fsyncDirectory(decisionDir());
2336
+ }
2337
+ }
2338
+ }
2339
+ }
2340
+
2341
+ function countAbsorbedIntents(records) {
2342
+ return records.filter((record) => record.event.type === 'begin').length;
2343
+ }
2344
+
2345
+ function printAbsorbReport({ mappings, abandoned, intentCount }) {
2346
+ const remappedIntents = mappings.filter((mapping) => mapping.kind === 'intent').length;
2347
+ const remappedDecisions = mappings.filter((mapping) => mapping.kind === 'decision').length;
2348
+ printLine(
2349
+ `absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
2350
+ );
2351
+ for (const mapping of mappings) {
2352
+ if (mapping.kind === 'intent') printLine(`${mapping.from} (theirs) -> ${mapping.to}`);
2353
+ else printLine(`decision ${mapping.from} (theirs) -> ${mapping.to}`);
2354
+ }
2355
+ if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
2356
+ }
2357
+
2358
+ function abandonOpenIntent(records, targetId, side) {
2359
+ records.push({
2360
+ event: {
2361
+ schemaVersion: EVENT_SCHEMA_VERSION,
2362
+ type: 'end',
2363
+ id: targetId,
2364
+ ts: new Date().toISOString(),
2365
+ status: 'abandoned',
2366
+ note: `abandoned during absorb (--abandon-${side})`,
2367
+ verifyResult: null,
2368
+ },
2369
+ });
2370
+ return targetId;
2371
+ }
2372
+
2373
+ function resolveOpenIntents(result, oursRecords, theirsRecords, abandon, { allowConflict = false } = {}) {
2374
+ const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
2375
+ const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
2376
+ try {
2377
+ openIntent(fold(result.map((record) => record.event)));
2378
+ return { abandoned: null, conflict: false };
2379
+ } catch (err) {
2380
+ if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
2381
+ throw err;
2382
+ }
2383
+ if (abandon === 'theirs' && theirsOpen) {
2384
+ return { abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'), conflict: false };
2385
+ }
2386
+ if (abandon === 'ours' && oursOpen) {
2387
+ return { abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'), conflict: false };
2388
+ }
2389
+ if (allowConflict) return { abandoned: null, conflict: true };
2390
+ fail(`${err.message}; re-run with --abandon-theirs or --abandon-ours`);
2391
+ }
2392
+ }
2393
+
2394
+ function mergeRecordStreams(ours, theirs, baseRecords) {
2395
+ const inferred = ours.slice(0, commonPrefixLength(ours, theirs));
2396
+ const prefix = baseRecords && baseRecords.length > 0 ? baseRecords : inferred;
2397
+ if (!recordsHavePrefix(ours, prefix) || !recordsHavePrefix(theirs, prefix)) {
2398
+ fail('cannot absorb: shared history does not match the base log');
2399
+ }
2400
+ return {
2401
+ base: prefix,
2402
+ oursNew: ours.slice(prefix.length),
2403
+ theirsNew: theirs.slice(prefix.length),
2404
+ };
2405
+ }
2406
+
2407
+ function GITATTRIBUTES_MERGE_LINE() {
2408
+ return '.intent-log/events.jsonl merge=driftseal';
2409
+ }
2410
+
2411
+ function ensureGitAttributes() {
2412
+ const target = path.join(process.cwd(), '.gitattributes');
2413
+ const line = GITATTRIBUTES_MERGE_LINE();
2414
+ const existed = fs.existsSync(target);
2415
+ const current = existed ? fs.readFileSync(target, 'utf8') : '';
2416
+ const eol = current.includes('\r\n') ? '\r\n' : '\n';
2417
+ const lines = current.split(/\r?\n/);
2418
+ if (lines.some((entry) => entry.trim() === line)) return { changed: false, target };
2419
+ const prefix = current.length === 0 || current.endsWith('\n') || current.endsWith('\r\n') ? '' : eol;
2420
+ const next = `${current}${prefix}${line}${eol}`;
2421
+ atomicWriteFile(target, next);
2422
+ return { changed: true, target };
2423
+ }
2424
+
2425
+ function ensureGitMergeDriver() {
2426
+ if (!isGitWorkTree()) return { changed: false, configured: false };
2427
+ const name = gitCapture(['config', '--local', '--get', 'merge.driftseal.name']);
2428
+ const driver = gitCapture(['config', '--local', '--get', 'merge.driftseal.driver']);
2429
+ const expectedName = 'DriftSeal intent log merge';
2430
+ const expectedDriver = 'driftseal absorb --git %O %A %B';
2431
+ if (name === expectedName && driver === expectedDriver) {
2432
+ return { changed: false, configured: true };
2433
+ }
2434
+ execFileSync('git', ['config', '--local', 'merge.driftseal.name', expectedName], {
2435
+ stdio: ['ignore', 'ignore', 'pipe'],
2436
+ });
2437
+ execFileSync('git', ['config', '--local', 'merge.driftseal.driver', expectedDriver], {
2438
+ stdio: ['ignore', 'ignore', 'pipe'],
2439
+ });
2440
+ return { changed: true, configured: true };
2441
+ }
2442
+
2443
+ function absorbUsage() {
2444
+ return (
2445
+ 'usage: driftseal absorb [other-events.jsonl] [--decisions <dir>] [--abandon-theirs | --abandon-ours] [--dry-run]\n' +
2446
+ ' or: driftseal absorb --git <base> <ours> <theirs>'
2447
+ );
2448
+ }
2449
+
2450
+ function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false } = {}) {
2451
+ if (!fs.existsSync(file)) {
2452
+ if (allowMissing) return { records: [], conflict: false };
2453
+ fail(`intent log not found: ${file}`);
2454
+ }
2455
+ let content = fs.readFileSync(file, 'utf8');
2456
+ if (repairTail && !/^<<<<<<< /m.test(content)) {
2457
+ readEvents({ file, repairTail: true });
2458
+ content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
2459
+ }
2460
+ const conflict = parseConflictContent(content);
2461
+ if (conflict) {
2462
+ return {
2463
+ ours: parseJsonlRecords(conflict.oursText, `${label} ours`),
2464
+ theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`),
2465
+ conflict: true,
2466
+ };
2467
+ }
2468
+ return { records: parseJsonlRecords(content, label), conflict: false };
2469
+ }
2470
+
2471
+ function finishAbsorb({
2472
+ result,
2473
+ oursRecords,
2474
+ theirsRecords,
2475
+ mappings,
2476
+ copies,
2477
+ abandon,
2478
+ dryRun,
2479
+ outputFile,
2480
+ intentCount,
2481
+ allowConflict = false,
2482
+ }) {
2483
+ const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
2484
+ allowConflict,
2485
+ });
2486
+ fold(result.map((record) => record.event));
2487
+ if (!conflict) openIntent(fold(result.map((record) => record.event)));
2488
+ if (!dryRun) {
2489
+ writeJsonl(outputFile, result);
2490
+ applyDecisionCopies(copies, dryRun);
2491
+ }
2492
+ if (intentCount === 0 && mappings.length === 0 && copies.length === 0 && !abandoned) {
2493
+ printLine('nothing to absorb');
2494
+ } else {
2495
+ printAbsorbReport({
2496
+ mappings,
2497
+ abandoned,
2498
+ intentCount,
2499
+ });
2500
+ }
2501
+ if (conflict) {
2502
+ printLine('multiple intents remain in progress; re-run with --abandon-theirs or --abandon-ours');
2503
+ }
2504
+ return {
2505
+ mappings,
2506
+ abandoned,
2507
+ copies: copies.map((item) => item.toFile),
2508
+ outputFile,
2509
+ exitCode: conflict ? 1 : 0,
2510
+ };
2511
+ }
2512
+
2513
+ function absorbFromStreams(ours, theirs, baseRecords, options) {
2514
+ const streams = mergeRecordStreams(ours, theirs, baseRecords);
2515
+ const decisionPlan = planDecisionAbsorb({
2516
+ oursEntries: options.oursDecisionEntries || [],
2517
+ theirsEntries: options.theirsDecisionEntries || [],
2518
+ baseIds: options.baseDecisionIds || new Set(),
2519
+ });
2520
+ const remapped = remapTheirsRecords(
2521
+ streams.theirsNew,
2522
+ [...streams.base, ...streams.oursNew].map((record) => record.event),
2523
+ decisionPlan.decisionMap
2524
+ );
2525
+ const result = [...streams.base, ...streams.oursNew, ...remapped.records];
2526
+ return finishAbsorb({
2527
+ result,
2528
+ oursRecords: [...streams.base, ...streams.oursNew],
2529
+ theirsRecords: [...streams.base, ...remapped.records],
2530
+ mappings: [...remapped.mappings, ...decisionPlan.mappings],
2531
+ copies: decisionPlan.copies,
2532
+ abandon: options.abandon,
2533
+ dryRun: options.dryRun,
2534
+ outputFile: options.outputFile,
2535
+ allowConflict: options.allowConflict,
2536
+ intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
2537
+ });
2538
+ }
2539
+
2540
+ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
2541
+ const oursFile = logFile();
2542
+ const loaded = loadAbsorbSide(oursFile, oursFile, { repairTail: true, allowMissing: !otherFile });
2543
+ if (loaded.conflict) {
2544
+ const baseIds = collectDecisionIdsFromEvents(
2545
+ loaded.ours.slice(0, commonPrefixLength(loaded.ours, loaded.theirs)).map((record) => record.event)
2546
+ );
2547
+ const gitBase = gitMergeBase();
2548
+ const baseDecisionIds = gitBase ? gitDecisionIds(gitBase) : baseIds;
2549
+ return absorbFromStreams(loaded.ours, loaded.theirs, null, {
2550
+ abandon,
2551
+ dryRun,
2552
+ outputFile: oursFile,
2553
+ oursDecisionEntries: listDecisionEntries(decisionDir(), { allowDuplicates: true }),
2554
+ theirsDecisionEntries: otherDecisions
2555
+ ? listDecisionEntries(otherDecisions)
2556
+ : splitDuplicateDecisions(listDecisionEntries(decisionDir(), { allowDuplicates: true })).theirs,
2557
+ baseDecisionIds,
2558
+ });
2559
+ }
2560
+
2561
+ if (!otherFile) {
2562
+ const oursEntries = listDecisionEntries(decisionDir(), { allowDuplicates: true });
2563
+ const split = splitDuplicateDecisions(oursEntries);
2564
+ const repaired = repairDuplicateIntentRecords(loaded.records, new Map());
2565
+ const gitBase = gitMergeBase();
2566
+ const decisionPlan = planDecisionAbsorb({
2567
+ oursEntries: split.ours,
2568
+ theirsEntries: split.theirs,
2569
+ baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
2570
+ });
2571
+ const withDecisions = repairDuplicateIntentRecords(repaired.records, decisionPlan.decisionMap);
2572
+ const result = withDecisions.records;
2573
+ const mappings = [...repaired.mappings, ...withDecisions.mappings, ...decisionPlan.mappings];
2574
+ const remappedIds = new Set(
2575
+ mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
2576
+ );
2577
+ const oursRecords = result.filter((record) => !remappedIds.has(record.event.id));
2578
+ return finishAbsorb({
2579
+ result,
2580
+ oursRecords: oursRecords.length > 0 ? oursRecords : result,
2581
+ theirsRecords: result,
2582
+ mappings,
2583
+ copies: decisionPlan.copies,
2584
+ abandon,
2585
+ dryRun,
2586
+ outputFile: oursFile,
2587
+ intentCount: remappedIds.size,
2588
+ });
2589
+ }
2590
+
2591
+ const theirs = loadAbsorbSide(otherFile, otherFile);
2592
+ if (theirs.conflict) fail(`incoming log still contains conflict markers: ${otherFile}`);
2593
+ const otherRoot = path.resolve(path.dirname(otherFile), '..');
2594
+ const otherHead = isGitWorkTree(otherRoot) ? gitCapture(['rev-parse', 'HEAD'], otherRoot) : null;
2595
+ const gitBase = otherHead ? gitCapture(['merge-base', 'HEAD', otherHead]) : gitMergeBase();
2596
+ const baseDecisionIds = gitBase
2597
+ ? gitDecisionIds(gitBase)
2598
+ : collectDecisionIdsFromEvents(
2599
+ loaded.records
2600
+ .slice(0, commonPrefixLength(loaded.records, theirs.records))
2601
+ .map((record) => record.event)
2602
+ );
2603
+ return absorbFromStreams(loaded.records, theirs.records, null, {
2604
+ abandon,
2605
+ dryRun,
2606
+ outputFile: oursFile,
2607
+ oursDecisionEntries: listDecisionEntries(decisionDir()),
2608
+ theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
2609
+ baseDecisionIds,
2610
+ });
2611
+ }
2612
+
2613
+ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
2614
+ const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true });
2615
+ const ours = loadAbsorbSide(oursFile, oursFile);
2616
+ const theirs = loadAbsorbSide(theirsFile, theirsFile);
2617
+ if (base.conflict || ours.conflict || theirs.conflict) {
2618
+ fail('git merge driver received a log that still contains conflict markers');
2619
+ }
2620
+ const otherHead = gitOtherHead();
2621
+ const mergeBase = gitMergeBase();
2622
+ return absorbFromStreams(ours.records, theirs.records, base.records, {
2623
+ abandon,
2624
+ dryRun,
2625
+ outputFile: oursFile,
2626
+ allowConflict: !abandon,
2627
+ oursDecisionEntries: listDecisionEntries(decisionDir(), { allowDuplicates: true }),
2628
+ theirsDecisionEntries: otherHead ? gitDecisionEntries(otherHead) : [],
2629
+ baseDecisionIds: mergeBase
2630
+ ? gitDecisionIds(mergeBase)
2631
+ : collectDecisionIdsFromEvents(base.records.map((record) => record.event)),
2632
+ });
2633
+ }
2634
+
2008
2635
  const commands = {
2009
2636
  begin(argv) {
2010
2637
  const { positionals, flags } = parseArgs(argv, {
@@ -2425,6 +3052,29 @@ const commands = {
2425
3052
  fail(hookUsage());
2426
3053
  },
2427
3054
 
3055
+ absorb(argv) {
3056
+ const { positionals, flags } = parseArgs(argv, {
3057
+ git: 'boolean',
3058
+ decisions: 'single',
3059
+ 'abandon-theirs': 'boolean',
3060
+ 'abandon-ours': 'boolean',
3061
+ 'dry-run': 'boolean',
3062
+ });
3063
+ if (flags['abandon-theirs'] && flags['abandon-ours']) {
3064
+ fail('cannot combine --abandon-theirs and --abandon-ours');
3065
+ }
3066
+ const abandon = flags['abandon-theirs'] ? 'theirs' : flags['abandon-ours'] ? 'ours' : null;
3067
+ const dryRun = Boolean(flags['dry-run']);
3068
+
3069
+ if (flags.git) {
3070
+ if (positionals.length !== 3) fail(absorbUsage());
3071
+ if (flags.decisions) fail('--decisions cannot be combined with --git');
3072
+ return absorbGit(positionals[0], positionals[1], positionals[2], { abandon, dryRun });
3073
+ }
3074
+ if (positionals.length > 1) fail(absorbUsage());
3075
+ return absorbLogs(positionals[0], flags.decisions, { abandon, dryRun });
3076
+ },
3077
+
2428
3078
  init(argv) {
2429
3079
  const { positionals } = parseArgs(argv, {});
2430
3080
  if (positionals.length > 0) fail('usage: driftseal init');
@@ -2448,6 +3098,7 @@ const commands = {
2448
3098
  protocolEol(previousIntentProtocolBlock(5), eol),
2449
3099
  protocolEol(previousIntentProtocolBlock(6), eol),
2450
3100
  protocolEol(previousIntentProtocolBlock(7), eol),
3101
+ protocolEol(previousIntentProtocolBlock(8), eol),
2451
3102
  ],
2452
3103
  knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
2453
3104
  });
@@ -2465,6 +3116,7 @@ const commands = {
2465
3116
  protocolEol(previousDecisionProtocolBlock(5), eol),
2466
3117
  protocolEol(previousDecisionProtocolBlock(6), eol),
2467
3118
  protocolEol(previousDecisionProtocolBlock(7), eol),
3119
+ protocolEol(previousDecisionProtocolBlock(8), eol),
2468
3120
  ],
2469
3121
  knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
2470
3122
  });
@@ -2484,12 +3136,28 @@ const commands = {
2484
3136
  updated += separator + additions.join(eol + eol) + eol;
2485
3137
  }
2486
3138
 
2487
- if (updated === current) {
3139
+ const attributes = ensureGitAttributes();
3140
+ let driver = { changed: false, configured: false };
3141
+ try {
3142
+ driver = ensureGitMergeDriver();
3143
+ } catch (err) {
3144
+ printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
3145
+ }
3146
+
3147
+ if (updated === current && !attributes.changed && !driver.changed) {
2488
3148
  printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
2489
3149
  return { changed: false, target };
2490
3150
  }
2491
- atomicWriteFile(target, updated);
2492
- printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
3151
+ if (updated !== current) {
3152
+ atomicWriteFile(target, updated);
3153
+ printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
3154
+ }
3155
+ if (attributes.changed) {
3156
+ printLine(`Configured git merge attribute: ${attributes.target}`);
3157
+ }
3158
+ if (driver.changed) {
3159
+ printLine('Configured local git merge driver for DriftSeal intent logs');
3160
+ }
2493
3161
  return { changed: true, target };
2494
3162
  },
2495
3163
 
@@ -2507,6 +3175,11 @@ usage:
2507
3175
  hide meaningless closed records without deleting them
2508
3176
  driftseal unreclaim <id> --reason "<why>"
2509
3177
  restore a reclaimed record to the visible log
3178
+ driftseal absorb [other-events.jsonl] [--decisions <dir>]
3179
+ [--abandon-theirs | --abandon-ours] [--dry-run]
3180
+ merge another intent log, remapping colliding ids
3181
+ driftseal absorb --git <base> <ours> <theirs>
3182
+ git merge driver for .intent-log/events.jsonl
2510
3183
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
2511
3184
  driftseal decision update <id> [--status STATUS] --note "..."
2512
3185
  reconcile a linked decision in the open intent
@@ -2524,7 +3197,7 @@ usage:
2524
3197
  targets: kimi-code (global only), claude-code, codex (prompt only)
2525
3198
  driftseal hook prompt|stop [--format plain|claude-code]
2526
3199
  emit the reminder a lifecycle hook injects; never blocks
2527
- driftseal init inject intent and decision protocols into ./AGENTS.md
3200
+ driftseal init inject protocols into ./AGENTS.md and configure the git merge driver
2528
3201
  driftseal --version | -V print the installed DriftSeal version
2529
3202
  driftseal help
2530
3203
 
@@ -2559,6 +3232,7 @@ function mutationResources(cmd, argv) {
2559
3232
  if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
2560
3233
  if (cmd === 'init') return [process.cwd()];
2561
3234
  if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
3235
+ if (cmd === 'absorb') return [logDir(), decisionDir()];
2562
3236
  if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
2563
3237
  return [logDir()];
2564
3238
  }
@@ -2580,13 +3254,14 @@ function dispatch(argv) {
2580
3254
  const fn = commands[cmd];
2581
3255
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
2582
3256
  const mutates =
2583
- ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim'].includes(cmd) ||
3257
+ ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
2584
3258
  (cmd === 'hook' && rest[0] === 'install') ||
2585
3259
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
2586
3260
  const readsIntentLog = ['status', 'log'].includes(cmd);
2587
3261
  if (mutates || readsIntentLog) {
2588
3262
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
2589
- return { data: withMutationLocks(resources, () => fn(rest)), exitCode: 0 };
3263
+ const data = withMutationLocks(resources, () => fn(rest));
3264
+ return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
2590
3265
  }
2591
3266
  return { data: fn(rest), exitCode: 0 };
2592
3267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",
@@ -37,5 +37,13 @@ requires. For command syntax, run:
37
37
  driftseal help
38
38
  ```
39
39
 
40
+ ## After a merge
41
+
42
+ If `status` or `log` fails with a duplicate id, or the intent log has conflict
43
+ markers, run `driftseal absorb` instead of editing `.intent-log/events.jsonl`.
44
+ When both sides still have an open intent, add `--abandon-theirs` or
45
+ `--abandon-ours`. `driftseal init` also configures the local git merge driver;
46
+ clones need `init` again for that driver.
47
+
40
48
  Do not treat this skill, MCP descriptions, or lifecycle-hook reminders as
41
49
  additional policy. If they conflict with `AGENTS.md`, follow `AGENTS.md`.