driftseal 2.1.0 → 3.0.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 +21 -9
- package/README.zh-CN.md +17 -8
- package/benchmark/recent-log.js +181 -0
- package/bin/driftseal.js +430 -474
- 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/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
|
|
@@ -155,12 +156,23 @@ has, `status`, `log`, and `lane` fall back to `main` with a warning; `begin`
|
|
|
155
156
|
still refuses until you `lane switch main` or add the lane. `log --last N` can
|
|
156
157
|
return more than N records when an open outcome sits on another lane.
|
|
157
158
|
|
|
158
|
-
A derived
|
|
159
|
-
seal).
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
A derived SQLite outcome index stores folded records in Git metadata (or beside
|
|
160
|
+
a custom seal). It is a disposable read model; `events.jsonl` remains the only
|
|
161
|
+
canonical history. Incremental sync follows `indexedThrough` and
|
|
162
|
+
`indexedLines`. File identity makes unchanged hot reads constant-time, while a
|
|
163
|
+
full checksum of the previously indexed WAL prefix validates every incremental
|
|
164
|
+
catch-up. A source rewrite, incompatible schema, malformed indexed row, or
|
|
165
|
+
SQLite read error triggers a full rebuild.
|
|
166
|
+
|
|
167
|
+
`log --last N` uses a `(lane, reclaimed, ordinal)` SQLite index and reads only
|
|
168
|
+
the selected outcome rows plus open outcomes from other lanes. Parked events
|
|
169
|
+
are checked and folded as a targeted overlay using indexed committed event
|
|
170
|
+
identities, so an open outcome does not force a committed-WAL scan. When a
|
|
171
|
+
lock-free read sees a missing or stale database, DriftSeal folds the canonical
|
|
172
|
+
WAL in memory instead of serving stale index data. Stored WAL byte ranges remain
|
|
173
|
+
available for future projections but are not required for recent-log lookup.
|
|
174
|
+
The database is reconstructable and is not committed with the log. Custom-home
|
|
175
|
+
sidecars sit next to `events.jsonl`; when that directory is inside a Git
|
|
164
176
|
worktree, they are listed in its `.gitignore`.
|
|
165
177
|
|
|
166
178
|
## Decisions and MADR
|
|
@@ -329,9 +341,9 @@ MADR tools, and the three migration tools. Resources are:
|
|
|
329
341
|
DriftSeal; use `reclaim`, `unreclaim`, `lane`, and `absorb` instead of manual edits.
|
|
330
342
|
- `.seal/madr/` stores numbered MADR documents.
|
|
331
343
|
- `$DRIFTSEAL_HOME` replaces the `.seal` root.
|
|
332
|
-
- The current lane and derived
|
|
344
|
+
- The current lane and derived SQLite outcome index live in Git metadata for a default
|
|
333
345
|
repository seal, or beside a custom seal (`outcomes/.current-lane` and
|
|
334
|
-
`outcomes/.
|
|
346
|
+
`outcomes/.outcome-index.sqlite`). When the custom seal sits inside a Git
|
|
335
347
|
worktree, those sidecars are gitignored. They are reconstructable and are not
|
|
336
348
|
part of the committed WAL.
|
|
337
349
|
- Advisory hooks remind agents about lifecycle state but never broaden the
|
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
|
|
@@ -140,11 +141,19 @@ lane 不能改名或删除。`lane add` 打错的名字会一直出现在 `drift
|
|
|
140
141
|
直到 `lane switch main` 或把该 lane 加回来。`log --last N` 在 open outcome 属于
|
|
141
142
|
别的 lane 时,返回条数可以多于 N。
|
|
142
143
|
|
|
143
|
-
派生的
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
派生的 SQLite outcome index 放在 Git metadata(或自定义 seal 旁边)。它只是可以
|
|
145
|
+
随时删除重建的 read model;`events.jsonl` 仍是唯一 canonical history。增量同步跟随
|
|
146
|
+
`indexedThrough` 和 `indexedLines`。未变化的 hot read 用 file identity 做常量时间
|
|
147
|
+
校验;增量追赶前会校验此前全部 WAL prefix 的 checksum。source 被改写、schema
|
|
148
|
+
不兼容、indexed row 损坏或 SQLite read 失败时,DriftSeal 会全量重建。
|
|
149
|
+
|
|
150
|
+
`log --last N` 使用 `(lane, reclaimed, ordinal)` SQLite index,只读取命中的 outcome
|
|
151
|
+
row,并补上其他 lane 的 open outcome。parked event 会依据 index 中保存的 committed
|
|
152
|
+
event identity 做定向 overlay,不再因此重扫 committed WAL。无锁读取遇到缺失或 stale
|
|
153
|
+
database 时,会在内存中 fold canonical WAL,绝不会返回 stale index。保存的 WAL byte
|
|
154
|
+
range 留给后续 projection 使用,但 recent-log lookup 不依赖它。database 可以重建,
|
|
155
|
+
不会随 log 一起提交。自定义 home 下的 sidecar 放在 `events.jsonl` 旁边;该目录在 Git
|
|
156
|
+
worktree 内时,由目录里的 `.gitignore` 忽略。
|
|
148
157
|
|
|
149
158
|
## Decision 与 MADR
|
|
150
159
|
|
|
@@ -301,8 +310,8 @@ resources 为:
|
|
|
301
310
|
`absorb`,不要手改。
|
|
302
311
|
- `.seal/madr/` 保存编号化 MADR。
|
|
303
312
|
- `$DRIFTSEAL_HOME` 替换整个 `.seal` root。
|
|
304
|
-
- 当前 lane 与派生
|
|
305
|
-
则放在旁边(`outcomes/.current-lane` 与 `outcomes/.
|
|
313
|
+
- 当前 lane 与派生 SQLite outcome index 对默认 repo seal 存在 Git metadata 里,对自定义 seal
|
|
314
|
+
则放在旁边(`outcomes/.current-lane` 与 `outcomes/.outcome-index.sqlite`)。自定义
|
|
306
315
|
seal 在 Git worktree 内时,这些 sidecar 会被 gitignore。它们可以重建,不是
|
|
307
316
|
committed WAL 的一部分。
|
|
308
317
|
- advisory hook 只提示 lifecycle 状态,不会扩大 repo 中 `AGENTS.md` 的政策边界。
|
|
@@ -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
|
+
}
|