driftseal 1.1.5 → 1.1.6

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
@@ -317,6 +317,20 @@ ambiguous decision catalog. Run `driftseal absorb`, stage the repaired intent
317
317
  and decision logs, then continue the merge. Clones need `init` again because
318
318
  the driver lives in local git config.
319
319
 
320
+ In a Git worktree, `begin` parks the open intent in Git metadata instead of
321
+ appending to the tracked `events.jsonl`. Git can merge while that intent is
322
+ still in progress, so you do not need a log-only commit just to get a clean
323
+ tree. `end` moves the parked records into the tracked log and writes the closing
324
+ record there — never into Git metadata — so an interrupted `end` leaves the
325
+ intent open in the log and can simply be run again. If the parked intent's id
326
+ collides with incoming merged events, DriftSeal remaps it the same way `absorb`
327
+ remaps colliding worktree ids.
328
+
329
+ When a merge brings in a second open intent, `absorb --abandon-ours` closes the
330
+ parked one into the tracked log and `absorb --abandon-theirs` closes the
331
+ incoming one and leaves yours parked. `end <id>` also works on the incoming
332
+ intent directly, and `begin --force` abandons every open intent at once.
333
+
320
334
  ## Storage
321
335
 
322
336
  - `.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
@@ -288,6 +288,18 @@ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
288
288
  `driftseal absorb`、stage 修复后的 intent / decision logs,再继续 merge。clone
289
289
  之后需要再跑一次 `init`,因为 driver 只存在于 local git config。
290
290
 
291
+ 在 Git worktree 里,`begin` 会把未关闭的 intent 停在 Git 元数据中,而不是追加到
292
+ 已跟踪的 `events.jsonl`。因此工作树保持干净,`git merge` 可以在 intent 仍进行中
293
+ 时执行,不必为了清工作树而多做一个只含日志的提交。`end` 会先把停放的记录移入跟踪
294
+ 日志,再把关闭记录写在那里,而不会写进 Git 元数据;因此 `end` 中途失败时,intent
295
+ 只是以未关闭状态留在日志里,重跑一次即可。如果停放中的 intent id 与合并进来的事件
296
+ 撞号,DriftSeal 会按 `absorb` 同样的规则重编号。
297
+
298
+ 合并带进来第二个未关闭的 intent 时,`absorb --abandon-ours` 会关闭停放的那一个并
299
+ 写入跟踪日志,`absorb --abandon-theirs` 则关闭合并进来的那一个、保留自己的停放
300
+ 状态。也可以用 `end <id>` 直接关闭合并进来的 intent,或用 `begin --force` 一次性
301
+ 放弃所有未关闭的 intent。
302
+
291
303
  ## 数据保存在哪里
292
304
 
293
305
  - `.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
  }
@@ -2234,9 +2387,14 @@ function hookLogFile() {
2234
2387
  return fs.existsSync(configured) ? configured : null;
2235
2388
  }
2236
2389
  let current = path.resolve(process.cwd());
2390
+ const root = gitWorktreeRoot(current);
2237
2391
  while (true) {
2238
2392
  const candidate = path.join(current, '.intent-log', 'events.jsonl');
2239
2393
  if (fs.existsSync(candidate)) return candidate;
2394
+ if (root && path.resolve(root) === current) {
2395
+ const park = worktreeInProgressFile(current);
2396
+ if (park && fs.existsSync(park)) return candidate;
2397
+ }
2240
2398
  const parent = path.dirname(current);
2241
2399
  if (parent === current) return null;
2242
2400
  current = parent;
@@ -2727,8 +2885,9 @@ function printAbsorbReport({ mappings, abandoned, intentCount }) {
2727
2885
  `absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
2728
2886
  );
2729
2887
  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}`);
2888
+ const side = mapping.side || 'theirs';
2889
+ if (mapping.kind === 'intent') printLine(`${mapping.from} (${side}) -> ${mapping.to}`);
2890
+ else printLine(`decision ${mapping.from} (${side}) -> ${mapping.to}`);
2732
2891
  }
2733
2892
  if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
2734
2893
  }
@@ -2748,23 +2907,45 @@ function abandonOpenIntent(records, targetId, side) {
2748
2907
  return targetId;
2749
2908
  }
2750
2909
 
2751
- function resolveOpenIntents(result, oursRecords, theirsRecords, abandon, { allowConflict = false } = {}) {
2910
+ function resolveOpenIntents(
2911
+ result,
2912
+ oursRecords,
2913
+ theirsRecords,
2914
+ abandon,
2915
+ { allowConflict = false, overlay = [], parkedOpen = null } = {}
2916
+ ) {
2752
2917
  const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
2753
2918
  const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
2754
2919
  try {
2755
- openIntent(fold(result.map((record) => record.event)));
2756
- return { abandoned: null, conflict: false };
2920
+ openIntent(fold([...result, ...overlay].map((record) => record.event)));
2921
+ return { abandoned: null, conflict: false, parkedClosed: false };
2757
2922
  } catch (err) {
2758
2923
  if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
2759
2924
  throw err;
2760
2925
  }
2761
2926
  if (abandon === 'theirs' && theirsOpen) {
2762
- return { abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'), conflict: false };
2927
+ return {
2928
+ abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'),
2929
+ conflict: false,
2930
+ parkedClosed: false,
2931
+ };
2932
+ }
2933
+ // A parked intent is local by construction, so --abandon-ours targets it before the log.
2934
+ if (abandon === 'ours' && parkedOpen) {
2935
+ return {
2936
+ abandoned: abandonOpenIntent(overlay, parkedOpen.id, 'ours'),
2937
+ conflict: false,
2938
+ parkedClosed: true,
2939
+ };
2763
2940
  }
2764
2941
  if (abandon === 'ours' && oursOpen) {
2765
- return { abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'), conflict: false };
2942
+ return {
2943
+ abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'),
2944
+ conflict: false,
2945
+ parkedClosed: false,
2946
+ };
2766
2947
  }
2767
- if (allowConflict) return { abandoned: null, conflict: true };
2948
+ if (allowConflict) return { abandoned: null, conflict: true, parkedClosed: false };
2768
2949
  fail(`${err.message}; re-run with --abandon-theirs or --abandon-ours`);
2769
2950
  }
2770
2951
  }
@@ -2859,20 +3040,51 @@ function finishAbsorb({
2859
3040
  allowConflict = false,
2860
3041
  followupMessage = null,
2861
3042
  }) {
2862
- const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
2863
- allowConflict,
3043
+ // An intent parked in Git metadata is part of our side even though the log never saw it.
3044
+ const park = shouldAttachInProgress(outputFile) ? inProgressFile() : null;
3045
+ const plan = planInProgressOverlay(result.map((record) => record.event), park, {
3046
+ repairTail: true,
2864
3047
  });
2865
- fold(result.map((record) => record.event));
2866
- if (!conflict) openIntent(fold(result.map((record) => record.event)));
3048
+ const overlay = plan && !plan.alreadyCommitted ? plan.records : [];
3049
+ const parkedOpen =
3050
+ overlay.length > 0 ? openIntent(fold(overlay.map((record) => record.event))) : null;
3051
+ const parkMappings = plan
3052
+ ? plan.mappings.map((mapping) => ({ ...mapping, side: 'parked' }))
3053
+ : [];
3054
+ const allMappings = [...mappings, ...parkMappings];
3055
+ const { abandoned, conflict, parkedClosed } = resolveOpenIntents(
3056
+ result,
3057
+ oursRecords,
3058
+ theirsRecords,
3059
+ abandon,
3060
+ { allowConflict, overlay, parkedOpen }
3061
+ );
3062
+ // A parked overlay with nothing left open in it belongs in the tracked log, whether the
3063
+ // abandon flag just closed it or an interrupted end left it closed.
3064
+ const flushOverlay = parkedClosed || (overlay.length > 0 && !parkedOpen);
3065
+ const merged = flushOverlay ? [...result, ...overlay] : result;
3066
+ const effective = [...result, ...overlay].map((record) => record.event);
3067
+ fold(effective);
3068
+ if (!conflict) openIntent(fold(effective));
2867
3069
  if (!dryRun) {
2868
- writeJsonl(outputFile, result);
3070
+ writeJsonl(outputFile, merged);
2869
3071
  applyDecisionCopies(copies, dryRun);
3072
+ if (plan) {
3073
+ if (plan.alreadyCommitted || flushOverlay) discardInProgressLog(park);
3074
+ else if (plan.mappings.length > 0) writeJsonl(park, overlay);
3075
+ }
2870
3076
  }
2871
- if (intentCount === 0 && mappings.length === 0 && copies.length === 0 && !abandoned) {
3077
+ if (
3078
+ intentCount === 0 &&
3079
+ allMappings.length === 0 &&
3080
+ copies.length === 0 &&
3081
+ !abandoned &&
3082
+ !flushOverlay
3083
+ ) {
2872
3084
  printLine('nothing to absorb');
2873
3085
  } else {
2874
3086
  printAbsorbReport({
2875
- mappings,
3087
+ mappings: allMappings,
2876
3088
  abandoned,
2877
3089
  intentCount,
2878
3090
  });
@@ -2882,7 +3094,7 @@ function finishAbsorb({
2882
3094
  }
2883
3095
  if (followupMessage) printLine(followupMessage);
2884
3096
  return {
2885
- mappings,
3097
+ mappings: allMappings,
2886
3098
  abandoned,
2887
3099
  copies: copies.map((item) => item.toFile),
2888
3100
  outputFile,
@@ -3111,22 +3323,30 @@ const commands = {
3111
3323
 
3112
3324
  const events = readEvents({ repairTail: true });
3113
3325
  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
- }
3326
+ // A parked intent and a merged-in one can both be open; --force clears every one of them.
3327
+ const open = records.filter((record) => record.status === 'in_progress');
3328
+ if (open.length > 1 && !flags.force) {
3329
+ fail(
3330
+ `multiple intents in progress: ${open.map((record) => record.id).join(', ')}\n` +
3331
+ 'resolve them with driftseal absorb --abandon-ours or --abandon-theirs, ' +
3332
+ 'or re-run with --force to abandon all of them'
3333
+ );
3334
+ }
3335
+ if (open.length === 1 && !flags.force) {
3336
+ fail(
3337
+ `intent ${open[0].id} is still in_progress: "${open[0].intent}"\n` +
3338
+ `end it first (driftseal end) or re-run with --force to abandon it`
3339
+ );
3340
+ }
3341
+ for (const record of open) {
3122
3342
  const status = closeIntentAsEscape(
3123
3343
  events,
3124
- open,
3344
+ record,
3125
3345
  'abandoned',
3126
3346
  'superseded by --force',
3127
3347
  null
3128
3348
  );
3129
- printError(`driftseal: ${status} ${open.id}`);
3349
+ printError(`driftseal: ${status} ${record.id}`);
3130
3350
  }
3131
3351
 
3132
3352
  const id = nextId(events);
@@ -3674,7 +3894,8 @@ decision add options:
3674
3894
  --consequence "..." repeat for each consequence
3675
3895
 
3676
3896
  intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
3677
- decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory`);
3897
+ decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory
3898
+ In a Git worktree, begin parks an open intent in Git metadata until end, so merge does not need a log-only commit.`);
3678
3899
  return null;
3679
3900
  },
3680
3901
 
@@ -3723,7 +3944,8 @@ function dispatch(argv) {
3723
3944
  ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
3724
3945
  (cmd === 'hook' && rest[0] === 'install') ||
3725
3946
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
3726
- const readsIntentLog = ['status', 'log'].includes(cmd);
3947
+ const readsIntentLog =
3948
+ ['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
3727
3949
  if (mutates || readsIntentLog) {
3728
3950
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
3729
3951
  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.6",
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`.