driftseal 1.0.0 → 1.1.1
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 +39 -5
- package/README.zh-CN.md +28 -5
- package/bin/driftseal.js +918 -29
- 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
|
|
|
@@ -177,7 +179,7 @@ Cursor have no supported hook surface for this yet.
|
|
|
177
179
|
|
|
178
180
|
## A work round
|
|
179
181
|
|
|
180
|
-
Declare the round before
|
|
182
|
+
Declare the round before making non-Git changes:
|
|
181
183
|
|
|
182
184
|
```sh
|
|
183
185
|
driftseal begin "add rate limiting to /api/login" \
|
|
@@ -195,7 +197,12 @@ driftseal end \
|
|
|
195
197
|
|
|
196
198
|
If the scope changes, close the current intent as `partial` or `abandoned`, then start a new one. After context loss, use `driftseal status` and `driftseal log --last 3` to re-anchor.
|
|
197
199
|
|
|
198
|
-
|
|
200
|
+
Git operations are entirely outside the intent log because Git maintains their
|
|
201
|
+
history. Inspection, branch and worktree management, staging, commits, merges,
|
|
202
|
+
rebases, cherry-picks, tags, and pushes never need an intent of their own. They
|
|
203
|
+
still require normal authorization and safety checks. Single-step builds and
|
|
204
|
+
checks, such as compiling or running tests, also need no intent. Any non-Git
|
|
205
|
+
content change starts a new work round.
|
|
199
206
|
|
|
200
207
|
## Commands
|
|
201
208
|
|
|
@@ -207,6 +214,8 @@ Single-step commands that only build, check, or record work already done — com
|
|
|
207
214
|
| `driftseal log [-n N] [--all]` | Review intent history (`--all` includes reclaimed records). |
|
|
208
215
|
| `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | Hide meaningless closed records behind append-only markers. |
|
|
209
216
|
| `driftseal unreclaim <id> --reason "..."` | Restore a reclaimed record to the visible log. |
|
|
217
|
+
| `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | Merge another worktree's logs, remapping colliding intent and decision ids. |
|
|
218
|
+
| `driftseal absorb --git <base> <ours> <theirs>` | Git merge driver for `.intent-log/events.jsonl`. |
|
|
210
219
|
| `driftseal decision add "<title>" -c "..." -o "..."` | Write a numbered MADR decision. |
|
|
211
220
|
| `driftseal decision update <id> [-s status] -n "..."` | Reconcile a linked decision in the open intent. |
|
|
212
221
|
| `driftseal decision list [-s status] [--last N \| --count]` | List or count decision records, optionally filtered by status. |
|
|
@@ -215,7 +224,7 @@ Single-step commands that only build, check, or record work already done — com
|
|
|
215
224
|
| `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
225
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
|
|
217
226
|
| `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
|
|
227
|
+
| `driftseal init` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. |
|
|
219
228
|
| `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
|
|
220
229
|
| `driftseal help` | Print CLI usage. |
|
|
221
230
|
|
|
@@ -269,9 +278,34 @@ contents. Status-filtered listing and counting parse all records because status
|
|
|
269
278
|
is stored in each MADR document; DriftSeal does not maintain a stale-prone sidecar
|
|
270
279
|
index.
|
|
271
280
|
|
|
281
|
+
## Merging worktrees
|
|
282
|
+
|
|
283
|
+
Two worktrees allocate intent and decision ids from their local logs, so a
|
|
284
|
+
same-day parallel `begin` or `decision add` can collide when the branches
|
|
285
|
+
merge. `driftseal absorb` rebuilds a valid log by keeping our ids and remapping
|
|
286
|
+
the incoming side, then prints the mapping. A single lineage stays append-only;
|
|
287
|
+
absorb is the one cross-lineage rewrite.
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
driftseal absorb ../other-worktree/.intent-log/events.jsonl \
|
|
291
|
+
--decisions ../other-worktree/.decision-log
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
With no path, `absorb` repairs the current log after a git conflict or a
|
|
295
|
+
concatenated duplicate. If both sides still have an open intent, pass
|
|
296
|
+
`--abandon-theirs` or `--abandon-ours`. Concurrent edits of a decision that
|
|
297
|
+
already existed in the shared base are not auto-merged.
|
|
298
|
+
|
|
299
|
+
`driftseal init` writes that absorb rule into `AGENTS.md`, plus `.gitattributes`
|
|
300
|
+
and the local git merge driver so `events.jsonl` merges through `absorb --git`.
|
|
301
|
+
When decision ids collide, the driver stops the merge before Git can commit an
|
|
302
|
+
ambiguous decision catalog. Run `driftseal absorb`, stage the repaired intent
|
|
303
|
+
and decision logs, then continue the merge. Clones need `init` again because
|
|
304
|
+
the driver lives in local git config.
|
|
305
|
+
|
|
272
306
|
## Storage
|
|
273
307
|
|
|
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.
|
|
308
|
+
- `.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
309
|
- `.decision-log/` contains numbered MADR decision records.
|
|
276
310
|
- Set `DRIFTSEAL_HOME` or `DRIFTSEAL_DECISION_HOME` to store either log outside the current project.
|
|
277
311
|
|
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
|
|
|
@@ -171,7 +172,7 @@ Kimi Code 只在全局 `config.toml` 中记录 hook,因此该 target 必须指
|
|
|
171
172
|
|
|
172
173
|
## 一轮标准工作流
|
|
173
174
|
|
|
174
|
-
|
|
175
|
+
进行非 Git 改动前,先声明这轮工作的目标:
|
|
175
176
|
|
|
176
177
|
```sh
|
|
177
178
|
driftseal begin "add rate limiting to /api/login" \
|
|
@@ -189,7 +190,10 @@ driftseal end \
|
|
|
189
190
|
|
|
190
191
|
如果范围发生变化,先把当前 intent 以 `partial` 或 `abandoned` 关闭,再开启新的 intent。发生 context loss 后,用 `driftseal status` 和 `driftseal log --last 3` 重新锚定当前目标。
|
|
191
192
|
|
|
192
|
-
|
|
193
|
+
Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看状态、管理 branch
|
|
194
|
+
或 worktree、stage、commit、merge、rebase、cherry-pick、tag 和 push 都不需要
|
|
195
|
+
单独开启 intent,但仍须遵守正常的授权与安全要求。编译、跑测试等单步构建或检查
|
|
196
|
+
也不需要 intent;只要要做非 Git 内容改动,就开启新一轮。
|
|
193
197
|
|
|
194
198
|
## 命令速览
|
|
195
199
|
|
|
@@ -201,6 +205,8 @@ driftseal end \
|
|
|
201
205
|
| `driftseal log [-n N] [--all]` | 查看 intent 历史(`--all` 包含已回收的记录)。 |
|
|
202
206
|
| `driftseal reclaim [id ...] --reason "..." [--older-than days] [--force] [--dry-run]` | 用 append-only 标记隐藏已无意义的已关闭记录。 |
|
|
203
207
|
| `driftseal unreclaim <id> --reason "..."` | 把已回收的记录恢复到可见历史中。 |
|
|
208
|
+
| `driftseal absorb [other-events.jsonl] [--decisions dir] [--abandon-theirs \| --abandon-ours] [--dry-run]` | 合并另一条 worktree 的日志,并给撞号的 intent / decision id 重新编号。 |
|
|
209
|
+
| `driftseal absorb --git <base> <ours> <theirs>` | `.intent-log/events.jsonl` 的 git merge driver。 |
|
|
204
210
|
| `driftseal decision add "<title>" -c "..." -o "..."` | 写入编号化的 MADR decision。 |
|
|
205
211
|
| `driftseal decision update <id> [-s status] -n "..."` | 在当前 intent 中 reconcile 已关联的 decision。 |
|
|
206
212
|
| `driftseal decision list [-s status] [--last N \| --count]` | 列出或统计 decision records,也可按 status 筛选。 |
|
|
@@ -209,7 +215,7 @@ driftseal end \
|
|
|
209
215
|
| `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
|
|
210
216
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
|
|
211
217
|
| `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
|
|
212
|
-
| `driftseal init` | 把接入协议写入 `AGENTS.md
|
|
218
|
+
| `driftseal init` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。 |
|
|
213
219
|
| `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
|
|
214
220
|
| `driftseal help` | 查看 CLI 用法。 |
|
|
215
221
|
|
|
@@ -255,9 +261,26 @@ recovery 只处理当前 intent,因此历史冲突不会阻塞之后的 decisi
|
|
|
255
261
|
时仍需解析全部 records,因为 status 保存在各个 MADR 文档中;DriftSeal 不维护容易
|
|
256
262
|
滞后的 sidecar index。
|
|
257
263
|
|
|
264
|
+
## 合并 worktree
|
|
265
|
+
|
|
266
|
+
两条 worktree 各自按本地日志分配 intent / decision id,同一天并行 `begin` 或 `decision add`,分支合并时就会撞号。`driftseal absorb` 保留我方编号、给进来的一侧重编号,并打印对照表。单线 WAL 仍然只追加;absorb 是唯一允许的跨谱系重写。
|
|
267
|
+
|
|
268
|
+
```sh
|
|
269
|
+
driftseal absorb ../other-worktree/.intent-log/events.jsonl \
|
|
270
|
+
--decisions ../other-worktree/.decision-log
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
不带 path 时,`absorb` 修复当前日志里的冲突标记或已经拼在一起的重复 id。两边都还有未关闭 intent 时,必须加上 `--abandon-theirs` 或 `--abandon-ours`。共享 base 里已经存在、又被两边同时改过的 decision,不会自动合并。
|
|
274
|
+
|
|
275
|
+
`driftseal init` 会把这条 absorb 规则写入 `AGENTS.md`,同时写入 `.gitattributes`
|
|
276
|
+
并配置 local git merge driver,让 `events.jsonl` 走 `absorb --git`。decision id
|
|
277
|
+
撞号时,driver 会先中止 merge,避免 Git 提交语义不明确的 decision catalog;运行
|
|
278
|
+
`driftseal absorb`、stage 修复后的 intent / decision logs,再继续 merge。clone
|
|
279
|
+
之后需要再跑一次 `init`,因为 driver 只存在于 local git config。
|
|
280
|
+
|
|
258
281
|
## 数据保存在哪里
|
|
259
282
|
|
|
260
|
-
- `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim
|
|
283
|
+
- `.intent-log/events.jsonl`:append-only intent log。所有读写都必须经过 `driftseal`(CLI 或 MCP)——不要直接读取、修改、移动或删除该文件;需要让无意义的记录退场时使用 `driftseal reclaim`,而不是删除日志行。合并撞号时用 `driftseal absorb`,不要手改这个文件。
|
|
261
284
|
- `.decision-log/`:编号化的 MADR decision records。
|
|
262
285
|
- 设置 `DRIFTSEAL_HOME` 或 `DRIFTSEAL_DECISION_HOME`,即可把对应 log 放到当前项目之外。
|
|
263
286
|
|
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 = 10;
|
|
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;
|
|
@@ -1116,11 +1136,14 @@ default; the companion skill only helps discover and resume the workflow, while
|
|
|
1116
1136
|
MCP and lifecycle hooks are optional adapters.
|
|
1117
1137
|
|
|
1118
1138
|
1. **Write intent first**, before modifying, creating, or deleting files, or
|
|
1119
|
-
making any other change that may need a rollback:
|
|
1139
|
+
making any other non-Git change that may need a rollback:
|
|
1120
1140
|
\`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
|
|
1121
1141
|
Add one \`--decision <id>\` for each existing decision this round may change.
|
|
1122
|
-
|
|
1123
|
-
|
|
1142
|
+
Git operations never need an intent and are not included in the intent log;
|
|
1143
|
+
Git maintains their history. This includes inspection, branch and worktree
|
|
1144
|
+
management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.
|
|
1145
|
+
Single-step commands that only build or check work already done, such as
|
|
1146
|
+
compiling or running tests, also need no intent.
|
|
1124
1147
|
2. **Execute only the intent.** Scope change? Close the current intent
|
|
1125
1148
|
(\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
|
|
1126
1149
|
3. **Verify, then close**: run the declared verification, then
|
|
@@ -1133,9 +1156,9 @@ MCP and lifecycle hooks are optional adapters.
|
|
|
1133
1156
|
the final content hash is recorded. Interrupted reconciliation is recovered
|
|
1134
1157
|
by the next linked \`decision update\` or successful \`end\`. Closing as
|
|
1135
1158
|
\`failed\` or \`abandoned\` cancels pending recovery for that intent.
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1159
|
+
Git operations remain subject to normal authorization and safety requirements
|
|
1160
|
+
even though they do not require an intent. Any non-Git content change made while
|
|
1161
|
+
preparing a Git operation does require a new intent.
|
|
1139
1162
|
4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
|
|
1140
1163
|
doing anything else. The open intent is the source of truth: resume it when its
|
|
1141
1164
|
objective still matches the current task; otherwise close it (\`partial\` or
|
|
@@ -1146,13 +1169,50 @@ MCP and lifecycle hooks are optional adapters.
|
|
|
1146
1169
|
\`driftseal\` commands or the MCP tools. Retire meaningless closed records with
|
|
1147
1170
|
\`driftseal reclaim [id ...] --reason "<why>"\` — it appends a marker, never
|
|
1148
1171
|
deletes log lines; \`driftseal unreclaim <id> --reason "<why>"\` restores one.
|
|
1172
|
+
After a merge collision, run \`driftseal absorb\` rather than editing the log;
|
|
1173
|
+
if both sides still have an open intent, add \`--abandon-theirs\` or
|
|
1174
|
+
\`--abandon-ours\`.
|
|
1149
1175
|
|
|
1150
1176
|
Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
|
|
1151
1177
|
${INTENT_PROTOCOL_END}`;
|
|
1152
1178
|
}
|
|
1153
1179
|
|
|
1154
1180
|
function previousIntentProtocolBlock(version) {
|
|
1155
|
-
const
|
|
1181
|
+
const v9 = intentProtocolBlock(version)
|
|
1182
|
+
.replace(
|
|
1183
|
+
'1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
|
|
1184
|
+
' making any other non-Git change that may need a rollback:\n' +
|
|
1185
|
+
' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
|
|
1186
|
+
' Add one `--decision <id>` for each existing decision this round may change.\n' +
|
|
1187
|
+
' Git operations never need an intent and are not included in the intent log;\n' +
|
|
1188
|
+
' Git maintains their history. This includes inspection, branch and worktree\n' +
|
|
1189
|
+
' management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.\n' +
|
|
1190
|
+
' Single-step commands that only build or check work already done, such as\n' +
|
|
1191
|
+
' compiling or running tests, also need no intent.',
|
|
1192
|
+
'1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
|
|
1193
|
+
' making any other change that may need a rollback:\n' +
|
|
1194
|
+
' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
|
|
1195
|
+
' Add one `--decision <id>` for each existing decision this round may change.\n' +
|
|
1196
|
+
' Single-step commands that only build, check, or record work already done\n' +
|
|
1197
|
+
' (compiling, running tests, `git add`/`git commit`) need no intent.'
|
|
1198
|
+
)
|
|
1199
|
+
.replace(
|
|
1200
|
+
' Git operations remain subject to normal authorization and safety requirements\n' +
|
|
1201
|
+
' even though they do not require an intent. Any non-Git content change made while\n' +
|
|
1202
|
+
' preparing a Git operation does require a new intent.',
|
|
1203
|
+
' An authorized Git commit that only stages and records the verified changes and\n' +
|
|
1204
|
+
' just-closed log finalizes that round without requiring a new intent. Any content\n' +
|
|
1205
|
+
' change made while preparing the commit does require a new intent.'
|
|
1206
|
+
);
|
|
1207
|
+
if (version >= 9) return v9;
|
|
1208
|
+
const v8 = v9.replace(
|
|
1209
|
+
'\nAfter a merge collision, run `driftseal absorb` rather than editing the log;\n' +
|
|
1210
|
+
'if both sides still have an open intent, add `--abandon-theirs` or\n' +
|
|
1211
|
+
'`--abandon-ours`.',
|
|
1212
|
+
''
|
|
1213
|
+
);
|
|
1214
|
+
if (version >= 8) return v8;
|
|
1215
|
+
const v7 = v8.replace(
|
|
1156
1216
|
'\nThis `AGENTS.md` protocol is the source of truth. Use the `driftseal` CLI by\n' +
|
|
1157
1217
|
'default; the companion skill only helps discover and resume the workflow, while\n' +
|
|
1158
1218
|
'MCP and lifecycle hooks are optional adapters.\n',
|
|
@@ -1220,6 +1280,8 @@ Count postponed choices with \`driftseal decision list --status deferred --count
|
|
|
1220
1280
|
then review them with \`driftseal decision list --status deferred\`.
|
|
1221
1281
|
When an intent declares an existing decision with \`--decision <id>\`, use
|
|
1222
1282
|
\`driftseal decision update\` to record its status transition or explicit confirmation.
|
|
1283
|
+
After a merge, colliding decision ids are remapped with \`driftseal absorb\`;
|
|
1284
|
+
concurrent edits of a shared decision are not auto-merged.
|
|
1223
1285
|
Commit \`.decision-log/\` with the code.
|
|
1224
1286
|
${DECISION_PROTOCOL_END}`;
|
|
1225
1287
|
}
|
|
@@ -1276,8 +1338,15 @@ Commit \`.decision-log/\` with the code.`;
|
|
|
1276
1338
|
}
|
|
1277
1339
|
|
|
1278
1340
|
function previousDecisionProtocolBlock(version) {
|
|
1279
|
-
|
|
1280
|
-
|
|
1341
|
+
const v9 = decisionProtocolBlock(version);
|
|
1342
|
+
if (version >= 9) return v9;
|
|
1343
|
+
const v8 = v9.replace(
|
|
1344
|
+
'\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
|
|
1345
|
+
'concurrent edits of a shared decision are not auto-merged.',
|
|
1346
|
+
''
|
|
1347
|
+
);
|
|
1348
|
+
if (version >= 7) return v8;
|
|
1349
|
+
return v8.replace(' --driver "<decision driver>"', '');
|
|
1281
1350
|
}
|
|
1282
1351
|
|
|
1283
1352
|
function upgradeManagedBlock({
|
|
@@ -2005,6 +2074,776 @@ function runHookReminder(event, argv) {
|
|
|
2005
2074
|
return { changed: true, event, format };
|
|
2006
2075
|
}
|
|
2007
2076
|
|
|
2077
|
+
function gitCaptureRaw(args, cwd = process.cwd()) {
|
|
2078
|
+
try {
|
|
2079
|
+
return execFileSync('git', args, {
|
|
2080
|
+
cwd,
|
|
2081
|
+
encoding: 'utf8',
|
|
2082
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2083
|
+
});
|
|
2084
|
+
} catch {
|
|
2085
|
+
return null;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function gitCapture(args, cwd = process.cwd()) {
|
|
2090
|
+
const output = gitCaptureRaw(args, cwd);
|
|
2091
|
+
return output === null ? null : output.trim();
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function isGitWorkTree(cwd = process.cwd()) {
|
|
2095
|
+
return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function gitOtherHead(cwd = process.cwd()) {
|
|
2099
|
+
return (
|
|
2100
|
+
gitCapture(['rev-parse', '--verify', 'MERGE_HEAD'], cwd) ||
|
|
2101
|
+
gitCapture(['rev-parse', '--verify', 'REBASE_HEAD'], cwd) ||
|
|
2102
|
+
gitCapture(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], cwd)
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function gitMergeBase(cwd = process.cwd()) {
|
|
2107
|
+
const other = gitOtherHead(cwd);
|
|
2108
|
+
if (!other) return null;
|
|
2109
|
+
return gitCapture(['merge-base', 'HEAD', other], cwd);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function gitMergeBaseFor(left, right, cwd = process.cwd()) {
|
|
2113
|
+
return gitCapture(['merge-base', left, right], cwd);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
function gitReadFile(treeish, file, cwd = process.cwd()) {
|
|
2117
|
+
return gitCaptureRaw(['show', `${treeish}:${file}`], cwd);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function gitFindCommitForFile(file, repositoryPath, cwd = process.cwd()) {
|
|
2121
|
+
const blob = gitCapture(['hash-object', file], cwd);
|
|
2122
|
+
const history = gitCapture(['rev-list', '--all', '--reflog', '--', repositoryPath], cwd);
|
|
2123
|
+
if (!blob || !history) return null;
|
|
2124
|
+
for (const commit of history.split('\n')) {
|
|
2125
|
+
if (gitCapture(['rev-parse', `${commit}:${repositoryPath}`], cwd) === blob) return commit;
|
|
2126
|
+
}
|
|
2127
|
+
return null;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function gitMergeParents(cwd = process.cwd()) {
|
|
2131
|
+
const line = gitCapture(['rev-list', '--parents', '-n', '1', 'HEAD'], cwd);
|
|
2132
|
+
if (!line) return null;
|
|
2133
|
+
const parts = line.split(/\s+/);
|
|
2134
|
+
if (parts.length !== 3) return null;
|
|
2135
|
+
return { ours: parts[1], theirs: parts[2] };
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function gitDecisionIds(treeish, cwd = process.cwd()) {
|
|
2139
|
+
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
|
|
2140
|
+
if (!out) return new Set();
|
|
2141
|
+
const ids = new Set();
|
|
2142
|
+
for (const file of out.split('\n')) {
|
|
2143
|
+
const match = path.basename(file).match(/^(\d{4,})-.*\.md$/);
|
|
2144
|
+
if (match) ids.add(normalizeDecisionId(match[1]));
|
|
2145
|
+
}
|
|
2146
|
+
return ids;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function gitDecisionEntries(treeish, cwd = process.cwd()) {
|
|
2150
|
+
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
|
|
2151
|
+
if (!out) return [];
|
|
2152
|
+
const entries = [];
|
|
2153
|
+
for (const file of out.split('\n')) {
|
|
2154
|
+
const name = path.basename(file);
|
|
2155
|
+
const match = name.match(/^(\d{4,})-.*\.md$/);
|
|
2156
|
+
if (!match) continue;
|
|
2157
|
+
const content = gitReadFile(treeish, file, cwd);
|
|
2158
|
+
if (content === null) continue;
|
|
2159
|
+
entries.push({
|
|
2160
|
+
id: normalizeDecisionId(match[1]),
|
|
2161
|
+
file: name,
|
|
2162
|
+
path: file,
|
|
2163
|
+
content,
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
return entries;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function gitIntentRecords(treeish, cwd = process.cwd()) {
|
|
2170
|
+
const content = gitReadFile(treeish, '.intent-log/events.jsonl', cwd);
|
|
2171
|
+
return content === null ? [] : parseJsonlRecords(content, `${treeish}:.intent-log/events.jsonl`);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function canonicalEvent(event) {
|
|
2175
|
+
return Object.fromEntries(
|
|
2176
|
+
Object.entries(event)
|
|
2177
|
+
.filter(([, value]) => value !== undefined)
|
|
2178
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function eventsEqual(left, right) {
|
|
2183
|
+
return JSON.stringify(canonicalEvent(left)) === JSON.stringify(canonicalEvent(right));
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function commonPrefixLength(ours, theirs) {
|
|
2187
|
+
let index = 0;
|
|
2188
|
+
while (
|
|
2189
|
+
index < ours.length &&
|
|
2190
|
+
index < theirs.length &&
|
|
2191
|
+
eventsEqual(ours[index].event, theirs[index].event)
|
|
2192
|
+
) {
|
|
2193
|
+
index += 1;
|
|
2194
|
+
}
|
|
2195
|
+
return index;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function recordsHavePrefix(records, prefix) {
|
|
2199
|
+
if (prefix.length > records.length) return false;
|
|
2200
|
+
return prefix.every((record, index) => eventsEqual(record.event, records[index].event));
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
function parseConflictContent(content) {
|
|
2204
|
+
if (!/^<<<<<<< /m.test(content)) return null;
|
|
2205
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
2206
|
+
const ours = [];
|
|
2207
|
+
const theirs = [];
|
|
2208
|
+
let mode = 'base';
|
|
2209
|
+
for (const line of content.split(/\r?\n/)) {
|
|
2210
|
+
if (line.startsWith('<<<<<<< ')) {
|
|
2211
|
+
mode = 'ours';
|
|
2212
|
+
continue;
|
|
2213
|
+
}
|
|
2214
|
+
if (line.startsWith('||||||| ')) {
|
|
2215
|
+
mode = 'ancestor';
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
if (line.startsWith('=======')) {
|
|
2219
|
+
mode = 'theirs';
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
if (line.startsWith('>>>>>>> ')) {
|
|
2223
|
+
mode = 'base';
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
if (mode === 'base') {
|
|
2227
|
+
ours.push(line);
|
|
2228
|
+
theirs.push(line);
|
|
2229
|
+
} else if (mode === 'ours') {
|
|
2230
|
+
ours.push(line);
|
|
2231
|
+
} else if (mode === 'theirs') {
|
|
2232
|
+
theirs.push(line);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
return { oursText: ours.join(eol), theirsText: theirs.join(eol) };
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function collectDecisionIdsFromEvents(events) {
|
|
2239
|
+
const ids = new Set();
|
|
2240
|
+
for (const event of events) {
|
|
2241
|
+
if (Array.isArray(event.decisions)) {
|
|
2242
|
+
for (const id of event.decisions) ids.add(normalizeDecisionId(id));
|
|
2243
|
+
}
|
|
2244
|
+
if (event.decisionId) ids.add(normalizeDecisionId(event.decisionId));
|
|
2245
|
+
}
|
|
2246
|
+
return ids;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function nextDecisionIdFromIds(ids) {
|
|
2250
|
+
if (ids.size === 0) return '0001';
|
|
2251
|
+
let max = 0n;
|
|
2252
|
+
for (const id of ids) {
|
|
2253
|
+
const value = BigInt(id);
|
|
2254
|
+
if (value > max) max = value;
|
|
2255
|
+
}
|
|
2256
|
+
return String(max + 1n).padStart(4, '0');
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function decisionSlugFromFile(file) {
|
|
2260
|
+
return file.replace(/^\d+-/, '').replace(/\.md$/, '');
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function rewriteDecisionId(content, newId) {
|
|
2264
|
+
return content.replace(/^# [0-9]+\. /, `# ${String(BigInt(newId))}. `);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function splitDuplicateDecisions(entries) {
|
|
2268
|
+
const ours = [];
|
|
2269
|
+
const theirs = [];
|
|
2270
|
+
const seen = new Set();
|
|
2271
|
+
for (const entry of entries) {
|
|
2272
|
+
if (seen.has(entry.id)) theirs.push(entry);
|
|
2273
|
+
else {
|
|
2274
|
+
seen.add(entry.id);
|
|
2275
|
+
ours.push(entry);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
return { ours, theirs };
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function hasDuplicateDecisionIds(entries) {
|
|
2282
|
+
const seen = new Set();
|
|
2283
|
+
for (const entry of entries) {
|
|
2284
|
+
if (seen.has(entry.id)) return true;
|
|
2285
|
+
seen.add(entry.id);
|
|
2286
|
+
}
|
|
2287
|
+
return false;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
function hasDuplicateIntentBegins(records) {
|
|
2291
|
+
const seen = new Set();
|
|
2292
|
+
for (const record of records) {
|
|
2293
|
+
if (record.event.type !== 'begin') continue;
|
|
2294
|
+
if (seen.has(record.event.id)) return true;
|
|
2295
|
+
seen.add(record.event.id);
|
|
2296
|
+
}
|
|
2297
|
+
return false;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
function planDecisionAbsorb({ oursEntries, theirsEntries, baseEntries = [], baseIds = new Set() }) {
|
|
2301
|
+
const mappings = [];
|
|
2302
|
+
const copies = [];
|
|
2303
|
+
const decisionMap = new Map();
|
|
2304
|
+
const usedIds = new Set(oursEntries.map((entry) => entry.id));
|
|
2305
|
+
const oursById = new Map();
|
|
2306
|
+
const baseById = new Map();
|
|
2307
|
+
for (const entry of oursEntries) {
|
|
2308
|
+
if (!oursById.has(entry.id)) oursById.set(entry.id, entry);
|
|
2309
|
+
}
|
|
2310
|
+
for (const entry of baseEntries) {
|
|
2311
|
+
if (!baseById.has(entry.id)) baseById.set(entry.id, entry);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
for (const entry of theirsEntries) {
|
|
2315
|
+
const ours = oursById.get(entry.id);
|
|
2316
|
+
const theirsContent = entry.content !== undefined ? entry.content : fs.readFileSync(entry.path, 'utf8');
|
|
2317
|
+
if (!ours) {
|
|
2318
|
+
copies.push({
|
|
2319
|
+
fromFile: entry.file,
|
|
2320
|
+
toFile: entry.file,
|
|
2321
|
+
content: theirsContent,
|
|
2322
|
+
removeFile: null,
|
|
2323
|
+
});
|
|
2324
|
+
usedIds.add(entry.id);
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
const oursContent = ours.content !== undefined ? ours.content : fs.readFileSync(ours.path, 'utf8');
|
|
2328
|
+
const oursHash = contentHash(oursContent);
|
|
2329
|
+
const theirsHash = contentHash(theirsContent);
|
|
2330
|
+
if (oursHash === theirsHash) {
|
|
2331
|
+
if (ours.file !== entry.file) {
|
|
2332
|
+
copies.push({
|
|
2333
|
+
fromFile: entry.file,
|
|
2334
|
+
toFile: ours.file,
|
|
2335
|
+
content: oursContent,
|
|
2336
|
+
removeFile: entry.file,
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
const base = baseById.get(entry.id);
|
|
2343
|
+
if (base) {
|
|
2344
|
+
const baseContent = base.content !== undefined ? base.content : fs.readFileSync(base.path, 'utf8');
|
|
2345
|
+
const baseHash = contentHash(baseContent);
|
|
2346
|
+
if (oursHash === baseHash) {
|
|
2347
|
+
copies.push({
|
|
2348
|
+
fromFile: entry.file,
|
|
2349
|
+
toFile: ours.file,
|
|
2350
|
+
content: theirsContent,
|
|
2351
|
+
removeFile: entry.file !== ours.file ? entry.file : null,
|
|
2352
|
+
});
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
if (theirsHash === baseHash) {
|
|
2356
|
+
if (entry.file !== ours.file) {
|
|
2357
|
+
copies.push({
|
|
2358
|
+
fromFile: entry.file,
|
|
2359
|
+
toFile: ours.file,
|
|
2360
|
+
content: oursContent,
|
|
2361
|
+
removeFile: entry.file,
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
fail(
|
|
2367
|
+
`decision ${entry.id} was edited on both sides; resolve ${ours.file} manually before absorbing`
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
if (baseIds.has(entry.id)) {
|
|
2371
|
+
fail(
|
|
2372
|
+
`decision ${entry.id} was edited on both sides or its base content is unavailable; ` +
|
|
2373
|
+
`resolve ${ours.file} manually before absorbing`
|
|
2374
|
+
);
|
|
2375
|
+
}
|
|
2376
|
+
const newId = nextDecisionIdFromIds(usedIds);
|
|
2377
|
+
usedIds.add(newId);
|
|
2378
|
+
decisionMap.set(entry.id, newId);
|
|
2379
|
+
mappings.push({ kind: 'decision', from: entry.id, to: newId });
|
|
2380
|
+
copies.push({
|
|
2381
|
+
fromFile: entry.file,
|
|
2382
|
+
toFile: `${newId}-${decisionSlugFromFile(entry.file)}.md`,
|
|
2383
|
+
content: rewriteDecisionId(theirsContent, newId),
|
|
2384
|
+
removeFile: entry.file !== ours.file ? entry.file : null,
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
return { decisionMap, mappings, copies };
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function remapEvent(event, intentMap, decisionMap) {
|
|
2391
|
+
const next = { ...event };
|
|
2392
|
+
if (intentMap.has(event.id)) next.id = intentMap.get(event.id);
|
|
2393
|
+
if (Array.isArray(next.decisions) && next.decisions.length > 0) {
|
|
2394
|
+
next.decisions = next.decisions.map((id) => {
|
|
2395
|
+
const normalized = normalizeDecisionId(id);
|
|
2396
|
+
return decisionMap.get(normalized) || id;
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
if (next.decisionId) {
|
|
2400
|
+
const normalized = normalizeDecisionId(next.decisionId);
|
|
2401
|
+
if (decisionMap.has(normalized)) next.decisionId = decisionMap.get(normalized);
|
|
2402
|
+
}
|
|
2403
|
+
return next;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
|
|
2407
|
+
const intentMap = new Map();
|
|
2408
|
+
const mappings = [];
|
|
2409
|
+
const used = [...oursUsedEvents];
|
|
2410
|
+
const records = theirsNew.map((record) => {
|
|
2411
|
+
let event = record.event;
|
|
2412
|
+
if (event.type === 'begin' && used.some((item) => item.type === 'begin' && item.id === event.id)) {
|
|
2413
|
+
const { date } = parseIntentId(event.id);
|
|
2414
|
+
const newId = nextIdForDate(date, used);
|
|
2415
|
+
intentMap.set(event.id, newId);
|
|
2416
|
+
mappings.push({ kind: 'intent', from: event.id, to: newId });
|
|
2417
|
+
}
|
|
2418
|
+
event = remapEvent(event, intentMap, decisionMap);
|
|
2419
|
+
used.push(event);
|
|
2420
|
+
return { event };
|
|
2421
|
+
});
|
|
2422
|
+
return { records, mappings };
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function repairDuplicateIntentRecords(records, decisionMap) {
|
|
2426
|
+
const seenBegins = new Set();
|
|
2427
|
+
const intentMap = new Map();
|
|
2428
|
+
const used = [];
|
|
2429
|
+
const mappings = [];
|
|
2430
|
+
const result = [];
|
|
2431
|
+
let incomingSide = false;
|
|
2432
|
+
for (const record of records) {
|
|
2433
|
+
let event = record.event;
|
|
2434
|
+
if (event.type === 'begin' && seenBegins.has(event.id)) {
|
|
2435
|
+
incomingSide = true;
|
|
2436
|
+
const { date } = parseIntentId(event.id);
|
|
2437
|
+
const newId = nextIdForDate(date, used);
|
|
2438
|
+
intentMap.set(event.id, newId);
|
|
2439
|
+
mappings.push({ kind: 'intent', from: event.id, to: newId });
|
|
2440
|
+
} else if (event.type === 'begin') {
|
|
2441
|
+
seenBegins.add(event.id);
|
|
2442
|
+
}
|
|
2443
|
+
const remapped = remapEvent(event, intentMap, incomingSide ? decisionMap : new Map());
|
|
2444
|
+
const changed = remapped !== event && JSON.stringify(remapped) !== JSON.stringify(event);
|
|
2445
|
+
result.push(changed ? { event: remapped } : record);
|
|
2446
|
+
used.push(result.at(-1).event);
|
|
2447
|
+
}
|
|
2448
|
+
return { records: result, mappings, incomingSide };
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
function serializeRecords(records) {
|
|
2452
|
+
if (records.length === 0) return '';
|
|
2453
|
+
return `${records.map((record) => record.raw || JSON.stringify(record.event)).join('\n')}\n`;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
function writeJsonl(file, records) {
|
|
2457
|
+
ensureDirectoryDurable(path.dirname(file));
|
|
2458
|
+
atomicWriteFile(file, serializeRecords(records));
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function applyDecisionCopies(copies, dryRun) {
|
|
2462
|
+
if (dryRun || copies.length === 0) return;
|
|
2463
|
+
ensureDirectoryDurable(decisionDir());
|
|
2464
|
+
for (const item of copies) {
|
|
2465
|
+
atomicWriteFile(path.join(decisionDir(), item.toFile), item.content);
|
|
2466
|
+
if (item.removeFile && item.removeFile !== item.toFile) {
|
|
2467
|
+
const stale = path.join(decisionDir(), item.removeFile);
|
|
2468
|
+
if (fs.existsSync(stale)) {
|
|
2469
|
+
fs.unlinkSync(stale);
|
|
2470
|
+
fsyncDirectory(decisionDir());
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function countAbsorbedIntents(records) {
|
|
2477
|
+
return records.filter((record) => record.event.type === 'begin').length;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function printAbsorbReport({ mappings, abandoned, intentCount }) {
|
|
2481
|
+
const remappedIntents = mappings.filter((mapping) => mapping.kind === 'intent').length;
|
|
2482
|
+
const remappedDecisions = mappings.filter((mapping) => mapping.kind === 'decision').length;
|
|
2483
|
+
printLine(
|
|
2484
|
+
`absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
|
|
2485
|
+
);
|
|
2486
|
+
for (const mapping of mappings) {
|
|
2487
|
+
if (mapping.kind === 'intent') printLine(`${mapping.from} (theirs) -> ${mapping.to}`);
|
|
2488
|
+
else printLine(`decision ${mapping.from} (theirs) -> ${mapping.to}`);
|
|
2489
|
+
}
|
|
2490
|
+
if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
function abandonOpenIntent(records, targetId, side) {
|
|
2494
|
+
records.push({
|
|
2495
|
+
event: {
|
|
2496
|
+
schemaVersion: EVENT_SCHEMA_VERSION,
|
|
2497
|
+
type: 'end',
|
|
2498
|
+
id: targetId,
|
|
2499
|
+
ts: new Date().toISOString(),
|
|
2500
|
+
status: 'abandoned',
|
|
2501
|
+
note: `abandoned during absorb (--abandon-${side})`,
|
|
2502
|
+
verifyResult: null,
|
|
2503
|
+
},
|
|
2504
|
+
});
|
|
2505
|
+
return targetId;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function resolveOpenIntents(result, oursRecords, theirsRecords, abandon, { allowConflict = false } = {}) {
|
|
2509
|
+
const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
|
|
2510
|
+
const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
|
|
2511
|
+
try {
|
|
2512
|
+
openIntent(fold(result.map((record) => record.event)));
|
|
2513
|
+
return { abandoned: null, conflict: false };
|
|
2514
|
+
} catch (err) {
|
|
2515
|
+
if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
|
|
2516
|
+
throw err;
|
|
2517
|
+
}
|
|
2518
|
+
if (abandon === 'theirs' && theirsOpen) {
|
|
2519
|
+
return { abandoned: abandonOpenIntent(result, theirsOpen.id, 'theirs'), conflict: false };
|
|
2520
|
+
}
|
|
2521
|
+
if (abandon === 'ours' && oursOpen) {
|
|
2522
|
+
return { abandoned: abandonOpenIntent(result, oursOpen.id, 'ours'), conflict: false };
|
|
2523
|
+
}
|
|
2524
|
+
if (allowConflict) return { abandoned: null, conflict: true };
|
|
2525
|
+
fail(`${err.message}; re-run with --abandon-theirs or --abandon-ours`);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
function mergeRecordStreams(ours, theirs, baseRecords) {
|
|
2530
|
+
const inferred = ours.slice(0, commonPrefixLength(ours, theirs));
|
|
2531
|
+
const prefix = baseRecords && baseRecords.length > 0 ? baseRecords : inferred;
|
|
2532
|
+
if (!recordsHavePrefix(ours, prefix) || !recordsHavePrefix(theirs, prefix)) {
|
|
2533
|
+
fail('cannot absorb: shared history does not match the base log');
|
|
2534
|
+
}
|
|
2535
|
+
return {
|
|
2536
|
+
base: prefix,
|
|
2537
|
+
oursNew: ours.slice(prefix.length),
|
|
2538
|
+
theirsNew: theirs.slice(prefix.length),
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function GITATTRIBUTES_MERGE_LINE() {
|
|
2543
|
+
return '.intent-log/events.jsonl merge=driftseal';
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function ensureGitAttributes() {
|
|
2547
|
+
const target = path.join(process.cwd(), '.gitattributes');
|
|
2548
|
+
const line = GITATTRIBUTES_MERGE_LINE();
|
|
2549
|
+
const existed = fs.existsSync(target);
|
|
2550
|
+
const current = existed ? fs.readFileSync(target, 'utf8') : '';
|
|
2551
|
+
const eol = current.includes('\r\n') ? '\r\n' : '\n';
|
|
2552
|
+
const lines = current.split(/\r?\n/);
|
|
2553
|
+
if (lines.some((entry) => entry.trim() === line)) return { changed: false, target };
|
|
2554
|
+
const prefix = current.length === 0 || current.endsWith('\n') || current.endsWith('\r\n') ? '' : eol;
|
|
2555
|
+
const next = `${current}${prefix}${line}${eol}`;
|
|
2556
|
+
atomicWriteFile(target, next);
|
|
2557
|
+
return { changed: true, target };
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
function ensureGitMergeDriver() {
|
|
2561
|
+
if (!isGitWorkTree()) return { changed: false, configured: false };
|
|
2562
|
+
const name = gitCapture(['config', '--local', '--get', 'merge.driftseal.name']);
|
|
2563
|
+
const driver = gitCapture(['config', '--local', '--get', 'merge.driftseal.driver']);
|
|
2564
|
+
const expectedName = 'DriftSeal intent log merge';
|
|
2565
|
+
const expectedDriver = 'driftseal absorb --git %O %A %B';
|
|
2566
|
+
if (name === expectedName && driver === expectedDriver) {
|
|
2567
|
+
return { changed: false, configured: true };
|
|
2568
|
+
}
|
|
2569
|
+
execFileSync('git', ['config', '--local', 'merge.driftseal.name', expectedName], {
|
|
2570
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2571
|
+
});
|
|
2572
|
+
execFileSync('git', ['config', '--local', 'merge.driftseal.driver', expectedDriver], {
|
|
2573
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2574
|
+
});
|
|
2575
|
+
return { changed: true, configured: true };
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function absorbUsage() {
|
|
2579
|
+
return (
|
|
2580
|
+
'usage: driftseal absorb [other-events.jsonl] [--decisions <dir>] [--abandon-theirs | --abandon-ours] [--dry-run]\n' +
|
|
2581
|
+
' or: driftseal absorb --git <base> <ours> <theirs>'
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false } = {}) {
|
|
2586
|
+
if (!fs.existsSync(file)) {
|
|
2587
|
+
if (allowMissing) return { records: [], conflict: false };
|
|
2588
|
+
fail(`intent log not found: ${file}`);
|
|
2589
|
+
}
|
|
2590
|
+
let content = fs.readFileSync(file, 'utf8');
|
|
2591
|
+
if (repairTail && !/^<<<<<<< /m.test(content)) {
|
|
2592
|
+
readEvents({ file, repairTail: true });
|
|
2593
|
+
content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
2594
|
+
}
|
|
2595
|
+
const conflict = parseConflictContent(content);
|
|
2596
|
+
if (conflict) {
|
|
2597
|
+
return {
|
|
2598
|
+
ours: parseJsonlRecords(conflict.oursText, `${label} ours`),
|
|
2599
|
+
theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`),
|
|
2600
|
+
conflict: true,
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
return { records: parseJsonlRecords(content, label), conflict: false };
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
function finishAbsorb({
|
|
2607
|
+
result,
|
|
2608
|
+
oursRecords,
|
|
2609
|
+
theirsRecords,
|
|
2610
|
+
mappings,
|
|
2611
|
+
copies,
|
|
2612
|
+
abandon,
|
|
2613
|
+
dryRun,
|
|
2614
|
+
outputFile,
|
|
2615
|
+
intentCount,
|
|
2616
|
+
allowConflict = false,
|
|
2617
|
+
followupMessage = null,
|
|
2618
|
+
}) {
|
|
2619
|
+
const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
|
|
2620
|
+
allowConflict,
|
|
2621
|
+
});
|
|
2622
|
+
fold(result.map((record) => record.event));
|
|
2623
|
+
if (!conflict) openIntent(fold(result.map((record) => record.event)));
|
|
2624
|
+
if (!dryRun) {
|
|
2625
|
+
writeJsonl(outputFile, result);
|
|
2626
|
+
applyDecisionCopies(copies, dryRun);
|
|
2627
|
+
}
|
|
2628
|
+
if (intentCount === 0 && mappings.length === 0 && copies.length === 0 && !abandoned) {
|
|
2629
|
+
printLine('nothing to absorb');
|
|
2630
|
+
} else {
|
|
2631
|
+
printAbsorbReport({
|
|
2632
|
+
mappings,
|
|
2633
|
+
abandoned,
|
|
2634
|
+
intentCount,
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
if (conflict) {
|
|
2638
|
+
printLine('multiple intents remain in progress; re-run with --abandon-theirs or --abandon-ours');
|
|
2639
|
+
}
|
|
2640
|
+
if (followupMessage) printLine(followupMessage);
|
|
2641
|
+
return {
|
|
2642
|
+
mappings,
|
|
2643
|
+
abandoned,
|
|
2644
|
+
copies: copies.map((item) => item.toFile),
|
|
2645
|
+
outputFile,
|
|
2646
|
+
exitCode: conflict || followupMessage ? 1 : 0,
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function absorbFromStreams(ours, theirs, baseRecords, options) {
|
|
2651
|
+
const streams = mergeRecordStreams(ours, theirs, baseRecords);
|
|
2652
|
+
const decisionPlan = planDecisionAbsorb({
|
|
2653
|
+
oursEntries: options.oursDecisionEntries || [],
|
|
2654
|
+
theirsEntries: options.theirsDecisionEntries || [],
|
|
2655
|
+
baseEntries: options.baseDecisionEntries || [],
|
|
2656
|
+
baseIds: options.baseDecisionIds || new Set(),
|
|
2657
|
+
});
|
|
2658
|
+
const remapped = remapTheirsRecords(
|
|
2659
|
+
streams.theirsNew,
|
|
2660
|
+
[...streams.base, ...streams.oursNew].map((record) => record.event),
|
|
2661
|
+
decisionPlan.decisionMap
|
|
2662
|
+
);
|
|
2663
|
+
const result = [...streams.base, ...streams.oursNew, ...remapped.records];
|
|
2664
|
+
return finishAbsorb({
|
|
2665
|
+
result,
|
|
2666
|
+
oursRecords: [...streams.base, ...streams.oursNew],
|
|
2667
|
+
theirsRecords: [...streams.base, ...remapped.records],
|
|
2668
|
+
mappings: [...remapped.mappings, ...decisionPlan.mappings],
|
|
2669
|
+
copies: decisionPlan.copies,
|
|
2670
|
+
abandon: options.abandon,
|
|
2671
|
+
dryRun: options.dryRun,
|
|
2672
|
+
outputFile: options.outputFile,
|
|
2673
|
+
allowConflict: options.allowConflict,
|
|
2674
|
+
followupMessage: options.followupMessage,
|
|
2675
|
+
intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function gitAbsorbRepairContext(records, decisionEntries) {
|
|
2680
|
+
const pending = gitOtherHead();
|
|
2681
|
+
if (pending) {
|
|
2682
|
+
return {
|
|
2683
|
+
ours: 'HEAD',
|
|
2684
|
+
theirs: pending,
|
|
2685
|
+
base: gitMergeBaseFor('HEAD', pending),
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateIntentBegins(records)) return null;
|
|
2689
|
+
const parents = gitMergeParents();
|
|
2690
|
+
if (!parents) return null;
|
|
2691
|
+
return {
|
|
2692
|
+
...parents,
|
|
2693
|
+
base: gitMergeBaseFor(parents.ours, parents.theirs),
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function absorbFromGitContext(context, { abandon, dryRun, outputFile }) {
|
|
2698
|
+
const baseRecords = context.base ? gitIntentRecords(context.base) : [];
|
|
2699
|
+
return absorbFromStreams(
|
|
2700
|
+
gitIntentRecords(context.ours),
|
|
2701
|
+
gitIntentRecords(context.theirs),
|
|
2702
|
+
baseRecords,
|
|
2703
|
+
{
|
|
2704
|
+
abandon,
|
|
2705
|
+
dryRun,
|
|
2706
|
+
outputFile,
|
|
2707
|
+
oursDecisionEntries: gitDecisionEntries(context.ours),
|
|
2708
|
+
theirsDecisionEntries: gitDecisionEntries(context.theirs),
|
|
2709
|
+
baseDecisionEntries: context.base ? gitDecisionEntries(context.base) : [],
|
|
2710
|
+
baseDecisionIds: context.base
|
|
2711
|
+
? gitDecisionIds(context.base)
|
|
2712
|
+
: collectDecisionIdsFromEvents(baseRecords.map((record) => record.event)),
|
|
2713
|
+
}
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
2718
|
+
const oursFile = logFile();
|
|
2719
|
+
const loaded = loadAbsorbSide(oursFile, oursFile, { repairTail: true, allowMissing: !otherFile });
|
|
2720
|
+
const currentDecisionEntries = !otherFile
|
|
2721
|
+
? listDecisionEntries(decisionDir(), { allowDuplicates: true })
|
|
2722
|
+
: [];
|
|
2723
|
+
const gitContext = !otherFile
|
|
2724
|
+
? gitAbsorbRepairContext(loaded.records || [], currentDecisionEntries)
|
|
2725
|
+
: null;
|
|
2726
|
+
if (gitContext) {
|
|
2727
|
+
return absorbFromGitContext(gitContext, { abandon, dryRun, outputFile: oursFile });
|
|
2728
|
+
}
|
|
2729
|
+
if (loaded.conflict) {
|
|
2730
|
+
const baseIds = collectDecisionIdsFromEvents(
|
|
2731
|
+
loaded.ours.slice(0, commonPrefixLength(loaded.ours, loaded.theirs)).map((record) => record.event)
|
|
2732
|
+
);
|
|
2733
|
+
const gitBase = gitMergeBase();
|
|
2734
|
+
const baseDecisionIds = gitBase ? gitDecisionIds(gitBase) : baseIds;
|
|
2735
|
+
return absorbFromStreams(loaded.ours, loaded.theirs, null, {
|
|
2736
|
+
abandon,
|
|
2737
|
+
dryRun,
|
|
2738
|
+
outputFile: oursFile,
|
|
2739
|
+
oursDecisionEntries: listDecisionEntries(decisionDir(), { allowDuplicates: true }),
|
|
2740
|
+
theirsDecisionEntries: otherDecisions
|
|
2741
|
+
? listDecisionEntries(otherDecisions)
|
|
2742
|
+
: splitDuplicateDecisions(listDecisionEntries(decisionDir(), { allowDuplicates: true })).theirs,
|
|
2743
|
+
baseDecisionIds,
|
|
2744
|
+
baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
if (!otherFile) {
|
|
2749
|
+
const oursEntries = currentDecisionEntries;
|
|
2750
|
+
const split = splitDuplicateDecisions(oursEntries);
|
|
2751
|
+
const gitBase = gitMergeBase();
|
|
2752
|
+
const decisionPlan = planDecisionAbsorb({
|
|
2753
|
+
oursEntries: split.ours,
|
|
2754
|
+
theirsEntries: split.theirs,
|
|
2755
|
+
baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
2756
|
+
baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
|
|
2757
|
+
});
|
|
2758
|
+
const repaired = repairDuplicateIntentRecords(loaded.records, decisionPlan.decisionMap);
|
|
2759
|
+
if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
|
|
2760
|
+
fail(
|
|
2761
|
+
'cannot determine which intent records own the duplicate decision; ' +
|
|
2762
|
+
'run absorb during the merge or provide the incoming log and decision directory'
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
const result = repaired.records;
|
|
2766
|
+
const mappings = [...repaired.mappings, ...decisionPlan.mappings];
|
|
2767
|
+
const remappedIds = new Set(
|
|
2768
|
+
mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
|
|
2769
|
+
);
|
|
2770
|
+
const oursRecords = result.filter((record) => !remappedIds.has(record.event.id));
|
|
2771
|
+
return finishAbsorb({
|
|
2772
|
+
result,
|
|
2773
|
+
oursRecords: oursRecords.length > 0 ? oursRecords : result,
|
|
2774
|
+
theirsRecords: result,
|
|
2775
|
+
mappings,
|
|
2776
|
+
copies: decisionPlan.copies,
|
|
2777
|
+
abandon,
|
|
2778
|
+
dryRun,
|
|
2779
|
+
outputFile: oursFile,
|
|
2780
|
+
intentCount: remappedIds.size,
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
const theirs = loadAbsorbSide(otherFile, otherFile);
|
|
2785
|
+
if (theirs.conflict) fail(`incoming log still contains conflict markers: ${otherFile}`);
|
|
2786
|
+
const otherRoot = path.resolve(path.dirname(otherFile), '..');
|
|
2787
|
+
const otherHead = isGitWorkTree(otherRoot) ? gitCapture(['rev-parse', 'HEAD'], otherRoot) : null;
|
|
2788
|
+
const gitBase = otherHead ? gitCapture(['merge-base', 'HEAD', otherHead]) : gitMergeBase();
|
|
2789
|
+
const baseDecisionIds = gitBase
|
|
2790
|
+
? gitDecisionIds(gitBase)
|
|
2791
|
+
: collectDecisionIdsFromEvents(
|
|
2792
|
+
loaded.records
|
|
2793
|
+
.slice(0, commonPrefixLength(loaded.records, theirs.records))
|
|
2794
|
+
.map((record) => record.event)
|
|
2795
|
+
);
|
|
2796
|
+
return absorbFromStreams(loaded.records, theirs.records, null, {
|
|
2797
|
+
abandon,
|
|
2798
|
+
dryRun,
|
|
2799
|
+
outputFile: oursFile,
|
|
2800
|
+
oursDecisionEntries: listDecisionEntries(decisionDir()),
|
|
2801
|
+
theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
|
|
2802
|
+
baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
2803
|
+
baseDecisionIds,
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
|
|
2808
|
+
const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true });
|
|
2809
|
+
const ours = loadAbsorbSide(oursFile, oursFile);
|
|
2810
|
+
const theirs = loadAbsorbSide(theirsFile, theirsFile);
|
|
2811
|
+
if (base.conflict || ours.conflict || theirs.conflict) {
|
|
2812
|
+
fail('git merge driver received a log that still contains conflict markers');
|
|
2813
|
+
}
|
|
2814
|
+
const otherHead =
|
|
2815
|
+
gitOtherHead() || gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
|
|
2816
|
+
const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
|
|
2817
|
+
let followupMessage = null;
|
|
2818
|
+
if (!otherHead) {
|
|
2819
|
+
followupMessage =
|
|
2820
|
+
'incoming Git tree could not be identified safely; re-run driftseal absorb after Git stops the merge';
|
|
2821
|
+
} else {
|
|
2822
|
+
const decisionPlan = planDecisionAbsorb({
|
|
2823
|
+
oursEntries: gitDecisionEntries('HEAD'),
|
|
2824
|
+
theirsEntries: gitDecisionEntries(otherHead),
|
|
2825
|
+
baseEntries: mergeBase ? gitDecisionEntries(mergeBase) : [],
|
|
2826
|
+
baseIds: mergeBase
|
|
2827
|
+
? gitDecisionIds(mergeBase)
|
|
2828
|
+
: collectDecisionIdsFromEvents(base.records.map((record) => record.event)),
|
|
2829
|
+
});
|
|
2830
|
+
const requiresDecisionRepair =
|
|
2831
|
+
decisionPlan.mappings.length > 0 ||
|
|
2832
|
+
decisionPlan.copies.some((copy) => copy.removeFile && copy.removeFile !== copy.toFile);
|
|
2833
|
+
if (requiresDecisionRepair) {
|
|
2834
|
+
followupMessage =
|
|
2835
|
+
'decision ids require worktree repair; run driftseal absorb, then stage the repaired logs';
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
return absorbFromStreams(ours.records, theirs.records, base.records, {
|
|
2839
|
+
abandon,
|
|
2840
|
+
dryRun,
|
|
2841
|
+
outputFile: oursFile,
|
|
2842
|
+
allowConflict: !abandon,
|
|
2843
|
+
followupMessage,
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2008
2847
|
const commands = {
|
|
2009
2848
|
begin(argv) {
|
|
2010
2849
|
const { positionals, flags } = parseArgs(argv, {
|
|
@@ -2425,6 +3264,29 @@ const commands = {
|
|
|
2425
3264
|
fail(hookUsage());
|
|
2426
3265
|
},
|
|
2427
3266
|
|
|
3267
|
+
absorb(argv) {
|
|
3268
|
+
const { positionals, flags } = parseArgs(argv, {
|
|
3269
|
+
git: 'boolean',
|
|
3270
|
+
decisions: 'single',
|
|
3271
|
+
'abandon-theirs': 'boolean',
|
|
3272
|
+
'abandon-ours': 'boolean',
|
|
3273
|
+
'dry-run': 'boolean',
|
|
3274
|
+
});
|
|
3275
|
+
if (flags['abandon-theirs'] && flags['abandon-ours']) {
|
|
3276
|
+
fail('cannot combine --abandon-theirs and --abandon-ours');
|
|
3277
|
+
}
|
|
3278
|
+
const abandon = flags['abandon-theirs'] ? 'theirs' : flags['abandon-ours'] ? 'ours' : null;
|
|
3279
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
3280
|
+
|
|
3281
|
+
if (flags.git) {
|
|
3282
|
+
if (positionals.length !== 3) fail(absorbUsage());
|
|
3283
|
+
if (flags.decisions) fail('--decisions cannot be combined with --git');
|
|
3284
|
+
return absorbGit(positionals[0], positionals[1], positionals[2], { abandon, dryRun });
|
|
3285
|
+
}
|
|
3286
|
+
if (positionals.length > 1) fail(absorbUsage());
|
|
3287
|
+
return absorbLogs(positionals[0], flags.decisions, { abandon, dryRun });
|
|
3288
|
+
},
|
|
3289
|
+
|
|
2428
3290
|
init(argv) {
|
|
2429
3291
|
const { positionals } = parseArgs(argv, {});
|
|
2430
3292
|
if (positionals.length > 0) fail('usage: driftseal init');
|
|
@@ -2448,6 +3310,8 @@ const commands = {
|
|
|
2448
3310
|
protocolEol(previousIntentProtocolBlock(5), eol),
|
|
2449
3311
|
protocolEol(previousIntentProtocolBlock(6), eol),
|
|
2450
3312
|
protocolEol(previousIntentProtocolBlock(7), eol),
|
|
3313
|
+
protocolEol(previousIntentProtocolBlock(8), eol),
|
|
3314
|
+
protocolEol(previousIntentProtocolBlock(9), eol),
|
|
2451
3315
|
],
|
|
2452
3316
|
knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
|
|
2453
3317
|
});
|
|
@@ -2465,6 +3329,8 @@ const commands = {
|
|
|
2465
3329
|
protocolEol(previousDecisionProtocolBlock(5), eol),
|
|
2466
3330
|
protocolEol(previousDecisionProtocolBlock(6), eol),
|
|
2467
3331
|
protocolEol(previousDecisionProtocolBlock(7), eol),
|
|
3332
|
+
protocolEol(previousDecisionProtocolBlock(8), eol),
|
|
3333
|
+
protocolEol(previousDecisionProtocolBlock(9), eol),
|
|
2468
3334
|
],
|
|
2469
3335
|
knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
|
|
2470
3336
|
});
|
|
@@ -2484,12 +3350,28 @@ const commands = {
|
|
|
2484
3350
|
updated += separator + additions.join(eol + eol) + eol;
|
|
2485
3351
|
}
|
|
2486
3352
|
|
|
2487
|
-
|
|
3353
|
+
const attributes = ensureGitAttributes();
|
|
3354
|
+
let driver = { changed: false, configured: false };
|
|
3355
|
+
try {
|
|
3356
|
+
driver = ensureGitMergeDriver();
|
|
3357
|
+
} catch (err) {
|
|
3358
|
+
printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
if (updated === current && !attributes.changed && !driver.changed) {
|
|
2488
3362
|
printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
|
|
2489
3363
|
return { changed: false, target };
|
|
2490
3364
|
}
|
|
2491
|
-
|
|
2492
|
-
|
|
3365
|
+
if (updated !== current) {
|
|
3366
|
+
atomicWriteFile(target, updated);
|
|
3367
|
+
printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
|
|
3368
|
+
}
|
|
3369
|
+
if (attributes.changed) {
|
|
3370
|
+
printLine(`Configured git merge attribute: ${attributes.target}`);
|
|
3371
|
+
}
|
|
3372
|
+
if (driver.changed) {
|
|
3373
|
+
printLine('Configured local git merge driver for DriftSeal intent logs');
|
|
3374
|
+
}
|
|
2493
3375
|
return { changed: true, target };
|
|
2494
3376
|
},
|
|
2495
3377
|
|
|
@@ -2507,6 +3389,11 @@ usage:
|
|
|
2507
3389
|
hide meaningless closed records without deleting them
|
|
2508
3390
|
driftseal unreclaim <id> --reason "<why>"
|
|
2509
3391
|
restore a reclaimed record to the visible log
|
|
3392
|
+
driftseal absorb [other-events.jsonl] [--decisions <dir>]
|
|
3393
|
+
[--abandon-theirs | --abandon-ours] [--dry-run]
|
|
3394
|
+
merge another intent log, remapping colliding ids
|
|
3395
|
+
driftseal absorb --git <base> <ours> <theirs>
|
|
3396
|
+
git merge driver for .intent-log/events.jsonl
|
|
2510
3397
|
driftseal decision add "<title>" --context "..." --outcome "..." [options]
|
|
2511
3398
|
driftseal decision update <id> [--status STATUS] --note "..."
|
|
2512
3399
|
reconcile a linked decision in the open intent
|
|
@@ -2524,7 +3411,7 @@ usage:
|
|
|
2524
3411
|
targets: kimi-code (global only), claude-code, codex (prompt only)
|
|
2525
3412
|
driftseal hook prompt|stop [--format plain|claude-code]
|
|
2526
3413
|
emit the reminder a lifecycle hook injects; never blocks
|
|
2527
|
-
driftseal init inject
|
|
3414
|
+
driftseal init inject protocols into ./AGENTS.md and configure the git merge driver
|
|
2528
3415
|
driftseal --version | -V print the installed DriftSeal version
|
|
2529
3416
|
driftseal help
|
|
2530
3417
|
|
|
@@ -2559,6 +3446,7 @@ function mutationResources(cmd, argv) {
|
|
|
2559
3446
|
if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
|
|
2560
3447
|
if (cmd === 'init') return [process.cwd()];
|
|
2561
3448
|
if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
|
|
3449
|
+
if (cmd === 'absorb') return [logDir(), decisionDir()];
|
|
2562
3450
|
if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
|
|
2563
3451
|
return [logDir()];
|
|
2564
3452
|
}
|
|
@@ -2580,13 +3468,14 @@ function dispatch(argv) {
|
|
|
2580
3468
|
const fn = commands[cmd];
|
|
2581
3469
|
if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
|
|
2582
3470
|
const mutates =
|
|
2583
|
-
['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim'].includes(cmd) ||
|
|
3471
|
+
['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
|
|
2584
3472
|
(cmd === 'hook' && rest[0] === 'install') ||
|
|
2585
3473
|
(cmd === 'decision' && ['add', 'update'].includes(rest[0]));
|
|
2586
3474
|
const readsIntentLog = ['status', 'log'].includes(cmd);
|
|
2587
3475
|
if (mutates || readsIntentLog) {
|
|
2588
3476
|
const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
|
|
2589
|
-
|
|
3477
|
+
const data = withMutationLocks(resources, () => fn(rest));
|
|
3478
|
+
return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
|
|
2590
3479
|
}
|
|
2591
3480
|
return { data: fn(rest), exitCode: 0 };
|
|
2592
3481
|
}
|
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`.
|