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 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 18 or newer.
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 lane index caches fold state in Git metadata (or beside a custom
159
- seal). Incremental rebuilds follow `indexedThrough` and `indexedLines`; a full
160
- rebuild happens when the log identity changes. Per-lane heads, reverse links,
161
- and WAL byte ranges are stored for a seek path that is not consumed yet. The
162
- index is reconstructable and is not committed with the log. Custom-home
163
- sidecars sit next to `events.jsonl`. When that directory is inside a Git
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 lane index live in Git metadata for a default
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/.lane-index.json`). When the custom seal sits inside a Git
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 18 或更高版本。
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
- 派生的 lane index 缓存 fold 状态,放在 Git metadata(或自定义 seal 旁边)。
144
- 增量重建跟随 `indexedThrough` `indexedLines`;log 身份变化时全量重建。每条
145
- lane head、反向链接和 WAL byte range 会写入 index,供尚未接入的 seek 路径使用。
146
- 它可以重建,不会随 log 一起提交。自定义 home 下的 sidecar 放在 `events.jsonl`
147
- 旁边;该目录在 Git worktree 内时,由目录里的 `.gitignore` 忽略。
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 与派生 lane index 对默认 repo seal 存在 Git metadata 里,对自定义 seal
305
- 则放在旁边(`outcomes/.current-lane` 与 `outcomes/.lane-index.json`)。自定义
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
+ }