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 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
@@ -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 `.gitignore` or the
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 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
164
- worktree, they are listed in its `.gitignore`.
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 worktree, `begin` parks the open outcome in Git metadata so it does not
284
- dirty the tracked log. `end` flushes the lineage to
285
- `.seal/outcomes/events.jsonl`. The event log is append-only during normal work.
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 current lane and derived lane index live in Git metadata for a default
333
- repository seal, or beside a custom seal (`outcomes/.current-lane` and
334
- `outcomes/.lane-index.json`). When the custom seal sits inside a Git
335
- worktree, those sidecars are gitignored. They are reconstructable and are not
336
- part of the committed WAL.
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 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
@@ -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
- 不会替你修改 `.gitignore` 或 Git index。
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
- 派生的 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` 忽略。
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
- Git worktree 中,`begin` 会把 open outcome park 到 Git metadata,避免弄脏 tracked
257
- log;`end` 再把完整 lineage 写入 `.seal/outcomes/events.jsonl`。正常工作期间 event log
258
- 保持 append-only。
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
- - 当前 lane 与派生 lane index 对默认 repo seal 存在 Git metadata 里,对自定义 seal
305
- 则放在旁边(`outcomes/.current-lane` 与 `outcomes/.lane-index.json`)。自定义
306
- seal Git worktree 内时,这些 sidecar 会被 gitignore。它们可以重建,不是
307
- committed WAL 的一部分。
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
+ }