driftseal 1.1.5 → 1.1.7

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
@@ -73,8 +73,9 @@ driftseal skill install --target kimi-code --scope global
73
73
  | `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
74
74
 
75
75
  Use `--root <repository>` to select a project when running the installer
76
- elsewhere. Repeated installs of identical content are no-ops; a different
77
- existing skill requires `--force`. MCP and lifecycle hooks are optional
76
+ elsewhere. Repeated installs of identical content are no-ops, and a skill left
77
+ by an earlier DriftSeal release is upgraded in place; only a skill this
78
+ installer never wrote requires `--force`. MCP and lifecycle hooks are optional
78
79
  adapters; enable them only for a concrete host constraint or reminder need, not
79
80
  as additional policy layers.
80
81
 
@@ -317,6 +318,20 @@ ambiguous decision catalog. Run `driftseal absorb`, stage the repaired intent
317
318
  and decision logs, then continue the merge. Clones need `init` again because
318
319
  the driver lives in local git config.
319
320
 
321
+ In a Git worktree, `begin` parks the open intent in Git metadata instead of
322
+ appending to the tracked `events.jsonl`. Git can merge while that intent is
323
+ still in progress, so you do not need a log-only commit just to get a clean
324
+ tree. `end` moves the parked records into the tracked log and writes the closing
325
+ record there — never into Git metadata — so an interrupted `end` leaves the
326
+ intent open in the log and can simply be run again. If the parked intent's id
327
+ collides with incoming merged events, DriftSeal remaps it the same way `absorb`
328
+ remaps colliding worktree ids.
329
+
330
+ When a merge brings in a second open intent, `absorb --abandon-ours` closes the
331
+ parked one into the tracked log and `absorb --abandon-theirs` closes the
332
+ incoming one and leaves yours parked. `end <id>` also works on the incoming
333
+ intent directly, and `begin --force` abandons every open intent at once.
334
+
320
335
  ## Storage
321
336
 
322
337
  - `.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.
package/README.zh-CN.md CHANGED
@@ -71,7 +71,8 @@ driftseal skill install --target kimi-code --scope global
71
71
  | `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
72
72
 
73
73
  如果不在目标 repository 中执行,用 `--root <repository>` 明确指定项目。
74
- 重复安装相同内容不会产生改动;目标位置已有不同版本时必须显式传入 `--force`。
74
+ 重复安装相同内容不会产生改动;旧版本 DriftSeal 装下的 skill 会被直接原地升级,
75
+ 只有安装器从未写过的 skill 才需要显式传入 `--force`。
75
76
  MCP 与 lifecycle hook 都是可选适配层;只有确实存在 host 限制或提醒需求时
76
77
  才启用,不要把它们叠成额外的 policy 层。
77
78
 
@@ -288,6 +289,18 @@ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
288
289
  `driftseal absorb`、stage 修复后的 intent / decision logs,再继续 merge。clone
289
290
  之后需要再跑一次 `init`,因为 driver 只存在于 local git config。
290
291
 
292
+ 在 Git worktree 里,`begin` 会把未关闭的 intent 停在 Git 元数据中,而不是追加到
293
+ 已跟踪的 `events.jsonl`。因此工作树保持干净,`git merge` 可以在 intent 仍进行中
294
+ 时执行,不必为了清工作树而多做一个只含日志的提交。`end` 会先把停放的记录移入跟踪
295
+ 日志,再把关闭记录写在那里,而不会写进 Git 元数据;因此 `end` 中途失败时,intent
296
+ 只是以未关闭状态留在日志里,重跑一次即可。如果停放中的 intent id 与合并进来的事件
297
+ 撞号,DriftSeal 会按 `absorb` 同样的规则重编号。
298
+
299
+ 合并带进来第二个未关闭的 intent 时,`absorb --abandon-ours` 会关闭停放的那一个并
300
+ 写入跟踪日志,`absorb --abandon-theirs` 则关闭合并进来的那一个、保留自己的停放
301
+ 状态。也可以用 `end <id>` 直接关闭合并进来的 intent,或用 `begin --force` 一次性
302
+ 放弃所有未关闭的 intent。
303
+
291
304
  ## 数据保存在哪里
292
305
 
293
306
  - `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim`,而不是删除日志行。合并撞号时用 `driftseal absorb`,不要手改这个文件。
package/bin/driftseal.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * { "type": "end", "id", "ts", "status", "note", "verifyResult" }
17
17
  *
18
18
  * Intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl in cwd.
19
+ * In a Git worktree, an open intent is parked in Git metadata until end.
19
20
  * Decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in cwd.
20
21
  */
21
22
 
@@ -23,6 +24,7 @@ const fs = require('fs');
23
24
  const path = require('path');
24
25
  const crypto = require('crypto');
25
26
  const os = require('os');
27
+ const { isDeepStrictEqual } = require('util');
26
28
  const { execFileSync } = require('child_process');
27
29
  const { version: PACKAGE_VERSION } = require('../package.json');
28
30
 
@@ -38,6 +40,7 @@ const DECISION_STATUSES = [
38
40
  const EVENT_SCHEMA_VERSION = 3;
39
41
  const PROTOCOL_VERSION = 11;
40
42
  const DEFAULT_LOG_LANGUAGE = 'en';
43
+ const IN_PROGRESS_GIT_PATH = 'driftseal-in-progress.jsonl';
41
44
  const LOCK_STALE_MS = 30 * 60 * 1000;
42
45
  const LOCK_INIT_STALE_MS = 5 * 1000;
43
46
  const MAX_DECISION_SLUG_LENGTH = 180;
@@ -193,7 +196,47 @@ function normalizeEvent(event, line) {
193
196
  fail(`unknown event type "${event.type}" on log line ${line}`);
194
197
  }
195
198
 
196
- function readEvents({ repairTail = false, file = logFile() } = {}) {
199
+ function gitWorktreeRoot(cwd = process.cwd()) {
200
+ if (!isGitWorkTree(cwd)) return null;
201
+ return gitCapture(['rev-parse', '--show-toplevel'], cwd);
202
+ }
203
+
204
+ function worktreeInProgressFile(cwd = process.cwd()) {
205
+ if (!isGitWorkTree(cwd)) return null;
206
+ const gitPath = gitCapture(['rev-parse', '--git-path', IN_PROGRESS_GIT_PATH], cwd);
207
+ if (!gitPath) return null;
208
+ return path.resolve(cwd, gitPath);
209
+ }
210
+
211
+ function isParkableIntentLog() {
212
+ if (process.env.DRIFTSEAL_HOME) return false;
213
+ const root = gitWorktreeRoot();
214
+ if (!root) return false;
215
+ return path.resolve(logFile()) === path.resolve(root, '.intent-log', 'events.jsonl');
216
+ }
217
+
218
+ function inProgressFile() {
219
+ if (!isParkableIntentLog()) return null;
220
+ return worktreeInProgressFile();
221
+ }
222
+
223
+ function liveWorktreeIntentLog() {
224
+ const root = gitWorktreeRoot();
225
+ if (!root) return null;
226
+ return path.resolve(root, '.intent-log', 'events.jsonl');
227
+ }
228
+
229
+ function sameResolvedPath(left, right) {
230
+ return path.resolve(left) === path.resolve(right);
231
+ }
232
+
233
+ function shouldAttachInProgress(file) {
234
+ if (process.env.DRIFTSEAL_HOME) return false;
235
+ const live = liveWorktreeIntentLog();
236
+ return live !== null && sameResolvedPath(file, live);
237
+ }
238
+
239
+ function readJsonlRecordsFromFile(file, { repairTail = false } = {}) {
197
240
  if (!fs.existsSync(file)) return [];
198
241
  let content = fs.readFileSync(file, 'utf8');
199
242
  const rawLines = content.split('\n');
@@ -214,7 +257,65 @@ function readEvents({ repairTail = false, file = logFile() } = {}) {
214
257
  content = content.slice(0, validLength);
215
258
  }
216
259
  }
217
- return parseJsonlRecords(content, file).map((record) => record.event);
260
+ return parseJsonlRecords(content, file);
261
+ }
262
+
263
+ /**
264
+ * True when the parked records already sit in the committed log. A flush appends them, so
265
+ * they land as a suffix; a merge that appends afterwards leaves them as an interior run.
266
+ */
267
+ function overlayIsCommitted(committedEvents, overlayEvents) {
268
+ if (overlayEvents.length === 0 || committedEvents.length < overlayEvents.length) return false;
269
+ const head = overlayEvents[0];
270
+ for (let start = committedEvents.length - overlayEvents.length; start >= 0; start--) {
271
+ const candidate = committedEvents[start];
272
+ if (candidate.type !== head.type || candidate.id !== head.id || candidate.ts !== head.ts) continue;
273
+ const matches = overlayEvents.every((event, index) =>
274
+ isDeepStrictEqual(committedEvents[start + index], event)
275
+ );
276
+ if (matches) return true;
277
+ }
278
+ return false;
279
+ }
280
+
281
+ /** How a parked overlay lines up with the committed log; touches neither file. */
282
+ function planInProgressOverlay(committedEvents, park, { repairTail = false } = {}) {
283
+ if (!park || !fs.existsSync(park)) return null;
284
+ const overlayRecords = readJsonlRecordsFromFile(park, { repairTail });
285
+ const overlayEvents = overlayRecords.map((record) => record.event);
286
+ if (overlayEvents.length === 0 || overlayIsCommitted(committedEvents, overlayEvents)) {
287
+ return { park, records: [], mappings: [], alreadyCommitted: true };
288
+ }
289
+ const remapped = remapTheirsRecords(overlayRecords, committedEvents, new Map(), new Map());
290
+ return { park, records: remapped.records, mappings: remapped.mappings, alreadyCommitted: false };
291
+ }
292
+
293
+ function discardInProgressLog(park) {
294
+ fs.unlinkSync(park);
295
+ fsyncDirectory(path.dirname(park));
296
+ }
297
+
298
+ function reconcileInProgressRecords(committedEvents, { repairTail = false, park = inProgressFile() } = {}) {
299
+ const plan = planInProgressOverlay(committedEvents, park, { repairTail });
300
+ if (!plan) return [];
301
+ if (plan.alreadyCommitted) {
302
+ discardInProgressLog(park);
303
+ return [];
304
+ }
305
+ if (plan.mappings.length > 0) writeJsonl(park, plan.records);
306
+ return plan.records;
307
+ }
308
+
309
+ function readEvents({ repairTail = false, file = logFile() } = {}) {
310
+ const records = readJsonlRecordsFromFile(file, { repairTail });
311
+ const events = records.map((record) => record.event);
312
+ if (!shouldAttachInProgress(file)) return events;
313
+ return events.concat(
314
+ reconcileInProgressRecords(events, {
315
+ repairTail,
316
+ park: worktreeInProgressFile(),
317
+ }).map((record) => record.event)
318
+ );
218
319
  }
219
320
 
220
321
  function parseJsonlRecords(content, source = 'log') {
@@ -257,15 +358,11 @@ function ensureDirectoryDurable(directory) {
257
358
  for (const created of missing.reverse()) fsyncDirectory(path.dirname(created));
258
359
  }
259
360
 
260
- function appendEvent(event) {
261
- ensureDirectoryDurable(logDir());
262
- const file = logFile();
361
+ function appendEventTo(file, event) {
362
+ ensureDirectoryDurable(path.dirname(file));
263
363
  const existed = fs.existsSync(file);
264
364
  const storedEvent = { schemaVersion: EVENT_SCHEMA_VERSION, ...event };
265
- const line = Buffer.from(
266
- JSON.stringify(storedEvent) + '\n',
267
- 'utf8'
268
- );
365
+ const line = Buffer.from(`${JSON.stringify(storedEvent)}\n`, 'utf8');
269
366
  const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
270
367
  try {
271
368
  const stat = fs.fstatSync(fd);
@@ -285,10 +382,66 @@ function appendEvent(event) {
285
382
  } finally {
286
383
  fs.closeSync(fd);
287
384
  }
288
- if (!existed) fsyncDirectory(logDir());
385
+ if (!existed) fsyncDirectory(path.dirname(file));
289
386
  return storedEvent;
290
387
  }
291
388
 
389
+ /**
390
+ * Move the parked records into the tracked log. Safe to retry: a remap is persisted to the
391
+ * park first, the log is written before the park file is dropped, so an interruption either
392
+ * leaves nothing committed or leaves the overlay recognizable as already committed.
393
+ * Returns the intent ids it had to remap.
394
+ */
395
+ function flushInProgressLog() {
396
+ const park = inProgressFile();
397
+ if (!park || !fs.existsSync(park)) return new Map();
398
+ const committedRecords = readJsonlRecordsFromFile(logFile());
399
+ const plan = planInProgressOverlay(
400
+ committedRecords.map((record) => record.event),
401
+ park,
402
+ { repairTail: true }
403
+ );
404
+ if (!plan) return new Map();
405
+ if (plan.alreadyCommitted) {
406
+ discardInProgressLog(park);
407
+ return new Map();
408
+ }
409
+ // Persist a remap before touching the log: after any crash the park then matches what the
410
+ // log received (or will receive), so recovery never re-remaps it into a duplicate.
411
+ if (plan.mappings.length > 0) writeJsonl(park, plan.records);
412
+ writeJsonl(logFile(), [...committedRecords, ...plan.records]);
413
+ discardInProgressLog(park);
414
+ return new Map(
415
+ plan.mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => [mapping.from, mapping.to])
416
+ );
417
+ }
418
+
419
+ function parkedOpenIntent(park) {
420
+ if (!fs.existsSync(park)) return null;
421
+ const records = readJsonlRecordsFromFile(park, { repairTail: true });
422
+ return openIntent(fold(records.map((record) => record.event)));
423
+ }
424
+
425
+ function appendEvent(event) {
426
+ const park = inProgressFile();
427
+ if (!park) return appendEventTo(logFile(), event);
428
+
429
+ const open = parkedOpenIntent(park);
430
+ // A park with nothing open left in it belongs in the log; an interrupted end retries here.
431
+ if (!open) flushInProgressLog();
432
+
433
+ if (event.type === 'begin') return appendEventTo(park, event);
434
+ if (!open || open.id !== event.id) return appendEventTo(logFile(), event);
435
+ if (event.type !== 'end') return appendEventTo(park, event);
436
+ // Close in the tracked log, never in Git metadata: the parked records move first, so the
437
+ // closing record cannot end up somewhere a clone or a removed worktree would drop it.
438
+ const remapped = flushInProgressLog();
439
+ if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_IN_PROGRESS_FLUSH === '1') {
440
+ fail('simulated interruption after the in-progress flush');
441
+ }
442
+ return appendEventTo(logFile(), remapEvent(event, remapped, new Map()));
443
+ }
444
+
292
445
  function contentHash(content) {
293
446
  return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
294
447
  }
@@ -1831,6 +1984,27 @@ function installMcp(request) {
1831
1984
 
1832
1985
  const SKILL_NAME = 'use-driftseal';
1833
1986
 
1987
+ /*
1988
+ * Every skill tree DriftSeal has ever bundled, oldest first, as skillTreeDigest
1989
+ * hashes. `skill install` treats a directory matching one of these as its own
1990
+ * earlier output and upgrades it in place; anything else is someone's local
1991
+ * skill that only --force may replace. Append the new digest whenever the
1992
+ * bundled skill changes, otherwise the next release cannot upgrade this one;
1993
+ * the "bundled use-driftseal skill is a known release" test fails until you do.
1994
+ */
1995
+ const SKILL_RELEASE_DIGESTS = new Set([
1996
+ 'e996627c96edc7c09599bc454c4225a416aa89e50aef14d4dd569dd21454e882', // 1b76215 initial commit
1997
+ '5f702545b18e117bafcea669b48cdb3b31c98079f97eaa084fd3b4ceff9500e8', // 5c2ec74 intents only for rollback-worthy changes
1998
+ '77f6365590ff181ee6be003930dd1696c363e213368de0f4e89962555af3fabe', // 56380a8 local MCP server
1999
+ 'b7e2310eaf20b50b5ec28531471965607e7e070a4f58263e29408b2cd5cdfb11', // 1cc77f6 reclaim markers
2000
+ '08bc63b8dcf0f4f078252179a3fc2ca4ef2632ecb5b9dfe3782a452c3202c2c4', // de4a8a1 skill slimmed into a usage guide
2001
+ '8523459ff81cf0b97a36b30d216956a58d8a3b3b9760f455d7ea334604da335a', // 3a1d6e0 protocol v7 resume semantics
2002
+ 'cc98b9348ec222320bfcd285ba3f1f499a42d15b31e9b1d83c35f0206b2d5ba9', // ca16785 CLI-first skill integration
2003
+ '0fd870f8c1b81f8386d986d64742679d56cd1d317c02890830c81876eb9227d6', // da8afd2 1.1.0 absorb
2004
+ '72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
2005
+ 'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb (current)
2006
+ ]);
2007
+
1834
2008
  function skillInstallUsage() {
1835
2009
  return 'usage: driftseal skill install --target <codex|kimi-code|opencode|claude-code|cursor> [--scope project|global] [--root <repository>] [--force]';
1836
2010
  }
@@ -1893,7 +2067,13 @@ function parseSkillInstallRequest(argv) {
1893
2067
  };
1894
2068
  }
1895
2069
 
1896
- function directoryDigest(directory) {
2070
+ /*
2071
+ * Identifies a skill tree by its contents alone: relative paths joined with "/"
2072
+ * and file bytes, never file modes or timestamps. The digest therefore stays
2073
+ * stable across platforms, checkouts, and npm tarballs, which is what lets
2074
+ * SKILL_RELEASE_DIGESTS recognize a skill DriftSeal installed earlier.
2075
+ */
2076
+ function skillTreeDigest(directory) {
1897
2077
  if (!fs.existsSync(directory)) return null;
1898
2078
  const digest = crypto.createHash('sha256');
1899
2079
 
@@ -1902,12 +2082,12 @@ function directoryDigest(directory) {
1902
2082
  if (stat.isDirectory()) {
1903
2083
  digest.update(`directory\0${relative}\0`);
1904
2084
  for (const name of fs.readdirSync(current).sort()) {
1905
- visit(path.join(current, name), relative ? path.join(relative, name) : name);
2085
+ visit(path.join(current, name), relative ? `${relative}/${name}` : name);
1906
2086
  }
1907
2087
  return;
1908
2088
  }
1909
2089
  if (stat.isFile()) {
1910
- digest.update(`file\0${relative}\0${stat.mode & 0o777}\0`);
2090
+ digest.update(`file\0${relative}\0`);
1911
2091
  digest.update(fs.readFileSync(current));
1912
2092
  digest.update('\0');
1913
2093
  return;
@@ -1916,7 +2096,7 @@ function directoryDigest(directory) {
1916
2096
  digest.update(`symlink\0${relative}\0${fs.readlinkSync(current)}\0`);
1917
2097
  return;
1918
2098
  }
1919
- digest.update(`other\0${relative}\0${stat.mode}\0`);
2099
+ digest.update(`other\0${relative}\0`);
1920
2100
  }
1921
2101
 
1922
2102
  visit(directory, '');
@@ -1943,15 +2123,18 @@ function installSkill(request) {
1943
2123
  fail(`bundled ${SKILL_NAME} skill is missing from this DriftSeal installation: ${sourceDir}`);
1944
2124
  }
1945
2125
 
1946
- const sourceDigest = directoryDigest(sourceDir);
1947
- const existingDigest = directoryDigest(skillDir);
2126
+ const sourceDigest = skillTreeDigest(sourceDir);
2127
+ const existingDigest = skillTreeDigest(skillDir);
1948
2128
  if (existingDigest === sourceDigest) {
1949
2129
  printLine(`${SKILL_NAME} skill is already installed for ${targetLabel} (${scope}): ${skillDir}`);
1950
2130
  return { changed: false, target, scope, root, skillDir };
1951
2131
  }
1952
- if (existingDigest !== null && !force) {
2132
+ // An untouched skill from an earlier DriftSeal upgrades on its own; only a
2133
+ // skill this installer never wrote needs the operator to confirm with --force.
2134
+ const upgraded = existingDigest !== null && SKILL_RELEASE_DIGESTS.has(existingDigest);
2135
+ if (existingDigest !== null && !upgraded && !force) {
1953
2136
  fail(
1954
- `${targetLabel} already has a different ${SKILL_NAME} skill at ${skillDir}; ` +
2137
+ `${targetLabel} already has a ${SKILL_NAME} skill DriftSeal did not install at ${skillDir}; ` +
1955
2138
  're-run with --force to replace it'
1956
2139
  );
1957
2140
  }
@@ -1981,9 +2164,12 @@ function installSkill(request) {
1981
2164
  throw err;
1982
2165
  }
1983
2166
 
1984
- printLine(`Installed ${SKILL_NAME} skill for ${targetLabel} (${scope}): ${skillDir}`);
2167
+ printLine(
2168
+ `${upgraded ? 'Upgraded' : 'Installed'} ${SKILL_NAME} skill for ` +
2169
+ `${targetLabel} (${scope}): ${skillDir}`
2170
+ );
1985
2171
  if (scope === 'project') printLine(`Repository root: ${root}`);
1986
- return { changed: true, target, scope, root, skillDir };
2172
+ return { changed: true, upgraded, target, scope, root, skillDir };
1987
2173
  }
1988
2174
 
1989
2175
  const HOOK_TARGETS = ['kimi-code', 'claude-code', 'codex'];
@@ -2234,9 +2420,14 @@ function hookLogFile() {
2234
2420
  return fs.existsSync(configured) ? configured : null;
2235
2421
  }
2236
2422
  let current = path.resolve(process.cwd());
2423
+ const root = gitWorktreeRoot(current);
2237
2424
  while (true) {
2238
2425
  const candidate = path.join(current, '.intent-log', 'events.jsonl');
2239
2426
  if (fs.existsSync(candidate)) return candidate;
2427
+ if (root && path.resolve(root) === current) {
2428
+ const park = worktreeInProgressFile(current);
2429
+ if (park && fs.existsSync(park)) return candidate;
2430
+ }
2240
2431
  const parent = path.dirname(current);
2241
2432
  if (parent === current) return null;
2242
2433
  current = parent;
@@ -2727,8 +2918,9 @@ function printAbsorbReport({ mappings, abandoned, intentCount }) {
2727
2918
  `absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
2728
2919
  );
2729
2920
  for (const mapping of mappings) {
2730
- if (mapping.kind === 'intent') printLine(`${mapping.from} (theirs) -> ${mapping.to}`);
2731
- else printLine(`decision ${mapping.from} (theirs) -> ${mapping.to}`);
2921
+ const side = mapping.side || 'theirs';
2922
+ if (mapping.kind === 'intent') printLine(`${mapping.from} (${side}) -> ${mapping.to}`);
2923
+ else printLine(`decision ${mapping.from} (${side}) -> ${mapping.to}`);
2732
2924
  }
2733
2925
  if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
2734
2926
  }
@@ -2748,23 +2940,45 @@ function abandonOpenIntent(records, targetId, side) {
2748
2940
  return targetId;
2749
2941
  }
2750
2942
 
2751
- function resolveOpenIntents(result, oursRecords, theirsRecords, abandon, { allowConflict = false } = {}) {
2943
+ function resolveOpenIntents(
2944
+ result,
2945
+ oursRecords,
2946
+ theirsRecords,
2947
+ abandon,
2948
+ { allowConflict = false, overlay = [], parkedOpen = null } = {}
2949
+ ) {
2752
2950
  const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
2753
2951
  const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
2754
2952
  try {
2755
- openIntent(fold(result.map((record) => record.event)));
2756
- return { abandoned: null, conflict: false };
2953
+ openIntent(fold([...result, ...overlay].map((record) => record.event)));
2954
+ return { abandoned: null, conflict: false, parkedClosed: false };
2757
2955
  } catch (err) {
2758
2956
  if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
2759
2957
  throw err;
2760
2958
  }
2761
2959
  if (abandon === 'theirs' && theirsOpen) {
2762
- return { abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'), conflict: false };
2960
+ return {
2961
+ abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'),
2962
+ conflict: false,
2963
+ parkedClosed: false,
2964
+ };
2965
+ }
2966
+ // A parked intent is local by construction, so --abandon-ours targets it before the log.
2967
+ if (abandon === 'ours' && parkedOpen) {
2968
+ return {
2969
+ abandoned: abandonOpenIntent(overlay, parkedOpen.id, 'ours'),
2970
+ conflict: false,
2971
+ parkedClosed: true,
2972
+ };
2763
2973
  }
2764
2974
  if (abandon === 'ours' && oursOpen) {
2765
- return { abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'), conflict: false };
2975
+ return {
2976
+ abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'),
2977
+ conflict: false,
2978
+ parkedClosed: false,
2979
+ };
2766
2980
  }
2767
- if (allowConflict) return { abandoned: null, conflict: true };
2981
+ if (allowConflict) return { abandoned: null, conflict: true, parkedClosed: false };
2768
2982
  fail(`${err.message}; re-run with --abandon-theirs or --abandon-ours`);
2769
2983
  }
2770
2984
  }
@@ -2859,20 +3073,51 @@ function finishAbsorb({
2859
3073
  allowConflict = false,
2860
3074
  followupMessage = null,
2861
3075
  }) {
2862
- const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
2863
- allowConflict,
3076
+ // An intent parked in Git metadata is part of our side even though the log never saw it.
3077
+ const park = shouldAttachInProgress(outputFile) ? inProgressFile() : null;
3078
+ const plan = planInProgressOverlay(result.map((record) => record.event), park, {
3079
+ repairTail: true,
2864
3080
  });
2865
- fold(result.map((record) => record.event));
2866
- if (!conflict) openIntent(fold(result.map((record) => record.event)));
3081
+ const overlay = plan && !plan.alreadyCommitted ? plan.records : [];
3082
+ const parkedOpen =
3083
+ overlay.length > 0 ? openIntent(fold(overlay.map((record) => record.event))) : null;
3084
+ const parkMappings = plan
3085
+ ? plan.mappings.map((mapping) => ({ ...mapping, side: 'parked' }))
3086
+ : [];
3087
+ const allMappings = [...mappings, ...parkMappings];
3088
+ const { abandoned, conflict, parkedClosed } = resolveOpenIntents(
3089
+ result,
3090
+ oursRecords,
3091
+ theirsRecords,
3092
+ abandon,
3093
+ { allowConflict, overlay, parkedOpen }
3094
+ );
3095
+ // A parked overlay with nothing left open in it belongs in the tracked log, whether the
3096
+ // abandon flag just closed it or an interrupted end left it closed.
3097
+ const flushOverlay = parkedClosed || (overlay.length > 0 && !parkedOpen);
3098
+ const merged = flushOverlay ? [...result, ...overlay] : result;
3099
+ const effective = [...result, ...overlay].map((record) => record.event);
3100
+ fold(effective);
3101
+ if (!conflict) openIntent(fold(effective));
2867
3102
  if (!dryRun) {
2868
- writeJsonl(outputFile, result);
3103
+ writeJsonl(outputFile, merged);
2869
3104
  applyDecisionCopies(copies, dryRun);
3105
+ if (plan) {
3106
+ if (plan.alreadyCommitted || flushOverlay) discardInProgressLog(park);
3107
+ else if (plan.mappings.length > 0) writeJsonl(park, overlay);
3108
+ }
2870
3109
  }
2871
- if (intentCount === 0 && mappings.length === 0 && copies.length === 0 && !abandoned) {
3110
+ if (
3111
+ intentCount === 0 &&
3112
+ allMappings.length === 0 &&
3113
+ copies.length === 0 &&
3114
+ !abandoned &&
3115
+ !flushOverlay
3116
+ ) {
2872
3117
  printLine('nothing to absorb');
2873
3118
  } else {
2874
3119
  printAbsorbReport({
2875
- mappings,
3120
+ mappings: allMappings,
2876
3121
  abandoned,
2877
3122
  intentCount,
2878
3123
  });
@@ -2882,7 +3127,7 @@ function finishAbsorb({
2882
3127
  }
2883
3128
  if (followupMessage) printLine(followupMessage);
2884
3129
  return {
2885
- mappings,
3130
+ mappings: allMappings,
2886
3131
  abandoned,
2887
3132
  copies: copies.map((item) => item.toFile),
2888
3133
  outputFile,
@@ -3111,22 +3356,30 @@ const commands = {
3111
3356
 
3112
3357
  const events = readEvents({ repairTail: true });
3113
3358
  const records = fold(events);
3114
- const open = openIntent(records);
3115
- if (open) {
3116
- if (!flags.force) {
3117
- fail(
3118
- `intent ${open.id} is still in_progress: "${open.intent}"\n` +
3119
- `end it first (driftseal end) or re-run with --force to abandon it`
3120
- );
3121
- }
3359
+ // A parked intent and a merged-in one can both be open; --force clears every one of them.
3360
+ const open = records.filter((record) => record.status === 'in_progress');
3361
+ if (open.length > 1 && !flags.force) {
3362
+ fail(
3363
+ `multiple intents in progress: ${open.map((record) => record.id).join(', ')}\n` +
3364
+ 'resolve them with driftseal absorb --abandon-ours or --abandon-theirs, ' +
3365
+ 'or re-run with --force to abandon all of them'
3366
+ );
3367
+ }
3368
+ if (open.length === 1 && !flags.force) {
3369
+ fail(
3370
+ `intent ${open[0].id} is still in_progress: "${open[0].intent}"\n` +
3371
+ `end it first (driftseal end) or re-run with --force to abandon it`
3372
+ );
3373
+ }
3374
+ for (const record of open) {
3122
3375
  const status = closeIntentAsEscape(
3123
3376
  events,
3124
- open,
3377
+ record,
3125
3378
  'abandoned',
3126
3379
  'superseded by --force',
3127
3380
  null
3128
3381
  );
3129
- printError(`driftseal: ${status} ${open.id}`);
3382
+ printError(`driftseal: ${status} ${record.id}`);
3130
3383
  }
3131
3384
 
3132
3385
  const id = nextId(events);
@@ -3674,7 +3927,8 @@ decision add options:
3674
3927
  --consequence "..." repeat for each consequence
3675
3928
 
3676
3929
  intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
3677
- decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory`);
3930
+ decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory
3931
+ In a Git worktree, begin parks an open intent in Git metadata until end, so merge does not need a log-only commit.`);
3678
3932
  return null;
3679
3933
  },
3680
3934
 
@@ -3723,7 +3977,8 @@ function dispatch(argv) {
3723
3977
  ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
3724
3978
  (cmd === 'hook' && rest[0] === 'install') ||
3725
3979
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
3726
- const readsIntentLog = ['status', 'log'].includes(cmd);
3980
+ const readsIntentLog =
3981
+ ['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
3727
3982
  if (mutates || readsIntentLog) {
3728
3983
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
3729
3984
  const data = withMutationLocks(resources, () => fn(rest));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",
@@ -39,11 +39,17 @@ driftseal help
39
39
 
40
40
  ## After a merge
41
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.
42
+ If `status` or `log` fails with a duplicate id or with `multiple intents in
43
+ progress`, or the intent log has conflict markers, run `driftseal absorb`
44
+ instead of editing `.intent-log/events.jsonl`. When both sides still have an
45
+ open intent, add `--abandon-theirs` or `--abandon-ours`; this works whether your
46
+ open intent sits in the log or is parked in Git metadata. `driftseal init` also
47
+ configures the local git merge driver; clones need `init` again for that driver.
48
+
49
+ In a Git worktree, `begin` does not dirty the tracked intent log, so `git merge`
50
+ can run with an intent still in progress. `end` writes the closed record to
51
+ `.intent-log/events.jsonl`; if it is interrupted, the intent stays open there
52
+ and `end` can be run again.
47
53
 
48
54
  Do not treat this skill, MCP descriptions, or lifecycle-hook reminders as
49
55
  additional policy. If they conflict with `AGENTS.md`, follow `AGENTS.md`.