driftseal 1.1.0 → 1.1.2
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 +11 -3
- package/README.zh-CN.md +9 -4
- package/bin/driftseal.js +272 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ Cursor have no supported hook surface for this yet.
|
|
|
179
179
|
|
|
180
180
|
## A work round
|
|
181
181
|
|
|
182
|
-
Declare the round before
|
|
182
|
+
Declare the round before making non-Git changes:
|
|
183
183
|
|
|
184
184
|
```sh
|
|
185
185
|
driftseal begin "add rate limiting to /api/login" \
|
|
@@ -197,7 +197,12 @@ driftseal end \
|
|
|
197
197
|
|
|
198
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.
|
|
199
199
|
|
|
200
|
-
|
|
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.
|
|
201
206
|
|
|
202
207
|
## Commands
|
|
203
208
|
|
|
@@ -293,7 +298,10 @@ already existed in the shared base are not auto-merged.
|
|
|
293
298
|
|
|
294
299
|
`driftseal init` writes that absorb rule into `AGENTS.md`, plus `.gitattributes`
|
|
295
300
|
and the local git merge driver so `events.jsonl` merges through `absorb --git`.
|
|
296
|
-
|
|
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.
|
|
297
305
|
|
|
298
306
|
## Storage
|
|
299
307
|
|
package/README.zh-CN.md
CHANGED
|
@@ -172,7 +172,7 @@ Kimi Code 只在全局 `config.toml` 中记录 hook,因此该 target 必须指
|
|
|
172
172
|
|
|
173
173
|
## 一轮标准工作流
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
进行非 Git 改动前,先声明这轮工作的目标:
|
|
176
176
|
|
|
177
177
|
```sh
|
|
178
178
|
driftseal begin "add rate limiting to /api/login" \
|
|
@@ -190,7 +190,10 @@ driftseal end \
|
|
|
190
190
|
|
|
191
191
|
如果范围发生变化,先把当前 intent 以 `partial` 或 `abandoned` 关闭,再开启新的 intent。发生 context loss 后,用 `driftseal status` 和 `driftseal log --last 3` 重新锚定当前目标。
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看状态、管理 branch
|
|
194
|
+
或 worktree、stage、commit、merge、rebase、cherry-pick、tag 和 push 都不需要
|
|
195
|
+
单独开启 intent,但仍须遵守正常的授权与安全要求。编译、跑测试等单步构建或检查
|
|
196
|
+
也不需要 intent;只要要做非 Git 内容改动,就开启新一轮。
|
|
194
197
|
|
|
195
198
|
## 命令速览
|
|
196
199
|
|
|
@@ -270,8 +273,10 @@ driftseal absorb ../other-worktree/.intent-log/events.jsonl \
|
|
|
270
273
|
不带 path 时,`absorb` 修复当前日志里的冲突标记或已经拼在一起的重复 id。两边都还有未关闭 intent 时,必须加上 `--abandon-theirs` 或 `--abandon-ours`。共享 base 里已经存在、又被两边同时改过的 decision,不会自动合并。
|
|
271
274
|
|
|
272
275
|
`driftseal init` 会把这条 absorb 规则写入 `AGENTS.md`,同时写入 `.gitattributes`
|
|
273
|
-
并配置 local git merge driver,让 `events.jsonl` 走 `absorb --git`。
|
|
274
|
-
|
|
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。
|
|
275
280
|
|
|
276
281
|
## 数据保存在哪里
|
|
277
282
|
|
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;
|
|
@@ -1136,11 +1136,14 @@ default; the companion skill only helps discover and resume the workflow, while
|
|
|
1136
1136
|
MCP and lifecycle hooks are optional adapters.
|
|
1137
1137
|
|
|
1138
1138
|
1. **Write intent first**, before modifying, creating, or deleting files, or
|
|
1139
|
-
making any other change that may need a rollback:
|
|
1139
|
+
making any other non-Git change that may need a rollback:
|
|
1140
1140
|
\`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
|
|
1141
1141
|
Add one \`--decision <id>\` for each existing decision this round may change.
|
|
1142
|
-
|
|
1143
|
-
|
|
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.
|
|
1144
1147
|
2. **Execute only the intent.** Scope change? Close the current intent
|
|
1145
1148
|
(\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
|
|
1146
1149
|
3. **Verify, then close**: run the declared verification, then
|
|
@@ -1153,9 +1156,9 @@ MCP and lifecycle hooks are optional adapters.
|
|
|
1153
1156
|
the final content hash is recorded. Interrupted reconciliation is recovered
|
|
1154
1157
|
by the next linked \`decision update\` or successful \`end\`. Closing as
|
|
1155
1158
|
\`failed\` or \`abandoned\` cancels pending recovery for that intent.
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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.
|
|
1159
1162
|
4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
|
|
1160
1163
|
doing anything else. The open intent is the source of truth: resume it when its
|
|
1161
1164
|
objective still matches the current task; otherwise close it (\`partial\` or
|
|
@@ -1175,7 +1178,34 @@ ${INTENT_PROTOCOL_END}`;
|
|
|
1175
1178
|
}
|
|
1176
1179
|
|
|
1177
1180
|
function previousIntentProtocolBlock(version) {
|
|
1178
|
-
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(
|
|
1179
1209
|
'\nAfter a merge collision, run `driftseal absorb` rather than editing the log;\n' +
|
|
1180
1210
|
'if both sides still have an open intent, add `--abandon-theirs` or\n' +
|
|
1181
1211
|
'`--abandon-ours`.',
|
|
@@ -1308,7 +1338,9 @@ Commit \`.decision-log/\` with the code.`;
|
|
|
1308
1338
|
}
|
|
1309
1339
|
|
|
1310
1340
|
function previousDecisionProtocolBlock(version) {
|
|
1311
|
-
const
|
|
1341
|
+
const v9 = decisionProtocolBlock(version);
|
|
1342
|
+
if (version >= 9) return v9;
|
|
1343
|
+
const v8 = v9.replace(
|
|
1312
1344
|
'\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
|
|
1313
1345
|
'concurrent edits of a shared decision are not auto-merged.',
|
|
1314
1346
|
''
|
|
@@ -1654,7 +1686,7 @@ function directoryDigest(directory) {
|
|
|
1654
1686
|
function visit(current, relative) {
|
|
1655
1687
|
const stat = fs.lstatSync(current);
|
|
1656
1688
|
if (stat.isDirectory()) {
|
|
1657
|
-
digest.update(`directory\0${relative}\0
|
|
1689
|
+
digest.update(`directory\0${relative}\0`);
|
|
1658
1690
|
for (const name of fs.readdirSync(current).sort()) {
|
|
1659
1691
|
visit(path.join(current, name), relative ? path.join(relative, name) : name);
|
|
1660
1692
|
}
|
|
@@ -2042,18 +2074,23 @@ function runHookReminder(event, argv) {
|
|
|
2042
2074
|
return { changed: true, event, format };
|
|
2043
2075
|
}
|
|
2044
2076
|
|
|
2045
|
-
function
|
|
2077
|
+
function gitCaptureRaw(args, cwd = process.cwd()) {
|
|
2046
2078
|
try {
|
|
2047
2079
|
return execFileSync('git', args, {
|
|
2048
2080
|
cwd,
|
|
2049
2081
|
encoding: 'utf8',
|
|
2050
2082
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2051
|
-
})
|
|
2083
|
+
});
|
|
2052
2084
|
} catch {
|
|
2053
2085
|
return null;
|
|
2054
2086
|
}
|
|
2055
2087
|
}
|
|
2056
2088
|
|
|
2089
|
+
function gitCapture(args, cwd = process.cwd()) {
|
|
2090
|
+
const output = gitCaptureRaw(args, cwd);
|
|
2091
|
+
return output === null ? null : output.trim();
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2057
2094
|
function isGitWorkTree(cwd = process.cwd()) {
|
|
2058
2095
|
return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
|
|
2059
2096
|
}
|
|
@@ -2072,6 +2109,32 @@ function gitMergeBase(cwd = process.cwd()) {
|
|
|
2072
2109
|
return gitCapture(['merge-base', 'HEAD', other], cwd);
|
|
2073
2110
|
}
|
|
2074
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
|
+
|
|
2075
2138
|
function gitDecisionIds(treeish, cwd = process.cwd()) {
|
|
2076
2139
|
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
|
|
2077
2140
|
if (!out) return new Set();
|
|
@@ -2091,7 +2154,7 @@ function gitDecisionEntries(treeish, cwd = process.cwd()) {
|
|
|
2091
2154
|
const name = path.basename(file);
|
|
2092
2155
|
const match = name.match(/^(\d{4,})-.*\.md$/);
|
|
2093
2156
|
if (!match) continue;
|
|
2094
|
-
const content =
|
|
2157
|
+
const content = gitReadFile(treeish, file, cwd);
|
|
2095
2158
|
if (content === null) continue;
|
|
2096
2159
|
entries.push({
|
|
2097
2160
|
id: normalizeDecisionId(match[1]),
|
|
@@ -2103,6 +2166,11 @@ function gitDecisionEntries(treeish, cwd = process.cwd()) {
|
|
|
2103
2166
|
return entries;
|
|
2104
2167
|
}
|
|
2105
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
|
+
|
|
2106
2174
|
function canonicalEvent(event) {
|
|
2107
2175
|
return Object.fromEntries(
|
|
2108
2176
|
Object.entries(event)
|
|
@@ -2210,15 +2278,39 @@ function splitDuplicateDecisions(entries) {
|
|
|
2210
2278
|
return { ours, theirs };
|
|
2211
2279
|
}
|
|
2212
2280
|
|
|
2213
|
-
function
|
|
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() }) {
|
|
2214
2301
|
const mappings = [];
|
|
2215
2302
|
const copies = [];
|
|
2216
2303
|
const decisionMap = new Map();
|
|
2304
|
+
const hashMap = new Map();
|
|
2217
2305
|
const usedIds = new Set(oursEntries.map((entry) => entry.id));
|
|
2218
2306
|
const oursById = new Map();
|
|
2307
|
+
const baseById = new Map();
|
|
2219
2308
|
for (const entry of oursEntries) {
|
|
2220
2309
|
if (!oursById.has(entry.id)) oursById.set(entry.id, entry);
|
|
2221
2310
|
}
|
|
2311
|
+
for (const entry of baseEntries) {
|
|
2312
|
+
if (!baseById.has(entry.id)) baseById.set(entry.id, entry);
|
|
2313
|
+
}
|
|
2222
2314
|
|
|
2223
2315
|
for (const entry of theirsEntries) {
|
|
2224
2316
|
const ours = oursById.get(entry.id);
|
|
@@ -2234,27 +2326,73 @@ function planDecisionAbsorb({ oursEntries, theirsEntries, baseIds }) {
|
|
|
2234
2326
|
continue;
|
|
2235
2327
|
}
|
|
2236
2328
|
const oursContent = ours.content !== undefined ? ours.content : fs.readFileSync(ours.path, 'utf8');
|
|
2237
|
-
|
|
2238
|
-
|
|
2329
|
+
const oursHash = contentHash(oursContent);
|
|
2330
|
+
const theirsHash = contentHash(theirsContent);
|
|
2331
|
+
if (oursHash === theirsHash) {
|
|
2332
|
+
if (ours.file !== entry.file) {
|
|
2333
|
+
copies.push({
|
|
2334
|
+
fromFile: entry.file,
|
|
2335
|
+
toFile: ours.file,
|
|
2336
|
+
content: oursContent,
|
|
2337
|
+
removeFile: entry.file,
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
continue;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
const base = baseById.get(entry.id);
|
|
2344
|
+
if (base) {
|
|
2345
|
+
const baseContent = base.content !== undefined ? base.content : fs.readFileSync(base.path, 'utf8');
|
|
2346
|
+
const baseHash = contentHash(baseContent);
|
|
2347
|
+
if (oursHash === baseHash) {
|
|
2348
|
+
copies.push({
|
|
2349
|
+
fromFile: entry.file,
|
|
2350
|
+
toFile: ours.file,
|
|
2351
|
+
content: theirsContent,
|
|
2352
|
+
removeFile: entry.file !== ours.file ? entry.file : null,
|
|
2353
|
+
});
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
2356
|
+
if (theirsHash === baseHash) {
|
|
2357
|
+
if (entry.file !== ours.file) {
|
|
2358
|
+
copies.push({
|
|
2359
|
+
fromFile: entry.file,
|
|
2360
|
+
toFile: ours.file,
|
|
2361
|
+
content: oursContent,
|
|
2362
|
+
removeFile: entry.file,
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2239
2367
|
fail(
|
|
2240
2368
|
`decision ${entry.id} was edited on both sides; resolve ${ours.file} manually before absorbing`
|
|
2241
2369
|
);
|
|
2242
2370
|
}
|
|
2371
|
+
if (baseIds.has(entry.id)) {
|
|
2372
|
+
fail(
|
|
2373
|
+
`decision ${entry.id} was edited on both sides or its base content is unavailable; ` +
|
|
2374
|
+
`resolve ${ours.file} manually before absorbing`
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2243
2377
|
const newId = nextDecisionIdFromIds(usedIds);
|
|
2244
2378
|
usedIds.add(newId);
|
|
2245
2379
|
decisionMap.set(entry.id, newId);
|
|
2246
2380
|
mappings.push({ kind: 'decision', from: entry.id, to: newId });
|
|
2381
|
+
const rewritten = rewriteDecisionId(theirsContent, newId);
|
|
2382
|
+
const originalHash = contentHash(theirsContent);
|
|
2383
|
+
const rewrittenHash = contentHash(rewritten);
|
|
2384
|
+
if (originalHash !== rewrittenHash) hashMap.set(originalHash, rewrittenHash);
|
|
2247
2385
|
copies.push({
|
|
2248
2386
|
fromFile: entry.file,
|
|
2249
2387
|
toFile: `${newId}-${decisionSlugFromFile(entry.file)}.md`,
|
|
2250
|
-
content:
|
|
2388
|
+
content: rewritten,
|
|
2251
2389
|
removeFile: entry.file !== ours.file ? entry.file : null,
|
|
2252
2390
|
});
|
|
2253
2391
|
}
|
|
2254
|
-
return { decisionMap, mappings, copies };
|
|
2392
|
+
return { decisionMap, hashMap, mappings, copies };
|
|
2255
2393
|
}
|
|
2256
2394
|
|
|
2257
|
-
function remapEvent(event, intentMap, decisionMap) {
|
|
2395
|
+
function remapEvent(event, intentMap, decisionMap, hashMap = new Map()) {
|
|
2258
2396
|
const next = { ...event };
|
|
2259
2397
|
if (intentMap.has(event.id)) next.id = intentMap.get(event.id);
|
|
2260
2398
|
if (Array.isArray(next.decisions) && next.decisions.length > 0) {
|
|
@@ -2267,10 +2405,15 @@ function remapEvent(event, intentMap, decisionMap) {
|
|
|
2267
2405
|
const normalized = normalizeDecisionId(next.decisionId);
|
|
2268
2406
|
if (decisionMap.has(normalized)) next.decisionId = decisionMap.get(normalized);
|
|
2269
2407
|
}
|
|
2408
|
+
for (const field of ['oldHash', 'newHash', 'fileHash']) {
|
|
2409
|
+
if (typeof next[field] === 'string' && hashMap.has(next[field])) {
|
|
2410
|
+
next[field] = hashMap.get(next[field]);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2270
2413
|
return next;
|
|
2271
2414
|
}
|
|
2272
2415
|
|
|
2273
|
-
function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
|
|
2416
|
+
function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
|
|
2274
2417
|
const intentMap = new Map();
|
|
2275
2418
|
const mappings = [];
|
|
2276
2419
|
const used = [...oursUsedEvents];
|
|
@@ -2282,22 +2425,24 @@ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
|
|
|
2282
2425
|
intentMap.set(event.id, newId);
|
|
2283
2426
|
mappings.push({ kind: 'intent', from: event.id, to: newId });
|
|
2284
2427
|
}
|
|
2285
|
-
event = remapEvent(event, intentMap, decisionMap);
|
|
2428
|
+
event = remapEvent(event, intentMap, decisionMap, hashMap);
|
|
2286
2429
|
used.push(event);
|
|
2287
2430
|
return { event };
|
|
2288
2431
|
});
|
|
2289
2432
|
return { records, mappings };
|
|
2290
2433
|
}
|
|
2291
2434
|
|
|
2292
|
-
function repairDuplicateIntentRecords(records, decisionMap) {
|
|
2435
|
+
function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map()) {
|
|
2293
2436
|
const seenBegins = new Set();
|
|
2294
2437
|
const intentMap = new Map();
|
|
2295
2438
|
const used = [];
|
|
2296
2439
|
const mappings = [];
|
|
2297
2440
|
const result = [];
|
|
2441
|
+
let incomingSide = false;
|
|
2298
2442
|
for (const record of records) {
|
|
2299
2443
|
let event = record.event;
|
|
2300
2444
|
if (event.type === 'begin' && seenBegins.has(event.id)) {
|
|
2445
|
+
incomingSide = true;
|
|
2301
2446
|
const { date } = parseIntentId(event.id);
|
|
2302
2447
|
const newId = nextIdForDate(date, used);
|
|
2303
2448
|
intentMap.set(event.id, newId);
|
|
@@ -2305,12 +2450,17 @@ function repairDuplicateIntentRecords(records, decisionMap) {
|
|
|
2305
2450
|
} else if (event.type === 'begin') {
|
|
2306
2451
|
seenBegins.add(event.id);
|
|
2307
2452
|
}
|
|
2308
|
-
const remapped = remapEvent(
|
|
2453
|
+
const remapped = remapEvent(
|
|
2454
|
+
event,
|
|
2455
|
+
intentMap,
|
|
2456
|
+
incomingSide ? decisionMap : new Map(),
|
|
2457
|
+
incomingSide ? hashMap : new Map()
|
|
2458
|
+
);
|
|
2309
2459
|
const changed = remapped !== event && JSON.stringify(remapped) !== JSON.stringify(event);
|
|
2310
2460
|
result.push(changed ? { event: remapped } : record);
|
|
2311
2461
|
used.push(result.at(-1).event);
|
|
2312
2462
|
}
|
|
2313
|
-
return { records: result, mappings };
|
|
2463
|
+
return { records: result, mappings, incomingSide };
|
|
2314
2464
|
}
|
|
2315
2465
|
|
|
2316
2466
|
function serializeRecords(records) {
|
|
@@ -2479,6 +2629,7 @@ function finishAbsorb({
|
|
|
2479
2629
|
outputFile,
|
|
2480
2630
|
intentCount,
|
|
2481
2631
|
allowConflict = false,
|
|
2632
|
+
followupMessage = null,
|
|
2482
2633
|
}) {
|
|
2483
2634
|
const { abandoned, conflict } = resolveOpenIntents(result, oursRecords, theirsRecords, abandon, {
|
|
2484
2635
|
allowConflict,
|
|
@@ -2501,12 +2652,13 @@ function finishAbsorb({
|
|
|
2501
2652
|
if (conflict) {
|
|
2502
2653
|
printLine('multiple intents remain in progress; re-run with --abandon-theirs or --abandon-ours');
|
|
2503
2654
|
}
|
|
2655
|
+
if (followupMessage) printLine(followupMessage);
|
|
2504
2656
|
return {
|
|
2505
2657
|
mappings,
|
|
2506
2658
|
abandoned,
|
|
2507
2659
|
copies: copies.map((item) => item.toFile),
|
|
2508
2660
|
outputFile,
|
|
2509
|
-
exitCode: conflict ? 1 : 0,
|
|
2661
|
+
exitCode: conflict || followupMessage ? 1 : 0,
|
|
2510
2662
|
};
|
|
2511
2663
|
}
|
|
2512
2664
|
|
|
@@ -2515,12 +2667,14 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
|
|
|
2515
2667
|
const decisionPlan = planDecisionAbsorb({
|
|
2516
2668
|
oursEntries: options.oursDecisionEntries || [],
|
|
2517
2669
|
theirsEntries: options.theirsDecisionEntries || [],
|
|
2670
|
+
baseEntries: options.baseDecisionEntries || [],
|
|
2518
2671
|
baseIds: options.baseDecisionIds || new Set(),
|
|
2519
2672
|
});
|
|
2520
2673
|
const remapped = remapTheirsRecords(
|
|
2521
2674
|
streams.theirsNew,
|
|
2522
2675
|
[...streams.base, ...streams.oursNew].map((record) => record.event),
|
|
2523
|
-
decisionPlan.decisionMap
|
|
2676
|
+
decisionPlan.decisionMap,
|
|
2677
|
+
decisionPlan.hashMap
|
|
2524
2678
|
);
|
|
2525
2679
|
const result = [...streams.base, ...streams.oursNew, ...remapped.records];
|
|
2526
2680
|
return finishAbsorb({
|
|
@@ -2533,13 +2687,61 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
|
|
|
2533
2687
|
dryRun: options.dryRun,
|
|
2534
2688
|
outputFile: options.outputFile,
|
|
2535
2689
|
allowConflict: options.allowConflict,
|
|
2690
|
+
followupMessage: options.followupMessage,
|
|
2536
2691
|
intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
|
|
2537
2692
|
});
|
|
2538
2693
|
}
|
|
2539
2694
|
|
|
2695
|
+
function gitAbsorbRepairContext(records, decisionEntries) {
|
|
2696
|
+
const pending = gitOtherHead();
|
|
2697
|
+
if (pending) {
|
|
2698
|
+
return {
|
|
2699
|
+
ours: 'HEAD',
|
|
2700
|
+
theirs: pending,
|
|
2701
|
+
base: gitMergeBaseFor('HEAD', pending),
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateIntentBegins(records)) return null;
|
|
2705
|
+
const parents = gitMergeParents();
|
|
2706
|
+
if (!parents) return null;
|
|
2707
|
+
return {
|
|
2708
|
+
...parents,
|
|
2709
|
+
base: gitMergeBaseFor(parents.ours, parents.theirs),
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function absorbFromGitContext(context, { abandon, dryRun, outputFile }) {
|
|
2714
|
+
const baseRecords = context.base ? gitIntentRecords(context.base) : [];
|
|
2715
|
+
return absorbFromStreams(
|
|
2716
|
+
gitIntentRecords(context.ours),
|
|
2717
|
+
gitIntentRecords(context.theirs),
|
|
2718
|
+
baseRecords,
|
|
2719
|
+
{
|
|
2720
|
+
abandon,
|
|
2721
|
+
dryRun,
|
|
2722
|
+
outputFile,
|
|
2723
|
+
oursDecisionEntries: gitDecisionEntries(context.ours),
|
|
2724
|
+
theirsDecisionEntries: gitDecisionEntries(context.theirs),
|
|
2725
|
+
baseDecisionEntries: context.base ? gitDecisionEntries(context.base) : [],
|
|
2726
|
+
baseDecisionIds: context.base
|
|
2727
|
+
? gitDecisionIds(context.base)
|
|
2728
|
+
: collectDecisionIdsFromEvents(baseRecords.map((record) => record.event)),
|
|
2729
|
+
}
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2540
2733
|
function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
2541
2734
|
const oursFile = logFile();
|
|
2542
2735
|
const loaded = loadAbsorbSide(oursFile, oursFile, { repairTail: true, allowMissing: !otherFile });
|
|
2736
|
+
const currentDecisionEntries = !otherFile
|
|
2737
|
+
? listDecisionEntries(decisionDir(), { allowDuplicates: true })
|
|
2738
|
+
: [];
|
|
2739
|
+
const gitContext = !otherFile
|
|
2740
|
+
? gitAbsorbRepairContext(loaded.records || [], currentDecisionEntries)
|
|
2741
|
+
: null;
|
|
2742
|
+
if (gitContext) {
|
|
2743
|
+
return absorbFromGitContext(gitContext, { abandon, dryRun, outputFile: oursFile });
|
|
2744
|
+
}
|
|
2543
2745
|
if (loaded.conflict) {
|
|
2544
2746
|
const baseIds = collectDecisionIdsFromEvents(
|
|
2545
2747
|
loaded.ours.slice(0, commonPrefixLength(loaded.ours, loaded.theirs)).map((record) => record.event)
|
|
@@ -2555,22 +2757,33 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
|
2555
2757
|
? listDecisionEntries(otherDecisions)
|
|
2556
2758
|
: splitDuplicateDecisions(listDecisionEntries(decisionDir(), { allowDuplicates: true })).theirs,
|
|
2557
2759
|
baseDecisionIds,
|
|
2760
|
+
baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
2558
2761
|
});
|
|
2559
2762
|
}
|
|
2560
2763
|
|
|
2561
2764
|
if (!otherFile) {
|
|
2562
|
-
const oursEntries =
|
|
2765
|
+
const oursEntries = currentDecisionEntries;
|
|
2563
2766
|
const split = splitDuplicateDecisions(oursEntries);
|
|
2564
|
-
const repaired = repairDuplicateIntentRecords(loaded.records, new Map());
|
|
2565
2767
|
const gitBase = gitMergeBase();
|
|
2566
2768
|
const decisionPlan = planDecisionAbsorb({
|
|
2567
2769
|
oursEntries: split.ours,
|
|
2568
2770
|
theirsEntries: split.theirs,
|
|
2771
|
+
baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
2569
2772
|
baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
|
|
2570
2773
|
});
|
|
2571
|
-
const
|
|
2572
|
-
|
|
2573
|
-
|
|
2774
|
+
const repaired = repairDuplicateIntentRecords(
|
|
2775
|
+
loaded.records,
|
|
2776
|
+
decisionPlan.decisionMap,
|
|
2777
|
+
decisionPlan.hashMap
|
|
2778
|
+
);
|
|
2779
|
+
if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
|
|
2780
|
+
fail(
|
|
2781
|
+
'cannot determine which intent records own the duplicate decision; ' +
|
|
2782
|
+
'run absorb during the merge or provide the incoming log and decision directory'
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
const result = repaired.records;
|
|
2786
|
+
const mappings = [...repaired.mappings, ...decisionPlan.mappings];
|
|
2574
2787
|
const remappedIds = new Set(
|
|
2575
2788
|
mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
|
|
2576
2789
|
);
|
|
@@ -2606,6 +2819,7 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
|
2606
2819
|
outputFile: oursFile,
|
|
2607
2820
|
oursDecisionEntries: listDecisionEntries(decisionDir()),
|
|
2608
2821
|
theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
|
|
2822
|
+
baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
2609
2823
|
baseDecisionIds,
|
|
2610
2824
|
});
|
|
2611
2825
|
}
|
|
@@ -2617,18 +2831,36 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
|
|
|
2617
2831
|
if (base.conflict || ours.conflict || theirs.conflict) {
|
|
2618
2832
|
fail('git merge driver received a log that still contains conflict markers');
|
|
2619
2833
|
}
|
|
2620
|
-
const otherHead =
|
|
2621
|
-
|
|
2834
|
+
const otherHead =
|
|
2835
|
+
gitOtherHead() || gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
|
|
2836
|
+
const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
|
|
2837
|
+
let followupMessage = null;
|
|
2838
|
+
if (!otherHead) {
|
|
2839
|
+
followupMessage =
|
|
2840
|
+
'incoming Git tree could not be identified safely; re-run driftseal absorb after Git stops the merge';
|
|
2841
|
+
} else {
|
|
2842
|
+
const decisionPlan = planDecisionAbsorb({
|
|
2843
|
+
oursEntries: gitDecisionEntries('HEAD'),
|
|
2844
|
+
theirsEntries: gitDecisionEntries(otherHead),
|
|
2845
|
+
baseEntries: mergeBase ? gitDecisionEntries(mergeBase) : [],
|
|
2846
|
+
baseIds: mergeBase
|
|
2847
|
+
? gitDecisionIds(mergeBase)
|
|
2848
|
+
: collectDecisionIdsFromEvents(base.records.map((record) => record.event)),
|
|
2849
|
+
});
|
|
2850
|
+
const requiresDecisionRepair =
|
|
2851
|
+
decisionPlan.mappings.length > 0 ||
|
|
2852
|
+
decisionPlan.copies.some((copy) => copy.removeFile && copy.removeFile !== copy.toFile);
|
|
2853
|
+
if (requiresDecisionRepair) {
|
|
2854
|
+
followupMessage =
|
|
2855
|
+
'decision ids require worktree repair; run driftseal absorb, then stage the repaired logs';
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2622
2858
|
return absorbFromStreams(ours.records, theirs.records, base.records, {
|
|
2623
2859
|
abandon,
|
|
2624
2860
|
dryRun,
|
|
2625
2861
|
outputFile: oursFile,
|
|
2626
2862
|
allowConflict: !abandon,
|
|
2627
|
-
|
|
2628
|
-
theirsDecisionEntries: otherHead ? gitDecisionEntries(otherHead) : [],
|
|
2629
|
-
baseDecisionIds: mergeBase
|
|
2630
|
-
? gitDecisionIds(mergeBase)
|
|
2631
|
-
: collectDecisionIdsFromEvents(base.records.map((record) => record.event)),
|
|
2863
|
+
followupMessage,
|
|
2632
2864
|
});
|
|
2633
2865
|
}
|
|
2634
2866
|
|
|
@@ -3099,6 +3331,7 @@ const commands = {
|
|
|
3099
3331
|
protocolEol(previousIntentProtocolBlock(6), eol),
|
|
3100
3332
|
protocolEol(previousIntentProtocolBlock(7), eol),
|
|
3101
3333
|
protocolEol(previousIntentProtocolBlock(8), eol),
|
|
3334
|
+
protocolEol(previousIntentProtocolBlock(9), eol),
|
|
3102
3335
|
],
|
|
3103
3336
|
knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
|
|
3104
3337
|
});
|
|
@@ -3117,6 +3350,7 @@ const commands = {
|
|
|
3117
3350
|
protocolEol(previousDecisionProtocolBlock(6), eol),
|
|
3118
3351
|
protocolEol(previousDecisionProtocolBlock(7), eol),
|
|
3119
3352
|
protocolEol(previousDecisionProtocolBlock(8), eol),
|
|
3353
|
+
protocolEol(previousDecisionProtocolBlock(9), eol),
|
|
3120
3354
|
],
|
|
3121
3355
|
knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
|
|
3122
3356
|
});
|