driftseal 1.1.3 → 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 +25 -4
- package/README.zh-CN.md +21 -4
- package/bin/driftseal.js +492 -52
- package/package.json +1 -1
- package/skills/use-driftseal/SKILL.md +11 -5
package/README.md
CHANGED
|
@@ -36,7 +36,12 @@ driftseal init
|
|
|
36
36
|
|
|
37
37
|
`driftseal init` writes the protocol to `AGENTS.md`, including how to `absorb`
|
|
38
38
|
colliding worktree logs, and configures the local git merge driver. It can be
|
|
39
|
-
run again without duplicating either.
|
|
39
|
+
run again without duplicating either. Pass `--lang zh-CN` (or another
|
|
40
|
+
[BCP 47](https://www.rfc-editor.org/rfc/rfc5646.html) tag) to declare the
|
|
41
|
+
language agents should use for intent and decision prose; the default is `en`.
|
|
42
|
+
Command names, flags, status tokens, ids, and MADR section headings stay in
|
|
43
|
+
English. Re-running `init` without `--lang` preserves the declared language
|
|
44
|
+
while upgrading the protocol. DriftSeal requires Node.js 18+.
|
|
40
45
|
|
|
41
46
|
For local development from this checkout:
|
|
42
47
|
|
|
@@ -231,7 +236,7 @@ content change starts a new work round.
|
|
|
231
236
|
| `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | Install the repository-pinned MCP server into Codex, Kimi Code, OpenCode, Claude Code, or Cursor. |
|
|
232
237
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
|
|
233
238
|
| `driftseal hook prompt\|stop [--format plain\|claude-code]` | Emit the reminder a lifecycle hook injects; never blocks. |
|
|
234
|
-
| `driftseal init` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. |
|
|
239
|
+
| `driftseal init [--lang <tag>]` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. `--lang` sets the intent/decision log language (BCP 47, default `en`). |
|
|
235
240
|
| `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
|
|
236
241
|
| `driftseal help` | Print CLI usage. |
|
|
237
242
|
|
|
@@ -274,8 +279,10 @@ conflicts cannot block later decision work.
|
|
|
274
279
|
New events carry a schema version. DriftSeal rejects newer unsupported schemas and
|
|
275
280
|
fails closed if a legacy client closes a linked intent without reconciliation.
|
|
276
281
|
`driftseal init` writes versioned managed blocks and upgrades only exact, recognized
|
|
277
|
-
older blocks.
|
|
278
|
-
|
|
282
|
+
older blocks. Current-version blocks that differ only by log language are also
|
|
283
|
+
recognized, so `--lang` can change the language without rewriting policy by hand.
|
|
284
|
+
It refuses newer protocol versions and any unrecognized or customized block
|
|
285
|
+
without changing `AGENTS.md`.
|
|
279
286
|
|
|
280
287
|
`--count` prints only the number of records remaining after status filtering.
|
|
281
288
|
It cannot be combined with `--last`, whose limiting semantics would make the
|
|
@@ -310,6 +317,20 @@ ambiguous decision catalog. Run `driftseal absorb`, stage the repaired intent
|
|
|
310
317
|
and decision logs, then continue the merge. Clones need `init` again because
|
|
311
318
|
the driver lives in local git config.
|
|
312
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
|
+
|
|
313
334
|
## Storage
|
|
314
335
|
|
|
315
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
|
@@ -35,7 +35,11 @@ driftseal init
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
`driftseal init` 会把协议写入 `AGENTS.md`(包括 worktree 撞号后如何 `absorb`),
|
|
38
|
-
并配置 local git merge driver
|
|
38
|
+
并配置 local git merge driver。重复运行不会产生副本。用 `--lang zh-CN`(或其他
|
|
39
|
+
[BCP 47](https://www.rfc-editor.org/rfc/rfc5646.html) 标签)声明 agent 写入
|
|
40
|
+
intent / decision 正文时应使用的语言,默认是 `en`。命令名、flag、status token、
|
|
41
|
+
id 以及 MADR 小节标题仍保持英文。再次运行不带 `--lang` 的 `init` 会保留已声明
|
|
42
|
+
的语言并升级协议。DriftSeal 需要 Node.js 18+。
|
|
39
43
|
|
|
40
44
|
从当前 checkout 本地开发时:
|
|
41
45
|
|
|
@@ -220,7 +224,7 @@ Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看
|
|
|
220
224
|
| `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
|
|
221
225
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
|
|
222
226
|
| `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
|
|
223
|
-
| `driftseal init` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver
|
|
227
|
+
| `driftseal init [--lang <tag>]` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。`--lang` 设置 intent / decision log 的语言(BCP 47,默认 `en`)。 |
|
|
224
228
|
| `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
|
|
225
229
|
| `driftseal help` | 查看 CLI 用法。 |
|
|
226
230
|
|
|
@@ -257,8 +261,9 @@ recovery 只处理当前 intent,因此历史冲突不会阻塞之后的 decisi
|
|
|
257
261
|
新 event 带有 schema version。遇到更高且不支持的版本时,DriftSeal 会拒绝继续;如果
|
|
258
262
|
旧 client 未经 reconciliation 就关闭 linked intent,新 client 也会 fail closed。
|
|
259
263
|
`driftseal init` 会写入带版本的 managed blocks,并且只升级内容完全匹配的已知旧版本。
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
当前版本的 block 如果只是 log language 不同,也会被识别,因此可以用 `--lang`
|
|
265
|
+
改语言而不必手改协议。遇到更新的协议版本、无法识别的 block 或自定义内容时,
|
|
266
|
+
它会保持 `AGENTS.md` 不变并拒绝继续。
|
|
262
267
|
|
|
263
268
|
`--count` 只输出 status 筛选后的记录总数。它不能与 `--last` 一起使用,以免
|
|
264
269
|
“先限制再计数”造成歧义。Decision 文件名会构成一个轻量的内存索引:`show` 只
|
|
@@ -283,6 +288,18 @@ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
|
|
|
283
288
|
`driftseal absorb`、stage 修复后的 intent / decision logs,再继续 merge。clone
|
|
284
289
|
之后需要再跑一次 `init`,因为 driver 只存在于 local git config。
|
|
285
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
|
+
|
|
286
303
|
## 数据保存在哪里
|
|
287
304
|
|
|
288
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
|
|
|
@@ -36,7 +38,9 @@ const DECISION_STATUSES = [
|
|
|
36
38
|
'superseded',
|
|
37
39
|
];
|
|
38
40
|
const EVENT_SCHEMA_VERSION = 3;
|
|
39
|
-
const PROTOCOL_VERSION =
|
|
41
|
+
const PROTOCOL_VERSION = 11;
|
|
42
|
+
const DEFAULT_LOG_LANGUAGE = 'en';
|
|
43
|
+
const IN_PROGRESS_GIT_PATH = 'driftseal-in-progress.jsonl';
|
|
40
44
|
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
41
45
|
const LOCK_INIT_STALE_MS = 5 * 1000;
|
|
42
46
|
const MAX_DECISION_SLUG_LENGTH = 180;
|
|
@@ -192,7 +196,47 @@ function normalizeEvent(event, line) {
|
|
|
192
196
|
fail(`unknown event type "${event.type}" on log line ${line}`);
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
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 } = {}) {
|
|
196
240
|
if (!fs.existsSync(file)) return [];
|
|
197
241
|
let content = fs.readFileSync(file, 'utf8');
|
|
198
242
|
const rawLines = content.split('\n');
|
|
@@ -213,7 +257,65 @@ function readEvents({ repairTail = false, file = logFile() } = {}) {
|
|
|
213
257
|
content = content.slice(0, validLength);
|
|
214
258
|
}
|
|
215
259
|
}
|
|
216
|
-
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
|
+
);
|
|
217
319
|
}
|
|
218
320
|
|
|
219
321
|
function parseJsonlRecords(content, source = 'log') {
|
|
@@ -256,15 +358,11 @@ function ensureDirectoryDurable(directory) {
|
|
|
256
358
|
for (const created of missing.reverse()) fsyncDirectory(path.dirname(created));
|
|
257
359
|
}
|
|
258
360
|
|
|
259
|
-
function
|
|
260
|
-
ensureDirectoryDurable(
|
|
261
|
-
const file = logFile();
|
|
361
|
+
function appendEventTo(file, event) {
|
|
362
|
+
ensureDirectoryDurable(path.dirname(file));
|
|
262
363
|
const existed = fs.existsSync(file);
|
|
263
364
|
const storedEvent = { schemaVersion: EVENT_SCHEMA_VERSION, ...event };
|
|
264
|
-
const line = Buffer.from(
|
|
265
|
-
JSON.stringify(storedEvent) + '\n',
|
|
266
|
-
'utf8'
|
|
267
|
-
);
|
|
365
|
+
const line = Buffer.from(`${JSON.stringify(storedEvent)}\n`, 'utf8');
|
|
268
366
|
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
|
|
269
367
|
try {
|
|
270
368
|
const stat = fs.fstatSync(fd);
|
|
@@ -284,10 +382,66 @@ function appendEvent(event) {
|
|
|
284
382
|
} finally {
|
|
285
383
|
fs.closeSync(fd);
|
|
286
384
|
}
|
|
287
|
-
if (!existed) fsyncDirectory(
|
|
385
|
+
if (!existed) fsyncDirectory(path.dirname(file));
|
|
288
386
|
return storedEvent;
|
|
289
387
|
}
|
|
290
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
|
+
|
|
291
445
|
function contentHash(content) {
|
|
292
446
|
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
293
447
|
}
|
|
@@ -1122,10 +1276,213 @@ const INTENT_PROTOCOL_MARKER = '<!-- driftseal -->';
|
|
|
1122
1276
|
const INTENT_PROTOCOL_END = '<!-- /driftseal -->';
|
|
1123
1277
|
const DECISION_PROTOCOL_MARKER = '<!-- driftseal-decisions -->';
|
|
1124
1278
|
const DECISION_PROTOCOL_END = '<!-- /driftseal-decisions -->';
|
|
1279
|
+
const LOG_LANGUAGE_COMMENT_RE = /^<!-- driftseal-log-language: ([^>\r\n]+) -->\r?$/m;
|
|
1280
|
+
const LOG_LANGUAGE_PROSE_RE = /\*\*Log language:\*\* `([^`]+)`/;
|
|
1281
|
+
const IRREGULAR_GRANDFATHERED_TAGS = new Map([
|
|
1282
|
+
['en-gb-oed', 'en-GB-oed'],
|
|
1283
|
+
['i-ami', 'i-ami'],
|
|
1284
|
+
['i-bnn', 'i-bnn'],
|
|
1285
|
+
['i-default', 'i-default'],
|
|
1286
|
+
['i-enochian', 'i-enochian'],
|
|
1287
|
+
['i-hak', 'i-hak'],
|
|
1288
|
+
['i-klingon', 'i-klingon'],
|
|
1289
|
+
['i-lux', 'i-lux'],
|
|
1290
|
+
['i-mingo', 'i-mingo'],
|
|
1291
|
+
['i-navajo', 'i-navajo'],
|
|
1292
|
+
['i-pwn', 'i-pwn'],
|
|
1293
|
+
['i-tao', 'i-tao'],
|
|
1294
|
+
['i-tay', 'i-tay'],
|
|
1295
|
+
['i-tsu', 'i-tsu'],
|
|
1296
|
+
['sgn-be-fr', 'sgn-BE-FR'],
|
|
1297
|
+
['sgn-be-nl', 'sgn-BE-NL'],
|
|
1298
|
+
['sgn-ch-de', 'sgn-CH-DE'],
|
|
1299
|
+
]);
|
|
1300
|
+
|
|
1301
|
+
function titleCaseSubtag(value) {
|
|
1302
|
+
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function isBcp47Variant(subtag) {
|
|
1306
|
+
return /^[A-Za-z0-9]{5,8}$/.test(subtag) || /^[0-9][A-Za-z0-9]{3}$/.test(subtag);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function isBcp47Singleton(subtag) {
|
|
1310
|
+
return /^[0-9A-WY-Za-wy-z]$/.test(subtag);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function parsePrivateUseSubtags(subtags, start, parts) {
|
|
1314
|
+
if (start >= subtags.length || !/^x$/i.test(subtags[start])) return null;
|
|
1315
|
+
parts.push('x');
|
|
1316
|
+
let index = start + 1;
|
|
1317
|
+
let count = 0;
|
|
1318
|
+
while (index < subtags.length && /^[A-Za-z0-9]{1,8}$/.test(subtags[index])) {
|
|
1319
|
+
parts.push(subtags[index].toLowerCase());
|
|
1320
|
+
index += 1;
|
|
1321
|
+
count += 1;
|
|
1322
|
+
}
|
|
1323
|
+
if (count === 0 || index !== subtags.length) return null;
|
|
1324
|
+
return parts.join('-');
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function wellFormedBcp47(tag) {
|
|
1328
|
+
if (typeof tag !== 'string' || tag.length === 0 || tag.startsWith('-') || tag.endsWith('-')) {
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
const irregular = IRREGULAR_GRANDFATHERED_TAGS.get(tag.toLowerCase());
|
|
1332
|
+
if (irregular) return irregular;
|
|
1333
|
+
|
|
1334
|
+
const subtags = tag.split('-');
|
|
1335
|
+
if (subtags.some((subtag) => subtag.length === 0)) return null;
|
|
1336
|
+
|
|
1337
|
+
const parts = [];
|
|
1338
|
+
if (/^x$/i.test(subtags[0])) return parsePrivateUseSubtags(subtags, 0, parts);
|
|
1339
|
+
|
|
1340
|
+
let index = 0;
|
|
1341
|
+
const language = subtags[index];
|
|
1342
|
+
if (!/^[A-Za-z]{2,8}$/.test(language)) return null;
|
|
1343
|
+
parts.push(language.toLowerCase());
|
|
1344
|
+
index += 1;
|
|
1345
|
+
if (language.length <= 3) {
|
|
1346
|
+
let extlang = 0;
|
|
1347
|
+
while (index < subtags.length && /^[A-Za-z]{3}$/.test(subtags[index]) && extlang < 3) {
|
|
1348
|
+
parts.push(subtags[index].toLowerCase());
|
|
1349
|
+
index += 1;
|
|
1350
|
+
extlang += 1;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (index < subtags.length && /^[A-Za-z]{4}$/.test(subtags[index])) {
|
|
1355
|
+
parts.push(titleCaseSubtag(subtags[index]));
|
|
1356
|
+
index += 1;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (index < subtags.length && /^[A-Za-z]{2}$/.test(subtags[index])) {
|
|
1360
|
+
parts.push(subtags[index].toUpperCase());
|
|
1361
|
+
index += 1;
|
|
1362
|
+
} else if (index < subtags.length && /^[0-9]{3}$/.test(subtags[index])) {
|
|
1363
|
+
parts.push(subtags[index]);
|
|
1364
|
+
index += 1;
|
|
1365
|
+
}
|
|
1125
1366
|
|
|
1126
|
-
|
|
1367
|
+
const variants = new Set();
|
|
1368
|
+
while (index < subtags.length && isBcp47Variant(subtags[index])) {
|
|
1369
|
+
const variant = subtags[index].toLowerCase();
|
|
1370
|
+
if (variants.has(variant)) return null;
|
|
1371
|
+
variants.add(variant);
|
|
1372
|
+
parts.push(variant);
|
|
1373
|
+
index += 1;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const singletons = new Set();
|
|
1377
|
+
while (index < subtags.length && isBcp47Singleton(subtags[index])) {
|
|
1378
|
+
const singleton = subtags[index].toLowerCase();
|
|
1379
|
+
if (singletons.has(singleton)) return null;
|
|
1380
|
+
singletons.add(singleton);
|
|
1381
|
+
parts.push(singleton);
|
|
1382
|
+
index += 1;
|
|
1383
|
+
let following = 0;
|
|
1384
|
+
while (index < subtags.length && /^[A-Za-z0-9]{2,8}$/.test(subtags[index])) {
|
|
1385
|
+
parts.push(subtags[index].toLowerCase());
|
|
1386
|
+
index += 1;
|
|
1387
|
+
following += 1;
|
|
1388
|
+
}
|
|
1389
|
+
if (following === 0) return null;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (index < subtags.length && /^x$/i.test(subtags[index])) {
|
|
1393
|
+
return parsePrivateUseSubtags(subtags, index, parts);
|
|
1394
|
+
}
|
|
1395
|
+
if (index !== subtags.length) return null;
|
|
1396
|
+
return parts.join('-');
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function canonicalizeLogLanguage(value) {
|
|
1400
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
1401
|
+
fail('invalid log language: use a BCP 47 tag such as en or zh-CN');
|
|
1402
|
+
}
|
|
1403
|
+
const canonical = wellFormedBcp47(value.trim());
|
|
1404
|
+
if (!canonical) {
|
|
1405
|
+
fail(`invalid log language "${value}": use a BCP 47 tag such as en or zh-CN`);
|
|
1406
|
+
}
|
|
1407
|
+
return canonical;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function intentLogLanguageParagraph(language) {
|
|
1411
|
+
return `**Log language:** \`${language}\`. Write intent-log prose (intent, note,
|
|
1412
|
+
verify-result, and reclaim/unreclaim reason) in that language. Keep command
|
|
1413
|
+
names, flags, status tokens, and ids in English.`;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function decisionLogLanguageParagraph(language) {
|
|
1417
|
+
return `**Log language:** \`${language}\`. Write decision-log prose (title, context,
|
|
1418
|
+
outcome, drivers, options, consequences, and update notes) in that language.
|
|
1419
|
+
Keep MADR section headings, status tokens, and ids in English.`;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function parseLogLanguageFromBlock(block, label) {
|
|
1423
|
+
const commentMatch = block.match(LOG_LANGUAGE_COMMENT_RE);
|
|
1424
|
+
const proseMatch = block.match(LOG_LANGUAGE_PROSE_RE);
|
|
1425
|
+
const comment = commentMatch ? canonicalizeLogLanguage(commentMatch[1]) : null;
|
|
1426
|
+
const prose = proseMatch ? canonicalizeLogLanguage(proseMatch[1]) : null;
|
|
1427
|
+
if (comment && prose && comment !== prose) {
|
|
1428
|
+
fail(
|
|
1429
|
+
`${label} declares different log languages in the comment (${comment}) and prose (${prose}); pass --lang to set one`
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
return comment || prose || null;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function extractManagedBlock(content, marker, endMarker) {
|
|
1436
|
+
const start = content.indexOf(marker);
|
|
1437
|
+
if (start === -1) return null;
|
|
1438
|
+
const end = content.indexOf(endMarker, start);
|
|
1439
|
+
if (end === -1) return null;
|
|
1440
|
+
return content.slice(start, end + endMarker.length);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function resolveInitLogLanguage(requested, content) {
|
|
1444
|
+
if (requested !== undefined) return canonicalizeLogLanguage(requested);
|
|
1445
|
+
const languages = new Set();
|
|
1446
|
+
const intent = extractManagedBlock(content, INTENT_PROTOCOL_MARKER, INTENT_PROTOCOL_END);
|
|
1447
|
+
const decision = extractManagedBlock(content, DECISION_PROTOCOL_MARKER, DECISION_PROTOCOL_END);
|
|
1448
|
+
if (intent) {
|
|
1449
|
+
const language = parseLogLanguageFromBlock(intent, 'intent protocol');
|
|
1450
|
+
if (language) languages.add(language);
|
|
1451
|
+
}
|
|
1452
|
+
if (decision) {
|
|
1453
|
+
const language = parseLogLanguageFromBlock(decision, 'decision protocol');
|
|
1454
|
+
if (language) languages.add(language);
|
|
1455
|
+
}
|
|
1456
|
+
if (languages.size > 1) {
|
|
1457
|
+
fail(
|
|
1458
|
+
`intent and decision protocols declare different log languages (${[...languages].join(', ')}); pass --lang to set one`
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
return languages.size === 1 ? [...languages][0] : DEFAULT_LOG_LANGUAGE;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function protocolBlockKey(block) {
|
|
1465
|
+
return block
|
|
1466
|
+
.replace(/^<!-- driftseal-log-language: [^>\r\n]+ -->\r?$/m, '<!-- driftseal-log-language: -->')
|
|
1467
|
+
.replace(/\*\*Log language:\*\* `[^`]+`/g, '**Log language:** ``');
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function stripIntentLogLanguage(block, language = DEFAULT_LOG_LANGUAGE) {
|
|
1471
|
+
return block
|
|
1472
|
+
.replace(`\n<!-- driftseal-log-language: ${language} -->`, '')
|
|
1473
|
+
.replace(`\n${intentLogLanguageParagraph(language)}\n`, '');
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function stripDecisionLogLanguage(block, language = DEFAULT_LOG_LANGUAGE) {
|
|
1477
|
+
return block
|
|
1478
|
+
.replace(`\n<!-- driftseal-log-language: ${language} -->`, '')
|
|
1479
|
+
.replace(`\n${decisionLogLanguageParagraph(language)}\n`, '');
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE) {
|
|
1127
1483
|
return `${INTENT_PROTOCOL_MARKER}
|
|
1128
1484
|
<!-- driftseal-version: ${version} -->
|
|
1485
|
+
<!-- driftseal-log-language: ${language} -->
|
|
1129
1486
|
|
|
1130
1487
|
## Agent protocol: intent write-ahead log
|
|
1131
1488
|
|
|
@@ -1135,6 +1492,8 @@ This \`AGENTS.md\` protocol is the source of truth. Use the \`driftseal\` CLI by
|
|
|
1135
1492
|
default; the companion skill only helps discover and resume the workflow, while
|
|
1136
1493
|
MCP and lifecycle hooks are optional adapters.
|
|
1137
1494
|
|
|
1495
|
+
${intentLogLanguageParagraph(language)}
|
|
1496
|
+
|
|
1138
1497
|
1. **Write intent first**, before modifying, creating, or deleting files, or
|
|
1139
1498
|
making any other non-Git change that may need a rollback:
|
|
1140
1499
|
\`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
|
|
@@ -1178,8 +1537,9 @@ ${INTENT_PROTOCOL_END}`;
|
|
|
1178
1537
|
}
|
|
1179
1538
|
|
|
1180
1539
|
function previousIntentProtocolBlock(version) {
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1540
|
+
const v10 = stripIntentLogLanguage(intentProtocolBlock(version, DEFAULT_LOG_LANGUAGE));
|
|
1541
|
+
if (version >= 10) return v10;
|
|
1542
|
+
const v9 = v10.replace(
|
|
1183
1543
|
'1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
|
|
1184
1544
|
' making any other non-Git change that may need a rollback:\n' +
|
|
1185
1545
|
' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
|
|
@@ -1258,9 +1618,10 @@ function protocolEol(content, eol) {
|
|
|
1258
1618
|
return eol === '\n' ? content : content.replace(/\n/g, eol);
|
|
1259
1619
|
}
|
|
1260
1620
|
|
|
1261
|
-
function decisionProtocolBlock(version = PROTOCOL_VERSION) {
|
|
1621
|
+
function decisionProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE) {
|
|
1262
1622
|
return `${DECISION_PROTOCOL_MARKER}
|
|
1263
1623
|
<!-- driftseal-decisions-version: ${version} -->
|
|
1624
|
+
<!-- driftseal-log-language: ${language} -->
|
|
1264
1625
|
|
|
1265
1626
|
## Agent protocol: decision log
|
|
1266
1627
|
|
|
@@ -1270,6 +1631,8 @@ revisiting, non-obvious rationale behind a long-lived or costly-to-reverse accep
|
|
|
1270
1631
|
choice, or a deprecated or superseded decision. Do not record routine, local,
|
|
1271
1632
|
readily reversible choices.
|
|
1272
1633
|
|
|
1634
|
+
${decisionLogLanguageParagraph(language)}
|
|
1635
|
+
|
|
1273
1636
|
\`driftseal decision add "<title>" --context "<problem and constraints>" --outcome "<decision and rationale>" --driver "<decision driver>" --option "<considered option>" --consequence "<result>"\`
|
|
1274
1637
|
|
|
1275
1638
|
Add one \`--driver\`, \`--option\`, or \`--consequence\` flag per item. Use
|
|
@@ -1338,9 +1701,9 @@ Commit \`.decision-log/\` with the code.`;
|
|
|
1338
1701
|
}
|
|
1339
1702
|
|
|
1340
1703
|
function previousDecisionProtocolBlock(version) {
|
|
1341
|
-
const
|
|
1342
|
-
if (version >= 9) return
|
|
1343
|
-
const v8 =
|
|
1704
|
+
const v10 = stripDecisionLogLanguage(decisionProtocolBlock(version, DEFAULT_LOG_LANGUAGE));
|
|
1705
|
+
if (version >= 9) return v10;
|
|
1706
|
+
const v8 = v10.replace(
|
|
1344
1707
|
'\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
|
|
1345
1708
|
'concurrent edits of a shared decision are not auto-merged.',
|
|
1346
1709
|
''
|
|
@@ -1381,7 +1744,11 @@ function upgradeManagedBlock({
|
|
|
1381
1744
|
`protocol version ${version} requires a newer DriftSeal client (supported: ${PROTOCOL_VERSION})`
|
|
1382
1745
|
);
|
|
1383
1746
|
}
|
|
1384
|
-
if (
|
|
1747
|
+
if (
|
|
1748
|
+
block !== replacement &&
|
|
1749
|
+
!knownManagedBlocks.includes(block) &&
|
|
1750
|
+
protocolBlockKey(block) !== protocolBlockKey(replacement)
|
|
1751
|
+
) {
|
|
1385
1752
|
fail(`cannot safely upgrade customized protocol block beginning with ${marker}`);
|
|
1386
1753
|
}
|
|
1387
1754
|
return {
|
|
@@ -2020,9 +2387,14 @@ function hookLogFile() {
|
|
|
2020
2387
|
return fs.existsSync(configured) ? configured : null;
|
|
2021
2388
|
}
|
|
2022
2389
|
let current = path.resolve(process.cwd());
|
|
2390
|
+
const root = gitWorktreeRoot(current);
|
|
2023
2391
|
while (true) {
|
|
2024
2392
|
const candidate = path.join(current, '.intent-log', 'events.jsonl');
|
|
2025
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
|
+
}
|
|
2026
2398
|
const parent = path.dirname(current);
|
|
2027
2399
|
if (parent === current) return null;
|
|
2028
2400
|
current = parent;
|
|
@@ -2513,8 +2885,9 @@ function printAbsorbReport({ mappings, abandoned, intentCount }) {
|
|
|
2513
2885
|
`absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
|
|
2514
2886
|
);
|
|
2515
2887
|
for (const mapping of mappings) {
|
|
2516
|
-
|
|
2517
|
-
|
|
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}`);
|
|
2518
2891
|
}
|
|
2519
2892
|
if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
|
|
2520
2893
|
}
|
|
@@ -2534,23 +2907,45 @@ function abandonOpenIntent(records, targetId, side) {
|
|
|
2534
2907
|
return targetId;
|
|
2535
2908
|
}
|
|
2536
2909
|
|
|
2537
|
-
function resolveOpenIntents(
|
|
2910
|
+
function resolveOpenIntents(
|
|
2911
|
+
result,
|
|
2912
|
+
oursRecords,
|
|
2913
|
+
theirsRecords,
|
|
2914
|
+
abandon,
|
|
2915
|
+
{ allowConflict = false, overlay = [], parkedOpen = null } = {}
|
|
2916
|
+
) {
|
|
2538
2917
|
const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
|
|
2539
2918
|
const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
|
|
2540
2919
|
try {
|
|
2541
|
-
openIntent(fold(result.map((record) => record.event)));
|
|
2542
|
-
return { abandoned: null, conflict: false };
|
|
2920
|
+
openIntent(fold([...result, ...overlay].map((record) => record.event)));
|
|
2921
|
+
return { abandoned: null, conflict: false, parkedClosed: false };
|
|
2543
2922
|
} catch (err) {
|
|
2544
2923
|
if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
|
|
2545
2924
|
throw err;
|
|
2546
2925
|
}
|
|
2547
2926
|
if (abandon === 'theirs' && theirsOpen) {
|
|
2548
|
-
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
|
+
};
|
|
2549
2940
|
}
|
|
2550
2941
|
if (abandon === 'ours' && oursOpen) {
|
|
2551
|
-
return {
|
|
2942
|
+
return {
|
|
2943
|
+
abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'),
|
|
2944
|
+
conflict: false,
|
|
2945
|
+
parkedClosed: false,
|
|
2946
|
+
};
|
|
2552
2947
|
}
|
|
2553
|
-
if (allowConflict) return { abandoned: null, conflict: true };
|
|
2948
|
+
if (allowConflict) return { abandoned: null, conflict: true, parkedClosed: false };
|
|
2554
2949
|
fail(`${err.message}; re-run with --abandon-theirs or --abandon-ours`);
|
|
2555
2950
|
}
|
|
2556
2951
|
}
|
|
@@ -2645,20 +3040,51 @@ function finishAbsorb({
|
|
|
2645
3040
|
allowConflict = false,
|
|
2646
3041
|
followupMessage = null,
|
|
2647
3042
|
}) {
|
|
2648
|
-
|
|
2649
|
-
|
|
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,
|
|
2650
3047
|
});
|
|
2651
|
-
|
|
2652
|
-
|
|
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));
|
|
2653
3069
|
if (!dryRun) {
|
|
2654
|
-
writeJsonl(outputFile,
|
|
3070
|
+
writeJsonl(outputFile, merged);
|
|
2655
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
|
+
}
|
|
2656
3076
|
}
|
|
2657
|
-
if (
|
|
3077
|
+
if (
|
|
3078
|
+
intentCount === 0 &&
|
|
3079
|
+
allMappings.length === 0 &&
|
|
3080
|
+
copies.length === 0 &&
|
|
3081
|
+
!abandoned &&
|
|
3082
|
+
!flushOverlay
|
|
3083
|
+
) {
|
|
2658
3084
|
printLine('nothing to absorb');
|
|
2659
3085
|
} else {
|
|
2660
3086
|
printAbsorbReport({
|
|
2661
|
-
mappings,
|
|
3087
|
+
mappings: allMappings,
|
|
2662
3088
|
abandoned,
|
|
2663
3089
|
intentCount,
|
|
2664
3090
|
});
|
|
@@ -2668,7 +3094,7 @@ function finishAbsorb({
|
|
|
2668
3094
|
}
|
|
2669
3095
|
if (followupMessage) printLine(followupMessage);
|
|
2670
3096
|
return {
|
|
2671
|
-
mappings,
|
|
3097
|
+
mappings: allMappings,
|
|
2672
3098
|
abandoned,
|
|
2673
3099
|
copies: copies.map((item) => item.toFile),
|
|
2674
3100
|
outputFile,
|
|
@@ -2897,22 +3323,30 @@ const commands = {
|
|
|
2897
3323
|
|
|
2898
3324
|
const events = readEvents({ repairTail: true });
|
|
2899
3325
|
const records = fold(events);
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
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) {
|
|
2908
3342
|
const status = closeIntentAsEscape(
|
|
2909
3343
|
events,
|
|
2910
|
-
|
|
3344
|
+
record,
|
|
2911
3345
|
'abandoned',
|
|
2912
3346
|
'superseded by --force',
|
|
2913
3347
|
null
|
|
2914
3348
|
);
|
|
2915
|
-
printError(`driftseal: ${status} ${
|
|
3349
|
+
printError(`driftseal: ${status} ${record.id}`);
|
|
2916
3350
|
}
|
|
2917
3351
|
|
|
2918
3352
|
const id = nextId(events);
|
|
@@ -3322,14 +3756,15 @@ const commands = {
|
|
|
3322
3756
|
},
|
|
3323
3757
|
|
|
3324
3758
|
init(argv) {
|
|
3325
|
-
const { positionals } = parseArgs(argv, {});
|
|
3326
|
-
if (positionals.length > 0) fail('usage: driftseal init');
|
|
3759
|
+
const { positionals, flags } = parseArgs(argv, { lang: 'single' });
|
|
3760
|
+
if (positionals.length > 0) fail('usage: driftseal init [--lang <tag>]');
|
|
3327
3761
|
const target = path.join(process.cwd(), 'AGENTS.md');
|
|
3328
3762
|
const existed = fs.existsSync(target);
|
|
3329
3763
|
const current = existed ? fs.readFileSync(target, 'utf8') : '';
|
|
3330
3764
|
const eol = current.includes('\r\n') ? '\r\n' : '\n';
|
|
3331
|
-
const
|
|
3332
|
-
const
|
|
3765
|
+
const language = resolveInitLogLanguage(flags.lang, current);
|
|
3766
|
+
const intentBlock = protocolEol(intentProtocolBlock(PROTOCOL_VERSION, language), eol);
|
|
3767
|
+
const decisionBlock = protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, language), eol);
|
|
3333
3768
|
let updated = current;
|
|
3334
3769
|
const intent = upgradeManagedBlock({
|
|
3335
3770
|
content: updated,
|
|
@@ -3346,6 +3781,7 @@ const commands = {
|
|
|
3346
3781
|
protocolEol(previousIntentProtocolBlock(7), eol),
|
|
3347
3782
|
protocolEol(previousIntentProtocolBlock(8), eol),
|
|
3348
3783
|
protocolEol(previousIntentProtocolBlock(9), eol),
|
|
3784
|
+
protocolEol(previousIntentProtocolBlock(10), eol),
|
|
3349
3785
|
],
|
|
3350
3786
|
knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
|
|
3351
3787
|
});
|
|
@@ -3365,6 +3801,7 @@ const commands = {
|
|
|
3365
3801
|
protocolEol(previousDecisionProtocolBlock(7), eol),
|
|
3366
3802
|
protocolEol(previousDecisionProtocolBlock(8), eol),
|
|
3367
3803
|
protocolEol(previousDecisionProtocolBlock(9), eol),
|
|
3804
|
+
protocolEol(previousDecisionProtocolBlock(10), eol),
|
|
3368
3805
|
],
|
|
3369
3806
|
knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
|
|
3370
3807
|
});
|
|
@@ -3445,7 +3882,8 @@ usage:
|
|
|
3445
3882
|
targets: kimi-code (global only), claude-code, codex (prompt only)
|
|
3446
3883
|
driftseal hook prompt|stop [--format plain|claude-code]
|
|
3447
3884
|
emit the reminder a lifecycle hook injects; never blocks
|
|
3448
|
-
driftseal init
|
|
3885
|
+
driftseal init [--lang <tag>] inject protocols into ./AGENTS.md and configure the git merge driver
|
|
3886
|
+
--lang sets the intent/decision log language (BCP 47, default: en)
|
|
3449
3887
|
driftseal --version | -V print the installed DriftSeal version
|
|
3450
3888
|
driftseal help
|
|
3451
3889
|
|
|
@@ -3456,7 +3894,8 @@ decision add options:
|
|
|
3456
3894
|
--consequence "..." repeat for each consequence
|
|
3457
3895
|
|
|
3458
3896
|
intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
|
|
3459
|
-
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.`);
|
|
3460
3899
|
return null;
|
|
3461
3900
|
},
|
|
3462
3901
|
|
|
@@ -3505,7 +3944,8 @@ function dispatch(argv) {
|
|
|
3505
3944
|
['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
|
|
3506
3945
|
(cmd === 'hook' && rest[0] === 'install') ||
|
|
3507
3946
|
(cmd === 'decision' && ['add', 'update'].includes(rest[0]));
|
|
3508
|
-
const readsIntentLog =
|
|
3947
|
+
const readsIntentLog =
|
|
3948
|
+
['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
|
|
3509
3949
|
if (mutates || readsIntentLog) {
|
|
3510
3950
|
const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
|
|
3511
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`.
|