driftseal 2.0.0 → 2.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/bin/driftseal.js CHANGED
@@ -43,11 +43,19 @@ const DECISION_STATUSES = [
43
43
  'superseded',
44
44
  ];
45
45
  const LOG_VERSION = 2;
46
- const EVENT_SCHEMA_VERSION = 1;
46
+ const EVENT_SCHEMA_VERSION = 2;
47
+ const DEFAULT_WRITE_SCHEMA_VERSION = 1;
47
48
  const LEGACY_EVENT_SCHEMA_VERSION = 4;
48
- const PROTOCOL_VERSION = '2.0';
49
+ const PROTOCOL_VERSION = '2.1';
49
50
  const DEFAULT_LOG_LANGUAGE = 'en';
51
+ const DEFAULT_LANE = 'main';
52
+ const LANE_NAME_RE = /^[a-z][a-z0-9-]{0,62}$/;
50
53
  const IN_PROGRESS_GIT_PATH = 'driftseal-v2-in-progress.jsonl';
54
+ const CURRENT_LANE_GIT_PATH = 'driftseal-v2-current-lane';
55
+ const LANE_INDEX_GIT_PATH = 'driftseal-v2-lane-index.json';
56
+ const LANE_INDEX_VERSION = 1;
57
+ const LANE_INDEX_PREFIX_BYTES = 8192;
58
+ const LANE_INDEX_TAIL_BYTES = 64 * 1024;
51
59
  const LOCK_STALE_MS = 30 * 60 * 1000;
52
60
  const LOCK_INIT_STALE_MS = 5 * 1000;
53
61
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
@@ -84,7 +92,12 @@ function usageFor(key) {
84
92
  verify: 'usage: driftseal verify [--allow-tracked-command]',
85
93
  end: 'usage: driftseal end [id] [options]',
86
94
  status: 'usage: driftseal status',
87
- log: 'usage: driftseal log [--last N] [--all]',
95
+ log: 'usage: driftseal log [--last N] [--all] [--all-lanes]',
96
+ lane: 'usage: driftseal lane [add|switch|assign|show] ... (run: driftseal help)',
97
+ 'lane add': 'usage: driftseal lane add <name> [--desc "<why this capability exists>"]',
98
+ 'lane switch': 'usage: driftseal lane switch <name>',
99
+ 'lane assign': 'usage: driftseal lane assign <id> <name>',
100
+ 'lane show': 'usage: driftseal lane show [name]',
88
101
  reclaim:
89
102
  'usage: driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]',
90
103
  unreclaim: 'usage: driftseal unreclaim <id> --reason "<why>"',
@@ -265,6 +278,26 @@ function normalizeMigrationSource(value, line) {
265
278
  };
266
279
  }
267
280
 
281
+ function laneEventId(name) {
282
+ return `lane:${name}`;
283
+ }
284
+
285
+ function normalizeLaneName(value, { optional = false, line } = {}) {
286
+ const prefix = line === undefined ? '' : ` on log line ${line}`;
287
+ if (value === undefined || value === null || value === '') {
288
+ if (optional) return DEFAULT_LANE;
289
+ fail(`lane name required${prefix}`);
290
+ }
291
+ if (typeof value !== 'string') fail(`invalid lane name${prefix}`);
292
+ const name = value.trim();
293
+ if (!LANE_NAME_RE.test(name)) {
294
+ fail(
295
+ `invalid lane name "${name}"${prefix} (expected a lowercase letter, then up to 62 letters, digits, or hyphens)`
296
+ );
297
+ }
298
+ return name;
299
+ }
300
+
268
301
  function normalizeEvent(event, line) {
269
302
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
270
303
  fail(`invalid event object on log line ${line}`);
@@ -311,7 +344,8 @@ function normalizeEvent(event, line) {
311
344
  if (new Set(decisions).size !== decisions.length) {
312
345
  fail(`duplicate linked decision on log line ${line}`);
313
346
  }
314
- return { ...event, logVersion, outcome, acceptance, decisions, head: normalizeHead(event.head) };
347
+ const lane = normalizeLaneName(event.lane, { optional: true, line });
348
+ return { ...event, logVersion, outcome, acceptance, decisions, lane, head: normalizeHead(event.head) };
315
349
  }
316
350
 
317
351
  if (event.type === 'extend') {
@@ -412,7 +446,8 @@ function normalizeEvent(event, line) {
412
446
  typeof source.id !== 'string' || source.id.length === 0)) {
413
447
  fail(`invalid imported outcome source on log line ${line}`);
414
448
  }
415
- return { ...event, logVersion, decisions, head: normalizeHead(event.head) };
449
+ const lane = normalizeLaneName(event.lane, { optional: true, line });
450
+ return { ...event, logVersion, decisions, lane, head: normalizeHead(event.head) };
416
451
  }
417
452
 
418
453
  if (event.type === 'migration') {
@@ -432,6 +467,32 @@ function normalizeEvent(event, line) {
432
467
  };
433
468
  }
434
469
 
470
+ if (event.type === 'lane_add') {
471
+ if (logVersion !== LOG_VERSION) fail(`invalid lane add event on log line ${line}`);
472
+ const lane = normalizeLaneName(event.lane, { line });
473
+ if (lane === DEFAULT_LANE) fail(`cannot add the default lane on log line ${line}`);
474
+ if (event.id !== laneEventId(lane)) {
475
+ fail(`lane add id must be ${laneEventId(lane)} on log line ${line}`);
476
+ }
477
+ if (
478
+ event.description !== undefined &&
479
+ event.description !== null &&
480
+ typeof event.description !== 'string'
481
+ ) {
482
+ fail(`invalid lane description on log line ${line}`);
483
+ }
484
+ const description = event.description && event.description.trim().length > 0
485
+ ? event.description.trim()
486
+ : null;
487
+ return { ...event, logVersion, lane, description };
488
+ }
489
+
490
+ if (event.type === 'lane_assign') {
491
+ if (logVersion !== LOG_VERSION) fail(`invalid lane assign event on log line ${line}`);
492
+ const lane = normalizeLaneName(event.lane, { line });
493
+ return { ...event, logVersion, lane };
494
+ }
495
+
435
496
  if (event.type === 'reclaim' || event.type === 'unreclaim') {
436
497
  if (typeof event.reason !== 'string' || event.reason.trim().length === 0) {
437
498
  fail(`invalid ${event.type} event on log line ${line}`);
@@ -519,6 +580,639 @@ function inProgressFile() {
519
580
  return worktreeInProgressFile();
520
581
  }
521
582
 
583
+ function worktreeMetadataFile(gitPath, cwd = process.cwd()) {
584
+ if (!isGitWorkTree(cwd)) return null;
585
+ const resolved = gitCapture(['rev-parse', '--git-path', gitPath], cwd);
586
+ if (!resolved) return null;
587
+ return path.resolve(cwd, resolved);
588
+ }
589
+
590
+ function currentLaneFile() {
591
+ if (isParkableOutcomeLog()) return worktreeMetadataFile(CURRENT_LANE_GIT_PATH);
592
+ return path.join(logDir(), '.current-lane');
593
+ }
594
+
595
+ function laneIndexFile() {
596
+ if (isParkableOutcomeLog()) return worktreeMetadataFile(LANE_INDEX_GIT_PATH);
597
+ return path.join(logDir(), '.lane-index.json');
598
+ }
599
+
600
+ function emptyLaneCatalog() {
601
+ return new Map([[DEFAULT_LANE, { name: DEFAULT_LANE, description: null, addedAt: null, head: null }]]);
602
+ }
603
+
604
+ function readCurrentLaneName() {
605
+ const file = currentLaneFile();
606
+ if (!file || !fs.existsSync(file)) return DEFAULT_LANE;
607
+ const name = fs.readFileSync(file, 'utf8').trim();
608
+ if (!name) return DEFAULT_LANE;
609
+ return normalizeLaneName(name);
610
+ }
611
+
612
+ function writeCurrentLaneName(name, { readOnly = false } = {}) {
613
+ if (readOnly) return;
614
+ const file = currentLaneFile();
615
+ if (!file) fail('cannot persist the current lane outside a writable seal');
616
+ ensureDirectoryDurable(path.dirname(file));
617
+ ensureDerivedLaneSidecarIgnore();
618
+ atomicWriteFile(file, `${name}\n`, 0o600);
619
+ }
620
+
621
+ function ensureDerivedLaneSidecarIgnore() {
622
+ if (isParkableOutcomeLog()) return;
623
+ if (!isGitWorkTree(logDir())) return;
624
+ const ignoreFile = path.join(logDir(), '.gitignore');
625
+ let current = fs.existsSync(ignoreFile) ? fs.readFileSync(ignoreFile, 'utf8') : '';
626
+ let next = current;
627
+ for (const name of ['.current-lane', '.lane-index.json']) {
628
+ const present = next.split(/\r?\n/).some((line) => line.trim() === name);
629
+ if (present) continue;
630
+ if (next && !next.endsWith('\n')) next += '\n';
631
+ next += `${name}\n`;
632
+ }
633
+ if (next === current) return;
634
+ ensureDirectoryDurable(logDir());
635
+ atomicWriteFile(ignoreFile, next, 0o644);
636
+ }
637
+
638
+ function defaultLaneCatalogObject() {
639
+ return { [DEFAULT_LANE]: { name: DEFAULT_LANE, description: null, addedAt: null, head: null } };
640
+ }
641
+
642
+ function emptyLaneIndexState() {
643
+ return {
644
+ indexVersion: LANE_INDEX_VERSION,
645
+ source: {
646
+ indexedThrough: 0,
647
+ indexedLines: 0,
648
+ prefixHash: contentHash(''),
649
+ tailHash: contentHash(''),
650
+ },
651
+ lastBuild: 'full',
652
+ lanes: emptyLaneCatalog(),
653
+ order: [],
654
+ records: new Map(),
655
+ ranges: new Map(),
656
+ reconciliations: new Map(),
657
+ };
658
+ }
659
+
660
+ function hashFileRange(file, start, length) {
661
+ if (length <= 0) return contentHash('');
662
+ const fd = fs.openSync(file, 'r');
663
+ try {
664
+ const buf = Buffer.alloc(length);
665
+ const read = fs.readSync(fd, buf, 0, length, start);
666
+ return crypto.createHash('sha256').update(buf.subarray(0, read)).digest('hex');
667
+ } finally {
668
+ fs.closeSync(fd);
669
+ }
670
+ }
671
+
672
+ function laneIndexSourceIdentity(file, indexedThrough, indexedLines = 0) {
673
+ if (!fs.existsSync(file) || indexedThrough <= 0) {
674
+ return {
675
+ indexedThrough: 0,
676
+ indexedLines: 0,
677
+ prefixHash: contentHash(''),
678
+ tailHash: contentHash(''),
679
+ };
680
+ }
681
+ const prefix = Math.min(LANE_INDEX_PREFIX_BYTES, indexedThrough);
682
+ const tail = Math.min(LANE_INDEX_TAIL_BYTES, indexedThrough);
683
+ return {
684
+ indexedThrough,
685
+ indexedLines,
686
+ prefixHash: hashFileRange(file, 0, prefix),
687
+ tailHash: hashFileRange(file, indexedThrough - tail, tail),
688
+ };
689
+ }
690
+
691
+ function laneIndexMatchesFile(index, file) {
692
+ if (!index || index.indexVersion !== LANE_INDEX_VERSION) return false;
693
+ if (!Number.isSafeInteger(index.source?.indexedLines) || index.source.indexedLines < 0) return false;
694
+ if (!fs.existsSync(file)) return index.source.indexedThrough === 0;
695
+ const size = fs.statSync(file).size;
696
+ if (size < index.source.indexedThrough) return false;
697
+ const identity = laneIndexSourceIdentity(file, index.source.indexedThrough);
698
+ return (
699
+ identity.prefixHash === index.source.prefixHash &&
700
+ identity.tailHash === index.source.tailHash
701
+ );
702
+ }
703
+
704
+ function serializeLaneIndex(state) {
705
+ return {
706
+ indexVersion: state.indexVersion,
707
+ source: state.source,
708
+ lastBuild: state.lastBuild,
709
+ lanes: Object.fromEntries(
710
+ [...state.lanes.entries()].map(([name, lane]) => [
711
+ name,
712
+ { name, description: lane.description || null, addedAt: lane.addedAt || null, head: lane.head || null, inferred: lane.inferred === true },
713
+ ])
714
+ ),
715
+ order: [...state.order],
716
+ records: Object.fromEntries(state.records),
717
+ ranges: Object.fromEntries(state.ranges),
718
+ reconciliations: Object.fromEntries(state.reconciliations),
719
+ };
720
+ }
721
+
722
+ function deserializeLaneIndex(raw) {
723
+ if (!raw || raw.indexVersion !== LANE_INDEX_VERSION || !raw.source || !raw.records) return null;
724
+ const lanes = new Map();
725
+ for (const [name, lane] of Object.entries(raw.lanes || defaultLaneCatalogObject())) {
726
+ lanes.set(name, {
727
+ name,
728
+ description: lane.description || null,
729
+ addedAt: lane.addedAt || null,
730
+ head: lane.head || null,
731
+ inferred: lane.inferred === true,
732
+ });
733
+ }
734
+ if (!lanes.has(DEFAULT_LANE)) {
735
+ lanes.set(DEFAULT_LANE, { name: DEFAULT_LANE, description: null, addedAt: null, head: null });
736
+ }
737
+ return {
738
+ indexVersion: LANE_INDEX_VERSION,
739
+ source: raw.source,
740
+ lastBuild: raw.lastBuild || 'full',
741
+ lanes,
742
+ order: Array.isArray(raw.order) ? [...raw.order] : [],
743
+ records: new Map(Object.entries(raw.records)),
744
+ ranges: new Map(Object.entries(raw.ranges || {})),
745
+ reconciliations: new Map(Object.entries(raw.reconciliations || {})),
746
+ };
747
+ }
748
+
749
+ function cloneLaneIndexState(state) {
750
+ return deserializeLaneIndex(serializeLaneIndex(state));
751
+ }
752
+
753
+ function linkLaneIndex(state) {
754
+ const heads = new Map();
755
+ for (const id of state.order) {
756
+ const rec = state.records.get(id);
757
+ rec.previous = heads.get(rec.lane) || null;
758
+ heads.set(rec.lane, id);
759
+ }
760
+ for (const [name, lane] of state.lanes) {
761
+ lane.head = heads.get(name) || null;
762
+ }
763
+ }
764
+
765
+ function applyIndexedEvent(state, ev, startByte, endByte) {
766
+ if (ev.type === 'begin' || ev.type === 'import') {
767
+ state.ranges.set(ev.id, { firstByte: startByte, lastByte: endByte });
768
+ } else if (state.records.has(ev.id) || state.ranges.has(ev.id)) {
769
+ const range = state.ranges.get(ev.id) || { firstByte: startByte, lastByte: endByte };
770
+ range.lastByte = endByte;
771
+ state.ranges.set(ev.id, range);
772
+ }
773
+ applyFoldEvent(state, ev);
774
+ }
775
+
776
+ function applyFoldEvent(state, ev) {
777
+ const { records, reconciliations, order, lanes } = state;
778
+ const ensureLane = (name) => {
779
+ if (lanes.has(name)) return;
780
+ lanes.set(name, { name, description: null, addedAt: null, head: null, inferred: true });
781
+ };
782
+ if (ev.type === 'begin') {
783
+ if (records.has(ev.id)) fail(`duplicate begin event for outcome id: ${ev.id}`);
784
+ const record = newOutcomeRecord(ev);
785
+ ensureLane(record.lane);
786
+ records.set(ev.id, record);
787
+ order.push(ev.id);
788
+ return;
789
+ }
790
+ if (ev.type === 'import') {
791
+ if (records.has(ev.id)) fail(`duplicate imported outcome id: ${ev.id}`);
792
+ const record = newOutcomeRecord({
793
+ ...ev,
794
+ ts: ev.beganAt,
795
+ acceptance: [],
796
+ verify: null,
797
+ });
798
+ ensureLane(record.lane);
799
+ record.status = ev.status;
800
+ record.tsEnd = ev.endedAt;
801
+ record.note = ev.summary || null;
802
+ record.reclaimed = ev.reclaimed === true;
803
+ record.reclaimReason = ev.reclaimReason || null;
804
+ record.reclaimedAt = ev.reclaimedAt || null;
805
+ record.imported = {
806
+ sourceIds: ev.sources.map((source) => source.id),
807
+ sourceFingerprint: ev.sourceFingerprint,
808
+ sources: ev.sources,
809
+ };
810
+ records.set(ev.id, record);
811
+ order.push(ev.id);
812
+ return;
813
+ }
814
+ if (ev.type === 'migration') return;
815
+ if (ev.type === 'lane_add') {
816
+ const existing = lanes.get(ev.lane);
817
+ if (existing && !existing.inferred) {
818
+ if (ev.description) existing.description = ev.description;
819
+ return;
820
+ }
821
+ lanes.set(ev.lane, {
822
+ name: ev.lane,
823
+ description: ev.description || null,
824
+ addedAt: ev.ts,
825
+ head: existing ? existing.head : null,
826
+ inferred: false,
827
+ });
828
+ return;
829
+ }
830
+ if (ev.type === 'lane_assign') {
831
+ const rec = records.get(ev.id);
832
+ if (!rec) fail(`lane assign references unknown outcome id: ${ev.id}`);
833
+ if (rec.status === 'in_progress') fail(`cannot assign lane of in_progress outcome ${ev.id}`);
834
+ ensureLane(ev.lane);
835
+ rec.lane = ev.lane;
836
+ return;
837
+ }
838
+ if (ev.type === 'extend') {
839
+ const rec = records.get(ev.id);
840
+ if (!rec) fail(`extension references unknown outcome id: ${ev.id}`);
841
+ if (rec.status !== 'in_progress') fail(`extension occurred after outcome ${ev.id} was closed`);
842
+ rec.extensions.push({
843
+ extension: ev.extension,
844
+ acceptance: ev.acceptance,
845
+ verify: ev.verify,
846
+ decisions: ev.decisions,
847
+ extendedAt: ev.ts,
848
+ head: ev.head || null,
849
+ });
850
+ rec.acceptance = [...new Set([...rec.acceptance, ...ev.acceptance])];
851
+ if (ev.verify) rec.verify = ev.verify;
852
+ rec.decisions = [...new Set([...rec.decisions, ...ev.decisions])];
853
+ rec.contractHash = outcomeContractHash(rec);
854
+ rec.verification = null;
855
+ rec.decisionUpdates = [];
856
+ return;
857
+ }
858
+ if (ev.type === 'verify') {
859
+ const rec = records.get(ev.id);
860
+ if (!rec) fail(`verification event references unknown outcome id: ${ev.id}`);
861
+ if (rec.status !== 'in_progress') fail(`verification occurred after outcome ${ev.id} was closed`);
862
+ if (rec.acceptance.length === 0 || !rec.verify) {
863
+ fail(`verification event references outcome ${ev.id} without acceptance criteria`);
864
+ }
865
+ if (ev.command !== rec.verify) fail(`verification command does not match outcome ${ev.id}`);
866
+ if (rec.logVersion === LOG_VERSION && ev.contractHash !== rec.contractHash) {
867
+ fail(`verification contract does not match outcome ${ev.id}`);
868
+ }
869
+ rec.verificationAttempts.push(ev);
870
+ rec.verification = ev;
871
+ return;
872
+ }
873
+ if (ev.type === 'reclaim' || ev.type === 'unreclaim') {
874
+ const rec = records.get(ev.id);
875
+ if (!rec) fail(`${ev.type} event references unknown outcome id: ${ev.id}`);
876
+ if (ev.type === 'reclaim') {
877
+ if (rec.status === 'in_progress') fail(`cannot reclaim outcome ${ev.id} while it is in_progress`);
878
+ if (rec.reclaimed) fail(`duplicate reclaim event for outcome id: ${ev.id}`);
879
+ rec.reclaimed = true;
880
+ rec.reclaimReason = ev.reason;
881
+ rec.reclaimedAt = ev.ts;
882
+ } else {
883
+ if (!rec.reclaimed) fail(`unreclaim event for outcome id that is not reclaimed: ${ev.id}`);
884
+ rec.reclaimed = false;
885
+ rec.reclaimReason = null;
886
+ rec.reclaimedAt = null;
887
+ }
888
+ return;
889
+ }
890
+ if (ev.type === 'end') {
891
+ const rec = records.get(ev.id);
892
+ if (!rec) fail(`end event references unknown outcome id: ${ev.id}`);
893
+ if (rec.status !== 'in_progress') fail(`duplicate end event for outcome id: ${ev.id}`);
894
+ const conflictingCancellation = rec.decisionTerminals.find(
895
+ (terminal) => terminal.type === 'decision_reconcile_cancel' && terminal.outcomeStatus !== ev.status
896
+ );
897
+ if (conflictingCancellation) {
898
+ fail(`outcome ${ev.id} was closed as ${ev.status} after reconciliation recovery was cancelled for ${conflictingCancellation.outcomeStatus}`);
899
+ }
900
+ if (
901
+ ['completed', 'partial'].includes(ev.status) &&
902
+ rec.decisions.length > 0 &&
903
+ ((rec.logVersion === 1 && rec.schemaVersion >= 2 && (ev.schemaVersion || 1) < 2) ||
904
+ rec.decisions.some((decisionId) => qualifyingDecisionUpdates(rec, decisionId).length === 0))
905
+ ) {
906
+ fail(`linked outcome ${ev.id} was closed without reconciling every declared decision`);
907
+ }
908
+ if (ev.status === 'completed' && rec.acceptance.length > 0) {
909
+ if (!rec.verification || !rec.verification.passed) {
910
+ fail(`acceptance-bound outcome ${ev.id} was completed without successful machine verification`);
911
+ }
912
+ if (
913
+ (rec.logVersion === 1 && (ev.schemaVersion || 1) < 4) ||
914
+ ev.verificationId !== rec.verification.verificationId ||
915
+ (ev.workspace ?? null) !== rec.verification.workspace ||
916
+ (rec.logVersion === LOG_VERSION &&
917
+ (ev.contractHash !== rec.contractHash || rec.verification.contractHash !== rec.contractHash))
918
+ ) {
919
+ fail(`acceptance-bound outcome ${ev.id} was completed with stale machine verification`);
920
+ }
921
+ }
922
+ rec.status = ev.status;
923
+ rec.tsEnd = ev.ts;
924
+ rec.note = ev.note || null;
925
+ rec.verifyResult = ev.verifyResult || null;
926
+ rec.endHead = ev.head || null;
927
+ return;
928
+ }
929
+ if (ev.type === 'decision_reconcile_prepare') {
930
+ const rec = records.get(ev.id);
931
+ if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
932
+ if (rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
933
+ if (!rec.decisions.includes(ev.decisionId)) fail(`decision reconciliation references unlinked decision ${ev.decisionId}`);
934
+ if (reconciliations.has(ev.reconciliationId)) fail(`duplicate reconciliation id: ${ev.reconciliationId}`);
935
+ rec.decisionPrepares.push(ev);
936
+ reconciliations.set(ev.reconciliationId, {
937
+ prepare: ev,
938
+ terminal: null,
939
+ contractHash: rec.contractHash,
940
+ });
941
+ return;
942
+ }
943
+ if (ev.type === 'decision_reconcile') {
944
+ const rec = records.get(ev.id);
945
+ if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
946
+ if (rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
947
+ if (rec.logVersion === 1 && rec.schemaVersion >= 2) {
948
+ fail(`linked legacy schema-v2 outcome ${rec.id} contains a legacy decision reconciliation`);
949
+ }
950
+ rec.decisionUpdates.push(ev);
951
+ return;
952
+ }
953
+ if (
954
+ ev.type === 'decision_reconcile_commit' ||
955
+ ev.type === 'decision_reconcile_abort' ||
956
+ ev.type === 'decision_reconcile_cancel'
957
+ ) {
958
+ const rec = records.get(ev.id);
959
+ const reconciliation = reconciliations.get(ev.reconciliationId);
960
+ if (rec && rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
961
+ if (!rec || !reconciliation || reconciliation.prepare.id !== ev.id || reconciliation.prepare.decisionId !== ev.decisionId) {
962
+ fail(`decision reconciliation terminal has no matching prepare: ${ev.reconciliationId}`);
963
+ }
964
+ if (reconciliation.terminal) fail(`decision reconciliation already has a terminal event: ${ev.reconciliationId}`);
965
+ const priorCancellation = rec.decisionTerminals.find((terminal) => terminal.type === 'decision_reconcile_cancel');
966
+ if (ev.type === 'decision_reconcile_cancel' && priorCancellation && priorCancellation.outcomeStatus !== ev.outcomeStatus) {
967
+ fail(`outcome ${ev.id} has conflicting reconciliation cancellation statuses`);
968
+ }
969
+ if (
970
+ ev.type === 'decision_reconcile_commit' &&
971
+ (reconciliation.prepare.newHash !== ev.fileHash ||
972
+ reconciliation.prepare.fromStatus !== ev.fromStatus ||
973
+ reconciliation.prepare.toStatus !== ev.toStatus)
974
+ ) {
975
+ fail(`decision reconciliation commit does not match prepare: ${ev.reconciliationId}`);
976
+ }
977
+ reconciliation.terminal = ev;
978
+ rec.decisionTerminals.push(ev);
979
+ if (
980
+ ev.type === 'decision_reconcile_commit' &&
981
+ reconciliation.contractHash === rec.contractHash
982
+ ) {
983
+ rec.decisionUpdates.push(ev);
984
+ }
985
+ }
986
+ }
987
+
988
+ function consumeLogSlice(file, startByte, onEvent, { repairTail = false, readOnly = false, startLine = 0 } = {}) {
989
+ if (!fs.existsSync(file)) return { endByte: 0, endLine: 0 };
990
+ const fd = fs.openSync(file, 'r');
991
+ let size;
992
+ let buf;
993
+ try {
994
+ size = fs.fstatSync(fd).size;
995
+ if (startByte > size) fail('lane index is ahead of the outcome log');
996
+ if (startByte === size) return { endByte: size, endLine: startLine };
997
+ buf = Buffer.alloc(size - startByte);
998
+ fs.readSync(fd, buf, 0, buf.length, startByte);
999
+ } finally {
1000
+ fs.closeSync(fd);
1001
+ }
1002
+ let text = buf.toString('utf8');
1003
+ if (text.length > 0 && !text.endsWith('\n')) {
1004
+ const rawLines = text.split('\n');
1005
+ const tail = rawLines.at(-1);
1006
+ try {
1007
+ JSON.parse(tail);
1008
+ } catch {
1009
+ const validLength = text.lastIndexOf('\n') + 1;
1010
+ if (!readOnly) {
1011
+ if (!repairTail) fail(`corrupt final log line in ${file}`);
1012
+ const truncateTo = startByte + Buffer.byteLength(text.slice(0, validLength), 'utf8');
1013
+ const repairFd = fs.openSync(file, 'r+');
1014
+ try {
1015
+ fs.ftruncateSync(repairFd, truncateTo);
1016
+ fs.fsyncSync(repairFd);
1017
+ } finally {
1018
+ fs.closeSync(repairFd);
1019
+ }
1020
+ }
1021
+ text = text.slice(0, validLength);
1022
+ }
1023
+ }
1024
+ const parts = text.split('\n');
1025
+ let pos = startByte;
1026
+ let lineNumber = startLine;
1027
+ for (let i = 0; i < parts.length; i++) {
1028
+ const line = parts[i];
1029
+ if (i === parts.length - 1 && line === '') break;
1030
+ lineNumber += 1;
1031
+ const start = pos;
1032
+ pos += Buffer.byteLength(line, 'utf8');
1033
+ if (i < parts.length - 1) pos += 1;
1034
+ if (line.trim().length === 0) continue;
1035
+ try {
1036
+ const event = normalizeEvent(JSON.parse(line), lineNumber);
1037
+ if (event.logVersion !== LOG_VERSION) {
1038
+ fail(`v1 intent log cannot be used as a v2 outcome log; run driftseal migrate v1-to-v2 inspect`);
1039
+ }
1040
+ onEvent(event, start, pos);
1041
+ } catch (err) {
1042
+ if (err instanceof DriftSealError) throw err;
1043
+ fail(`corrupt log line ${lineNumber} in ${file}`);
1044
+ }
1045
+ }
1046
+ return { endByte: pos, endLine: lineNumber };
1047
+ }
1048
+
1049
+ function persistLaneIndex(state, { readOnly = false } = {}) {
1050
+ if (readOnly) return;
1051
+ const file = laneIndexFile();
1052
+ if (!file) return;
1053
+ ensureDirectoryDurable(path.dirname(file));
1054
+ ensureDerivedLaneSidecarIgnore();
1055
+ atomicWriteFile(file, `${JSON.stringify(serializeLaneIndex(state))}\n`, 0o600);
1056
+ }
1057
+
1058
+ function loadPersistedLaneIndex() {
1059
+ const file = laneIndexFile();
1060
+ if (!file || !fs.existsSync(file)) return null;
1061
+ try {
1062
+ return deserializeLaneIndex(JSON.parse(fs.readFileSync(file, 'utf8')));
1063
+ } catch {
1064
+ return null;
1065
+ }
1066
+ }
1067
+
1068
+ function syncCommittedLaneIndex({ repairTail = false, readOnly = false } = {}) {
1069
+ const file = logFile();
1070
+ let state = loadPersistedLaneIndex();
1071
+ const canIncrement = state && laneIndexMatchesFile(state, file);
1072
+ let indexedThrough = 0;
1073
+ let indexedLines = 0;
1074
+ if (!canIncrement) {
1075
+ state = emptyLaneIndexState();
1076
+ const slice = consumeLogSlice(file, 0, (event, start, end) => applyIndexedEvent(state, event, start, end), {
1077
+ repairTail,
1078
+ readOnly,
1079
+ });
1080
+ indexedThrough = slice.endByte;
1081
+ indexedLines = slice.endLine;
1082
+ state.lastBuild = 'full';
1083
+ } else {
1084
+ const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
1085
+ if (size > state.source.indexedThrough) {
1086
+ const slice = consumeLogSlice(
1087
+ file,
1088
+ state.source.indexedThrough,
1089
+ (event, start, end) => applyIndexedEvent(state, event, start, end),
1090
+ { repairTail, readOnly, startLine: state.source.indexedLines || 0 }
1091
+ );
1092
+ indexedThrough = slice.endByte;
1093
+ indexedLines = slice.endLine;
1094
+ state.lastBuild = 'incremental';
1095
+ } else {
1096
+ indexedThrough = size;
1097
+ indexedLines = state.source.indexedLines || 0;
1098
+ state.lastBuild = 'hot';
1099
+ }
1100
+ }
1101
+ state.source = laneIndexSourceIdentity(file, indexedThrough, indexedLines);
1102
+ linkLaneIndex(state);
1103
+ if (state.lastBuild !== 'hot') persistLaneIndex(state, { readOnly });
1104
+ return state;
1105
+ }
1106
+
1107
+ function applyOverlayToLaneIndex(state, overlayEvents) {
1108
+ if (!overlayEvents || overlayEvents.length === 0) return state;
1109
+ const next = cloneLaneIndexState(state);
1110
+ for (const event of overlayEvents) applyFoldEvent(next, event);
1111
+ linkLaneIndex(next);
1112
+ return next;
1113
+ }
1114
+
1115
+ function foldedRecordsFromLaneIndex(state) {
1116
+ const records = state.order.map((id) => state.records.get(id)).filter(Boolean);
1117
+ records.lanes = state.lanes;
1118
+ return records;
1119
+ }
1120
+
1121
+ function loadOutcomeView({ repairTail = false, readOnly = false } = {}) {
1122
+ const committed = syncCommittedLaneIndex({ repairTail, readOnly });
1123
+ const park = inProgressFile();
1124
+ if (!park || !fs.existsSync(park)) {
1125
+ return { state: committed, records: foldedRecordsFromLaneIndex(committed) };
1126
+ }
1127
+ try {
1128
+ const committedEvents = readJsonlRecordsFromFile(logFile(), { repairTail, readOnly }).map(
1129
+ (record) => record.event
1130
+ );
1131
+ const overlay = planInProgressOverlay(committedEvents, park, { repairTail, readOnly });
1132
+ if (!overlay || overlay.alreadyCommitted) {
1133
+ if (overlay && overlay.alreadyCommitted && !readOnly) discardInProgressLog(park);
1134
+ return { state: committed, records: foldedRecordsFromLaneIndex(committed) };
1135
+ }
1136
+ if (!readOnly && overlay.mappings.length > 0) writeJsonl(park, overlay.records);
1137
+ const state = applyOverlayToLaneIndex(
1138
+ committed,
1139
+ overlay.records.map((record) => record.event)
1140
+ );
1141
+ return { state, records: foldedRecordsFromLaneIndex(state) };
1142
+ } catch (err) {
1143
+ if (readOnly && err && err.code === 'ENOENT') {
1144
+ return { state: committed, records: foldedRecordsFromLaneIndex(committed) };
1145
+ }
1146
+ throw err;
1147
+ }
1148
+ }
1149
+
1150
+ function laneSummary(records, name) {
1151
+ const lanes = records.lanes || emptyLaneCatalog();
1152
+ const lane = lanes.get(name);
1153
+ const members = records.filter((record) => (record.lane || DEFAULT_LANE) === name);
1154
+ const visible = members.filter((record) => !record.reclaimed);
1155
+ return {
1156
+ name,
1157
+ description: lane ? lane.description : null,
1158
+ addedAt: lane ? lane.addedAt : null,
1159
+ inferred: Boolean(lane && lane.inferred),
1160
+ visible: visible.length,
1161
+ count: members.length,
1162
+ };
1163
+ }
1164
+
1165
+ function publicLane(summary) {
1166
+ return {
1167
+ name: summary.name,
1168
+ description: summary.description,
1169
+ addedAt: summary.addedAt || null,
1170
+ inferred: summary.inferred === true,
1171
+ visible: summary.visible,
1172
+ count: summary.count,
1173
+ };
1174
+ }
1175
+
1176
+ function resolveCurrentLane(records, { required = false } = {}) {
1177
+ const requested = readCurrentLaneName();
1178
+ const lanes = records.lanes || emptyLaneCatalog();
1179
+ if (lanes.has(requested)) return { current: requested, missing: null };
1180
+ if (required) {
1181
+ fail(`current lane ${requested} does not exist; switch to ${DEFAULT_LANE} or add it`);
1182
+ }
1183
+ return { current: DEFAULT_LANE, missing: requested };
1184
+ }
1185
+
1186
+ function currentLaneOrFail(records) {
1187
+ return resolveCurrentLane(records, { required: true }).current;
1188
+ }
1189
+
1190
+ function warnMissingCurrentLane(missing) {
1191
+ if (!missing) return;
1192
+ printLine(`warning: current lane ${missing} does not exist; showing ${DEFAULT_LANE}`);
1193
+ }
1194
+
1195
+ function renderLaneLine(records, name) {
1196
+ const summary = laneSummary(records, name);
1197
+ return `lane: ${name} (${summary.visible} visible / ${summary.count} in lane)`;
1198
+ }
1199
+
1200
+ function selectLaneLogRecords(records, current) {
1201
+ return records.filter(
1202
+ (record) => (record.lane || DEFAULT_LANE) === current || record.status === 'in_progress'
1203
+ );
1204
+ }
1205
+
1206
+ function selectLastLogRecords(records, current, n) {
1207
+ const inLane = records.filter((record) => (record.lane || DEFAULT_LANE) === current);
1208
+ const clipped = inLane.slice(-n);
1209
+ const kept = new Set(clipped.map((record) => record.id));
1210
+ const extras = records.filter((record) => record.status === 'in_progress' && !kept.has(record.id));
1211
+ if (extras.length === 0) return clipped;
1212
+ const order = new Map(records.map((record, index) => [record.id, index]));
1213
+ return [...clipped, ...extras].sort((left, right) => order.get(left.id) - order.get(right.id));
1214
+ }
1215
+
522
1216
  function liveWorktreeOutcomeLog() {
523
1217
  const root = gitWorktreeRoot();
524
1218
  if (!root) return null;
@@ -706,10 +1400,27 @@ function ensureV2OutcomeLogExists() {
706
1400
  fsyncDirectory(logDir());
707
1401
  }
708
1402
 
1403
+ function eventWriteSchemaVersion(event) {
1404
+ if (Number.isSafeInteger(event.schemaVersion)) return event.schemaVersion;
1405
+ if (event.type === 'lane_add' || event.type === 'lane_assign') return EVENT_SCHEMA_VERSION;
1406
+ if (
1407
+ (event.type === 'begin' || event.type === 'import') &&
1408
+ event.lane &&
1409
+ event.lane !== DEFAULT_LANE
1410
+ ) {
1411
+ return EVENT_SCHEMA_VERSION;
1412
+ }
1413
+ return DEFAULT_WRITE_SCHEMA_VERSION;
1414
+ }
1415
+
709
1416
  function appendEventTo(file, event) {
710
1417
  ensureDirectoryDurable(path.dirname(file));
711
1418
  const existed = fs.existsSync(file);
712
- const storedEvent = { logVersion: LOG_VERSION, schemaVersion: EVENT_SCHEMA_VERSION, ...event };
1419
+ const storedEvent = {
1420
+ logVersion: LOG_VERSION,
1421
+ ...event,
1422
+ schemaVersion: eventWriteSchemaVersion(event),
1423
+ };
713
1424
  const line = Buffer.from(`${JSON.stringify(storedEvent)}\n`, 'utf8');
714
1425
  const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
715
1426
  try {
@@ -1182,6 +1893,7 @@ function newOutcomeRecord(ev) {
1182
1893
  decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
1183
1894
  logVersion: ev.logVersion || 1,
1184
1895
  schemaVersion: ev.schemaVersion || 1,
1896
+ lane: ev.lane || DEFAULT_LANE,
1185
1897
  decisionPrepares: [],
1186
1898
  decisionTerminals: [],
1187
1899
  decisionUpdates: [],
@@ -1204,177 +1916,16 @@ function newOutcomeRecord(ev) {
1204
1916
 
1205
1917
  /** Fold the event stream into one record per outcome. Legacy v1 events are accepted for migration. */
1206
1918
  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));
1919
+ const state = {
1920
+ records: new Map(),
1921
+ reconciliations: new Map(),
1922
+ order: [],
1923
+ lanes: emptyLaneCatalog(),
1924
+ };
1925
+ for (const ev of events) applyFoldEvent(state, ev);
1926
+ const folded = state.order.map((id) => state.records.get(id));
1927
+ folded.lanes = state.lanes;
1928
+ return folded;
1378
1929
  }
1379
1930
 
1380
1931
  function qualifyingDecisionUpdates(record, decisionId) {
@@ -1756,8 +2307,12 @@ function parseArgs(argv, spec, usageKey) {
1756
2307
  return { positionals, flags };
1757
2308
  }
1758
2309
 
1759
- function render(rec) {
2310
+ function render(rec, { currentLane } = {}) {
1760
2311
  const lines = [`[${rec.id}] ${rec.status}`];
2312
+ const lane = rec.lane || DEFAULT_LANE;
2313
+ if (lane !== DEFAULT_LANE || (currentLane && lane !== currentLane)) {
2314
+ lines.push(` lane: ${lane}`);
2315
+ }
1761
2316
  lines.push(` outcome: ${rec.outcome}`);
1762
2317
  for (const extension of rec.extensions) lines.push(` extend: ${extension.extension}`);
1763
2318
  for (const criterion of rec.acceptance) lines.push(` accept: ${criterion}`);
@@ -1807,6 +2362,7 @@ function publicOutcome(rec) {
1807
2362
  return {
1808
2363
  id: rec.id,
1809
2364
  outcome: rec.outcome,
2365
+ lane: rec.lane || DEFAULT_LANE,
1810
2366
  extensions: rec.extensions.map((extension) => ({ ...extension })),
1811
2367
  acceptance: [...rec.acceptance],
1812
2368
  verify: rec.verify,
@@ -2085,12 +2641,12 @@ function stripDecisionLogLanguage(block, language = DEFAULT_LOG_LANGUAGE) {
2085
2641
  function outcomeLogLanguageParagraph(language) {
2086
2642
  return `**Log language:** \`${language}\`. Write outcome-log prose (outcome, extension, note,
2087
2643
  verify-result, and reclaim/unreclaim reason) in that language. Keep command
2088
- names, flags, status tokens, and ids in English.`;
2644
+ names, flags, status tokens, ids, and lane names in English.`;
2089
2645
  }
2090
2646
 
2091
- function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2647
+ function intentProtocolBlockV20(language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2092
2648
  return `${INTENT_PROTOCOL_MARKER}
2093
- <!-- driftseal-version: ${version} -->
2649
+ <!-- driftseal-version: 2.0 -->
2094
2650
  <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
2095
2651
 
2096
2652
  ## Agent protocol: outcome write-ahead log
@@ -2099,7 +2655,9 @@ This repository uses DriftSeal (\`driftseal\`) to prevent agent drift. This
2099
2655
  \`AGENTS.md\` protocol is the source of truth; use the CLI by default, with MCP
2100
2656
  and lifecycle hooks as optional adapters.
2101
2657
 
2102
- ${outcomeLogLanguageParagraph(language)}
2658
+ **Log language:** \`${language}\`. Write outcome-log prose (outcome, extension, note,
2659
+ verify-result, and reclaim/unreclaim reason) in that language. Keep command
2660
+ names, flags, status tokens, and ids in English.
2103
2661
 
2104
2662
  1. **Write the outcome first**, before changing durable project content:
2105
2663
  \`driftseal begin "<coherent delivery outcome>" --accept "<observable result>" --verify "<exact command that proves the cumulative contract>"\`.
@@ -2141,6 +2699,70 @@ Seal root: \`.seal/\` (override with \`$DRIFTSEAL_HOME\`); outcome log:
2141
2699
  ${INTENT_PROTOCOL_END}`;
2142
2700
  }
2143
2701
 
2702
+ function intentProtocolBlockV21(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2703
+ return `${INTENT_PROTOCOL_MARKER}
2704
+ <!-- driftseal-version: ${version} -->
2705
+ <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
2706
+
2707
+ ## Agent protocol: outcome write-ahead log
2708
+
2709
+ This repository uses DriftSeal (\`driftseal\`) to prevent agent drift. This
2710
+ \`AGENTS.md\` protocol is the source of truth; use the CLI by default, with MCP
2711
+ and lifecycle hooks as optional adapters.
2712
+
2713
+ ${outcomeLogLanguageParagraph(language)}
2714
+
2715
+ 1. **Write the outcome first**, before changing durable project content:
2716
+ \`driftseal begin "<coherent delivery outcome>" --accept "<observable result>" --verify "<exact command that proves the cumulative contract>"\`.
2717
+ Repeat \`--accept\` for independently observable criteria and add one
2718
+ \`--decision <id>\` for each existing MADR this outcome may change.
2719
+ Record outcomes for changes intended to persist in the project: code,
2720
+ configuration, documentation, dependencies, and equivalent files, inside or
2721
+ outside Git. Git operations, checks, temporary auxiliary work, and external
2722
+ state changes are exempt when they do not write durable project content here.
2723
+ 2. **Extend only the same outcome.** For another step toward the same coherent
2724
+ delivery goal, append \`driftseal extend "<addition>"\`. It may add
2725
+ \`--accept\`, \`--decision\`, and a replacement \`--verify\`; adding acceptance
2726
+ requires a replacement verifier that proves the complete accumulated contract.
2727
+ Every extension invalidates earlier verification and MADR reconciliation. If
2728
+ the delivery goal changes, close the current outcome honestly and begin a new one.
2729
+ One open outcome belongs to one worktree, or one configured non-Git project
2730
+ root. Every agent changing durable content in the same root re-anchors and
2731
+ continues it; separate worktrees hold separate outcomes.
2732
+ Outcomes belong to one named lane (\`driftseal lane\`). The default lane is
2733
+ \`main\`; untagged history lives there. Re-anchoring and \`driftseal log\`
2734
+ follow the current lane. Close the open outcome before switching lanes.
2735
+ Create a lane only for a long-lived capability you expect to leave and resume.
2736
+ 3. **Reconcile, verify, then close.** After the final extension, reconcile every
2737
+ linked MADR with \`driftseal decision update\`. Inspect \`driftseal status\`,
2738
+ then run \`driftseal verify\` for an acceptance-bound outcome. A verifier
2739
+ without matching local provenance is untrusted and requires
2740
+ \`--allow-tracked-command\` after inspection. Finish with
2741
+ \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>"\`.
2742
+ Completed outcomes require fresh successful verification bound to both the
2743
+ current contract hash and Git-visible workspace. Never report success without
2744
+ closing the outcome.
2745
+ 4. **Re-anchor after context loss or handoff:** run \`driftseal status\` and
2746
+ \`driftseal log --last 3\` before changing durable content. Both follow the
2747
+ current lane. Resume the open outcome when it still matches; otherwise close
2748
+ it and begin a new one. If the requested work belongs to a different existing
2749
+ lane, switch first.
2750
+
2751
+ **Log access goes only through DriftSeal.** Never read, edit, move, or delete
2752
+ \`.seal/outcomes/events.jsonl\` (or its configured equivalent) directly. Use
2753
+ \`reclaim\`/\`unreclaim\` for visibility markers and \`absorb\` after merge
2754
+ collisions. These operations preserve append-only single-lineage history.
2755
+
2756
+ Seal root: \`.seal/\` (override with \`$DRIFTSEAL_HOME\`); outcome log:
2757
+ \`.seal/outcomes/events.jsonl\`; ${localLog ? 'keep `.seal/` local and untracked.' : 'commit `.seal/` with the code.'}
2758
+ ${INTENT_PROTOCOL_END}`;
2759
+ }
2760
+
2761
+ function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2762
+ if (String(version) === '2.0') return intentProtocolBlockV20(language, localLog);
2763
+ return intentProtocolBlockV21(version, language, localLog);
2764
+ }
2765
+
2144
2766
  function v1IntentProtocolBlock(version = 14, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2145
2767
  return `${INTENT_PROTOCOL_MARKER}
2146
2768
  <!-- driftseal-version: ${version} -->
@@ -2858,6 +3480,8 @@ const SKILL_RELEASE_DIGESTS = new Set([
2858
3480
  '72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
2859
3481
  'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb
2860
3482
  '42a0549dff21483c0508ea4a79658e7bf05cd98f8af4238c95dbd23cdcde7ee6', // 2.0.0 outcome workflow
3483
+ '38e89060ff37ecdd663eae73a3e0c646d6bb220fc8232a5762356375d84ba69b', // 2.1.0 outcome lanes
3484
+ 'b4b2cc27ea71c5777b5eb9b67d861fbe1da43f31ab5d422d6a877e0cad592493', // 2.1.0 lane re-anchor recovery
2861
3485
  ]);
2862
3486
 
2863
3487
  function skillInstallUsage() {
@@ -3849,6 +4473,19 @@ function rebindV2ContractHashes(records) {
3849
4473
  });
3850
4474
  }
3851
4475
 
4476
+ function dropDuplicateLaneAdds(oursEvents, theirsRecords) {
4477
+ const seen = new Set();
4478
+ for (const event of oursEvents) {
4479
+ if (event.type === 'lane_add') seen.add(event.lane);
4480
+ }
4481
+ return theirsRecords.filter((record) => {
4482
+ if (record.event.type !== 'lane_add') return true;
4483
+ if (seen.has(record.event.lane)) return false;
4484
+ seen.add(record.event.lane);
4485
+ return true;
4486
+ });
4487
+ }
4488
+
3852
4489
  function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
3853
4490
  const intentMap = new Map();
3854
4491
  const mappings = [];
@@ -3870,6 +4507,7 @@ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = ne
3870
4507
 
3871
4508
  function repairDuplicateOutcomeRecords(records, decisionMap, hashMap = new Map()) {
3872
4509
  const seenBegins = new Set();
4510
+ const seenLanes = new Set();
3873
4511
  const intentMap = new Map();
3874
4512
  const used = [];
3875
4513
  const mappings = [];
@@ -3877,6 +4515,10 @@ function repairDuplicateOutcomeRecords(records, decisionMap, hashMap = new Map()
3877
4515
  let incomingSide = false;
3878
4516
  for (const record of records) {
3879
4517
  let event = record.event;
4518
+ if (event.type === 'lane_add') {
4519
+ if (seenLanes.has(event.lane)) continue;
4520
+ seenLanes.add(event.lane);
4521
+ }
3880
4522
  if (isOutcomeStart(event) && seenBegins.has(event.id)) {
3881
4523
  incomingSide = true;
3882
4524
  const { date } = parseOutcomeId(event.id);
@@ -3946,7 +4588,7 @@ function abandonOpenIntent(records, targetId, side) {
3946
4588
  records.push({
3947
4589
  event: {
3948
4590
  logVersion: LOG_VERSION,
3949
- schemaVersion: EVENT_SCHEMA_VERSION,
4591
+ schemaVersion: DEFAULT_WRITE_SCHEMA_VERSION,
3950
4592
  type: 'end',
3951
4593
  id: targetId,
3952
4594
  ts: new Date().toISOString(),
@@ -4169,7 +4811,10 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
4169
4811
  baseIds: options.baseDecisionIds || new Set(),
4170
4812
  });
4171
4813
  const remapped = remapTheirsRecords(
4172
- streams.theirsNew,
4814
+ dropDuplicateLaneAdds(
4815
+ [...streams.base, ...streams.oursNew].map((record) => record.event),
4816
+ streams.theirsNew
4817
+ ),
4173
4818
  [...streams.base, ...streams.oursNew].map((record) => record.event),
4174
4819
  decisionPlan.decisionMap,
4175
4820
  decisionPlan.hashMap
@@ -4913,7 +5558,7 @@ function validateMigrationPlan(plan, snapshot) {
4913
5558
  }
4914
5559
 
4915
5560
  function storedV2Event(event) {
4916
- return { logVersion: LOG_VERSION, schemaVersion: EVENT_SCHEMA_VERSION, ...event };
5561
+ return { logVersion: LOG_VERSION, schemaVersion: DEFAULT_WRITE_SCHEMA_VERSION, ...event };
4917
5562
  }
4918
5563
 
4919
5564
  function migrationImportEvent(snapshot, validated, group, events) {
@@ -5270,6 +5915,116 @@ function checkMigration(snapshot, { sourceMissing = false } = {}) {
5270
5915
  };
5271
5916
  }
5272
5917
 
5918
+ function listLaneSnapshot(records) {
5919
+ const catalog = records.lanes || emptyLaneCatalog();
5920
+ const { current, missing } = resolveCurrentLane(records);
5921
+ const lanes = [...catalog.keys()].map((name) => {
5922
+ const summary = laneSummary(records, name);
5923
+ return {
5924
+ ...publicLane(summary),
5925
+ current: name === current,
5926
+ };
5927
+ });
5928
+ return { current, missingCurrentLane: missing, lanes, total: records.length };
5929
+ }
5930
+
5931
+ function printLaneSnapshot(snapshot) {
5932
+ warnMissingCurrentLane(snapshot.missingCurrentLane);
5933
+ const lines = snapshot.lanes.map((lane) => {
5934
+ const mark = lane.current ? '*' : ' ';
5935
+ const desc = lane.description ? ` — ${lane.description}` : '';
5936
+ const inferred = lane.inferred ? ' (inferred)' : '';
5937
+ return `${mark} ${lane.name} ${lane.visible} visible / ${lane.count} in lane${desc}${inferred}`;
5938
+ });
5939
+ printLine(`current lane: ${snapshot.current}`);
5940
+ printLine(lines.join('\n'));
5941
+ return snapshot;
5942
+ }
5943
+
5944
+ function showLanes(argv, { readOnly = false } = {}) {
5945
+ const { positionals } = parseArgs(argv, {}, 'lane show');
5946
+ const view = loadOutcomeView({ repairTail: true, readOnly });
5947
+ const snapshot = listLaneSnapshot(view.records);
5948
+ if (positionals.length === 0) return printLaneSnapshot(snapshot);
5949
+ if (positionals.length !== 1) fail(usageFor('lane show'));
5950
+ const name = normalizeLaneName(positionals[0]);
5951
+ const lane = snapshot.lanes.find((item) => item.name === name);
5952
+ if (!lane) fail(`unknown lane ${name}`);
5953
+ printLine(
5954
+ `${lane.current ? 'current ' : ''}lane: ${lane.name} (${lane.visible} visible / ${lane.count} in lane)`
5955
+ );
5956
+ if (lane.description) printLine(lane.description);
5957
+ return lane;
5958
+ }
5959
+
5960
+ function addLane(argv) {
5961
+ const { positionals, flags } = parseArgs(argv, { desc: 'single' }, 'lane add');
5962
+ if (positionals.length !== 1) fail(usageFor('lane add'));
5963
+ const name = normalizeLaneName(positionals[0]);
5964
+ if (name === DEFAULT_LANE) fail(`lane ${DEFAULT_LANE} always exists`);
5965
+ const events = readEvents({ repairTail: true });
5966
+ const records = fold(events);
5967
+ const catalog = records.lanes || emptyLaneCatalog();
5968
+ const existing = catalog.get(name);
5969
+ if (existing && !existing.inferred) fail(`lane ${name} already exists`);
5970
+ const description = flags.desc && flags.desc.trim() ? flags.desc.trim() : null;
5971
+ appendEvent({
5972
+ type: 'lane_add',
5973
+ id: laneEventId(name),
5974
+ ts: new Date().toISOString(),
5975
+ lane: name,
5976
+ description,
5977
+ });
5978
+ printLine(`added lane ${name}`);
5979
+ return { name, description };
5980
+ }
5981
+
5982
+ function switchLane(argv) {
5983
+ const { positionals } = parseArgs(argv, {}, 'lane switch');
5984
+ if (positionals.length !== 1) fail(usageFor('lane switch'));
5985
+ const name = normalizeLaneName(positionals[0]);
5986
+ const events = readEvents({ repairTail: true });
5987
+ const records = fold(events);
5988
+ const catalog = records.lanes || emptyLaneCatalog();
5989
+ if (!catalog.has(name)) fail(`unknown lane ${name}; add it with driftseal lane add ${name}`);
5990
+ const open = openOutcome(records);
5991
+ if (open) {
5992
+ fail(
5993
+ `outcome ${open.id} is still in_progress on lane ${open.lane || DEFAULT_LANE}; ` +
5994
+ 'end it before switching lanes'
5995
+ );
5996
+ }
5997
+ writeCurrentLaneName(name);
5998
+ printLine(`switched to lane ${name}`);
5999
+ return { current: name };
6000
+ }
6001
+
6002
+ function assignLane(argv) {
6003
+ const { positionals } = parseArgs(argv, {}, 'lane assign');
6004
+ if (positionals.length !== 2) fail(usageFor('lane assign'));
6005
+ const id = positionals[0];
6006
+ const name = normalizeLaneName(positionals[1]);
6007
+ const events = readEvents({ repairTail: true });
6008
+ const records = fold(events);
6009
+ const record = records.find((candidate) => candidate.id === id);
6010
+ if (!record) fail(`unknown outcome id: ${id}`);
6011
+ if (record.status === 'in_progress') fail(`cannot assign lane of in_progress outcome ${id}`);
6012
+ const catalog = records.lanes || emptyLaneCatalog();
6013
+ if (!catalog.has(name)) fail(`unknown lane ${name}; add it with driftseal lane add ${name}`);
6014
+ if ((record.lane || DEFAULT_LANE) === name) {
6015
+ printLine(`${id} already on lane ${name}`);
6016
+ return publicOutcome(record);
6017
+ }
6018
+ appendEvent({
6019
+ type: 'lane_assign',
6020
+ id,
6021
+ ts: new Date().toISOString(),
6022
+ lane: name,
6023
+ });
6024
+ printLine(`${id} assigned to lane ${name}`);
6025
+ return { ...publicOutcome(record), lane: name };
6026
+ }
6027
+
5273
6028
  const commands = {
5274
6029
  begin(argv) {
5275
6030
  const { positionals, flags } = parseArgs(argv, {
@@ -5324,6 +6079,7 @@ const commands = {
5324
6079
  }
5325
6080
 
5326
6081
  const id = nextId(events);
6082
+ const currentLane = currentLaneOrFail(records);
5327
6083
  events.push(appendEvent({
5328
6084
  type: 'begin',
5329
6085
  id,
@@ -5332,6 +6088,7 @@ const commands = {
5332
6088
  acceptance,
5333
6089
  verify: flags.verify || null,
5334
6090
  decisions,
6091
+ ...(currentLane !== DEFAULT_LANE ? { lane: currentLane } : {}),
5335
6092
  head: gitCapture(['rev-parse', 'HEAD']),
5336
6093
  }));
5337
6094
  const record = fold(events).find((candidate) => candidate.id === id);
@@ -5513,34 +6270,71 @@ const commands = {
5513
6270
  printLine('parked v1 intent; close it with: driftseal end --status abandoned --note "close parked v1 intent before migration"');
5514
6271
  return publicOutcome(parkedV1);
5515
6272
  }
5516
- const open = openOutcome(fold(readEvents({ repairTail: true, readOnly })));
6273
+ const view = loadOutcomeView({ repairTail: true, readOnly });
6274
+ const records = view.records;
6275
+ const { current, missing } = resolveCurrentLane(records);
6276
+ warnMissingCurrentLane(missing);
6277
+ const customLanes = records.lanes && records.lanes.size > 1;
6278
+ if (customLanes || current !== DEFAULT_LANE) {
6279
+ printLine(renderLaneLine(records, current));
6280
+ }
6281
+ const open = openOutcome(records);
5517
6282
  if (!open) {
5518
6283
  printLine('no outcome in progress');
5519
6284
  return null;
5520
6285
  }
5521
- printLine(render(open));
6286
+ printLine(render(open, { currentLane: current }));
5522
6287
  return publicOutcome(open);
5523
6288
  },
5524
6289
 
5525
6290
  log(argv, { readOnly = false } = {}) {
5526
- const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' }, 'log');
6291
+ const { positionals, flags } = parseArgs(argv, {
6292
+ last: '-n',
6293
+ all: 'boolean',
6294
+ 'all-lanes': 'boolean',
6295
+ }, 'log');
5527
6296
  if (positionals.length > 0) fail(usageFor('log'));
5528
- let records = fold(readEvents({ repairTail: true, readOnly }));
6297
+ const view = loadOutcomeView({ repairTail: true, readOnly });
6298
+ let records = view.records;
5529
6299
  const parkedV1 = legacyParkedIntent();
5530
6300
  if (parkedV1 && !records.some((record) => record.id === parkedV1.id && record.status === 'in_progress')) {
5531
- records = [...records, parkedV1];
6301
+ records = Object.assign([...records, parkedV1], { lanes: records.lanes });
6302
+ }
6303
+ const catalog = records.lanes || emptyLaneCatalog();
6304
+ const { current, missing } = resolveCurrentLane(view.records);
6305
+ warnMissingCurrentLane(missing);
6306
+ const allLanes = flags['all-lanes'] === true;
6307
+ if (!allLanes) {
6308
+ records = Object.assign(selectLaneLogRecords(records, current), { lanes: catalog });
6309
+ }
6310
+ const visible = flags.all ? records : records.filter((record) => !record.reclaimed);
6311
+ const customLanes = catalog.size > 1;
6312
+ if (!allLanes && (customLanes || current !== DEFAULT_LANE || missing)) {
6313
+ printLine(renderLaneLine(view.records, current));
5532
6314
  }
5533
- if (!flags.all) records = records.filter((record) => !record.reclaimed);
6315
+ let shown = visible;
5534
6316
  if (flags.last) {
5535
6317
  const n = positiveInteger(flags.last, '--last');
5536
- records = records.slice(-n);
6318
+ shown = selectLastLogRecords(visible, current, n);
5537
6319
  }
5538
- if (records.length === 0) {
6320
+ if (shown.length === 0) {
5539
6321
  printLine('log is empty');
5540
6322
  return [];
5541
6323
  }
5542
- printLine(records.map(render).join('\n\n'));
5543
- return records.map(publicOutcome);
6324
+ printLine(shown.map((record) => render(record, { currentLane: current })).join('\n\n'));
6325
+ return shown.map(publicOutcome);
6326
+ },
6327
+
6328
+ lane(argv, { readOnly = false } = {}) {
6329
+ const [subcommand, ...rest] = argv;
6330
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('lane');
6331
+ if (!subcommand || subcommand === 'show') {
6332
+ return showLanes(rest, { readOnly });
6333
+ }
6334
+ if (subcommand === 'add') return addLane(rest);
6335
+ if (subcommand === 'switch') return switchLane(rest);
6336
+ if (subcommand === 'assign') return assignLane(rest);
6337
+ fail(usageFor('lane'));
5544
6338
  },
5545
6339
 
5546
6340
  reclaim(argv) {
@@ -5906,6 +6700,9 @@ const commands = {
5906
6700
  knownManagedBlocks: [
5907
6701
  ...sourceLanguages.flatMap((source) => [
5908
6702
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
6703
+ protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6704
+ protocolEol(intentProtocolBlockV20(source), eol),
6705
+ protocolEol(intentProtocolBlockV20(source, true), eol),
5909
6706
  protocolEol(v1IntentProtocolBlock(14, source), eol),
5910
6707
  protocolEol(v1IntentProtocolBlock(14, source, true), eol),
5911
6708
  protocolEol(previousIntentProtocolBlock(13, source), eol),
@@ -5937,6 +6734,9 @@ const commands = {
5937
6734
  knownManagedBlocks: [
5938
6735
  ...sourceLanguages.flatMap((source) => [
5939
6736
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
6737
+ protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source, true), eol),
6738
+ protocolEol(decisionProtocolBlock('2.0', source), eol),
6739
+ protocolEol(decisionProtocolBlock('2.0', source, true), eol),
5940
6740
  protocolEol(v1DecisionProtocolBlock(14, source), eol),
5941
6741
  protocolEol(v1DecisionProtocolBlock(14, source, true), eol),
5942
6742
  protocolEol(previousDecisionProtocolBlock(13, source), eol),
@@ -6016,7 +6816,13 @@ usage:
6016
6816
  to the current contract and Git-visible workspace
6017
6817
  driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
6018
6818
  driftseal status show the outcome currently in progress
6019
- driftseal log [--last N] [--all] show outcome history (--all includes reclaimed records)
6819
+ driftseal log [--last N] [--all] [--all-lanes]
6820
+ show outcome history (current lane; --all-lanes is global)
6821
+ driftseal lane show named outcome lanes and the current lane
6822
+ driftseal lane add <name> [--desc "..."]
6823
+ driftseal lane switch <name>
6824
+ driftseal lane assign <id> <name>
6825
+ partition outcome history by long-lived capability
6020
6826
  driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]
6021
6827
  hide meaningless closed records without deleting them
6022
6828
  driftseal unreclaim <id> --reason "<why>"
@@ -6096,6 +6902,8 @@ const VALUE_TAKING_FLAGS = {
6096
6902
  extend: ['--accept', '--verify', '-v', '--decision'],
6097
6903
  end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
6098
6904
  log: ['--last', '-n'],
6905
+ lane: ['--desc'],
6906
+ 'lane add': ['--desc'],
6099
6907
  reclaim: ['--reason', '-r', '--older-than'],
6100
6908
  unreclaim: ['--reason', '-r'],
6101
6909
  absorb: ['--decisions'],
@@ -6133,6 +6941,7 @@ function mutationResources(cmd, argv) {
6133
6941
  if (cmd === 'init') return [process.cwd()];
6134
6942
  if (cmd === 'migrate') return [process.cwd()];
6135
6943
  if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
6944
+ if (cmd === 'lane') return [logDir()];
6136
6945
  if (cmd === 'absorb' && argv[0] === '--git') {
6137
6946
  const ours = argv[2];
6138
6947
  return ours ? [path.dirname(path.resolve(ours))] : [process.cwd()];
@@ -6152,7 +6961,7 @@ function mutationResources(cmd, argv) {
6152
6961
 
6153
6962
  function usesV2RepositoryState(cmd, rest) {
6154
6963
  if (
6155
- ['begin', 'extend', 'verify', 'end', 'status', 'log', 'reclaim', 'unreclaim', 'absorb', 'decision', 'init'].includes(cmd)
6964
+ ['begin', 'extend', 'verify', 'end', 'status', 'log', 'lane', 'reclaim', 'unreclaim', 'absorb', 'decision', 'init'].includes(cmd)
6156
6965
  ) {
6157
6966
  return true;
6158
6967
  }
@@ -6421,11 +7230,14 @@ function dispatch(argv) {
6421
7230
  assertV2RepositoryReady(cmd, rest);
6422
7231
  const mutates =
6423
7232
  ['begin', 'extend', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
7233
+ (cmd === 'lane' && ['add', 'switch', 'assign'].includes(rest[0])) ||
6424
7234
  (cmd === 'migrate' && rest[1] === 'apply') ||
6425
7235
  (cmd === 'hook' && rest[0] === 'install') ||
6426
7236
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
6427
7237
  const readsIntentLog =
6428
- ['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
7238
+ ['status', 'log'].includes(cmd) ||
7239
+ (cmd === 'lane' && (!rest[0] || rest[0] === 'show')) ||
7240
+ (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
6429
7241
  if (mutates || readsIntentLog) {
6430
7242
  if (readsIntentLog) {
6431
7243
  let resources;
@@ -6595,12 +7407,27 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
6595
7407
  appendFlag(argv, '--verify-result', verifyResult);
6596
7408
  return call(argv);
6597
7409
  },
6598
- log({ last, all = false } = {}) {
7410
+ log({ last, all = false, allLanes = false } = {}) {
6599
7411
  const argv = ['log'];
6600
7412
  appendFlag(argv, '--last', last);
6601
7413
  if (all) argv.push('--all');
7414
+ if (allLanes) argv.push('--all-lanes');
6602
7415
  return call(argv);
6603
7416
  },
7417
+ lane() {
7418
+ return call(['lane']);
7419
+ },
7420
+ laneAdd({ name, description } = {}) {
7421
+ const argv = ['lane', 'add', String(name)];
7422
+ appendFlag(argv, '--desc', description);
7423
+ return call(argv);
7424
+ },
7425
+ laneSwitch({ name } = {}) {
7426
+ return call(['lane', 'switch', String(name)]);
7427
+ },
7428
+ laneAssign({ id, lane } = {}) {
7429
+ return call(['lane', 'assign', String(id), String(lane)]);
7430
+ },
6604
7431
  absorb({ otherLog, otherDecisions, abandon, dryRun = false } = {}) {
6605
7432
  if (abandon && !['ours', 'theirs'].includes(abandon)) {
6606
7433
  fail('absorb abandon must be "ours" or "theirs"');