driftseal 1.0.0 → 1.1.1

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
 
@@ -177,7 +179,7 @@ Cursor have no supported hook surface for this yet.
177
179
 
178
180
  ## A work round
179
181
 
180
- Declare the round before changing files:
182
+ Declare the round before making non-Git changes:
181
183
 
182
184
  ```sh
183
185
  driftseal begin "add rate limiting to /api/login" \
@@ -195,7 +197,12 @@ driftseal end \
195
197
 
196
198
  If the scope changes, close the current intent as `partial` or `abandoned`, then start a new one. After context loss, use `driftseal status` and `driftseal log --last 3` to re-anchor.
197
199
 
198
- Single-step commands that only build, check, or record work already done — compiling, running tests, `git add`/`git commit` — need no intent of their own. When a commit is authorized, staging and committing only the verified changes and the just-closed intent log finalizes that round. Any content change made while preparing the commit starts a new round.
200
+ Git operations are entirely outside the intent log because Git maintains their
201
+ history. Inspection, branch and worktree management, staging, commits, merges,
202
+ rebases, cherry-picks, tags, and pushes never need an intent of their own. They
203
+ still require normal authorization and safety checks. Single-step builds and
204
+ checks, such as compiling or running tests, also need no intent. Any non-Git
205
+ content change starts a new work round.
199
206
 
200
207
  ## Commands
201
208
 
@@ -207,6 +214,8 @@ Single-step commands that only build, check, or record work already done — com
207
214
  | `driftseal log [-n N] [--all]` | Review intent history (`--all` includes reclaimed records). |
208
215
  | `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | Hide meaningless closed records behind append-only markers. |
209
216
  | `driftseal unreclaim <id> --reason "..."` | Restore a reclaimed record to the visible log. |
217
+ | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | Merge another worktree's logs, remapping colliding intent and decision ids. |
218
+ | `driftseal absorb --git <base> <ours> <theirs>` | Git merge driver for `.intent-log/events.jsonl`. |
210
219
  | `driftseal decision add "<title>" -c "..." -o "..."` | Write a numbered MADR decision. |
211
220
  | `driftseal decision update <id> [-s status] -n "..."` | Reconcile a linked decision in the open intent. |
212
221
  | `driftseal decision list [-s status] [--last N \| --count]` | List or count decision records, optionally filtered by status. |
@@ -215,7 +224,7 @@ Single-step commands that only build, check, or record work already done — com
215
224
  | `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
225
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
217
226
  | `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`. |
227
+ | `driftseal init` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. |
219
228
  | `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
220
229
  | `driftseal help` | Print CLI usage. |
221
230
 
@@ -269,9 +278,34 @@ contents. Status-filtered listing and counting parse all records because status
269
278
  is stored in each MADR document; DriftSeal does not maintain a stale-prone sidecar
270
279
  index.
271
280
 
281
+ ## Merging worktrees
282
+
283
+ Two worktrees allocate intent and decision ids from their local logs, so a
284
+ same-day parallel `begin` or `decision add` can collide when the branches
285
+ merge. `driftseal absorb` rebuilds a valid log by keeping our ids and remapping
286
+ the incoming side, then prints the mapping. A single lineage stays append-only;
287
+ absorb is the one cross-lineage rewrite.
288
+
289
+ ```sh
290
+ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
291
+ --decisions ../other-worktree/.decision-log
292
+ ```
293
+
294
+ With no path, `absorb` repairs the current log after a git conflict or a
295
+ concatenated duplicate. If both sides still have an open intent, pass
296
+ `--abandon-theirs` or `--abandon-ours`. Concurrent edits of a decision that
297
+ already existed in the shared base are not auto-merged.
298
+
299
+ `driftseal init` writes that absorb rule into `AGENTS.md`, plus `.gitattributes`
300
+ and the local git merge driver so `events.jsonl` merges through `absorb --git`.
301
+ When decision ids collide, the driver stops the merge before Git can commit an
302
+ ambiguous decision catalog. Run `driftseal absorb`, stage the repaired intent
303
+ and decision logs, then continue the merge. Clones need `init` again because
304
+ the driver lives in local git config.
305
+
272
306
  ## Storage
273
307
 
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.
308
+ - `.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
309
  - `.decision-log/` contains numbered MADR decision records.
276
310
  - Set `DRIFTSEAL_HOME` or `DRIFTSEAL_DECISION_HOME` to store either log outside the current project.
277
311
 
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
 
@@ -171,7 +172,7 @@ Kimi Code 只在全局 `config.toml` 中记录 hook,因此该 target 必须指
171
172
 
172
173
  ## 一轮标准工作流
173
174
 
174
- 修改文件前,先声明这轮工作的目标:
175
+ 进行非 Git 改动前,先声明这轮工作的目标:
175
176
 
176
177
  ```sh
177
178
  driftseal begin "add rate limiting to /api/login" \
@@ -189,7 +190,10 @@ driftseal end \
189
190
 
190
191
  如果范围发生变化,先把当前 intent 以 `partial` 或 `abandoned` 关闭,再开启新的 intent。发生 context loss 后,用 `driftseal status` 和 `driftseal log --last 3` 重新锚定当前目标。
191
192
 
192
- 只负责构建、检查或记录已完成工作的单步命令(编译、跑测试、`git add`/`git commit`)不需要单独开启 intent。如果用户已经授权创建 Git commit,只把已验证的改动和刚关闭的 intent log 进行 stage 和 commit,就属于这一轮的持久化收尾。准备 commit 时一旦需要修改内容,就必须开启新一轮。
193
+ Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看状态、管理 branch
194
+ 或 worktree、stage、commit、merge、rebase、cherry-pick、tag 和 push 都不需要
195
+ 单独开启 intent,但仍须遵守正常的授权与安全要求。编译、跑测试等单步构建或检查
196
+ 也不需要 intent;只要要做非 Git 内容改动,就开启新一轮。
193
197
 
194
198
  ## 命令速览
195
199
 
@@ -201,6 +205,8 @@ driftseal end \
201
205
  | `driftseal log [-n N] [--all]` | 查看 intent 历史(`--all` 包含已回收的记录)。 |
202
206
  | `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | 用 append-only 标记隐藏已无意义的已关闭记录。 |
203
207
  | `driftseal unreclaim <id> --reason "..."` | 把已回收的记录恢复到可见历史中。 |
208
+ | `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | 合并另一条 worktree 的日志,并给撞号的 intent / decision id 重新编号。 |
209
+ | `driftseal absorb --git <base> <ours> <theirs>` | `.intent-log/events.jsonl` 的 git merge driver。 |
204
210
  | `driftseal decision add "<title>" -c "..." -o "..."` | 写入编号化的 MADR decision。 |
205
211
  | `driftseal decision update <id> [-s status] -n "..."` | 在当前 intent 中 reconcile 已关联的 decision。 |
206
212
  | `driftseal decision list [-s status] [--last N \| --count]` | 列出或统计 decision records,也可按 status 筛选。 |
@@ -209,7 +215,7 @@ driftseal end \
209
215
  | `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
210
216
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
211
217
  | `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
212
- | `driftseal init` | 把接入协议写入 `AGENTS.md`。 |
218
+ | `driftseal init` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。 |
213
219
  | `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
214
220
  | `driftseal help` | 查看 CLI 用法。 |
215
221
 
@@ -255,9 +261,26 @@ recovery 只处理当前 intent,因此历史冲突不会阻塞之后的 decisi
255
261
  时仍需解析全部 records,因为 status 保存在各个 MADR 文档中;DriftSeal 不维护容易
256
262
  滞后的 sidecar index。
257
263
 
264
+ ## 合并 worktree
265
+
266
+ 两条 worktree 各自按本地日志分配 intent / decision id,同一天并行 `begin` 或 `decision add`,分支合并时就会撞号。`driftseal absorb` 保留我方编号、给进来的一侧重编号,并打印对照表。单线 WAL 仍然只追加;absorb 是唯一允许的跨谱系重写。
267
+
268
+ ```sh
269
+ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
270
+ --decisions ../other-worktree/.decision-log
271
+ ```
272
+
273
+ 不带 path 时,`absorb` 修复当前日志里的冲突标记或已经拼在一起的重复 id。两边都还有未关闭 intent 时,必须加上 `--abandon-theirs` 或 `--abandon-ours`。共享 base 里已经存在、又被两边同时改过的 decision,不会自动合并。
274
+
275
+ `driftseal init` 会把这条 absorb 规则写入 `AGENTS.md`,同时写入 `.gitattributes`
276
+ 并配置 local git merge driver,让 `events.jsonl` 走 `absorb --git`。decision id
277
+ 撞号时,driver 会先中止 merge,避免 Git 提交语义不明确的 decision catalog;运行
278
+ `driftseal absorb`、stage 修复后的 intent / decision logs,再继续 merge。clone
279
+ 之后需要再跑一次 `init`,因为 driver 只存在于 local git config。
280
+
258
281
  ## 数据保存在哪里
259
282
 
260
- - `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim`,而不是删除日志行。
283
+ - `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim`,而不是删除日志行。合并撞号时用 `driftseal absorb`,不要手改这个文件。
261
284
  - `.decision-log/`:编号化的 MADR decision records。
262
285
  - 设置 `DRIFTSEAL_HOME` 或 `DRIFTSEAL_DECISION_HOME`,即可把对应 log 放到当前项目之外。
263
286
 
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 = 10;
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;
@@ -1116,11 +1136,14 @@ default; the companion skill only helps discover and resume the workflow, while
1116
1136
  MCP and lifecycle hooks are optional adapters.
1117
1137
 
1118
1138
  1. **Write intent first**, before modifying, creating, or deleting files, or
1119
- making any other change that may need a rollback:
1139
+ making any other non-Git change that may need a rollback:
1120
1140
  \`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
1121
1141
  Add one \`--decision <id>\` for each existing decision this round may change.
1122
- Single-step commands that only build, check, or record work already done
1123
- (compiling, running tests, \`git add\`/\`git commit\`) need no intent.
1142
+ Git operations never need an intent and are not included in the intent log;
1143
+ Git maintains their history. This includes inspection, branch and worktree
1144
+ management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.
1145
+ Single-step commands that only build or check work already done, such as
1146
+ compiling or running tests, also need no intent.
1124
1147
  2. **Execute only the intent.** Scope change? Close the current intent
1125
1148
  (\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
1126
1149
  3. **Verify, then close**: run the declared verification, then
@@ -1133,9 +1156,9 @@ MCP and lifecycle hooks are optional adapters.
1133
1156
  the final content hash is recorded. Interrupted reconciliation is recovered
1134
1157
  by the next linked \`decision update\` or successful \`end\`. Closing as
1135
1158
  \`failed\` or \`abandoned\` cancels pending recovery for that intent.
1136
- An authorized Git commit that only stages and records the verified changes and
1137
- just-closed log finalizes that round without requiring a new intent. Any content
1138
- change made while preparing the commit does require a new intent.
1159
+ Git operations remain subject to normal authorization and safety requirements
1160
+ even though they do not require an intent. Any non-Git content change made while
1161
+ preparing a Git operation does require a new intent.
1139
1162
  4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1140
1163
  doing anything else. The open intent is the source of truth: resume it when its
1141
1164
  objective still matches the current task; otherwise close it (\`partial\` or
@@ -1146,13 +1169,50 @@ MCP and lifecycle hooks are optional adapters.
1146
1169
  \`driftseal\` commands or the MCP tools. Retire meaningless closed records with
1147
1170
  \`driftseal reclaim [id ...] --reason "<why>"\` — it appends a marker, never
1148
1171
  deletes log lines; \`driftseal unreclaim <id> --reason "<why>"\` restores one.
1172
+ After a merge collision, run \`driftseal absorb\` rather than editing the log;
1173
+ if both sides still have an open intent, add \`--abandon-theirs\` or
1174
+ \`--abandon-ours\`.
1149
1175
 
1150
1176
  Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
1151
1177
  ${INTENT_PROTOCOL_END}`;
1152
1178
  }
1153
1179
 
1154
1180
  function previousIntentProtocolBlock(version) {
1155
- const v7 = intentProtocolBlock(version).replace(
1181
+ const v9 = intentProtocolBlock(version)
1182
+ .replace(
1183
+ '1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
1184
+ ' making any other non-Git change that may need a rollback:\n' +
1185
+ ' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
1186
+ ' Add one `--decision <id>` for each existing decision this round may change.\n' +
1187
+ ' Git operations never need an intent and are not included in the intent log;\n' +
1188
+ ' Git maintains their history. This includes inspection, branch and worktree\n' +
1189
+ ' management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.\n' +
1190
+ ' Single-step commands that only build or check work already done, such as\n' +
1191
+ ' compiling or running tests, also need no intent.',
1192
+ '1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
1193
+ ' making any other change that may need a rollback:\n' +
1194
+ ' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
1195
+ ' Add one `--decision <id>` for each existing decision this round may change.\n' +
1196
+ ' Single-step commands that only build, check, or record work already done\n' +
1197
+ ' (compiling, running tests, `git add`/`git commit`) need no intent.'
1198
+ )
1199
+ .replace(
1200
+ ' Git operations remain subject to normal authorization and safety requirements\n' +
1201
+ ' even though they do not require an intent. Any non-Git content change made while\n' +
1202
+ ' preparing a Git operation does require a new intent.',
1203
+ ' An authorized Git commit that only stages and records the verified changes and\n' +
1204
+ ' just-closed log finalizes that round without requiring a new intent. Any content\n' +
1205
+ ' change made while preparing the commit does require a new intent.'
1206
+ );
1207
+ if (version >= 9) return v9;
1208
+ const v8 = v9.replace(
1209
+ '\nAfter a merge collision, run `driftseal absorb` rather than editing the log;\n' +
1210
+ 'if both sides still have an open intent, add `--abandon-theirs` or\n' +
1211
+ '`--abandon-ours`.',
1212
+ ''
1213
+ );
1214
+ if (version >= 8) return v8;
1215
+ const v7 = v8.replace(
1156
1216
  '\nThis `AGENTS.md` protocol is the source of truth. Use the `driftseal` CLI by\n' +
1157
1217
  'default; the companion skill only helps discover and resume the workflow, while\n' +
1158
1218
  'MCP and lifecycle hooks are optional adapters.\n',
@@ -1220,6 +1280,8 @@ Count postponed choices with \`driftseal decision list --status deferred --count
1220
1280
  then review them with \`driftseal decision list --status deferred\`.
1221
1281
  When an intent declares an existing decision with \`--decision <id>\`, use
1222
1282
  \`driftseal decision update\` to record its status transition or explicit confirmation.
1283
+ After a merge, colliding decision ids are remapped with \`driftseal absorb\`;
1284
+ concurrent edits of a shared decision are not auto-merged.
1223
1285
  Commit \`.decision-log/\` with the code.
1224
1286
  ${DECISION_PROTOCOL_END}`;
1225
1287
  }
@@ -1276,8 +1338,15 @@ Commit \`.decision-log/\` with the code.`;
1276
1338
  }
1277
1339
 
1278
1340
  function previousDecisionProtocolBlock(version) {
1279
- if (version >= 7) return decisionProtocolBlock(version);
1280
- return decisionProtocolBlock(version).replace(' --driver "<decision driver>"', '');
1341
+ const v9 = decisionProtocolBlock(version);
1342
+ if (version >= 9) return v9;
1343
+ const v8 = v9.replace(
1344
+ '\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
1345
+ 'concurrent edits of a shared decision are not auto-merged.',
1346
+ ''
1347
+ );
1348
+ if (version >= 7) return v8;
1349
+ return v8.replace(' --driver "<decision driver>"', '');
1281
1350
  }
1282
1351
 
1283
1352
  function upgradeManagedBlock({
@@ -2005,6 +2074,776 @@ function runHookReminder(event, argv) {
2005
2074
  return { changed: true, event, format };
2006
2075
  }
2007
2076
 
2077
+ function gitCaptureRaw(args, cwd = process.cwd()) {
2078
+ try {
2079
+ return execFileSync('git', args, {
2080
+ cwd,
2081
+ encoding: 'utf8',
2082
+ stdio: ['ignore', 'pipe', 'ignore'],
2083
+ });
2084
+ } catch {
2085
+ return null;
2086
+ }
2087
+ }
2088
+
2089
+ function gitCapture(args, cwd = process.cwd()) {
2090
+ const output = gitCaptureRaw(args, cwd);
2091
+ return output === null ? null : output.trim();
2092
+ }
2093
+
2094
+ function isGitWorkTree(cwd = process.cwd()) {
2095
+ return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
2096
+ }
2097
+
2098
+ function gitOtherHead(cwd = process.cwd()) {
2099
+ return (
2100
+ gitCapture(['rev-parse', '--verify', 'MERGE_HEAD'], cwd) ||
2101
+ gitCapture(['rev-parse', '--verify', 'REBASE_HEAD'], cwd) ||
2102
+ gitCapture(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], cwd)
2103
+ );
2104
+ }
2105
+
2106
+ function gitMergeBase(cwd = process.cwd()) {
2107
+ const other = gitOtherHead(cwd);
2108
+ if (!other) return null;
2109
+ return gitCapture(['merge-base', 'HEAD', other], cwd);
2110
+ }
2111
+
2112
+ function gitMergeBaseFor(left, right, cwd = process.cwd()) {
2113
+ return gitCapture(['merge-base', left, right], cwd);
2114
+ }
2115
+
2116
+ function gitReadFile(treeish, file, cwd = process.cwd()) {
2117
+ return gitCaptureRaw(['show', `${treeish}:${file}`], cwd);
2118
+ }
2119
+
2120
+ function gitFindCommitForFile(file, repositoryPath, cwd = process.cwd()) {
2121
+ const blob = gitCapture(['hash-object', file], cwd);
2122
+ const history = gitCapture(['rev-list', '--all', '--reflog', '--', repositoryPath], cwd);
2123
+ if (!blob || !history) return null;
2124
+ for (const commit of history.split('\n')) {
2125
+ if (gitCapture(['rev-parse', `${commit}:${repositoryPath}`], cwd) === blob) return commit;
2126
+ }
2127
+ return null;
2128
+ }
2129
+
2130
+ function gitMergeParents(cwd = process.cwd()) {
2131
+ const line = gitCapture(['rev-list', '--parents', '-n', '1', 'HEAD'], cwd);
2132
+ if (!line) return null;
2133
+ const parts = line.split(/\s+/);
2134
+ if (parts.length !== 3) return null;
2135
+ return { ours: parts[1], theirs: parts[2] };
2136
+ }
2137
+
2138
+ function gitDecisionIds(treeish, cwd = process.cwd()) {
2139
+ const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
2140
+ if (!out) return new Set();
2141
+ const ids = new Set();
2142
+ for (const file of out.split('\n')) {
2143
+ const match = path.basename(file).match(/^(\d{4,})-.*\.md$/);
2144
+ if (match) ids.add(normalizeDecisionId(match[1]));
2145
+ }
2146
+ return ids;
2147
+ }
2148
+
2149
+ function gitDecisionEntries(treeish, cwd = process.cwd()) {
2150
+ const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
2151
+ if (!out) return [];
2152
+ const entries = [];
2153
+ for (const file of out.split('\n')) {
2154
+ const name = path.basename(file);
2155
+ const match = name.match(/^(\d{4,})-.*\.md$/);
2156
+ if (!match) continue;
2157
+ const content = gitReadFile(treeish, file, cwd);
2158
+ if (content === null) continue;
2159
+ entries.push({
2160
+ id: normalizeDecisionId(match[1]),
2161
+ file: name,
2162
+ path: file,
2163
+ content,
2164
+ });
2165
+ }
2166
+ return entries;
2167
+ }
2168
+
2169
+ function gitIntentRecords(treeish, cwd = process.cwd()) {
2170
+ const content = gitReadFile(treeish, '.intent-log/events.jsonl', cwd);
2171
+ return content === null ? [] : parseJsonlRecords(content, `${treeish}:.intent-log/events.jsonl`);
2172
+ }
2173
+
2174
+ function canonicalEvent(event) {
2175
+ return Object.fromEntries(
2176
+ Object.entries(event)
2177
+ .filter(([, value]) => value !== undefined)
2178
+ .sort(([left], [right]) => left.localeCompare(right))
2179
+ );
2180
+ }
2181
+
2182
+ function eventsEqual(left, right) {
2183
+ return JSON.stringify(canonicalEvent(left)) === JSON.stringify(canonicalEvent(right));
2184
+ }
2185
+
2186
+ function commonPrefixLength(ours, theirs) {
2187
+ let index = 0;
2188
+ while (
2189
+ index < ours.length &&
2190
+ index < theirs.length &&
2191
+ eventsEqual(ours[index].event, theirs[index].event)
2192
+ ) {
2193
+ index += 1;
2194
+ }
2195
+ return index;
2196
+ }
2197
+
2198
+ function recordsHavePrefix(records, prefix) {
2199
+ if (prefix.length > records.length) return false;
2200
+ return prefix.every((record, index) => eventsEqual(record.event, records[index].event));
2201
+ }
2202
+
2203
+ function parseConflictContent(content) {
2204
+ if (!/^<<<<<<< /m.test(content)) return null;
2205
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
2206
+ const ours = [];
2207
+ const theirs = [];
2208
+ let mode = 'base';
2209
+ for (const line of content.split(/\r?\n/)) {
2210
+ if (line.startsWith('<<<<<<< ')) {
2211
+ mode = 'ours';
2212
+ continue;
2213
+ }
2214
+ if (line.startsWith('||||||| ')) {
2215
+ mode = 'ancestor';
2216
+ continue;
2217
+ }
2218
+ if (line.startsWith('=======')) {
2219
+ mode = 'theirs';
2220
+ continue;
2221
+ }
2222
+ if (line.startsWith('>>>>>>> ')) {
2223
+ mode = 'base';
2224
+ continue;
2225
+ }
2226
+ if (mode === 'base') {
2227
+ ours.push(line);
2228
+ theirs.push(line);
2229
+ } else if (mode === 'ours') {
2230
+ ours.push(line);
2231
+ } else if (mode === 'theirs') {
2232
+ theirs.push(line);
2233
+ }
2234
+ }
2235
+ return { oursText: ours.join(eol), theirsText: theirs.join(eol) };
2236
+ }
2237
+
2238
+ function collectDecisionIdsFromEvents(events) {
2239
+ const ids = new Set();
2240
+ for (const event of events) {
2241
+ if (Array.isArray(event.decisions)) {
2242
+ for (const id of event.decisions) ids.add(normalizeDecisionId(id));
2243
+ }
2244
+ if (event.decisionId) ids.add(normalizeDecisionId(event.decisionId));
2245
+ }
2246
+ return ids;
2247
+ }
2248
+
2249
+ function nextDecisionIdFromIds(ids) {
2250
+ if (ids.size === 0) return '0001';
2251
+ let max = 0n;
2252
+ for (const id of ids) {
2253
+ const value = BigInt(id);
2254
+ if (value > max) max = value;
2255
+ }
2256
+ return String(max + 1n).padStart(4, '0');
2257
+ }
2258
+
2259
+ function decisionSlugFromFile(file) {
2260
+ return file.replace(/^\d+-/, '').replace(/\.md$/, '');
2261
+ }
2262
+
2263
+ function rewriteDecisionId(content, newId) {
2264
+ return content.replace(/^# [0-9]+\. /, `# ${String(BigInt(newId))}. `);
2265
+ }
2266
+
2267
+ function splitDuplicateDecisions(entries) {
2268
+ const ours = [];
2269
+ const theirs = [];
2270
+ const seen = new Set();
2271
+ for (const entry of entries) {
2272
+ if (seen.has(entry.id)) theirs.push(entry);
2273
+ else {
2274
+ seen.add(entry.id);
2275
+ ours.push(entry);
2276
+ }
2277
+ }
2278
+ return { ours, theirs };
2279
+ }
2280
+
2281
+ function hasDuplicateDecisionIds(entries) {
2282
+ const seen = new Set();
2283
+ for (const entry of entries) {
2284
+ if (seen.has(entry.id)) return true;
2285
+ seen.add(entry.id);
2286
+ }
2287
+ return false;
2288
+ }
2289
+
2290
+ function hasDuplicateIntentBegins(records) {
2291
+ const seen = new Set();
2292
+ for (const record of records) {
2293
+ if (record.event.type !== 'begin') continue;
2294
+ if (seen.has(record.event.id)) return true;
2295
+ seen.add(record.event.id);
2296
+ }
2297
+ return false;
2298
+ }
2299
+
2300
+ function planDecisionAbsorb({ oursEntries, theirsEntries, baseEntries = [], baseIds = new Set() }) {
2301
+ const mappings = [];
2302
+ const copies = [];
2303
+ const decisionMap = new Map();
2304
+ const usedIds = new Set(oursEntries.map((entry) => entry.id));
2305
+ const oursById = new Map();
2306
+ const baseById = new Map();
2307
+ for (const entry of oursEntries) {
2308
+ if (!oursById.has(entry.id)) oursById.set(entry.id, entry);
2309
+ }
2310
+ for (const entry of baseEntries) {
2311
+ if (!baseById.has(entry.id)) baseById.set(entry.id, entry);
2312
+ }
2313
+
2314
+ for (const entry of theirsEntries) {
2315
+ const ours = oursById.get(entry.id);
2316
+ const theirsContent = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
2317
+ if (!ours) {
2318
+ copies.push({
2319
+ fromFile: entry.file,
2320
+ toFile: entry.file,
2321
+ content: theirsContent,
2322
+ removeFile: null,
2323
+ });
2324
+ usedIds.add(entry.id);
2325
+ continue;
2326
+ }
2327
+ const oursContent = ours.content !== undefined ? ours.content : fs.readFileSync(ours.path, 'utf8');
2328
+ const oursHash = contentHash(oursContent);
2329
+ const theirsHash = contentHash(theirsContent);
2330
+ if (oursHash === theirsHash) {
2331
+ if (ours.file !== entry.file) {
2332
+ copies.push({
2333
+ fromFile: entry.file,
2334
+ toFile: ours.file,
2335
+ content: oursContent,
2336
+ removeFile: entry.file,
2337
+ });
2338
+ }
2339
+ continue;
2340
+ }
2341
+
2342
+ const base = baseById.get(entry.id);
2343
+ if (base) {
2344
+ const baseContent = base.content !== undefined ? base.content : fs.readFileSync(base.path, 'utf8');
2345
+ const baseHash = contentHash(baseContent);
2346
+ if (oursHash === baseHash) {
2347
+ copies.push({
2348
+ fromFile: entry.file,
2349
+ toFile: ours.file,
2350
+ content: theirsContent,
2351
+ removeFile: entry.file !== ours.file ? entry.file : null,
2352
+ });
2353
+ continue;
2354
+ }
2355
+ if (theirsHash === baseHash) {
2356
+ if (entry.file !== ours.file) {
2357
+ copies.push({
2358
+ fromFile: entry.file,
2359
+ toFile: ours.file,
2360
+ content: oursContent,
2361
+ removeFile: entry.file,
2362
+ });
2363
+ }
2364
+ continue;
2365
+ }
2366
+ fail(
2367
+ `decision ${entry.id} was edited on both sides; resolve ${ours.file} manually before absorbing`
2368
+ );
2369
+ }
2370
+ if (baseIds.has(entry.id)) {
2371
+ fail(
2372
+ `decision ${entry.id} was edited on both sides or its base content is unavailable; ` +
2373
+ `resolve ${ours.file} manually before absorbing`
2374
+ );
2375
+ }
2376
+ const newId = nextDecisionIdFromIds(usedIds);
2377
+ usedIds.add(newId);
2378
+ decisionMap.set(entry.id, newId);
2379
+ mappings.push({ kind: 'decision', from: entry.id, to: newId });
2380
+ copies.push({
2381
+ fromFile: entry.file,
2382
+ toFile: `${newId}-${decisionSlugFromFile(entry.file)}.md`,
2383
+ content: rewriteDecisionId(theirsContent, newId),
2384
+ removeFile: entry.file !== ours.file ? entry.file : null,
2385
+ });
2386
+ }
2387
+ return { decisionMap, mappings, copies };
2388
+ }
2389
+
2390
+ function remapEvent(event, intentMap, decisionMap) {
2391
+ const next = { ...event };
2392
+ if (intentMap.has(event.id)) next.id = intentMap.get(event.id);
2393
+ if (Array.isArray(next.decisions) && next.decisions.length > 0) {
2394
+ next.decisions = next.decisions.map((id) => {
2395
+ const normalized = normalizeDecisionId(id);
2396
+ return decisionMap.get(normalized) || id;
2397
+ });
2398
+ }
2399
+ if (next.decisionId) {
2400
+ const normalized = normalizeDecisionId(next.decisionId);
2401
+ if (decisionMap.has(normalized)) next.decisionId = decisionMap.get(normalized);
2402
+ }
2403
+ return next;
2404
+ }
2405
+
2406
+ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
2407
+ const intentMap = new Map();
2408
+ const mappings = [];
2409
+ const used = [...oursUsedEvents];
2410
+ const records = theirsNew.map((record) => {
2411
+ let event = record.event;
2412
+ if (event.type === 'begin' && used.some((item) => item.type === 'begin' && item.id === event.id)) {
2413
+ const { date } = parseIntentId(event.id);
2414
+ const newId = nextIdForDate(date, used);
2415
+ intentMap.set(event.id, newId);
2416
+ mappings.push({ kind: 'intent', from: event.id, to: newId });
2417
+ }
2418
+ event = remapEvent(event, intentMap, decisionMap);
2419
+ used.push(event);
2420
+ return { event };
2421
+ });
2422
+ return { records, mappings };
2423
+ }
2424
+
2425
+ function repairDuplicateIntentRecords(records, decisionMap) {
2426
+ const seenBegins = new Set();
2427
+ const intentMap = new Map();
2428
+ const used = [];
2429
+ const mappings = [];
2430
+ const result = [];
2431
+ let incomingSide = false;
2432
+ for (const record of records) {
2433
+ let event = record.event;
2434
+ if (event.type === 'begin' && seenBegins.has(event.id)) {
2435
+ incomingSide = true;
2436
+ const { date } = parseIntentId(event.id);
2437
+ const newId = nextIdForDate(date, used);
2438
+ intentMap.set(event.id, newId);
2439
+ mappings.push({ kind: 'intent', from: event.id, to: newId });
2440
+ } else if (event.type === 'begin') {
2441
+ seenBegins.add(event.id);
2442
+ }
2443
+ const remapped = remapEvent(event, intentMap, incomingSide ? decisionMap : new Map());
2444
+ const changed = remapped !== event && JSON.stringify(remapped) !== JSON.stringify(event);
2445
+ result.push(changed ? { event: remapped } : record);
2446
+ used.push(result.at(-1).event);
2447
+ }
2448
+ return { records: result, mappings, incomingSide };
2449
+ }
2450
+
2451
+ function serializeRecords(records) {
2452
+ if (records.length === 0) return '';
2453
+ return `${records.map((record) => record.raw || JSON.stringify(record.event)).join('\n')}\n`;
2454
+ }
2455
+
2456
+ function writeJsonl(file, records) {
2457
+ ensureDirectoryDurable(path.dirname(file));
2458
+ atomicWriteFile(file, serializeRecords(records));
2459
+ }
2460
+
2461
+ function applyDecisionCopies(copies, dryRun) {
2462
+ if (dryRun || copies.length === 0) return;
2463
+ ensureDirectoryDurable(decisionDir());
2464
+ for (const item of copies) {
2465
+ atomicWriteFile(path.join(decisionDir(), item.toFile), item.content);
2466
+ if (item.removeFile && item.removeFile !== item.toFile) {
2467
+ const stale = path.join(decisionDir(), item.removeFile);
2468
+ if (fs.existsSync(stale)) {
2469
+ fs.unlinkSync(stale);
2470
+ fsyncDirectory(decisionDir());
2471
+ }
2472
+ }
2473
+ }
2474
+ }
2475
+
2476
+ function countAbsorbedIntents(records) {
2477
+ return records.filter((record) => record.event.type === 'begin').length;
2478
+ }
2479
+
2480
+ function printAbsorbReport({ mappings, abandoned, intentCount }) {
2481
+ const remappedIntents = mappings.filter((mapping) => mapping.kind === 'intent').length;
2482
+ const remappedDecisions = mappings.filter((mapping) => mapping.kind === 'decision').length;
2483
+ printLine(
2484
+ `absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
2485
+ );
2486
+ for (const mapping of mappings) {
2487
+ if (mapping.kind === 'intent') printLine(`${mapping.from} (theirs) -> ${mapping.to}`);
2488
+ else printLine(`decision ${mapping.from} (theirs) -> ${mapping.to}`);
2489
+ }
2490
+ if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
2491
+ }
2492
+
2493
+ function abandonOpenIntent(records, targetId, side) {
2494
+ records.push({
2495
+ event: {
2496
+ schemaVersion: EVENT_SCHEMA_VERSION,
2497
+ type: 'end',
2498
+ id: targetId,
2499
+ ts: new Date().toISOString(),
2500
+ status: 'abandoned',
2501
+ note: `abandoned during absorb (--abandon-${side})`,
2502
+ verifyResult: null,
2503
+ },
2504
+ });
2505
+ return targetId;
2506
+ }
2507
+
2508
+ function resolveOpenIntents(result, oursRecords, theirsRecords, abandon, { allowConflict = false } = {}) {
2509
+ const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
2510
+ const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
2511
+ try {
2512
+ openIntent(fold(result.map((record) => record.event)));
2513
+ return { abandoned: null, conflict: false };
2514
+ } catch (err) {
2515
+ if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
2516
+ throw err;
2517
+ }
2518
+ if (abandon === 'theirs' && theirsOpen) {
2519
+ return { abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'), conflict: false };
2520
+ }
2521
+ if (abandon === 'ours' && oursOpen) {
2522
+ return { abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'), conflict: false };
2523
+ }
2524
+ if (allowConflict) return { abandoned: null, conflict: true };
2525
+ fail(`${err.message}; re-run with --abandon-theirs or --abandon-ours`);
2526
+ }
2527
+ }
2528
+
2529
+ function mergeRecordStreams(ours, theirs, baseRecords) {
2530
+ const inferred = ours.slice(0, commonPrefixLength(ours, theirs));
2531
+ const prefix = baseRecords && baseRecords.length > 0 ? baseRecords : inferred;
2532
+ if (!recordsHavePrefix(ours, prefix) || !recordsHavePrefix(theirs, prefix)) {
2533
+ fail('cannot absorb: shared history does not match the base log');
2534
+ }
2535
+ return {
2536
+ base: prefix,
2537
+ oursNew: ours.slice(prefix.length),
2538
+ theirsNew: theirs.slice(prefix.length),
2539
+ };
2540
+ }
2541
+
2542
+ function GITATTRIBUTES_MERGE_LINE() {
2543
+ return '.intent-log/events.jsonl merge=driftseal';
2544
+ }
2545
+
2546
+ function ensureGitAttributes() {
2547
+ const target = path.join(process.cwd(), '.gitattributes');
2548
+ const line = GITATTRIBUTES_MERGE_LINE();
2549
+ const existed = fs.existsSync(target);
2550
+ const current = existed ? fs.readFileSync(target, 'utf8') : '';
2551
+ const eol = current.includes('\r\n') ? '\r\n' : '\n';
2552
+ const lines = current.split(/\r?\n/);
2553
+ if (lines.some((entry) => entry.trim() === line)) return { changed: false, target };
2554
+ const prefix = current.length === 0 || current.endsWith('\n') || current.endsWith('\r\n') ? '' : eol;
2555
+ const next = `${current}${prefix}${line}${eol}`;
2556
+ atomicWriteFile(target, next);
2557
+ return { changed: true, target };
2558
+ }
2559
+
2560
+ function ensureGitMergeDriver() {
2561
+ if (!isGitWorkTree()) return { changed: false, configured: false };
2562
+ const name = gitCapture(['config', '--local', '--get', 'merge.driftseal.name']);
2563
+ const driver = gitCapture(['config', '--local', '--get', 'merge.driftseal.driver']);
2564
+ const expectedName = 'DriftSeal intent log merge';
2565
+ const expectedDriver = 'driftseal absorb --git %O %A %B';
2566
+ if (name === expectedName && driver === expectedDriver) {
2567
+ return { changed: false, configured: true };
2568
+ }
2569
+ execFileSync('git', ['config', '--local', 'merge.driftseal.name', expectedName], {
2570
+ stdio: ['ignore', 'ignore', 'pipe'],
2571
+ });
2572
+ execFileSync('git', ['config', '--local', 'merge.driftseal.driver', expectedDriver], {
2573
+ stdio: ['ignore', 'ignore', 'pipe'],
2574
+ });
2575
+ return { changed: true, configured: true };
2576
+ }
2577
+
2578
+ function absorbUsage() {
2579
+ return (
2580
+ 'usage: driftseal absorb [other-events.jsonl] [--decisions <dir>] [--abandon-theirs | --abandon-ours] [--dry-run]\n' +
2581
+ ' or: driftseal absorb --git <base> <ours> <theirs>'
2582
+ );
2583
+ }
2584
+
2585
+ function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false } = {}) {
2586
+ if (!fs.existsSync(file)) {
2587
+ if (allowMissing) return { records: [], conflict: false };
2588
+ fail(`intent log not found: ${file}`);
2589
+ }
2590
+ let content = fs.readFileSync(file, 'utf8');
2591
+ if (repairTail && !/^<<<<<<< /m.test(content)) {
2592
+ readEvents({ file, repairTail: true });
2593
+ content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
2594
+ }
2595
+ const conflict = parseConflictContent(content);
2596
+ if (conflict) {
2597
+ return {
2598
+ ours: parseJsonlRecords(conflict.oursText, `${label} ours`),
2599
+ theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`),
2600
+ conflict: true,
2601
+ };
2602
+ }
2603
+ return { records: parseJsonlRecords(content, label), conflict: false };
2604
+ }
2605
+
2606
+ function finishAbsorb({
2607
+ result,
2608
+ oursRecords,
2609
+ theirsRecords,
2610
+ mappings,
2611
+ copies,
2612
+ abandon,
2613
+ dryRun,
2614
+ outputFile,
2615
+ intentCount,
2616
+ allowConflict = false,
2617
+ followupMessage = null,
2618
+ }) {
2619
+ const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
2620
+ allowConflict,
2621
+ });
2622
+ fold(result.map((record) => record.event));
2623
+ if (!conflict) openIntent(fold(result.map((record) => record.event)));
2624
+ if (!dryRun) {
2625
+ writeJsonl(outputFile, result);
2626
+ applyDecisionCopies(copies, dryRun);
2627
+ }
2628
+ if (intentCount === 0 && mappings.length === 0 && copies.length === 0 && !abandoned) {
2629
+ printLine('nothing to absorb');
2630
+ } else {
2631
+ printAbsorbReport({
2632
+ mappings,
2633
+ abandoned,
2634
+ intentCount,
2635
+ });
2636
+ }
2637
+ if (conflict) {
2638
+ printLine('multiple intents remain in progress; re-run with --abandon-theirs or --abandon-ours');
2639
+ }
2640
+ if (followupMessage) printLine(followupMessage);
2641
+ return {
2642
+ mappings,
2643
+ abandoned,
2644
+ copies: copies.map((item) => item.toFile),
2645
+ outputFile,
2646
+ exitCode: conflict || followupMessage ? 1 : 0,
2647
+ };
2648
+ }
2649
+
2650
+ function absorbFromStreams(ours, theirs, baseRecords, options) {
2651
+ const streams = mergeRecordStreams(ours, theirs, baseRecords);
2652
+ const decisionPlan = planDecisionAbsorb({
2653
+ oursEntries: options.oursDecisionEntries || [],
2654
+ theirsEntries: options.theirsDecisionEntries || [],
2655
+ baseEntries: options.baseDecisionEntries || [],
2656
+ baseIds: options.baseDecisionIds || new Set(),
2657
+ });
2658
+ const remapped = remapTheirsRecords(
2659
+ streams.theirsNew,
2660
+ [...streams.base, ...streams.oursNew].map((record) => record.event),
2661
+ decisionPlan.decisionMap
2662
+ );
2663
+ const result = [...streams.base, ...streams.oursNew, ...remapped.records];
2664
+ return finishAbsorb({
2665
+ result,
2666
+ oursRecords: [...streams.base, ...streams.oursNew],
2667
+ theirsRecords: [...streams.base, ...remapped.records],
2668
+ mappings: [...remapped.mappings, ...decisionPlan.mappings],
2669
+ copies: decisionPlan.copies,
2670
+ abandon: options.abandon,
2671
+ dryRun: options.dryRun,
2672
+ outputFile: options.outputFile,
2673
+ allowConflict: options.allowConflict,
2674
+ followupMessage: options.followupMessage,
2675
+ intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
2676
+ });
2677
+ }
2678
+
2679
+ function gitAbsorbRepairContext(records, decisionEntries) {
2680
+ const pending = gitOtherHead();
2681
+ if (pending) {
2682
+ return {
2683
+ ours: 'HEAD',
2684
+ theirs: pending,
2685
+ base: gitMergeBaseFor('HEAD', pending),
2686
+ };
2687
+ }
2688
+ if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateIntentBegins(records)) return null;
2689
+ const parents = gitMergeParents();
2690
+ if (!parents) return null;
2691
+ return {
2692
+ ...parents,
2693
+ base: gitMergeBaseFor(parents.ours, parents.theirs),
2694
+ };
2695
+ }
2696
+
2697
+ function absorbFromGitContext(context, { abandon, dryRun, outputFile }) {
2698
+ const baseRecords = context.base ? gitIntentRecords(context.base) : [];
2699
+ return absorbFromStreams(
2700
+ gitIntentRecords(context.ours),
2701
+ gitIntentRecords(context.theirs),
2702
+ baseRecords,
2703
+ {
2704
+ abandon,
2705
+ dryRun,
2706
+ outputFile,
2707
+ oursDecisionEntries: gitDecisionEntries(context.ours),
2708
+ theirsDecisionEntries: gitDecisionEntries(context.theirs),
2709
+ baseDecisionEntries: context.base ? gitDecisionEntries(context.base) : [],
2710
+ baseDecisionIds: context.base
2711
+ ? gitDecisionIds(context.base)
2712
+ : collectDecisionIdsFromEvents(baseRecords.map((record) => record.event)),
2713
+ }
2714
+ );
2715
+ }
2716
+
2717
+ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
2718
+ const oursFile = logFile();
2719
+ const loaded = loadAbsorbSide(oursFile, oursFile, { repairTail: true, allowMissing: !otherFile });
2720
+ const currentDecisionEntries = !otherFile
2721
+ ? listDecisionEntries(decisionDir(), { allowDuplicates: true })
2722
+ : [];
2723
+ const gitContext = !otherFile
2724
+ ? gitAbsorbRepairContext(loaded.records || [], currentDecisionEntries)
2725
+ : null;
2726
+ if (gitContext) {
2727
+ return absorbFromGitContext(gitContext, { abandon, dryRun, outputFile: oursFile });
2728
+ }
2729
+ if (loaded.conflict) {
2730
+ const baseIds = collectDecisionIdsFromEvents(
2731
+ loaded.ours.slice(0, commonPrefixLength(loaded.ours, loaded.theirs)).map((record) => record.event)
2732
+ );
2733
+ const gitBase = gitMergeBase();
2734
+ const baseDecisionIds = gitBase ? gitDecisionIds(gitBase) : baseIds;
2735
+ return absorbFromStreams(loaded.ours, loaded.theirs, null, {
2736
+ abandon,
2737
+ dryRun,
2738
+ outputFile: oursFile,
2739
+ oursDecisionEntries: listDecisionEntries(decisionDir(), { allowDuplicates: true }),
2740
+ theirsDecisionEntries: otherDecisions
2741
+ ? listDecisionEntries(otherDecisions)
2742
+ : splitDuplicateDecisions(listDecisionEntries(decisionDir(), { allowDuplicates: true })).theirs,
2743
+ baseDecisionIds,
2744
+ baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
2745
+ });
2746
+ }
2747
+
2748
+ if (!otherFile) {
2749
+ const oursEntries = currentDecisionEntries;
2750
+ const split = splitDuplicateDecisions(oursEntries);
2751
+ const gitBase = gitMergeBase();
2752
+ const decisionPlan = planDecisionAbsorb({
2753
+ oursEntries: split.ours,
2754
+ theirsEntries: split.theirs,
2755
+ baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
2756
+ baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
2757
+ });
2758
+ const repaired = repairDuplicateIntentRecords(loaded.records, decisionPlan.decisionMap);
2759
+ if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
2760
+ fail(
2761
+ 'cannot determine which intent records own the duplicate decision; ' +
2762
+ 'run absorb during the merge or provide the incoming log and decision directory'
2763
+ );
2764
+ }
2765
+ const result = repaired.records;
2766
+ const mappings = [...repaired.mappings, ...decisionPlan.mappings];
2767
+ const remappedIds = new Set(
2768
+ mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
2769
+ );
2770
+ const oursRecords = result.filter((record) => !remappedIds.has(record.event.id));
2771
+ return finishAbsorb({
2772
+ result,
2773
+ oursRecords: oursRecords.length > 0 ? oursRecords : result,
2774
+ theirsRecords: result,
2775
+ mappings,
2776
+ copies: decisionPlan.copies,
2777
+ abandon,
2778
+ dryRun,
2779
+ outputFile: oursFile,
2780
+ intentCount: remappedIds.size,
2781
+ });
2782
+ }
2783
+
2784
+ const theirs = loadAbsorbSide(otherFile, otherFile);
2785
+ if (theirs.conflict) fail(`incoming log still contains conflict markers: ${otherFile}`);
2786
+ const otherRoot = path.resolve(path.dirname(otherFile), '..');
2787
+ const otherHead = isGitWorkTree(otherRoot) ? gitCapture(['rev-parse', 'HEAD'], otherRoot) : null;
2788
+ const gitBase = otherHead ? gitCapture(['merge-base', 'HEAD', otherHead]) : gitMergeBase();
2789
+ const baseDecisionIds = gitBase
2790
+ ? gitDecisionIds(gitBase)
2791
+ : collectDecisionIdsFromEvents(
2792
+ loaded.records
2793
+ .slice(0, commonPrefixLength(loaded.records, theirs.records))
2794
+ .map((record) => record.event)
2795
+ );
2796
+ return absorbFromStreams(loaded.records, theirs.records, null, {
2797
+ abandon,
2798
+ dryRun,
2799
+ outputFile: oursFile,
2800
+ oursDecisionEntries: listDecisionEntries(decisionDir()),
2801
+ theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
2802
+ baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
2803
+ baseDecisionIds,
2804
+ });
2805
+ }
2806
+
2807
+ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
2808
+ const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true });
2809
+ const ours = loadAbsorbSide(oursFile, oursFile);
2810
+ const theirs = loadAbsorbSide(theirsFile, theirsFile);
2811
+ if (base.conflict || ours.conflict || theirs.conflict) {
2812
+ fail('git merge driver received a log that still contains conflict markers');
2813
+ }
2814
+ const otherHead =
2815
+ gitOtherHead() || gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
2816
+ const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
2817
+ let followupMessage = null;
2818
+ if (!otherHead) {
2819
+ followupMessage =
2820
+ 'incoming Git tree could not be identified safely; re-run driftseal absorb after Git stops the merge';
2821
+ } else {
2822
+ const decisionPlan = planDecisionAbsorb({
2823
+ oursEntries: gitDecisionEntries('HEAD'),
2824
+ theirsEntries: gitDecisionEntries(otherHead),
2825
+ baseEntries: mergeBase ? gitDecisionEntries(mergeBase) : [],
2826
+ baseIds: mergeBase
2827
+ ? gitDecisionIds(mergeBase)
2828
+ : collectDecisionIdsFromEvents(base.records.map((record) => record.event)),
2829
+ });
2830
+ const requiresDecisionRepair =
2831
+ decisionPlan.mappings.length > 0 ||
2832
+ decisionPlan.copies.some((copy) => copy.removeFile && copy.removeFile !== copy.toFile);
2833
+ if (requiresDecisionRepair) {
2834
+ followupMessage =
2835
+ 'decision ids require worktree repair; run driftseal absorb, then stage the repaired logs';
2836
+ }
2837
+ }
2838
+ return absorbFromStreams(ours.records, theirs.records, base.records, {
2839
+ abandon,
2840
+ dryRun,
2841
+ outputFile: oursFile,
2842
+ allowConflict: !abandon,
2843
+ followupMessage,
2844
+ });
2845
+ }
2846
+
2008
2847
  const commands = {
2009
2848
  begin(argv) {
2010
2849
  const { positionals, flags } = parseArgs(argv, {
@@ -2425,6 +3264,29 @@ const commands = {
2425
3264
  fail(hookUsage());
2426
3265
  },
2427
3266
 
3267
+ absorb(argv) {
3268
+ const { positionals, flags } = parseArgs(argv, {
3269
+ git: 'boolean',
3270
+ decisions: 'single',
3271
+ 'abandon-theirs': 'boolean',
3272
+ 'abandon-ours': 'boolean',
3273
+ 'dry-run': 'boolean',
3274
+ });
3275
+ if (flags['abandon-theirs'] && flags['abandon-ours']) {
3276
+ fail('cannot combine --abandon-theirs and --abandon-ours');
3277
+ }
3278
+ const abandon = flags['abandon-theirs'] ? 'theirs' : flags['abandon-ours'] ? 'ours' : null;
3279
+ const dryRun = Boolean(flags['dry-run']);
3280
+
3281
+ if (flags.git) {
3282
+ if (positionals.length !== 3) fail(absorbUsage());
3283
+ if (flags.decisions) fail('--decisions cannot be combined with --git');
3284
+ return absorbGit(positionals[0], positionals[1], positionals[2], { abandon, dryRun });
3285
+ }
3286
+ if (positionals.length > 1) fail(absorbUsage());
3287
+ return absorbLogs(positionals[0], flags.decisions, { abandon, dryRun });
3288
+ },
3289
+
2428
3290
  init(argv) {
2429
3291
  const { positionals } = parseArgs(argv, {});
2430
3292
  if (positionals.length > 0) fail('usage: driftseal init');
@@ -2448,6 +3310,8 @@ const commands = {
2448
3310
  protocolEol(previousIntentProtocolBlock(5), eol),
2449
3311
  protocolEol(previousIntentProtocolBlock(6), eol),
2450
3312
  protocolEol(previousIntentProtocolBlock(7), eol),
3313
+ protocolEol(previousIntentProtocolBlock(8), eol),
3314
+ protocolEol(previousIntentProtocolBlock(9), eol),
2451
3315
  ],
2452
3316
  knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
2453
3317
  });
@@ -2465,6 +3329,8 @@ const commands = {
2465
3329
  protocolEol(previousDecisionProtocolBlock(5), eol),
2466
3330
  protocolEol(previousDecisionProtocolBlock(6), eol),
2467
3331
  protocolEol(previousDecisionProtocolBlock(7), eol),
3332
+ protocolEol(previousDecisionProtocolBlock(8), eol),
3333
+ protocolEol(previousDecisionProtocolBlock(9), eol),
2468
3334
  ],
2469
3335
  knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
2470
3336
  });
@@ -2484,12 +3350,28 @@ const commands = {
2484
3350
  updated += separator + additions.join(eol + eol) + eol;
2485
3351
  }
2486
3352
 
2487
- if (updated === current) {
3353
+ const attributes = ensureGitAttributes();
3354
+ let driver = { changed: false, configured: false };
3355
+ try {
3356
+ driver = ensureGitMergeDriver();
3357
+ } catch (err) {
3358
+ printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
3359
+ }
3360
+
3361
+ if (updated === current && !attributes.changed && !driver.changed) {
2488
3362
  printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
2489
3363
  return { changed: false, target };
2490
3364
  }
2491
- atomicWriteFile(target, updated);
2492
- printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
3365
+ if (updated !== current) {
3366
+ atomicWriteFile(target, updated);
3367
+ printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
3368
+ }
3369
+ if (attributes.changed) {
3370
+ printLine(`Configured git merge attribute: ${attributes.target}`);
3371
+ }
3372
+ if (driver.changed) {
3373
+ printLine('Configured local git merge driver for DriftSeal intent logs');
3374
+ }
2493
3375
  return { changed: true, target };
2494
3376
  },
2495
3377
 
@@ -2507,6 +3389,11 @@ usage:
2507
3389
  hide meaningless closed records without deleting them
2508
3390
  driftseal unreclaim <id> --reason "<why>"
2509
3391
  restore a reclaimed record to the visible log
3392
+ driftseal absorb [other-events.jsonl] [--decisions <dir>]
3393
+ [--abandon-theirs | --abandon-ours] [--dry-run]
3394
+ merge another intent log, remapping colliding ids
3395
+ driftseal absorb --git <base> <ours> <theirs>
3396
+ git merge driver for .intent-log/events.jsonl
2510
3397
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
2511
3398
  driftseal decision update <id> [--status STATUS] --note "..."
2512
3399
  reconcile a linked decision in the open intent
@@ -2524,7 +3411,7 @@ usage:
2524
3411
  targets: kimi-code (global only), claude-code, codex (prompt only)
2525
3412
  driftseal hook prompt|stop [--format plain|claude-code]
2526
3413
  emit the reminder a lifecycle hook injects; never blocks
2527
- driftseal init inject intent and decision protocols into ./AGENTS.md
3414
+ driftseal init inject protocols into ./AGENTS.md and configure the git merge driver
2528
3415
  driftseal --version | -V print the installed DriftSeal version
2529
3416
  driftseal help
2530
3417
 
@@ -2559,6 +3446,7 @@ function mutationResources(cmd, argv) {
2559
3446
  if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
2560
3447
  if (cmd === 'init') return [process.cwd()];
2561
3448
  if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
3449
+ if (cmd === 'absorb') return [logDir(), decisionDir()];
2562
3450
  if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
2563
3451
  return [logDir()];
2564
3452
  }
@@ -2580,13 +3468,14 @@ function dispatch(argv) {
2580
3468
  const fn = commands[cmd];
2581
3469
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
2582
3470
  const mutates =
2583
- ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim'].includes(cmd) ||
3471
+ ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
2584
3472
  (cmd === 'hook' && rest[0] === 'install') ||
2585
3473
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
2586
3474
  const readsIntentLog = ['status', 'log'].includes(cmd);
2587
3475
  if (mutates || readsIntentLog) {
2588
3476
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
2589
- return { data: withMutationLocks(resources, () => fn(rest)), exitCode: 0 };
3477
+ const data = withMutationLocks(resources, () => fn(rest));
3478
+ return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
2590
3479
  }
2591
3480
  return { data: fn(rest), exitCode: 0 };
2592
3481
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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`.