driftseal 2.1.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -18
- package/README.zh-CN.md +34 -14
- package/benchmark/recent-log.js +181 -0
- package/bin/driftseal.js +561 -532
- package/lib/outcome-fold.js +377 -0
- package/lib/outcome-index-sqlite.js +802 -0
- package/lib/sqlite-runtime.js +71 -0
- package/package.json +7 -2
- package/skills/use-driftseal/SKILL.md +6 -5
- package/test/package-smoke.js +150 -0
package/README.md
CHANGED
|
@@ -39,7 +39,8 @@ DriftSeal v2 is an outcome log rather than an intent-per-step log.
|
|
|
39
39
|
|
|
40
40
|
## Install
|
|
41
41
|
|
|
42
|
-
DriftSeal requires Node.js
|
|
42
|
+
DriftSeal requires Node.js 22.13 or newer. Its derived outcome index uses the
|
|
43
|
+
built-in `node:sqlite` module; no native database package is installed.
|
|
43
44
|
|
|
44
45
|
```sh
|
|
45
46
|
npm install --global driftseal
|
|
@@ -65,8 +66,9 @@ fresh clone because Git config is local to each clone.
|
|
|
65
66
|
|
|
66
67
|
Use `driftseal init --lang <BCP-47-tag>` to choose the prose language stored in
|
|
67
68
|
outcome and MADR records. Use `--local-log` only when `.seal/` should remain
|
|
68
|
-
untracked; DriftSeal reports tracked state but does not edit
|
|
69
|
-
Git index.
|
|
69
|
+
untracked; DriftSeal reports tracked state but does not edit the repository-root
|
|
70
|
+
`.gitignore` or the Git index. When the seal sits inside a Git worktree, `init`
|
|
71
|
+
may still write `.seal/outcomes/.gitignore` so derived sidecars stay untracked.
|
|
70
72
|
|
|
71
73
|
## Core workflow
|
|
72
74
|
|
|
@@ -155,13 +157,25 @@ has, `status`, `log`, and `lane` fall back to `main` with a warning; `begin`
|
|
|
155
157
|
still refuses until you `lane switch main` or add the lane. `log --last N` can
|
|
156
158
|
return more than N records when an open outcome sits on another lane.
|
|
157
159
|
|
|
158
|
-
A derived
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
and
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
A derived SQLite outcome index stores folded records beside the WAL
|
|
161
|
+
(`outcomes/.outcome-index.sqlite`). It is a disposable read model;
|
|
162
|
+
`events.jsonl` remains the only canonical history. Incremental sync follows
|
|
163
|
+
`indexedThrough` and `indexedLines`. File identity makes unchanged hot reads
|
|
164
|
+
constant-time, while a full checksum of the previously indexed WAL prefix
|
|
165
|
+
validates every incremental catch-up. A source rewrite, incompatible schema,
|
|
166
|
+
malformed indexed row, or SQLite read error triggers a full rebuild.
|
|
167
|
+
|
|
168
|
+
`log --last N` uses a `(lane, reclaimed, ordinal)` SQLite index and reads only
|
|
169
|
+
the selected outcome rows plus open outcomes from other lanes. Parked events
|
|
170
|
+
are checked and folded as a targeted overlay using indexed committed event
|
|
171
|
+
identities, so an open outcome does not force a committed-WAL scan. When a
|
|
172
|
+
lock-free read sees a missing or stale database, DriftSeal folds the canonical
|
|
173
|
+
WAL in memory instead of serving stale index data. Stored WAL byte ranges remain
|
|
174
|
+
available for future projections but are not required for recent-log lookup.
|
|
175
|
+
The database is reconstructable and is not committed with the log. Sidecars sit
|
|
176
|
+
next to `events.jsonl`; when that directory is inside a Git worktree, they are
|
|
177
|
+
listed in its `.gitignore`. Keeping the file in the workspace avoids agent
|
|
178
|
+
sandboxes that deny writes under `.git/`.
|
|
165
179
|
|
|
166
180
|
## Decisions and MADR
|
|
167
181
|
|
|
@@ -280,9 +294,11 @@ migrated state.
|
|
|
280
294
|
|
|
281
295
|
## Git and merge behavior
|
|
282
296
|
|
|
283
|
-
In a Git
|
|
284
|
-
|
|
285
|
-
|
|
297
|
+
In a default Git-repository seal, `begin` parks the open outcome beside the WAL
|
|
298
|
+
(`.seal/outcomes/.in-progress.jsonl`, gitignored) so it does not dirty the
|
|
299
|
+
tracked log. Custom `$DRIFTSEAL_HOME` seals write that open outcome directly to
|
|
300
|
+
`events.jsonl`. `end` flushes a parked lineage to `.seal/outcomes/events.jsonl`.
|
|
301
|
+
The event log is append-only during normal work.
|
|
286
302
|
|
|
287
303
|
After a merge collision, run:
|
|
288
304
|
|
|
@@ -329,11 +345,19 @@ MADR tools, and the three migration tools. Resources are:
|
|
|
329
345
|
DriftSeal; use `reclaim`, `unreclaim`, `lane`, and `absorb` instead of manual edits.
|
|
330
346
|
- `.seal/madr/` stores numbered MADR documents.
|
|
331
347
|
- `$DRIFTSEAL_HOME` replaces the `.seal` root.
|
|
332
|
-
- The
|
|
333
|
-
|
|
334
|
-
`
|
|
335
|
-
|
|
336
|
-
|
|
348
|
+
- The derived SQLite outcome index and the current-lane pointer sit next to the
|
|
349
|
+
WAL (`outcomes/.outcome-index.sqlite`, `outcomes/.current-lane`). The index is
|
|
350
|
+
reconstructable from `events.jsonl`; a missing or stale current-lane pointer
|
|
351
|
+
falls back to `main`. A default repository seal also parks the open outcome and
|
|
352
|
+
local verification provenance beside the WAL
|
|
353
|
+
(`outcomes/.in-progress.jsonl`, `outcomes/.driftseal-local-outcome.json`). Those
|
|
354
|
+
two files are not reconstructable: deleting the park discards the still-open
|
|
355
|
+
outcome, and deleting provenance changes verifier trust. Custom
|
|
356
|
+
`$DRIFTSEAL_HOME` seals write open outcomes directly to the WAL and still keep
|
|
357
|
+
provenance beside that log. When the outcomes directory is inside a Git
|
|
358
|
+
worktree, the sidecars are gitignored. A default repository seal keeps one
|
|
359
|
+
ignored copy per worktree; a shared `$DRIFTSEAL_HOME` shares the files that
|
|
360
|
+
exist there.
|
|
337
361
|
- Advisory hooks remind agents about lifecycle state but never broaden the
|
|
338
362
|
repository's `AGENTS.md` policy.
|
|
339
363
|
|
package/README.zh-CN.md
CHANGED
|
@@ -33,7 +33,8 @@ DriftSeal v2 从“按步骤记录 intent”改为“按交付记录 outcome”
|
|
|
33
33
|
|
|
34
34
|
## 安装
|
|
35
35
|
|
|
36
|
-
DriftSeal 需要 Node.js
|
|
36
|
+
DriftSeal 需要 Node.js 22.13 或更高版本。派生 outcome index 使用 Node 内置的
|
|
37
|
+
`node:sqlite`,不会安装 native database package。
|
|
37
38
|
|
|
38
39
|
```sh
|
|
39
40
|
npm install --global driftseal
|
|
@@ -59,7 +60,8 @@ attribute,并配置本地 Git merge driver。Git config 不会随 clone 传播
|
|
|
59
60
|
|
|
60
61
|
`driftseal init --lang <BCP-47-tag>` 用来指定 outcome 与 MADR 正文的语言。
|
|
61
62
|
`--local-log` 会让 `.seal/` 保持本地、不被跟踪;DriftSeal 只报告当前 tracked 状态,
|
|
62
|
-
|
|
63
|
+
不会替你修改仓库根目录的 `.gitignore` 或 Git index。seal 在 Git worktree 内时,
|
|
64
|
+
`init` 仍可能写入 `.seal/outcomes/.gitignore`,用来忽略派生 sidecar。
|
|
63
65
|
|
|
64
66
|
## 基本工作流
|
|
65
67
|
|
|
@@ -140,11 +142,21 @@ lane 不能改名或删除。`lane add` 打错的名字会一直出现在 `drift
|
|
|
140
142
|
直到 `lane switch main` 或把该 lane 加回来。`log --last N` 在 open outcome 属于
|
|
141
143
|
别的 lane 时,返回条数可以多于 N。
|
|
142
144
|
|
|
143
|
-
派生的
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
派生的 SQLite outcome index 放在 WAL 旁边(`outcomes/.outcome-index.sqlite`)。
|
|
146
|
+
它只是可以随时删除重建的 read model;`events.jsonl` 仍是唯一 canonical history。
|
|
147
|
+
增量同步跟随 `indexedThrough` 和 `indexedLines`。未变化的 hot read 用 file
|
|
148
|
+
identity 做常量时间校验;增量追赶前会校验此前全部 WAL prefix 的 checksum。
|
|
149
|
+
source 被改写、schema 不兼容、indexed row 损坏或 SQLite read 失败时,DriftSeal
|
|
150
|
+
会全量重建。
|
|
151
|
+
|
|
152
|
+
`log --last N` 使用 `(lane, reclaimed, ordinal)` SQLite index,只读取命中的 outcome
|
|
153
|
+
row,并补上其他 lane 的 open outcome。parked event 会依据 index 中保存的 committed
|
|
154
|
+
event identity 做定向 overlay,不再因此重扫 committed WAL。无锁读取遇到缺失或 stale
|
|
155
|
+
database 时,会在内存中 fold canonical WAL,绝不会返回 stale index。保存的 WAL byte
|
|
156
|
+
range 留给后续 projection 使用,但 recent-log lookup 不依赖它。database 可以重建,
|
|
157
|
+
不会随 log 一起提交。sidecar 放在 `events.jsonl` 旁边;该目录在 Git worktree
|
|
158
|
+
内时,由目录里的 `.gitignore` 忽略。文件留在工作树里,避免被默认禁止写入
|
|
159
|
+
`.git/` 的 agent sandbox 拦住。
|
|
148
160
|
|
|
149
161
|
## Decision 与 MADR
|
|
150
162
|
|
|
@@ -253,9 +265,11 @@ tools 可以立刻看到 migration 后的状态。
|
|
|
253
265
|
|
|
254
266
|
## Git 与 merge
|
|
255
267
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
268
|
+
在默认 Git-repository seal 中,`begin` 会把 open outcome park 到 WAL 旁边
|
|
269
|
+
(`.seal/outcomes/.in-progress.jsonl`,已被 gitignore),避免弄脏 tracked log。
|
|
270
|
+
自定义 `$DRIFTSEAL_HOME` 会把该 open outcome 直接写入 `events.jsonl`。`end`
|
|
271
|
+
再把 parked lineage flush 到 `.seal/outcomes/events.jsonl`。正常工作期间 event
|
|
272
|
+
log 保持 append-only。
|
|
259
273
|
|
|
260
274
|
发生 merge collision 后执行:
|
|
261
275
|
|
|
@@ -301,10 +315,16 @@ resources 为:
|
|
|
301
315
|
`absorb`,不要手改。
|
|
302
316
|
- `.seal/madr/` 保存编号化 MADR。
|
|
303
317
|
- `$DRIFTSEAL_HOME` 替换整个 `.seal` root。
|
|
304
|
-
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
318
|
+
- 派生 SQLite outcome index 和当前 lane 指针放在 WAL 旁边
|
|
319
|
+
(`outcomes/.outcome-index.sqlite`、`outcomes/.current-lane`)。index 可以从
|
|
320
|
+
`events.jsonl` 重建;缺失或过期的 current-lane 会回退到 `main`。默认 repo
|
|
321
|
+
seal 还会把 open outcome 和本地 verification provenance park 在 WAL 旁边
|
|
322
|
+
(`outcomes/.in-progress.jsonl`、`outcomes/.driftseal-local-outcome.json`)。
|
|
323
|
+
这两份文件不能重建:删掉 park 会丢掉尚未 flush 的 open outcome,删掉
|
|
324
|
+
provenance 会改变 verifier 信任。自定义 `$DRIFTSEAL_HOME` 会把 open outcome
|
|
325
|
+
直接写入 WAL,provenance 仍放在该 log 旁边。outcomes 目录在 Git worktree 内时,
|
|
326
|
+
这些 sidecar 会被 gitignore。默认 repo seal 下每个 worktree 各自一份;共享的
|
|
327
|
+
`$DRIFTSEAL_HOME` 会共用那里实际存在的文件。
|
|
308
328
|
- advisory hook 只提示 lifecycle 状态,不会扩大 repo 中 `AGENTS.md` 的政策边界。
|
|
309
329
|
|
|
310
330
|
DriftSeal 不会替你判断 verification command 是否安全,也不会判断测试本身是否充分。
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { performance } = require('node:perf_hooks');
|
|
9
|
+
const { openOutcomeIndex } = require('../lib/outcome-index-sqlite.js');
|
|
10
|
+
|
|
11
|
+
const DRIFTSEAL = path.join(__dirname, '..', 'bin', 'driftseal.js');
|
|
12
|
+
const quick = process.argv.includes('--quick');
|
|
13
|
+
const large = process.argv.includes('--large');
|
|
14
|
+
const unrelatedOutcomes = large ? 100_000 : quick ? 2_000 : 10_000;
|
|
15
|
+
const laneOutcomes = large ? 1_000 : quick ? 20 : 100;
|
|
16
|
+
const samples = quick ? 5 : 20;
|
|
17
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'driftseal-recent-log-benchmark-'));
|
|
18
|
+
const home = path.join(root, 'seal');
|
|
19
|
+
const outcomeDirectory = path.join(home, 'outcomes');
|
|
20
|
+
const wal = path.join(outcomeDirectory, 'events.jsonl');
|
|
21
|
+
const indexFile = path.join(outcomeDirectory, '.outcome-index.sqlite');
|
|
22
|
+
const env = {
|
|
23
|
+
...process.env,
|
|
24
|
+
DRIFTSEAL_HOME: home,
|
|
25
|
+
DRIFTSEAL_DECISION_HOME: path.join(home, 'madr'),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function runCli(args, envOverrides = {}) {
|
|
29
|
+
return execFileSync(process.execPath, [DRIFTSEAL, ...args], {
|
|
30
|
+
cwd: root,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
env: { ...env, ...envOverrides },
|
|
33
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
34
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function event(type, fields) {
|
|
39
|
+
return {
|
|
40
|
+
logVersion: 2,
|
|
41
|
+
schemaVersion: type === 'lane_add' || fields.lane ? 2 : 1,
|
|
42
|
+
type,
|
|
43
|
+
...fields,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function outcomeEvents(id, outcome, lane, minute) {
|
|
48
|
+
const beganAt = new Date(Date.UTC(2026, 0, 1, 0, minute, 0)).toISOString();
|
|
49
|
+
const endedAt = new Date(Date.UTC(2026, 0, 1, 0, minute, 1)).toISOString();
|
|
50
|
+
return [
|
|
51
|
+
event('begin', {
|
|
52
|
+
id,
|
|
53
|
+
ts: beganAt,
|
|
54
|
+
outcome,
|
|
55
|
+
acceptance: [],
|
|
56
|
+
verify: null,
|
|
57
|
+
decisions: [],
|
|
58
|
+
...(lane === 'main' ? {} : { lane }),
|
|
59
|
+
}),
|
|
60
|
+
event('end', {
|
|
61
|
+
id,
|
|
62
|
+
ts: endedAt,
|
|
63
|
+
status: 'abandoned',
|
|
64
|
+
note: `closed ${outcome}`,
|
|
65
|
+
verifyResult: null,
|
|
66
|
+
head: null,
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeSyntheticWal() {
|
|
72
|
+
fs.mkdirSync(outcomeDirectory, { recursive: true });
|
|
73
|
+
const lines = [];
|
|
74
|
+
let sequence = 1;
|
|
75
|
+
for (let i = 0; i < unrelatedOutcomes; i++) {
|
|
76
|
+
const id = `2026-01-01-${String(sequence++).padStart(6, '0')}`;
|
|
77
|
+
lines.push(...outcomeEvents(id, `unrelated work ${i}`, 'main', i % 1440));
|
|
78
|
+
}
|
|
79
|
+
lines.push(
|
|
80
|
+
event('lane_add', {
|
|
81
|
+
id: 'lane:focus',
|
|
82
|
+
lane: 'focus',
|
|
83
|
+
description: 'Benchmark focus lane',
|
|
84
|
+
ts: '2026-01-02T00:00:00.000Z',
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
for (let i = 0; i < laneOutcomes; i++) {
|
|
88
|
+
const id = `2026-01-02-${String(sequence++).padStart(6, '0')}`;
|
|
89
|
+
lines.push(...outcomeEvents(id, `focus work ${i}`, 'focus', i));
|
|
90
|
+
}
|
|
91
|
+
fs.writeFileSync(wal, `${lines.map(JSON.stringify).join('\n')}\n`);
|
|
92
|
+
fs.writeFileSync(path.join(outcomeDirectory, '.current-lane'), 'focus\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function distribution(values) {
|
|
96
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
97
|
+
return {
|
|
98
|
+
p50: Number(sorted[Math.floor(sorted.length * 0.5)].toFixed(2)),
|
|
99
|
+
p95: Number(sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))].toFixed(2)),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function measure(action, count = samples) {
|
|
104
|
+
const durations = [];
|
|
105
|
+
let value;
|
|
106
|
+
for (let i = 0; i < count; i++) {
|
|
107
|
+
const started = performance.now();
|
|
108
|
+
value = action();
|
|
109
|
+
durations.push(performance.now() - started);
|
|
110
|
+
}
|
|
111
|
+
return { ...distribution(durations), value };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
writeSyntheticWal();
|
|
116
|
+
const cold = measure(() => runCli(['log', '--last', '3']), 1);
|
|
117
|
+
const hot = measure(() => runCli(['log', '--last', '3']));
|
|
118
|
+
const fullFold = measure(
|
|
119
|
+
() =>
|
|
120
|
+
runCli(['log', '--last', '3'], {
|
|
121
|
+
_DRIFTSEAL_TEST_DISABLE_OUTCOME_INDEX: '1',
|
|
122
|
+
}),
|
|
123
|
+
quick ? 1 : 3
|
|
124
|
+
);
|
|
125
|
+
assert.equal(hot.value, fullFold.value);
|
|
126
|
+
assert.match(hot.value, new RegExp(`focus work ${laneOutcomes - 1}`));
|
|
127
|
+
assert.doesNotMatch(hot.value, /unrelated work/);
|
|
128
|
+
|
|
129
|
+
const index = openOutcomeIndex(indexFile, { readOnly: true });
|
|
130
|
+
const direct = measure(() => index.queryRecent('focus', 3));
|
|
131
|
+
const queryPlan = index.explainRecent().map((row) => row.detail);
|
|
132
|
+
index.close();
|
|
133
|
+
assert.equal(direct.value.length, 3);
|
|
134
|
+
assert.ok(
|
|
135
|
+
queryPlan.some((detail) => /outcomes_lane_visible_ordinal/.test(detail)),
|
|
136
|
+
`expected recent query index in plan: ${queryPlan.join('; ')}`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const nextId = `2026-01-03-${String(unrelatedOutcomes + laneOutcomes + 1).padStart(6, '0')}`;
|
|
140
|
+
fs.appendFileSync(
|
|
141
|
+
wal,
|
|
142
|
+
`${outcomeEvents(nextId, 'incremental focus work', 'focus', 0)
|
|
143
|
+
.map(JSON.stringify)
|
|
144
|
+
.join('\n')}\n`
|
|
145
|
+
);
|
|
146
|
+
const incremental = measure(() => runCli(['log', '--last', '3']), 1);
|
|
147
|
+
assert.match(incremental.value, /incremental focus work/);
|
|
148
|
+
|
|
149
|
+
const startup = measure(() =>
|
|
150
|
+
execFileSync(process.execPath, ['-e', ''], {
|
|
151
|
+
cwd: root,
|
|
152
|
+
stdio: 'ignore',
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
const stats = {
|
|
156
|
+
mode: large ? 'large' : quick ? 'quick' : 'default',
|
|
157
|
+
node: process.version,
|
|
158
|
+
platform: `${process.platform}-${process.arch}`,
|
|
159
|
+
outcomes: {
|
|
160
|
+
unrelated: unrelatedOutcomes,
|
|
161
|
+
currentLane: laneOutcomes,
|
|
162
|
+
},
|
|
163
|
+
samples,
|
|
164
|
+
bytes: {
|
|
165
|
+
wal: fs.statSync(wal).size,
|
|
166
|
+
sqlite: fs.statSync(indexFile).size,
|
|
167
|
+
},
|
|
168
|
+
milliseconds: {
|
|
169
|
+
processStartup: { p50: startup.p50, p95: startup.p95 },
|
|
170
|
+
coldBuildAndRecent: { p50: cold.p50, p95: cold.p95 },
|
|
171
|
+
hotRecentCli: { p50: hot.p50, p95: hot.p95 },
|
|
172
|
+
hotRecentQuery: { p50: direct.p50, p95: direct.p95 },
|
|
173
|
+
fullFoldRecent: { p50: fullFold.p50, p95: fullFold.p95 },
|
|
174
|
+
incrementalAndRecent: { p50: incremental.p50, p95: incremental.p95 },
|
|
175
|
+
},
|
|
176
|
+
queryPlan,
|
|
177
|
+
};
|
|
178
|
+
process.stdout.write(`${JSON.stringify(stats, null, 2)}\n`);
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
181
|
+
}
|