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 +17 -2
- package/README.zh-CN.md +14 -1
- package/bin/driftseal.js +303 -48
- package/package.json +1 -1
- package/skills/use-driftseal/SKILL.md +11 -5
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
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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 ?
|
|
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
|
|
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
|
|
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 =
|
|
1947
|
-
const existingDigest =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
2731
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
2863
|
-
|
|
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
|
-
|
|
2866
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
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
|
-
|
|
3377
|
+
record,
|
|
3125
3378
|
'abandoned',
|
|
3126
3379
|
'superseded by --force',
|
|
3127
3380
|
null
|
|
3128
3381
|
);
|
|
3129
|
-
printError(`driftseal: ${status} ${
|
|
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 =
|
|
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
|
@@ -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`.
|