driftseal 1.1.0 → 1.1.2

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
@@ -179,7 +179,7 @@ Cursor have no supported hook surface for this yet.
179
179
 
180
180
  ## A work round
181
181
 
182
- Declare the round before changing files:
182
+ Declare the round before making non-Git changes:
183
183
 
184
184
  ```sh
185
185
  driftseal begin "add rate limiting to /api/login" \
@@ -197,7 +197,12 @@ driftseal end \
197
197
 
198
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.
199
199
 
200
- 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.
201
206
 
202
207
  ## Commands
203
208
 
@@ -293,7 +298,10 @@ already existed in the shared base are not auto-merged.
293
298
 
294
299
  `driftseal init` writes that absorb rule into `AGENTS.md`, plus `.gitattributes`
295
300
  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.
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.
297
305
 
298
306
  ## Storage
299
307
 
package/README.zh-CN.md CHANGED
@@ -172,7 +172,7 @@ Kimi Code 只在全局 `config.toml` 中记录 hook,因此该 target 必须指
172
172
 
173
173
  ## 一轮标准工作流
174
174
 
175
- 修改文件前,先声明这轮工作的目标:
175
+ 进行非 Git 改动前,先声明这轮工作的目标:
176
176
 
177
177
  ```sh
178
178
  driftseal begin "add rate limiting to /api/login" \
@@ -190,7 +190,10 @@ driftseal end \
190
190
 
191
191
  如果范围发生变化,先把当前 intent 以 `partial` 或 `abandoned` 关闭,再开启新的 intent。发生 context loss 后,用 `driftseal status` 和 `driftseal log --last 3` 重新锚定当前目标。
192
192
 
193
- 只负责构建、检查或记录已完成工作的单步命令(编译、跑测试、`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 内容改动,就开启新一轮。
194
197
 
195
198
  ## 命令速览
196
199
 
@@ -270,8 +273,10 @@ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
270
273
  不带 path 时,`absorb` 修复当前日志里的冲突标记或已经拼在一起的重复 id。两边都还有未关闭 intent 时,必须加上 `--abandon-theirs` 或 `--abandon-ours`。共享 base 里已经存在、又被两边同时改过的 decision,不会自动合并。
271
274
 
272
275
  `driftseal init` 会把这条 absorb 规则写入 `AGENTS.md`,同时写入 `.gitattributes`
273
- 并配置 local git merge driver,让 `events.jsonl` 走 `absorb --git`。clone 之后
274
- 需要再跑一次 `init`,因为 driver 只存在于 local git config。
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。
275
280
 
276
281
  ## 数据保存在哪里
277
282
 
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 = 9;
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;
@@ -1136,11 +1136,14 @@ default; the companion skill only helps discover and resume the workflow, while
1136
1136
  MCP and lifecycle hooks are optional adapters.
1137
1137
 
1138
1138
  1. **Write intent first**, before modifying, creating, or deleting files, or
1139
- making any other change that may need a rollback:
1139
+ making any other non-Git change that may need a rollback:
1140
1140
  \`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
1141
1141
  Add one \`--decision <id>\` for each existing decision this round may change.
1142
- Single-step commands that only build, check, or record work already done
1143
- (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.
1144
1147
  2. **Execute only the intent.** Scope change? Close the current intent
1145
1148
  (\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
1146
1149
  3. **Verify, then close**: run the declared verification, then
@@ -1153,9 +1156,9 @@ MCP and lifecycle hooks are optional adapters.
1153
1156
  the final content hash is recorded. Interrupted reconciliation is recovered
1154
1157
  by the next linked \`decision update\` or successful \`end\`. Closing as
1155
1158
  \`failed\` or \`abandoned\` cancels pending recovery for that intent.
1156
- An authorized Git commit that only stages and records the verified changes and
1157
- just-closed log finalizes that round without requiring a new intent. Any content
1158
- 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.
1159
1162
  4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1160
1163
  doing anything else. The open intent is the source of truth: resume it when its
1161
1164
  objective still matches the current task; otherwise close it (\`partial\` or
@@ -1175,7 +1178,34 @@ ${INTENT_PROTOCOL_END}`;
1175
1178
  }
1176
1179
 
1177
1180
  function previousIntentProtocolBlock(version) {
1178
- const v8 = 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(
1179
1209
  '\nAfter a merge collision, run `driftseal absorb` rather than editing the log;\n' +
1180
1210
  'if both sides still have an open intent, add `--abandon-theirs` or\n' +
1181
1211
  '`--abandon-ours`.',
@@ -1308,7 +1338,9 @@ Commit \`.decision-log/\` with the code.`;
1308
1338
  }
1309
1339
 
1310
1340
  function previousDecisionProtocolBlock(version) {
1311
- const v8 = decisionProtocolBlock(version).replace(
1341
+ const v9 = decisionProtocolBlock(version);
1342
+ if (version >= 9) return v9;
1343
+ const v8 = v9.replace(
1312
1344
  '\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
1313
1345
  'concurrent edits of a shared decision are not auto-merged.',
1314
1346
  ''
@@ -1654,7 +1686,7 @@ function directoryDigest(directory) {
1654
1686
  function visit(current, relative) {
1655
1687
  const stat = fs.lstatSync(current);
1656
1688
  if (stat.isDirectory()) {
1657
- digest.update(`directory\0${relative}\0${stat.mode & 0o777}\0`);
1689
+ digest.update(`directory\0${relative}\0`);
1658
1690
  for (const name of fs.readdirSync(current).sort()) {
1659
1691
  visit(path.join(current, name), relative ? path.join(relative, name) : name);
1660
1692
  }
@@ -2042,18 +2074,23 @@ function runHookReminder(event, argv) {
2042
2074
  return { changed: true, event, format };
2043
2075
  }
2044
2076
 
2045
- function gitCapture(args, cwd = process.cwd()) {
2077
+ function gitCaptureRaw(args, cwd = process.cwd()) {
2046
2078
  try {
2047
2079
  return execFileSync('git', args, {
2048
2080
  cwd,
2049
2081
  encoding: 'utf8',
2050
2082
  stdio: ['ignore', 'pipe', 'ignore'],
2051
- }).trim();
2083
+ });
2052
2084
  } catch {
2053
2085
  return null;
2054
2086
  }
2055
2087
  }
2056
2088
 
2089
+ function gitCapture(args, cwd = process.cwd()) {
2090
+ const output = gitCaptureRaw(args, cwd);
2091
+ return output === null ? null : output.trim();
2092
+ }
2093
+
2057
2094
  function isGitWorkTree(cwd = process.cwd()) {
2058
2095
  return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
2059
2096
  }
@@ -2072,6 +2109,32 @@ function gitMergeBase(cwd = process.cwd()) {
2072
2109
  return gitCapture(['merge-base', 'HEAD', other], cwd);
2073
2110
  }
2074
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
+
2075
2138
  function gitDecisionIds(treeish, cwd = process.cwd()) {
2076
2139
  const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
2077
2140
  if (!out) return new Set();
@@ -2091,7 +2154,7 @@ function gitDecisionEntries(treeish, cwd = process.cwd()) {
2091
2154
  const name = path.basename(file);
2092
2155
  const match = name.match(/^(\d{4,})-.*\.md$/);
2093
2156
  if (!match) continue;
2094
- const content = gitCapture(['show', `${treeish}:${file}`], cwd);
2157
+ const content = gitReadFile(treeish, file, cwd);
2095
2158
  if (content === null) continue;
2096
2159
  entries.push({
2097
2160
  id: normalizeDecisionId(match[1]),
@@ -2103,6 +2166,11 @@ function gitDecisionEntries(treeish, cwd = process.cwd()) {
2103
2166
  return entries;
2104
2167
  }
2105
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
+
2106
2174
  function canonicalEvent(event) {
2107
2175
  return Object.fromEntries(
2108
2176
  Object.entries(event)
@@ -2210,15 +2278,39 @@ function splitDuplicateDecisions(entries) {
2210
2278
  return { ours, theirs };
2211
2279
  }
2212
2280
 
2213
- function planDecisionAbsorb({ oursEntries, theirsEntries, baseIds }) {
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() }) {
2214
2301
  const mappings = [];
2215
2302
  const copies = [];
2216
2303
  const decisionMap = new Map();
2304
+ const hashMap = new Map();
2217
2305
  const usedIds = new Set(oursEntries.map((entry) => entry.id));
2218
2306
  const oursById = new Map();
2307
+ const baseById = new Map();
2219
2308
  for (const entry of oursEntries) {
2220
2309
  if (!oursById.has(entry.id)) oursById.set(entry.id, entry);
2221
2310
  }
2311
+ for (const entry of baseEntries) {
2312
+ if (!baseById.has(entry.id)) baseById.set(entry.id, entry);
2313
+ }
2222
2314
 
2223
2315
  for (const entry of theirsEntries) {
2224
2316
  const ours = oursById.get(entry.id);
@@ -2234,27 +2326,73 @@ function planDecisionAbsorb({ oursEntries, theirsEntries, baseIds }) {
2234
2326
  continue;
2235
2327
  }
2236
2328
  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)) {
2329
+ const oursHash = contentHash(oursContent);
2330
+ const theirsHash = contentHash(theirsContent);
2331
+ if (oursHash === theirsHash) {
2332
+ if (ours.file !== entry.file) {
2333
+ copies.push({
2334
+ fromFile: entry.file,
2335
+ toFile: ours.file,
2336
+ content: oursContent,
2337
+ removeFile: entry.file,
2338
+ });
2339
+ }
2340
+ continue;
2341
+ }
2342
+
2343
+ const base = baseById.get(entry.id);
2344
+ if (base) {
2345
+ const baseContent = base.content !== undefined ? base.content : fs.readFileSync(base.path, 'utf8');
2346
+ const baseHash = contentHash(baseContent);
2347
+ if (oursHash === baseHash) {
2348
+ copies.push({
2349
+ fromFile: entry.file,
2350
+ toFile: ours.file,
2351
+ content: theirsContent,
2352
+ removeFile: entry.file !== ours.file ? entry.file : null,
2353
+ });
2354
+ continue;
2355
+ }
2356
+ if (theirsHash === baseHash) {
2357
+ if (entry.file !== ours.file) {
2358
+ copies.push({
2359
+ fromFile: entry.file,
2360
+ toFile: ours.file,
2361
+ content: oursContent,
2362
+ removeFile: entry.file,
2363
+ });
2364
+ }
2365
+ continue;
2366
+ }
2239
2367
  fail(
2240
2368
  `decision ${entry.id} was edited on both sides; resolve ${ours.file} manually before absorbing`
2241
2369
  );
2242
2370
  }
2371
+ if (baseIds.has(entry.id)) {
2372
+ fail(
2373
+ `decision ${entry.id} was edited on both sides or its base content is unavailable; ` +
2374
+ `resolve ${ours.file} manually before absorbing`
2375
+ );
2376
+ }
2243
2377
  const newId = nextDecisionIdFromIds(usedIds);
2244
2378
  usedIds.add(newId);
2245
2379
  decisionMap.set(entry.id, newId);
2246
2380
  mappings.push({ kind: 'decision', from: entry.id, to: newId });
2381
+ const rewritten = rewriteDecisionId(theirsContent, newId);
2382
+ const originalHash = contentHash(theirsContent);
2383
+ const rewrittenHash = contentHash(rewritten);
2384
+ if (originalHash !== rewrittenHash) hashMap.set(originalHash, rewrittenHash);
2247
2385
  copies.push({
2248
2386
  fromFile: entry.file,
2249
2387
  toFile: `${newId}-${decisionSlugFromFile(entry.file)}.md`,
2250
- content: rewriteDecisionId(theirsContent, newId),
2388
+ content: rewritten,
2251
2389
  removeFile: entry.file !== ours.file ? entry.file : null,
2252
2390
  });
2253
2391
  }
2254
- return { decisionMap, mappings, copies };
2392
+ return { decisionMap, hashMap, mappings, copies };
2255
2393
  }
2256
2394
 
2257
- function remapEvent(event, intentMap, decisionMap) {
2395
+ function remapEvent(event, intentMap, decisionMap, hashMap = new Map()) {
2258
2396
  const next = { ...event };
2259
2397
  if (intentMap.has(event.id)) next.id = intentMap.get(event.id);
2260
2398
  if (Array.isArray(next.decisions) && next.decisions.length > 0) {
@@ -2267,10 +2405,15 @@ function remapEvent(event, intentMap, decisionMap) {
2267
2405
  const normalized = normalizeDecisionId(next.decisionId);
2268
2406
  if (decisionMap.has(normalized)) next.decisionId = decisionMap.get(normalized);
2269
2407
  }
2408
+ for (const field of ['oldHash', 'newHash', 'fileHash']) {
2409
+ if (typeof next[field] === 'string' && hashMap.has(next[field])) {
2410
+ next[field] = hashMap.get(next[field]);
2411
+ }
2412
+ }
2270
2413
  return next;
2271
2414
  }
2272
2415
 
2273
- function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
2416
+ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
2274
2417
  const intentMap = new Map();
2275
2418
  const mappings = [];
2276
2419
  const used = [...oursUsedEvents];
@@ -2282,22 +2425,24 @@ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
2282
2425
  intentMap.set(event.id, newId);
2283
2426
  mappings.push({ kind: 'intent', from: event.id, to: newId });
2284
2427
  }
2285
- event = remapEvent(event, intentMap, decisionMap);
2428
+ event = remapEvent(event, intentMap, decisionMap, hashMap);
2286
2429
  used.push(event);
2287
2430
  return { event };
2288
2431
  });
2289
2432
  return { records, mappings };
2290
2433
  }
2291
2434
 
2292
- function repairDuplicateIntentRecords(records, decisionMap) {
2435
+ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map()) {
2293
2436
  const seenBegins = new Set();
2294
2437
  const intentMap = new Map();
2295
2438
  const used = [];
2296
2439
  const mappings = [];
2297
2440
  const result = [];
2441
+ let incomingSide = false;
2298
2442
  for (const record of records) {
2299
2443
  let event = record.event;
2300
2444
  if (event.type === 'begin' && seenBegins.has(event.id)) {
2445
+ incomingSide = true;
2301
2446
  const { date } = parseIntentId(event.id);
2302
2447
  const newId = nextIdForDate(date, used);
2303
2448
  intentMap.set(event.id, newId);
@@ -2305,12 +2450,17 @@ function repairDuplicateIntentRecords(records, decisionMap) {
2305
2450
  } else if (event.type === 'begin') {
2306
2451
  seenBegins.add(event.id);
2307
2452
  }
2308
- const remapped = remapEvent(event, intentMap, decisionMap);
2453
+ const remapped = remapEvent(
2454
+ event,
2455
+ intentMap,
2456
+ incomingSide ? decisionMap : new Map(),
2457
+ incomingSide ? hashMap : new Map()
2458
+ );
2309
2459
  const changed = remapped !== event && JSON.stringify(remapped) !== JSON.stringify(event);
2310
2460
  result.push(changed ? { event: remapped } : record);
2311
2461
  used.push(result.at(-1).event);
2312
2462
  }
2313
- return { records: result, mappings };
2463
+ return { records: result, mappings, incomingSide };
2314
2464
  }
2315
2465
 
2316
2466
  function serializeRecords(records) {
@@ -2479,6 +2629,7 @@ function finishAbsorb({
2479
2629
  outputFile,
2480
2630
  intentCount,
2481
2631
  allowConflict = false,
2632
+ followupMessage = null,
2482
2633
  }) {
2483
2634
  const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
2484
2635
  allowConflict,
@@ -2501,12 +2652,13 @@ function finishAbsorb({
2501
2652
  if (conflict) {
2502
2653
  printLine('multiple intents remain in progress; re-run with --abandon-theirs or --abandon-ours');
2503
2654
  }
2655
+ if (followupMessage) printLine(followupMessage);
2504
2656
  return {
2505
2657
  mappings,
2506
2658
  abandoned,
2507
2659
  copies: copies.map((item) => item.toFile),
2508
2660
  outputFile,
2509
- exitCode: conflict ? 1 : 0,
2661
+ exitCode: conflict || followupMessage ? 1 : 0,
2510
2662
  };
2511
2663
  }
2512
2664
 
@@ -2515,12 +2667,14 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
2515
2667
  const decisionPlan = planDecisionAbsorb({
2516
2668
  oursEntries: options.oursDecisionEntries || [],
2517
2669
  theirsEntries: options.theirsDecisionEntries || [],
2670
+ baseEntries: options.baseDecisionEntries || [],
2518
2671
  baseIds: options.baseDecisionIds || new Set(),
2519
2672
  });
2520
2673
  const remapped = remapTheirsRecords(
2521
2674
  streams.theirsNew,
2522
2675
  [...streams.base, ...streams.oursNew].map((record) => record.event),
2523
- decisionPlan.decisionMap
2676
+ decisionPlan.decisionMap,
2677
+ decisionPlan.hashMap
2524
2678
  );
2525
2679
  const result = [...streams.base, ...streams.oursNew, ...remapped.records];
2526
2680
  return finishAbsorb({
@@ -2533,13 +2687,61 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
2533
2687
  dryRun: options.dryRun,
2534
2688
  outputFile: options.outputFile,
2535
2689
  allowConflict: options.allowConflict,
2690
+ followupMessage: options.followupMessage,
2536
2691
  intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
2537
2692
  });
2538
2693
  }
2539
2694
 
2695
+ function gitAbsorbRepairContext(records, decisionEntries) {
2696
+ const pending = gitOtherHead();
2697
+ if (pending) {
2698
+ return {
2699
+ ours: 'HEAD',
2700
+ theirs: pending,
2701
+ base: gitMergeBaseFor('HEAD', pending),
2702
+ };
2703
+ }
2704
+ if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateIntentBegins(records)) return null;
2705
+ const parents = gitMergeParents();
2706
+ if (!parents) return null;
2707
+ return {
2708
+ ...parents,
2709
+ base: gitMergeBaseFor(parents.ours, parents.theirs),
2710
+ };
2711
+ }
2712
+
2713
+ function absorbFromGitContext(context, { abandon, dryRun, outputFile }) {
2714
+ const baseRecords = context.base ? gitIntentRecords(context.base) : [];
2715
+ return absorbFromStreams(
2716
+ gitIntentRecords(context.ours),
2717
+ gitIntentRecords(context.theirs),
2718
+ baseRecords,
2719
+ {
2720
+ abandon,
2721
+ dryRun,
2722
+ outputFile,
2723
+ oursDecisionEntries: gitDecisionEntries(context.ours),
2724
+ theirsDecisionEntries: gitDecisionEntries(context.theirs),
2725
+ baseDecisionEntries: context.base ? gitDecisionEntries(context.base) : [],
2726
+ baseDecisionIds: context.base
2727
+ ? gitDecisionIds(context.base)
2728
+ : collectDecisionIdsFromEvents(baseRecords.map((record) => record.event)),
2729
+ }
2730
+ );
2731
+ }
2732
+
2540
2733
  function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
2541
2734
  const oursFile = logFile();
2542
2735
  const loaded = loadAbsorbSide(oursFile, oursFile, { repairTail: true, allowMissing: !otherFile });
2736
+ const currentDecisionEntries = !otherFile
2737
+ ? listDecisionEntries(decisionDir(), { allowDuplicates: true })
2738
+ : [];
2739
+ const gitContext = !otherFile
2740
+ ? gitAbsorbRepairContext(loaded.records || [], currentDecisionEntries)
2741
+ : null;
2742
+ if (gitContext) {
2743
+ return absorbFromGitContext(gitContext, { abandon, dryRun, outputFile: oursFile });
2744
+ }
2543
2745
  if (loaded.conflict) {
2544
2746
  const baseIds = collectDecisionIdsFromEvents(
2545
2747
  loaded.ours.slice(0, commonPrefixLength(loaded.ours, loaded.theirs)).map((record) => record.event)
@@ -2555,22 +2757,33 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
2555
2757
  ? listDecisionEntries(otherDecisions)
2556
2758
  : splitDuplicateDecisions(listDecisionEntries(decisionDir(), { allowDuplicates: true })).theirs,
2557
2759
  baseDecisionIds,
2760
+ baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
2558
2761
  });
2559
2762
  }
2560
2763
 
2561
2764
  if (!otherFile) {
2562
- const oursEntries = listDecisionEntries(decisionDir(), { allowDuplicates: true });
2765
+ const oursEntries = currentDecisionEntries;
2563
2766
  const split = splitDuplicateDecisions(oursEntries);
2564
- const repaired = repairDuplicateIntentRecords(loaded.records, new Map());
2565
2767
  const gitBase = gitMergeBase();
2566
2768
  const decisionPlan = planDecisionAbsorb({
2567
2769
  oursEntries: split.ours,
2568
2770
  theirsEntries: split.theirs,
2771
+ baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
2569
2772
  baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
2570
2773
  });
2571
- const withDecisions = repairDuplicateIntentRecords(repaired.records, decisionPlan.decisionMap);
2572
- const result = withDecisions.records;
2573
- const mappings = [...repaired.mappings, ...withDecisions.mappings, ...decisionPlan.mappings];
2774
+ const repaired = repairDuplicateIntentRecords(
2775
+ loaded.records,
2776
+ decisionPlan.decisionMap,
2777
+ decisionPlan.hashMap
2778
+ );
2779
+ if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
2780
+ fail(
2781
+ 'cannot determine which intent records own the duplicate decision; ' +
2782
+ 'run absorb during the merge or provide the incoming log and decision directory'
2783
+ );
2784
+ }
2785
+ const result = repaired.records;
2786
+ const mappings = [...repaired.mappings, ...decisionPlan.mappings];
2574
2787
  const remappedIds = new Set(
2575
2788
  mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
2576
2789
  );
@@ -2606,6 +2819,7 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
2606
2819
  outputFile: oursFile,
2607
2820
  oursDecisionEntries: listDecisionEntries(decisionDir()),
2608
2821
  theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
2822
+ baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
2609
2823
  baseDecisionIds,
2610
2824
  });
2611
2825
  }
@@ -2617,18 +2831,36 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
2617
2831
  if (base.conflict || ours.conflict || theirs.conflict) {
2618
2832
  fail('git merge driver received a log that still contains conflict markers');
2619
2833
  }
2620
- const otherHead = gitOtherHead();
2621
- const mergeBase = gitMergeBase();
2834
+ const otherHead =
2835
+ gitOtherHead() || gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
2836
+ const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
2837
+ let followupMessage = null;
2838
+ if (!otherHead) {
2839
+ followupMessage =
2840
+ 'incoming Git tree could not be identified safely; re-run driftseal absorb after Git stops the merge';
2841
+ } else {
2842
+ const decisionPlan = planDecisionAbsorb({
2843
+ oursEntries: gitDecisionEntries('HEAD'),
2844
+ theirsEntries: gitDecisionEntries(otherHead),
2845
+ baseEntries: mergeBase ? gitDecisionEntries(mergeBase) : [],
2846
+ baseIds: mergeBase
2847
+ ? gitDecisionIds(mergeBase)
2848
+ : collectDecisionIdsFromEvents(base.records.map((record) => record.event)),
2849
+ });
2850
+ const requiresDecisionRepair =
2851
+ decisionPlan.mappings.length > 0 ||
2852
+ decisionPlan.copies.some((copy) => copy.removeFile && copy.removeFile !== copy.toFile);
2853
+ if (requiresDecisionRepair) {
2854
+ followupMessage =
2855
+ 'decision ids require worktree repair; run driftseal absorb, then stage the repaired logs';
2856
+ }
2857
+ }
2622
2858
  return absorbFromStreams(ours.records, theirs.records, base.records, {
2623
2859
  abandon,
2624
2860
  dryRun,
2625
2861
  outputFile: oursFile,
2626
2862
  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)),
2863
+ followupMessage,
2632
2864
  });
2633
2865
  }
2634
2866
 
@@ -3099,6 +3331,7 @@ const commands = {
3099
3331
  protocolEol(previousIntentProtocolBlock(6), eol),
3100
3332
  protocolEol(previousIntentProtocolBlock(7), eol),
3101
3333
  protocolEol(previousIntentProtocolBlock(8), eol),
3334
+ protocolEol(previousIntentProtocolBlock(9), eol),
3102
3335
  ],
3103
3336
  knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
3104
3337
  });
@@ -3117,6 +3350,7 @@ const commands = {
3117
3350
  protocolEol(previousDecisionProtocolBlock(6), eol),
3118
3351
  protocolEol(previousDecisionProtocolBlock(7), eol),
3119
3352
  protocolEol(previousDecisionProtocolBlock(8), eol),
3353
+ protocolEol(previousDecisionProtocolBlock(9), eol),
3120
3354
  ],
3121
3355
  knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
3122
3356
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",