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 +14 -0
- package/README.zh-CN.md +12 -0
- package/bin/driftseal.js +260 -38
- package/package.json +1 -1
- package/skills/use-driftseal/SKILL.md +11 -5
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
|
|
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)
|
|
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
|
|
261
|
-
ensureDirectoryDurable(
|
|
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(
|
|
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
|
-
|
|
2731
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
2863
|
-
|
|
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
|
-
|
|
2866
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
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
|
-
|
|
3344
|
+
record,
|
|
3125
3345
|
'abandoned',
|
|
3126
3346
|
'superseded by --force',
|
|
3127
3347
|
null
|
|
3128
3348
|
);
|
|
3129
|
-
printError(`driftseal: ${status} ${
|
|
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 =
|
|
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
|
@@ -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
|
|
43
|
-
markers, run `driftseal absorb`
|
|
44
|
-
When both sides still have an
|
|
45
|
-
`--abandon-
|
|
46
|
-
|
|
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`.
|