driftseal 1.0.0 → 1.1.0
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 +29 -3
- package/README.zh-CN.md +21 -3
- package/bin/driftseal.js +698 -23
- package/package.json +1 -1
- package/skills/use-driftseal/SKILL.md +8 -0
package/README.md
CHANGED
|
@@ -34,7 +34,9 @@ cd your-project
|
|
|
34
34
|
driftseal init
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
`driftseal init`
|
|
37
|
+
`driftseal init` writes the protocol to `AGENTS.md`, including how to `absorb`
|
|
38
|
+
colliding worktree logs, and configures the local git merge driver. It can be
|
|
39
|
+
run again without duplicating either. DriftSeal requires Node.js 18+.
|
|
38
40
|
|
|
39
41
|
For local development from this checkout:
|
|
40
42
|
|
|
@@ -207,6 +209,8 @@ Single-step commands that only build, check, or record work already done — com
|
|
|
207
209
|
| `driftseal log [-n N] [--all]` | Review intent history (`--all` includes reclaimed records). |
|
|
208
210
|
| `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | Hide meaningless closed records behind append-only markers. |
|
|
209
211
|
| `driftseal unreclaim <id> --reason "..."` | Restore a reclaimed record to the visible log. |
|
|
212
|
+
| `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | Merge another worktree's logs, remapping colliding intent and decision ids. |
|
|
213
|
+
| `driftseal absorb --git <base> <ours> <theirs>` | Git merge driver for `.intent-log/events.jsonl`. |
|
|
210
214
|
| `driftseal decision add "<title>" -c "..." -o "..."` | Write a numbered MADR decision. |
|
|
211
215
|
| `driftseal decision update <id> [-s status] -n "..."` | Reconcile a linked decision in the open intent. |
|
|
212
216
|
| `driftseal decision list [-s status] [--last N \| --count]` | List or count decision records, optionally filtered by status. |
|
|
@@ -215,7 +219,7 @@ Single-step commands that only build, check, or record work already done — com
|
|
|
215
219
|
| `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. |
|
|
216
220
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
|
|
217
221
|
| `driftseal hook prompt\|stop [--format plain\|claude-code]` | Emit the reminder a lifecycle hook injects; never blocks. |
|
|
218
|
-
| `driftseal init` | Add the adoption protocol to `AGENTS.md
|
|
222
|
+
| `driftseal init` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. |
|
|
219
223
|
| `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
|
|
220
224
|
| `driftseal help` | Print CLI usage. |
|
|
221
225
|
|
|
@@ -269,9 +273,31 @@ contents. Status-filtered listing and counting parse all records because status
|
|
|
269
273
|
is stored in each MADR document; DriftSeal does not maintain a stale-prone sidecar
|
|
270
274
|
index.
|
|
271
275
|
|
|
276
|
+
## Merging worktrees
|
|
277
|
+
|
|
278
|
+
Two worktrees allocate intent and decision ids from their local logs, so a
|
|
279
|
+
same-day parallel `begin` or `decision add` can collide when the branches
|
|
280
|
+
merge. `driftseal absorb` rebuilds a valid log by keeping our ids and remapping
|
|
281
|
+
the incoming side, then prints the mapping. A single lineage stays append-only;
|
|
282
|
+
absorb is the one cross-lineage rewrite.
|
|
283
|
+
|
|
284
|
+
```sh
|
|
285
|
+
driftseal absorb ../other-worktree/.intent-log/events.jsonl \
|
|
286
|
+
--decisions ../other-worktree/.decision-log
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
With no path, `absorb` repairs the current log after a git conflict or a
|
|
290
|
+
concatenated duplicate. If both sides still have an open intent, pass
|
|
291
|
+
`--abandon-theirs` or `--abandon-ours`. Concurrent edits of a decision that
|
|
292
|
+
already existed in the shared base are not auto-merged.
|
|
293
|
+
|
|
294
|
+
`driftseal init` writes that absorb rule into `AGENTS.md`, plus `.gitattributes`
|
|
295
|
+
and the local git merge driver so `events.jsonl` merges through `absorb --git`.
|
|
296
|
+
Clones need `init` again because the driver lives in local git config.
|
|
297
|
+
|
|
272
298
|
## Storage
|
|
273
299
|
|
|
274
|
-
- `.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.
|
|
300
|
+
- `.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.
|
|
275
301
|
- `.decision-log/` contains numbered MADR decision records.
|
|
276
302
|
- Set `DRIFTSEAL_HOME` or `DRIFTSEAL_DECISION_HOME` to store either log outside the current project.
|
|
277
303
|
|
package/README.zh-CN.md
CHANGED
|
@@ -34,7 +34,8 @@ cd your-project
|
|
|
34
34
|
driftseal init
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
`driftseal init`
|
|
37
|
+
`driftseal init` 会把协议写入 `AGENTS.md`(包括 worktree 撞号后如何 `absorb`),
|
|
38
|
+
并配置 local git merge driver。重复运行不会产生副本。DriftSeal 需要 Node.js 18+。
|
|
38
39
|
|
|
39
40
|
从当前 checkout 本地开发时:
|
|
40
41
|
|
|
@@ -201,6 +202,8 @@ driftseal end \
|
|
|
201
202
|
| `driftseal log [-n N] [--all]` | 查看 intent 历史(`--all` 包含已回收的记录)。 |
|
|
202
203
|
| `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | 用 append-only 标记隐藏已无意义的已关闭记录。 |
|
|
203
204
|
| `driftseal unreclaim <id> --reason "..."` | 把已回收的记录恢复到可见历史中。 |
|
|
205
|
+
| `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | 合并另一条 worktree 的日志,并给撞号的 intent / decision id 重新编号。 |
|
|
206
|
+
| `driftseal absorb --git <base> <ours> <theirs>` | `.intent-log/events.jsonl` 的 git merge driver。 |
|
|
204
207
|
| `driftseal decision add "<title>" -c "..." -o "..."` | 写入编号化的 MADR decision。 |
|
|
205
208
|
| `driftseal decision update <id> [-s status] -n "..."` | 在当前 intent 中 reconcile 已关联的 decision。 |
|
|
206
209
|
| `driftseal decision list [-s status] [--last N \| --count]` | 列出或统计 decision records,也可按 status 筛选。 |
|
|
@@ -209,7 +212,7 @@ driftseal end \
|
|
|
209
212
|
| `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
|
|
210
213
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
|
|
211
214
|
| `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
|
|
212
|
-
| `driftseal init` | 把接入协议写入 `AGENTS.md
|
|
215
|
+
| `driftseal init` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。 |
|
|
213
216
|
| `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
|
|
214
217
|
| `driftseal help` | 查看 CLI 用法。 |
|
|
215
218
|
|
|
@@ -255,9 +258,24 @@ recovery 只处理当前 intent,因此历史冲突不会阻塞之后的 decisi
|
|
|
255
258
|
时仍需解析全部 records,因为 status 保存在各个 MADR 文档中;DriftSeal 不维护容易
|
|
256
259
|
滞后的 sidecar index。
|
|
257
260
|
|
|
261
|
+
## 合并 worktree
|
|
262
|
+
|
|
263
|
+
两条 worktree 各自按本地日志分配 intent / decision id,同一天并行 `begin` 或 `decision add`,分支合并时就会撞号。`driftseal absorb` 保留我方编号、给进来的一侧重编号,并打印对照表。单线 WAL 仍然只追加;absorb 是唯一允许的跨谱系重写。
|
|
264
|
+
|
|
265
|
+
```sh
|
|
266
|
+
driftseal absorb ../other-worktree/.intent-log/events.jsonl \
|
|
267
|
+
--decisions ../other-worktree/.decision-log
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
不带 path 时,`absorb` 修复当前日志里的冲突标记或已经拼在一起的重复 id。两边都还有未关闭 intent 时,必须加上 `--abandon-theirs` 或 `--abandon-ours`。共享 base 里已经存在、又被两边同时改过的 decision,不会自动合并。
|
|
271
|
+
|
|
272
|
+
`driftseal init` 会把这条 absorb 规则写入 `AGENTS.md`,同时写入 `.gitattributes`
|
|
273
|
+
并配置 local git merge driver,让 `events.jsonl` 走 `absorb --git`。clone 之后
|
|
274
|
+
需要再跑一次 `init`,因为 driver 只存在于 local git config。
|
|
275
|
+
|
|
258
276
|
## 数据保存在哪里
|
|
259
277
|
|
|
260
|
-
- `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim
|
|
278
|
+
- `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim`,而不是删除日志行。合并撞号时用 `driftseal absorb`,不要手改这个文件。
|
|
261
279
|
- `.decision-log/`:编号化的 MADR decision records。
|
|
262
280
|
- 设置 `DRIFTSEAL_HOME` 或 `DRIFTSEAL_DECISION_HOME`,即可把对应 log 放到当前项目之外。
|
|
263
281
|
|
package/bin/driftseal.js
CHANGED
|
@@ -36,7 +36,7 @@ const DECISION_STATUSES = [
|
|
|
36
36
|
'superseded',
|
|
37
37
|
];
|
|
38
38
|
const EVENT_SCHEMA_VERSION = 3;
|
|
39
|
-
const PROTOCOL_VERSION =
|
|
39
|
+
const PROTOCOL_VERSION = 9;
|
|
40
40
|
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
41
41
|
const LOCK_INIT_STALE_MS = 5 * 1000;
|
|
42
42
|
const MAX_DECISION_SLUG_LENGTH = 180;
|
|
@@ -213,15 +213,19 @@ function readEvents({ repairTail = false, file = logFile() } = {}) {
|
|
|
213
213
|
content = content.slice(0, validLength);
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
|
+
return parseJsonlRecords(content, file).map((record) => record.event);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseJsonlRecords(content, source = 'log') {
|
|
216
220
|
return content
|
|
217
221
|
.split('\n')
|
|
218
222
|
.filter((line) => line.trim().length > 0)
|
|
219
223
|
.map((line, i) => {
|
|
220
224
|
try {
|
|
221
|
-
return normalizeEvent(JSON.parse(line), i + 1);
|
|
225
|
+
return { raw: line, event: normalizeEvent(JSON.parse(line), i + 1) };
|
|
222
226
|
} catch (err) {
|
|
223
227
|
if (err instanceof DriftSealError) throw err;
|
|
224
|
-
fail(`corrupt log line ${i + 1} in ${
|
|
228
|
+
fail(`corrupt log line ${i + 1} in ${source}`);
|
|
225
229
|
}
|
|
226
230
|
});
|
|
227
231
|
}
|
|
@@ -714,16 +718,26 @@ function openIntent(records) {
|
|
|
714
718
|
return open[0] || null;
|
|
715
719
|
}
|
|
716
720
|
|
|
717
|
-
function
|
|
718
|
-
const
|
|
721
|
+
function parseIntentId(id) {
|
|
722
|
+
const match = String(id).match(/^(\d{4}-\d{2}-\d{2})-(\d+)$/);
|
|
723
|
+
if (!match) fail(`invalid intent id: ${id}`);
|
|
724
|
+
return { date: match[1], seq: Number.parseInt(match[2], 10) };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function nextIdForDate(date, events) {
|
|
719
728
|
let maxSeq = 0;
|
|
729
|
+
const prefix = `${date}-`;
|
|
720
730
|
for (const ev of events) {
|
|
721
|
-
if (ev.type === 'begin' && typeof ev.id === 'string' && ev.id.startsWith(
|
|
722
|
-
const seq = parseInt(ev.id.slice(
|
|
731
|
+
if (ev.type === 'begin' && typeof ev.id === 'string' && ev.id.startsWith(prefix)) {
|
|
732
|
+
const seq = Number.parseInt(ev.id.slice(prefix.length), 10);
|
|
723
733
|
if (Number.isFinite(seq) && seq > maxSeq) maxSeq = seq;
|
|
724
734
|
}
|
|
725
735
|
}
|
|
726
|
-
return `${
|
|
736
|
+
return `${date}-${String(maxSeq + 1).padStart(3, '0')}`;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function nextId(events) {
|
|
740
|
+
return nextIdForDate(new Date().toISOString().slice(0, 10), events);
|
|
727
741
|
}
|
|
728
742
|
|
|
729
743
|
function normalizeDecisionId(value) {
|
|
@@ -771,25 +785,31 @@ function compareDecisionEntries(a, b) {
|
|
|
771
785
|
return a.id.length - b.id.length || a.id.localeCompare(b.id) || a.file.localeCompare(b.file);
|
|
772
786
|
}
|
|
773
787
|
|
|
774
|
-
function
|
|
775
|
-
if (!fs.existsSync(
|
|
788
|
+
function listDecisionEntries(dir, { allowDuplicates = false } = {}) {
|
|
789
|
+
if (!dir || !fs.existsSync(dir)) return [];
|
|
776
790
|
const entries = [];
|
|
777
791
|
const ids = new Map();
|
|
778
|
-
for (const entry of fs.readdirSync(
|
|
792
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
779
793
|
const match = entry.name.match(/^(\d{4,})-.*\.md$/);
|
|
780
794
|
if (!match) continue;
|
|
781
|
-
const fullPath = path.join(
|
|
795
|
+
const fullPath = path.join(dir, entry.name);
|
|
782
796
|
const stat = fs.lstatSync(fullPath);
|
|
783
797
|
if (stat.isSymbolicLink()) fail(`decision record must not be a symbolic link: ${entry.name}`);
|
|
784
798
|
if (!stat.isFile()) fail(`decision record is not a regular file: ${entry.name}`);
|
|
785
799
|
const id = normalizeDecisionId(match[1]);
|
|
786
|
-
if (ids.has(id)
|
|
787
|
-
|
|
800
|
+
if (ids.has(id) && !allowDuplicates) {
|
|
801
|
+
fail(`duplicate decision id ${id}: ${ids.get(id)}, ${entry.name}`);
|
|
802
|
+
}
|
|
803
|
+
if (!ids.has(id)) ids.set(id, entry.name);
|
|
788
804
|
entries.push({ id, file: entry.name, path: fullPath });
|
|
789
805
|
}
|
|
790
806
|
return entries.sort(compareDecisionEntries);
|
|
791
807
|
}
|
|
792
808
|
|
|
809
|
+
function decisionIndex() {
|
|
810
|
+
return listDecisionEntries(decisionDir());
|
|
811
|
+
}
|
|
812
|
+
|
|
793
813
|
function readDecision(entry) {
|
|
794
814
|
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
|
795
815
|
let fd;
|
|
@@ -1146,13 +1166,23 @@ MCP and lifecycle hooks are optional adapters.
|
|
|
1146
1166
|
\`driftseal\` commands or the MCP tools. Retire meaningless closed records with
|
|
1147
1167
|
\`driftseal reclaim [id ...] --reason "<why>"\` — it appends a marker, never
|
|
1148
1168
|
deletes log lines; \`driftseal unreclaim <id> --reason "<why>"\` restores one.
|
|
1169
|
+
After a merge collision, run \`driftseal absorb\` rather than editing the log;
|
|
1170
|
+
if both sides still have an open intent, add \`--abandon-theirs\` or
|
|
1171
|
+
\`--abandon-ours\`.
|
|
1149
1172
|
|
|
1150
1173
|
Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
|
|
1151
1174
|
${INTENT_PROTOCOL_END}`;
|
|
1152
1175
|
}
|
|
1153
1176
|
|
|
1154
1177
|
function previousIntentProtocolBlock(version) {
|
|
1155
|
-
const
|
|
1178
|
+
const v8 = intentProtocolBlock(version).replace(
|
|
1179
|
+
'\nAfter a merge collision, run `driftseal absorb` rather than editing the log;\n' +
|
|
1180
|
+
'if both sides still have an open intent, add `--abandon-theirs` or\n' +
|
|
1181
|
+
'`--abandon-ours`.',
|
|
1182
|
+
''
|
|
1183
|
+
);
|
|
1184
|
+
if (version >= 8) return v8;
|
|
1185
|
+
const v7 = v8.replace(
|
|
1156
1186
|
'\nThis `AGENTS.md` protocol is the source of truth. Use the `driftseal` CLI by\n' +
|
|
1157
1187
|
'default; the companion skill only helps discover and resume the workflow, while\n' +
|
|
1158
1188
|
'MCP and lifecycle hooks are optional adapters.\n',
|
|
@@ -1220,6 +1250,8 @@ Count postponed choices with \`driftseal decision list --status deferred --count
|
|
|
1220
1250
|
then review them with \`driftseal decision list --status deferred\`.
|
|
1221
1251
|
When an intent declares an existing decision with \`--decision <id>\`, use
|
|
1222
1252
|
\`driftseal decision update\` to record its status transition or explicit confirmation.
|
|
1253
|
+
After a merge, colliding decision ids are remapped with \`driftseal absorb\`;
|
|
1254
|
+
concurrent edits of a shared decision are not auto-merged.
|
|
1223
1255
|
Commit \`.decision-log/\` with the code.
|
|
1224
1256
|
${DECISION_PROTOCOL_END}`;
|
|
1225
1257
|
}
|
|
@@ -1276,8 +1308,13 @@ Commit \`.decision-log/\` with the code.`;
|
|
|
1276
1308
|
}
|
|
1277
1309
|
|
|
1278
1310
|
function previousDecisionProtocolBlock(version) {
|
|
1279
|
-
|
|
1280
|
-
|
|
1311
|
+
const v8 = decisionProtocolBlock(version).replace(
|
|
1312
|
+
'\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
|
|
1313
|
+
'concurrent edits of a shared decision are not auto-merged.',
|
|
1314
|
+
''
|
|
1315
|
+
);
|
|
1316
|
+
if (version >= 7) return v8;
|
|
1317
|
+
return v8.replace(' --driver "<decision driver>"', '');
|
|
1281
1318
|
}
|
|
1282
1319
|
|
|
1283
1320
|
function upgradeManagedBlock({
|
|
@@ -2005,6 +2042,596 @@ function runHookReminder(event, argv) {
|
|
|
2005
2042
|
return { changed: true, event, format };
|
|
2006
2043
|
}
|
|
2007
2044
|
|
|
2045
|
+
function gitCapture(args, cwd = process.cwd()) {
|
|
2046
|
+
try {
|
|
2047
|
+
return execFileSync('git', args, {
|
|
2048
|
+
cwd,
|
|
2049
|
+
encoding: 'utf8',
|
|
2050
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2051
|
+
}).trim();
|
|
2052
|
+
} catch {
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
function isGitWorkTree(cwd = process.cwd()) {
|
|
2058
|
+
return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function gitOtherHead(cwd = process.cwd()) {
|
|
2062
|
+
return (
|
|
2063
|
+
gitCapture(['rev-parse', '--verify', 'MERGE_HEAD'], cwd) ||
|
|
2064
|
+
gitCapture(['rev-parse', '--verify', 'REBASE_HEAD'], cwd) ||
|
|
2065
|
+
gitCapture(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], cwd)
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function gitMergeBase(cwd = process.cwd()) {
|
|
2070
|
+
const other = gitOtherHead(cwd);
|
|
2071
|
+
if (!other) return null;
|
|
2072
|
+
return gitCapture(['merge-base', 'HEAD', other], cwd);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function gitDecisionIds(treeish, cwd = process.cwd()) {
|
|
2076
|
+
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
|
|
2077
|
+
if (!out) return new Set();
|
|
2078
|
+
const ids = new Set();
|
|
2079
|
+
for (const file of out.split('\n')) {
|
|
2080
|
+
const match = path.basename(file).match(/^(\d{4,})-.*\.md$/);
|
|
2081
|
+
if (match) ids.add(normalizeDecisionId(match[1]));
|
|
2082
|
+
}
|
|
2083
|
+
return ids;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function gitDecisionEntries(treeish, cwd = process.cwd()) {
|
|
2087
|
+
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
|
|
2088
|
+
if (!out) return [];
|
|
2089
|
+
const entries = [];
|
|
2090
|
+
for (const file of out.split('\n')) {
|
|
2091
|
+
const name = path.basename(file);
|
|
2092
|
+
const match = name.match(/^(\d{4,})-.*\.md$/);
|
|
2093
|
+
if (!match) continue;
|
|
2094
|
+
const content = gitCapture(['show', `${treeish}:${file}`], cwd);
|
|
2095
|
+
if (content === null) continue;
|
|
2096
|
+
entries.push({
|
|
2097
|
+
id: normalizeDecisionId(match[1]),
|
|
2098
|
+
file: name,
|
|
2099
|
+
path: file,
|
|
2100
|
+
content,
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
return entries;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function canonicalEvent(event) {
|
|
2107
|
+
return Object.fromEntries(
|
|
2108
|
+
Object.entries(event)
|
|
2109
|
+
.filter(([, value]) => value !== undefined)
|
|
2110
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function eventsEqual(left, right) {
|
|
2115
|
+
return JSON.stringify(canonicalEvent(left)) === JSON.stringify(canonicalEvent(right));
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function commonPrefixLength(ours, theirs) {
|
|
2119
|
+
let index = 0;
|
|
2120
|
+
while (
|
|
2121
|
+
index < ours.length &&
|
|
2122
|
+
index < theirs.length &&
|
|
2123
|
+
eventsEqual(ours[index].event, theirs[index].event)
|
|
2124
|
+
) {
|
|
2125
|
+
index += 1;
|
|
2126
|
+
}
|
|
2127
|
+
return index;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function recordsHavePrefix(records, prefix) {
|
|
2131
|
+
if (prefix.length > records.length) return false;
|
|
2132
|
+
return prefix.every((record, index) => eventsEqual(record.event, records[index].event));
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function parseConflictContent(content) {
|
|
2136
|
+
if (!/^<<<<<<< /m.test(content)) return null;
|
|
2137
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
2138
|
+
const ours = [];
|
|
2139
|
+
const theirs = [];
|
|
2140
|
+
let mode = 'base';
|
|
2141
|
+
for (const line of content.split(/\r?\n/)) {
|
|
2142
|
+
if (line.startsWith('<<<<<<< ')) {
|
|
2143
|
+
mode = 'ours';
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
if (line.startsWith('||||||| ')) {
|
|
2147
|
+
mode = 'ancestor';
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
if (line.startsWith('=======')) {
|
|
2151
|
+
mode = 'theirs';
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
if (line.startsWith('>>>>>>> ')) {
|
|
2155
|
+
mode = 'base';
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
if (mode === 'base') {
|
|
2159
|
+
ours.push(line);
|
|
2160
|
+
theirs.push(line);
|
|
2161
|
+
} else if (mode === 'ours') {
|
|
2162
|
+
ours.push(line);
|
|
2163
|
+
} else if (mode === 'theirs') {
|
|
2164
|
+
theirs.push(line);
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return { oursText: ours.join(eol), theirsText: theirs.join(eol) };
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function collectDecisionIdsFromEvents(events) {
|
|
2171
|
+
const ids = new Set();
|
|
2172
|
+
for (const event of events) {
|
|
2173
|
+
if (Array.isArray(event.decisions)) {
|
|
2174
|
+
for (const id of event.decisions) ids.add(normalizeDecisionId(id));
|
|
2175
|
+
}
|
|
2176
|
+
if (event.decisionId) ids.add(normalizeDecisionId(event.decisionId));
|
|
2177
|
+
}
|
|
2178
|
+
return ids;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
function nextDecisionIdFromIds(ids) {
|
|
2182
|
+
if (ids.size === 0) return '0001';
|
|
2183
|
+
let max = 0n;
|
|
2184
|
+
for (const id of ids) {
|
|
2185
|
+
const value = BigInt(id);
|
|
2186
|
+
if (value > max) max = value;
|
|
2187
|
+
}
|
|
2188
|
+
return String(max + 1n).padStart(4, '0');
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function decisionSlugFromFile(file) {
|
|
2192
|
+
return file.replace(/^\d+-/, '').replace(/\.md$/, '');
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function rewriteDecisionId(content, newId) {
|
|
2196
|
+
return content.replace(/^# [0-9]+\. /, `# ${String(BigInt(newId))}. `);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
function splitDuplicateDecisions(entries) {
|
|
2200
|
+
const ours = [];
|
|
2201
|
+
const theirs = [];
|
|
2202
|
+
const seen = new Set();
|
|
2203
|
+
for (const entry of entries) {
|
|
2204
|
+
if (seen.has(entry.id)) theirs.push(entry);
|
|
2205
|
+
else {
|
|
2206
|
+
seen.add(entry.id);
|
|
2207
|
+
ours.push(entry);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
return { ours, theirs };
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function planDecisionAbsorb({ oursEntries, theirsEntries, baseIds }) {
|
|
2214
|
+
const mappings = [];
|
|
2215
|
+
const copies = [];
|
|
2216
|
+
const decisionMap = new Map();
|
|
2217
|
+
const usedIds = new Set(oursEntries.map((entry) => entry.id));
|
|
2218
|
+
const oursById = new Map();
|
|
2219
|
+
for (const entry of oursEntries) {
|
|
2220
|
+
if (!oursById.has(entry.id)) oursById.set(entry.id, entry);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
for (const entry of theirsEntries) {
|
|
2224
|
+
const ours = oursById.get(entry.id);
|
|
2225
|
+
const theirsContent = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
|
|
2226
|
+
if (!ours) {
|
|
2227
|
+
copies.push({
|
|
2228
|
+
fromFile: entry.file,
|
|
2229
|
+
toFile: entry.file,
|
|
2230
|
+
content: theirsContent,
|
|
2231
|
+
removeFile: null,
|
|
2232
|
+
});
|
|
2233
|
+
usedIds.add(entry.id);
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
2236
|
+
const oursContent = ours.content !== undefined ? ours.content : fs.readFileSync(ours.path, 'utf8');
|
|
2237
|
+
if (contentHash(oursContent) === contentHash(theirsContent)) continue;
|
|
2238
|
+
if (baseIds.has(entry.id)) {
|
|
2239
|
+
fail(
|
|
2240
|
+
`decision ${entry.id} was edited on both sides; resolve ${ours.file} manually before absorbing`
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
const newId = nextDecisionIdFromIds(usedIds);
|
|
2244
|
+
usedIds.add(newId);
|
|
2245
|
+
decisionMap.set(entry.id, newId);
|
|
2246
|
+
mappings.push({ kind: 'decision', from: entry.id, to: newId });
|
|
2247
|
+
copies.push({
|
|
2248
|
+
fromFile: entry.file,
|
|
2249
|
+
toFile: `${newId}-${decisionSlugFromFile(entry.file)}.md`,
|
|
2250
|
+
content: rewriteDecisionId(theirsContent, newId),
|
|
2251
|
+
removeFile: entry.file !== ours.file ? entry.file : null,
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
return { decisionMap, mappings, copies };
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function remapEvent(event, intentMap, decisionMap) {
|
|
2258
|
+
const next = { ...event };
|
|
2259
|
+
if (intentMap.has(event.id)) next.id = intentMap.get(event.id);
|
|
2260
|
+
if (Array.isArray(next.decisions) && next.decisions.length > 0) {
|
|
2261
|
+
next.decisions = next.decisions.map((id) => {
|
|
2262
|
+
const normalized = normalizeDecisionId(id);
|
|
2263
|
+
return decisionMap.get(normalized) || id;
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
if (next.decisionId) {
|
|
2267
|
+
const normalized = normalizeDecisionId(next.decisionId);
|
|
2268
|
+
if (decisionMap.has(normalized)) next.decisionId = decisionMap.get(normalized);
|
|
2269
|
+
}
|
|
2270
|
+
return next;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
|
|
2274
|
+
const intentMap = new Map();
|
|
2275
|
+
const mappings = [];
|
|
2276
|
+
const used = [...oursUsedEvents];
|
|
2277
|
+
const records = theirsNew.map((record) => {
|
|
2278
|
+
let event = record.event;
|
|
2279
|
+
if (event.type === 'begin' && used.some((item) => item.type === 'begin' && item.id === event.id)) {
|
|
2280
|
+
const { date } = parseIntentId(event.id);
|
|
2281
|
+
const newId = nextIdForDate(date, used);
|
|
2282
|
+
intentMap.set(event.id, newId);
|
|
2283
|
+
mappings.push({ kind: 'intent', from: event.id, to: newId });
|
|
2284
|
+
}
|
|
2285
|
+
event = remapEvent(event, intentMap, decisionMap);
|
|
2286
|
+
used.push(event);
|
|
2287
|
+
return { event };
|
|
2288
|
+
});
|
|
2289
|
+
return { records, mappings };
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function repairDuplicateIntentRecords(records, decisionMap) {
|
|
2293
|
+
const seenBegins = new Set();
|
|
2294
|
+
const intentMap = new Map();
|
|
2295
|
+
const used = [];
|
|
2296
|
+
const mappings = [];
|
|
2297
|
+
const result = [];
|
|
2298
|
+
for (const record of records) {
|
|
2299
|
+
let event = record.event;
|
|
2300
|
+
if (event.type === 'begin' && seenBegins.has(event.id)) {
|
|
2301
|
+
const { date } = parseIntentId(event.id);
|
|
2302
|
+
const newId = nextIdForDate(date, used);
|
|
2303
|
+
intentMap.set(event.id, newId);
|
|
2304
|
+
mappings.push({ kind: 'intent', from: event.id, to: newId });
|
|
2305
|
+
} else if (event.type === 'begin') {
|
|
2306
|
+
seenBegins.add(event.id);
|
|
2307
|
+
}
|
|
2308
|
+
const remapped = remapEvent(event, intentMap, decisionMap);
|
|
2309
|
+
const changed = remapped !== event && JSON.stringify(remapped) !== JSON.stringify(event);
|
|
2310
|
+
result.push(changed ? { event: remapped } : record);
|
|
2311
|
+
used.push(result.at(-1).event);
|
|
2312
|
+
}
|
|
2313
|
+
return { records: result, mappings };
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function serializeRecords(records) {
|
|
2317
|
+
if (records.length === 0) return '';
|
|
2318
|
+
return `${records.map((record) => record.raw || JSON.stringify(record.event)).join('\n')}\n`;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function writeJsonl(file, records) {
|
|
2322
|
+
ensureDirectoryDurable(path.dirname(file));
|
|
2323
|
+
atomicWriteFile(file, serializeRecords(records));
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function applyDecisionCopies(copies, dryRun) {
|
|
2327
|
+
if (dryRun || copies.length === 0) return;
|
|
2328
|
+
ensureDirectoryDurable(decisionDir());
|
|
2329
|
+
for (const item of copies) {
|
|
2330
|
+
atomicWriteFile(path.join(decisionDir(), item.toFile), item.content);
|
|
2331
|
+
if (item.removeFile && item.removeFile !== item.toFile) {
|
|
2332
|
+
const stale = path.join(decisionDir(), item.removeFile);
|
|
2333
|
+
if (fs.existsSync(stale)) {
|
|
2334
|
+
fs.unlinkSync(stale);
|
|
2335
|
+
fsyncDirectory(decisionDir());
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
function countAbsorbedIntents(records) {
|
|
2342
|
+
return records.filter((record) => record.event.type === 'begin').length;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function printAbsorbReport({ mappings, abandoned, intentCount }) {
|
|
2346
|
+
const remappedIntents = mappings.filter((mapping) => mapping.kind === 'intent').length;
|
|
2347
|
+
const remappedDecisions = mappings.filter((mapping) => mapping.kind === 'decision').length;
|
|
2348
|
+
printLine(
|
|
2349
|
+
`absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
|
|
2350
|
+
);
|
|
2351
|
+
for (const mapping of mappings) {
|
|
2352
|
+
if (mapping.kind === 'intent') printLine(`${mapping.from} (theirs) -> ${mapping.to}`);
|
|
2353
|
+
else printLine(`decision ${mapping.from} (theirs) -> ${mapping.to}`);
|
|
2354
|
+
}
|
|
2355
|
+
if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function abandonOpenIntent(records, targetId, side) {
|
|
2359
|
+
records.push({
|
|
2360
|
+
event: {
|
|
2361
|
+
schemaVersion: EVENT_SCHEMA_VERSION,
|
|
2362
|
+
type: 'end',
|
|
2363
|
+
id: targetId,
|
|
2364
|
+
ts: new Date().toISOString(),
|
|
2365
|
+
status: 'abandoned',
|
|
2366
|
+
note: `abandoned during absorb (--abandon-${side})`,
|
|
2367
|
+
verifyResult: null,
|
|
2368
|
+
},
|
|
2369
|
+
});
|
|
2370
|
+
return targetId;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function resolveOpenIntents(result, oursRecords, theirsRecords, abandon, { allowConflict = false } = {}) {
|
|
2374
|
+
const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
|
|
2375
|
+
const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
|
|
2376
|
+
try {
|
|
2377
|
+
openIntent(fold(result.map((record) => record.event)));
|
|
2378
|
+
return { abandoned: null, conflict: false };
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
|
|
2381
|
+
throw err;
|
|
2382
|
+
}
|
|
2383
|
+
if (abandon === 'theirs' && theirsOpen) {
|
|
2384
|
+
return { abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'), conflict: false };
|
|
2385
|
+
}
|
|
2386
|
+
if (abandon === 'ours' && oursOpen) {
|
|
2387
|
+
return { abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'), conflict: false };
|
|
2388
|
+
}
|
|
2389
|
+
if (allowConflict) return { abandoned: null, conflict: true };
|
|
2390
|
+
fail(`${err.message}; re-run with --abandon-theirs or --abandon-ours`);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function mergeRecordStreams(ours, theirs, baseRecords) {
|
|
2395
|
+
const inferred = ours.slice(0, commonPrefixLength(ours, theirs));
|
|
2396
|
+
const prefix = baseRecords && baseRecords.length > 0 ? baseRecords : inferred;
|
|
2397
|
+
if (!recordsHavePrefix(ours, prefix) || !recordsHavePrefix(theirs, prefix)) {
|
|
2398
|
+
fail('cannot absorb: shared history does not match the base log');
|
|
2399
|
+
}
|
|
2400
|
+
return {
|
|
2401
|
+
base: prefix,
|
|
2402
|
+
oursNew: ours.slice(prefix.length),
|
|
2403
|
+
theirsNew: theirs.slice(prefix.length),
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function GITATTRIBUTES_MERGE_LINE() {
|
|
2408
|
+
return '.intent-log/events.jsonl merge=driftseal';
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function ensureGitAttributes() {
|
|
2412
|
+
const target = path.join(process.cwd(), '.gitattributes');
|
|
2413
|
+
const line = GITATTRIBUTES_MERGE_LINE();
|
|
2414
|
+
const existed = fs.existsSync(target);
|
|
2415
|
+
const current = existed ? fs.readFileSync(target, 'utf8') : '';
|
|
2416
|
+
const eol = current.includes('\r\n') ? '\r\n' : '\n';
|
|
2417
|
+
const lines = current.split(/\r?\n/);
|
|
2418
|
+
if (lines.some((entry) => entry.trim() === line)) return { changed: false, target };
|
|
2419
|
+
const prefix = current.length === 0 || current.endsWith('\n') || current.endsWith('\r\n') ? '' : eol;
|
|
2420
|
+
const next = `${current}${prefix}${line}${eol}`;
|
|
2421
|
+
atomicWriteFile(target, next);
|
|
2422
|
+
return { changed: true, target };
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function ensureGitMergeDriver() {
|
|
2426
|
+
if (!isGitWorkTree()) return { changed: false, configured: false };
|
|
2427
|
+
const name = gitCapture(['config', '--local', '--get', 'merge.driftseal.name']);
|
|
2428
|
+
const driver = gitCapture(['config', '--local', '--get', 'merge.driftseal.driver']);
|
|
2429
|
+
const expectedName = 'DriftSeal intent log merge';
|
|
2430
|
+
const expectedDriver = 'driftseal absorb --git %O %A %B';
|
|
2431
|
+
if (name === expectedName && driver === expectedDriver) {
|
|
2432
|
+
return { changed: false, configured: true };
|
|
2433
|
+
}
|
|
2434
|
+
execFileSync('git', ['config', '--local', 'merge.driftseal.name', expectedName], {
|
|
2435
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2436
|
+
});
|
|
2437
|
+
execFileSync('git', ['config', '--local', 'merge.driftseal.driver', expectedDriver], {
|
|
2438
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2439
|
+
});
|
|
2440
|
+
return { changed: true, configured: true };
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function absorbUsage() {
|
|
2444
|
+
return (
|
|
2445
|
+
'usage: driftseal absorb [other-events.jsonl] [--decisions <dir>] [--abandon-theirs | --abandon-ours] [--dry-run]\n' +
|
|
2446
|
+
' or: driftseal absorb --git <base> <ours> <theirs>'
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false } = {}) {
|
|
2451
|
+
if (!fs.existsSync(file)) {
|
|
2452
|
+
if (allowMissing) return { records: [], conflict: false };
|
|
2453
|
+
fail(`intent log not found: ${file}`);
|
|
2454
|
+
}
|
|
2455
|
+
let content = fs.readFileSync(file, 'utf8');
|
|
2456
|
+
if (repairTail && !/^<<<<<<< /m.test(content)) {
|
|
2457
|
+
readEvents({ file, repairTail: true });
|
|
2458
|
+
content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
2459
|
+
}
|
|
2460
|
+
const conflict = parseConflictContent(content);
|
|
2461
|
+
if (conflict) {
|
|
2462
|
+
return {
|
|
2463
|
+
ours: parseJsonlRecords(conflict.oursText, `${label} ours`),
|
|
2464
|
+
theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`),
|
|
2465
|
+
conflict: true,
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
return { records: parseJsonlRecords(content, label), conflict: false };
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
function finishAbsorb({
|
|
2472
|
+
result,
|
|
2473
|
+
oursRecords,
|
|
2474
|
+
theirsRecords,
|
|
2475
|
+
mappings,
|
|
2476
|
+
copies,
|
|
2477
|
+
abandon,
|
|
2478
|
+
dryRun,
|
|
2479
|
+
outputFile,
|
|
2480
|
+
intentCount,
|
|
2481
|
+
allowConflict = false,
|
|
2482
|
+
}) {
|
|
2483
|
+
const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
|
|
2484
|
+
allowConflict,
|
|
2485
|
+
});
|
|
2486
|
+
fold(result.map((record) => record.event));
|
|
2487
|
+
if (!conflict) openIntent(fold(result.map((record) => record.event)));
|
|
2488
|
+
if (!dryRun) {
|
|
2489
|
+
writeJsonl(outputFile, result);
|
|
2490
|
+
applyDecisionCopies(copies, dryRun);
|
|
2491
|
+
}
|
|
2492
|
+
if (intentCount === 0 && mappings.length === 0 && copies.length === 0 && !abandoned) {
|
|
2493
|
+
printLine('nothing to absorb');
|
|
2494
|
+
} else {
|
|
2495
|
+
printAbsorbReport({
|
|
2496
|
+
mappings,
|
|
2497
|
+
abandoned,
|
|
2498
|
+
intentCount,
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
if (conflict) {
|
|
2502
|
+
printLine('multiple intents remain in progress; re-run with --abandon-theirs or --abandon-ours');
|
|
2503
|
+
}
|
|
2504
|
+
return {
|
|
2505
|
+
mappings,
|
|
2506
|
+
abandoned,
|
|
2507
|
+
copies: copies.map((item) => item.toFile),
|
|
2508
|
+
outputFile,
|
|
2509
|
+
exitCode: conflict ? 1 : 0,
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function absorbFromStreams(ours, theirs, baseRecords, options) {
|
|
2514
|
+
const streams = mergeRecordStreams(ours, theirs, baseRecords);
|
|
2515
|
+
const decisionPlan = planDecisionAbsorb({
|
|
2516
|
+
oursEntries: options.oursDecisionEntries || [],
|
|
2517
|
+
theirsEntries: options.theirsDecisionEntries || [],
|
|
2518
|
+
baseIds: options.baseDecisionIds || new Set(),
|
|
2519
|
+
});
|
|
2520
|
+
const remapped = remapTheirsRecords(
|
|
2521
|
+
streams.theirsNew,
|
|
2522
|
+
[...streams.base, ...streams.oursNew].map((record) => record.event),
|
|
2523
|
+
decisionPlan.decisionMap
|
|
2524
|
+
);
|
|
2525
|
+
const result = [...streams.base, ...streams.oursNew, ...remapped.records];
|
|
2526
|
+
return finishAbsorb({
|
|
2527
|
+
result,
|
|
2528
|
+
oursRecords: [...streams.base, ...streams.oursNew],
|
|
2529
|
+
theirsRecords: [...streams.base, ...remapped.records],
|
|
2530
|
+
mappings: [...remapped.mappings, ...decisionPlan.mappings],
|
|
2531
|
+
copies: decisionPlan.copies,
|
|
2532
|
+
abandon: options.abandon,
|
|
2533
|
+
dryRun: options.dryRun,
|
|
2534
|
+
outputFile: options.outputFile,
|
|
2535
|
+
allowConflict: options.allowConflict,
|
|
2536
|
+
intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
2541
|
+
const oursFile = logFile();
|
|
2542
|
+
const loaded = loadAbsorbSide(oursFile, oursFile, { repairTail: true, allowMissing: !otherFile });
|
|
2543
|
+
if (loaded.conflict) {
|
|
2544
|
+
const baseIds = collectDecisionIdsFromEvents(
|
|
2545
|
+
loaded.ours.slice(0, commonPrefixLength(loaded.ours, loaded.theirs)).map((record) => record.event)
|
|
2546
|
+
);
|
|
2547
|
+
const gitBase = gitMergeBase();
|
|
2548
|
+
const baseDecisionIds = gitBase ? gitDecisionIds(gitBase) : baseIds;
|
|
2549
|
+
return absorbFromStreams(loaded.ours, loaded.theirs, null, {
|
|
2550
|
+
abandon,
|
|
2551
|
+
dryRun,
|
|
2552
|
+
outputFile: oursFile,
|
|
2553
|
+
oursDecisionEntries: listDecisionEntries(decisionDir(), { allowDuplicates: true }),
|
|
2554
|
+
theirsDecisionEntries: otherDecisions
|
|
2555
|
+
? listDecisionEntries(otherDecisions)
|
|
2556
|
+
: splitDuplicateDecisions(listDecisionEntries(decisionDir(), { allowDuplicates: true })).theirs,
|
|
2557
|
+
baseDecisionIds,
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
if (!otherFile) {
|
|
2562
|
+
const oursEntries = listDecisionEntries(decisionDir(), { allowDuplicates: true });
|
|
2563
|
+
const split = splitDuplicateDecisions(oursEntries);
|
|
2564
|
+
const repaired = repairDuplicateIntentRecords(loaded.records, new Map());
|
|
2565
|
+
const gitBase = gitMergeBase();
|
|
2566
|
+
const decisionPlan = planDecisionAbsorb({
|
|
2567
|
+
oursEntries: split.ours,
|
|
2568
|
+
theirsEntries: split.theirs,
|
|
2569
|
+
baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
|
|
2570
|
+
});
|
|
2571
|
+
const withDecisions = repairDuplicateIntentRecords(repaired.records, decisionPlan.decisionMap);
|
|
2572
|
+
const result = withDecisions.records;
|
|
2573
|
+
const mappings = [...repaired.mappings, ...withDecisions.mappings, ...decisionPlan.mappings];
|
|
2574
|
+
const remappedIds = new Set(
|
|
2575
|
+
mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
|
|
2576
|
+
);
|
|
2577
|
+
const oursRecords = result.filter((record) => !remappedIds.has(record.event.id));
|
|
2578
|
+
return finishAbsorb({
|
|
2579
|
+
result,
|
|
2580
|
+
oursRecords: oursRecords.length > 0 ? oursRecords : result,
|
|
2581
|
+
theirsRecords: result,
|
|
2582
|
+
mappings,
|
|
2583
|
+
copies: decisionPlan.copies,
|
|
2584
|
+
abandon,
|
|
2585
|
+
dryRun,
|
|
2586
|
+
outputFile: oursFile,
|
|
2587
|
+
intentCount: remappedIds.size,
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
const theirs = loadAbsorbSide(otherFile, otherFile);
|
|
2592
|
+
if (theirs.conflict) fail(`incoming log still contains conflict markers: ${otherFile}`);
|
|
2593
|
+
const otherRoot = path.resolve(path.dirname(otherFile), '..');
|
|
2594
|
+
const otherHead = isGitWorkTree(otherRoot) ? gitCapture(['rev-parse', 'HEAD'], otherRoot) : null;
|
|
2595
|
+
const gitBase = otherHead ? gitCapture(['merge-base', 'HEAD', otherHead]) : gitMergeBase();
|
|
2596
|
+
const baseDecisionIds = gitBase
|
|
2597
|
+
? gitDecisionIds(gitBase)
|
|
2598
|
+
: collectDecisionIdsFromEvents(
|
|
2599
|
+
loaded.records
|
|
2600
|
+
.slice(0, commonPrefixLength(loaded.records, theirs.records))
|
|
2601
|
+
.map((record) => record.event)
|
|
2602
|
+
);
|
|
2603
|
+
return absorbFromStreams(loaded.records, theirs.records, null, {
|
|
2604
|
+
abandon,
|
|
2605
|
+
dryRun,
|
|
2606
|
+
outputFile: oursFile,
|
|
2607
|
+
oursDecisionEntries: listDecisionEntries(decisionDir()),
|
|
2608
|
+
theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
|
|
2609
|
+
baseDecisionIds,
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
|
|
2614
|
+
const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true });
|
|
2615
|
+
const ours = loadAbsorbSide(oursFile, oursFile);
|
|
2616
|
+
const theirs = loadAbsorbSide(theirsFile, theirsFile);
|
|
2617
|
+
if (base.conflict || ours.conflict || theirs.conflict) {
|
|
2618
|
+
fail('git merge driver received a log that still contains conflict markers');
|
|
2619
|
+
}
|
|
2620
|
+
const otherHead = gitOtherHead();
|
|
2621
|
+
const mergeBase = gitMergeBase();
|
|
2622
|
+
return absorbFromStreams(ours.records, theirs.records, base.records, {
|
|
2623
|
+
abandon,
|
|
2624
|
+
dryRun,
|
|
2625
|
+
outputFile: oursFile,
|
|
2626
|
+
allowConflict: !abandon,
|
|
2627
|
+
oursDecisionEntries: listDecisionEntries(decisionDir(), { allowDuplicates: true }),
|
|
2628
|
+
theirsDecisionEntries: otherHead ? gitDecisionEntries(otherHead) : [],
|
|
2629
|
+
baseDecisionIds: mergeBase
|
|
2630
|
+
? gitDecisionIds(mergeBase)
|
|
2631
|
+
: collectDecisionIdsFromEvents(base.records.map((record) => record.event)),
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2008
2635
|
const commands = {
|
|
2009
2636
|
begin(argv) {
|
|
2010
2637
|
const { positionals, flags } = parseArgs(argv, {
|
|
@@ -2425,6 +3052,29 @@ const commands = {
|
|
|
2425
3052
|
fail(hookUsage());
|
|
2426
3053
|
},
|
|
2427
3054
|
|
|
3055
|
+
absorb(argv) {
|
|
3056
|
+
const { positionals, flags } = parseArgs(argv, {
|
|
3057
|
+
git: 'boolean',
|
|
3058
|
+
decisions: 'single',
|
|
3059
|
+
'abandon-theirs': 'boolean',
|
|
3060
|
+
'abandon-ours': 'boolean',
|
|
3061
|
+
'dry-run': 'boolean',
|
|
3062
|
+
});
|
|
3063
|
+
if (flags['abandon-theirs'] && flags['abandon-ours']) {
|
|
3064
|
+
fail('cannot combine --abandon-theirs and --abandon-ours');
|
|
3065
|
+
}
|
|
3066
|
+
const abandon = flags['abandon-theirs'] ? 'theirs' : flags['abandon-ours'] ? 'ours' : null;
|
|
3067
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
3068
|
+
|
|
3069
|
+
if (flags.git) {
|
|
3070
|
+
if (positionals.length !== 3) fail(absorbUsage());
|
|
3071
|
+
if (flags.decisions) fail('--decisions cannot be combined with --git');
|
|
3072
|
+
return absorbGit(positionals[0], positionals[1], positionals[2], { abandon, dryRun });
|
|
3073
|
+
}
|
|
3074
|
+
if (positionals.length > 1) fail(absorbUsage());
|
|
3075
|
+
return absorbLogs(positionals[0], flags.decisions, { abandon, dryRun });
|
|
3076
|
+
},
|
|
3077
|
+
|
|
2428
3078
|
init(argv) {
|
|
2429
3079
|
const { positionals } = parseArgs(argv, {});
|
|
2430
3080
|
if (positionals.length > 0) fail('usage: driftseal init');
|
|
@@ -2448,6 +3098,7 @@ const commands = {
|
|
|
2448
3098
|
protocolEol(previousIntentProtocolBlock(5), eol),
|
|
2449
3099
|
protocolEol(previousIntentProtocolBlock(6), eol),
|
|
2450
3100
|
protocolEol(previousIntentProtocolBlock(7), eol),
|
|
3101
|
+
protocolEol(previousIntentProtocolBlock(8), eol),
|
|
2451
3102
|
],
|
|
2452
3103
|
knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
|
|
2453
3104
|
});
|
|
@@ -2465,6 +3116,7 @@ const commands = {
|
|
|
2465
3116
|
protocolEol(previousDecisionProtocolBlock(5), eol),
|
|
2466
3117
|
protocolEol(previousDecisionProtocolBlock(6), eol),
|
|
2467
3118
|
protocolEol(previousDecisionProtocolBlock(7), eol),
|
|
3119
|
+
protocolEol(previousDecisionProtocolBlock(8), eol),
|
|
2468
3120
|
],
|
|
2469
3121
|
knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
|
|
2470
3122
|
});
|
|
@@ -2484,12 +3136,28 @@ const commands = {
|
|
|
2484
3136
|
updated += separator + additions.join(eol + eol) + eol;
|
|
2485
3137
|
}
|
|
2486
3138
|
|
|
2487
|
-
|
|
3139
|
+
const attributes = ensureGitAttributes();
|
|
3140
|
+
let driver = { changed: false, configured: false };
|
|
3141
|
+
try {
|
|
3142
|
+
driver = ensureGitMergeDriver();
|
|
3143
|
+
} catch (err) {
|
|
3144
|
+
printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
if (updated === current && !attributes.changed && !driver.changed) {
|
|
2488
3148
|
printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
|
|
2489
3149
|
return { changed: false, target };
|
|
2490
3150
|
}
|
|
2491
|
-
|
|
2492
|
-
|
|
3151
|
+
if (updated !== current) {
|
|
3152
|
+
atomicWriteFile(target, updated);
|
|
3153
|
+
printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
|
|
3154
|
+
}
|
|
3155
|
+
if (attributes.changed) {
|
|
3156
|
+
printLine(`Configured git merge attribute: ${attributes.target}`);
|
|
3157
|
+
}
|
|
3158
|
+
if (driver.changed) {
|
|
3159
|
+
printLine('Configured local git merge driver for DriftSeal intent logs');
|
|
3160
|
+
}
|
|
2493
3161
|
return { changed: true, target };
|
|
2494
3162
|
},
|
|
2495
3163
|
|
|
@@ -2507,6 +3175,11 @@ usage:
|
|
|
2507
3175
|
hide meaningless closed records without deleting them
|
|
2508
3176
|
driftseal unreclaim <id> --reason "<why>"
|
|
2509
3177
|
restore a reclaimed record to the visible log
|
|
3178
|
+
driftseal absorb [other-events.jsonl] [--decisions <dir>]
|
|
3179
|
+
[--abandon-theirs | --abandon-ours] [--dry-run]
|
|
3180
|
+
merge another intent log, remapping colliding ids
|
|
3181
|
+
driftseal absorb --git <base> <ours> <theirs>
|
|
3182
|
+
git merge driver for .intent-log/events.jsonl
|
|
2510
3183
|
driftseal decision add "<title>" --context "..." --outcome "..." [options]
|
|
2511
3184
|
driftseal decision update <id> [--status STATUS] --note "..."
|
|
2512
3185
|
reconcile a linked decision in the open intent
|
|
@@ -2524,7 +3197,7 @@ usage:
|
|
|
2524
3197
|
targets: kimi-code (global only), claude-code, codex (prompt only)
|
|
2525
3198
|
driftseal hook prompt|stop [--format plain|claude-code]
|
|
2526
3199
|
emit the reminder a lifecycle hook injects; never blocks
|
|
2527
|
-
driftseal init inject
|
|
3200
|
+
driftseal init inject protocols into ./AGENTS.md and configure the git merge driver
|
|
2528
3201
|
driftseal --version | -V print the installed DriftSeal version
|
|
2529
3202
|
driftseal help
|
|
2530
3203
|
|
|
@@ -2559,6 +3232,7 @@ function mutationResources(cmd, argv) {
|
|
|
2559
3232
|
if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
|
|
2560
3233
|
if (cmd === 'init') return [process.cwd()];
|
|
2561
3234
|
if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
|
|
3235
|
+
if (cmd === 'absorb') return [logDir(), decisionDir()];
|
|
2562
3236
|
if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
|
|
2563
3237
|
return [logDir()];
|
|
2564
3238
|
}
|
|
@@ -2580,13 +3254,14 @@ function dispatch(argv) {
|
|
|
2580
3254
|
const fn = commands[cmd];
|
|
2581
3255
|
if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
|
|
2582
3256
|
const mutates =
|
|
2583
|
-
['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim'].includes(cmd) ||
|
|
3257
|
+
['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
|
|
2584
3258
|
(cmd === 'hook' && rest[0] === 'install') ||
|
|
2585
3259
|
(cmd === 'decision' && ['add', 'update'].includes(rest[0]));
|
|
2586
3260
|
const readsIntentLog = ['status', 'log'].includes(cmd);
|
|
2587
3261
|
if (mutates || readsIntentLog) {
|
|
2588
3262
|
const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
|
|
2589
|
-
|
|
3263
|
+
const data = withMutationLocks(resources, () => fn(rest));
|
|
3264
|
+
return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
|
|
2590
3265
|
}
|
|
2591
3266
|
return { data: fn(rest), exitCode: 0 };
|
|
2592
3267
|
}
|
package/package.json
CHANGED
|
@@ -37,5 +37,13 @@ requires. For command syntax, run:
|
|
|
37
37
|
driftseal help
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## After a merge
|
|
41
|
+
|
|
42
|
+
If `status` or `log` fails with a duplicate id, or the intent log has conflict
|
|
43
|
+
markers, run `driftseal absorb` instead of editing `.intent-log/events.jsonl`.
|
|
44
|
+
When both sides still have an open intent, add `--abandon-theirs` or
|
|
45
|
+
`--abandon-ours`. `driftseal init` also configures the local git merge driver;
|
|
46
|
+
clones need `init` again for that driver.
|
|
47
|
+
|
|
40
48
|
Do not treat this skill, MCP descriptions, or lifecycle-hook reminders as
|
|
41
49
|
additional policy. If they conflict with `AGENTS.md`, follow `AGENTS.md`.
|