driftseal 1.4.0 → 2.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
@@ -2,24 +2,26 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * DriftSeal — Seal the intent. Stop the drift.
5
+ * DriftSeal — Seal the outcome. Stop the drift.
6
6
  *
7
- * Intent-level write-ahead log and MADR decision log for agentic coding sessions.
7
+ * Outcome-level write-ahead log and MADR decision log for agentic coding sessions.
8
8
  *
9
9
  * Protocol per work round:
10
- * 1. driftseal begin "<intent>" [--accept "<observable outcome>"] [--verify "<command>"]
11
- * 2. execute the intent
10
+ * 1. driftseal begin "<outcome>" [--accept "<observable result>"] [--verify "<command>"]
11
+ * 2. execute the outcome, using driftseal extend for same-outcome additions
12
12
  * 3. driftseal verify (for acceptance-bound machine evidence)
13
- * 4. driftseal end [--status ...] [--note ...] [--verify-result ...] (reconcile against intent)
13
+ * 4. driftseal end [--status ...] [--note ...] [--verify-result ...]
14
14
  *
15
15
  * Events are appended to an append-only JSONL log (WAL semantics):
16
- * { "type": "begin", "id", "ts", "intent", "acceptance", "verify" }
16
+ * { "type": "begin", "id", "ts", "outcome", "acceptance", "verify" }
17
+ * { "type": "extend", "id", "ts", "extension", "acceptance", "verify" }
17
18
  * { "type": "verify", "id", "ts", "command", "passed", "workspace" }
18
19
  * { "type": "end", "id", "ts", "status", "note", "verifyResult" }
19
20
  *
20
- * Intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl in cwd.
21
- * In a Git worktree, an open intent is parked in Git metadata until end.
22
- * Decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in cwd.
21
+ * Seal root: $DRIFTSEAL_HOME, or .seal in cwd.
22
+ * Outcome log: <seal-root>/outcomes/events.jsonl.
23
+ * In a Git worktree, an open outcome is parked in Git metadata until end.
24
+ * MADR records: <seal-root>/madr/.
23
25
  */
24
26
 
25
27
  const fs = require('fs');
@@ -40,10 +42,12 @@ const DECISION_STATUSES = [
40
42
  'deprecated',
41
43
  'superseded',
42
44
  ];
43
- const EVENT_SCHEMA_VERSION = 4;
44
- const PROTOCOL_VERSION = 14;
45
+ const LOG_VERSION = 2;
46
+ const EVENT_SCHEMA_VERSION = 1;
47
+ const LEGACY_EVENT_SCHEMA_VERSION = 4;
48
+ const PROTOCOL_VERSION = '2.0';
45
49
  const DEFAULT_LOG_LANGUAGE = 'en';
46
- const IN_PROGRESS_GIT_PATH = 'driftseal-in-progress.jsonl';
50
+ const IN_PROGRESS_GIT_PATH = 'driftseal-v2-in-progress.jsonl';
47
51
  const LOCK_STALE_MS = 30 * 60 * 1000;
48
52
  const LOCK_INIT_STALE_MS = 5 * 1000;
49
53
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
@@ -52,7 +56,7 @@ const MAX_DECISION_SLUG_LENGTH = 180;
52
56
  const VERIFICATION_OUTPUT_CHUNK_BYTES = 64 * 1024;
53
57
  const CAPTURE_OUTPUT_EDGE_CHARACTERS = 32 * 1024;
54
58
  const CAPTURE_OUTPUT_OMISSION = '\n... [driftseal captured output truncated] ...\n';
55
- const LOCAL_INTENT_PROVENANCE_FILE = '.driftseal-local-intent.json';
59
+ const LOCAL_OUTCOME_PROVENANCE_FILE = '.driftseal-local-outcome.json';
56
60
 
57
61
  class DriftSealError extends Error {
58
62
  constructor(message) {
@@ -74,7 +78,9 @@ class HelpRequested extends DriftSealError {
74
78
  function usageFor(key) {
75
79
  const lines = {
76
80
  begin:
77
- 'usage: driftseal begin "<intent>" [--accept "<observable outcome>"] [--verify "<command>"] [--decision <id>] [--force]',
81
+ 'usage: driftseal begin "<outcome>" [--accept "<observable result>"] [--verify "<command>"] [--decision <id>] [--force]',
82
+ extend:
83
+ 'usage: driftseal extend "<same-outcome addition>" [--accept "<observable result>"] [--verify "<command>"] [--decision <id>]',
78
84
  verify: 'usage: driftseal verify [--allow-tracked-command]',
79
85
  end: 'usage: driftseal end [id] [options]',
80
86
  status: 'usage: driftseal status',
@@ -84,6 +90,8 @@ function usageFor(key) {
84
90
  unreclaim: 'usage: driftseal unreclaim <id> --reason "<why>"',
85
91
  absorb: absorbUsage(),
86
92
  init: 'usage: driftseal init [--lang <tag>] [--local-log]',
93
+ migrate:
94
+ 'usage: driftseal migrate v1-to-v2 inspect|apply|check [--source-log <file>] [--source-decisions <dir>] [--destination <dir>] [--plan <file>] [--json]',
87
95
  decision: 'usage: driftseal decision add|update|list|show (run: driftseal help)',
88
96
  'decision add':
89
97
  'usage: driftseal decision add "<title>" --context "..." --outcome "..." [options]',
@@ -174,8 +182,12 @@ if (process.env._DRIFTSEAL_TEST_UMASK) {
174
182
  process.umask(Number.parseInt(process.env._DRIFTSEAL_TEST_UMASK, 8));
175
183
  }
176
184
 
185
+ function sealRoot() {
186
+ return process.env.DRIFTSEAL_HOME || path.join(process.cwd(), '.seal');
187
+ }
188
+
177
189
  function logDir() {
178
- return process.env.DRIFTSEAL_HOME || path.join(process.cwd(), '.intent-log');
190
+ return path.join(sealRoot(), 'outcomes');
179
191
  }
180
192
 
181
193
  function logFile() {
@@ -183,7 +195,18 @@ function logFile() {
183
195
  }
184
196
 
185
197
  function decisionDir() {
186
- return process.env.DRIFTSEAL_DECISION_HOME || path.join(process.cwd(), '.decision-log');
198
+ return path.join(sealRoot(), 'madr');
199
+ }
200
+
201
+ // isolateStorage clears write-target env vars; detection still sees the inherited v1 homes.
202
+ let isolatedV1Detection = null;
203
+
204
+ function v1HomeEnv() {
205
+ return process.env.DRIFTSEAL_HOME || isolatedV1Detection?.home || null;
206
+ }
207
+
208
+ function v1DecisionHomeEnv() {
209
+ return process.env.DRIFTSEAL_DECISION_HOME || isolatedV1Detection?.decisions || null;
187
210
  }
188
211
 
189
212
  // A corrupt log line must not wedge reads; a non-string head degrades to null.
@@ -191,27 +214,84 @@ function normalizeHead(value) {
191
214
  return typeof value === 'string' ? value : null;
192
215
  }
193
216
 
217
+ function normalizeMadrManifest(value, line) {
218
+ if (value === undefined) return undefined;
219
+ if (!Array.isArray(value)) fail(`invalid migration MADR manifest on log line ${line}`);
220
+ const names = new Set();
221
+ return value.map((entry) => {
222
+ if (
223
+ !entry || typeof entry !== 'object' || Array.isArray(entry) ||
224
+ typeof entry.name !== 'string' || path.basename(entry.name) !== entry.name ||
225
+ !entry.name.endsWith('.md') || names.has(entry.name) ||
226
+ typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256) ||
227
+ !Number.isSafeInteger(entry.bytes) || entry.bytes < 0
228
+ ) {
229
+ fail(`invalid migration MADR manifest on log line ${line}`);
230
+ }
231
+ names.add(entry.name);
232
+ return { name: entry.name, sha256: entry.sha256, bytes: entry.bytes };
233
+ });
234
+ }
235
+
236
+ function normalizeMigrationSource(value, line) {
237
+ if (value === undefined) return undefined;
238
+ const normalizeLocation = (location) => {
239
+ if (typeof location === 'string' && location.length > 0 && !location.includes('\0')) {
240
+ return location;
241
+ }
242
+ if (
243
+ !location || typeof location !== 'object' || Array.isArray(location) ||
244
+ !['repository', 'absolute'].includes(location.base) ||
245
+ typeof location.path !== 'string' || location.path.length === 0 || location.path.includes('\0') ||
246
+ (location.base === 'repository' && path.isAbsolute(location.path))
247
+ ) {
248
+ fail(`invalid migration source identity on log line ${line}`);
249
+ }
250
+ if (location.base === 'repository' && !pathContains(process.cwd(), path.resolve(process.cwd(), location.path))) {
251
+ fail(`invalid migration source identity on log line ${line}`);
252
+ }
253
+ return { base: location.base, path: location.path };
254
+ };
255
+ if (
256
+ !value || typeof value !== 'object' || Array.isArray(value) ||
257
+ typeof value.logPresent !== 'boolean'
258
+ ) {
259
+ fail(`invalid migration source identity on log line ${line}`);
260
+ }
261
+ return {
262
+ log: normalizeLocation(value.log),
263
+ decisions: normalizeLocation(value.decisions),
264
+ logPresent: value.logPresent,
265
+ };
266
+ }
267
+
194
268
  function normalizeEvent(event, line) {
195
269
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
196
270
  fail(`invalid event object on log line ${line}`);
197
271
  }
272
+ const logVersion = event.logVersion === undefined ? 1 : event.logVersion;
273
+ if (!Number.isSafeInteger(logVersion) || logVersion < 1 || logVersion > LOG_VERSION) {
274
+ fail(`invalid or unsupported log version on log line ${line}`);
275
+ }
276
+ const supportedSchema = logVersion === LOG_VERSION ? EVENT_SCHEMA_VERSION : LEGACY_EVENT_SCHEMA_VERSION;
198
277
  if (
199
278
  event.schemaVersion !== undefined &&
200
279
  (!Number.isSafeInteger(event.schemaVersion) || event.schemaVersion < 1)
201
280
  ) {
202
281
  fail(`invalid event schema version on log line ${line}`);
203
282
  }
204
- if (event.schemaVersion > EVENT_SCHEMA_VERSION) {
283
+ if (event.schemaVersion > supportedSchema) {
205
284
  fail(
206
- `event schema ${event.schemaVersion} requires a newer DriftSeal client (supported: ${EVENT_SCHEMA_VERSION})`
285
+ `event schema ${event.schemaVersion} requires a newer DriftSeal client (supported: ${supportedSchema})`
207
286
  );
208
287
  }
209
288
  if (typeof event.type !== 'string' || typeof event.id !== 'string' || event.id.length === 0) {
210
- fail(`invalid event type or intent id on log line ${line}`);
289
+ fail(`invalid event type or outcome id on log line ${line}`);
211
290
  }
212
291
 
213
292
  if (event.type === 'begin') {
214
- if (typeof event.intent !== 'string' || event.intent.trim().length === 0) {
293
+ const outcome = logVersion === LOG_VERSION ? event.outcome : event.intent;
294
+ if (typeof outcome !== 'string' || outcome.trim().length === 0) {
215
295
  fail(`invalid begin event on log line ${line}`);
216
296
  }
217
297
  if (!Array.isArray(event.decisions) && event.decisions !== undefined) {
@@ -231,7 +311,35 @@ function normalizeEvent(event, line) {
231
311
  if (new Set(decisions).size !== decisions.length) {
232
312
  fail(`duplicate linked decision on log line ${line}`);
233
313
  }
234
- return { ...event, acceptance, decisions, head: normalizeHead(event.head) };
314
+ return { ...event, logVersion, outcome, acceptance, decisions, head: normalizeHead(event.head) };
315
+ }
316
+
317
+ if (event.type === 'extend') {
318
+ if (logVersion !== LOG_VERSION || typeof event.extension !== 'string' || event.extension.trim().length === 0) {
319
+ fail(`invalid extend event on log line ${line}`);
320
+ }
321
+ if (!Array.isArray(event.decisions) && event.decisions !== undefined) {
322
+ fail(`invalid extend decisions list on log line ${line}`);
323
+ }
324
+ if (!Array.isArray(event.acceptance) && event.acceptance !== undefined) {
325
+ fail(`invalid extend acceptance list on log line ${line}`);
326
+ }
327
+ const acceptance = event.acceptance || [];
328
+ if (acceptance.some((criterion) => typeof criterion !== 'string' || criterion.trim().length === 0)) {
329
+ fail(`invalid extend acceptance criterion on log line ${line}`);
330
+ }
331
+ if (acceptance.length > 0 && (typeof event.verify !== 'string' || event.verify.trim().length === 0)) {
332
+ fail(`acceptance-extending event has no replacement verification command on log line ${line}`);
333
+ }
334
+ if (event.verify !== null && event.verify !== undefined &&
335
+ (typeof event.verify !== 'string' || event.verify.trim().length === 0)) {
336
+ fail(`invalid extend verification command on log line ${line}`);
337
+ }
338
+ const decisions = (event.decisions || []).map(normalizeDecisionId);
339
+ if (new Set(decisions).size !== decisions.length) {
340
+ fail(`duplicate linked decision on log line ${line}`);
341
+ }
342
+ return { ...event, logVersion, acceptance, decisions, verify: event.verify || null, head: normalizeHead(event.head) };
235
343
  }
236
344
 
237
345
  if (event.type === 'verify') {
@@ -253,11 +361,13 @@ function normalizeEvent(event, line) {
253
361
  !/^[a-f0-9]{64}$/.test(event.outputHash) ||
254
362
  (event.workspace !== null &&
255
363
  (typeof event.workspace !== 'string' || !/^[a-f0-9]{64}$/.test(event.workspace))) ||
364
+ (logVersion === LOG_VERSION &&
365
+ (typeof event.contractHash !== 'string' || !/^[a-f0-9]{64}$/.test(event.contractHash))) ||
256
366
  event.passed !== (event.exitCode === 0 && event.signal === null)
257
367
  ) {
258
368
  fail(`invalid verification event on log line ${line}`);
259
369
  }
260
- return { ...event, head: normalizeHead(event.head) };
370
+ return { ...event, logVersion, head: normalizeHead(event.head) };
261
371
  }
262
372
 
263
373
  if (event.type === 'end') {
@@ -276,14 +386,57 @@ function normalizeEvent(event, line) {
276
386
  ) {
277
387
  fail(`invalid end workspace on log line ${line}`);
278
388
  }
279
- return { ...event, head: normalizeHead(event.head) };
389
+ if (logVersion === LOG_VERSION && event.status === 'completed' &&
390
+ (typeof event.contractHash !== 'string' || !/^[a-f0-9]{64}$/.test(event.contractHash))) {
391
+ fail(`completed outcome has no contract hash on log line ${line}`);
392
+ }
393
+ return { ...event, logVersion, head: normalizeHead(event.head) };
394
+ }
395
+
396
+ if (event.type === 'import') {
397
+ if (
398
+ logVersion !== LOG_VERSION ||
399
+ typeof event.outcome !== 'string' || event.outcome.trim().length === 0 ||
400
+ !END_STATUSES.includes(event.status) ||
401
+ !Array.isArray(event.sources) || event.sources.length === 0 ||
402
+ !Array.isArray(event.decisions) ||
403
+ typeof event.sourceFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(event.sourceFingerprint)
404
+ ) {
405
+ fail(`invalid imported outcome on log line ${line}`);
406
+ }
407
+ const decisions = event.decisions.map(normalizeDecisionId);
408
+ if (new Set(decisions).size !== decisions.length) {
409
+ fail(`duplicate linked decision on log line ${line}`);
410
+ }
411
+ if (event.sources.some((source) => !source || typeof source !== 'object' ||
412
+ typeof source.id !== 'string' || source.id.length === 0)) {
413
+ fail(`invalid imported outcome source on log line ${line}`);
414
+ }
415
+ return { ...event, logVersion, decisions, head: normalizeHead(event.head) };
416
+ }
417
+
418
+ if (event.type === 'migration') {
419
+ if (
420
+ logVersion !== LOG_VERSION ||
421
+ typeof event.sourceFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(event.sourceFingerprint) ||
422
+ typeof event.planDigest !== 'string' || !/^[a-f0-9]{64}$/.test(event.planDigest) ||
423
+ !Array.isArray(event.excluded)
424
+ ) {
425
+ fail(`invalid migration event on log line ${line}`);
426
+ }
427
+ return {
428
+ ...event,
429
+ logVersion,
430
+ madrManifest: normalizeMadrManifest(event.madrManifest, line),
431
+ source: normalizeMigrationSource(event.source, line),
432
+ };
280
433
  }
281
434
 
282
435
  if (event.type === 'reclaim' || event.type === 'unreclaim') {
283
436
  if (typeof event.reason !== 'string' || event.reason.trim().length === 0) {
284
437
  fail(`invalid ${event.type} event on log line ${line}`);
285
438
  }
286
- return event;
439
+ return { ...event, logVersion };
287
440
  }
288
441
 
289
442
  if (
@@ -294,7 +447,9 @@ function normalizeEvent(event, line) {
294
447
  event.type === 'decision_reconcile_cancel'
295
448
  ) {
296
449
  const schemaVersion = event.schemaVersion || 1;
297
- if (event.type === 'decision_reconcile' && schemaVersion >= 2) {
450
+ const outcomeStatus = logVersion === LOG_VERSION ? event.outcomeStatus : event.intentStatus;
451
+ if (event.type === 'decision_reconcile' &&
452
+ (logVersion === LOG_VERSION || schemaVersion >= 2)) {
298
453
  fail(`legacy decision reconciliation is not valid in schema ${schemaVersion} on log line ${line}`);
299
454
  }
300
455
  if (
@@ -330,11 +485,11 @@ function normalizeEvent(event, line) {
330
485
  }
331
486
  if (
332
487
  event.type === 'decision_reconcile_cancel' &&
333
- !['failed', 'abandoned'].includes(event.intentStatus)
488
+ !['failed', 'abandoned'].includes(outcomeStatus)
334
489
  ) {
335
490
  fail(`invalid reconciliation cancellation on log line ${line}`);
336
491
  }
337
- return { ...event, decisionId };
492
+ return { ...event, logVersion, decisionId, ...(event.type === 'decision_reconcile_cancel' ? { outcomeStatus } : {}) };
338
493
  }
339
494
 
340
495
  fail(`unknown event type "${event.type}" on log line ${line}`);
@@ -352,22 +507,22 @@ function worktreeInProgressFile(cwd = process.cwd()) {
352
507
  return path.resolve(cwd, gitPath);
353
508
  }
354
509
 
355
- function isParkableIntentLog() {
510
+ function isParkableOutcomeLog() {
356
511
  if (process.env.DRIFTSEAL_HOME) return false;
357
512
  const root = gitWorktreeRoot();
358
513
  if (!root) return false;
359
- return path.resolve(logFile()) === path.resolve(root, '.intent-log', 'events.jsonl');
514
+ return path.resolve(logFile()) === path.resolve(root, '.seal', 'outcomes', 'events.jsonl');
360
515
  }
361
516
 
362
517
  function inProgressFile() {
363
- if (!isParkableIntentLog()) return null;
518
+ if (!isParkableOutcomeLog()) return null;
364
519
  return worktreeInProgressFile();
365
520
  }
366
521
 
367
- function liveWorktreeIntentLog() {
522
+ function liveWorktreeOutcomeLog() {
368
523
  const root = gitWorktreeRoot();
369
524
  if (!root) return null;
370
- return path.resolve(root, '.intent-log', 'events.jsonl');
525
+ return path.resolve(root, '.seal', 'outcomes', 'events.jsonl');
371
526
  }
372
527
 
373
528
  function sameResolvedPath(left, right) {
@@ -376,7 +531,7 @@ function sameResolvedPath(left, right) {
376
531
 
377
532
  function shouldAttachInProgress(file) {
378
533
  if (process.env.DRIFTSEAL_HOME) return false;
379
- const live = liveWorktreeIntentLog();
534
+ const live = liveWorktreeOutcomeLog();
380
535
  return live !== null && sameResolvedPath(file, live);
381
536
  }
382
537
 
@@ -499,13 +654,17 @@ function readEvents({ repairTail = false, readOnly = false, file = logFile() } =
499
654
  }
500
655
  }
501
656
 
502
- function parseJsonlRecords(content, source = 'log') {
657
+ function parseJsonlRecords(content, source = 'log', { allowLegacy = false } = {}) {
503
658
  return content
504
659
  .split('\n')
505
660
  .filter((line) => line.trim().length > 0)
506
661
  .map((line, i) => {
507
662
  try {
508
- return { raw: line, event: normalizeEvent(JSON.parse(line), i + 1) };
663
+ const event = normalizeEvent(JSON.parse(line), i + 1);
664
+ if (!allowLegacy && event.logVersion !== LOG_VERSION) {
665
+ fail(`v1 intent log cannot be used as a v2 outcome log; run driftseal migrate v1-to-v2 inspect`);
666
+ }
667
+ return { raw: line, event };
509
668
  } catch (err) {
510
669
  if (err instanceof DriftSealError) throw err;
511
670
  fail(`corrupt log line ${i + 1} in ${source}`);
@@ -539,10 +698,18 @@ function ensureDirectoryDurable(directory) {
539
698
  for (const created of missing.reverse()) fsyncDirectory(path.dirname(created));
540
699
  }
541
700
 
701
+ function ensureV2OutcomeLogExists() {
702
+ ensureDirectoryDurable(logDir());
703
+ if (fs.existsSync(logFile())) return;
704
+ if (isParkableOutcomeLog()) return;
705
+ fs.writeFileSync(logFile(), '');
706
+ fsyncDirectory(logDir());
707
+ }
708
+
542
709
  function appendEventTo(file, event) {
543
710
  ensureDirectoryDurable(path.dirname(file));
544
711
  const existed = fs.existsSync(file);
545
- const storedEvent = { schemaVersion: EVENT_SCHEMA_VERSION, ...event };
712
+ const storedEvent = { logVersion: LOG_VERSION, schemaVersion: EVENT_SCHEMA_VERSION, ...event };
546
713
  const line = Buffer.from(`${JSON.stringify(storedEvent)}\n`, 'utf8');
547
714
  const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
548
715
  try {
@@ -571,7 +738,7 @@ function appendEventTo(file, event) {
571
738
  * Move the parked records into the tracked log. Safe to retry: a remap is persisted to the
572
739
  * park first, the log is written before the park file is dropped, so an interruption either
573
740
  * leaves nothing committed or leaves the overlay recognizable as already committed.
574
- * Returns the intent ids it had to remap.
741
+ * Returns the outcome ids it had to remap.
575
742
  */
576
743
  function flushInProgressLog() {
577
744
  const park = inProgressFile();
@@ -593,26 +760,26 @@ function flushInProgressLog() {
593
760
  writeJsonl(logFile(), [...committedRecords, ...plan.records]);
594
761
  discardInProgressLog(park);
595
762
  return new Map(
596
- plan.mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => [mapping.from, mapping.to])
763
+ plan.mappings.filter((mapping) => mapping.kind === 'outcome').map((mapping) => [mapping.from, mapping.to])
597
764
  );
598
765
  }
599
766
 
600
- function parkedOpenIntent(park) {
767
+ function parkedOpenOutcome(park) {
601
768
  if (!fs.existsSync(park)) return null;
602
769
  const records = readJsonlRecordsFromFile(park, { repairTail: true });
603
- return openIntent(fold(records.map((record) => record.event)));
770
+ return openOutcome(fold(records.map((record) => record.event)));
604
771
  }
605
772
 
606
773
  function appendEvent(event) {
607
774
  const park = inProgressFile();
608
775
  if (!park) {
609
776
  const stored = appendEventTo(logFile(), event);
610
- if (event.type === 'begin') writeLocalIntentProvenance(stored);
611
- if (event.type === 'end') clearLocalIntentProvenance(event.id);
777
+ if (event.type === 'begin') writeLocalOutcomeProvenance(stored);
778
+ if (event.type === 'end') clearLocalOutcomeProvenance(event.id);
612
779
  return stored;
613
780
  }
614
781
 
615
- const open = parkedOpenIntent(park);
782
+ const open = parkedOpenOutcome(park);
616
783
  // A park with nothing open left in it belongs in the log; an interrupted end retries here.
617
784
  if (!open) flushInProgressLog();
618
785
 
@@ -632,15 +799,15 @@ function contentHash(content) {
632
799
  return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
633
800
  }
634
801
 
635
- function localIntentProvenanceFile() {
802
+ function localOutcomeProvenanceFile() {
636
803
  const root = gitWorktreeRoot();
637
804
  const key = contentHash(path.resolve(logFile())).slice(0, 16);
638
- if (!root) return path.join(logDir(), LOCAL_INTENT_PROVENANCE_FILE);
639
- const gitPath = gitCapture(['rev-parse', '--git-path', `driftseal-local-intent-${key}.json`]);
805
+ if (!root) return path.join(logDir(), LOCAL_OUTCOME_PROVENANCE_FILE);
806
+ const gitPath = gitCapture(['rev-parse', '--git-path', `driftseal-local-outcome-${key}.json`]);
640
807
  return gitPath ? path.resolve(process.cwd(), gitPath) : null;
641
808
  }
642
809
 
643
- function localIntentLogIdentity() {
810
+ function localOutcomeLogIdentity() {
644
811
  try {
645
812
  const stat = fs.statSync(logFile(), { bigint: true });
646
813
  return contentHash(JSON.stringify([String(stat.dev), String(stat.ino), String(stat.birthtimeNs)]));
@@ -649,12 +816,12 @@ function localIntentLogIdentity() {
649
816
  }
650
817
  }
651
818
 
652
- function localIntentProvenanceFingerprint({ id, ts, verify }) {
819
+ function localOutcomeProvenanceFingerprint({ id, ts, verify }) {
653
820
  return contentHash(JSON.stringify([id, ts, verify || null]));
654
821
  }
655
822
 
656
- function writeLocalIntentProvenance(event) {
657
- const file = localIntentProvenanceFile();
823
+ function writeLocalOutcomeProvenance(event) {
824
+ const file = localOutcomeProvenanceFile();
658
825
  if (!file) return;
659
826
  ensureDirectoryDurable(path.dirname(file));
660
827
  atomicWriteFile(
@@ -662,15 +829,15 @@ function writeLocalIntentProvenance(event) {
662
829
  JSON.stringify({
663
830
  version: 1,
664
831
  id: event.id,
665
- fingerprint: localIntentProvenanceFingerprint(event),
666
- logIdentity: localIntentLogIdentity(),
832
+ fingerprint: localOutcomeProvenanceFingerprint(event),
833
+ logIdentity: localOutcomeLogIdentity(),
667
834
  }) + '\n',
668
835
  0o600
669
836
  );
670
837
  }
671
838
 
672
- function readLocalIntentProvenance() {
673
- const file = localIntentProvenanceFile();
839
+ function readLocalOutcomeProvenance() {
840
+ const file = localOutcomeProvenanceFile();
674
841
  if (!file || !fs.existsSync(file)) return null;
675
842
  try {
676
843
  const provenance = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -688,24 +855,24 @@ function readLocalIntentProvenance() {
688
855
  }
689
856
  }
690
857
 
691
- function hasMatchingLocalIntentProvenance(intent) {
692
- const provenance = readLocalIntentProvenance();
858
+ function hasMatchingLocalOutcomeProvenance(outcome) {
859
+ const provenance = readLocalOutcomeProvenance();
693
860
  return (
694
861
  provenance !== null &&
695
- provenance.id === intent.id &&
696
- provenance.logIdentity === localIntentLogIdentity() &&
862
+ provenance.id === outcome.id &&
863
+ provenance.logIdentity === localOutcomeLogIdentity() &&
697
864
  provenance.fingerprint ===
698
- localIntentProvenanceFingerprint({
699
- id: intent.id,
700
- ts: intent.tsBegin,
701
- verify: intent.verify,
865
+ localOutcomeProvenanceFingerprint({
866
+ id: outcome.id,
867
+ ts: outcome.tsBegin,
868
+ verify: outcome.verify,
702
869
  })
703
870
  );
704
871
  }
705
872
 
706
- function clearLocalIntentProvenance(id) {
707
- const file = localIntentProvenanceFile();
708
- const provenance = readLocalIntentProvenance();
873
+ function clearLocalOutcomeProvenance(id) {
874
+ const file = localOutcomeProvenanceFile();
875
+ const provenance = readLocalOutcomeProvenance();
709
876
  if (!file || !provenance || provenance.id !== id) return;
710
877
  fs.unlinkSync(file);
711
878
  fsyncDirectory(path.dirname(file));
@@ -988,105 +1155,164 @@ function withMutationLocks(resources, action, { tryWaitMs } = {}) {
988
1155
  }
989
1156
  }
990
1157
 
991
- /** Fold the event stream into one record per intent. */
1158
+ 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
+ }));
1171
+ }
1172
+
1173
+ 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;
1203
+ }
1204
+
1205
+ /** Fold the event stream into one record per outcome. Legacy v1 events are accepted for migration. */
992
1206
  function fold(events) {
993
1207
  const records = new Map();
994
1208
  const reconciliations = new Map();
995
1209
  const order = [];
996
1210
  for (const ev of events) {
997
1211
  if (ev.type === 'begin') {
998
- if (records.has(ev.id)) fail(`duplicate begin event for intent id: ${ev.id}`);
999
- records.set(ev.id, {
1000
- id: ev.id,
1001
- tsBegin: ev.ts,
1002
- intent: ev.intent,
1003
- acceptance: Array.isArray(ev.acceptance) ? ev.acceptance : [],
1004
- verify: ev.verify || null,
1005
- beginHead: ev.head || null,
1006
- decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
1007
- schemaVersion: ev.schemaVersion || 1,
1008
- decisionPrepares: [],
1009
- decisionTerminals: [],
1010
- decisionUpdates: [],
1011
- verificationAttempts: [],
1012
- verification: null,
1013
- status: 'in_progress',
1014
- tsEnd: null,
1015
- note: null,
1016
- verifyResult: null,
1017
- endHead: null,
1018
- reclaimed: false,
1019
- reclaimReason: null,
1020
- reclaimedAt: null,
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,
1021
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);
1022
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 = [];
1023
1258
  } else if (ev.type === 'verify') {
1024
1259
  const rec = records.get(ev.id);
1025
- if (!rec) fail(`verification event references unknown intent id: ${ev.id}`);
1026
- if (rec.status !== 'in_progress') {
1027
- fail(`verification occurred after intent ${ev.id} was closed`);
1028
- }
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`);
1029
1262
  if (rec.acceptance.length === 0 || !rec.verify) {
1030
- fail(`verification event references intent ${ev.id} without acceptance criteria`);
1263
+ fail(`verification event references outcome ${ev.id} without acceptance criteria`);
1031
1264
  }
1032
- if (ev.command !== rec.verify) {
1033
- fail(`verification command does not match intent ${ev.id}`);
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}`);
1034
1268
  }
1035
1269
  rec.verificationAttempts.push(ev);
1036
1270
  rec.verification = ev;
1037
1271
  } else if (ev.type === 'reclaim' || ev.type === 'unreclaim') {
1038
1272
  const rec = records.get(ev.id);
1039
- if (!rec) fail(`${ev.type} event references unknown intent id: ${ev.id}`);
1273
+ if (!rec) fail(`${ev.type} event references unknown outcome id: ${ev.id}`);
1040
1274
  if (ev.type === 'reclaim') {
1041
- if (rec.status === 'in_progress') {
1042
- fail(`cannot reclaim intent ${ev.id} while it is in_progress`);
1043
- }
1044
- if (rec.reclaimed) fail(`duplicate reclaim event for intent id: ${ev.id}`);
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}`);
1045
1277
  rec.reclaimed = true;
1046
1278
  rec.reclaimReason = ev.reason;
1047
1279
  rec.reclaimedAt = ev.ts;
1048
1280
  } else {
1049
- if (!rec.reclaimed) fail(`unreclaim event for intent id that is not reclaimed: ${ev.id}`);
1281
+ if (!rec.reclaimed) fail(`unreclaim event for outcome id that is not reclaimed: ${ev.id}`);
1050
1282
  rec.reclaimed = false;
1051
1283
  rec.reclaimReason = null;
1052
1284
  rec.reclaimedAt = null;
1053
1285
  }
1054
1286
  } else if (ev.type === 'end') {
1055
1287
  const rec = records.get(ev.id);
1056
- if (!rec) fail(`end event references unknown intent id: ${ev.id}`);
1057
- if (rec.status !== 'in_progress') {
1058
- fail(`duplicate end event for intent id: ${ev.id}`);
1059
- }
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}`);
1060
1290
  const conflictingCancellation = rec.decisionTerminals.find(
1061
- (terminal) =>
1062
- terminal.type === 'decision_reconcile_cancel' &&
1063
- terminal.intentStatus !== ev.status
1291
+ (terminal) => terminal.type === 'decision_reconcile_cancel' && terminal.outcomeStatus !== ev.status
1064
1292
  );
1065
1293
  if (conflictingCancellation) {
1066
- fail(
1067
- `intent ${ev.id} was closed as ${ev.status} after reconciliation recovery was cancelled for ${conflictingCancellation.intentStatus}`
1068
- );
1294
+ fail(`outcome ${ev.id} was closed as ${ev.status} after reconciliation recovery was cancelled for ${conflictingCancellation.outcomeStatus}`);
1069
1295
  }
1070
1296
  if (
1071
1297
  ['completed', 'partial'].includes(ev.status) &&
1072
1298
  rec.decisions.length > 0 &&
1073
- ((rec.schemaVersion >= 2 && (ev.schemaVersion || 1) < 2) ||
1074
- rec.decisions.some(
1075
- (decisionId) => qualifyingDecisionUpdates(rec, decisionId).length === 0
1076
- ))
1299
+ ((rec.logVersion === 1 && rec.schemaVersion >= 2 && (ev.schemaVersion || 1) < 2) ||
1300
+ rec.decisions.some((decisionId) => qualifyingDecisionUpdates(rec, decisionId).length === 0))
1077
1301
  ) {
1078
- fail(`linked intent ${ev.id} was closed without reconciling every declared decision`);
1302
+ fail(`linked outcome ${ev.id} was closed without reconciling every declared decision`);
1079
1303
  }
1080
1304
  if (ev.status === 'completed' && rec.acceptance.length > 0) {
1081
1305
  if (!rec.verification || !rec.verification.passed) {
1082
- fail(`acceptance-bound intent ${ev.id} was completed without successful machine verification`);
1306
+ fail(`acceptance-bound outcome ${ev.id} was completed without successful machine verification`);
1083
1307
  }
1084
1308
  if (
1085
- (ev.schemaVersion || 1) < 4 ||
1309
+ (rec.logVersion === 1 && (ev.schemaVersion || 1) < 4) ||
1086
1310
  ev.verificationId !== rec.verification.verificationId ||
1087
- (ev.workspace ?? null) !== rec.verification.workspace
1311
+ (ev.workspace ?? null) !== rec.verification.workspace ||
1312
+ (rec.logVersion === LOG_VERSION &&
1313
+ (ev.contractHash !== rec.contractHash || rec.verification.contractHash !== rec.contractHash))
1088
1314
  ) {
1089
- fail(`acceptance-bound intent ${ev.id} was completed with stale machine verification`);
1315
+ fail(`acceptance-bound outcome ${ev.id} was completed with stale machine verification`);
1090
1316
  }
1091
1317
  }
1092
1318
  rec.status = ev.status;
@@ -1096,26 +1322,22 @@ function fold(events) {
1096
1322
  rec.endHead = ev.head || null;
1097
1323
  } else if (ev.type === 'decision_reconcile_prepare') {
1098
1324
  const rec = records.get(ev.id);
1099
- if (!rec) fail(`decision reconciliation references unknown intent id: ${ev.id}`);
1100
- if (rec.status !== 'in_progress') {
1101
- fail(`decision reconciliation occurred after intent ${ev.id} was closed`);
1102
- }
1103
- if (!rec.decisions.includes(ev.decisionId)) {
1104
- fail(`decision reconciliation references unlinked decision ${ev.decisionId}`);
1105
- }
1106
- if (reconciliations.has(ev.reconciliationId)) {
1107
- fail(`duplicate reconciliation id: ${ev.reconciliationId}`);
1108
- }
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}`);
1109
1329
  rec.decisionPrepares.push(ev);
1110
- reconciliations.set(ev.reconciliationId, { prepare: ev, terminal: null });
1330
+ reconciliations.set(ev.reconciliationId, {
1331
+ prepare: ev,
1332
+ terminal: null,
1333
+ contractHash: rec.contractHash,
1334
+ });
1111
1335
  } else if (ev.type === 'decision_reconcile') {
1112
1336
  const rec = records.get(ev.id);
1113
- if (!rec) fail(`decision reconciliation references unknown intent id: ${ev.id}`);
1114
- if (rec.status !== 'in_progress') {
1115
- fail(`decision reconciliation occurred after intent ${ev.id} was closed`);
1116
- }
1117
- if (rec.schemaVersion >= 2) {
1118
- fail(`linked schema-v2 intent ${rec.id} contains a legacy decision reconciliation`);
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`);
1119
1341
  }
1120
1342
  rec.decisionUpdates.push(ev);
1121
1343
  } else if (
@@ -1125,29 +1347,14 @@ function fold(events) {
1125
1347
  ) {
1126
1348
  const rec = records.get(ev.id);
1127
1349
  const reconciliation = reconciliations.get(ev.reconciliationId);
1128
- if (rec && rec.status !== 'in_progress') {
1129
- fail(`decision reconciliation occurred after intent ${ev.id} was closed`);
1130
- }
1131
- if (
1132
- !rec ||
1133
- !reconciliation ||
1134
- reconciliation.prepare.id !== ev.id ||
1135
- reconciliation.prepare.decisionId !== ev.decisionId
1136
- ) {
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) {
1137
1352
  fail(`decision reconciliation terminal has no matching prepare: ${ev.reconciliationId}`);
1138
1353
  }
1139
- if (reconciliation.terminal) {
1140
- fail(`decision reconciliation already has a terminal event: ${ev.reconciliationId}`);
1141
- }
1142
- const priorCancellation = rec.decisionTerminals.find(
1143
- (terminal) => terminal.type === 'decision_reconcile_cancel'
1144
- );
1145
- if (
1146
- ev.type === 'decision_reconcile_cancel' &&
1147
- priorCancellation &&
1148
- priorCancellation.intentStatus !== ev.intentStatus
1149
- ) {
1150
- fail(`intent ${ev.id} has conflicting reconciliation cancellation statuses`);
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`);
1151
1358
  }
1152
1359
  if (
1153
1360
  ev.type === 'decision_reconcile_commit' &&
@@ -1159,7 +1366,12 @@ function fold(events) {
1159
1366
  }
1160
1367
  reconciliation.terminal = ev;
1161
1368
  rec.decisionTerminals.push(ev);
1162
- if (ev.type === 'decision_reconcile_commit') rec.decisionUpdates.push(ev);
1369
+ if (
1370
+ ev.type === 'decision_reconcile_commit' &&
1371
+ reconciliation.contractHash === rec.contractHash
1372
+ ) {
1373
+ rec.decisionUpdates.push(ev);
1374
+ }
1163
1375
  }
1164
1376
  }
1165
1377
  return order.map((id) => records.get(id));
@@ -1168,24 +1380,24 @@ function fold(events) {
1168
1380
  function qualifyingDecisionUpdates(record, decisionId) {
1169
1381
  return record.decisionUpdates.filter((update) => {
1170
1382
  if (update.decisionId !== decisionId) return false;
1171
- if (record.schemaVersion < 2) return true;
1383
+ if (record.logVersion === 1 && record.schemaVersion < 2) return true;
1172
1384
  return (
1173
1385
  update.type === 'decision_reconcile_commit' &&
1174
- (update.schemaVersion || 1) >= 2 &&
1386
+ (update.logVersion === LOG_VERSION || (update.schemaVersion || 1) >= 2) &&
1175
1387
  typeof update.fileHash === 'string'
1176
1388
  );
1177
1389
  });
1178
1390
  }
1179
1391
 
1180
- function openIntent(records) {
1392
+ function openOutcome(records) {
1181
1393
  const open = records.filter((record) => record.status === 'in_progress');
1182
- if (open.length > 1) fail(`multiple intents in progress: ${open.map((record) => record.id).join(', ')}`);
1394
+ if (open.length > 1) fail(`multiple outcomes in progress: ${open.map((record) => record.id).join(', ')}`);
1183
1395
  return open[0] || null;
1184
1396
  }
1185
1397
 
1186
- function parseIntentId(id) {
1398
+ function parseOutcomeId(id) {
1187
1399
  const match = String(id).match(/^(\d{4}-\d{2}-\d{2})-(\d+)$/);
1188
- if (!match) fail(`invalid intent id: ${id}`);
1400
+ if (!match) fail(`invalid outcome id: ${id}`);
1189
1401
  return { date: match[1], seq: Number.parseInt(match[2], 10) };
1190
1402
  }
1191
1403
 
@@ -1193,7 +1405,7 @@ function nextIdForDate(date, events) {
1193
1405
  let maxSeq = 0;
1194
1406
  const prefix = `${date}-`;
1195
1407
  for (const ev of events) {
1196
- if (ev.type === 'begin' && typeof ev.id === 'string' && ev.id.startsWith(prefix)) {
1408
+ if ((ev.type === 'begin' || ev.type === 'import') && typeof ev.id === 'string' && ev.id.startsWith(prefix)) {
1197
1409
  const seq = Number.parseInt(ev.id.slice(prefix.length), 10);
1198
1410
  if (Number.isFinite(seq) && seq > maxSeq) maxSeq = seq;
1199
1411
  }
@@ -1338,7 +1550,7 @@ function renderDecision({ id, title, date, status, context, outcome, drivers, op
1338
1550
  ].join('\n\n') + '\n';
1339
1551
  }
1340
1552
 
1341
- function prepareDecisionReconciliation(decision, intentId, status, note) {
1553
+ function prepareDecisionReconciliation(decision, outcomeId, status, note) {
1342
1554
  const target = path.join(decisionDir(), decision.file);
1343
1555
  const fromStatus = decision.status;
1344
1556
  const reconciliationId = crypto.randomUUID();
@@ -1351,12 +1563,12 @@ function prepareDecisionReconciliation(decision, intentId, status, note) {
1351
1563
  const normalizedNote = note.trim().replace(/\r\n|\r|\n/g, eol);
1352
1564
  const hasDriftSealHistory = /^<!-- [a-z][a-z0-9-]*-reconciliation: [^>\r\n]+ -->\r?$/m.test(updated);
1353
1565
  const historyHeading = hasDriftSealHistory ? '' : `## Decision History${eol}${eol}`;
1354
- const history = `${historyHeading}<!-- driftseal-reconciliation: ${reconciliationId} -->${eol}### ${ts} — Intent \`${intentId}\`${eol}${eol}Status: ${titleCase(fromStatus)} → ${titleCase(status)}${eol}${eol}${normalizedNote}${eol}`;
1566
+ const history = `${historyHeading}<!-- driftseal-reconciliation: ${reconciliationId} -->${eol}### ${ts} — Outcome \`${outcomeId}\`${eol}${eol}Status: ${titleCase(fromStatus)} → ${titleCase(status)}${eol}${eol}${normalizedNote}${eol}`;
1355
1567
  const separator = updated.endsWith(eol + eol) ? '' : updated.endsWith(eol) ? eol : eol + eol;
1356
1568
  const nextContent = updated + separator + history;
1357
1569
  return {
1358
1570
  type: 'decision_reconcile_prepare',
1359
- id: intentId,
1571
+ id: outcomeId,
1360
1572
  decisionId: decision.id,
1361
1573
  reconciliationId,
1362
1574
  ts,
@@ -1384,11 +1596,11 @@ function reconciliationEvent(type, prepare) {
1384
1596
  };
1385
1597
  }
1386
1598
 
1387
- function pendingReconciliations(events, intentId) {
1599
+ function pendingReconciliations(events, outcomeId) {
1388
1600
  const prepares = new Map();
1389
1601
  const finished = new Set();
1390
1602
  for (const event of events) {
1391
- if (event.id !== intentId) continue;
1603
+ if (event.id !== outcomeId) continue;
1392
1604
  if (event.type === 'decision_reconcile_prepare') {
1393
1605
  prepares.set(event.reconciliationId, event);
1394
1606
  } else if (
@@ -1405,8 +1617,8 @@ function pendingReconciliations(events, intentId) {
1405
1617
  );
1406
1618
  }
1407
1619
 
1408
- function recoverPendingReconciliations(events, intentId) {
1409
- const pending = pendingReconciliations(events, intentId);
1620
+ function recoverPendingReconciliations(events, outcomeId) {
1621
+ const pending = pendingReconciliations(events, outcomeId);
1410
1622
  if (pending.length === 0) return events;
1411
1623
  const index = decisionIndex();
1412
1624
  for (const prepare of pending) {
@@ -1434,16 +1646,16 @@ function recoverPendingReconciliations(events, intentId) {
1434
1646
  return events;
1435
1647
  }
1436
1648
 
1437
- function cancelPendingReconciliations(events, intentId, intentStatus) {
1438
- for (const prepare of pendingReconciliations(events, intentId)) {
1649
+ function cancelPendingReconciliations(events, outcomeId, outcomeStatus) {
1650
+ for (const prepare of pendingReconciliations(events, outcomeId)) {
1439
1651
  const cancellation = {
1440
1652
  type: 'decision_reconcile_cancel',
1441
1653
  id: prepare.id,
1442
1654
  decisionId: prepare.decisionId,
1443
1655
  reconciliationId: prepare.reconciliationId,
1444
1656
  ts: new Date().toISOString(),
1445
- intentStatus,
1446
- note: `automatic recovery cancelled because intent closed as ${intentStatus}`,
1657
+ outcomeStatus,
1658
+ note: `automatic recovery cancelled because outcome closed as ${outcomeStatus}`,
1447
1659
  };
1448
1660
  events.push(appendEvent(cancellation));
1449
1661
  }
@@ -1454,7 +1666,7 @@ function escapeCancellationStatus(record) {
1454
1666
  const cancellation = record.decisionTerminals.find(
1455
1667
  (terminal) => terminal.type === 'decision_reconcile_cancel'
1456
1668
  );
1457
- return cancellation ? cancellation.intentStatus : null;
1669
+ return cancellation ? cancellation.outcomeStatus : null;
1458
1670
  }
1459
1671
 
1460
1672
  function closeIntentAsEscape(events, record, requestedStatus, note, verifyResult) {
@@ -1546,7 +1758,8 @@ function parseArgs(argv, spec, usageKey) {
1546
1758
 
1547
1759
  function render(rec) {
1548
1760
  const lines = [`[${rec.id}] ${rec.status}`];
1549
- lines.push(` intent: ${rec.intent}`);
1761
+ lines.push(` outcome: ${rec.outcome}`);
1762
+ for (const extension of rec.extensions) lines.push(` extend: ${extension.extension}`);
1550
1763
  for (const criterion of rec.acceptance) lines.push(` accept: ${criterion}`);
1551
1764
  if (rec.decisions.length > 0) lines.push(` decisions: ${rec.decisions.join(', ')}`);
1552
1765
  if (rec.verify) lines.push(` verify: ${rec.verify}`);
@@ -1567,6 +1780,7 @@ function render(rec) {
1567
1780
  }
1568
1781
  lines.push(` began: ${rec.tsBegin}` + (rec.tsEnd ? ` ended: ${rec.tsEnd}` : ''));
1569
1782
  if (rec.reclaimed) lines.push(` reclaimed: ${rec.reclaimReason}`);
1783
+ if (rec.imported) lines.push(` imported-from: ${rec.imported.sourceIds.join(', ')}`);
1570
1784
  return lines.join('\n');
1571
1785
  }
1572
1786
 
@@ -1582,18 +1796,21 @@ function publicVerification(verification) {
1582
1796
  stdoutBytes: verification.stdoutBytes,
1583
1797
  stderrBytes: verification.stderrBytes,
1584
1798
  workspace: verification.workspace,
1799
+ contractHash: verification.contractHash || null,
1585
1800
  head: verification.head,
1586
1801
  ranAt: verification.ts,
1587
1802
  };
1588
1803
  }
1589
1804
 
1590
- function publicIntent(rec) {
1805
+ function publicOutcome(rec) {
1591
1806
  if (!rec) return null;
1592
1807
  return {
1593
1808
  id: rec.id,
1594
- intent: rec.intent,
1809
+ outcome: rec.outcome,
1810
+ extensions: rec.extensions.map((extension) => ({ ...extension })),
1595
1811
  acceptance: [...rec.acceptance],
1596
1812
  verify: rec.verify,
1813
+ contractHash: rec.contractHash,
1597
1814
  verification: publicVerification(rec.verification),
1598
1815
  decisions: [...rec.decisions],
1599
1816
  status: rec.status,
@@ -1606,6 +1823,12 @@ function publicIntent(rec) {
1606
1823
  reclaimed: rec.reclaimed,
1607
1824
  reclaimReason: rec.reclaimReason,
1608
1825
  reclaimedAt: rec.reclaimedAt,
1826
+ imported: rec.imported
1827
+ ? {
1828
+ sourceIds: [...rec.imported.sourceIds],
1829
+ sourceFingerprint: rec.imported.sourceFingerprint,
1830
+ }
1831
+ : null,
1609
1832
  };
1610
1833
  }
1611
1834
 
@@ -1859,11 +2082,70 @@ function stripDecisionLogLanguage(block, language = DEFAULT_LOG_LANGUAGE) {
1859
2082
  .replace(`\n${decisionLogLanguageParagraph(language)}\n`, '');
1860
2083
  }
1861
2084
 
2085
+ function outcomeLogLanguageParagraph(language) {
2086
+ return `**Log language:** \`${language}\`. Write outcome-log prose (outcome, extension, note,
2087
+ verify-result, and reclaim/unreclaim reason) in that language. Keep command
2088
+ names, flags, status tokens, and ids in English.`;
2089
+ }
2090
+
1862
2091
  function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
1863
2092
  return `${INTENT_PROTOCOL_MARKER}
1864
2093
  <!-- driftseal-version: ${version} -->
1865
2094
  <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
1866
2095
 
2096
+ ## Agent protocol: outcome write-ahead log
2097
+
2098
+ This repository uses DriftSeal (\`driftseal\`) to prevent agent drift. This
2099
+ \`AGENTS.md\` protocol is the source of truth; use the CLI by default, with MCP
2100
+ and lifecycle hooks as optional adapters.
2101
+
2102
+ ${outcomeLogLanguageParagraph(language)}
2103
+
2104
+ 1. **Write the outcome first**, before changing durable project content:
2105
+ \`driftseal begin "<coherent delivery outcome>" --accept "<observable result>" --verify "<exact command that proves the cumulative contract>"\`.
2106
+ Repeat \`--accept\` for independently observable criteria and add one
2107
+ \`--decision <id>\` for each existing MADR this outcome may change.
2108
+ Record outcomes for changes intended to persist in the project: code,
2109
+ configuration, documentation, dependencies, and equivalent files, inside or
2110
+ outside Git. Git operations, checks, temporary auxiliary work, and external
2111
+ state changes are exempt when they do not write durable project content here.
2112
+ 2. **Extend only the same outcome.** For another step toward the same coherent
2113
+ delivery goal, append \`driftseal extend "<addition>"\`. It may add
2114
+ \`--accept\`, \`--decision\`, and a replacement \`--verify\`; adding acceptance
2115
+ requires a replacement verifier that proves the complete accumulated contract.
2116
+ Every extension invalidates earlier verification and MADR reconciliation. If
2117
+ the delivery goal changes, close the current outcome honestly and begin a new one.
2118
+ One open outcome belongs to one worktree, or one configured non-Git project
2119
+ root. Every agent changing durable content in the same root re-anchors and
2120
+ continues it; separate worktrees hold separate outcomes.
2121
+ 3. **Reconcile, verify, then close.** After the final extension, reconcile every
2122
+ linked MADR with \`driftseal decision update\`. Inspect \`driftseal status\`,
2123
+ then run \`driftseal verify\` for an acceptance-bound outcome. A verifier
2124
+ without matching local provenance is untrusted and requires
2125
+ \`--allow-tracked-command\` after inspection. Finish with
2126
+ \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>"\`.
2127
+ Completed outcomes require fresh successful verification bound to both the
2128
+ current contract hash and Git-visible workspace. Never report success without
2129
+ closing the outcome.
2130
+ 4. **Re-anchor after context loss or handoff:** run \`driftseal status\` and
2131
+ \`driftseal log --last 3\` before changing durable content. Resume the open
2132
+ outcome when it still matches; otherwise close it and begin a new one.
2133
+
2134
+ **Log access goes only through DriftSeal.** Never read, edit, move, or delete
2135
+ \`.seal/outcomes/events.jsonl\` (or its configured equivalent) directly. Use
2136
+ \`reclaim\`/\`unreclaim\` for visibility markers and \`absorb\` after merge
2137
+ collisions. These operations preserve append-only single-lineage history.
2138
+
2139
+ Seal root: \`.seal/\` (override with \`$DRIFTSEAL_HOME\`); outcome log:
2140
+ \`.seal/outcomes/events.jsonl\`; ${localLog ? 'keep `.seal/` local and untracked.' : 'commit `.seal/` with the code.'}
2141
+ ${INTENT_PROTOCOL_END}`;
2142
+ }
2143
+
2144
+ function v1IntentProtocolBlock(version = 14, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2145
+ return `${INTENT_PROTOCOL_MARKER}
2146
+ <!-- driftseal-version: ${version} -->
2147
+ <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
2148
+
1867
2149
  ## Agent protocol: intent write-ahead log
1868
2150
 
1869
2151
  This repo uses DriftSeal (\`driftseal\`) to prevent agent drift. Every work round:
@@ -1949,7 +2231,7 @@ ${INTENT_PROTOCOL_END}`;
1949
2231
  }
1950
2232
 
1951
2233
  function previousIntentProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
1952
- const v13 = intentProtocolBlock(version, language, localLog)
2234
+ const v13 = v1IntentProtocolBlock(version, language, localLog)
1953
2235
  .replace(
1954
2236
  '1. **Write intent first**, before changing durable project content:\n' +
1955
2237
  ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
@@ -2157,6 +2439,31 @@ function decisionProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LO
2157
2439
 
2158
2440
  ## Agent protocol: decision log
2159
2441
 
2442
+ Record a MADR only when it preserves context that the outcome log and Git cannot
2443
+ recover: rejected or deferred paths worth revisiting, non-obvious rationale for
2444
+ long-lived or costly-to-reverse choices, and deprecated or superseded decisions.
2445
+ Do not record routine, local, readily reversible choices.
2446
+
2447
+ ${decisionLogLanguageParagraph(language)}
2448
+
2449
+ \`driftseal decision add "<title>" --context "<problem and constraints>" --outcome "<decision and rationale>" --driver "<decision driver>" --option "<considered option>" --consequence "<result>"\`
2450
+
2451
+ Use \`proposed|accepted|rejected|deferred|deprecated|superseded\` statuses. Link
2452
+ existing MADRs from \`begin\` or \`extend\`, then reconcile each linked record
2453
+ with \`driftseal decision update\` before successful or partial closure. After a
2454
+ merge, \`driftseal absorb\` remaps colliding ids; it never auto-merges concurrent
2455
+ edits of a shared MADR.
2456
+ ${localLog ? 'Keep `.seal/madr/` local and untracked.' : 'Commit `.seal/madr/` with the code.'}
2457
+ ${DECISION_PROTOCOL_END}`;
2458
+ }
2459
+
2460
+ function v1DecisionProtocolBlock(version = 14, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2461
+ return `${DECISION_PROTOCOL_MARKER}
2462
+ <!-- driftseal-decisions-version: ${version} -->
2463
+ <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
2464
+
2465
+ ## Agent protocol: decision log
2466
+
2160
2467
  Record a MADR document only when it preserves decision context that cannot be
2161
2468
  recovered from the intent log and Git history: a rejected or deferred path worth
2162
2469
  revisiting, non-obvious rationale behind a long-lived or costly-to-reverse accepted
@@ -2233,8 +2540,8 @@ Commit \`.decision-log/\` with the code.`;
2233
2540
  }
2234
2541
 
2235
2542
  function previousDecisionProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2236
- if (version >= 11) return decisionProtocolBlock(version, language, localLog);
2237
- const v10 = stripDecisionLogLanguage(decisionProtocolBlock(version, language, localLog), language);
2543
+ if (version >= 11) return v1DecisionProtocolBlock(version, language, localLog);
2544
+ const v10 = stripDecisionLogLanguage(v1DecisionProtocolBlock(version, language, localLog), language);
2238
2545
  if (version >= 9) return v10;
2239
2546
  const v8 = v10.replace(
2240
2547
  '\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
@@ -2268,11 +2575,17 @@ function upgradeManagedBlock({
2268
2575
  if (!versionMatch) {
2269
2576
  fail(`cannot safely upgrade unversioned managed protocol block beginning with ${marker}`);
2270
2577
  }
2271
- const version = Number(versionMatch[1]);
2272
- if (!Number.isSafeInteger(version) || version < 1) {
2578
+ const version = versionMatch[1];
2579
+ if (!/^\d+(?:\.\d+)?$/.test(version)) {
2273
2580
  fail(`invalid protocol version in block beginning with ${marker}`);
2274
2581
  }
2275
- if (version > PROTOCOL_VERSION) {
2582
+ const futureV2 = version.includes('.') && (() => {
2583
+ const [major, minor] = version.split('.').map(Number);
2584
+ const [supportedMajor, supportedMinor] = PROTOCOL_VERSION.split('.').map(Number);
2585
+ return major > supportedMajor || (major === supportedMajor && minor > supportedMinor);
2586
+ })();
2587
+ const futureV1 = !version.includes('.') && Number(version) > 14;
2588
+ if (futureV2 || futureV1) {
2276
2589
  fail(
2277
2590
  `protocol version ${version} requires a newer DriftSeal client (supported: ${PROTOCOL_VERSION})`
2278
2591
  );
@@ -2543,7 +2856,8 @@ const SKILL_RELEASE_DIGESTS = new Set([
2543
2856
  'cc98b9348ec222320bfcd285ba3f1f499a42d15b31e9b1d83c35f0206b2d5ba9', // ca16785 CLI-first skill integration
2544
2857
  '0fd870f8c1b81f8386d986d64742679d56cd1d317c02890830c81876eb9227d6', // da8afd2 1.1.0 absorb
2545
2858
  '72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
2546
- 'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb (current)
2859
+ 'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb
2860
+ '42a0549dff21483c0508ea4a79658e7bf05cd98f8af4238c95dbd23cdcde7ee6', // 2.0.0 outcome workflow
2547
2861
  ]);
2548
2862
 
2549
2863
  function skillInstallUsage() {
@@ -2964,7 +3278,7 @@ function hookLogFile() {
2964
3278
  let current = path.resolve(process.cwd());
2965
3279
  const root = gitWorktreeRoot(current);
2966
3280
  while (true) {
2967
- const candidate = path.join(current, '.intent-log', 'events.jsonl');
3281
+ const candidate = path.join(current, '.seal', 'outcomes', 'events.jsonl');
2968
3282
  if (fs.existsSync(candidate)) return candidate;
2969
3283
  if (root && path.resolve(root) === current) {
2970
3284
  const park = worktreeInProgressFile(current);
@@ -2976,7 +3290,7 @@ function hookLogFile() {
2976
3290
  }
2977
3291
  }
2978
3292
 
2979
- /** Advisory reminder text; null when no ancestor has an intent log yet. */
3293
+ /** Advisory reminder text; null when no ancestor has an outcome log yet. */
2980
3294
  function hookReminder(event, { readOnly = false } = {}) {
2981
3295
  const file = hookLogFile();
2982
3296
  if (!file) return null;
@@ -2984,27 +3298,27 @@ function hookReminder(event, { readOnly = false } = {}) {
2984
3298
  return (
2985
3299
  'DriftSeal reminder: if this round will change durable project content in this workspace ' +
2986
3300
  '(code, configuration, documentation, dependencies), ' +
2987
- 'begin an intent first: driftseal begin "<intent>" --accept "<observable outcome>" ' +
3301
+ 'begin an outcome first: driftseal begin "<coherent outcome>" --accept "<observable result>" ' +
2988
3302
  '--verify "<command>". ' +
2989
3303
  'Questions, read-only exploration, single-step checks, temporary work outside durable ' +
2990
3304
  'project content, and external state changes that do not write project content here need ' +
2991
- 'no intent — skip this reminder when it does not apply.'
3305
+ 'no outcome — skip this reminder when it does not apply.'
2992
3306
  );
2993
3307
  }
2994
- const open = openIntent(fold(readEvents({ file, readOnly })));
3308
+ const open = openOutcome(fold(readEvents({ file, readOnly })));
2995
3309
  if (open) {
2996
3310
  const reconciliation = open.decisions.length > 0 ? 'reconcile every linked decision, then ' : '';
2997
3311
  const verification = open.acceptance.length > 0
2998
3312
  ? `${reconciliation}inspect and run driftseal verify, then close it with driftseal end`
2999
3313
  : `${reconciliation}run the declared verification, then close it with driftseal end`;
3000
3314
  return (
3001
- `DriftSeal reminder: intent ${open.id} is still in_progress: "${open.intent}". ` +
3315
+ `DriftSeal reminder: outcome ${open.id} is still in_progress: "${open.outcome}". ` +
3002
3316
  `If its work is done, ${verification}; ` +
3003
3317
  'if this turn was unrelated, ignore this reminder.'
3004
3318
  );
3005
3319
  }
3006
3320
  return (
3007
- 'DriftSeal reminder: no intent is open. If this round changed files without one, consider ' +
3321
+ 'DriftSeal reminder: no outcome is open. If this round changed files without one, consider ' +
3008
3322
  'whether the work should have been logged; ignore this reminder when nothing changed.'
3009
3323
  );
3010
3324
  }
@@ -3078,7 +3392,7 @@ function isGitWorkTree(cwd = process.cwd()) {
3078
3392
  * Hash the material Git-visible workspace contents rather than trusting the
3079
3393
  * current commit alone. Any tracked or untracked (non-ignored) content change
3080
3394
  * makes the verification stale.
3081
- * The intent event log is excluded because recording verification and closure
3395
+ * The outcome event log is excluded because recording verification and closure
3082
3396
  * necessarily appends to it.
3083
3397
  */
3084
3398
  function workspaceFingerprint(cwd = process.cwd()) {
@@ -3148,8 +3462,8 @@ function workspaceFingerprint(cwd = process.cwd()) {
3148
3462
  *
3149
3463
  * Paths are read from `ls-files -z` with `:(literal)` pathspecs because git's
3150
3464
  * human-readable listing C-quotes non-ASCII names and treats `*?[\` as
3151
- * wildcards. The printed remediation uses the fixed names `.intent-log` and
3152
- * `.decision-log` and is meant to be run from this directory: git resolves
3465
+ * wildcards. The printed remediation uses the fixed name `.seal` and is meant
3466
+ * to be run from this directory: git resolves
3153
3467
  * those pathspecs against the init cwd, so the command stays paste-safe in
3154
3468
  * POSIX shells, cmd.exe, and PowerShell without embedding the repo-relative
3155
3469
  * prefix or any shell quoting.
@@ -3160,7 +3474,7 @@ function warnIfDefaultLogsTracked(cwd = process.cwd()) {
3160
3474
  if (!root) return;
3161
3475
  const prefix = gitCaptureLine(['rev-parse', '--show-prefix'], cwd);
3162
3476
  if (prefix === null) return;
3163
- const logNames = ['.intent-log', '.decision-log'];
3477
+ const logNames = ['.seal'];
3164
3478
  const logDirs = logNames.map((name) => `${prefix}${name}`);
3165
3479
  const listing = gitCaptureRaw(
3166
3480
  ['ls-files', '-z', '--', ...logDirs.map((name) => `:(literal)${name}`)],
@@ -3223,7 +3537,7 @@ function gitMergeParents(cwd = process.cwd()) {
3223
3537
  }
3224
3538
 
3225
3539
  function gitDecisionIds(treeish, cwd = process.cwd()) {
3226
- const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
3540
+ const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.seal/madr'], cwd);
3227
3541
  if (!out) return new Set();
3228
3542
  const ids = new Set();
3229
3543
  for (const file of out.split('\n')) {
@@ -3234,7 +3548,7 @@ function gitDecisionIds(treeish, cwd = process.cwd()) {
3234
3548
  }
3235
3549
 
3236
3550
  function gitDecisionEntries(treeish, cwd = process.cwd()) {
3237
- const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.decision-log'], cwd);
3551
+ const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.seal/madr'], cwd);
3238
3552
  if (!out) return [];
3239
3553
  const entries = [];
3240
3554
  for (const file of out.split('\n')) {
@@ -3253,9 +3567,9 @@ function gitDecisionEntries(treeish, cwd = process.cwd()) {
3253
3567
  return entries;
3254
3568
  }
3255
3569
 
3256
- function gitIntentRecords(treeish, cwd = process.cwd()) {
3257
- const content = gitReadFile(treeish, '.intent-log/events.jsonl', cwd);
3258
- return content === null ? [] : parseJsonlRecords(content, `${treeish}:.intent-log/events.jsonl`);
3570
+ function gitOutcomeRecords(treeish, cwd = process.cwd()) {
3571
+ const content = gitReadFile(treeish, '.seal/outcomes/events.jsonl', cwd);
3572
+ return content === null ? [] : parseJsonlRecords(content, `${treeish}:.seal/outcomes/events.jsonl`);
3259
3573
  }
3260
3574
 
3261
3575
  function canonicalEvent(event) {
@@ -3374,10 +3688,14 @@ function hasDuplicateDecisionIds(entries) {
3374
3688
  return false;
3375
3689
  }
3376
3690
 
3377
- function hasDuplicateIntentBegins(records) {
3691
+ function isOutcomeStart(event) {
3692
+ return event.type === 'begin' || event.type === 'import';
3693
+ }
3694
+
3695
+ function hasDuplicateOutcomeStarts(records) {
3378
3696
  const seen = new Set();
3379
3697
  for (const record of records) {
3380
- if (record.event.type !== 'begin') continue;
3698
+ if (!isOutcomeStart(record.event)) continue;
3381
3699
  if (seen.has(record.event.id)) return true;
3382
3700
  seen.add(record.event.id);
3383
3701
  }
@@ -3500,26 +3818,57 @@ function remapEvent(event, intentMap, decisionMap, hashMap = new Map()) {
3500
3818
  return next;
3501
3819
  }
3502
3820
 
3821
+ function rebindV2ContractHashes(records) {
3822
+ const contracts = new Map();
3823
+ return records.map((record) => {
3824
+ let event = record.event;
3825
+ if (event.type === 'begin' && event.logVersion === LOG_VERSION) {
3826
+ contracts.set(event.id, newOutcomeRecord(event));
3827
+ } else if (event.type === 'extend' && event.logVersion === LOG_VERSION) {
3828
+ const state = contracts.get(event.id);
3829
+ if (state) {
3830
+ state.extensions.push({
3831
+ extension: event.extension,
3832
+ acceptance: event.acceptance,
3833
+ verify: event.verify,
3834
+ decisions: event.decisions,
3835
+ });
3836
+ state.acceptance = [...new Set([...state.acceptance, ...event.acceptance])];
3837
+ if (event.verify) state.verify = event.verify;
3838
+ state.decisions = [...new Set([...state.decisions, ...event.decisions])];
3839
+ state.contractHash = outcomeContractHash(state);
3840
+ }
3841
+ } else if (event.logVersion === LOG_VERSION &&
3842
+ (event.type === 'verify' || (event.type === 'end' && event.status === 'completed'))) {
3843
+ const state = contracts.get(event.id);
3844
+ if (state && event.contractHash !== state.contractHash) {
3845
+ event = { ...event, contractHash: state.contractHash };
3846
+ }
3847
+ }
3848
+ return event === record.event ? record : { event };
3849
+ });
3850
+ }
3851
+
3503
3852
  function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
3504
3853
  const intentMap = new Map();
3505
3854
  const mappings = [];
3506
3855
  const used = [...oursUsedEvents];
3507
3856
  const records = theirsNew.map((record) => {
3508
3857
  let event = record.event;
3509
- if (event.type === 'begin' && used.some((item) => item.type === 'begin' && item.id === event.id)) {
3510
- const { date } = parseIntentId(event.id);
3858
+ if (isOutcomeStart(event) && used.some((item) => isOutcomeStart(item) && item.id === event.id)) {
3859
+ const { date } = parseOutcomeId(event.id);
3511
3860
  const newId = nextIdForDate(date, used);
3512
3861
  intentMap.set(event.id, newId);
3513
- mappings.push({ kind: 'intent', from: event.id, to: newId });
3862
+ mappings.push({ kind: 'outcome', from: event.id, to: newId });
3514
3863
  }
3515
3864
  event = remapEvent(event, intentMap, decisionMap, hashMap);
3516
3865
  used.push(event);
3517
3866
  return { event };
3518
3867
  });
3519
- return { records, mappings };
3868
+ return { records: rebindV2ContractHashes(records), mappings };
3520
3869
  }
3521
3870
 
3522
- function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map()) {
3871
+ function repairDuplicateOutcomeRecords(records, decisionMap, hashMap = new Map()) {
3523
3872
  const seenBegins = new Set();
3524
3873
  const intentMap = new Map();
3525
3874
  const used = [];
@@ -3528,13 +3877,13 @@ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map())
3528
3877
  let incomingSide = false;
3529
3878
  for (const record of records) {
3530
3879
  let event = record.event;
3531
- if (event.type === 'begin' && seenBegins.has(event.id)) {
3880
+ if (isOutcomeStart(event) && seenBegins.has(event.id)) {
3532
3881
  incomingSide = true;
3533
- const { date } = parseIntentId(event.id);
3882
+ const { date } = parseOutcomeId(event.id);
3534
3883
  const newId = nextIdForDate(date, used);
3535
3884
  intentMap.set(event.id, newId);
3536
- mappings.push({ kind: 'intent', from: event.id, to: newId });
3537
- } else if (event.type === 'begin') {
3885
+ mappings.push({ kind: 'outcome', from: event.id, to: newId });
3886
+ } else if (isOutcomeStart(event)) {
3538
3887
  seenBegins.add(event.id);
3539
3888
  }
3540
3889
  const remapped = remapEvent(
@@ -3547,7 +3896,7 @@ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map())
3547
3896
  result.push(changed ? { event: remapped } : record);
3548
3897
  used.push(result.at(-1).event);
3549
3898
  }
3550
- return { records: result, mappings, incomingSide };
3899
+ return { records: rebindV2ContractHashes(result), mappings, incomingSide };
3551
3900
  }
3552
3901
 
3553
3902
  function serializeRecords(records) {
@@ -3575,19 +3924,19 @@ function applyDecisionCopies(copies, dryRun) {
3575
3924
  }
3576
3925
  }
3577
3926
 
3578
- function countAbsorbedIntents(records) {
3579
- return records.filter((record) => record.event.type === 'begin').length;
3927
+ function countAbsorbedOutcomes(records) {
3928
+ return records.filter((record) => ['begin', 'import'].includes(record.event.type)).length;
3580
3929
  }
3581
3930
 
3582
- function printAbsorbReport({ mappings, abandoned, intentCount }) {
3583
- const remappedIntents = mappings.filter((mapping) => mapping.kind === 'intent').length;
3931
+ function printAbsorbReport({ mappings, abandoned, outcomeCount }) {
3932
+ const remappedOutcomes = mappings.filter((mapping) => mapping.kind === 'outcome').length;
3584
3933
  const remappedDecisions = mappings.filter((mapping) => mapping.kind === 'decision').length;
3585
3934
  printLine(
3586
- `absorbed ${intentCount} intent(s), remapped ${remappedIntents} intent id(s), ${remappedDecisions} decision id(s)`
3935
+ `absorbed ${outcomeCount} outcome(s), remapped ${remappedOutcomes} outcome id(s), ${remappedDecisions} decision id(s)`
3587
3936
  );
3588
3937
  for (const mapping of mappings) {
3589
3938
  const side = mapping.side || 'theirs';
3590
- if (mapping.kind === 'intent') printLine(`${mapping.from} (${side}) -> ${mapping.to}`);
3939
+ if (mapping.kind === 'outcome') printLine(`${mapping.from} (${side}) -> ${mapping.to}`);
3591
3940
  else printLine(`decision ${mapping.from} (${side}) -> ${mapping.to}`);
3592
3941
  }
3593
3942
  if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
@@ -3596,6 +3945,7 @@ function printAbsorbReport({ mappings, abandoned, intentCount }) {
3596
3945
  function abandonOpenIntent(records, targetId, side) {
3597
3946
  records.push({
3598
3947
  event: {
3948
+ logVersion: LOG_VERSION,
3599
3949
  schemaVersion: EVENT_SCHEMA_VERSION,
3600
3950
  type: 'end',
3601
3951
  id: targetId,
@@ -3616,13 +3966,13 @@ function resolveOpenIntents(
3616
3966
  abandon,
3617
3967
  { allowConflict = false, overlay = [], parkedOpen = null } = {}
3618
3968
  ) {
3619
- const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
3620
- const theirsOpen = openIntent(fold(theirsRecords.map((record) => record.event)));
3969
+ const oursOpen = openOutcome(fold(oursRecords.map((record) => record.event)));
3970
+ const theirsOpen = openOutcome(fold(theirsRecords.map((record) => record.event)));
3621
3971
  try {
3622
- openIntent(fold([...result, ...overlay].map((record) => record.event)));
3972
+ openOutcome(fold([...result, ...overlay].map((record) => record.event)));
3623
3973
  return { abandoned: null, conflict: false, parkedClosed: false };
3624
3974
  } catch (err) {
3625
- if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
3975
+ if (!(err instanceof DriftSealError) || !/multiple outcomes in progress/.test(err.message)) {
3626
3976
  throw err;
3627
3977
  }
3628
3978
  if (abandon === 'theirs' && theirsOpen) {
@@ -3666,19 +4016,25 @@ function mergeRecordStreams(ours, theirs, baseRecords) {
3666
4016
  }
3667
4017
 
3668
4018
  function GITATTRIBUTES_MERGE_LINE() {
3669
- return '.intent-log/events.jsonl merge=driftseal';
4019
+ return '.seal/outcomes/events.jsonl merge=driftseal';
3670
4020
  }
3671
4021
 
3672
4022
  function ensureGitAttributes() {
3673
4023
  const target = path.join(process.cwd(), '.gitattributes');
3674
4024
  const line = GITATTRIBUTES_MERGE_LINE();
4025
+ const legacyLine = '.intent-log/events.jsonl merge=driftseal';
3675
4026
  const existed = fs.existsSync(target);
3676
4027
  const current = existed ? fs.readFileSync(target, 'utf8') : '';
3677
4028
  const eol = current.includes('\r\n') ? '\r\n' : '\n';
3678
4029
  const lines = current.split(/\r?\n/);
3679
- if (lines.some((entry) => entry.trim() === line)) return { changed: false, target };
3680
- const prefix = current.length === 0 || current.endsWith('\n') || current.endsWith('\r\n') ? '' : eol;
3681
- const next = `${current}${prefix}${line}${eol}`;
4030
+ const filtered = lines.filter((entry) => entry.trim() !== legacyLine);
4031
+ if (!filtered.some((entry) => entry.trim() === line)) {
4032
+ const insertion = filtered.length > 0 && filtered.at(-1) === '' ? filtered.length - 1 : filtered.length;
4033
+ filtered.splice(insertion, 0, line);
4034
+ }
4035
+ let next = filtered.join(eol);
4036
+ if (!next.endsWith(eol)) next += eol;
4037
+ if (next === current) return { changed: false, target };
3682
4038
  atomicWriteFile(target, next);
3683
4039
  return { changed: true, target };
3684
4040
  }
@@ -3687,7 +4043,7 @@ function ensureGitMergeDriver() {
3687
4043
  if (!isGitWorkTree()) return { changed: false, configured: false };
3688
4044
  const name = gitCapture(['config', '--local', '--get', 'merge.driftseal.name']);
3689
4045
  const driver = gitCapture(['config', '--local', '--get', 'merge.driftseal.driver']);
3690
- const expectedName = 'DriftSeal intent log merge';
4046
+ const expectedName = 'DriftSeal outcome log merge';
3691
4047
  const expectedDriver = 'driftseal absorb --git %O %A %B';
3692
4048
  if (name === expectedName && driver === expectedDriver) {
3693
4049
  return { changed: false, configured: true };
@@ -3708,10 +4064,10 @@ function absorbUsage() {
3708
4064
  );
3709
4065
  }
3710
4066
 
3711
- function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false } = {}) {
4067
+ function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false, allowLegacy = false } = {}) {
3712
4068
  if (!fs.existsSync(file)) {
3713
4069
  if (allowMissing) return { records: [], conflict: false };
3714
- fail(`intent log not found: ${file}`);
4070
+ fail(`outcome log not found: ${file}`);
3715
4071
  }
3716
4072
  let content = fs.readFileSync(file, 'utf8');
3717
4073
  if (repairTail && !/^<<<<<<< /m.test(content)) {
@@ -3721,12 +4077,12 @@ function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false
3721
4077
  const conflict = parseConflictContent(content);
3722
4078
  if (conflict) {
3723
4079
  return {
3724
- ours: parseJsonlRecords(conflict.oursText, `${label} ours`),
3725
- theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`),
4080
+ ours: parseJsonlRecords(conflict.oursText, `${label} ours`, { allowLegacy }),
4081
+ theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`, { allowLegacy }),
3726
4082
  conflict: true,
3727
4083
  };
3728
4084
  }
3729
- return { records: parseJsonlRecords(content, label), conflict: false };
4085
+ return { records: parseJsonlRecords(content, label, { allowLegacy }), conflict: false };
3730
4086
  }
3731
4087
 
3732
4088
  function finishAbsorb({
@@ -3738,18 +4094,18 @@ function finishAbsorb({
3738
4094
  abandon,
3739
4095
  dryRun,
3740
4096
  outputFile,
3741
- intentCount,
4097
+ outcomeCount,
3742
4098
  allowConflict = false,
3743
4099
  followupMessage = null,
3744
4100
  }) {
3745
- // An intent parked in Git metadata is part of our side even though the log never saw it.
4101
+ // An outcome parked in Git metadata is part of our side even though the log never saw it.
3746
4102
  const park = shouldAttachInProgress(outputFile) ? inProgressFile() : null;
3747
4103
  const plan = planInProgressOverlay(result.map((record) => record.event), park, {
3748
4104
  repairTail: true,
3749
4105
  });
3750
4106
  const overlay = plan && !plan.alreadyCommitted ? plan.records : [];
3751
4107
  const parkedOpen =
3752
- overlay.length > 0 ? openIntent(fold(overlay.map((record) => record.event))) : null;
4108
+ overlay.length > 0 ? openOutcome(fold(overlay.map((record) => record.event))) : null;
3753
4109
  const parkMappings = plan
3754
4110
  ? plan.mappings.map((mapping) => ({ ...mapping, side: 'parked' }))
3755
4111
  : [];
@@ -3767,7 +4123,7 @@ function finishAbsorb({
3767
4123
  const merged = flushOverlay ? [...result, ...overlay] : result;
3768
4124
  const effective = [...result, ...overlay].map((record) => record.event);
3769
4125
  fold(effective);
3770
- if (!conflict) openIntent(fold(effective));
4126
+ if (!conflict) openOutcome(fold(effective));
3771
4127
  if (!dryRun) {
3772
4128
  writeJsonl(outputFile, merged);
3773
4129
  applyDecisionCopies(copies, dryRun);
@@ -3777,7 +4133,7 @@ function finishAbsorb({
3777
4133
  }
3778
4134
  }
3779
4135
  if (
3780
- intentCount === 0 &&
4136
+ outcomeCount === 0 &&
3781
4137
  allMappings.length === 0 &&
3782
4138
  copies.length === 0 &&
3783
4139
  !abandoned &&
@@ -3788,11 +4144,11 @@ function finishAbsorb({
3788
4144
  printAbsorbReport({
3789
4145
  mappings: allMappings,
3790
4146
  abandoned,
3791
- intentCount,
4147
+ outcomeCount,
3792
4148
  });
3793
4149
  }
3794
4150
  if (conflict) {
3795
- printLine('multiple intents remain in progress; re-run with --abandon-theirs or --abandon-ours');
4151
+ printLine('multiple outcomes remain in progress; re-run with --abandon-theirs or --abandon-ours');
3796
4152
  }
3797
4153
  if (followupMessage) printLine(followupMessage);
3798
4154
  return {
@@ -3830,7 +4186,7 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
3830
4186
  outputFile: options.outputFile,
3831
4187
  allowConflict: options.allowConflict,
3832
4188
  followupMessage: options.followupMessage,
3833
- intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
4189
+ outcomeCount: streams.theirsNew.filter((record) => ['begin', 'import'].includes(record.event.type)).length,
3834
4190
  });
3835
4191
  }
3836
4192
 
@@ -3843,7 +4199,7 @@ function gitAbsorbRepairContext(records, decisionEntries) {
3843
4199
  base: gitMergeBaseFor('HEAD', pending),
3844
4200
  };
3845
4201
  }
3846
- if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateIntentBegins(records)) return null;
4202
+ if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateOutcomeStarts(records)) return null;
3847
4203
  const parents = gitMergeParents();
3848
4204
  if (!parents) return null;
3849
4205
  return {
@@ -3853,10 +4209,10 @@ function gitAbsorbRepairContext(records, decisionEntries) {
3853
4209
  }
3854
4210
 
3855
4211
  function absorbFromGitContext(context, { abandon, dryRun, outputFile }) {
3856
- const baseRecords = context.base ? gitIntentRecords(context.base) : [];
4212
+ const baseRecords = context.base ? gitOutcomeRecords(context.base) : [];
3857
4213
  return absorbFromStreams(
3858
- gitIntentRecords(context.ours),
3859
- gitIntentRecords(context.theirs),
4214
+ gitOutcomeRecords(context.ours),
4215
+ gitOutcomeRecords(context.theirs),
3860
4216
  baseRecords,
3861
4217
  {
3862
4218
  abandon,
@@ -3913,21 +4269,21 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
3913
4269
  baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
3914
4270
  baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
3915
4271
  });
3916
- const repaired = repairDuplicateIntentRecords(
4272
+ const repaired = repairDuplicateOutcomeRecords(
3917
4273
  loaded.records,
3918
4274
  decisionPlan.decisionMap,
3919
4275
  decisionPlan.hashMap
3920
4276
  );
3921
4277
  if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
3922
4278
  fail(
3923
- 'cannot determine which intent records own the duplicate decision; ' +
4279
+ 'cannot determine which outcome records own the duplicate decision; ' +
3924
4280
  'run absorb during the merge or provide the incoming log and decision directory'
3925
4281
  );
3926
4282
  }
3927
4283
  const result = repaired.records;
3928
4284
  const mappings = [...repaired.mappings, ...decisionPlan.mappings];
3929
4285
  const remappedIds = new Set(
3930
- mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
4286
+ mappings.filter((mapping) => mapping.kind === 'outcome').map((mapping) => mapping.to)
3931
4287
  );
3932
4288
  const oursRecords = result.filter((record) => !remappedIds.has(record.event.id));
3933
4289
  return finishAbsorb({
@@ -3939,13 +4295,13 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
3939
4295
  abandon,
3940
4296
  dryRun,
3941
4297
  outputFile: oursFile,
3942
- intentCount: remappedIds.size,
4298
+ outcomeCount: remappedIds.size,
3943
4299
  });
3944
4300
  }
3945
4301
 
3946
4302
  const theirs = loadAbsorbSide(otherFile, otherFile);
3947
4303
  if (theirs.conflict) fail(`incoming log still contains conflict markers: ${otherFile}`);
3948
- const otherRoot = path.resolve(path.dirname(otherFile), '..');
4304
+ const otherRoot = path.resolve(path.dirname(otherFile), '..', '..');
3949
4305
  const otherHead = isGitWorkTree(otherRoot) ? gitCapture(['rev-parse', 'HEAD'], otherRoot) : null;
3950
4306
  const gitBase = otherHead ? gitCapture(['merge-base', 'HEAD', otherHead]) : gitMergeBase();
3951
4307
  const baseDecisionIds = gitBase
@@ -3960,21 +4316,23 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
3960
4316
  dryRun,
3961
4317
  outputFile: oursFile,
3962
4318
  oursDecisionEntries: listDecisionEntries(decisionDir()),
3963
- theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
4319
+ theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', 'madr')),
3964
4320
  baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
3965
4321
  baseDecisionIds,
3966
4322
  });
3967
4323
  }
3968
4324
 
3969
4325
  function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
3970
- const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true });
3971
- const ours = loadAbsorbSide(oursFile, oursFile);
3972
- const theirs = loadAbsorbSide(theirsFile, theirsFile);
4326
+ const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true, allowLegacy: true });
4327
+ const ours = loadAbsorbSide(oursFile, oursFile, { allowLegacy: true });
4328
+ const theirs = loadAbsorbSide(theirsFile, theirsFile, { allowLegacy: true });
3973
4329
  if (base.conflict || ours.conflict || theirs.conflict) {
3974
4330
  fail('git merge driver received a log that still contains conflict markers');
3975
4331
  }
3976
4332
  const otherHead =
3977
- gitOtherHead() || gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
4333
+ gitOtherHead() ||
4334
+ gitFindCommitForFile(theirsFile, '.seal/outcomes/events.jsonl') ||
4335
+ gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
3978
4336
  const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
3979
4337
  let followupMessage = null;
3980
4338
  if (!otherHead) {
@@ -4103,20 +4461,21 @@ function executeVerificationCommand(command) {
4103
4461
 
4104
4462
  function runMachineVerification({ allowTrackedCommand = false } = {}) {
4105
4463
  const snapshot = withMutationLocks([logDir()], () => {
4106
- const intent = openIntent(fold(readEvents({ repairTail: true })));
4107
- if (!intent) fail('no intent in progress; nothing to verify');
4108
- if (intent.acceptance.length === 0) {
4109
- fail(`intent ${intent.id} has no acceptance criteria; declare them with driftseal begin --accept`);
4464
+ const outcome = openOutcome(fold(readEvents({ repairTail: true })));
4465
+ if (!outcome) fail('no outcome in progress; nothing to verify');
4466
+ if (outcome.acceptance.length === 0) {
4467
+ fail(`outcome ${outcome.id} has no acceptance criteria; declare them with driftseal begin --accept`);
4110
4468
  }
4111
- if (!intent.verify) fail(`intent ${intent.id} has no verification command`);
4469
+ if (!outcome.verify) fail(`outcome ${outcome.id} has no verification command`);
4112
4470
  const park = inProgressFile();
4113
- const parked = park ? parkedOpenIntent(park) : null;
4114
- const locallyProvenanced = hasMatchingLocalIntentProvenance(intent);
4471
+ const parked = park ? parkedOpenOutcome(park) : null;
4472
+ const locallyProvenanced = hasMatchingLocalOutcomeProvenance(outcome);
4115
4473
  return {
4116
- id: intent.id,
4117
- command: intent.verify,
4474
+ id: outcome.id,
4475
+ command: outcome.verify,
4476
+ contractHash: outcome.contractHash,
4118
4477
  requiresExplicitTrust:
4119
- (!parked || parked.id !== intent.id) && !locallyProvenanced,
4478
+ (!parked || parked.id !== outcome.id) && !locallyProvenanced,
4120
4479
  };
4121
4480
  });
4122
4481
 
@@ -4125,7 +4484,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4125
4484
  if (snapshot.requiresExplicitTrust && !allowTrackedCommand) {
4126
4485
  fail(
4127
4486
  `refusing to execute a verification command that DriftSeal cannot confirm was created locally: ${displayedCommand}\n` +
4128
- 'no matching local intent provenance was found; ' +
4487
+ 'no matching local outcome provenance was found; ' +
4129
4488
  'inspect the command, then re-run with --allow-tracked-command only if you trust it'
4130
4489
  );
4131
4490
  }
@@ -4141,6 +4500,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4141
4500
  verificationId: crypto.randomUUID(),
4142
4501
  ts: new Date().toISOString(),
4143
4502
  command: snapshot.command,
4503
+ contractHash: snapshot.contractHash,
4144
4504
  passed,
4145
4505
  exitCode,
4146
4506
  signal,
@@ -4152,23 +4512,764 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4152
4512
  head: gitCapture(['rev-parse', 'HEAD']),
4153
4513
  };
4154
4514
 
4155
- const intent = withMutationLocks([logDir()], () => {
4515
+ const outcome = withMutationLocks([logDir()], () => {
4156
4516
  const events = readEvents({ repairTail: true });
4157
- const current = openIntent(fold(events));
4158
- if (!current || current.id !== snapshot.id || current.verify !== snapshot.command) {
4159
- fail(`intent ${snapshot.id} changed while its verification command was running`);
4517
+ const current = openOutcome(fold(events));
4518
+ if (!current || current.id !== snapshot.id || current.verify !== snapshot.command ||
4519
+ current.contractHash !== snapshot.contractHash) {
4520
+ fail(`outcome ${snapshot.id} changed while its verification command was running`);
4160
4521
  }
4161
4522
  events.push(appendEvent(verificationEvent));
4162
4523
  return fold(events).find((candidate) => candidate.id === snapshot.id);
4163
4524
  });
4164
4525
  printLine(`${snapshot.id} verification ${passed ? 'passed' : 'failed'} (exit ${exitCode})`);
4165
4526
  return {
4166
- intent: publicIntent(intent),
4167
- verification: publicVerification(intent.verification),
4527
+ outcome: publicOutcome(outcome),
4528
+ verification: publicVerification(outcome.verification),
4168
4529
  exitCode,
4169
4530
  };
4170
4531
  }
4171
4532
 
4533
+ const MIGRATION_PLAN_FORMAT = 'driftseal-v1-to-v2-plan';
4534
+
4535
+ function canonicalPath(file) {
4536
+ const resolved = path.resolve(file);
4537
+ const missing = [];
4538
+ let cursor = resolved;
4539
+ while (!fs.existsSync(cursor)) {
4540
+ const parent = path.dirname(cursor);
4541
+ if (parent === cursor) break;
4542
+ missing.push(path.basename(cursor));
4543
+ cursor = parent;
4544
+ }
4545
+ const existing = fs.existsSync(cursor) ? fs.realpathSync(cursor) : cursor;
4546
+ return path.join(existing, ...missing.reverse());
4547
+ }
4548
+
4549
+ function pathContains(parent, candidate) {
4550
+ const relative = path.relative(canonicalPath(parent), canonicalPath(candidate));
4551
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
4552
+ }
4553
+
4554
+ function resolveMigrationIdentityPath(location) {
4555
+ if (typeof location === 'string') return canonicalPath(location);
4556
+ if (location?.base === 'repository') return canonicalPath(path.resolve(process.cwd(), location.path));
4557
+ if (location?.base === 'absolute') return canonicalPath(location.path);
4558
+ return null;
4559
+ }
4560
+
4561
+ function encodeMigrationIdentityPath(file) {
4562
+ const resolved = canonicalPath(file);
4563
+ const root = canonicalPath(process.cwd());
4564
+ const relative = path.relative(root, resolved);
4565
+ if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
4566
+ return {
4567
+ base: 'repository',
4568
+ path: (relative || '.').split(path.sep).join('/'),
4569
+ };
4570
+ }
4571
+ return { base: 'absolute', path: resolved };
4572
+ }
4573
+
4574
+ function defaultV1SourceLog() {
4575
+ const home = v1HomeEnv();
4576
+ if (home) {
4577
+ const configured = path.join(home, 'events.jsonl');
4578
+ if (fs.existsSync(configured)) return configured;
4579
+ }
4580
+ return path.join(process.cwd(), '.intent-log', 'events.jsonl');
4581
+ }
4582
+
4583
+ function migrationPaths(flags = {}, { storedSource } = {}) {
4584
+ const sourceLog = canonicalPath(
4585
+ flags['source-log'] ||
4586
+ resolveMigrationIdentityPath(storedSource?.log) ||
4587
+ defaultV1SourceLog()
4588
+ );
4589
+ const sourceDecisions = canonicalPath(
4590
+ flags['source-decisions'] ||
4591
+ resolveMigrationIdentityPath(storedSource?.decisions) ||
4592
+ v1DecisionHomeEnv() ||
4593
+ path.join(process.cwd(), '.decision-log')
4594
+ );
4595
+ const destination = canonicalPath(flags.destination || sealRoot());
4596
+ for (const sourcePath of [sourceLog, sourceDecisions]) {
4597
+ if (pathContains(sourcePath, destination) || pathContains(destination, sourcePath)) {
4598
+ fail(`migration destination overlaps v1 source path: ${destination} and ${sourcePath}`);
4599
+ }
4600
+ }
4601
+ return { sourceLog, sourceDecisions, destination };
4602
+ }
4603
+
4604
+ function legacyParkFile() {
4605
+ if (!isGitWorkTree()) return null;
4606
+ const gitPath = gitCapture(['rev-parse', '--git-path', 'driftseal-in-progress.jsonl']);
4607
+ return gitPath ? path.resolve(process.cwd(), gitPath) : null;
4608
+ }
4609
+
4610
+ function legacyIntentLogFile() {
4611
+ return path.resolve(process.cwd(), '.intent-log', 'events.jsonl');
4612
+ }
4613
+
4614
+ function legacyParkedIntent() {
4615
+ const park = legacyParkFile();
4616
+ if (!park || !fs.existsSync(park)) return null;
4617
+ try {
4618
+ const records = parseJsonlRecords(fs.readFileSync(park, 'utf8'), park, { allowLegacy: true });
4619
+ return openOutcome(fold(records.map((record) => record.event)));
4620
+ } catch {
4621
+ return null;
4622
+ }
4623
+ }
4624
+
4625
+ function closeLegacyParkedIntent(status, note) {
4626
+ const parked = legacyParkedIntent();
4627
+ if (!parked) fail('no parked v1 intent to close');
4628
+ const park = legacyParkFile();
4629
+ const log = legacyIntentLogFile();
4630
+ const parkRecords = parseJsonlRecords(fs.readFileSync(park, 'utf8'), park, { allowLegacy: true });
4631
+ const logRecords = fs.existsSync(log)
4632
+ ? parseJsonlRecords(fs.readFileSync(log, 'utf8'), log, { allowLegacy: true })
4633
+ : [];
4634
+ const endEvent = {
4635
+ schemaVersion: LEGACY_EVENT_SCHEMA_VERSION,
4636
+ type: 'end',
4637
+ id: parked.id,
4638
+ ts: new Date().toISOString(),
4639
+ status,
4640
+ note: note || null,
4641
+ };
4642
+ writeJsonl(log, [
4643
+ ...logRecords,
4644
+ ...parkRecords,
4645
+ { raw: JSON.stringify(endEvent), event: normalizeEvent(endEvent, logRecords.length + parkRecords.length + 1) },
4646
+ ]);
4647
+ fs.unlinkSync(park);
4648
+ fsyncDirectory(path.dirname(park));
4649
+ const closed = fold([...logRecords, ...parkRecords].map((record) => record.event).concat(normalizeEvent(endEvent, 1)))
4650
+ .find((record) => record.id === parked.id);
4651
+ return closed || { ...parked, status, note: note || null, tsEnd: endEvent.ts };
4652
+ }
4653
+
4654
+ function migrationDecisionFiles(directory) {
4655
+ if (!fs.existsSync(directory)) return [];
4656
+ return fs.readdirSync(directory, { withFileTypes: true })
4657
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
4658
+ .map((entry) => {
4659
+ const file = path.join(directory, entry.name);
4660
+ return { name: entry.name, file, bytes: fs.readFileSync(file) };
4661
+ })
4662
+ .sort((left, right) => left.name.localeCompare(right.name));
4663
+ }
4664
+
4665
+ function migrationMadrManifest(decisions) {
4666
+ return decisions.map((decision) => ({
4667
+ name: decision.name,
4668
+ sha256: crypto.createHash('sha256').update(decision.bytes).digest('hex'),
4669
+ bytes: decision.bytes.length,
4670
+ }));
4671
+ }
4672
+
4673
+ function migrationSourceIdentity(snapshot) {
4674
+ return {
4675
+ log: encodeMigrationIdentityPath(snapshot.sourceLog),
4676
+ decisions: encodeMigrationIdentityPath(snapshot.sourceDecisions),
4677
+ logPresent: snapshot.sourceLogPresent,
4678
+ };
4679
+ }
4680
+
4681
+ function migrationSourceMatchesSnapshot(source, snapshot) {
4682
+ return (
4683
+ source.logPresent === snapshot.sourceLogPresent &&
4684
+ sameResolvedPath(resolveMigrationIdentityPath(source.log), snapshot.sourceLog) &&
4685
+ sameResolvedPath(resolveMigrationIdentityPath(source.decisions), snapshot.sourceDecisions)
4686
+ );
4687
+ }
4688
+
4689
+ function manifestDecisionId(name) {
4690
+ const match = name.match(/^(\d+)-/);
4691
+ if (!match) return null;
4692
+ try {
4693
+ return normalizeDecisionId(match[1]);
4694
+ } catch {
4695
+ return null;
4696
+ }
4697
+ }
4698
+
4699
+ function latestMigrationReconciledHashes(events) {
4700
+ const latestReconciledHash = new Map();
4701
+ for (const record of fold(events)) {
4702
+ for (const update of record.decisionUpdates) {
4703
+ if (update.type === 'decision_reconcile_commit' && typeof update.fileHash === 'string') {
4704
+ latestReconciledHash.set(update.decisionId, update.fileHash);
4705
+ }
4706
+ }
4707
+ }
4708
+ return latestReconciledHash;
4709
+ }
4710
+
4711
+ function validateMigrationMadrManifest(directory, manifest, events = []) {
4712
+ const latestReconciledHash = latestMigrationReconciledHashes(events);
4713
+ for (const entry of manifest) {
4714
+ const file = path.join(directory, entry.name);
4715
+ if (!fs.existsSync(file)) fail(`migrated MADR is missing: ${entry.name}`);
4716
+ const bytes = fs.readFileSync(file);
4717
+ const hash = crypto.createHash('sha256').update(bytes).digest('hex');
4718
+ const decisionId = manifestDecisionId(entry.name);
4719
+ const reconciledHash = decisionId ? latestReconciledHash.get(decisionId) : null;
4720
+ const expected = reconciledHash || entry.sha256;
4721
+ if (hash !== expected || (!reconciledHash && bytes.length !== entry.bytes)) {
4722
+ fail(`migrated MADR does not match the migration manifest: ${entry.name}`);
4723
+ }
4724
+ }
4725
+ }
4726
+
4727
+ function migrationSourceContent(sourceLog, sourceDecisions) {
4728
+ const resolvedLog = canonicalPath(sourceLog);
4729
+ const resolvedDecisions = canonicalPath(sourceDecisions);
4730
+ const decisions = migrationDecisionFiles(resolvedDecisions);
4731
+ const sourceLogPresent = fs.existsSync(resolvedLog);
4732
+ const rawLog = sourceLogPresent ? fs.readFileSync(resolvedLog, 'utf8') : '';
4733
+ return {
4734
+ sourceLog: resolvedLog,
4735
+ sourceDecisions: resolvedDecisions,
4736
+ decisions,
4737
+ sourceLogPresent,
4738
+ rawLog,
4739
+ };
4740
+ }
4741
+
4742
+ function hashMigrationSourceContent(content) {
4743
+ const hash = crypto.createHash('sha256');
4744
+ hash.update(JSON.stringify(migrationSourceIdentity(content)));
4745
+ hash.update('\0');
4746
+ hash.update(content.rawLog);
4747
+ const legacyHash = crypto.createHash('sha256');
4748
+ legacyHash.update(content.sourceLog);
4749
+ legacyHash.update('\0');
4750
+ legacyHash.update(content.rawLog);
4751
+ for (const decision of content.decisions) {
4752
+ hash.update('\0');
4753
+ hash.update(decision.name);
4754
+ hash.update('\0');
4755
+ hash.update(decision.bytes);
4756
+ legacyHash.update('\0');
4757
+ legacyHash.update(decision.name);
4758
+ legacyHash.update('\0');
4759
+ legacyHash.update(decision.bytes);
4760
+ }
4761
+ return {
4762
+ sourceFingerprint: hash.digest('hex'),
4763
+ legacySourceFingerprint: legacyHash.digest('hex'),
4764
+ };
4765
+ }
4766
+
4767
+ function migrationSourceSnapshot(flags = {}, { storedSource } = {}) {
4768
+ const paths = migrationPaths(flags, { storedSource });
4769
+ const content = migrationSourceContent(paths.sourceLog, paths.sourceDecisions);
4770
+ if (!content.sourceLogPresent && content.decisions.length === 0) {
4771
+ fail(`v1 source not found: ${paths.sourceLog} or ${paths.sourceDecisions}`);
4772
+ }
4773
+ const park = legacyParkFile();
4774
+ if (park && fs.existsSync(park)) {
4775
+ fail(
4776
+ 'v1 migration requires no open intent; close the parked v1 intent first:\n' +
4777
+ ' driftseal end --status abandoned --note "close parked v1 intent before migration"'
4778
+ );
4779
+ }
4780
+ const records = fold(
4781
+ parseJsonlRecords(content.rawLog, content.sourceLog, { allowLegacy: true })
4782
+ .map((record) => record.event)
4783
+ );
4784
+ if (records.some((record) => record.logVersion !== 1)) {
4785
+ fail('migration source is not a v1 intent log');
4786
+ }
4787
+ const open = records.filter((record) => record.status === 'in_progress');
4788
+ if (open.length > 0) {
4789
+ fail(`v1 migration requires every intent to be closed; still open: ${open.map((record) => record.id).join(', ')}`);
4790
+ }
4791
+ return {
4792
+ ...paths,
4793
+ ...content,
4794
+ records,
4795
+ ...hashMigrationSourceContent(content),
4796
+ };
4797
+ }
4798
+
4799
+ function migrationInspection(snapshot) {
4800
+ return {
4801
+ format: MIGRATION_PLAN_FORMAT,
4802
+ sourceFingerprint: snapshot.sourceFingerprint,
4803
+ source: {
4804
+ log: snapshot.sourceLog,
4805
+ decisions: snapshot.sourceDecisions,
4806
+ logPresent: snapshot.sourceLogPresent,
4807
+ },
4808
+ destination: snapshot.destination,
4809
+ records: snapshot.records.map(publicOutcome),
4810
+ decisions: snapshot.decisions.map((decision) => ({ name: decision.name })),
4811
+ planSchema: {
4812
+ format: MIGRATION_PLAN_FORMAT,
4813
+ sourceFingerprint: snapshot.sourceFingerprint,
4814
+ groups: [
4815
+ {
4816
+ outcome: 'One coherent delivered outcome',
4817
+ summary: 'What the grouped v1 work ultimately achieved',
4818
+ sourceIds: ['YYYY-MM-DD-NNN'],
4819
+ },
4820
+ ],
4821
+ excluded: [
4822
+ {
4823
+ sourceId: 'YYYY-MM-DD-NNN',
4824
+ reason: 'Only already-reclaimed v1 noise may be excluded from visible outcomes',
4825
+ },
4826
+ ],
4827
+ },
4828
+ };
4829
+ }
4830
+
4831
+ function readMigrationPlan(file, inline) {
4832
+ if (!file && !inline) fail('migration apply requires --plan <file>');
4833
+ if (file && inline) fail('migration apply accepts only one of --plan or structured plan input');
4834
+ let plan;
4835
+ try {
4836
+ plan = JSON.parse(inline || fs.readFileSync(path.resolve(file), 'utf8'));
4837
+ } catch (error) {
4838
+ fail(`cannot read migration plan: ${error.message}`);
4839
+ }
4840
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) fail('migration plan must be a JSON object');
4841
+ return plan;
4842
+ }
4843
+
4844
+ function migrationFingerprints(snapshot) {
4845
+ return [snapshot.sourceFingerprint, snapshot.legacySourceFingerprint];
4846
+ }
4847
+
4848
+ function migrationPlanDigest(sourceFingerprint, groups, excluded) {
4849
+ return contentHash(JSON.stringify({
4850
+ format: MIGRATION_PLAN_FORMAT,
4851
+ sourceFingerprint,
4852
+ groups,
4853
+ excluded,
4854
+ }));
4855
+ }
4856
+
4857
+ function existingMigrationMatchesPlan(existing, validated, snapshot) {
4858
+ if (!migrationFingerprints(snapshot).includes(existing.sourceFingerprint)) return false;
4859
+ if (existing.planDigest === validated.planDigest) return true;
4860
+ return existing.planDigest === migrationPlanDigest(
4861
+ snapshot.legacySourceFingerprint,
4862
+ validated.groups,
4863
+ validated.excluded
4864
+ );
4865
+ }
4866
+
4867
+ function validateMigrationPlan(plan, snapshot) {
4868
+ if (plan.format !== MIGRATION_PLAN_FORMAT) fail(`migration plan format must be ${MIGRATION_PLAN_FORMAT}`);
4869
+ if (!migrationFingerprints(snapshot).includes(plan.sourceFingerprint)) {
4870
+ fail('migration plan source fingerprint does not match the current v1 source');
4871
+ }
4872
+ if (!Array.isArray(plan.groups)) fail('migration plan groups must be an array');
4873
+ if (!Array.isArray(plan.excluded)) fail('migration plan excluded must be an array');
4874
+ const byId = new Map(snapshot.records.map((record) => [record.id, record]));
4875
+ const excluded = new Map();
4876
+ for (const item of plan.excluded) {
4877
+ if (!item || typeof item.sourceId !== 'string' || typeof item.reason !== 'string' || !item.reason.trim()) {
4878
+ fail('each migration exclusion requires sourceId and a non-empty reason');
4879
+ }
4880
+ const record = byId.get(item.sourceId);
4881
+ if (!record) fail(`migration exclusion references unknown v1 intent: ${item.sourceId}`);
4882
+ if (!record.reclaimed) fail(`only reclaimed v1 intents may be excluded: ${item.sourceId}`);
4883
+ if (excluded.has(item.sourceId)) fail(`duplicate migration exclusion: ${item.sourceId}`);
4884
+ excluded.set(item.sourceId, item.reason.trim());
4885
+ }
4886
+ const expected = snapshot.records.filter((record) => !excluded.has(record.id)).map((record) => record.id);
4887
+ const actual = [];
4888
+ const groups = plan.groups.map((group, index) => {
4889
+ if (!group || typeof group.outcome !== 'string' || !group.outcome.trim() ||
4890
+ typeof group.summary !== 'string' || !group.summary.trim() ||
4891
+ !Array.isArray(group.sourceIds) || group.sourceIds.length === 0) {
4892
+ fail(`migration group ${index + 1} requires outcome, summary, and sourceIds`);
4893
+ }
4894
+ const sourceIds = group.sourceIds.map(String);
4895
+ for (const id of sourceIds) {
4896
+ if (!byId.has(id)) fail(`migration group ${index + 1} references unknown v1 intent: ${id}`);
4897
+ if (excluded.has(id)) fail(`migration source ${id} is both grouped and excluded`);
4898
+ actual.push(id);
4899
+ }
4900
+ return { outcome: group.outcome.trim(), summary: group.summary.trim(), sourceIds };
4901
+ });
4902
+ if (actual.length !== new Set(actual).size) fail('migration groups contain a duplicate v1 intent');
4903
+ if (!isDeepStrictEqual(actual, expected)) {
4904
+ fail('migration groups must form an ordered, complete partition of all non-excluded v1 intents');
4905
+ }
4906
+ const excludedItems = [...excluded].map(([sourceId, reason]) => ({ sourceId, reason }));
4907
+ return {
4908
+ groups,
4909
+ excluded: excludedItems,
4910
+ sourceFingerprint: snapshot.sourceFingerprint,
4911
+ planDigest: migrationPlanDigest(snapshot.sourceFingerprint, groups, excludedItems),
4912
+ };
4913
+ }
4914
+
4915
+ function storedV2Event(event) {
4916
+ return { logVersion: LOG_VERSION, schemaVersion: EVENT_SCHEMA_VERSION, ...event };
4917
+ }
4918
+
4919
+ function migrationImportEvent(snapshot, validated, group, events) {
4920
+ const byId = new Map(snapshot.records.map((record) => [record.id, record]));
4921
+ const sources = group.sourceIds.map((id) => byId.get(id));
4922
+ const first = sources[0];
4923
+ const last = sources.at(-1);
4924
+ const date = /^\d{4}-\d{2}-\d{2}/.test(first.tsBegin) ? first.tsBegin.slice(0, 10) : new Date().toISOString().slice(0, 10);
4925
+ const decisions = [...new Set(sources.flatMap((source) => source.decisions))];
4926
+ return storedV2Event({
4927
+ type: 'import',
4928
+ id: nextIdForDate(date, events),
4929
+ ts: new Date().toISOString(),
4930
+ outcome: group.outcome,
4931
+ summary: group.summary,
4932
+ status: last.status,
4933
+ beganAt: first.tsBegin,
4934
+ endedAt: last.tsEnd,
4935
+ decisions,
4936
+ sources: sources.map(publicOutcome),
4937
+ sourceFingerprint: validated.sourceFingerprint,
4938
+ reclaimed: sources.every((source) => source.reclaimed),
4939
+ reclaimReason: sources.every((source) => source.reclaimed)
4940
+ ? sources.map((source) => source.reclaimReason).filter(Boolean).join('; ') || 'reclaimed in v1'
4941
+ : null,
4942
+ reclaimedAt: sources.every((source) => source.reclaimed) ? last.reclaimedAt : null,
4943
+ head: last.endHead || first.beginHead || null,
4944
+ });
4945
+ }
4946
+
4947
+ function migrationMarkerEvent(snapshot, validated) {
4948
+ const byId = new Map(snapshot.records.map((record) => [record.id, record]));
4949
+ return storedV2Event({
4950
+ type: 'migration',
4951
+ id: 'v1-to-v2',
4952
+ ts: new Date().toISOString(),
4953
+ sourceFingerprint: validated.sourceFingerprint,
4954
+ planDigest: validated.planDigest,
4955
+ source: migrationSourceIdentity(snapshot),
4956
+ madrManifest: migrationMadrManifest(snapshot.decisions),
4957
+ excluded: validated.excluded.map((item) => ({
4958
+ ...item,
4959
+ source: publicOutcome(byId.get(item.sourceId)),
4960
+ })),
4961
+ });
4962
+ }
4963
+
4964
+ function migrationEvents(snapshot, validated) {
4965
+ const events = [];
4966
+ for (const group of validated.groups) {
4967
+ events.push(migrationImportEvent(snapshot, validated, group, events));
4968
+ }
4969
+ events.push(migrationMarkerEvent(snapshot, validated));
4970
+ fold(events);
4971
+ return events;
4972
+ }
4973
+
4974
+ function findMigrationEvent(file) {
4975
+ if (!fs.existsSync(file)) return null;
4976
+ let migration = null;
4977
+ for (const event of readEvents({ file, repairTail: false, readOnly: true })) {
4978
+ if (event.type === 'migration' && event.id === 'v1-to-v2') migration = event;
4979
+ }
4980
+ return migration;
4981
+ }
4982
+
4983
+ function validateStagedMigration(directory, snapshot, { allowReconciled = false } = {}) {
4984
+ const stagedLog = path.join(directory, 'outcomes', 'events.jsonl');
4985
+ const events = readEvents({ file: stagedLog, readOnly: true });
4986
+ fold(events);
4987
+ if (allowReconciled) {
4988
+ validateMigrationMadrManifest(
4989
+ path.join(directory, 'madr'),
4990
+ migrationMadrManifest(snapshot.decisions),
4991
+ events
4992
+ );
4993
+ return;
4994
+ }
4995
+ for (const decision of snapshot.decisions) {
4996
+ const staged = path.join(directory, 'madr', decision.name);
4997
+ if (!fs.existsSync(staged) || !fs.readFileSync(staged).equals(decision.bytes)) {
4998
+ fail(`staged MADR does not match v1 byte-for-byte: ${decision.name}`);
4999
+ }
5000
+ }
5001
+ }
5002
+
5003
+ function migrationRefreshPlan(destination, snapshot, validated, events) {
5004
+ const records = fold(events);
5005
+ const existingImports = records.filter((record) => record.imported);
5006
+ const sourceRecords = new Map(snapshot.records.map((record) => [record.id, record]));
5007
+ const importedBySource = new Map();
5008
+ const importedByGroup = new Map();
5009
+ for (const record of existingImports) {
5010
+ const key = JSON.stringify(record.imported.sourceIds);
5011
+ if (importedByGroup.has(key)) fail(`duplicate migrated v1 source group: ${record.imported.sourceIds.join(', ')}`);
5012
+ importedByGroup.set(key, record);
5013
+ for (const sourceId of record.imported.sourceIds) {
5014
+ if (importedBySource.has(sourceId)) fail(`v1 source was imported more than once: ${sourceId}`);
5015
+ importedBySource.set(sourceId, record);
5016
+ }
5017
+ }
5018
+
5019
+ const newGroups = [];
5020
+ for (const group of validated.groups) {
5021
+ const key = JSON.stringify(group.sourceIds);
5022
+ const existing = importedByGroup.get(key);
5023
+ const overlap = group.sourceIds.filter((sourceId) => importedBySource.has(sourceId));
5024
+ if (!existing) {
5025
+ if (overlap.length > 0) {
5026
+ fail(`refreshed migration plan regroups already imported v1 sources: ${overlap.join(', ')}`);
5027
+ }
5028
+ newGroups.push(group);
5029
+ continue;
5030
+ }
5031
+ const sources = group.sourceIds.map((sourceId) => sourceRecords.get(sourceId));
5032
+ if (
5033
+ existing.outcome !== group.outcome ||
5034
+ existing.note !== group.summary ||
5035
+ !isDeepStrictEqual(existing.imported.sources, sources.map(publicOutcome))
5036
+ ) {
5037
+ fail(`refreshed migration plan changes an already imported v1 source group: ${group.sourceIds.join(', ')}`);
5038
+ }
5039
+ }
5040
+ for (const record of existingImports) {
5041
+ if (!validated.groups.some((group) => isDeepStrictEqual(group.sourceIds, record.imported.sourceIds))) {
5042
+ fail(`refreshed migration plan omits an already imported v1 source group: ${record.imported.sourceIds.join(', ')}`);
5043
+ }
5044
+ }
5045
+
5046
+ const latestReconciledHash = latestMigrationReconciledHashes(events);
5047
+ const missingDecisions = [];
5048
+ for (const decision of snapshot.decisions) {
5049
+ const file = path.join(destination, 'madr', decision.name);
5050
+ if (!fs.existsSync(file)) {
5051
+ missingDecisions.push(decision);
5052
+ continue;
5053
+ }
5054
+ const bytes = fs.readFileSync(file);
5055
+ if (bytes.equals(decision.bytes)) continue;
5056
+ const decisionId = manifestDecisionId(decision.name);
5057
+ const hash = crypto.createHash('sha256').update(bytes).digest('hex');
5058
+ if (!decisionId || latestReconciledHash.get(decisionId) !== hash) {
5059
+ fail(`migrated MADR conflicts with the current v1 source: ${decision.name}`);
5060
+ }
5061
+ }
5062
+ return { newGroups, missingDecisions };
5063
+ }
5064
+
5065
+ function refreshMigration(destination, snapshot, validated, existing) {
5066
+ if (existing.source !== undefined && !migrationSourceMatchesSnapshot(existing.source, snapshot)) {
5067
+ fail('staged migration source identity does not match the current v1 source paths');
5068
+ }
5069
+ const destinationLog = path.join(destination, 'outcomes', 'events.jsonl');
5070
+ const events = readEvents({ file: destinationLog, readOnly: true });
5071
+ const refresh = migrationRefreshPlan(destination, snapshot, validated, events);
5072
+ const madrDirectory = path.join(destination, 'madr');
5073
+ ensureDirectoryDurable(madrDirectory);
5074
+ for (const decision of refresh.missingDecisions) {
5075
+ atomicWriteFile(path.join(madrDirectory, decision.name), decision.bytes);
5076
+ }
5077
+ for (const group of refresh.newGroups) {
5078
+ const event = migrationImportEvent(snapshot, validated, group, events);
5079
+ events.push(appendEventTo(destinationLog, event));
5080
+ }
5081
+ fold(events);
5082
+ appendEventTo(destinationLog, migrationMarkerEvent(snapshot, validated));
5083
+ validateStagedMigration(destination, snapshot, { allowReconciled: true });
5084
+ printLine(
5085
+ `refreshed staged v1-to-v2 migration with ${refresh.newGroups.length} additional outcome(s) ` +
5086
+ `and ${refresh.missingDecisions.length} MADR file(s)`
5087
+ );
5088
+ return {
5089
+ changed: true,
5090
+ refreshed: true,
5091
+ destination,
5092
+ sourceFingerprint: snapshot.sourceFingerprint,
5093
+ planDigest: validated.planDigest,
5094
+ importedOutcomes: refresh.newGroups.length,
5095
+ copiedDecisions: refresh.missingDecisions.length,
5096
+ };
5097
+ }
5098
+
5099
+ function applyMigration(snapshot, validated) {
5100
+ const destination = snapshot.destination;
5101
+ const destinationLog = path.join(destination, 'outcomes', 'events.jsonl');
5102
+ if (fs.existsSync(destination)) {
5103
+ const existing = findMigrationEvent(destinationLog);
5104
+ if (existing && existingMigrationMatchesPlan(existing, validated, snapshot)) {
5105
+ validateStagedMigration(destination, snapshot, { allowReconciled: true });
5106
+ const manifest = migrationMadrManifest(snapshot.decisions);
5107
+ const source = migrationSourceIdentity(snapshot);
5108
+ if (existing.source !== undefined && !migrationSourceMatchesSnapshot(existing.source, snapshot)) {
5109
+ fail('staged migration source identity does not match the current v1 source paths');
5110
+ }
5111
+ if (
5112
+ existing.madrManifest === undefined ||
5113
+ existing.source === undefined ||
5114
+ !isDeepStrictEqual(existing.source, source) ||
5115
+ existing.sourceFingerprint !== snapshot.sourceFingerprint ||
5116
+ existing.planDigest !== validated.planDigest
5117
+ ) {
5118
+ appendEventTo(destinationLog, {
5119
+ type: 'migration',
5120
+ id: 'v1-to-v2',
5121
+ ts: new Date().toISOString(),
5122
+ sourceFingerprint: snapshot.sourceFingerprint,
5123
+ planDigest: validated.planDigest,
5124
+ source,
5125
+ madrManifest: manifest,
5126
+ excluded: existing.excluded,
5127
+ });
5128
+ printLine('upgraded the staged v1-to-v2 migration with source identity and a MADR integrity manifest');
5129
+ return {
5130
+ changed: true,
5131
+ upgraded: true,
5132
+ destination,
5133
+ sourceFingerprint: snapshot.sourceFingerprint,
5134
+ planDigest: validated.planDigest,
5135
+ };
5136
+ }
5137
+ if (!isDeepStrictEqual(existing.madrManifest, manifest)) {
5138
+ fail('staged migration MADR manifest does not match the current v1 source');
5139
+ }
5140
+ printLine('v1-to-v2 migration is already staged with the same source and plan');
5141
+ return { changed: false, destination, sourceFingerprint: snapshot.sourceFingerprint, planDigest: validated.planDigest };
5142
+ }
5143
+ if (existing) return refreshMigration(destination, snapshot, validated, existing);
5144
+ fail(`migration destination already exists with different content: ${destination}`);
5145
+ }
5146
+ ensureDirectoryDurable(path.dirname(destination));
5147
+ const temporary = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.migrate-`));
5148
+ try {
5149
+ const outcomeDirectory = path.join(temporary, 'outcomes');
5150
+ const madrDirectory = path.join(temporary, 'madr');
5151
+ ensureDirectoryDurable(outcomeDirectory);
5152
+ ensureDirectoryDurable(madrDirectory);
5153
+ writeJsonl(path.join(outcomeDirectory, 'events.jsonl'), migrationEvents(snapshot, validated).map((event) => ({ event })));
5154
+ for (const decision of snapshot.decisions) {
5155
+ fs.copyFileSync(decision.file, path.join(madrDirectory, decision.name));
5156
+ }
5157
+ validateStagedMigration(temporary, snapshot);
5158
+ fs.renameSync(temporary, destination);
5159
+ fsyncDirectory(path.dirname(destination));
5160
+ } catch (error) {
5161
+ try { fs.rmSync(temporary, { recursive: true, force: true }); } catch {}
5162
+ throw error;
5163
+ }
5164
+ const initResult = commands.init([]);
5165
+ printLine(`staged v1-to-v2 migration at ${destination}`);
5166
+ printLine('DriftSeal did not delete v1 data; review the staged outcome log, then remove the old paths manually.');
5167
+ return {
5168
+ changed: true,
5169
+ destination,
5170
+ sourceFingerprint: validated.sourceFingerprint,
5171
+ planDigest: validated.planDigest,
5172
+ importedOutcomes: validated.groups.length,
5173
+ excluded: validated.excluded.length,
5174
+ init: initResult,
5175
+ };
5176
+ }
5177
+
5178
+ function gitTracksPath(target) {
5179
+ if (!isGitWorkTree()) return false;
5180
+ const root = gitWorktreeRoot();
5181
+ if (!root) return false;
5182
+ const resolved = canonicalPath(target);
5183
+ if (canonicalPath(root) !== resolved && !pathContains(root, resolved)) return false;
5184
+ const relative = path.relative(root, resolved).split(path.sep).join('/');
5185
+ const listing = gitCaptureRaw(
5186
+ ['ls-files', '-z', '--', `:(literal)${relative}`, `:(literal)${relative}/`],
5187
+ root
5188
+ );
5189
+ if (!listing) return false;
5190
+ return listing.split('\0').some((file) => file.length > 0);
5191
+ }
5192
+
5193
+ function displayV1RemovalPath(target) {
5194
+ const cwd = canonicalPath(process.cwd());
5195
+ const resolved = canonicalPath(target);
5196
+ const relative = path.relative(cwd, resolved).split(path.sep).join('/');
5197
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative;
5198
+ return resolved;
5199
+ }
5200
+
5201
+ function v1RemovalHint(snapshot) {
5202
+ const items = [];
5203
+ if (snapshot.sourceLogPresent && fs.existsSync(path.dirname(snapshot.sourceLog))) {
5204
+ items.push(path.dirname(snapshot.sourceLog));
5205
+ }
5206
+ if (fs.existsSync(snapshot.sourceDecisions)) items.push(snapshot.sourceDecisions);
5207
+ if (items.length === 0) return null;
5208
+ const tracked = items.every((item) => gitTracksPath(item));
5209
+ const shown = items.map((item) => {
5210
+ const relative = displayV1RemovalPath(item);
5211
+ return /[\s"'$]/.test(relative) ? JSON.stringify(relative) : relative;
5212
+ });
5213
+ const command = tracked && isGitWorkTree()
5214
+ ? `git rm -r -- ${shown.join(' ')}`
5215
+ : `rm -rf -- ${shown.join(' ')}`;
5216
+ return `after explicit user approval, remove the v1 source paths manually: ${command}`;
5217
+ }
5218
+
5219
+ function checkMigration(snapshot, { sourceMissing = false } = {}) {
5220
+ const destinationLog = path.join(snapshot.destination, 'outcomes', 'events.jsonl');
5221
+ const migration = findMigrationEvent(destinationLog);
5222
+ if (!migration) fail(`no staged v1-to-v2 migration found at ${snapshot.destination}`);
5223
+ const destinationEvents = readEvents({ file: destinationLog, readOnly: true });
5224
+ fold(destinationEvents);
5225
+ const migratedMadr = path.join(snapshot.destination, 'madr');
5226
+ if (sourceMissing) {
5227
+ if (migration.source === undefined) {
5228
+ fail(
5229
+ 'staged migration has no source path identity; restore the v1 source and re-run apply with the approved plan before deleting it'
5230
+ );
5231
+ }
5232
+ if (migration.madrManifest === undefined) {
5233
+ fail(
5234
+ 'staged migration has no MADR integrity manifest; restore the v1 source and re-run apply with the approved plan before deleting it'
5235
+ );
5236
+ }
5237
+ validateMigrationMadrManifest(migratedMadr, migration.madrManifest, destinationEvents);
5238
+ } else {
5239
+ const expectedManifest = migrationMadrManifest(snapshot.decisions);
5240
+ validateMigrationMadrManifest(migratedMadr, expectedManifest, destinationEvents);
5241
+ if (migration.madrManifest !== undefined) {
5242
+ if (!isDeepStrictEqual(migration.madrManifest, expectedManifest)) {
5243
+ fail('staged migration MADR manifest does not match the current v1 source');
5244
+ }
5245
+ validateMigrationMadrManifest(migratedMadr, migration.madrManifest, destinationEvents);
5246
+ }
5247
+ }
5248
+ if (migration.source !== undefined && !migrationSourceMatchesSnapshot(migration.source, snapshot)) {
5249
+ fail('staged migration source identity does not match the v1 source paths used for check');
5250
+ }
5251
+ if (
5252
+ !sourceMissing &&
5253
+ ![snapshot.sourceFingerprint, snapshot.legacySourceFingerprint].includes(migration.sourceFingerprint)
5254
+ ) {
5255
+ fail('staged migration no longer matches the v1 source fingerprint');
5256
+ }
5257
+ if (sourceMissing) {
5258
+ printLine('v1-to-v2 migration complete; v1 source paths are absent and the v2 log is valid');
5259
+ } else {
5260
+ printLine('v1-to-v2 migration is valid and staged side-by-side with v1');
5261
+ const hint = v1RemovalHint(snapshot);
5262
+ if (hint) printLine(hint);
5263
+ }
5264
+ return {
5265
+ valid: true,
5266
+ complete: sourceMissing,
5267
+ destination: snapshot.destination,
5268
+ sourceFingerprint: migration.sourceFingerprint,
5269
+ planDigest: migration.planDigest,
5270
+ };
5271
+ }
5272
+
4172
5273
  const commands = {
4173
5274
  begin(argv) {
4174
5275
  const { positionals, flags } = parseArgs(argv, {
@@ -4177,8 +5278,8 @@ const commands = {
4177
5278
  decision: 'multiple',
4178
5279
  force: 'boolean',
4179
5280
  }, 'begin');
4180
- const intent = positionals.join(' ').trim();
4181
- if (!intent) {
5281
+ const outcome = positionals.join(' ').trim();
5282
+ if (!outcome) {
4182
5283
  fail(usageFor('begin'));
4183
5284
  }
4184
5285
  const acceptance = [...new Set((flags.accept || []).map((criterion) => criterion.trim()))];
@@ -4196,18 +5297,18 @@ const commands = {
4196
5297
 
4197
5298
  const events = readEvents({ repairTail: true });
4198
5299
  const records = fold(events);
4199
- // A parked intent and a merged-in one can both be open; --force clears every one of them.
5300
+ // A parked outcome and a merged-in one can both be open; --force clears every one of them.
4200
5301
  const open = records.filter((record) => record.status === 'in_progress');
4201
5302
  if (open.length > 1 && !flags.force) {
4202
5303
  fail(
4203
- `multiple intents in progress: ${open.map((record) => record.id).join(', ')}\n` +
5304
+ `multiple outcomes in progress: ${open.map((record) => record.id).join(', ')}\n` +
4204
5305
  'resolve them with driftseal absorb --abandon-ours or --abandon-theirs, ' +
4205
5306
  'or re-run with --force to abandon all of them'
4206
5307
  );
4207
5308
  }
4208
5309
  if (open.length === 1 && !flags.force) {
4209
5310
  fail(
4210
- `intent ${open[0].id} is still in_progress: "${open[0].intent}"\n` +
5311
+ `outcome ${open[0].id} is still in_progress: "${open[0].outcome}"\n` +
4211
5312
  `end it first (driftseal end) or re-run with --force to abandon it`
4212
5313
  );
4213
5314
  }
@@ -4227,7 +5328,7 @@ const commands = {
4227
5328
  type: 'begin',
4228
5329
  id,
4229
5330
  ts: new Date().toISOString(),
4230
- intent,
5331
+ outcome,
4231
5332
  acceptance,
4232
5333
  verify: flags.verify || null,
4233
5334
  decisions,
@@ -4235,7 +5336,44 @@ const commands = {
4235
5336
  }));
4236
5337
  const record = fold(events).find((candidate) => candidate.id === id);
4237
5338
  printLine(id);
4238
- return publicIntent(record);
5339
+ return publicOutcome(record);
5340
+ },
5341
+
5342
+ extend(argv) {
5343
+ const { positionals, flags } = parseArgs(argv, {
5344
+ accept: 'multiple',
5345
+ verify: '-v',
5346
+ decision: 'multiple',
5347
+ }, 'extend');
5348
+ const extension = positionals.join(' ').trim();
5349
+ if (!extension) fail(usageFor('extend'));
5350
+ const acceptance = [...new Set((flags.accept || []).map((criterion) => criterion.trim()))];
5351
+ if (acceptance.some((criterion) => criterion.length === 0)) {
5352
+ fail('--accept requires a non-empty observable result');
5353
+ }
5354
+ if (acceptance.length > 0 && (!flags.verify || flags.verify.trim().length === 0)) {
5355
+ fail('extending acceptance requires --verify with a cumulative verification command');
5356
+ }
5357
+ const events = readEvents({ repairTail: true });
5358
+ const current = openOutcome(fold(events));
5359
+ if (!current) fail('no outcome in progress; begin one before extending it');
5360
+ const requestedDecisions = flags.decision || [];
5361
+ const index = requestedDecisions.length > 0 ? decisionIndex() : [];
5362
+ const decisions = [...new Set(requestedDecisions.map((id) => findDecision(id, index).id))]
5363
+ .filter((id) => !current.decisions.includes(id));
5364
+ events.push(appendEvent({
5365
+ type: 'extend',
5366
+ id: current.id,
5367
+ ts: new Date().toISOString(),
5368
+ extension,
5369
+ acceptance: acceptance.filter((criterion) => !current.acceptance.includes(criterion)),
5370
+ verify: flags.verify || null,
5371
+ decisions,
5372
+ head: gitCapture(['rev-parse', 'HEAD']),
5373
+ }));
5374
+ const record = fold(events).find((candidate) => candidate.id === current.id);
5375
+ printLine(`${current.id} extended`);
5376
+ return publicOutcome(record);
4239
5377
  },
4240
5378
 
4241
5379
  verify(argv) {
@@ -4260,16 +5398,26 @@ const commands = {
4260
5398
  fail(`invalid status "${status}" (expected: ${END_STATUSES.join(', ')})`);
4261
5399
  }
4262
5400
 
5401
+ const parkedV1 = legacyParkedIntent();
5402
+ if (parkedV1) {
5403
+ if (positionals.length > 0 && positionals[0] !== parkedV1.id) {
5404
+ fail(`unknown outcome id: ${positionals[0]}`);
5405
+ }
5406
+ const closed = closeLegacyParkedIntent(status, flags.note);
5407
+ printLine(`${closed.id} ${status}`);
5408
+ return publicOutcome(closed);
5409
+ }
5410
+
4263
5411
  let events = readEvents({ repairTail: true });
4264
5412
  let records = fold(events);
4265
5413
  let target;
4266
5414
  if (positionals.length > 0) {
4267
5415
  target = records.find((r) => r.id === positionals[0]);
4268
- if (!target) fail(`unknown intent id: ${positionals[0]}`);
4269
- if (target.status !== 'in_progress') fail(`intent ${target.id} already closed (${target.status})`);
5416
+ if (!target) fail(`unknown outcome id: ${positionals[0]}`);
5417
+ if (target.status !== 'in_progress') fail(`outcome ${target.id} already closed (${target.status})`);
4270
5418
  } else {
4271
- target = openIntent(records);
4272
- if (!target) fail('no intent in progress; nothing to end');
5419
+ target = openOutcome(records);
5420
+ if (!target) fail('no outcome in progress; nothing to end');
4273
5421
  }
4274
5422
 
4275
5423
  let completionWorkspace = null;
@@ -4277,14 +5425,15 @@ const commands = {
4277
5425
  if (status === 'completed' && target.acceptance.length > 0) {
4278
5426
  if (!target.verification || !target.verification.passed) {
4279
5427
  fail(
4280
- `cannot complete acceptance-bound intent ${target.id} without successful machine verification; ` +
5428
+ `cannot complete acceptance-bound outcome ${target.id} without successful machine verification; ` +
4281
5429
  'run: driftseal verify'
4282
5430
  );
4283
5431
  }
4284
5432
  completionWorkspace = workspaceFingerprint();
4285
- if (completionWorkspace !== target.verification.workspace) {
5433
+ if (completionWorkspace !== target.verification.workspace ||
5434
+ target.verification.contractHash !== target.contractHash) {
4286
5435
  fail(
4287
- `cannot complete acceptance-bound intent ${target.id}: workspace changed after machine verification; ` +
5436
+ `cannot complete acceptance-bound outcome ${target.id}: contract or workspace changed after machine verification; ` +
4288
5437
  'run: driftseal verify'
4289
5438
  );
4290
5439
  }
@@ -4301,7 +5450,7 @@ const commands = {
4301
5450
  );
4302
5451
  const record = fold(events).find((candidate) => candidate.id === target.id);
4303
5452
  printLine(`${target.id} ${terminalStatus}`);
4304
- return publicIntent(record);
5453
+ return publicOutcome(record);
4305
5454
  }
4306
5455
 
4307
5456
  if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
@@ -4331,7 +5480,7 @@ const commands = {
4331
5480
  }
4332
5481
  if (problems.length > 0) {
4333
5482
  fail(
4334
- `cannot close linked intent ${target.id} as ${status}:\n` +
5483
+ `cannot close linked outcome ${target.id} as ${status}:\n` +
4335
5484
  problems.map((problem) => ` - ${problem}`).join('\n') +
4336
5485
  `\nrun: driftseal decision update <id> --note "<what changed or was confirmed>"`
4337
5486
  );
@@ -4347,29 +5496,40 @@ const commands = {
4347
5496
  verifyResult: flags['verify-result'] || null,
4348
5497
  verificationId: completionVerificationId,
4349
5498
  workspace: completionWorkspace,
5499
+ contractHash: target.contractHash,
4350
5500
  head: gitCapture(['rev-parse', 'HEAD']),
4351
5501
  }));
4352
5502
  const record = fold(events).find((candidate) => candidate.id === target.id);
4353
5503
  printLine(`${target.id} ${status}`);
4354
- return publicIntent(record);
5504
+ return publicOutcome(record);
4355
5505
  },
4356
5506
 
4357
5507
  status(argv, { readOnly = false } = {}) {
4358
5508
  const { positionals } = parseArgs(argv, {}, 'status');
4359
5509
  if (positionals.length > 0) fail(usageFor('status'));
4360
- const open = openIntent(fold(readEvents({ repairTail: true, readOnly })));
5510
+ const parkedV1 = legacyParkedIntent();
5511
+ if (parkedV1) {
5512
+ printLine(render(parkedV1));
5513
+ printLine('parked v1 intent; close it with: driftseal end --status abandoned --note "close parked v1 intent before migration"');
5514
+ return publicOutcome(parkedV1);
5515
+ }
5516
+ const open = openOutcome(fold(readEvents({ repairTail: true, readOnly })));
4361
5517
  if (!open) {
4362
- printLine('no intent in progress');
5518
+ printLine('no outcome in progress');
4363
5519
  return null;
4364
5520
  }
4365
5521
  printLine(render(open));
4366
- return publicIntent(open);
5522
+ return publicOutcome(open);
4367
5523
  },
4368
5524
 
4369
5525
  log(argv, { readOnly = false } = {}) {
4370
5526
  const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' }, 'log');
4371
5527
  if (positionals.length > 0) fail(usageFor('log'));
4372
5528
  let records = fold(readEvents({ repairTail: true, readOnly }));
5529
+ const parkedV1 = legacyParkedIntent();
5530
+ if (parkedV1 && !records.some((record) => record.id === parkedV1.id && record.status === 'in_progress')) {
5531
+ records = [...records, parkedV1];
5532
+ }
4373
5533
  if (!flags.all) records = records.filter((record) => !record.reclaimed);
4374
5534
  if (flags.last) {
4375
5535
  const n = positiveInteger(flags.last, '--last');
@@ -4380,7 +5540,7 @@ const commands = {
4380
5540
  return [];
4381
5541
  }
4382
5542
  printLine(records.map(render).join('\n\n'));
4383
- return records.map(publicIntent);
5543
+ return records.map(publicOutcome);
4384
5544
  },
4385
5545
 
4386
5546
  reclaim(argv) {
@@ -4405,16 +5565,16 @@ const commands = {
4405
5565
  const ids = [...new Set(positionals)];
4406
5566
  targets = ids.map((id) => {
4407
5567
  const record = records.find((candidate) => candidate.id === id);
4408
- if (!record) fail(`unknown intent id: ${id}`);
5568
+ if (!record) fail(`unknown outcome id: ${id}`);
4409
5569
  if (record.status === 'in_progress') {
4410
- fail(`cannot reclaim intent ${id} while it is in_progress`);
5570
+ fail(`cannot reclaim outcome ${id} while it is in_progress`);
4411
5571
  }
4412
- if (record.reclaimed) fail(`intent ${id} is already reclaimed`);
5572
+ if (record.reclaimed) fail(`outcome ${id} is already reclaimed`);
4413
5573
  const routine = ['failed', 'abandoned'].includes(record.status) &&
4414
5574
  record.decisions.length === 0;
4415
5575
  if (!routine && !flags.force) {
4416
5576
  fail(
4417
- `intent ${id} is ${record.status}` +
5577
+ `outcome ${id} is ${record.status}` +
4418
5578
  (record.decisions.length > 0 ? ' and linked to decisions' : '') +
4419
5579
  '; re-run with --force to reclaim it anyway'
4420
5580
  );
@@ -4422,7 +5582,7 @@ const commands = {
4422
5582
  return record;
4423
5583
  });
4424
5584
  } else {
4425
- if (flags.force) fail('--force requires explicit intent ids');
5585
+ if (flags.force) fail('--force requires explicit outcome ids');
4426
5586
  const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
4427
5587
  targets = records.filter(
4428
5588
  (record) =>
@@ -4433,14 +5593,14 @@ const commands = {
4433
5593
  Date.parse(record.tsEnd) < cutoff
4434
5594
  );
4435
5595
  if (targets.length === 0) {
4436
- printLine('no reclaimable intents');
5596
+ printLine('no reclaimable outcomes');
4437
5597
  return [];
4438
5598
  }
4439
5599
  }
4440
5600
 
4441
5601
  if (flags['dry-run']) {
4442
- printLine(targets.map((record) => `${record.id} ${record.status} — ${record.intent}`).join('\n'));
4443
- return targets.map(publicIntent);
5602
+ printLine(targets.map((record) => `${record.id} ${record.status} — ${record.outcome}`).join('\n'));
5603
+ return targets.map(publicOutcome);
4444
5604
  }
4445
5605
 
4446
5606
  let events = readEvents({ repairTail: true });
@@ -4458,7 +5618,7 @@ const commands = {
4458
5618
  targets.some((target) => target.id === record.id)
4459
5619
  );
4460
5620
  printLine(targets.map((record) => `${record.id} reclaimed`).join('\n'));
4461
- return reclaimed.map(publicIntent);
5621
+ return reclaimed.map(publicOutcome);
4462
5622
  },
4463
5623
 
4464
5624
  unreclaim(argv) {
@@ -4469,8 +5629,8 @@ const commands = {
4469
5629
  }
4470
5630
  const events = readEvents({ repairTail: true });
4471
5631
  const record = fold(events).find((candidate) => candidate.id === positionals[0]);
4472
- if (!record) fail(`unknown intent id: ${positionals[0]}`);
4473
- if (!record.reclaimed) fail(`intent ${positionals[0]} is not reclaimed`);
5632
+ if (!record) fail(`unknown outcome id: ${positionals[0]}`);
5633
+ if (!record.reclaimed) fail(`outcome ${positionals[0]} is not reclaimed`);
4474
5634
  events.push(
4475
5635
  appendEvent({
4476
5636
  type: 'unreclaim',
@@ -4481,7 +5641,7 @@ const commands = {
4481
5641
  );
4482
5642
  const restored = fold(events).find((candidate) => candidate.id === record.id);
4483
5643
  printLine(`${record.id} unreclaimed`);
4484
- return publicIntent(restored);
5644
+ return publicOutcome(restored);
4485
5645
  },
4486
5646
 
4487
5647
  decision(argv) {
@@ -4521,6 +5681,7 @@ const commands = {
4521
5681
  options: flags.option || [],
4522
5682
  consequences: flags.consequence || [],
4523
5683
  });
5684
+ ensureV2OutcomeLogExists();
4524
5685
  ensureDirectoryDurable(decisionDir());
4525
5686
  atomicCreateFile(path.join(decisionDir(), file), content);
4526
5687
  const decision = findDecision(String(id));
@@ -4537,22 +5698,22 @@ const commands = {
4537
5698
 
4538
5699
  let events = readEvents({ repairTail: true });
4539
5700
  let records = fold(events);
4540
- let intent = openIntent(records);
4541
- if (!intent) fail('decision update requires an intent in progress');
4542
- events = recoverPendingReconciliations(events, intent.id);
5701
+ let outcome = openOutcome(records);
5702
+ if (!outcome) fail('decision update requires an outcome in progress');
5703
+ events = recoverPendingReconciliations(events, outcome.id);
4543
5704
  records = fold(events);
4544
- intent = openIntent(records);
5705
+ outcome = openOutcome(records);
4545
5706
  const index = decisionIndex();
4546
5707
  const decision = findDecision(positionals[0], index);
4547
- if (!intent.decisions.includes(decision.id)) {
4548
- fail(`decision ${decision.id} is not linked to intent ${intent.id}; declare it with driftseal begin --decision ${decision.id}`);
5708
+ if (!outcome.decisions.includes(decision.id)) {
5709
+ fail(`decision ${decision.id} is not linked to outcome ${outcome.id}; declare it with driftseal begin or extend --decision ${decision.id}`);
4549
5710
  }
4550
5711
 
4551
5712
  const status = (flags.status || decision.status).toLowerCase();
4552
5713
  if (!DECISION_STATUSES.includes(status)) {
4553
5714
  fail(`invalid decision status "${status}" (expected: ${DECISION_STATUSES.join(', ')})`);
4554
5715
  }
4555
- const update = prepareDecisionReconciliation(decision, intent.id, status, note);
5716
+ const update = prepareDecisionReconciliation(decision, outcome.id, status, note);
4556
5717
  const { target, content, ...prepareEvent } = update;
4557
5718
  appendEvent(prepareEvent);
4558
5719
  if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_RECONCILIATION_PREPARE === '1') {
@@ -4564,7 +5725,7 @@ const commands = {
4564
5725
  }
4565
5726
  appendEvent(reconciliationEvent('decision_reconcile_commit', update));
4566
5727
  const reconciled = findDecision(decision.id);
4567
- printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
5728
+ printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${outcome.id})`);
4568
5729
  return publicDecision(reconciled, { includeContent: true });
4569
5730
  }
4570
5731
 
@@ -4662,6 +5823,60 @@ const commands = {
4662
5823
  return absorbLogs(positionals[0], flags.decisions, { abandon, dryRun });
4663
5824
  },
4664
5825
 
5826
+ migrate(argv) {
5827
+ const [route, action, ...rest] = argv;
5828
+ if (route === '--help' || route === '-h' ||
5829
+ (route === 'v1-to-v2' && (action === '--help' || action === '-h'))) {
5830
+ throw new HelpRequested('migrate');
5831
+ }
5832
+ if (route !== 'v1-to-v2' || !['inspect', 'apply', 'check'].includes(action)) {
5833
+ fail(usageFor('migrate'));
5834
+ }
5835
+ const { positionals, flags } = parseArgs(rest, {
5836
+ 'source-log': 'single',
5837
+ 'source-decisions': 'single',
5838
+ destination: 'single',
5839
+ plan: 'single',
5840
+ 'plan-json': 'single',
5841
+ json: 'boolean',
5842
+ }, 'migrate');
5843
+ if (positionals.length > 0) fail(usageFor('migrate'));
5844
+ if (action === 'inspect') {
5845
+ if (flags.plan || flags['plan-json']) fail('--plan is only valid with migration apply');
5846
+ const inspection = migrationInspection(migrationSourceSnapshot(flags));
5847
+ if (flags.json) printLine(JSON.stringify(inspection, null, 2));
5848
+ else {
5849
+ printLine(`v1 source ${inspection.sourceFingerprint}: ${inspection.records.length} closed intent(s), ${inspection.decisions.length} MADR file(s)`);
5850
+ printLine('Generate a driftseal-v1-to-v2-plan JSON document, then run migrate v1-to-v2 apply --plan <file>.');
5851
+ }
5852
+ return inspection;
5853
+ }
5854
+ if (action === 'apply') {
5855
+ if (flags.json) fail('--json is only valid with migration inspect or check');
5856
+ const destination = canonicalPath(flags.destination || sealRoot());
5857
+ const existing = findMigrationEvent(path.join(destination, 'outcomes', 'events.jsonl'));
5858
+ const snapshot = migrationSourceSnapshot(flags, { storedSource: existing?.source });
5859
+ const validated = validateMigrationPlan(readMigrationPlan(flags.plan, flags['plan-json']), snapshot);
5860
+ return applyMigration(snapshot, validated);
5861
+ }
5862
+ if (flags.plan || flags['plan-json']) fail('--plan is only valid with migration apply');
5863
+ const destination = canonicalPath(flags.destination || sealRoot());
5864
+ const existing = findMigrationEvent(path.join(destination, 'outcomes', 'events.jsonl'));
5865
+ const paths = migrationPaths(flags, { storedSource: existing?.source });
5866
+ let result;
5867
+ if (fs.existsSync(paths.sourceLog) || migrationDecisionFiles(paths.sourceDecisions).length > 0) {
5868
+ result = checkMigration(migrationSourceSnapshot(flags, { storedSource: existing?.source }));
5869
+ } else {
5870
+ result = checkMigration({
5871
+ ...paths,
5872
+ decisions: [],
5873
+ sourceLogPresent: existing?.source?.logPresent ?? false,
5874
+ }, { sourceMissing: true });
5875
+ }
5876
+ if (flags.json) printLine(JSON.stringify(result, null, 2));
5877
+ return result;
5878
+ },
5879
+
4665
5880
  init(argv) {
4666
5881
  const { positionals, flags } = parseArgs(argv, { lang: 'single', 'local-log': 'boolean' }, 'init');
4667
5882
  if (positionals.length > 0) fail(usageFor('init'));
@@ -4686,11 +5901,13 @@ const commands = {
4686
5901
  content: updated,
4687
5902
  marker: INTENT_PROTOCOL_MARKER,
4688
5903
  endMarker: INTENT_PROTOCOL_END,
4689
- versionPattern: /^<!-- driftseal-version: (\d+) -->\r?$/m,
5904
+ versionPattern: /^<!-- driftseal-version: (\d+(?:\.\d+)?) -->\r?$/m,
4690
5905
  replacement: intentBlock,
4691
5906
  knownManagedBlocks: [
4692
5907
  ...sourceLanguages.flatMap((source) => [
4693
5908
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
5909
+ protocolEol(v1IntentProtocolBlock(14, source), eol),
5910
+ protocolEol(v1IntentProtocolBlock(14, source, true), eol),
4694
5911
  protocolEol(previousIntentProtocolBlock(13, source), eol),
4695
5912
  protocolEol(previousIntentProtocolBlock(13, source, true), eol),
4696
5913
  protocolEol(previousIntentProtocolBlock(12, source), eol),
@@ -4715,11 +5932,13 @@ const commands = {
4715
5932
  content: updated,
4716
5933
  marker: DECISION_PROTOCOL_MARKER,
4717
5934
  endMarker: DECISION_PROTOCOL_END,
4718
- versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
5935
+ versionPattern: /^<!-- driftseal-decisions-version: (\d+(?:\.\d+)?) -->\r?$/m,
4719
5936
  replacement: decisionBlock,
4720
5937
  knownManagedBlocks: [
4721
5938
  ...sourceLanguages.flatMap((source) => [
4722
5939
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
5940
+ protocolEol(v1DecisionProtocolBlock(14, source), eol),
5941
+ protocolEol(v1DecisionProtocolBlock(14, source, true), eol),
4723
5942
  protocolEol(previousDecisionProtocolBlock(13, source), eol),
4724
5943
  protocolEol(previousDecisionProtocolBlock(13, source, true), eol),
4725
5944
  protocolEol(previousDecisionProtocolBlock(12, source), eol),
@@ -4777,37 +5996,39 @@ const commands = {
4777
5996
  printLine(`Configured git merge attribute: ${attributes.target}`);
4778
5997
  }
4779
5998
  if (driver.changed) {
4780
- printLine('Configured local git merge driver for DriftSeal intent logs');
5999
+ printLine('Configured local git merge driver for DriftSeal outcome logs');
4781
6000
  }
4782
6001
  return { changed: true, target };
4783
6002
  },
4784
6003
 
4785
6004
  help() {
4786
- printLine(`DriftSeal — Seal the intent. Stop the drift.
6005
+ printLine(`DriftSeal — Seal the outcome. Stop the drift.
4787
6006
 
4788
- Intent-level write-ahead log for agent sessions.
6007
+ Outcome-level write-ahead log for agent sessions.
4789
6008
 
4790
6009
  usage:
4791
- driftseal begin "<intent>" [--accept "<observable outcome>"] [--verify "<command>"]
6010
+ driftseal begin "<outcome>" [--accept "<observable result>"] [--verify "<command>"]
4792
6011
  [--decision <id>] [--force]
6012
+ driftseal extend "<same-outcome addition>" [--accept "<observable result>"]
6013
+ [--verify "<cumulative command>"] [--decision <id>]
4793
6014
  driftseal verify [--allow-tracked-command]
4794
6015
  run the declared command and bind its result
4795
- to the current Git-visible workspace contents
6016
+ to the current contract and Git-visible workspace
4796
6017
  driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
4797
- driftseal status show the intent currently in progress (re-anchor after drift)
4798
- driftseal log [--last N] [--all] show intent history (--all includes reclaimed records)
6018
+ driftseal status show the outcome currently in progress
6019
+ driftseal log [--last N] [--all] show outcome history (--all includes reclaimed records)
4799
6020
  driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]
4800
6021
  hide meaningless closed records without deleting them
4801
6022
  driftseal unreclaim <id> --reason "<why>"
4802
6023
  restore a reclaimed record to the visible log
4803
6024
  driftseal absorb [other-events.jsonl] [--decisions <dir>]
4804
6025
  [--abandon-theirs | --abandon-ours] [--dry-run]
4805
- merge another intent log, remapping colliding ids
6026
+ merge another outcome log, remapping colliding ids
4806
6027
  driftseal absorb --git <base> <ours> <theirs>
4807
- git merge driver for .intent-log/events.jsonl
6028
+ git merge driver for .seal/outcomes/events.jsonl
4808
6029
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
4809
6030
  driftseal decision update <id> [--status STATUS] --note "..."
4810
- reconcile a linked decision in the open intent
6031
+ reconcile a linked decision in the open outcome
4811
6032
  driftseal decision list [--status STATUS] [--last N | --count]
4812
6033
  list or count filtered MADR decision records
4813
6034
  driftseal decision show <id> print one MADR decision record
@@ -4824,8 +6045,17 @@ usage:
4824
6045
  emit the reminder a lifecycle hook injects; never blocks
4825
6046
  driftseal init [--lang <tag>] [--local-log]
4826
6047
  inject protocols into ./AGENTS.md and configure the git merge driver
4827
- --lang sets the intent/decision log language (BCP 47, default: en)
6048
+ --lang sets the outcome/MADR log language (BCP 47, default: en)
4828
6049
  --local-log keeps the logs local and untracked instead of committing them
6050
+ driftseal migrate v1-to-v2 inspect [--json] [migration paths]
6051
+ driftseal migrate v1-to-v2 apply --plan <file> [migration paths]
6052
+ driftseal migrate v1-to-v2 check [migration paths]
6053
+ model-assisted, validated migration that never deletes v1 data
6054
+
6055
+ migration paths:
6056
+ --source-log <file> v1 events.jsonl (default: $DRIFTSEAL_HOME/events.jsonl or .intent-log/events.jsonl)
6057
+ --source-decisions <dir> v1 MADR directory (default: $DRIFTSEAL_DECISION_HOME or .decision-log)
6058
+ --destination <dir> v2 seal root (default: $DRIFTSEAL_HOME or .seal)
4829
6059
  driftseal --version | -V print the installed DriftSeal version
4830
6060
  driftseal help
4831
6061
 
@@ -4835,9 +6065,11 @@ decision add options:
4835
6065
  --option "..." repeat for each considered option
4836
6066
  --consequence "..." repeat for each consequence
4837
6067
 
4838
- intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
4839
- decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory
4840
- In a Git worktree, begin parks an open intent in Git metadata until end, so merge does not need a log-only commit.`);
6068
+ seal root: $DRIFTSEAL_HOME, or .seal in the current directory
6069
+ outcome log: <seal-root>/outcomes/events.jsonl
6070
+ MADR records: <seal-root>/madr/
6071
+ $DRIFTSEAL_DECISION_HOME is a v1-only default for migration source detection; v2 runtime ignores it.
6072
+ In a Git worktree, begin parks an open outcome in Git metadata until end, so merge does not need a log-only commit.`);
4841
6073
  return null;
4842
6074
  },
4843
6075
 
@@ -4861,12 +6093,14 @@ function requestedEndStatus(argv) {
4861
6093
  */
4862
6094
  const VALUE_TAKING_FLAGS = {
4863
6095
  begin: ['--accept', '--verify', '-v', '--decision'],
6096
+ extend: ['--accept', '--verify', '-v', '--decision'],
4864
6097
  end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
4865
6098
  log: ['--last', '-n'],
4866
6099
  reclaim: ['--reason', '-r', '--older-than'],
4867
6100
  unreclaim: ['--reason', '-r'],
4868
6101
  absorb: ['--decisions'],
4869
6102
  init: ['--lang'],
6103
+ migrate: ['--source-log', '--source-decisions', '--destination', '--plan', '--plan-json'],
4870
6104
  'decision add': ['--context', '-c', '--outcome', '-o', '--status', '-s', '--driver', '--option', '--consequence'],
4871
6105
  'decision update': ['--status', '-s', '--note', '-n'],
4872
6106
  'decision list': ['--last', '-n', '--status', '-s'],
@@ -4897,8 +6131,16 @@ function mutationResources(cmd, argv) {
4897
6131
  if (cmd === 'mcp') return [parseMcpInstallRequest(argv).configDir];
4898
6132
  if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
4899
6133
  if (cmd === 'init') return [process.cwd()];
6134
+ if (cmd === 'migrate') return [process.cwd()];
4900
6135
  if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
6136
+ if (cmd === 'absorb' && argv[0] === '--git') {
6137
+ const ours = argv[2];
6138
+ return ours ? [path.dirname(path.resolve(ours))] : [process.cwd()];
6139
+ }
4901
6140
  if (cmd === 'absorb') return [logDir(), decisionDir()];
6141
+ if (cmd === 'end' && legacyParkedIntent()) {
6142
+ return [path.dirname(legacyIntentLogFile())];
6143
+ }
4902
6144
  if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
4903
6145
  return [logDir()];
4904
6146
  }
@@ -4908,6 +6150,258 @@ function mutationResources(cmd, argv) {
4908
6150
  return [logDir(), decisionDir()];
4909
6151
  }
4910
6152
 
6153
+ function usesV2RepositoryState(cmd, rest) {
6154
+ if (
6155
+ ['begin', 'extend', 'verify', 'end', 'status', 'log', 'reclaim', 'unreclaim', 'absorb', 'decision', 'init'].includes(cmd)
6156
+ ) {
6157
+ return true;
6158
+ }
6159
+ return cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]);
6160
+ }
6161
+
6162
+ function looksLikeV2SealRoot(root) {
6163
+ const resolved = canonicalPath(root);
6164
+ if (fs.existsSync(path.join(resolved, 'outcomes', 'events.jsonl'))) return true;
6165
+ return resolved === canonicalPath(path.join(process.cwd(), '.seal'));
6166
+ }
6167
+
6168
+ function isV2OutcomeLog(file) {
6169
+ const resolved = canonicalPath(file);
6170
+ if (resolved === canonicalPath(logFile()) && looksLikeV2SealRoot(sealRoot())) return true;
6171
+ const directory = path.dirname(resolved);
6172
+ return path.basename(resolved) === 'events.jsonl' &&
6173
+ path.basename(directory) === 'outcomes' &&
6174
+ looksLikeV2SealRoot(path.dirname(directory));
6175
+ }
6176
+
6177
+ function isV2MadrDirectory(directory) {
6178
+ const resolved = canonicalPath(directory);
6179
+ if (resolved === canonicalPath(decisionDir()) && looksLikeV2SealRoot(sealRoot())) return true;
6180
+ return path.basename(resolved) === 'madr' && looksLikeV2SealRoot(path.dirname(resolved));
6181
+ }
6182
+
6183
+ function existingV2SealRoots() {
6184
+ const roots = [];
6185
+ const seen = new Set();
6186
+ const consider = (root) => {
6187
+ const resolved = canonicalPath(root);
6188
+ if (seen.has(resolved)) return;
6189
+ seen.add(resolved);
6190
+ if (fs.existsSync(path.join(resolved, 'outcomes', 'events.jsonl'))) roots.push(resolved);
6191
+ };
6192
+ consider(path.join(process.cwd(), '.seal'));
6193
+ consider(sealRoot());
6194
+ const cwd = canonicalPath(process.cwd());
6195
+ // setup() tests use cwd=os.tmpdir() with DRIFTSEAL_HOME in a child directory.
6196
+ // Never scan the system temp root: sibling test dirs would look like extra lineages.
6197
+ if (cwd !== canonicalPath(os.tmpdir())) {
6198
+ let names = [];
6199
+ try {
6200
+ names = fs.readdirSync(process.cwd());
6201
+ } catch {
6202
+ names = [];
6203
+ }
6204
+ for (const name of names) {
6205
+ if (name === '.git' || name === 'node_modules') continue;
6206
+ consider(path.join(process.cwd(), name));
6207
+ }
6208
+ }
6209
+ return roots;
6210
+ }
6211
+
6212
+ function repositoryOutcomeLogFiles() {
6213
+ const files = [];
6214
+ const seen = new Set();
6215
+ const add = (file) => {
6216
+ const resolved = canonicalPath(file);
6217
+ if (seen.has(resolved)) return;
6218
+ seen.add(resolved);
6219
+ files.push(file);
6220
+ };
6221
+ add(logFile());
6222
+ add(path.join(process.cwd(), '.seal', 'outcomes', 'events.jsonl'));
6223
+ for (const root of existingV2SealRoots()) {
6224
+ add(path.join(root, 'outcomes', 'events.jsonl'));
6225
+ }
6226
+ return files;
6227
+ }
6228
+
6229
+ function repositoryMigrationEvent() {
6230
+ for (const file of repositoryOutcomeLogFiles()) {
6231
+ try {
6232
+ const migration = findMigrationEvent(file);
6233
+ if (migration) return migration;
6234
+ } catch {
6235
+ // Conflicted or corrupt v2 logs are handled by the command, not by v1 detection.
6236
+ }
6237
+ }
6238
+ return null;
6239
+ }
6240
+
6241
+ function v2KnownRecordIds() {
6242
+ const ids = new Set();
6243
+ for (const file of repositoryOutcomeLogFiles()) {
6244
+ if (!fs.existsSync(file)) continue;
6245
+ try {
6246
+ for (const event of readEvents({ file, repairTail: false, readOnly: true })) {
6247
+ if (event.type === 'import' && Array.isArray(event.sources)) {
6248
+ for (const source of event.sources) {
6249
+ if (source && typeof source.id === 'string') ids.add(source.id);
6250
+ }
6251
+ }
6252
+ if (event.type === 'migration' && Array.isArray(event.excluded)) {
6253
+ for (const item of event.excluded) {
6254
+ if (item && typeof item.sourceId === 'string') ids.add(item.sourceId);
6255
+ }
6256
+ }
6257
+ }
6258
+ } catch {
6259
+ // Unreadable v2 logs cannot attest that leftover v1 records are already present.
6260
+ }
6261
+ }
6262
+ return ids;
6263
+ }
6264
+
6265
+ function migrationCoversCandidate(candidate, migration) {
6266
+ if (!migration || typeof migration.sourceFingerprint !== 'string') return false;
6267
+ try {
6268
+ const content = migrationSourceContent(candidate.sourceLog, candidate.sourceDecisions);
6269
+ const hashes = hashMigrationSourceContent(content);
6270
+ if ([hashes.sourceFingerprint, hashes.legacySourceFingerprint].includes(migration.sourceFingerprint)) {
6271
+ return true;
6272
+ }
6273
+ const beginIds = parseJsonlRecords(content.rawLog, content.sourceLog, { allowLegacy: true })
6274
+ .map((record) => record.event)
6275
+ .filter((event) => event.type === 'begin')
6276
+ .map((event) => event.id);
6277
+ const knownIds = v2KnownRecordIds();
6278
+ if (beginIds.some((id) => !knownIds.has(id))) return false;
6279
+ if (Array.isArray(migration.madrManifest)) {
6280
+ const expected = new Map(migration.madrManifest.map((entry) => [entry.name, entry.sha256]));
6281
+ for (const decision of content.decisions) {
6282
+ const sha256 = expected.get(decision.name);
6283
+ if (!sha256) return false;
6284
+ const actual = crypto.createHash('sha256').update(decision.bytes).digest('hex');
6285
+ if (actual !== sha256) return false;
6286
+ }
6287
+ }
6288
+ return beginIds.length > 0 || content.decisions.length > 0;
6289
+ } catch {
6290
+ return false;
6291
+ }
6292
+ }
6293
+
6294
+ function unmigratedV1Source() {
6295
+ const migration = repositoryMigrationEvent();
6296
+ const defaultLog = path.resolve(process.cwd(), '.intent-log', 'events.jsonl');
6297
+ const defaultDecisions = path.resolve(process.cwd(), '.decision-log');
6298
+ const home = v1HomeEnv();
6299
+ const configuredLog = home ? path.resolve(home, 'events.jsonl') : null;
6300
+ const configuredDecisions = v1DecisionHomeEnv()
6301
+ ? path.resolve(v1DecisionHomeEnv())
6302
+ : defaultDecisions;
6303
+ const candidates = [{
6304
+ sourceLog: defaultLog,
6305
+ sourceDecisions: defaultDecisions,
6306
+ }];
6307
+ if (configuredLog) {
6308
+ candidates.push({
6309
+ sourceLog: configuredLog,
6310
+ sourceDecisions: configuredDecisions,
6311
+ });
6312
+ } else if (v1DecisionHomeEnv()) {
6313
+ candidates.push({
6314
+ sourceLog: defaultLog,
6315
+ sourceDecisions: configuredDecisions,
6316
+ });
6317
+ }
6318
+ const seen = new Set();
6319
+ const found = [];
6320
+ for (const candidate of candidates) {
6321
+ const key = `${canonicalPath(candidate.sourceLog)}\0${canonicalPath(candidate.sourceDecisions)}`;
6322
+ if (seen.has(key)) continue;
6323
+ seen.add(key);
6324
+ const hasLog = fs.existsSync(candidate.sourceLog) && !isV2OutcomeLog(candidate.sourceLog);
6325
+ const hasDecisions = !isV2MadrDirectory(candidate.sourceDecisions) &&
6326
+ migrationDecisionFiles(candidate.sourceDecisions).length > 0;
6327
+ if (!hasLog && !hasDecisions) continue;
6328
+ if (migration && migrationCoversCandidate(candidate, migration)) continue;
6329
+ found.push({ ...candidate, hasLog, hasDecisions });
6330
+ }
6331
+ if (found.length === 0) return null;
6332
+ const withLog = found.filter((candidate) => candidate.hasLog);
6333
+ const pool = withLog.length > 0 ? withLog : found;
6334
+ if (configuredLog) {
6335
+ const configured = pool.find((candidate) => canonicalPath(candidate.sourceLog) === canonicalPath(configuredLog));
6336
+ if (configured) return configured;
6337
+ }
6338
+ return pool[0];
6339
+ }
6340
+
6341
+ function staleInheritedSealHome() {
6342
+ if (!process.env.DRIFTSEAL_HOME) return null;
6343
+ const home = canonicalPath(process.env.DRIFTSEAL_HOME);
6344
+ if (fs.existsSync(logFile())) return null;
6345
+ const others = existingV2SealRoots().filter((root) => root !== home);
6346
+ if (others.length === 0) return null;
6347
+ const defaultSeal = canonicalPath(path.join(process.cwd(), '.seal'));
6348
+ return {
6349
+ home,
6350
+ seal: others.includes(defaultSeal) ? defaultSeal : others[0],
6351
+ };
6352
+ }
6353
+
6354
+ function suggestedMigrationDestination(source) {
6355
+ const configured = canonicalPath(sealRoot());
6356
+ const defaultSeal = canonicalPath(path.join(process.cwd(), '.seal'));
6357
+ if (
6358
+ pathContains(source.sourceLog, configured) ||
6359
+ pathContains(configured, source.sourceLog) ||
6360
+ pathContains(source.sourceDecisions, configured) ||
6361
+ pathContains(configured, source.sourceDecisions) ||
6362
+ fs.existsSync(path.join(configured, 'events.jsonl'))
6363
+ ) {
6364
+ return defaultSeal;
6365
+ }
6366
+ return configured;
6367
+ }
6368
+
6369
+ function unmigratedV1Message(source) {
6370
+ const destination = suggestedMigrationDestination(source);
6371
+ const staged = repositoryMigrationEvent();
6372
+ const mismatch = staged
6373
+ ? 'it does not match the already staged v1-to-v2 migration; v2 repository commands are disabled until the extra v1 source is removed\n'
6374
+ : 'v2 repository commands are disabled until migration is staged\n';
6375
+ const parked = legacyParkedIntent();
6376
+ const parkHint = parked
6377
+ ? `a parked v1 intent ${parked.id} is still open; close it first:\n` +
6378
+ ' driftseal end --status abandoned --note "close parked v1 intent before migration"\n'
6379
+ : '';
6380
+ return (
6381
+ `unmigrated v1 state detected at ${source.sourceLog} or ${source.sourceDecisions}; ` +
6382
+ mismatch +
6383
+ parkHint +
6384
+ `run: driftseal migrate v1-to-v2 inspect --source-log ${JSON.stringify(source.sourceLog)} ` +
6385
+ `--source-decisions ${JSON.stringify(source.sourceDecisions)} --destination ${JSON.stringify(destination)}\n` +
6386
+ 'if DRIFTSEAL_HOME points to v1 storage, unset or update it after migration'
6387
+ );
6388
+ }
6389
+
6390
+ function assertV2RepositoryReady(cmd, rest) {
6391
+ if (!usesV2RepositoryState(cmd, rest)) return;
6392
+ if (cmd === 'absorb' && rest[0] === '--git') return;
6393
+ if (['end', 'status', 'log'].includes(cmd) && legacyParkedIntent()) return;
6394
+ const source = unmigratedV1Source();
6395
+ if (source) fail(unmigratedV1Message(source));
6396
+ const staleHome = staleInheritedSealHome();
6397
+ if (!staleHome) return;
6398
+ fail(
6399
+ `DRIFTSEAL_HOME points to ${staleHome.home}, which has no v2 outcome log, ` +
6400
+ `but this repository already has v2 state at ${staleHome.seal}; ` +
6401
+ 'unset DRIFTSEAL_HOME or point it at the seal root'
6402
+ );
6403
+ }
6404
+
4911
6405
  function dispatch(argv) {
4912
6406
  const [cmd, ...rest] = argv;
4913
6407
  if (cmd === '--version' || cmd === '-V') {
@@ -4924,8 +6418,10 @@ function dispatch(argv) {
4924
6418
  // probe is spec-aware so it never bypasses the lock for a real mutation: a
4925
6419
  // --help token consumed as a flag value is left for parseArgs to reject.
4926
6420
  if (wantsHelpBeforeLock(cmd, rest)) return { data: fn(rest), exitCode: 0 };
6421
+ assertV2RepositoryReady(cmd, rest);
4927
6422
  const mutates =
4928
- ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
6423
+ ['begin', 'extend', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
6424
+ (cmd === 'migrate' && rest[1] === 'apply') ||
4929
6425
  (cmd === 'hook' && rest[0] === 'install') ||
4930
6426
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
4931
6427
  const readsIntentLog =
@@ -4942,6 +6438,8 @@ function dispatch(argv) {
4942
6438
  const hookFile = hookLogFile();
4943
6439
  if (!hookFile) return { data: fn(rest), exitCode: 0 };
4944
6440
  resources = [path.dirname(hookFile)];
6441
+ } else if (legacyParkedIntent()) {
6442
+ resources = [path.dirname(legacyIntentLogFile())];
4945
6443
  } else {
4946
6444
  resources = [logDir()];
4947
6445
  }
@@ -5018,6 +6516,10 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
5018
6516
  try {
5019
6517
  process.chdir(fixedRoot);
5020
6518
  if (isolateStorage) {
6519
+ isolatedV1Detection = {
6520
+ home: process.env.DRIFTSEAL_HOME || null,
6521
+ decisions: process.env.DRIFTSEAL_DECISION_HOME || null,
6522
+ };
5021
6523
  delete process.env.DRIFTSEAL_HOME;
5022
6524
  delete process.env.DRIFTSEAL_DECISION_HOME;
5023
6525
  }
@@ -5038,6 +6540,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
5038
6540
  } finally {
5039
6541
  activeOutput = previousOutput;
5040
6542
  process.chdir(previousCwd);
6543
+ isolatedV1Detection = null;
5041
6544
  if (previousIntentHome === undefined) delete process.env.DRIFTSEAL_HOME;
5042
6545
  else process.env.DRIFTSEAL_HOME = previousIntentHome;
5043
6546
  if (previousDecisionHome === undefined) delete process.env.DRIFTSEAL_DECISION_HOME;
@@ -5066,14 +6569,21 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
5066
6569
  status() {
5067
6570
  return call(['status']);
5068
6571
  },
5069
- begin({ intent, acceptance = [], verify, decisions = [], force = false }) {
5070
- const argv = ['begin', intent];
6572
+ begin({ outcome, acceptance = [], verify, decisions = [], force = false }) {
6573
+ const argv = ['begin', outcome];
5071
6574
  for (const criterion of acceptance) appendFlag(argv, '--accept', criterion);
5072
6575
  appendFlag(argv, '--verify', verify);
5073
6576
  for (const decision of decisions) appendFlag(argv, '--decision', decision);
5074
6577
  if (force) argv.push('--force');
5075
6578
  return call(argv);
5076
6579
  },
6580
+ extend({ extension, acceptance = [], verify, decisions = [] }) {
6581
+ const argv = ['extend', extension];
6582
+ for (const criterion of acceptance) appendFlag(argv, '--accept', criterion);
6583
+ appendFlag(argv, '--verify', verify);
6584
+ for (const decision of decisions) appendFlag(argv, '--decision', decision);
6585
+ return call(argv);
6586
+ },
5077
6587
  verify({ allowTrackedCommand = false } = {}) {
5078
6588
  return call(['verify', ...(allowTrackedCommand ? ['--allow-tracked-command'] : [])]);
5079
6589
  },
@@ -5135,6 +6645,27 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
5135
6645
  decisionShow({ id }) {
5136
6646
  return call(['decision', 'show', String(id)]);
5137
6647
  },
6648
+ migrationInspect({ sourceLog, sourceDecisions, destination } = {}) {
6649
+ const argv = ['migrate', 'v1-to-v2', 'inspect'];
6650
+ appendFlag(argv, '--source-log', sourceLog);
6651
+ appendFlag(argv, '--source-decisions', sourceDecisions);
6652
+ appendFlag(argv, '--destination', destination);
6653
+ return call(argv);
6654
+ },
6655
+ migrationApply({ plan, sourceLog, sourceDecisions, destination }) {
6656
+ const argv = ['migrate', 'v1-to-v2', 'apply', '--plan-json', JSON.stringify(plan)];
6657
+ appendFlag(argv, '--source-log', sourceLog);
6658
+ appendFlag(argv, '--source-decisions', sourceDecisions);
6659
+ appendFlag(argv, '--destination', destination);
6660
+ return call(argv);
6661
+ },
6662
+ migrationCheck({ sourceLog, sourceDecisions, destination } = {}) {
6663
+ const argv = ['migrate', 'v1-to-v2', 'check'];
6664
+ appendFlag(argv, '--source-log', sourceLog);
6665
+ appendFlag(argv, '--source-decisions', sourceDecisions);
6666
+ appendFlag(argv, '--destination', destination);
6667
+ return call(argv);
6668
+ },
5138
6669
  init() {
5139
6670
  return call(['init']);
5140
6671
  },