driftseal 2.0.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/bin/driftseal.js CHANGED
@@ -32,6 +32,15 @@ const { isDeepStrictEqual } = require('util');
32
32
  const { StringDecoder } = require('string_decoder');
33
33
  const { execFileSync, spawnSync } = require('child_process');
34
34
  const { version: PACKAGE_VERSION } = require('../package.json');
35
+ const { createOutcomeFold } = require('../lib/outcome-fold.js');
36
+ const {
37
+ OutcomeIndexError,
38
+ SqliteUnavailableError,
39
+ openOutcomeIndex,
40
+ removeIndexFiles,
41
+ temporaryIndexPath,
42
+ } = require('../lib/outcome-index-sqlite.js');
43
+ const { assertSupportedNode } = require('../lib/sqlite-runtime.js');
35
44
 
36
45
  const END_STATUSES = ['completed', 'partial', 'failed', 'abandoned'];
37
46
  const DECISION_STATUSES = [
@@ -43,11 +52,17 @@ const DECISION_STATUSES = [
43
52
  'superseded',
44
53
  ];
45
54
  const LOG_VERSION = 2;
46
- const EVENT_SCHEMA_VERSION = 1;
55
+ const EVENT_SCHEMA_VERSION = 2;
56
+ const DEFAULT_WRITE_SCHEMA_VERSION = 1;
47
57
  const LEGACY_EVENT_SCHEMA_VERSION = 4;
48
- const PROTOCOL_VERSION = '2.0';
58
+ const PROTOCOL_VERSION = '2.1';
49
59
  const DEFAULT_LOG_LANGUAGE = 'en';
60
+ const DEFAULT_LANE = 'main';
61
+ const LANE_NAME_RE = /^[a-z][a-z0-9-]{0,62}$/;
50
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';
51
66
  const LOCK_STALE_MS = 30 * 60 * 1000;
52
67
  const LOCK_INIT_STALE_MS = 5 * 1000;
53
68
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
@@ -57,6 +72,12 @@ const VERIFICATION_OUTPUT_CHUNK_BYTES = 64 * 1024;
57
72
  const CAPTURE_OUTPUT_EDGE_CHARACTERS = 32 * 1024;
58
73
  const CAPTURE_OUTPUT_OMISSION = '\n... [driftseal captured output truncated] ...\n';
59
74
  const LOCAL_OUTCOME_PROVENANCE_FILE = '.driftseal-local-outcome.json';
75
+ const outcomeFoldEngine = createOutcomeFold({
76
+ fail,
77
+ contentHash,
78
+ logVersion: LOG_VERSION,
79
+ defaultLane: DEFAULT_LANE,
80
+ });
60
81
 
61
82
  class DriftSealError extends Error {
62
83
  constructor(message) {
@@ -84,7 +105,12 @@ function usageFor(key) {
84
105
  verify: 'usage: driftseal verify [--allow-tracked-command]',
85
106
  end: 'usage: driftseal end [id] [options]',
86
107
  status: 'usage: driftseal status',
87
- log: 'usage: driftseal log [--last N] [--all]',
108
+ log: 'usage: driftseal log [--last N] [--all] [--all-lanes]',
109
+ lane: 'usage: driftseal lane [add|switch|assign|show] ... (run: driftseal help)',
110
+ 'lane add': 'usage: driftseal lane add <name> [--desc "<why this capability exists>"]',
111
+ 'lane switch': 'usage: driftseal lane switch <name>',
112
+ 'lane assign': 'usage: driftseal lane assign <id> <name>',
113
+ 'lane show': 'usage: driftseal lane show [name]',
88
114
  reclaim:
89
115
  'usage: driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]',
90
116
  unreclaim: 'usage: driftseal unreclaim <id> --reason "<why>"',
@@ -265,6 +291,26 @@ function normalizeMigrationSource(value, line) {
265
291
  };
266
292
  }
267
293
 
294
+ function laneEventId(name) {
295
+ return `lane:${name}`;
296
+ }
297
+
298
+ function normalizeLaneName(value, { optional = false, line } = {}) {
299
+ const prefix = line === undefined ? '' : ` on log line ${line}`;
300
+ if (value === undefined || value === null || value === '') {
301
+ if (optional) return DEFAULT_LANE;
302
+ fail(`lane name required${prefix}`);
303
+ }
304
+ if (typeof value !== 'string') fail(`invalid lane name${prefix}`);
305
+ const name = value.trim();
306
+ if (!LANE_NAME_RE.test(name)) {
307
+ fail(
308
+ `invalid lane name "${name}"${prefix} (expected a lowercase letter, then up to 62 letters, digits, or hyphens)`
309
+ );
310
+ }
311
+ return name;
312
+ }
313
+
268
314
  function normalizeEvent(event, line) {
269
315
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
270
316
  fail(`invalid event object on log line ${line}`);
@@ -311,7 +357,8 @@ function normalizeEvent(event, line) {
311
357
  if (new Set(decisions).size !== decisions.length) {
312
358
  fail(`duplicate linked decision on log line ${line}`);
313
359
  }
314
- return { ...event, logVersion, outcome, acceptance, decisions, head: normalizeHead(event.head) };
360
+ const lane = normalizeLaneName(event.lane, { optional: true, line });
361
+ return { ...event, logVersion, outcome, acceptance, decisions, lane, head: normalizeHead(event.head) };
315
362
  }
316
363
 
317
364
  if (event.type === 'extend') {
@@ -412,7 +459,8 @@ function normalizeEvent(event, line) {
412
459
  typeof source.id !== 'string' || source.id.length === 0)) {
413
460
  fail(`invalid imported outcome source on log line ${line}`);
414
461
  }
415
- return { ...event, logVersion, decisions, head: normalizeHead(event.head) };
462
+ const lane = normalizeLaneName(event.lane, { optional: true, line });
463
+ return { ...event, logVersion, decisions, lane, head: normalizeHead(event.head) };
416
464
  }
417
465
 
418
466
  if (event.type === 'migration') {
@@ -432,6 +480,32 @@ function normalizeEvent(event, line) {
432
480
  };
433
481
  }
434
482
 
483
+ if (event.type === 'lane_add') {
484
+ if (logVersion !== LOG_VERSION) fail(`invalid lane add event on log line ${line}`);
485
+ const lane = normalizeLaneName(event.lane, { line });
486
+ if (lane === DEFAULT_LANE) fail(`cannot add the default lane on log line ${line}`);
487
+ if (event.id !== laneEventId(lane)) {
488
+ fail(`lane add id must be ${laneEventId(lane)} on log line ${line}`);
489
+ }
490
+ if (
491
+ event.description !== undefined &&
492
+ event.description !== null &&
493
+ typeof event.description !== 'string'
494
+ ) {
495
+ fail(`invalid lane description on log line ${line}`);
496
+ }
497
+ const description = event.description && event.description.trim().length > 0
498
+ ? event.description.trim()
499
+ : null;
500
+ return { ...event, logVersion, lane, description };
501
+ }
502
+
503
+ if (event.type === 'lane_assign') {
504
+ if (logVersion !== LOG_VERSION) fail(`invalid lane assign event on log line ${line}`);
505
+ const lane = normalizeLaneName(event.lane, { line });
506
+ return { ...event, logVersion, lane };
507
+ }
508
+
435
509
  if (event.type === 'reclaim' || event.type === 'unreclaim') {
436
510
  if (typeof event.reason !== 'string' || event.reason.trim().length === 0) {
437
511
  fail(`invalid ${event.type} event on log line ${line}`);
@@ -519,6 +593,542 @@ function inProgressFile() {
519
593
  return worktreeInProgressFile();
520
594
  }
521
595
 
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);
601
+ }
602
+
603
+ function currentLaneFile() {
604
+ if (isParkableOutcomeLog()) return worktreeMetadataFile(CURRENT_LANE_GIT_PATH);
605
+ return path.join(logDir(), '.current-lane');
606
+ }
607
+
608
+ function laneIndexFile() {
609
+ if (isParkableOutcomeLog()) return worktreeMetadataFile(LANE_INDEX_GIT_PATH);
610
+ return path.join(logDir(), '.outcome-index.sqlite');
611
+ }
612
+
613
+ function legacyLaneIndexFile() {
614
+ if (isParkableOutcomeLog()) return worktreeMetadataFile(LEGACY_LANE_INDEX_GIT_PATH);
615
+ return path.join(logDir(), '.lane-index.json');
616
+ }
617
+
618
+ function emptyLaneCatalog() {
619
+ return outcomeFoldEngine.emptyLaneCatalog();
620
+ }
621
+
622
+ function readCurrentLaneName() {
623
+ const file = currentLaneFile();
624
+ if (!file || !fs.existsSync(file)) return DEFAULT_LANE;
625
+ const name = fs.readFileSync(file, 'utf8').trim();
626
+ if (!name) return DEFAULT_LANE;
627
+ return normalizeLaneName(name);
628
+ }
629
+
630
+ function writeCurrentLaneName(name, { readOnly = false } = {}) {
631
+ if (readOnly) return;
632
+ const file = currentLaneFile();
633
+ if (!file) fail('cannot persist the current lane outside a writable seal');
634
+ ensureDirectoryDurable(path.dirname(file));
635
+ ensureDerivedLaneSidecarIgnore();
636
+ atomicWriteFile(file, `${name}\n`, 0o600);
637
+ }
638
+
639
+ function ensureDerivedLaneSidecarIgnore() {
640
+ if (isParkableOutcomeLog()) return;
641
+ if (!isGitWorkTree(logDir())) return;
642
+ const ignoreFile = path.join(logDir(), '.gitignore');
643
+ let current = fs.existsSync(ignoreFile) ? fs.readFileSync(ignoreFile, 'utf8') : '';
644
+ 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;
656
+ if (next && !next.endsWith('\n')) next += '\n';
657
+ next += `${name}\n`;
658
+ }
659
+ if (next === current) return;
660
+ ensureDirectoryDurable(logDir());
661
+ atomicWriteFile(ignoreFile, next, 0o644);
662
+ }
663
+
664
+ function hashFilePrefix(file, length) {
665
+ const hash = crypto.createHash('sha256');
666
+ if (length <= 0) return hash.digest('hex');
667
+ const fd = fs.openSync(file, 'r');
668
+ try {
669
+ const buffer = Buffer.alloc(Math.min(1024 * 1024, length));
670
+ let position = 0;
671
+ while (position < length) {
672
+ const requested = Math.min(buffer.length, length - position);
673
+ const read = fs.readSync(fd, buffer, 0, requested, position);
674
+ if (read === 0) break;
675
+ hash.update(buffer.subarray(0, read));
676
+ position += read;
677
+ }
678
+ if (position !== length) return null;
679
+ return hash.digest('hex');
680
+ } finally {
681
+ fs.closeSync(fd);
682
+ }
683
+ }
684
+
685
+ function laneIndexSourceIdentity(file, indexedThrough, indexedLines = 0) {
686
+ if (!fs.existsSync(file)) {
687
+ return {
688
+ indexedThrough: 0,
689
+ indexedLines: 0,
690
+ walHash: contentHash(''),
691
+ device: null,
692
+ inode: null,
693
+ mtimeMs: null,
694
+ ctimeMs: null,
695
+ };
696
+ }
697
+ const stat = fs.statSync(file);
698
+ return {
699
+ indexedThrough,
700
+ indexedLines,
701
+ walHash: hashFilePrefix(file, indexedThrough),
702
+ device: Number(stat.dev),
703
+ inode: Number(stat.ino),
704
+ mtimeMs: stat.mtimeMs,
705
+ ctimeMs: stat.ctimeMs,
706
+ };
707
+ }
708
+
709
+ function laneIndexMatchesFile(source, file, { exact = false } = {}) {
710
+ if (
711
+ !source ||
712
+ typeof source.walHash !== 'string' ||
713
+ !Number.isSafeInteger(source.indexedLines) ||
714
+ source.indexedLines < 0
715
+ ) {
716
+ return false;
717
+ }
718
+ if (!fs.existsSync(file)) return source.indexedThrough === 0;
719
+ const stat = fs.statSync(file);
720
+ const size = stat.size;
721
+ if (size < source.indexedThrough || (exact && size !== source.indexedThrough)) return false;
722
+ if (
723
+ source.device !== null &&
724
+ source.inode !== null &&
725
+ (Number(stat.dev) !== source.device || Number(stat.ino) !== source.inode)
726
+ ) {
727
+ return false;
728
+ }
729
+ if (
730
+ size === source.indexedThrough &&
731
+ stat.mtimeMs === source.mtimeMs &&
732
+ stat.ctimeMs === source.ctimeMs
733
+ ) {
734
+ return true;
735
+ }
736
+ return hashFilePrefix(file, source.indexedThrough) === source.walHash;
737
+ }
738
+
739
+ function applyFoldEvent(state, ev) {
740
+ return outcomeFoldEngine.applyFoldEvent(state, ev);
741
+ }
742
+
743
+ function consumeLogSlice(file, startByte, onEvent, { repairTail = false, readOnly = false, startLine = 0 } = {}) {
744
+ if (!fs.existsSync(file)) return { endByte: 0, endLine: 0 };
745
+ const fd = fs.openSync(file, 'r');
746
+ let size;
747
+ let buf;
748
+ try {
749
+ size = fs.fstatSync(fd).size;
750
+ if (startByte > size) fail('lane index is ahead of the outcome log');
751
+ if (startByte === size) return { endByte: size, endLine: startLine };
752
+ buf = Buffer.alloc(size - startByte);
753
+ fs.readSync(fd, buf, 0, buf.length, startByte);
754
+ } finally {
755
+ fs.closeSync(fd);
756
+ }
757
+ let text = buf.toString('utf8');
758
+ if (text.length > 0 && !text.endsWith('\n')) {
759
+ const rawLines = text.split('\n');
760
+ const tail = rawLines.at(-1);
761
+ try {
762
+ JSON.parse(tail);
763
+ } catch {
764
+ const validLength = text.lastIndexOf('\n') + 1;
765
+ if (!readOnly) {
766
+ if (!repairTail) fail(`corrupt final log line in ${file}`);
767
+ const truncateTo = startByte + Buffer.byteLength(text.slice(0, validLength), 'utf8');
768
+ const repairFd = fs.openSync(file, 'r+');
769
+ try {
770
+ fs.ftruncateSync(repairFd, truncateTo);
771
+ fs.fsyncSync(repairFd);
772
+ } finally {
773
+ fs.closeSync(repairFd);
774
+ }
775
+ }
776
+ text = text.slice(0, validLength);
777
+ }
778
+ }
779
+ const parts = text.split('\n');
780
+ let pos = startByte;
781
+ let lineNumber = startLine;
782
+ for (let i = 0; i < parts.length; i++) {
783
+ const line = parts[i];
784
+ if (i === parts.length - 1 && line === '') break;
785
+ lineNumber += 1;
786
+ const start = pos;
787
+ pos += Buffer.byteLength(line, 'utf8');
788
+ if (i < parts.length - 1) pos += 1;
789
+ if (line.trim().length === 0) continue;
790
+ try {
791
+ const event = normalizeEvent(JSON.parse(line), lineNumber);
792
+ if (event.logVersion !== LOG_VERSION) {
793
+ fail(`v1 intent log cannot be used as a v2 outcome log; run driftseal migrate v1-to-v2 inspect`);
794
+ }
795
+ onEvent(event, start, pos);
796
+ } catch (err) {
797
+ if (err instanceof DriftSealError || err instanceof OutcomeIndexError) throw err;
798
+ fail(`corrupt log line ${lineNumber} in ${file}`);
799
+ }
800
+ }
801
+ return { endByte: pos, endLine: lineNumber };
802
+ }
803
+
804
+ function replaceOutcomeIndexFile(temporary, target) {
805
+ try {
806
+ fs.renameSync(temporary, target);
807
+ } catch (error) {
808
+ if (!['EEXIST', 'EPERM'].includes(error && error.code)) throw error;
809
+ removeIndexFiles(target);
810
+ fs.renameSync(temporary, target);
811
+ }
812
+ fs.chmodSync(target, 0o600);
813
+ for (const suffix of ['-journal', '-wal', '-shm']) {
814
+ fs.rmSync(`${target}${suffix}`, { force: true });
815
+ }
816
+ fsyncDirectory(path.dirname(target));
817
+ }
818
+
819
+ function removeLegacyLaneIndex() {
820
+ const legacy = legacyLaneIndexFile();
821
+ if (legacy) fs.rmSync(legacy, { force: true });
822
+ }
823
+
824
+ function rebuildCommittedOutcomeIndex({ repairTail = false } = {}) {
825
+ const wal = logFile();
826
+ const target = laneIndexFile();
827
+ if (!target) return null;
828
+ ensureDirectoryDurable(path.dirname(target));
829
+ ensureDerivedLaneSidecarIgnore();
830
+ const temporary = temporaryIndexPath(target);
831
+ removeIndexFiles(temporary);
832
+ let index;
833
+ try {
834
+ index = openOutcomeIndex(temporary);
835
+ let slice;
836
+ index.transaction(() => {
837
+ const indexedEvents = [];
838
+ slice = consumeLogSlice(
839
+ wal,
840
+ 0,
841
+ (event, startByte, endByte) =>
842
+ indexedEvents.push({ event, startByte, endByte }),
843
+ { repairTail }
844
+ );
845
+ index.replaceFromFoldState(
846
+ outcomeFoldEngine.foldState(indexedEvents.map((item) => item.event)),
847
+ indexedEvents
848
+ );
849
+ index.setSource(
850
+ laneIndexSourceIdentity(wal, slice.endByte, slice.endLine),
851
+ 'full'
852
+ );
853
+ index.acceptProjection();
854
+ });
855
+ if (!index.integrityCheck()) fail('rebuilt SQLite outcome index failed integrity check');
856
+ index.close();
857
+ index = null;
858
+ replaceOutcomeIndexFile(temporary, target);
859
+ removeLegacyLaneIndex();
860
+ const reopened = openOutcomeIndex(target);
861
+ reopened.build = 'full';
862
+ return reopened;
863
+ } catch (error) {
864
+ if (index) index.close();
865
+ removeIndexFiles(temporary);
866
+ throw error;
867
+ }
868
+ }
869
+
870
+ function syncCommittedLaneIndex({ repairTail = false, readOnly = false, forceFull = false } = {}) {
871
+ if (process.env._DRIFTSEAL_TEST_DISABLE_OUTCOME_INDEX === '1') return null;
872
+ const wal = logFile();
873
+ const target = laneIndexFile();
874
+ if (!target) return null;
875
+ if (readOnly) {
876
+ if (!fs.existsSync(target)) return null;
877
+ try {
878
+ const index = openOutcomeIndex(target, { readOnly: true });
879
+ if (
880
+ !index.projectionTrusted() ||
881
+ !laneIndexMatchesFile(index.source(), wal, { exact: true })
882
+ ) {
883
+ index.close();
884
+ return null;
885
+ }
886
+ index.build = 'hot';
887
+ return index;
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+ if (forceFull || !fs.existsSync(target)) {
893
+ try {
894
+ return rebuildCommittedOutcomeIndex({ repairTail });
895
+ } catch (error) {
896
+ if (error instanceof SqliteUnavailableError) return null;
897
+ throw error;
898
+ }
899
+ }
900
+ let index;
901
+ try {
902
+ index = openOutcomeIndex(target);
903
+ const source = index.source();
904
+ if (!index.projectionTrusted() || !laneIndexMatchesFile(source, wal)) {
905
+ index.close();
906
+ return rebuildCommittedOutcomeIndex({ repairTail });
907
+ }
908
+ const size = fs.existsSync(wal) ? fs.statSync(wal).size : 0;
909
+ if (size === source.indexedThrough) {
910
+ index.build = 'hot';
911
+ removeLegacyLaneIndex();
912
+ return index;
913
+ }
914
+ let slice;
915
+ index.transaction(() => {
916
+ slice = consumeLogSlice(
917
+ wal,
918
+ source.indexedThrough,
919
+ (event, start, end) =>
920
+ index.applyEvent(event, start, end, { applyFoldEvent, defaultLane: DEFAULT_LANE }),
921
+ {
922
+ repairTail,
923
+ startLine: source.indexedLines || 0,
924
+ }
925
+ );
926
+ index.setSource(
927
+ laneIndexSourceIdentity(wal, slice.endByte, slice.endLine),
928
+ 'incremental'
929
+ );
930
+ index.acceptProjection();
931
+ });
932
+ index.build = 'incremental';
933
+ removeLegacyLaneIndex();
934
+ return index;
935
+ } catch (error) {
936
+ if (index) index.close();
937
+ if (error instanceof DriftSealError) throw error;
938
+ if (error instanceof SqliteUnavailableError) return null;
939
+ return rebuildCommittedOutcomeIndex({ repairTail });
940
+ }
941
+ }
942
+
943
+ function attachIndexedOverlay(index, committed, plan, { readOnly = false } = {}) {
944
+ const lanes = index.laneCatalog();
945
+ if (!plan || plan.alreadyCommitted || plan.records.length === 0) {
946
+ committed.lanes = lanes;
947
+ return committed;
948
+ }
949
+ if (!readOnly && plan.mappings.length > 0) writeJsonl(plan.park, plan.records);
950
+ const overlay = fold(plan.records.map((record) => record.event));
951
+ for (const record of overlay) {
952
+ let lane = lanes.get(record.lane);
953
+ if (!lane) {
954
+ const foldedLane = overlay.lanes.get(record.lane);
955
+ lane = {
956
+ name: record.lane,
957
+ description: foldedLane ? foldedLane.description : null,
958
+ addedAt: foldedLane ? foldedLane.addedAt : null,
959
+ inferred: foldedLane ? foldedLane.inferred === true : true,
960
+ head: null,
961
+ count: 0,
962
+ visible: 0,
963
+ };
964
+ lanes.set(record.lane, lane);
965
+ }
966
+ lane.count = (lane.count || 0) + 1;
967
+ if (!record.reclaimed) lane.visible = (lane.visible || 0) + 1;
968
+ }
969
+ const records = [...committed, ...overlay];
970
+ records.lanes = lanes;
971
+ return records;
972
+ }
973
+
974
+ function queryOutcomeView(index, park, { repairTail = false, readOnly = false } = {}) {
975
+ const committed = index.queryAll();
976
+ if (!park || !fs.existsSync(park)) {
977
+ committed.lanes = index.laneCatalog();
978
+ return { index: null, records: committed };
979
+ }
980
+ const plan = planIndexedInProgressOverlay(index, park, { repairTail, readOnly });
981
+ if (plan && plan.alreadyCommitted && !readOnly) discardInProgressLog(park);
982
+ return {
983
+ index: null,
984
+ records: attachIndexedOverlay(index, committed, plan, { readOnly }),
985
+ };
986
+ }
987
+
988
+ function recoverableOutcomeIndexError(error) {
989
+ return (
990
+ error instanceof OutcomeIndexError ||
991
+ /^SQLITE_|^ERR_SQLITE_/.test(String(error && error.code))
992
+ );
993
+ }
994
+
995
+ function loadOutcomeView({ repairTail = false, readOnly = false } = {}) {
996
+ const park = inProgressFile();
997
+ let index = syncCommittedLaneIndex({ repairTail, readOnly });
998
+ if (!index) {
999
+ const records = fold(readEvents({ repairTail, readOnly }));
1000
+ return { index: null, records };
1001
+ }
1002
+ try {
1003
+ return queryOutcomeView(index, park, { repairTail, readOnly });
1004
+ } catch (error) {
1005
+ index.close();
1006
+ index = null;
1007
+ if (readOnly && (error.code === 'ENOENT' || recoverableOutcomeIndexError(error))) {
1008
+ return { index: null, records: fold(readEvents({ repairTail, readOnly })) };
1009
+ }
1010
+ if (!recoverableOutcomeIndexError(error)) throw error;
1011
+ index = syncCommittedLaneIndex({ repairTail, forceFull: true });
1012
+ if (!index) return { index: null, records: fold(readEvents({ repairTail })) };
1013
+ return queryOutcomeView(index, park, { repairTail });
1014
+ } finally {
1015
+ if (index) index.close();
1016
+ }
1017
+ }
1018
+
1019
+ function laneSummary(records, name) {
1020
+ const lanes = records.lanes || emptyLaneCatalog();
1021
+ const lane = lanes.get(name);
1022
+ const members = records.filter((record) => (record.lane || DEFAULT_LANE) === name);
1023
+ const visible = members.filter((record) => !record.reclaimed);
1024
+ return {
1025
+ name,
1026
+ description: lane ? lane.description : null,
1027
+ addedAt: lane ? lane.addedAt : null,
1028
+ inferred: Boolean(lane && lane.inferred),
1029
+ visible: visible.length,
1030
+ count: members.length,
1031
+ };
1032
+ }
1033
+
1034
+ function publicLane(summary) {
1035
+ return {
1036
+ name: summary.name,
1037
+ description: summary.description,
1038
+ addedAt: summary.addedAt || null,
1039
+ inferred: summary.inferred === true,
1040
+ visible: summary.visible,
1041
+ count: summary.count,
1042
+ };
1043
+ }
1044
+
1045
+ function resolveCurrentLane(records, { required = false } = {}) {
1046
+ const requested = readCurrentLaneName();
1047
+ const lanes = records.lanes || emptyLaneCatalog();
1048
+ if (lanes.has(requested)) return { current: requested, missing: null };
1049
+ if (required) {
1050
+ fail(`current lane ${requested} does not exist; switch to ${DEFAULT_LANE} or add it`);
1051
+ }
1052
+ return { current: DEFAULT_LANE, missing: requested };
1053
+ }
1054
+
1055
+ function currentLaneOrFail(records) {
1056
+ return resolveCurrentLane(records, { required: true }).current;
1057
+ }
1058
+
1059
+ function warnMissingCurrentLane(missing) {
1060
+ if (!missing) return;
1061
+ printLine(`warning: current lane ${missing} does not exist; showing ${DEFAULT_LANE}`);
1062
+ }
1063
+
1064
+ function renderLaneLine(records, name) {
1065
+ const summary = laneSummary(records, name);
1066
+ return `lane: ${name} (${summary.visible} visible / ${summary.count} in lane)`;
1067
+ }
1068
+
1069
+ function selectLaneLogRecords(records, current) {
1070
+ return records.filter(
1071
+ (record) => (record.lane || DEFAULT_LANE) === current || record.status === 'in_progress'
1072
+ );
1073
+ }
1074
+
1075
+ function selectLastLogRecords(records, current, n) {
1076
+ const inLane = records.filter((record) => (record.lane || DEFAULT_LANE) === current);
1077
+ const clipped = inLane.slice(-n);
1078
+ const kept = new Set(clipped.map((record) => record.id));
1079
+ const extras = records.filter((record) => record.status === 'in_progress' && !kept.has(record.id));
1080
+ if (extras.length === 0) return clipped;
1081
+ const order = new Map(records.map((record, index) => [record.id, index]));
1082
+ return [...clipped, ...extras].sort((left, right) => order.get(left.id) - order.get(right.id));
1083
+ }
1084
+
1085
+ function indexedLaneSummary(state, name) {
1086
+ const lane = state.lanes.get(name);
1087
+ return {
1088
+ name,
1089
+ description: lane ? lane.description : null,
1090
+ addedAt: lane ? lane.addedAt : null,
1091
+ inferred: Boolean(lane && lane.inferred),
1092
+ visible: lane ? lane.visible : 0,
1093
+ count: lane ? lane.count : 0,
1094
+ };
1095
+ }
1096
+
1097
+ function tryLoadRecentOutcomeView(n, { includeReclaimed = false, repairTail = false, readOnly = false } = {}) {
1098
+ const park = inProgressFile();
1099
+ let index = syncCommittedLaneIndex({ repairTail, readOnly });
1100
+ if (!index) return null;
1101
+ const query = () => {
1102
+ const plan =
1103
+ park && fs.existsSync(park)
1104
+ ? planIndexedInProgressOverlay(index, park, { repairTail, readOnly })
1105
+ : null;
1106
+ if (plan && plan.alreadyCommitted && !readOnly) discardInProgressLog(park);
1107
+ const overlayView = attachIndexedOverlay(index, [], plan, { readOnly });
1108
+ const state = { lanes: overlayView.lanes };
1109
+ const { current, missing } = resolveCurrentLane(state);
1110
+ const committed = index.queryRecent(current, n, { includeReclaimed });
1111
+ const records = selectLastLogRecords(
1112
+ [...committed, ...overlayView],
1113
+ current,
1114
+ n
1115
+ );
1116
+ return { state, records, current, missing };
1117
+ };
1118
+ try {
1119
+ return query();
1120
+ } catch (error) {
1121
+ if (readOnly && error && error.code === 'ENOENT') return null;
1122
+ index.close();
1123
+ index = null;
1124
+ if (readOnly) return null;
1125
+ index = syncCommittedLaneIndex({ repairTail, forceFull: true });
1126
+ return query();
1127
+ } finally {
1128
+ if (index) index.close();
1129
+ }
1130
+ }
1131
+
522
1132
  function liveWorktreeOutcomeLog() {
523
1133
  const root = gitWorktreeRoot();
524
1134
  if (!root) return null;
@@ -600,6 +1210,27 @@ function planInProgressOverlay(committedEvents, park, { repairTail = false, read
600
1210
  return { park, records: remapped.records, mappings: remapped.mappings, alreadyCommitted: false };
601
1211
  }
602
1212
 
1213
+ function planIndexedInProgressOverlay(index, park, { repairTail = false, readOnly = false } = {}) {
1214
+ if (!park || !fs.existsSync(park)) return null;
1215
+ const overlayRecords = readJsonlRecordsFromFile(park, { repairTail, readOnly });
1216
+ const overlayEvents = overlayRecords.map((record) => record.event);
1217
+ if (overlayEvents.length === 0 || index.containsEventSequence(overlayEvents)) {
1218
+ return { park, records: [], mappings: [], alreadyCommitted: true };
1219
+ }
1220
+ const remapped = remapTheirsRecords(
1221
+ overlayRecords,
1222
+ index.outcomeStartEvents(),
1223
+ new Map(),
1224
+ new Map()
1225
+ );
1226
+ return {
1227
+ park,
1228
+ records: remapped.records,
1229
+ mappings: remapped.mappings,
1230
+ alreadyCommitted: false,
1231
+ };
1232
+ }
1233
+
603
1234
  function discardInProgressLog(park) {
604
1235
  fs.unlinkSync(park);
605
1236
  fsyncDirectory(path.dirname(park));
@@ -706,10 +1337,27 @@ function ensureV2OutcomeLogExists() {
706
1337
  fsyncDirectory(logDir());
707
1338
  }
708
1339
 
1340
+ function eventWriteSchemaVersion(event) {
1341
+ if (Number.isSafeInteger(event.schemaVersion)) return event.schemaVersion;
1342
+ if (event.type === 'lane_add' || event.type === 'lane_assign') return EVENT_SCHEMA_VERSION;
1343
+ if (
1344
+ (event.type === 'begin' || event.type === 'import') &&
1345
+ event.lane &&
1346
+ event.lane !== DEFAULT_LANE
1347
+ ) {
1348
+ return EVENT_SCHEMA_VERSION;
1349
+ }
1350
+ return DEFAULT_WRITE_SCHEMA_VERSION;
1351
+ }
1352
+
709
1353
  function appendEventTo(file, event) {
710
1354
  ensureDirectoryDurable(path.dirname(file));
711
1355
  const existed = fs.existsSync(file);
712
- const storedEvent = { logVersion: LOG_VERSION, schemaVersion: EVENT_SCHEMA_VERSION, ...event };
1356
+ const storedEvent = {
1357
+ logVersion: LOG_VERSION,
1358
+ ...event,
1359
+ schemaVersion: eventWriteSchemaVersion(event),
1360
+ };
713
1361
  const line = Buffer.from(`${JSON.stringify(storedEvent)}\n`, 'utf8');
714
1362
  const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
715
1363
  try {
@@ -1156,237 +1804,20 @@ function withMutationLocks(resources, action, { tryWaitMs } = {}) {
1156
1804
  }
1157
1805
 
1158
1806
  function outcomeContractHash(record) {
1159
- return contentHash(JSON.stringify({
1160
- outcome: record.outcome,
1161
- extensions: record.extensions.map(({ extension, acceptance, verify, decisions }) => ({
1162
- extension,
1163
- acceptance,
1164
- verify,
1165
- decisions,
1166
- })),
1167
- acceptance: record.acceptance,
1168
- verify: record.verify,
1169
- decisions: record.decisions,
1170
- }));
1807
+ return outcomeFoldEngine.outcomeContractHash(record);
1171
1808
  }
1172
1809
 
1173
1810
  function newOutcomeRecord(ev) {
1174
- const record = {
1175
- id: ev.id,
1176
- tsBegin: ev.ts,
1177
- outcome: ev.outcome,
1178
- extensions: [],
1179
- acceptance: Array.isArray(ev.acceptance) ? ev.acceptance : [],
1180
- verify: ev.verify || null,
1181
- beginHead: ev.head || null,
1182
- decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
1183
- logVersion: ev.logVersion || 1,
1184
- schemaVersion: ev.schemaVersion || 1,
1185
- decisionPrepares: [],
1186
- decisionTerminals: [],
1187
- decisionUpdates: [],
1188
- verificationAttempts: [],
1189
- verification: null,
1190
- status: 'in_progress',
1191
- tsEnd: null,
1192
- note: null,
1193
- verifyResult: null,
1194
- endHead: null,
1195
- reclaimed: false,
1196
- reclaimReason: null,
1197
- reclaimedAt: null,
1198
- imported: null,
1199
- contractHash: null,
1200
- };
1201
- record.contractHash = outcomeContractHash(record);
1202
- return record;
1811
+ return outcomeFoldEngine.newOutcomeRecord(ev);
1203
1812
  }
1204
1813
 
1205
1814
  /** Fold the event stream into one record per outcome. Legacy v1 events are accepted for migration. */
1206
1815
  function fold(events) {
1207
- const records = new Map();
1208
- const reconciliations = new Map();
1209
- const order = [];
1210
- for (const ev of events) {
1211
- if (ev.type === 'begin') {
1212
- if (records.has(ev.id)) fail(`duplicate begin event for outcome id: ${ev.id}`);
1213
- records.set(ev.id, newOutcomeRecord(ev));
1214
- order.push(ev.id);
1215
- } else if (ev.type === 'import') {
1216
- if (records.has(ev.id)) fail(`duplicate imported outcome id: ${ev.id}`);
1217
- const record = newOutcomeRecord({
1218
- ...ev,
1219
- ts: ev.beganAt,
1220
- acceptance: [],
1221
- verify: null,
1222
- });
1223
- record.status = ev.status;
1224
- record.tsEnd = ev.endedAt;
1225
- record.note = ev.summary || null;
1226
- record.reclaimed = ev.reclaimed === true;
1227
- record.reclaimReason = ev.reclaimReason || null;
1228
- record.reclaimedAt = ev.reclaimedAt || null;
1229
- record.imported = {
1230
- sourceIds: ev.sources.map((source) => source.id),
1231
- sourceFingerprint: ev.sourceFingerprint,
1232
- sources: ev.sources,
1233
- };
1234
- records.set(ev.id, record);
1235
- order.push(ev.id);
1236
- } else if (ev.type === 'migration') {
1237
- continue;
1238
- } else if (ev.type === 'extend') {
1239
- const rec = records.get(ev.id);
1240
- if (!rec) fail(`extension references unknown outcome id: ${ev.id}`);
1241
- if (rec.status !== 'in_progress') fail(`extension occurred after outcome ${ev.id} was closed`);
1242
- rec.extensions.push({
1243
- extension: ev.extension,
1244
- acceptance: ev.acceptance,
1245
- verify: ev.verify,
1246
- decisions: ev.decisions,
1247
- extendedAt: ev.ts,
1248
- head: ev.head || null,
1249
- });
1250
- rec.acceptance = [...new Set([...rec.acceptance, ...ev.acceptance])];
1251
- if (ev.verify) rec.verify = ev.verify;
1252
- rec.decisions = [...new Set([...rec.decisions, ...ev.decisions])];
1253
- rec.contractHash = outcomeContractHash(rec);
1254
- rec.verification = null;
1255
- // Reconciliation certifies the final cumulative outcome contract, just like
1256
- // machine verification. Any later extension makes every earlier confirmation stale.
1257
- rec.decisionUpdates = [];
1258
- } else if (ev.type === 'verify') {
1259
- const rec = records.get(ev.id);
1260
- if (!rec) fail(`verification event references unknown outcome id: ${ev.id}`);
1261
- if (rec.status !== 'in_progress') fail(`verification occurred after outcome ${ev.id} was closed`);
1262
- if (rec.acceptance.length === 0 || !rec.verify) {
1263
- fail(`verification event references outcome ${ev.id} without acceptance criteria`);
1264
- }
1265
- if (ev.command !== rec.verify) fail(`verification command does not match outcome ${ev.id}`);
1266
- if (rec.logVersion === LOG_VERSION && ev.contractHash !== rec.contractHash) {
1267
- fail(`verification contract does not match outcome ${ev.id}`);
1268
- }
1269
- rec.verificationAttempts.push(ev);
1270
- rec.verification = ev;
1271
- } else if (ev.type === 'reclaim' || ev.type === 'unreclaim') {
1272
- const rec = records.get(ev.id);
1273
- if (!rec) fail(`${ev.type} event references unknown outcome id: ${ev.id}`);
1274
- if (ev.type === 'reclaim') {
1275
- if (rec.status === 'in_progress') fail(`cannot reclaim outcome ${ev.id} while it is in_progress`);
1276
- if (rec.reclaimed) fail(`duplicate reclaim event for outcome id: ${ev.id}`);
1277
- rec.reclaimed = true;
1278
- rec.reclaimReason = ev.reason;
1279
- rec.reclaimedAt = ev.ts;
1280
- } else {
1281
- if (!rec.reclaimed) fail(`unreclaim event for outcome id that is not reclaimed: ${ev.id}`);
1282
- rec.reclaimed = false;
1283
- rec.reclaimReason = null;
1284
- rec.reclaimedAt = null;
1285
- }
1286
- } else if (ev.type === 'end') {
1287
- const rec = records.get(ev.id);
1288
- if (!rec) fail(`end event references unknown outcome id: ${ev.id}`);
1289
- if (rec.status !== 'in_progress') fail(`duplicate end event for outcome id: ${ev.id}`);
1290
- const conflictingCancellation = rec.decisionTerminals.find(
1291
- (terminal) => terminal.type === 'decision_reconcile_cancel' && terminal.outcomeStatus !== ev.status
1292
- );
1293
- if (conflictingCancellation) {
1294
- fail(`outcome ${ev.id} was closed as ${ev.status} after reconciliation recovery was cancelled for ${conflictingCancellation.outcomeStatus}`);
1295
- }
1296
- if (
1297
- ['completed', 'partial'].includes(ev.status) &&
1298
- rec.decisions.length > 0 &&
1299
- ((rec.logVersion === 1 && rec.schemaVersion >= 2 && (ev.schemaVersion || 1) < 2) ||
1300
- rec.decisions.some((decisionId) => qualifyingDecisionUpdates(rec, decisionId).length === 0))
1301
- ) {
1302
- fail(`linked outcome ${ev.id} was closed without reconciling every declared decision`);
1303
- }
1304
- if (ev.status === 'completed' && rec.acceptance.length > 0) {
1305
- if (!rec.verification || !rec.verification.passed) {
1306
- fail(`acceptance-bound outcome ${ev.id} was completed without successful machine verification`);
1307
- }
1308
- if (
1309
- (rec.logVersion === 1 && (ev.schemaVersion || 1) < 4) ||
1310
- ev.verificationId !== rec.verification.verificationId ||
1311
- (ev.workspace ?? null) !== rec.verification.workspace ||
1312
- (rec.logVersion === LOG_VERSION &&
1313
- (ev.contractHash !== rec.contractHash || rec.verification.contractHash !== rec.contractHash))
1314
- ) {
1315
- fail(`acceptance-bound outcome ${ev.id} was completed with stale machine verification`);
1316
- }
1317
- }
1318
- rec.status = ev.status;
1319
- rec.tsEnd = ev.ts;
1320
- rec.note = ev.note || null;
1321
- rec.verifyResult = ev.verifyResult || null;
1322
- rec.endHead = ev.head || null;
1323
- } else if (ev.type === 'decision_reconcile_prepare') {
1324
- const rec = records.get(ev.id);
1325
- if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
1326
- if (rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
1327
- if (!rec.decisions.includes(ev.decisionId)) fail(`decision reconciliation references unlinked decision ${ev.decisionId}`);
1328
- if (reconciliations.has(ev.reconciliationId)) fail(`duplicate reconciliation id: ${ev.reconciliationId}`);
1329
- rec.decisionPrepares.push(ev);
1330
- reconciliations.set(ev.reconciliationId, {
1331
- prepare: ev,
1332
- terminal: null,
1333
- contractHash: rec.contractHash,
1334
- });
1335
- } else if (ev.type === 'decision_reconcile') {
1336
- const rec = records.get(ev.id);
1337
- if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
1338
- if (rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
1339
- if (rec.logVersion === 1 && rec.schemaVersion >= 2) {
1340
- fail(`linked legacy schema-v2 outcome ${rec.id} contains a legacy decision reconciliation`);
1341
- }
1342
- rec.decisionUpdates.push(ev);
1343
- } else if (
1344
- ev.type === 'decision_reconcile_commit' ||
1345
- ev.type === 'decision_reconcile_abort' ||
1346
- ev.type === 'decision_reconcile_cancel'
1347
- ) {
1348
- const rec = records.get(ev.id);
1349
- const reconciliation = reconciliations.get(ev.reconciliationId);
1350
- if (rec && rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
1351
- if (!rec || !reconciliation || reconciliation.prepare.id !== ev.id || reconciliation.prepare.decisionId !== ev.decisionId) {
1352
- fail(`decision reconciliation terminal has no matching prepare: ${ev.reconciliationId}`);
1353
- }
1354
- if (reconciliation.terminal) fail(`decision reconciliation already has a terminal event: ${ev.reconciliationId}`);
1355
- const priorCancellation = rec.decisionTerminals.find((terminal) => terminal.type === 'decision_reconcile_cancel');
1356
- if (ev.type === 'decision_reconcile_cancel' && priorCancellation && priorCancellation.outcomeStatus !== ev.outcomeStatus) {
1357
- fail(`outcome ${ev.id} has conflicting reconciliation cancellation statuses`);
1358
- }
1359
- if (
1360
- ev.type === 'decision_reconcile_commit' &&
1361
- (reconciliation.prepare.newHash !== ev.fileHash ||
1362
- reconciliation.prepare.fromStatus !== ev.fromStatus ||
1363
- reconciliation.prepare.toStatus !== ev.toStatus)
1364
- ) {
1365
- fail(`decision reconciliation commit does not match prepare: ${ev.reconciliationId}`);
1366
- }
1367
- reconciliation.terminal = ev;
1368
- rec.decisionTerminals.push(ev);
1369
- if (
1370
- ev.type === 'decision_reconcile_commit' &&
1371
- reconciliation.contractHash === rec.contractHash
1372
- ) {
1373
- rec.decisionUpdates.push(ev);
1374
- }
1375
- }
1376
- }
1377
- return order.map((id) => records.get(id));
1816
+ return outcomeFoldEngine.fold(events);
1378
1817
  }
1379
1818
 
1380
1819
  function qualifyingDecisionUpdates(record, decisionId) {
1381
- return record.decisionUpdates.filter((update) => {
1382
- if (update.decisionId !== decisionId) return false;
1383
- if (record.logVersion === 1 && record.schemaVersion < 2) return true;
1384
- return (
1385
- update.type === 'decision_reconcile_commit' &&
1386
- (update.logVersion === LOG_VERSION || (update.schemaVersion || 1) >= 2) &&
1387
- typeof update.fileHash === 'string'
1388
- );
1389
- });
1820
+ return outcomeFoldEngine.qualifyingDecisionUpdates(record, decisionId);
1390
1821
  }
1391
1822
 
1392
1823
  function openOutcome(records) {
@@ -1756,8 +2187,12 @@ function parseArgs(argv, spec, usageKey) {
1756
2187
  return { positionals, flags };
1757
2188
  }
1758
2189
 
1759
- function render(rec) {
2190
+ function render(rec, { currentLane } = {}) {
1760
2191
  const lines = [`[${rec.id}] ${rec.status}`];
2192
+ const lane = rec.lane || DEFAULT_LANE;
2193
+ if (lane !== DEFAULT_LANE || (currentLane && lane !== currentLane)) {
2194
+ lines.push(` lane: ${lane}`);
2195
+ }
1761
2196
  lines.push(` outcome: ${rec.outcome}`);
1762
2197
  for (const extension of rec.extensions) lines.push(` extend: ${extension.extension}`);
1763
2198
  for (const criterion of rec.acceptance) lines.push(` accept: ${criterion}`);
@@ -1807,6 +2242,7 @@ function publicOutcome(rec) {
1807
2242
  return {
1808
2243
  id: rec.id,
1809
2244
  outcome: rec.outcome,
2245
+ lane: rec.lane || DEFAULT_LANE,
1810
2246
  extensions: rec.extensions.map((extension) => ({ ...extension })),
1811
2247
  acceptance: [...rec.acceptance],
1812
2248
  verify: rec.verify,
@@ -2085,12 +2521,12 @@ function stripDecisionLogLanguage(block, language = DEFAULT_LOG_LANGUAGE) {
2085
2521
  function outcomeLogLanguageParagraph(language) {
2086
2522
  return `**Log language:** \`${language}\`. Write outcome-log prose (outcome, extension, note,
2087
2523
  verify-result, and reclaim/unreclaim reason) in that language. Keep command
2088
- names, flags, status tokens, and ids in English.`;
2524
+ names, flags, status tokens, ids, and lane names in English.`;
2089
2525
  }
2090
2526
 
2091
- function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2527
+ function intentProtocolBlockV20(language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2092
2528
  return `${INTENT_PROTOCOL_MARKER}
2093
- <!-- driftseal-version: ${version} -->
2529
+ <!-- driftseal-version: 2.0 -->
2094
2530
  <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
2095
2531
 
2096
2532
  ## Agent protocol: outcome write-ahead log
@@ -2099,7 +2535,9 @@ This repository uses DriftSeal (\`driftseal\`) to prevent agent drift. This
2099
2535
  \`AGENTS.md\` protocol is the source of truth; use the CLI by default, with MCP
2100
2536
  and lifecycle hooks as optional adapters.
2101
2537
 
2102
- ${outcomeLogLanguageParagraph(language)}
2538
+ **Log language:** \`${language}\`. Write outcome-log prose (outcome, extension, note,
2539
+ verify-result, and reclaim/unreclaim reason) in that language. Keep command
2540
+ names, flags, status tokens, and ids in English.
2103
2541
 
2104
2542
  1. **Write the outcome first**, before changing durable project content:
2105
2543
  \`driftseal begin "<coherent delivery outcome>" --accept "<observable result>" --verify "<exact command that proves the cumulative contract>"\`.
@@ -2141,6 +2579,70 @@ Seal root: \`.seal/\` (override with \`$DRIFTSEAL_HOME\`); outcome log:
2141
2579
  ${INTENT_PROTOCOL_END}`;
2142
2580
  }
2143
2581
 
2582
+ function intentProtocolBlockV21(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2583
+ return `${INTENT_PROTOCOL_MARKER}
2584
+ <!-- driftseal-version: ${version} -->
2585
+ <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
2586
+
2587
+ ## Agent protocol: outcome write-ahead log
2588
+
2589
+ This repository uses DriftSeal (\`driftseal\`) to prevent agent drift. This
2590
+ \`AGENTS.md\` protocol is the source of truth; use the CLI by default, with MCP
2591
+ and lifecycle hooks as optional adapters.
2592
+
2593
+ ${outcomeLogLanguageParagraph(language)}
2594
+
2595
+ 1. **Write the outcome first**, before changing durable project content:
2596
+ \`driftseal begin "<coherent delivery outcome>" --accept "<observable result>" --verify "<exact command that proves the cumulative contract>"\`.
2597
+ Repeat \`--accept\` for independently observable criteria and add one
2598
+ \`--decision <id>\` for each existing MADR this outcome may change.
2599
+ Record outcomes for changes intended to persist in the project: code,
2600
+ configuration, documentation, dependencies, and equivalent files, inside or
2601
+ outside Git. Git operations, checks, temporary auxiliary work, and external
2602
+ state changes are exempt when they do not write durable project content here.
2603
+ 2. **Extend only the same outcome.** For another step toward the same coherent
2604
+ delivery goal, append \`driftseal extend "<addition>"\`. It may add
2605
+ \`--accept\`, \`--decision\`, and a replacement \`--verify\`; adding acceptance
2606
+ requires a replacement verifier that proves the complete accumulated contract.
2607
+ Every extension invalidates earlier verification and MADR reconciliation. If
2608
+ the delivery goal changes, close the current outcome honestly and begin a new one.
2609
+ One open outcome belongs to one worktree, or one configured non-Git project
2610
+ root. Every agent changing durable content in the same root re-anchors and
2611
+ continues it; separate worktrees hold separate outcomes.
2612
+ Outcomes belong to one named lane (\`driftseal lane\`). The default lane is
2613
+ \`main\`; untagged history lives there. Re-anchoring and \`driftseal log\`
2614
+ follow the current lane. Close the open outcome before switching lanes.
2615
+ Create a lane only for a long-lived capability you expect to leave and resume.
2616
+ 3. **Reconcile, verify, then close.** After the final extension, reconcile every
2617
+ linked MADR with \`driftseal decision update\`. Inspect \`driftseal status\`,
2618
+ then run \`driftseal verify\` for an acceptance-bound outcome. A verifier
2619
+ without matching local provenance is untrusted and requires
2620
+ \`--allow-tracked-command\` after inspection. Finish with
2621
+ \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>"\`.
2622
+ Completed outcomes require fresh successful verification bound to both the
2623
+ current contract hash and Git-visible workspace. Never report success without
2624
+ closing the outcome.
2625
+ 4. **Re-anchor after context loss or handoff:** run \`driftseal status\` and
2626
+ \`driftseal log --last 3\` before changing durable content. Both follow the
2627
+ current lane. Resume the open outcome when it still matches; otherwise close
2628
+ it and begin a new one. If the requested work belongs to a different existing
2629
+ lane, switch first.
2630
+
2631
+ **Log access goes only through DriftSeal.** Never read, edit, move, or delete
2632
+ \`.seal/outcomes/events.jsonl\` (or its configured equivalent) directly. Use
2633
+ \`reclaim\`/\`unreclaim\` for visibility markers and \`absorb\` after merge
2634
+ collisions. These operations preserve append-only single-lineage history.
2635
+
2636
+ Seal root: \`.seal/\` (override with \`$DRIFTSEAL_HOME\`); outcome log:
2637
+ \`.seal/outcomes/events.jsonl\`; ${localLog ? 'keep `.seal/` local and untracked.' : 'commit `.seal/` with the code.'}
2638
+ ${INTENT_PROTOCOL_END}`;
2639
+ }
2640
+
2641
+ function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2642
+ if (String(version) === '2.0') return intentProtocolBlockV20(language, localLog);
2643
+ return intentProtocolBlockV21(version, language, localLog);
2644
+ }
2645
+
2144
2646
  function v1IntentProtocolBlock(version = 14, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2145
2647
  return `${INTENT_PROTOCOL_MARKER}
2146
2648
  <!-- driftseal-version: ${version} -->
@@ -2858,6 +3360,8 @@ const SKILL_RELEASE_DIGESTS = new Set([
2858
3360
  '72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
2859
3361
  'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb
2860
3362
  '42a0549dff21483c0508ea4a79658e7bf05cd98f8af4238c95dbd23cdcde7ee6', // 2.0.0 outcome workflow
3363
+ '38e89060ff37ecdd663eae73a3e0c646d6bb220fc8232a5762356375d84ba69b', // 2.1.0 outcome lanes
3364
+ 'b4b2cc27ea71c5777b5eb9b67d861fbe1da43f31ab5d422d6a877e0cad592493', // 2.1.0 lane re-anchor recovery
2861
3365
  ]);
2862
3366
 
2863
3367
  function skillInstallUsage() {
@@ -3849,6 +4353,19 @@ function rebindV2ContractHashes(records) {
3849
4353
  });
3850
4354
  }
3851
4355
 
4356
+ function dropDuplicateLaneAdds(oursEvents, theirsRecords) {
4357
+ const seen = new Set();
4358
+ for (const event of oursEvents) {
4359
+ if (event.type === 'lane_add') seen.add(event.lane);
4360
+ }
4361
+ return theirsRecords.filter((record) => {
4362
+ if (record.event.type !== 'lane_add') return true;
4363
+ if (seen.has(record.event.lane)) return false;
4364
+ seen.add(record.event.lane);
4365
+ return true;
4366
+ });
4367
+ }
4368
+
3852
4369
  function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
3853
4370
  const intentMap = new Map();
3854
4371
  const mappings = [];
@@ -3870,6 +4387,7 @@ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = ne
3870
4387
 
3871
4388
  function repairDuplicateOutcomeRecords(records, decisionMap, hashMap = new Map()) {
3872
4389
  const seenBegins = new Set();
4390
+ const seenLanes = new Set();
3873
4391
  const intentMap = new Map();
3874
4392
  const used = [];
3875
4393
  const mappings = [];
@@ -3877,6 +4395,10 @@ function repairDuplicateOutcomeRecords(records, decisionMap, hashMap = new Map()
3877
4395
  let incomingSide = false;
3878
4396
  for (const record of records) {
3879
4397
  let event = record.event;
4398
+ if (event.type === 'lane_add') {
4399
+ if (seenLanes.has(event.lane)) continue;
4400
+ seenLanes.add(event.lane);
4401
+ }
3880
4402
  if (isOutcomeStart(event) && seenBegins.has(event.id)) {
3881
4403
  incomingSide = true;
3882
4404
  const { date } = parseOutcomeId(event.id);
@@ -3946,7 +4468,7 @@ function abandonOpenIntent(records, targetId, side) {
3946
4468
  records.push({
3947
4469
  event: {
3948
4470
  logVersion: LOG_VERSION,
3949
- schemaVersion: EVENT_SCHEMA_VERSION,
4471
+ schemaVersion: DEFAULT_WRITE_SCHEMA_VERSION,
3950
4472
  type: 'end',
3951
4473
  id: targetId,
3952
4474
  ts: new Date().toISOString(),
@@ -4169,7 +4691,10 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
4169
4691
  baseIds: options.baseDecisionIds || new Set(),
4170
4692
  });
4171
4693
  const remapped = remapTheirsRecords(
4172
- streams.theirsNew,
4694
+ dropDuplicateLaneAdds(
4695
+ [...streams.base, ...streams.oursNew].map((record) => record.event),
4696
+ streams.theirsNew
4697
+ ),
4173
4698
  [...streams.base, ...streams.oursNew].map((record) => record.event),
4174
4699
  decisionPlan.decisionMap,
4175
4700
  decisionPlan.hashMap
@@ -4913,7 +5438,7 @@ function validateMigrationPlan(plan, snapshot) {
4913
5438
  }
4914
5439
 
4915
5440
  function storedV2Event(event) {
4916
- return { logVersion: LOG_VERSION, schemaVersion: EVENT_SCHEMA_VERSION, ...event };
5441
+ return { logVersion: LOG_VERSION, schemaVersion: DEFAULT_WRITE_SCHEMA_VERSION, ...event };
4917
5442
  }
4918
5443
 
4919
5444
  function migrationImportEvent(snapshot, validated, group, events) {
@@ -5270,6 +5795,116 @@ function checkMigration(snapshot, { sourceMissing = false } = {}) {
5270
5795
  };
5271
5796
  }
5272
5797
 
5798
+ function listLaneSnapshot(records) {
5799
+ const catalog = records.lanes || emptyLaneCatalog();
5800
+ const { current, missing } = resolveCurrentLane(records);
5801
+ const lanes = [...catalog.keys()].map((name) => {
5802
+ const summary = laneSummary(records, name);
5803
+ return {
5804
+ ...publicLane(summary),
5805
+ current: name === current,
5806
+ };
5807
+ });
5808
+ return { current, missingCurrentLane: missing, lanes, total: records.length };
5809
+ }
5810
+
5811
+ function printLaneSnapshot(snapshot) {
5812
+ warnMissingCurrentLane(snapshot.missingCurrentLane);
5813
+ const lines = snapshot.lanes.map((lane) => {
5814
+ const mark = lane.current ? '*' : ' ';
5815
+ const desc = lane.description ? ` — ${lane.description}` : '';
5816
+ const inferred = lane.inferred ? ' (inferred)' : '';
5817
+ return `${mark} ${lane.name} ${lane.visible} visible / ${lane.count} in lane${desc}${inferred}`;
5818
+ });
5819
+ printLine(`current lane: ${snapshot.current}`);
5820
+ printLine(lines.join('\n'));
5821
+ return snapshot;
5822
+ }
5823
+
5824
+ function showLanes(argv, { readOnly = false } = {}) {
5825
+ const { positionals } = parseArgs(argv, {}, 'lane show');
5826
+ const view = loadOutcomeView({ repairTail: true, readOnly });
5827
+ const snapshot = listLaneSnapshot(view.records);
5828
+ if (positionals.length === 0) return printLaneSnapshot(snapshot);
5829
+ if (positionals.length !== 1) fail(usageFor('lane show'));
5830
+ const name = normalizeLaneName(positionals[0]);
5831
+ const lane = snapshot.lanes.find((item) => item.name === name);
5832
+ if (!lane) fail(`unknown lane ${name}`);
5833
+ printLine(
5834
+ `${lane.current ? 'current ' : ''}lane: ${lane.name} (${lane.visible} visible / ${lane.count} in lane)`
5835
+ );
5836
+ if (lane.description) printLine(lane.description);
5837
+ return lane;
5838
+ }
5839
+
5840
+ function addLane(argv) {
5841
+ const { positionals, flags } = parseArgs(argv, { desc: 'single' }, 'lane add');
5842
+ if (positionals.length !== 1) fail(usageFor('lane add'));
5843
+ const name = normalizeLaneName(positionals[0]);
5844
+ if (name === DEFAULT_LANE) fail(`lane ${DEFAULT_LANE} always exists`);
5845
+ const events = readEvents({ repairTail: true });
5846
+ const records = fold(events);
5847
+ const catalog = records.lanes || emptyLaneCatalog();
5848
+ const existing = catalog.get(name);
5849
+ if (existing && !existing.inferred) fail(`lane ${name} already exists`);
5850
+ const description = flags.desc && flags.desc.trim() ? flags.desc.trim() : null;
5851
+ appendEvent({
5852
+ type: 'lane_add',
5853
+ id: laneEventId(name),
5854
+ ts: new Date().toISOString(),
5855
+ lane: name,
5856
+ description,
5857
+ });
5858
+ printLine(`added lane ${name}`);
5859
+ return { name, description };
5860
+ }
5861
+
5862
+ function switchLane(argv) {
5863
+ const { positionals } = parseArgs(argv, {}, 'lane switch');
5864
+ if (positionals.length !== 1) fail(usageFor('lane switch'));
5865
+ const name = normalizeLaneName(positionals[0]);
5866
+ const events = readEvents({ repairTail: true });
5867
+ const records = fold(events);
5868
+ const catalog = records.lanes || emptyLaneCatalog();
5869
+ if (!catalog.has(name)) fail(`unknown lane ${name}; add it with driftseal lane add ${name}`);
5870
+ const open = openOutcome(records);
5871
+ if (open) {
5872
+ fail(
5873
+ `outcome ${open.id} is still in_progress on lane ${open.lane || DEFAULT_LANE}; ` +
5874
+ 'end it before switching lanes'
5875
+ );
5876
+ }
5877
+ writeCurrentLaneName(name);
5878
+ printLine(`switched to lane ${name}`);
5879
+ return { current: name };
5880
+ }
5881
+
5882
+ function assignLane(argv) {
5883
+ const { positionals } = parseArgs(argv, {}, 'lane assign');
5884
+ if (positionals.length !== 2) fail(usageFor('lane assign'));
5885
+ const id = positionals[0];
5886
+ const name = normalizeLaneName(positionals[1]);
5887
+ const events = readEvents({ repairTail: true });
5888
+ const records = fold(events);
5889
+ const record = records.find((candidate) => candidate.id === id);
5890
+ if (!record) fail(`unknown outcome id: ${id}`);
5891
+ if (record.status === 'in_progress') fail(`cannot assign lane of in_progress outcome ${id}`);
5892
+ const catalog = records.lanes || emptyLaneCatalog();
5893
+ if (!catalog.has(name)) fail(`unknown lane ${name}; add it with driftseal lane add ${name}`);
5894
+ if ((record.lane || DEFAULT_LANE) === name) {
5895
+ printLine(`${id} already on lane ${name}`);
5896
+ return publicOutcome(record);
5897
+ }
5898
+ appendEvent({
5899
+ type: 'lane_assign',
5900
+ id,
5901
+ ts: new Date().toISOString(),
5902
+ lane: name,
5903
+ });
5904
+ printLine(`${id} assigned to lane ${name}`);
5905
+ return { ...publicOutcome(record), lane: name };
5906
+ }
5907
+
5273
5908
  const commands = {
5274
5909
  begin(argv) {
5275
5910
  const { positionals, flags } = parseArgs(argv, {
@@ -5324,6 +5959,7 @@ const commands = {
5324
5959
  }
5325
5960
 
5326
5961
  const id = nextId(events);
5962
+ const currentLane = currentLaneOrFail(records);
5327
5963
  events.push(appendEvent({
5328
5964
  type: 'begin',
5329
5965
  id,
@@ -5332,6 +5968,7 @@ const commands = {
5332
5968
  acceptance,
5333
5969
  verify: flags.verify || null,
5334
5970
  decisions,
5971
+ ...(currentLane !== DEFAULT_LANE ? { lane: currentLane } : {}),
5335
5972
  head: gitCapture(['rev-parse', 'HEAD']),
5336
5973
  }));
5337
5974
  const record = fold(events).find((candidate) => candidate.id === id);
@@ -5513,34 +6150,101 @@ const commands = {
5513
6150
  printLine('parked v1 intent; close it with: driftseal end --status abandoned --note "close parked v1 intent before migration"');
5514
6151
  return publicOutcome(parkedV1);
5515
6152
  }
5516
- const open = openOutcome(fold(readEvents({ repairTail: true, readOnly })));
6153
+ const view = loadOutcomeView({ repairTail: true, readOnly });
6154
+ const records = view.records;
6155
+ const { current, missing } = resolveCurrentLane(records);
6156
+ warnMissingCurrentLane(missing);
6157
+ const customLanes = records.lanes && records.lanes.size > 1;
6158
+ if (customLanes || current !== DEFAULT_LANE) {
6159
+ printLine(renderLaneLine(records, current));
6160
+ }
6161
+ const open = openOutcome(records);
5517
6162
  if (!open) {
5518
6163
  printLine('no outcome in progress');
5519
6164
  return null;
5520
6165
  }
5521
- printLine(render(open));
6166
+ printLine(render(open, { currentLane: current }));
5522
6167
  return publicOutcome(open);
5523
6168
  },
5524
6169
 
5525
6170
  log(argv, { readOnly = false } = {}) {
5526
- const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' }, 'log');
6171
+ const { positionals, flags } = parseArgs(argv, {
6172
+ last: '-n',
6173
+ all: 'boolean',
6174
+ 'all-lanes': 'boolean',
6175
+ }, 'log');
5527
6176
  if (positionals.length > 0) fail(usageFor('log'));
5528
- let records = fold(readEvents({ repairTail: true, readOnly }));
6177
+ const n = flags.last === undefined ? null : positiveInteger(flags.last, '--last');
6178
+ const allLanes = flags['all-lanes'] === true;
5529
6179
  const parkedV1 = legacyParkedIntent();
6180
+ if (
6181
+ n !== null &&
6182
+ !allLanes &&
6183
+ !parkedV1 &&
6184
+ process.env._DRIFTSEAL_TEST_DISABLE_RECENT_INDEX !== '1'
6185
+ ) {
6186
+ const recent = tryLoadRecentOutcomeView(n, {
6187
+ includeReclaimed: flags.all === true,
6188
+ repairTail: true,
6189
+ readOnly,
6190
+ });
6191
+ if (recent) {
6192
+ const { state, records, current, missing } = recent;
6193
+ warnMissingCurrentLane(missing);
6194
+ const customLanes = state.lanes.size > 1;
6195
+ if (customLanes || current !== DEFAULT_LANE || missing) {
6196
+ const summary = indexedLaneSummary(state, current);
6197
+ printLine(`lane: ${current} (${summary.visible} visible / ${summary.count} in lane)`);
6198
+ }
6199
+ if (records.length === 0) {
6200
+ printLine('log is empty');
6201
+ return [];
6202
+ }
6203
+ printLine(records.map((record) => render(record, { currentLane: current })).join('\n\n'));
6204
+ return records.map(publicOutcome);
6205
+ }
6206
+ }
6207
+ if (process.env._DRIFTSEAL_TEST_REQUIRE_RECENT_INDEX === '1' && n !== null && !allLanes) {
6208
+ fail('recent lane index fast path was not used');
6209
+ }
6210
+ const view = loadOutcomeView({ repairTail: true, readOnly });
6211
+ let records = view.records;
5530
6212
  if (parkedV1 && !records.some((record) => record.id === parkedV1.id && record.status === 'in_progress')) {
5531
- records = [...records, parkedV1];
6213
+ records = Object.assign([...records, parkedV1], { lanes: records.lanes });
5532
6214
  }
5533
- if (!flags.all) records = records.filter((record) => !record.reclaimed);
5534
- if (flags.last) {
5535
- const n = positiveInteger(flags.last, '--last');
5536
- records = records.slice(-n);
6215
+ const catalog = records.lanes || emptyLaneCatalog();
6216
+ const { current, missing } = resolveCurrentLane(view.records);
6217
+ warnMissingCurrentLane(missing);
6218
+ if (!allLanes) {
6219
+ records = Object.assign(selectLaneLogRecords(records, current), { lanes: catalog });
5537
6220
  }
5538
- if (records.length === 0) {
6221
+ const visible = flags.all ? records : records.filter((record) => !record.reclaimed);
6222
+ const customLanes = catalog.size > 1;
6223
+ if (!allLanes && (customLanes || current !== DEFAULT_LANE || missing)) {
6224
+ printLine(renderLaneLine(view.records, current));
6225
+ }
6226
+ let shown = visible;
6227
+ if (n !== null) {
6228
+ shown = selectLastLogRecords(visible, current, n);
6229
+ }
6230
+ if (shown.length === 0) {
5539
6231
  printLine('log is empty');
5540
6232
  return [];
5541
6233
  }
5542
- printLine(records.map(render).join('\n\n'));
5543
- return records.map(publicOutcome);
6234
+ printLine(shown.map((record) => render(record, { currentLane: current })).join('\n\n'));
6235
+ return shown.map(publicOutcome);
6236
+ },
6237
+
6238
+ lane(argv, { readOnly = false } = {}) {
6239
+ const [subcommand, ...rest] = argv;
6240
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('lane');
6241
+ if (!subcommand || subcommand === 'show') {
6242
+ return showLanes(rest, { readOnly });
6243
+ }
6244
+ if (subcommand === 'add') return addLane(rest);
6245
+ if (subcommand === 'switch') return switchLane(rest);
6246
+ if (subcommand === 'assign') return assignLane(rest);
6247
+ fail(usageFor('lane'));
5544
6248
  },
5545
6249
 
5546
6250
  reclaim(argv) {
@@ -5906,6 +6610,9 @@ const commands = {
5906
6610
  knownManagedBlocks: [
5907
6611
  ...sourceLanguages.flatMap((source) => [
5908
6612
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
6613
+ protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6614
+ protocolEol(intentProtocolBlockV20(source), eol),
6615
+ protocolEol(intentProtocolBlockV20(source, true), eol),
5909
6616
  protocolEol(v1IntentProtocolBlock(14, source), eol),
5910
6617
  protocolEol(v1IntentProtocolBlock(14, source, true), eol),
5911
6618
  protocolEol(previousIntentProtocolBlock(13, source), eol),
@@ -5937,6 +6644,9 @@ const commands = {
5937
6644
  knownManagedBlocks: [
5938
6645
  ...sourceLanguages.flatMap((source) => [
5939
6646
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
6647
+ protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6648
+ protocolEol(decisionProtocolBlock('2.0', source), eol),
6649
+ protocolEol(decisionProtocolBlock('2.0', source, true), eol),
5940
6650
  protocolEol(v1DecisionProtocolBlock(14, source), eol),
5941
6651
  protocolEol(v1DecisionProtocolBlock(14, source, true), eol),
5942
6652
  protocolEol(previousDecisionProtocolBlock(13, source), eol),
@@ -6016,7 +6726,13 @@ usage:
6016
6726
  to the current contract and Git-visible workspace
6017
6727
  driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
6018
6728
  driftseal status show the outcome currently in progress
6019
- driftseal log [--last N] [--all] show outcome history (--all includes reclaimed records)
6729
+ driftseal log [--last N] [--all] [--all-lanes]
6730
+ show outcome history (current lane; --all-lanes is global)
6731
+ driftseal lane show named outcome lanes and the current lane
6732
+ driftseal lane add <name> [--desc "..."]
6733
+ driftseal lane switch <name>
6734
+ driftseal lane assign <id> <name>
6735
+ partition outcome history by long-lived capability
6020
6736
  driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]
6021
6737
  hide meaningless closed records without deleting them
6022
6738
  driftseal unreclaim <id> --reason "<why>"
@@ -6096,6 +6812,8 @@ const VALUE_TAKING_FLAGS = {
6096
6812
  extend: ['--accept', '--verify', '-v', '--decision'],
6097
6813
  end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
6098
6814
  log: ['--last', '-n'],
6815
+ lane: ['--desc'],
6816
+ 'lane add': ['--desc'],
6099
6817
  reclaim: ['--reason', '-r', '--older-than'],
6100
6818
  unreclaim: ['--reason', '-r'],
6101
6819
  absorb: ['--decisions'],
@@ -6133,6 +6851,7 @@ function mutationResources(cmd, argv) {
6133
6851
  if (cmd === 'init') return [process.cwd()];
6134
6852
  if (cmd === 'migrate') return [process.cwd()];
6135
6853
  if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
6854
+ if (cmd === 'lane') return [logDir()];
6136
6855
  if (cmd === 'absorb' && argv[0] === '--git') {
6137
6856
  const ours = argv[2];
6138
6857
  return ours ? [path.dirname(path.resolve(ours))] : [process.cwd()];
@@ -6152,7 +6871,7 @@ function mutationResources(cmd, argv) {
6152
6871
 
6153
6872
  function usesV2RepositoryState(cmd, rest) {
6154
6873
  if (
6155
- ['begin', 'extend', 'verify', 'end', 'status', 'log', 'reclaim', 'unreclaim', 'absorb', 'decision', 'init'].includes(cmd)
6874
+ ['begin', 'extend', 'verify', 'end', 'status', 'log', 'lane', 'reclaim', 'unreclaim', 'absorb', 'decision', 'init'].includes(cmd)
6156
6875
  ) {
6157
6876
  return true;
6158
6877
  }
@@ -6226,9 +6945,52 @@ function repositoryOutcomeLogFiles() {
6226
6945
  return files;
6227
6946
  }
6228
6947
 
6948
+ function indexedMigrationEvent(file) {
6949
+ if (
6950
+ canonicalPath(file) !== canonicalPath(logFile()) ||
6951
+ !laneIndexFile() ||
6952
+ !fs.existsSync(laneIndexFile())
6953
+ ) {
6954
+ return { usable: false, migration: null };
6955
+ }
6956
+ let index;
6957
+ try {
6958
+ index = openOutcomeIndex(laneIndexFile(), { readOnly: true });
6959
+ const source = index.source();
6960
+ if (!laneIndexMatchesFile(source, file)) {
6961
+ return { usable: false, migration: null };
6962
+ }
6963
+ let migration = index.migrationEvent();
6964
+ const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
6965
+ if (size > source.indexedThrough) {
6966
+ consumeLogSlice(
6967
+ file,
6968
+ source.indexedThrough,
6969
+ (event) => {
6970
+ if (event.type === 'migration' && event.id === 'v1-to-v2') migration = event;
6971
+ },
6972
+ {
6973
+ readOnly: true,
6974
+ startLine: source.indexedLines || 0,
6975
+ }
6976
+ );
6977
+ }
6978
+ return { usable: true, migration };
6979
+ } catch {
6980
+ return { usable: false, migration: null };
6981
+ } finally {
6982
+ if (index) index.close();
6983
+ }
6984
+ }
6985
+
6229
6986
  function repositoryMigrationEvent() {
6230
6987
  for (const file of repositoryOutcomeLogFiles()) {
6231
6988
  try {
6989
+ const indexed = indexedMigrationEvent(file);
6990
+ if (indexed.usable) {
6991
+ if (indexed.migration) return indexed.migration;
6992
+ continue;
6993
+ }
6232
6994
  const migration = findMigrationEvent(file);
6233
6995
  if (migration) return migration;
6234
6996
  } catch {
@@ -6421,11 +7183,14 @@ function dispatch(argv) {
6421
7183
  assertV2RepositoryReady(cmd, rest);
6422
7184
  const mutates =
6423
7185
  ['begin', 'extend', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
7186
+ (cmd === 'lane' && ['add', 'switch', 'assign'].includes(rest[0])) ||
6424
7187
  (cmd === 'migrate' && rest[1] === 'apply') ||
6425
7188
  (cmd === 'hook' && rest[0] === 'install') ||
6426
7189
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
6427
7190
  const readsIntentLog =
6428
- ['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
7191
+ ['status', 'log'].includes(cmd) ||
7192
+ (cmd === 'lane' && (!rest[0] || rest[0] === 'show')) ||
7193
+ (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
6429
7194
  if (mutates || readsIntentLog) {
6430
7195
  if (readsIntentLog) {
6431
7196
  let resources;
@@ -6493,6 +7258,7 @@ function repositoryRoot(root) {
6493
7258
  }
6494
7259
 
6495
7260
  function runCommand(argv, { root = process.cwd(), isolateStorage = false, capture = true } = {}) {
7261
+ assertSupportedNode();
6496
7262
  if (!Array.isArray(argv) || argv.some((arg) => typeof arg !== 'string')) {
6497
7263
  fail('command arguments must be an array of strings');
6498
7264
  }
@@ -6553,6 +7319,7 @@ function appendFlag(argv, flag, value) {
6553
7319
  }
6554
7320
 
6555
7321
  function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
7322
+ assertSupportedNode();
6556
7323
  const fixedRoot = repositoryRoot(root);
6557
7324
  let lastReadOnly = false;
6558
7325
  const call = (argv) => {
@@ -6595,12 +7362,27 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
6595
7362
  appendFlag(argv, '--verify-result', verifyResult);
6596
7363
  return call(argv);
6597
7364
  },
6598
- log({ last, all = false } = {}) {
7365
+ log({ last, all = false, allLanes = false } = {}) {
6599
7366
  const argv = ['log'];
6600
7367
  appendFlag(argv, '--last', last);
6601
7368
  if (all) argv.push('--all');
7369
+ if (allLanes) argv.push('--all-lanes');
6602
7370
  return call(argv);
6603
7371
  },
7372
+ lane() {
7373
+ return call(['lane']);
7374
+ },
7375
+ laneAdd({ name, description } = {}) {
7376
+ const argv = ['lane', 'add', String(name)];
7377
+ appendFlag(argv, '--desc', description);
7378
+ return call(argv);
7379
+ },
7380
+ laneSwitch({ name } = {}) {
7381
+ return call(['lane', 'switch', String(name)]);
7382
+ },
7383
+ laneAssign({ id, lane } = {}) {
7384
+ return call(['lane', 'assign', String(id), String(lane)]);
7385
+ },
6604
7386
  absorb({ otherLog, otherDecisions, abandon, dryRun = false } = {}) {
6605
7387
  if (abandon && !['ours', 'theirs'].includes(abandon)) {
6606
7388
  fail('absorb abandon must be "ours" or "theirs"');
@@ -6674,6 +7456,7 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
6674
7456
 
6675
7457
  function main() {
6676
7458
  try {
7459
+ assertSupportedNode();
6677
7460
  const result = dispatch(process.argv.slice(2));
6678
7461
  process.exitCode = result.exitCode;
6679
7462
  } catch (err) {