driftseal 0.6.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,8 @@ 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. |
223
+ | `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
219
224
  | `driftseal help` | Print CLI usage. |
220
225
 
221
226
  When `begin` declares one or more `--decision <id>` links, every linked
@@ -268,9 +273,31 @@ contents. Status-filtered listing and counting parse all records because status
268
273
  is stored in each MADR document; DriftSeal does not maintain a stale-prone sidecar
269
274
  index.
270
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
+
271
298
  ## Storage
272
299
 
273
- - `.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.
274
301
  - `.decision-log/` contains numbered MADR decision records.
275
302
  - Set `DRIFTSEAL_HOME` or `DRIFTSEAL_DECISION_HOME` to store either log outside the current project.
276
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,8 @@ 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。 |
216
+ | `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
213
217
  | `driftseal help` | 查看 CLI 用法。 |
214
218
 
215
219
  如果 `begin` 通过一个或多个 `--decision <id>` 声明了关联,那么 intent
@@ -254,9 +258,24 @@ recovery 只处理当前 intent,因此历史冲突不会阻塞之后的 decisi
254
258
  时仍需解析全部 records,因为 status 保存在各个 MADR 文档中;DriftSeal 不维护容易
255
259
  滞后的 sidecar index。
256
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
+
257
276
  ## 数据保存在哪里
258
277
 
259
- - `.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`,不要手改这个文件。
260
279
  - `.decision-log/`:编号化的 MADR decision records。
261
280
  - 设置 `DRIFTSEAL_HOME` 或 `DRIFTSEAL_DECISION_HOME`,即可把对应 log 放到当前项目之外。
262
281
 
package/bin/driftseal.js CHANGED
@@ -24,6 +24,7 @@ const path = require('path');
24
24
  const crypto = require('crypto');
25
25
  const os = require('os');
26
26
  const { execFileSync } = require('child_process');
27
+ const { version: PACKAGE_VERSION } = require('../package.json');
27
28
 
28
29
  const END_STATUSES = ['completed', 'partial', 'failed', 'abandoned'];
29
30
  const DECISION_STATUSES = [
@@ -35,7 +36,7 @@ const DECISION_STATUSES = [
35
36
  'superseded',
36
37
  ];
37
38
  const EVENT_SCHEMA_VERSION = 3;
38
- const PROTOCOL_VERSION = 8;
39
+ const PROTOCOL_VERSION = 9;
39
40
  const LOCK_STALE_MS = 30 * 60 * 1000;
40
41
  const LOCK_INIT_STALE_MS = 5 * 1000;
41
42
  const MAX_DECISION_SLUG_LENGTH = 180;
@@ -212,15 +213,19 @@ function readEvents({ repairTail = false, file = logFile() } = {}) {
212
213
  content = content.slice(0, validLength);
213
214
  }
214
215
  }
216
+ return parseJsonlRecords(content, file).map((record) => record.event);
217
+ }
218
+
219
+ function parseJsonlRecords(content, source = 'log') {
215
220
  return content
216
221
  .split('\n')
217
222
  .filter((line) => line.trim().length > 0)
218
223
  .map((line, i) => {
219
224
  try {
220
- return normalizeEvent(JSON.parse(line), i + 1);
225
+ return { raw: line, event: normalizeEvent(JSON.parse(line), i + 1) };
221
226
  } catch (err) {
222
227
  if (err instanceof DriftSealError) throw err;
223
- fail(`corrupt log line ${i + 1} in ${file}`);
228
+ fail(`corrupt log line ${i + 1} in ${source}`);
224
229
  }
225
230
  });
226
231
  }
@@ -713,16 +718,26 @@ function openIntent(records) {
713
718
  return open[0] || null;
714
719
  }
715
720
 
716
- function nextId(events) {
717
- 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) {
718
728
  let maxSeq = 0;
729
+ const prefix = `${date}-`;
719
730
  for (const ev of events) {
720
- if (ev.type === 'begin' && typeof ev.id === 'string' && ev.id.startsWith(today + '-')) {
721
- 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);
722
733
  if (Number.isFinite(seq) && seq > maxSeq) maxSeq = seq;
723
734
  }
724
735
  }
725
- 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);
726
741
  }
727
742
 
728
743
  function normalizeDecisionId(value) {
@@ -770,25 +785,31 @@ function compareDecisionEntries(a, b) {
770
785
  return a.id.length - b.id.length || a.id.localeCompare(b.id) || a.file.localeCompare(b.file);
771
786
  }
772
787
 
773
- function decisionIndex() {
774
- if (!fs.existsSync(decisionDir())) return [];
788
+ function listDecisionEntries(dir, { allowDuplicates = false } = {}) {
789
+ if (!dir || !fs.existsSync(dir)) return [];
775
790
  const entries = [];
776
791
  const ids = new Map();
777
- for (const entry of fs.readdirSync(decisionDir(), { withFileTypes: true })) {
792
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
778
793
  const match = entry.name.match(/^(\d{4,})-.*\.md$/);
779
794
  if (!match) continue;
780
- const fullPath = path.join(decisionDir(), entry.name);
795
+ const fullPath = path.join(dir, entry.name);
781
796
  const stat = fs.lstatSync(fullPath);
782
797
  if (stat.isSymbolicLink()) fail(`decision record must not be a symbolic link: ${entry.name}`);
783
798
  if (!stat.isFile()) fail(`decision record is not a regular file: ${entry.name}`);
784
799
  const id = normalizeDecisionId(match[1]);
785
- if (ids.has(id)) fail(`duplicate decision id ${id}: ${ids.get(id)}, ${entry.name}`);
786
- 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);
787
804
  entries.push({ id, file: entry.name, path: fullPath });
788
805
  }
789
806
  return entries.sort(compareDecisionEntries);
790
807
  }
791
808
 
809
+ function decisionIndex() {
810
+ return listDecisionEntries(decisionDir());
811
+ }
812
+
792
813
  function readDecision(entry) {
793
814
  const noFollow = fs.constants.O_NOFOLLOW || 0;
794
815
  let fd;
@@ -1145,13 +1166,23 @@ MCP and lifecycle hooks are optional adapters.
1145
1166
  \`driftseal\` commands or the MCP tools. Retire meaningless closed records with
1146
1167
  \`driftseal reclaim [id ...] --reason "<why>"\` — it appends a marker, never
1147
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\`.
1148
1172
 
1149
1173
  Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
1150
1174
  ${INTENT_PROTOCOL_END}`;
1151
1175
  }
1152
1176
 
1153
1177
  function previousIntentProtocolBlock(version) {
1154
- 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(
1155
1186
  '\nThis `AGENTS.md` protocol is the source of truth. Use the `driftseal` CLI by\n' +
1156
1187
  'default; the companion skill only helps discover and resume the workflow, while\n' +
1157
1188
  'MCP and lifecycle hooks are optional adapters.\n',
@@ -1219,6 +1250,8 @@ Count postponed choices with \`driftseal decision list --status deferred --count
1219
1250
  then review them with \`driftseal decision list --status deferred\`.
1220
1251
  When an intent declares an existing decision with \`--decision <id>\`, use
1221
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.
1222
1255
  Commit \`.decision-log/\` with the code.
1223
1256
  ${DECISION_PROTOCOL_END}`;
1224
1257
  }
@@ -1275,8 +1308,13 @@ Commit \`.decision-log/\` with the code.`;
1275
1308
  }
1276
1309
 
1277
1310
  function previousDecisionProtocolBlock(version) {
1278
- if (version >= 7) return decisionProtocolBlock(version);
1279
- 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>"', '');
1280
1318
  }
1281
1319
 
1282
1320
  function upgradeManagedBlock({
@@ -2004,6 +2042,596 @@ function runHookReminder(event, argv) {
2004
2042
  return { changed: true, event, format };
2005
2043
  }
2006
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
+
2007
2635
  const commands = {
2008
2636
  begin(argv) {
2009
2637
  const { positionals, flags } = parseArgs(argv, {
@@ -2424,6 +3052,29 @@ const commands = {
2424
3052
  fail(hookUsage());
2425
3053
  },
2426
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
+
2427
3078
  init(argv) {
2428
3079
  const { positionals } = parseArgs(argv, {});
2429
3080
  if (positionals.length > 0) fail('usage: driftseal init');
@@ -2447,6 +3098,7 @@ const commands = {
2447
3098
  protocolEol(previousIntentProtocolBlock(5), eol),
2448
3099
  protocolEol(previousIntentProtocolBlock(6), eol),
2449
3100
  protocolEol(previousIntentProtocolBlock(7), eol),
3101
+ protocolEol(previousIntentProtocolBlock(8), eol),
2450
3102
  ],
2451
3103
  knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
2452
3104
  });
@@ -2464,6 +3116,7 @@ const commands = {
2464
3116
  protocolEol(previousDecisionProtocolBlock(5), eol),
2465
3117
  protocolEol(previousDecisionProtocolBlock(6), eol),
2466
3118
  protocolEol(previousDecisionProtocolBlock(7), eol),
3119
+ protocolEol(previousDecisionProtocolBlock(8), eol),
2467
3120
  ],
2468
3121
  knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
2469
3122
  });
@@ -2483,12 +3136,28 @@ const commands = {
2483
3136
  updated += separator + additions.join(eol + eol) + eol;
2484
3137
  }
2485
3138
 
2486
- 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) {
2487
3148
  printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
2488
3149
  return { changed: false, target };
2489
3150
  }
2490
- atomicWriteFile(target, updated);
2491
- 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
+ }
2492
3161
  return { changed: true, target };
2493
3162
  },
2494
3163
 
@@ -2506,6 +3175,11 @@ usage:
2506
3175
  hide meaningless closed records without deleting them
2507
3176
  driftseal unreclaim <id> --reason "<why>"
2508
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
2509
3183
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
2510
3184
  driftseal decision update <id> [--status STATUS] --note "..."
2511
3185
  reconcile a linked decision in the open intent
@@ -2523,7 +3197,8 @@ usage:
2523
3197
  targets: kimi-code (global only), claude-code, codex (prompt only)
2524
3198
  driftseal hook prompt|stop [--format plain|claude-code]
2525
3199
  emit the reminder a lifecycle hook injects; never blocks
2526
- driftseal init inject intent and decision protocols into ./AGENTS.md
3200
+ driftseal init inject protocols into ./AGENTS.md and configure the git merge driver
3201
+ driftseal --version | -V print the installed DriftSeal version
2527
3202
  driftseal help
2528
3203
 
2529
3204
  decision add options:
@@ -2536,6 +3211,11 @@ intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
2536
3211
  decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory`);
2537
3212
  return null;
2538
3213
  },
3214
+
3215
+ version() {
3216
+ printLine(PACKAGE_VERSION);
3217
+ return PACKAGE_VERSION;
3218
+ },
2539
3219
  };
2540
3220
 
2541
3221
  function requestedEndStatus(argv) {
@@ -2552,6 +3232,7 @@ function mutationResources(cmd, argv) {
2552
3232
  if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
2553
3233
  if (cmd === 'init') return [process.cwd()];
2554
3234
  if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
3235
+ if (cmd === 'absorb') return [logDir(), decisionDir()];
2555
3236
  if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
2556
3237
  return [logDir()];
2557
3238
  }
@@ -2563,19 +3244,24 @@ function mutationResources(cmd, argv) {
2563
3244
 
2564
3245
  function dispatch(argv) {
2565
3246
  const [cmd, ...rest] = argv;
3247
+ if (cmd === '--version' || cmd === '-V') {
3248
+ if (rest.length > 0) fail('usage: driftseal --version | -V');
3249
+ return { data: commands.version(), exitCode: 0 };
3250
+ }
2566
3251
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
2567
3252
  return { data: commands.help(), exitCode: cmd ? 0 : 1 };
2568
3253
  }
2569
3254
  const fn = commands[cmd];
2570
3255
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
2571
3256
  const mutates =
2572
- ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim'].includes(cmd) ||
3257
+ ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
2573
3258
  (cmd === 'hook' && rest[0] === 'install') ||
2574
3259
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
2575
3260
  const readsIntentLog = ['status', 'log'].includes(cmd);
2576
3261
  if (mutates || readsIntentLog) {
2577
3262
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
2578
- 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 };
2579
3265
  }
2580
3266
  return { data: fn(rest), exitCode: 0 };
2581
3267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "0.6.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`.