driftseal 3.0.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
@@ -66,8 +66,9 @@ fresh clone because Git config is local to each clone.
66
66
 
67
67
  Use `driftseal init --lang <BCP-47-tag>` to choose the prose language stored in
68
68
  outcome and MADR records. Use `--local-log` only when `.seal/` should remain
69
- untracked; DriftSeal reports tracked state but does not edit `.gitignore` or the
70
- 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.
71
72
 
72
73
  ## Core workflow
73
74
 
@@ -156,13 +157,13 @@ has, `status`, `log`, and `lane` fall back to `main` with a warning; `begin`
156
157
  still refuses until you `lane switch main` or add the lane. `log --last N` can
157
158
  return more than N records when an open outcome sits on another lane.
158
159
 
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.
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.
166
167
 
167
168
  `log --last N` uses a `(lane, reclaimed, ordinal)` SQLite index and reads only
168
169
  the selected outcome rows plus open outcomes from other lanes. Parked events
@@ -171,9 +172,10 @@ identities, so an open outcome does not force a committed-WAL scan. When a
171
172
  lock-free read sees a missing or stale database, DriftSeal folds the canonical
172
173
  WAL in memory instead of serving stale index data. Stored WAL byte ranges remain
173
174
  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
176
- worktree, they are listed in its `.gitignore`.
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/`.
177
179
 
178
180
  ## Decisions and MADR
179
181
 
@@ -292,9 +294,11 @@ migrated state.
292
294
 
293
295
  ## Git and merge behavior
294
296
 
295
- In a Git worktree, `begin` parks the open outcome in Git metadata so it does not
296
- dirty the tracked log. `end` flushes the lineage to
297
- `.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.
298
302
 
299
303
  After a merge collision, run:
300
304
 
@@ -341,11 +345,19 @@ MADR tools, and the three migration tools. Resources are:
341
345
  DriftSeal; use `reclaim`, `unreclaim`, `lane`, and `absorb` instead of manual edits.
342
346
  - `.seal/madr/` stores numbered MADR documents.
343
347
  - `$DRIFTSEAL_HOME` replaces the `.seal` root.
344
- - The current lane and derived SQLite outcome index live in Git metadata for a default
345
- repository seal, or beside a custom seal (`outcomes/.current-lane` and
346
- `outcomes/.outcome-index.sqlite`). When the custom seal sits inside a Git
347
- worktree, those sidecars are gitignored. They are reconstructable and are not
348
- 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.
349
361
  - Advisory hooks remind agents about lifecycle state but never broaden the
350
362
  repository's `AGENTS.md` policy.
351
363
 
package/README.zh-CN.md CHANGED
@@ -60,7 +60,8 @@ attribute,并配置本地 Git merge driver。Git config 不会随 clone 传播
60
60
 
61
61
  `driftseal init --lang <BCP-47-tag>` 用来指定 outcome 与 MADR 正文的语言。
62
62
  `--local-log` 会让 `.seal/` 保持本地、不被跟踪;DriftSeal 只报告当前 tracked 状态,
63
- 不会替你修改 `.gitignore` 或 Git index。
63
+ 不会替你修改仓库根目录的 `.gitignore` 或 Git index。seal 在 Git worktree 内时,
64
+ `init` 仍可能写入 `.seal/outcomes/.gitignore`,用来忽略派生 sidecar。
64
65
 
65
66
  ## 基本工作流
66
67
 
@@ -141,19 +142,21 @@ lane 不能改名或删除。`lane add` 打错的名字会一直出现在 `drift
141
142
  直到 `lane switch main` 或把该 lane 加回来。`log --last N` 在 open outcome 属于
142
143
  别的 lane 时,返回条数可以多于 N。
143
144
 
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 会全量重建。
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
+ 会全量重建。
149
151
 
150
152
  `log --last N` 使用 `(lane, reclaimed, ordinal)` SQLite index,只读取命中的 outcome
151
153
  row,并补上其他 lane 的 open outcome。parked event 会依据 index 中保存的 committed
152
154
  event identity 做定向 overlay,不再因此重扫 committed WAL。无锁读取遇到缺失或 stale
153
155
  database 时,会在内存中 fold canonical WAL,绝不会返回 stale index。保存的 WAL byte
154
156
  range 留给后续 projection 使用,但 recent-log lookup 不依赖它。database 可以重建,
155
- 不会随 log 一起提交。自定义 home 下的 sidecar 放在 `events.jsonl` 旁边;该目录在 Git
156
- worktree 内时,由目录里的 `.gitignore` 忽略。
157
+ 不会随 log 一起提交。sidecar 放在 `events.jsonl` 旁边;该目录在 Git worktree
158
+ 内时,由目录里的 `.gitignore` 忽略。文件留在工作树里,避免被默认禁止写入
159
+ `.git/` 的 agent sandbox 拦住。
157
160
 
158
161
  ## Decision 与 MADR
159
162
 
@@ -262,9 +265,11 @@ tools 可以立刻看到 migration 后的状态。
262
265
 
263
266
  ## Git 与 merge
264
267
 
265
- Git worktree 中,`begin` 会把 open outcome park 到 Git metadata,避免弄脏 tracked
266
- log;`end` 再把完整 lineage 写入 `.seal/outcomes/events.jsonl`。正常工作期间 event log
267
- 保持 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。
268
273
 
269
274
  发生 merge collision 后执行:
270
275
 
@@ -310,10 +315,16 @@ resources 为:
310
315
  `absorb`,不要手改。
311
316
  - `.seal/madr/` 保存编号化 MADR。
312
317
  - `$DRIFTSEAL_HOME` 替换整个 `.seal` root。
313
- - 当前 lane 与派生 SQLite outcome index 对默认 repo seal 存在 Git metadata 里,对自定义 seal
314
- 则放在旁边(`outcomes/.current-lane` 与 `outcomes/.outcome-index.sqlite`)。自定义
315
- seal Git worktree 内时,这些 sidecar 会被 gitignore。它们可以重建,不是
316
- 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` 会共用那里实际存在的文件。
317
328
  - advisory hook 只提示 lifecycle 状态,不会扩大 repo 中 `AGENTS.md` 的政策边界。
318
329
 
319
330
  DriftSeal 不会替你判断 verification command 是否安全,也不会判断测试本身是否充分。
package/bin/driftseal.js CHANGED
@@ -20,7 +20,8 @@
20
20
  *
21
21
  * Seal root: $DRIFTSEAL_HOME, or .seal in cwd.
22
22
  * Outcome log: <seal-root>/outcomes/events.jsonl.
23
- * In a Git worktree, an open outcome is parked in Git metadata until end.
23
+ * In a default Git-repository seal, an open outcome is parked beside the WAL until end.
24
+ * Custom $DRIFTSEAL_HOME seals write open outcomes directly to the WAL.
24
25
  * MADR records: <seal-root>/madr/.
25
26
  */
26
27
 
@@ -59,10 +60,22 @@ const PROTOCOL_VERSION = '2.1';
59
60
  const DEFAULT_LOG_LANGUAGE = 'en';
60
61
  const DEFAULT_LANE = 'main';
61
62
  const LANE_NAME_RE = /^[a-z][a-z0-9-]{0,62}$/;
62
- const IN_PROGRESS_GIT_PATH = 'driftseal-v2-in-progress.jsonl';
63
- const CURRENT_LANE_GIT_PATH = 'driftseal-v2-current-lane';
64
- const LANE_INDEX_GIT_PATH = 'driftseal-v3-outcome-index.sqlite';
65
- const LEGACY_LANE_INDEX_GIT_PATH = 'driftseal-v2-lane-index.json';
63
+ const IN_PROGRESS_SIDECAR = '.in-progress.jsonl';
64
+ const WORKSPACE_SIDECAR_IGNORE_NAMES = Object.freeze([
65
+ '.current-lane',
66
+ '.lane-index.json',
67
+ '.in-progress.jsonl',
68
+ '.driftseal-local-outcome.json',
69
+ '.outcome-index.sqlite',
70
+ '.outcome-index.sqlite-journal',
71
+ '.outcome-index.sqlite-wal',
72
+ '.outcome-index.sqlite-shm',
73
+ '..outcome-index.sqlite.*.tmp',
74
+ '..outcome-index.sqlite.*.tmp-*',
75
+ '..current-lane.*.tmp',
76
+ '..in-progress.jsonl.*.tmp',
77
+ '..driftseal-local-outcome.json.*.tmp',
78
+ ]);
66
79
  const LOCK_STALE_MS = 30 * 60 * 1000;
67
80
  const LOCK_INIT_STALE_MS = 5 * 1000;
68
81
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
@@ -574,13 +587,6 @@ function gitWorktreeRoot(cwd = process.cwd()) {
574
587
  return gitCapture(['rev-parse', '--show-toplevel'], cwd);
575
588
  }
576
589
 
577
- function worktreeInProgressFile(cwd = process.cwd()) {
578
- if (!isGitWorkTree(cwd)) return null;
579
- const gitPath = gitCapture(['rev-parse', '--git-path', IN_PROGRESS_GIT_PATH], cwd);
580
- if (!gitPath) return null;
581
- return path.resolve(cwd, gitPath);
582
- }
583
-
584
590
  function isParkableOutcomeLog() {
585
591
  if (process.env.DRIFTSEAL_HOME) return false;
586
592
  const root = gitWorktreeRoot();
@@ -588,30 +594,45 @@ function isParkableOutcomeLog() {
588
594
  return path.resolve(logFile()) === path.resolve(root, '.seal', 'outcomes', 'events.jsonl');
589
595
  }
590
596
 
597
+ function inProgressFileForLog(file) {
598
+ return path.join(path.dirname(file), IN_PROGRESS_SIDECAR);
599
+ }
600
+
591
601
  function inProgressFile() {
592
602
  if (!isParkableOutcomeLog()) return null;
593
- return worktreeInProgressFile();
603
+ return inProgressFileForLog(logFile());
594
604
  }
595
605
 
596
- function worktreeMetadataFile(gitPath, cwd = process.cwd()) {
597
- if (!isGitWorkTree(cwd)) return null;
598
- const resolved = gitCapture(['rev-parse', '--git-path', gitPath], cwd);
599
- if (!resolved) return null;
600
- return path.resolve(cwd, resolved);
606
+ function existingInProgressFileForLog(file) {
607
+ return inProgressFileForLog(file);
608
+ }
609
+
610
+ function existingInProgressFile() {
611
+ if (!isParkableOutcomeLog()) return null;
612
+ return existingInProgressFileForLog(logFile());
613
+ }
614
+
615
+ function adoptInProgressFile() {
616
+ const current = inProgressFile();
617
+ if (!current) return null;
618
+ ensureDerivedLaneSidecarIgnore();
619
+ ensureDirectoryDurable(path.dirname(current));
620
+ return current;
601
621
  }
602
622
 
603
623
  function currentLaneFile() {
604
- if (isParkableOutcomeLog()) return worktreeMetadataFile(CURRENT_LANE_GIT_PATH);
624
+ // Keep the worktree-local pointer beside the WAL so agent sandboxes that
625
+ // deny .git writes can still switch lanes.
605
626
  return path.join(logDir(), '.current-lane');
606
627
  }
607
628
 
608
629
  function laneIndexFile() {
609
- if (isParkableOutcomeLog()) return worktreeMetadataFile(LANE_INDEX_GIT_PATH);
630
+ // Keep the disposable index beside the WAL so agent sandboxes that deny
631
+ // .git writes can still rebuild it inside the workspace.
610
632
  return path.join(logDir(), '.outcome-index.sqlite');
611
633
  }
612
634
 
613
635
  function legacyLaneIndexFile() {
614
- if (isParkableOutcomeLog()) return worktreeMetadataFile(LEGACY_LANE_INDEX_GIT_PATH);
615
636
  return path.join(logDir(), '.lane-index.json');
616
637
  }
617
638
 
@@ -619,14 +640,17 @@ function emptyLaneCatalog() {
619
640
  return outcomeFoldEngine.emptyLaneCatalog();
620
641
  }
621
642
 
622
- function readCurrentLaneName() {
623
- const file = currentLaneFile();
624
- if (!file || !fs.existsSync(file)) return DEFAULT_LANE;
643
+ function readLaneNameFromFile(file) {
644
+ if (!file || !fs.existsSync(file)) return null;
625
645
  const name = fs.readFileSync(file, 'utf8').trim();
626
- if (!name) return DEFAULT_LANE;
646
+ if (!name) return null;
627
647
  return normalizeLaneName(name);
628
648
  }
629
649
 
650
+ function readCurrentLaneName() {
651
+ return readLaneNameFromFile(currentLaneFile()) || DEFAULT_LANE;
652
+ }
653
+
630
654
  function writeCurrentLaneName(name, { readOnly = false } = {}) {
631
655
  if (readOnly) return;
632
656
  const file = currentLaneFile();
@@ -636,29 +660,59 @@ function writeCurrentLaneName(name, { readOnly = false } = {}) {
636
660
  atomicWriteFile(file, `${name}\n`, 0o600);
637
661
  }
638
662
 
663
+ function existingAncestor(dir) {
664
+ let current = path.resolve(dir);
665
+ while (!fs.existsSync(current)) {
666
+ const parent = path.dirname(current);
667
+ if (parent === current) return current;
668
+ current = parent;
669
+ }
670
+ return current;
671
+ }
672
+
673
+ function listedIgnoreNames(content) {
674
+ return new Set(
675
+ content
676
+ .split(/\r?\n/)
677
+ .map((line) => line.trim())
678
+ .filter((line) => line.length > 0)
679
+ );
680
+ }
681
+
682
+ function workspaceSidecarIgnoreFile() {
683
+ return path.join(logDir(), '.gitignore');
684
+ }
685
+
686
+ function gitContainedOutcomeLog() {
687
+ return isGitWorkTree(existingAncestor(logDir()));
688
+ }
689
+
690
+ function workspaceSidecarIgnoreComplete() {
691
+ const ignoreFile = workspaceSidecarIgnoreFile();
692
+ if (!fs.existsSync(ignoreFile)) return false;
693
+ const listed = listedIgnoreNames(fs.readFileSync(ignoreFile, 'utf8'));
694
+ return WORKSPACE_SIDECAR_IGNORE_NAMES.every((name) => listed.has(name));
695
+ }
696
+
697
+ function canPersistDerivedOutcomeIndex() {
698
+ return !gitContainedOutcomeLog() || workspaceSidecarIgnoreComplete();
699
+ }
700
+
639
701
  function ensureDerivedLaneSidecarIgnore() {
640
- if (isParkableOutcomeLog()) return;
641
- if (!isGitWorkTree(logDir())) return;
642
- const ignoreFile = path.join(logDir(), '.gitignore');
702
+ if (!gitContainedOutcomeLog()) return { changed: false, target: null };
703
+ const ignoreFile = workspaceSidecarIgnoreFile();
643
704
  let current = fs.existsSync(ignoreFile) ? fs.readFileSync(ignoreFile, 'utf8') : '';
705
+ const listed = listedIgnoreNames(current);
644
706
  let next = current;
645
- for (const name of [
646
- '.current-lane',
647
- '.lane-index.json',
648
- '.outcome-index.sqlite',
649
- '.outcome-index.sqlite-journal',
650
- '.outcome-index.sqlite-wal',
651
- '.outcome-index.sqlite-shm',
652
- '..outcome-index.sqlite.*.tmp',
653
- ]) {
654
- const present = next.split(/\r?\n/).some((line) => line.trim() === name);
655
- if (present) continue;
707
+ for (const name of WORKSPACE_SIDECAR_IGNORE_NAMES) {
708
+ if (listed.has(name)) continue;
656
709
  if (next && !next.endsWith('\n')) next += '\n';
657
710
  next += `${name}\n`;
658
711
  }
659
- if (next === current) return;
712
+ if (next === current) return { changed: false, target: ignoreFile };
660
713
  ensureDirectoryDurable(logDir());
661
714
  atomicWriteFile(ignoreFile, next, 0o644);
715
+ return { changed: true, target: ignoreFile };
662
716
  }
663
717
 
664
718
  function hashFilePrefix(file, length) {
@@ -817,16 +871,15 @@ function replaceOutcomeIndexFile(temporary, target) {
817
871
  }
818
872
 
819
873
  function removeLegacyLaneIndex() {
820
- const legacy = legacyLaneIndexFile();
821
- if (legacy) fs.rmSync(legacy, { force: true });
874
+ fs.rmSync(legacyLaneIndexFile(), { force: true });
822
875
  }
823
876
 
824
877
  function rebuildCommittedOutcomeIndex({ repairTail = false } = {}) {
825
878
  const wal = logFile();
826
879
  const target = laneIndexFile();
827
880
  if (!target) return null;
881
+ if (!canPersistDerivedOutcomeIndex()) return null;
828
882
  ensureDirectoryDurable(path.dirname(target));
829
- ensureDerivedLaneSidecarIgnore();
830
883
  const temporary = temporaryIndexPath(target);
831
884
  removeIndexFiles(temporary);
832
885
  let index;
@@ -872,6 +925,7 @@ function syncCommittedLaneIndex({ repairTail = false, readOnly = false, forceFul
872
925
  const wal = logFile();
873
926
  const target = laneIndexFile();
874
927
  if (!target) return null;
928
+ if (!readOnly && !canPersistDerivedOutcomeIndex()) return null;
875
929
  if (readOnly) {
876
930
  if (!fs.existsSync(target)) return null;
877
931
  try {
@@ -993,7 +1047,7 @@ function recoverableOutcomeIndexError(error) {
993
1047
  }
994
1048
 
995
1049
  function loadOutcomeView({ repairTail = false, readOnly = false } = {}) {
996
- const park = inProgressFile();
1050
+ const park = existingInProgressFile();
997
1051
  let index = syncCommittedLaneIndex({ repairTail, readOnly });
998
1052
  if (!index) {
999
1053
  const records = fold(readEvents({ repairTail, readOnly }));
@@ -1095,7 +1149,7 @@ function indexedLaneSummary(state, name) {
1095
1149
  }
1096
1150
 
1097
1151
  function tryLoadRecentOutcomeView(n, { includeReclaimed = false, repairTail = false, readOnly = false } = {}) {
1098
- const park = inProgressFile();
1152
+ const park = existingInProgressFile();
1099
1153
  let index = syncCommittedLaneIndex({ repairTail, readOnly });
1100
1154
  if (!index) return null;
1101
1155
  const query = () => {
@@ -1150,7 +1204,7 @@ function readJsonlRecordsFromFile(file, { repairTail = false, readOnly = false }
1150
1204
  if (
1151
1205
  readOnly &&
1152
1206
  process.env._DRIFTSEAL_TEST_UNLINK_PARK_BEFORE_READ === '1' &&
1153
- path.basename(file) === IN_PROGRESS_GIT_PATH
1207
+ path.basename(file) === IN_PROGRESS_SIDECAR
1154
1208
  ) {
1155
1209
  // Simulate a writer flushing and unlinking the park between the existence
1156
1210
  // check and the read; the next attempt sees the park as absent.
@@ -1232,13 +1286,13 @@ function planIndexedInProgressOverlay(index, park, { repairTail = false, readOnl
1232
1286
  }
1233
1287
 
1234
1288
  function discardInProgressLog(park) {
1235
- fs.unlinkSync(park);
1289
+ fs.rmSync(park, { force: true });
1236
1290
  fsyncDirectory(path.dirname(park));
1237
1291
  }
1238
1292
 
1239
1293
  function reconcileInProgressRecords(
1240
1294
  committedEvents,
1241
- { repairTail = false, readOnly = false, park = inProgressFile() } = {}
1295
+ { repairTail = false, readOnly = false, park = existingInProgressFile() } = {}
1242
1296
  ) {
1243
1297
  const plan = planInProgressOverlay(committedEvents, park, { repairTail, readOnly });
1244
1298
  if (!plan) return [];
@@ -1260,7 +1314,7 @@ function readEventsSnapshot(file, { repairTail = false, readOnly = false } = {})
1260
1314
  reconcileInProgressRecords(events, {
1261
1315
  repairTail,
1262
1316
  readOnly,
1263
- park: worktreeInProgressFile(),
1317
+ park: existingInProgressFileForLog(file),
1264
1318
  }).map((record) => record.event)
1265
1319
  );
1266
1320
  }
@@ -1389,7 +1443,7 @@ function appendEventTo(file, event) {
1389
1443
  * Returns the outcome ids it had to remap.
1390
1444
  */
1391
1445
  function flushInProgressLog() {
1392
- const park = inProgressFile();
1446
+ const park = adoptInProgressFile();
1393
1447
  if (!park || !fs.existsSync(park)) return new Map();
1394
1448
  const committedRecords = readJsonlRecordsFromFile(logFile());
1395
1449
  const plan = planInProgressOverlay(
@@ -1419,7 +1473,8 @@ function parkedOpenOutcome(park) {
1419
1473
  }
1420
1474
 
1421
1475
  function appendEvent(event) {
1422
- const park = inProgressFile();
1476
+ ensureDerivedLaneSidecarIgnore();
1477
+ const park = adoptInProgressFile();
1423
1478
  if (!park) {
1424
1479
  const stored = appendEventTo(logFile(), event);
1425
1480
  if (event.type === 'begin') writeLocalOutcomeProvenance(stored);
@@ -1434,7 +1489,7 @@ function appendEvent(event) {
1434
1489
  if (event.type === 'begin') return appendEventTo(park, event);
1435
1490
  if (!open || open.id !== event.id) return appendEventTo(logFile(), event);
1436
1491
  if (event.type !== 'end') return appendEventTo(park, event);
1437
- // Close in the tracked log, never in Git metadata: the parked records move first, so the
1492
+ // Close in the tracked log, never only in the park: the parked records move first, so the
1438
1493
  // closing record cannot end up somewhere a clone or a removed worktree would drop it.
1439
1494
  const remapped = flushInProgressLog();
1440
1495
  if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_IN_PROGRESS_FLUSH === '1') {
@@ -1448,11 +1503,7 @@ function contentHash(content) {
1448
1503
  }
1449
1504
 
1450
1505
  function localOutcomeProvenanceFile() {
1451
- const root = gitWorktreeRoot();
1452
- const key = contentHash(path.resolve(logFile())).slice(0, 16);
1453
- if (!root) return path.join(logDir(), LOCAL_OUTCOME_PROVENANCE_FILE);
1454
- const gitPath = gitCapture(['rev-parse', '--git-path', `driftseal-local-outcome-${key}.json`]);
1455
- return gitPath ? path.resolve(process.cwd(), gitPath) : null;
1506
+ return path.join(logDir(), LOCAL_OUTCOME_PROVENANCE_FILE);
1456
1507
  }
1457
1508
 
1458
1509
  function localOutcomeLogIdentity() {
@@ -1471,6 +1522,7 @@ function localOutcomeProvenanceFingerprint({ id, ts, verify }) {
1471
1522
  function writeLocalOutcomeProvenance(event) {
1472
1523
  const file = localOutcomeProvenanceFile();
1473
1524
  if (!file) return;
1525
+ ensureDerivedLaneSidecarIgnore();
1474
1526
  ensureDirectoryDurable(path.dirname(file));
1475
1527
  atomicWriteFile(
1476
1528
  file,
@@ -1484,8 +1536,7 @@ function writeLocalOutcomeProvenance(event) {
1484
1536
  );
1485
1537
  }
1486
1538
 
1487
- function readLocalOutcomeProvenance() {
1488
- const file = localOutcomeProvenanceFile();
1539
+ function parseLocalOutcomeProvenance(file) {
1489
1540
  if (!file || !fs.existsSync(file)) return null;
1490
1541
  try {
1491
1542
  const provenance = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -1503,6 +1554,10 @@ function readLocalOutcomeProvenance() {
1503
1554
  }
1504
1555
  }
1505
1556
 
1557
+ function readLocalOutcomeProvenance() {
1558
+ return parseLocalOutcomeProvenance(localOutcomeProvenanceFile());
1559
+ }
1560
+
1506
1561
  function hasMatchingLocalOutcomeProvenance(outcome) {
1507
1562
  const provenance = readLocalOutcomeProvenance();
1508
1563
  return (
@@ -1519,11 +1574,13 @@ function hasMatchingLocalOutcomeProvenance(outcome) {
1519
1574
  }
1520
1575
 
1521
1576
  function clearLocalOutcomeProvenance(id) {
1522
- const file = localOutcomeProvenanceFile();
1523
1577
  const provenance = readLocalOutcomeProvenance();
1524
- if (!file || !provenance || provenance.id !== id) return;
1525
- fs.unlinkSync(file);
1526
- fsyncDirectory(path.dirname(file));
1578
+ if (!provenance || provenance.id !== id) return;
1579
+ const file = localOutcomeProvenanceFile();
1580
+ if (file && fs.existsSync(file)) {
1581
+ fs.unlinkSync(file);
1582
+ fsyncDirectory(path.dirname(file));
1583
+ }
1527
1584
  }
1528
1585
 
1529
1586
  function atomicWriteFile(target, content, createMode = 0o644) {
@@ -3362,6 +3419,8 @@ const SKILL_RELEASE_DIGESTS = new Set([
3362
3419
  '42a0549dff21483c0508ea4a79658e7bf05cd98f8af4238c95dbd23cdcde7ee6', // 2.0.0 outcome workflow
3363
3420
  '38e89060ff37ecdd663eae73a3e0c646d6bb220fc8232a5762356375d84ba69b', // 2.1.0 outcome lanes
3364
3421
  'b4b2cc27ea71c5777b5eb9b67d861fbe1da43f31ab5d422d6a877e0cad592493', // 2.1.0 lane re-anchor recovery
3422
+ '6c49523c2f4a36ea1cd9d0b4e793ea3cdbd9d299174cf1f9a0a54f3d9a7cd331', // workspace park sidecar wording
3423
+ '678cbaa8380f75f7a45d634010b500e51a59b06ea759eaf23cff2980142662f0', // default-seal parking help wording
3365
3424
  ]);
3366
3425
 
3367
3426
  function skillInstallUsage() {
@@ -3783,11 +3842,9 @@ function hookLogFile() {
3783
3842
  const root = gitWorktreeRoot(current);
3784
3843
  while (true) {
3785
3844
  const candidate = path.join(current, '.seal', 'outcomes', 'events.jsonl');
3786
- if (fs.existsSync(candidate)) return candidate;
3787
- if (root && path.resolve(root) === current) {
3788
- const park = worktreeInProgressFile(current);
3789
- if (park && fs.existsSync(park)) return candidate;
3790
- }
3845
+ const workspacePark = path.join(current, '.seal', 'outcomes', IN_PROGRESS_SIDECAR);
3846
+ if (fs.existsSync(candidate) || fs.existsSync(workspacePark)) return candidate;
3847
+ if (root && path.resolve(root) === current) return null;
3791
3848
  const parent = path.dirname(current);
3792
3849
  if (parent === current) return null;
3793
3850
  current = parent;
@@ -4620,8 +4677,12 @@ function finishAbsorb({
4620
4677
  allowConflict = false,
4621
4678
  followupMessage = null,
4622
4679
  }) {
4623
- // An outcome parked in Git metadata is part of our side even though the log never saw it.
4624
- const park = shouldAttachInProgress(outputFile) ? inProgressFile() : null;
4680
+ // A parked outcome is local even though the tracked log never saw it.
4681
+ const park = shouldAttachInProgress(outputFile)
4682
+ ? dryRun
4683
+ ? existingInProgressFileForLog(outputFile)
4684
+ : adoptInProgressFile() || existingInProgressFileForLog(outputFile)
4685
+ : null;
4625
4686
  const plan = planInProgressOverlay(result.map((record) => record.event), park, {
4626
4687
  repairTail: true,
4627
4688
  });
@@ -4992,7 +5053,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4992
5053
  fail(`outcome ${outcome.id} has no acceptance criteria; declare them with driftseal begin --accept`);
4993
5054
  }
4994
5055
  if (!outcome.verify) fail(`outcome ${outcome.id} has no verification command`);
4995
- const park = inProgressFile();
5056
+ const park = existingInProgressFile();
4996
5057
  const parked = park ? parkedOpenOutcome(park) : null;
4997
5058
  const locallyProvenanced = hasMatchingLocalOutcomeProvenance(outcome);
4998
5059
  return {
@@ -6691,10 +6752,11 @@ const commands = {
6691
6752
  } catch (err) {
6692
6753
  printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
6693
6754
  }
6755
+ const indexIgnore = ensureDerivedLaneSidecarIgnore();
6694
6756
 
6695
6757
  if (localLog) warnIfDefaultLogsTracked();
6696
6758
 
6697
- if (updated === current && !attributes.changed && !driver.changed) {
6759
+ if (updated === current && !attributes.changed && !driver.changed && !indexIgnore.changed) {
6698
6760
  printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
6699
6761
  return { changed: false, target };
6700
6762
  }
@@ -6708,6 +6770,9 @@ const commands = {
6708
6770
  if (driver.changed) {
6709
6771
  printLine('Configured local git merge driver for DriftSeal outcome logs');
6710
6772
  }
6773
+ if (indexIgnore.changed) {
6774
+ printLine(`Configured derived outcome-index ignore: ${indexIgnore.target}`);
6775
+ }
6711
6776
  return { changed: true, target };
6712
6777
  },
6713
6778
 
@@ -6785,7 +6850,7 @@ seal root: $DRIFTSEAL_HOME, or .seal in the current directory
6785
6850
  outcome log: <seal-root>/outcomes/events.jsonl
6786
6851
  MADR records: <seal-root>/madr/
6787
6852
  $DRIFTSEAL_DECISION_HOME is a v1-only default for migration source detection; v2 runtime ignores it.
6788
- In a Git worktree, begin parks an open outcome in Git metadata until end, so merge does not need a log-only commit.`);
6853
+ In a default Git-repository seal, begin parks an open outcome beside the WAL until end, so merge does not need a log-only commit. Custom $DRIFTSEAL_HOME seals write that open outcome directly to events.jsonl.`);
6789
6854
  return null;
6790
6855
  },
6791
6856
 
@@ -7197,7 +7262,7 @@ function dispatch(argv) {
7197
7262
  if (cmd === 'hook') {
7198
7263
  // Hooks read the nearest ancestor log, not the cwd-relative one: lock
7199
7264
  // the directory of the file the hook will actually read (the park a
7200
- // writer flushes under that same lock lives in the repo's Git metadata).
7265
+ // writer flushes under that same lock lives beside the WAL).
7201
7266
  // With no ancestor log the hook prints nothing, so skip locking — this
7202
7267
  // also avoids creating a spurious <cwd>/.intent-log.
7203
7268
  const hookFile = hookLogFile();
@@ -7205,6 +7270,14 @@ function dispatch(argv) {
7205
7270
  resources = [path.dirname(hookFile)];
7206
7271
  } else if (legacyParkedIntent()) {
7207
7272
  resources = [path.dirname(legacyIntentLogFile())];
7273
+ } else if (!fs.existsSync(logDir())) {
7274
+ // Do not mkdir a cwd-relative seal just to lock a read: status/log
7275
+ // from a subdirectory would otherwise plant packages/**/.seal.
7276
+ const data = fn(rest, { readOnly: true });
7277
+ return {
7278
+ data,
7279
+ exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0,
7280
+ };
7208
7281
  } else {
7209
7282
  resources = [logDir()];
7210
7283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Seal outcomes, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",
@@ -47,13 +47,14 @@ If `status` or `log` fails with a duplicate id or with `multiple outcomes in
47
47
  progress`, or the outcome log has conflict markers, run `driftseal absorb`
48
48
  instead of editing `.seal/outcomes/events.jsonl`. When both sides still have an
49
49
  open outcome, add `--abandon-theirs` or `--abandon-ours`; this works whether your
50
- open outcome sits in the log or is parked in Git metadata. `driftseal init` also
50
+ open outcome sits in the log or is parked beside the WAL. `driftseal init` also
51
51
  configures the local git merge driver; clones need `init` again for that driver.
52
52
 
53
- In a Git worktree, `begin` does not dirty the tracked outcome log, so `git merge`
54
- can run with an outcome still in progress. `end` writes the closed record to
55
- `.seal/outcomes/events.jsonl`; if it is interrupted, the outcome stays open
56
- there and `end` can be run again.
53
+ In a default Git-repository seal, `begin` parks beside the WAL and does not dirty
54
+ the tracked outcome log, so `git merge` can run with an outcome still in
55
+ progress. Custom `$DRIFTSEAL_HOME` seals write that open outcome directly to
56
+ `events.jsonl`. `end` writes the closed record to `.seal/outcomes/events.jsonl`;
57
+ if it is interrupted, the outcome stays open there and `end` can be run again.
57
58
 
58
59
  For a v1 repository, do not reinterpret or delete the old logs directly. Use
59
60
  `driftseal migrate v1-to-v2 inspect`, prepare a reviewed grouping plan, apply