driftseal 1.3.2 → 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 = 13;
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:
@@ -1874,20 +2156,31 @@ MCP and lifecycle hooks are optional adapters.
1874
2156
 
1875
2157
  ${intentLogLanguageParagraph(language)}
1876
2158
 
1877
- 1. **Write intent first**, before modifying, creating, or deleting files, or
1878
- making any other non-Git change that may need a rollback:
2159
+ 1. **Write intent first**, before changing durable project content:
1879
2160
  \`driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"\`.
1880
2161
  Repeat \`--accept\` when completion has multiple independently observable criteria.
1881
2162
  Add one \`--decision <id>\` for each existing decision this round may change.
1882
- Git operations never need an intent and are not included in the intent log;
1883
- Git maintains their history. This includes inspection, branch and worktree
1884
- management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.
1885
- A command whose result can be reconstructed from Git state (for example a
1886
- patch file regenerated from a commit range, or a scratch harness that
1887
- re-runs) needs no intent; content that will be committed and cannot be
1888
- reconstructed (for example a .gitignore edit) does.
1889
- Single-step commands that only build or check work already done, such as
1890
- compiling or running tests, also need no intent.
2163
+ Record intents for changes intended to persist in the project: edits to code,
2164
+ configuration, documentation, dependencies, and equivalent project files,
2165
+ whether or not the project is inside a Git worktree. Everything else is
2166
+ exempt: Git operations (Git maintains their history inspection, branch
2167
+ and worktree management, staging, commits, merges, rebases, cherry-picks,
2168
+ tags, and pushes); single-step commands that only build or check work
2169
+ already done, such as compiling or running tests; auxiliary file or shell
2170
+ operations whose results remain outside durable project content (for example
2171
+ an rsync scratch copy or temp scaffolding); and state changes to a remote
2172
+ machine or the local environment that do not write durable project content
2173
+ into this workspace. When an external operation does bring durable content
2174
+ into the project, record an intent for that project-content change, not for
2175
+ the external operation itself.
2176
+ In multi-agent work, one open intent belongs to one worktree, or to one
2177
+ configured project root outside Git. Every agent or subagent that changes
2178
+ durable project content in the same root first re-anchors and continues its
2179
+ matching open intent; agents working in separate worktrees hold separate
2180
+ intents. An agent that only receives another agent's changes through Git or
2181
+ into a shared worktree records no receiving intent and lets \`verify\` expose
2182
+ misalignment; handoff files are exempt while ignored or otherwise kept
2183
+ outside durable project content and require an intent when promoted into it.
1891
2184
  Size an intent to the smallest unit that leaves the tree self-consistent
1892
2185
  and can be verified on its own.
1893
2186
  2. **Execute only the intent.** Scope change? Close the current intent
@@ -1914,12 +2207,15 @@ ${intentLogLanguageParagraph(language)}
1914
2207
  by the next linked \`decision update\` or successful \`end\`. Closing as
1915
2208
  \`failed\` or \`abandoned\` cancels pending recovery for that intent.
1916
2209
  Git operations remain subject to normal authorization and safety requirements
1917
- even though they do not require an intent. Any non-Git content change made while
1918
- preparing a Git operation does require a new intent, per the step 1 test.
2210
+ even though they do not require an intent. Any content change made while
2211
+ preparing a Git operation still requires an intent when it meets the
2212
+ durable-project-content rule in step 1.
1919
2213
  4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1920
2214
  doing anything else. The open intent is the source of truth: resume it when its
1921
2215
  objective still matches the current task; otherwise close it (\`partial\` or
1922
- \`abandoned\`, with a note) and \`begin\` a new one.
2216
+ \`abandoned\`, with a note) and \`begin\` a new one. Taking over work in the
2217
+ same root from another agent is the same re-anchor: resume the open intent
2218
+ when its objective still matches.
1923
2219
 
1924
2220
  **Log access goes only through DriftSeal.** Never read, edit, move, or delete
1925
2221
  \`.intent-log/events.jsonl\` (or anything under \`$DRIFTSEAL_HOME\`) directly; use
@@ -1935,7 +2231,75 @@ ${INTENT_PROTOCOL_END}`;
1935
2231
  }
1936
2232
 
1937
2233
  function previousIntentProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
1938
- const v12 = intentProtocolBlock(version, language, localLog)
2234
+ const v13 = v1IntentProtocolBlock(version, language, localLog)
2235
+ .replace(
2236
+ '1. **Write intent first**, before changing durable project content:\n' +
2237
+ ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
2238
+ ' Repeat `--accept` when completion has multiple independently observable criteria.\n' +
2239
+ ' Add one `--decision <id>` for each existing decision this round may change.\n' +
2240
+ ' Record intents for changes intended to persist in the project: edits to code,\n' +
2241
+ ' configuration, documentation, dependencies, and equivalent project files,\n' +
2242
+ ' whether or not the project is inside a Git worktree. Everything else is\n' +
2243
+ ' exempt: Git operations (Git maintains their history — inspection, branch\n' +
2244
+ ' and worktree management, staging, commits, merges, rebases, cherry-picks,\n' +
2245
+ ' tags, and pushes); single-step commands that only build or check work\n' +
2246
+ ' already done, such as compiling or running tests; auxiliary file or shell\n' +
2247
+ ' operations whose results remain outside durable project content (for example\n' +
2248
+ ' an rsync scratch copy or temp scaffolding); and state changes to a remote\n' +
2249
+ ' machine or the local environment that do not write durable project content\n' +
2250
+ ' into this workspace. When an external operation does bring durable content\n' +
2251
+ ' into the project, record an intent for that project-content change, not for\n' +
2252
+ ' the external operation itself.\n' +
2253
+ ' In multi-agent work, one open intent belongs to one worktree, or to one\n' +
2254
+ ' configured project root outside Git. Every agent or subagent that changes\n' +
2255
+ ' durable project content in the same root first re-anchors and continues its\n' +
2256
+ ' matching open intent; agents working in separate worktrees hold separate\n' +
2257
+ " intents. An agent that only receives another agent's changes through Git or\n" +
2258
+ ' into a shared worktree records no receiving intent and lets `verify` expose\n' +
2259
+ ' misalignment; handoff files are exempt while ignored or otherwise kept\n' +
2260
+ ' outside durable project content and require an intent when promoted into it.\n' +
2261
+ ' Size an intent to the smallest unit that leaves the tree self-consistent\n' +
2262
+ ' and can be verified on its own.',
2263
+ '1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
2264
+ ' making any other non-Git change that may need a rollback:\n' +
2265
+ ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
2266
+ ' Repeat `--accept` when completion has multiple independently observable criteria.\n' +
2267
+ ' Add one `--decision <id>` for each existing decision this round may change.\n' +
2268
+ ' Git operations never need an intent and are not included in the intent log;\n' +
2269
+ ' Git maintains their history. This includes inspection, branch and worktree\n' +
2270
+ ' management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.\n' +
2271
+ ' A command whose result can be reconstructed from Git state (for example a\n' +
2272
+ ' patch file regenerated from a commit range, or a scratch harness that\n' +
2273
+ ' re-runs) needs no intent; content that will be committed and cannot be\n' +
2274
+ ' reconstructed (for example a .gitignore edit) does.\n' +
2275
+ ' Single-step commands that only build or check work already done, such as\n' +
2276
+ ' compiling or running tests, also need no intent.\n' +
2277
+ ' Size an intent to the smallest unit that leaves the tree self-consistent\n' +
2278
+ ' and can be verified on its own.'
2279
+ )
2280
+ .replace(
2281
+ ' Git operations remain subject to normal authorization and safety requirements\n' +
2282
+ ' even though they do not require an intent. Any content change made while\n' +
2283
+ ' preparing a Git operation still requires an intent when it meets the\n' +
2284
+ ' durable-project-content rule in step 1.',
2285
+ ' Git operations remain subject to normal authorization and safety requirements\n' +
2286
+ ' even though they do not require an intent. Any non-Git content change made while\n' +
2287
+ ' preparing a Git operation does require a new intent, per the step 1 test.'
2288
+ )
2289
+ .replace(
2290
+ '4. **Re-anchor after context loss**: run `driftseal status` and `driftseal log --last 3` before\n' +
2291
+ ' doing anything else. The open intent is the source of truth: resume it when its\n' +
2292
+ ' objective still matches the current task; otherwise close it (`partial` or\n' +
2293
+ ' `abandoned`, with a note) and `begin` a new one. Taking over work in the\n' +
2294
+ ' same root from another agent is the same re-anchor: resume the open intent\n' +
2295
+ ' when its objective still matches.',
2296
+ '4. **Re-anchor after context loss**: run `driftseal status` and `driftseal log --last 3` before\n' +
2297
+ ' doing anything else. The open intent is the source of truth: resume it when its\n' +
2298
+ ' objective still matches the current task; otherwise close it (`partial` or\n' +
2299
+ ' `abandoned`, with a note) and `begin` a new one.'
2300
+ );
2301
+ if (version >= 13) return v13;
2302
+ const v12 = v13
1939
2303
  .replace(
1940
2304
  ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
1941
2305
  ' Repeat `--accept` when completion has multiple independently observable criteria.',
@@ -2075,6 +2439,31 @@ function decisionProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LO
2075
2439
 
2076
2440
  ## Agent protocol: decision log
2077
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
+
2078
2467
  Record a MADR document only when it preserves decision context that cannot be
2079
2468
  recovered from the intent log and Git history: a rejected or deferred path worth
2080
2469
  revisiting, non-obvious rationale behind a long-lived or costly-to-reverse accepted
@@ -2151,8 +2540,8 @@ Commit \`.decision-log/\` with the code.`;
2151
2540
  }
2152
2541
 
2153
2542
  function previousDecisionProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2154
- if (version >= 11) return decisionProtocolBlock(version, language, localLog);
2155
- 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);
2156
2545
  if (version >= 9) return v10;
2157
2546
  const v8 = v10.replace(
2158
2547
  '\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
@@ -2186,11 +2575,17 @@ function upgradeManagedBlock({
2186
2575
  if (!versionMatch) {
2187
2576
  fail(`cannot safely upgrade unversioned managed protocol block beginning with ${marker}`);
2188
2577
  }
2189
- const version = Number(versionMatch[1]);
2190
- if (!Number.isSafeInteger(version) || version < 1) {
2578
+ const version = versionMatch[1];
2579
+ if (!/^\d+(?:\.\d+)?$/.test(version)) {
2191
2580
  fail(`invalid protocol version in block beginning with ${marker}`);
2192
2581
  }
2193
- 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) {
2194
2589
  fail(
2195
2590
  `protocol version ${version} requires a newer DriftSeal client (supported: ${PROTOCOL_VERSION})`
2196
2591
  );
@@ -2461,7 +2856,8 @@ const SKILL_RELEASE_DIGESTS = new Set([
2461
2856
  'cc98b9348ec222320bfcd285ba3f1f499a42d15b31e9b1d83c35f0206b2d5ba9', // ca16785 CLI-first skill integration
2462
2857
  '0fd870f8c1b81f8386d986d64742679d56cd1d317c02890830c81876eb9227d6', // da8afd2 1.1.0 absorb
2463
2858
  '72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
2464
- '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
2465
2861
  ]);
2466
2862
 
2467
2863
  function skillInstallUsage() {
@@ -2882,7 +3278,7 @@ function hookLogFile() {
2882
3278
  let current = path.resolve(process.cwd());
2883
3279
  const root = gitWorktreeRoot(current);
2884
3280
  while (true) {
2885
- const candidate = path.join(current, '.intent-log', 'events.jsonl');
3281
+ const candidate = path.join(current, '.seal', 'outcomes', 'events.jsonl');
2886
3282
  if (fs.existsSync(candidate)) return candidate;
2887
3283
  if (root && path.resolve(root) === current) {
2888
3284
  const park = worktreeInProgressFile(current);
@@ -2894,33 +3290,35 @@ function hookLogFile() {
2894
3290
  }
2895
3291
  }
2896
3292
 
2897
- /** 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. */
2898
3294
  function hookReminder(event, { readOnly = false } = {}) {
2899
3295
  const file = hookLogFile();
2900
3296
  if (!file) return null;
2901
3297
  if (event === 'prompt') {
2902
3298
  return (
2903
- 'DriftSeal reminder: if this round will modify files or anything else that may need a ' +
2904
- 'rollback, begin an intent first: driftseal begin "<intent>" --accept "<observable outcome>" ' +
3299
+ 'DriftSeal reminder: if this round will change durable project content in this workspace ' +
3300
+ '(code, configuration, documentation, dependencies), ' +
3301
+ 'begin an outcome first: driftseal begin "<coherent outcome>" --accept "<observable result>" ' +
2905
3302
  '--verify "<command>". ' +
2906
- 'Questions, read-only exploration, and single-step checks need no intent skip this ' +
2907
- 'reminder when it does not apply.'
3303
+ 'Questions, read-only exploration, single-step checks, temporary work outside durable ' +
3304
+ 'project content, and external state changes that do not write project content here need ' +
3305
+ 'no outcome — skip this reminder when it does not apply.'
2908
3306
  );
2909
3307
  }
2910
- const open = openIntent(fold(readEvents({ file, readOnly })));
3308
+ const open = openOutcome(fold(readEvents({ file, readOnly })));
2911
3309
  if (open) {
2912
3310
  const reconciliation = open.decisions.length > 0 ? 'reconcile every linked decision, then ' : '';
2913
3311
  const verification = open.acceptance.length > 0
2914
3312
  ? `${reconciliation}inspect and run driftseal verify, then close it with driftseal end`
2915
3313
  : `${reconciliation}run the declared verification, then close it with driftseal end`;
2916
3314
  return (
2917
- `DriftSeal reminder: intent ${open.id} is still in_progress: "${open.intent}". ` +
3315
+ `DriftSeal reminder: outcome ${open.id} is still in_progress: "${open.outcome}". ` +
2918
3316
  `If its work is done, ${verification}; ` +
2919
3317
  'if this turn was unrelated, ignore this reminder.'
2920
3318
  );
2921
3319
  }
2922
3320
  return (
2923
- '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 ' +
2924
3322
  'whether the work should have been logged; ignore this reminder when nothing changed.'
2925
3323
  );
2926
3324
  }
@@ -2994,7 +3392,7 @@ function isGitWorkTree(cwd = process.cwd()) {
2994
3392
  * Hash the material Git-visible workspace contents rather than trusting the
2995
3393
  * current commit alone. Any tracked or untracked (non-ignored) content change
2996
3394
  * makes the verification stale.
2997
- * The intent event log is excluded because recording verification and closure
3395
+ * The outcome event log is excluded because recording verification and closure
2998
3396
  * necessarily appends to it.
2999
3397
  */
3000
3398
  function workspaceFingerprint(cwd = process.cwd()) {
@@ -3064,8 +3462,8 @@ function workspaceFingerprint(cwd = process.cwd()) {
3064
3462
  *
3065
3463
  * Paths are read from `ls-files -z` with `:(literal)` pathspecs because git's
3066
3464
  * human-readable listing C-quotes non-ASCII names and treats `*?[\` as
3067
- * wildcards. The printed remediation uses the fixed names `.intent-log` and
3068
- * `.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
3069
3467
  * those pathspecs against the init cwd, so the command stays paste-safe in
3070
3468
  * POSIX shells, cmd.exe, and PowerShell without embedding the repo-relative
3071
3469
  * prefix or any shell quoting.
@@ -3076,7 +3474,7 @@ function warnIfDefaultLogsTracked(cwd = process.cwd()) {
3076
3474
  if (!root) return;
3077
3475
  const prefix = gitCaptureLine(['rev-parse', '--show-prefix'], cwd);
3078
3476
  if (prefix === null) return;
3079
- const logNames = ['.intent-log', '.decision-log'];
3477
+ const logNames = ['.seal'];
3080
3478
  const logDirs = logNames.map((name) => `${prefix}${name}`);
3081
3479
  const listing = gitCaptureRaw(
3082
3480
  ['ls-files', '-z', '--', ...logDirs.map((name) => `:(literal)${name}`)],
@@ -3139,7 +3537,7 @@ function gitMergeParents(cwd = process.cwd()) {
3139
3537
  }
3140
3538
 
3141
3539
  function gitDecisionIds(treeish, cwd = process.cwd()) {
3142
- 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);
3143
3541
  if (!out) return new Set();
3144
3542
  const ids = new Set();
3145
3543
  for (const file of out.split('\n')) {
@@ -3150,7 +3548,7 @@ function gitDecisionIds(treeish, cwd = process.cwd()) {
3150
3548
  }
3151
3549
 
3152
3550
  function gitDecisionEntries(treeish, cwd = process.cwd()) {
3153
- 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);
3154
3552
  if (!out) return [];
3155
3553
  const entries = [];
3156
3554
  for (const file of out.split('\n')) {
@@ -3169,9 +3567,9 @@ function gitDecisionEntries(treeish, cwd = process.cwd()) {
3169
3567
  return entries;
3170
3568
  }
3171
3569
 
3172
- function gitIntentRecords(treeish, cwd = process.cwd()) {
3173
- const content = gitReadFile(treeish, '.intent-log/events.jsonl', cwd);
3174
- 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`);
3175
3573
  }
3176
3574
 
3177
3575
  function canonicalEvent(event) {
@@ -3290,10 +3688,14 @@ function hasDuplicateDecisionIds(entries) {
3290
3688
  return false;
3291
3689
  }
3292
3690
 
3293
- function hasDuplicateIntentBegins(records) {
3691
+ function isOutcomeStart(event) {
3692
+ return event.type === 'begin' || event.type === 'import';
3693
+ }
3694
+
3695
+ function hasDuplicateOutcomeStarts(records) {
3294
3696
  const seen = new Set();
3295
3697
  for (const record of records) {
3296
- if (record.event.type !== 'begin') continue;
3698
+ if (!isOutcomeStart(record.event)) continue;
3297
3699
  if (seen.has(record.event.id)) return true;
3298
3700
  seen.add(record.event.id);
3299
3701
  }
@@ -3416,26 +3818,57 @@ function remapEvent(event, intentMap, decisionMap, hashMap = new Map()) {
3416
3818
  return next;
3417
3819
  }
3418
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
+
3419
3852
  function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
3420
3853
  const intentMap = new Map();
3421
3854
  const mappings = [];
3422
3855
  const used = [...oursUsedEvents];
3423
3856
  const records = theirsNew.map((record) => {
3424
3857
  let event = record.event;
3425
- if (event.type === 'begin' && used.some((item) => item.type === 'begin' && item.id === event.id)) {
3426
- const { date } = parseIntentId(event.id);
3858
+ if (isOutcomeStart(event) && used.some((item) => isOutcomeStart(item) && item.id === event.id)) {
3859
+ const { date } = parseOutcomeId(event.id);
3427
3860
  const newId = nextIdForDate(date, used);
3428
3861
  intentMap.set(event.id, newId);
3429
- mappings.push({ kind: 'intent', from: event.id, to: newId });
3862
+ mappings.push({ kind: 'outcome', from: event.id, to: newId });
3430
3863
  }
3431
3864
  event = remapEvent(event, intentMap, decisionMap, hashMap);
3432
3865
  used.push(event);
3433
3866
  return { event };
3434
3867
  });
3435
- return { records, mappings };
3868
+ return { records: rebindV2ContractHashes(records), mappings };
3436
3869
  }
3437
3870
 
3438
- function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map()) {
3871
+ function repairDuplicateOutcomeRecords(records, decisionMap, hashMap = new Map()) {
3439
3872
  const seenBegins = new Set();
3440
3873
  const intentMap = new Map();
3441
3874
  const used = [];
@@ -3444,13 +3877,13 @@ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map())
3444
3877
  let incomingSide = false;
3445
3878
  for (const record of records) {
3446
3879
  let event = record.event;
3447
- if (event.type === 'begin' && seenBegins.has(event.id)) {
3880
+ if (isOutcomeStart(event) && seenBegins.has(event.id)) {
3448
3881
  incomingSide = true;
3449
- const { date } = parseIntentId(event.id);
3882
+ const { date } = parseOutcomeId(event.id);
3450
3883
  const newId = nextIdForDate(date, used);
3451
3884
  intentMap.set(event.id, newId);
3452
- mappings.push({ kind: 'intent', from: event.id, to: newId });
3453
- } else if (event.type === 'begin') {
3885
+ mappings.push({ kind: 'outcome', from: event.id, to: newId });
3886
+ } else if (isOutcomeStart(event)) {
3454
3887
  seenBegins.add(event.id);
3455
3888
  }
3456
3889
  const remapped = remapEvent(
@@ -3463,7 +3896,7 @@ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map())
3463
3896
  result.push(changed ? { event: remapped } : record);
3464
3897
  used.push(result.at(-1).event);
3465
3898
  }
3466
- return { records: result, mappings, incomingSide };
3899
+ return { records: rebindV2ContractHashes(result), mappings, incomingSide };
3467
3900
  }
3468
3901
 
3469
3902
  function serializeRecords(records) {
@@ -3491,19 +3924,19 @@ function applyDecisionCopies(copies, dryRun) {
3491
3924
  }
3492
3925
  }
3493
3926
 
3494
- function countAbsorbedIntents(records) {
3495
- 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;
3496
3929
  }
3497
3930
 
3498
- function printAbsorbReport({ mappings, abandoned, intentCount }) {
3499
- 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;
3500
3933
  const remappedDecisions = mappings.filter((mapping) => mapping.kind === 'decision').length;
3501
3934
  printLine(
3502
- `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)`
3503
3936
  );
3504
3937
  for (const mapping of mappings) {
3505
3938
  const side = mapping.side || 'theirs';
3506
- if (mapping.kind === 'intent') printLine(`${mapping.from} (${side}) -> ${mapping.to}`);
3939
+ if (mapping.kind === 'outcome') printLine(`${mapping.from} (${side}) -> ${mapping.to}`);
3507
3940
  else printLine(`decision ${mapping.from} (${side}) -> ${mapping.to}`);
3508
3941
  }
3509
3942
  if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
@@ -3512,6 +3945,7 @@ function printAbsorbReport({ mappings, abandoned, intentCount }) {
3512
3945
  function abandonOpenIntent(records, targetId, side) {
3513
3946
  records.push({
3514
3947
  event: {
3948
+ logVersion: LOG_VERSION,
3515
3949
  schemaVersion: EVENT_SCHEMA_VERSION,
3516
3950
  type: 'end',
3517
3951
  id: targetId,
@@ -3532,13 +3966,13 @@ function resolveOpenIntents(
3532
3966
  abandon,
3533
3967
  { allowConflict = false, overlay = [], parkedOpen = null } = {}
3534
3968
  ) {
3535
- const oursOpen = openIntent(fold(oursRecords.map((record) => record.event)));
3536
- 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)));
3537
3971
  try {
3538
- openIntent(fold([...result, ...overlay].map((record) => record.event)));
3972
+ openOutcome(fold([...result, ...overlay].map((record) => record.event)));
3539
3973
  return { abandoned: null, conflict: false, parkedClosed: false };
3540
3974
  } catch (err) {
3541
- if (!(err instanceof DriftSealError) || !/multiple intents in progress/.test(err.message)) {
3975
+ if (!(err instanceof DriftSealError) || !/multiple outcomes in progress/.test(err.message)) {
3542
3976
  throw err;
3543
3977
  }
3544
3978
  if (abandon === 'theirs' && theirsOpen) {
@@ -3582,19 +4016,25 @@ function mergeRecordStreams(ours, theirs, baseRecords) {
3582
4016
  }
3583
4017
 
3584
4018
  function GITATTRIBUTES_MERGE_LINE() {
3585
- return '.intent-log/events.jsonl merge=driftseal';
4019
+ return '.seal/outcomes/events.jsonl merge=driftseal';
3586
4020
  }
3587
4021
 
3588
4022
  function ensureGitAttributes() {
3589
4023
  const target = path.join(process.cwd(), '.gitattributes');
3590
4024
  const line = GITATTRIBUTES_MERGE_LINE();
4025
+ const legacyLine = '.intent-log/events.jsonl merge=driftseal';
3591
4026
  const existed = fs.existsSync(target);
3592
4027
  const current = existed ? fs.readFileSync(target, 'utf8') : '';
3593
4028
  const eol = current.includes('\r\n') ? '\r\n' : '\n';
3594
4029
  const lines = current.split(/\r?\n/);
3595
- if (lines.some((entry) => entry.trim() === line)) return { changed: false, target };
3596
- const prefix = current.length === 0 || current.endsWith('\n') || current.endsWith('\r\n') ? '' : eol;
3597
- 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 };
3598
4038
  atomicWriteFile(target, next);
3599
4039
  return { changed: true, target };
3600
4040
  }
@@ -3603,7 +4043,7 @@ function ensureGitMergeDriver() {
3603
4043
  if (!isGitWorkTree()) return { changed: false, configured: false };
3604
4044
  const name = gitCapture(['config', '--local', '--get', 'merge.driftseal.name']);
3605
4045
  const driver = gitCapture(['config', '--local', '--get', 'merge.driftseal.driver']);
3606
- const expectedName = 'DriftSeal intent log merge';
4046
+ const expectedName = 'DriftSeal outcome log merge';
3607
4047
  const expectedDriver = 'driftseal absorb --git %O %A %B';
3608
4048
  if (name === expectedName && driver === expectedDriver) {
3609
4049
  return { changed: false, configured: true };
@@ -3624,10 +4064,10 @@ function absorbUsage() {
3624
4064
  );
3625
4065
  }
3626
4066
 
3627
- function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false } = {}) {
4067
+ function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false, allowLegacy = false } = {}) {
3628
4068
  if (!fs.existsSync(file)) {
3629
4069
  if (allowMissing) return { records: [], conflict: false };
3630
- fail(`intent log not found: ${file}`);
4070
+ fail(`outcome log not found: ${file}`);
3631
4071
  }
3632
4072
  let content = fs.readFileSync(file, 'utf8');
3633
4073
  if (repairTail && !/^<<<<<<< /m.test(content)) {
@@ -3637,12 +4077,12 @@ function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false
3637
4077
  const conflict = parseConflictContent(content);
3638
4078
  if (conflict) {
3639
4079
  return {
3640
- ours: parseJsonlRecords(conflict.oursText, `${label} ours`),
3641
- theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`),
4080
+ ours: parseJsonlRecords(conflict.oursText, `${label} ours`, { allowLegacy }),
4081
+ theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`, { allowLegacy }),
3642
4082
  conflict: true,
3643
4083
  };
3644
4084
  }
3645
- return { records: parseJsonlRecords(content, label), conflict: false };
4085
+ return { records: parseJsonlRecords(content, label, { allowLegacy }), conflict: false };
3646
4086
  }
3647
4087
 
3648
4088
  function finishAbsorb({
@@ -3654,18 +4094,18 @@ function finishAbsorb({
3654
4094
  abandon,
3655
4095
  dryRun,
3656
4096
  outputFile,
3657
- intentCount,
4097
+ outcomeCount,
3658
4098
  allowConflict = false,
3659
4099
  followupMessage = null,
3660
4100
  }) {
3661
- // 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.
3662
4102
  const park = shouldAttachInProgress(outputFile) ? inProgressFile() : null;
3663
4103
  const plan = planInProgressOverlay(result.map((record) => record.event), park, {
3664
4104
  repairTail: true,
3665
4105
  });
3666
4106
  const overlay = plan && !plan.alreadyCommitted ? plan.records : [];
3667
4107
  const parkedOpen =
3668
- overlay.length > 0 ? openIntent(fold(overlay.map((record) => record.event))) : null;
4108
+ overlay.length > 0 ? openOutcome(fold(overlay.map((record) => record.event))) : null;
3669
4109
  const parkMappings = plan
3670
4110
  ? plan.mappings.map((mapping) => ({ ...mapping, side: 'parked' }))
3671
4111
  : [];
@@ -3683,7 +4123,7 @@ function finishAbsorb({
3683
4123
  const merged = flushOverlay ? [...result, ...overlay] : result;
3684
4124
  const effective = [...result, ...overlay].map((record) => record.event);
3685
4125
  fold(effective);
3686
- if (!conflict) openIntent(fold(effective));
4126
+ if (!conflict) openOutcome(fold(effective));
3687
4127
  if (!dryRun) {
3688
4128
  writeJsonl(outputFile, merged);
3689
4129
  applyDecisionCopies(copies, dryRun);
@@ -3693,7 +4133,7 @@ function finishAbsorb({
3693
4133
  }
3694
4134
  }
3695
4135
  if (
3696
- intentCount === 0 &&
4136
+ outcomeCount === 0 &&
3697
4137
  allMappings.length === 0 &&
3698
4138
  copies.length === 0 &&
3699
4139
  !abandoned &&
@@ -3704,11 +4144,11 @@ function finishAbsorb({
3704
4144
  printAbsorbReport({
3705
4145
  mappings: allMappings,
3706
4146
  abandoned,
3707
- intentCount,
4147
+ outcomeCount,
3708
4148
  });
3709
4149
  }
3710
4150
  if (conflict) {
3711
- 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');
3712
4152
  }
3713
4153
  if (followupMessage) printLine(followupMessage);
3714
4154
  return {
@@ -3746,7 +4186,7 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
3746
4186
  outputFile: options.outputFile,
3747
4187
  allowConflict: options.allowConflict,
3748
4188
  followupMessage: options.followupMessage,
3749
- intentCount: streams.theirsNew.filter((record) => record.event.type === 'begin').length,
4189
+ outcomeCount: streams.theirsNew.filter((record) => ['begin', 'import'].includes(record.event.type)).length,
3750
4190
  });
3751
4191
  }
3752
4192
 
@@ -3759,7 +4199,7 @@ function gitAbsorbRepairContext(records, decisionEntries) {
3759
4199
  base: gitMergeBaseFor('HEAD', pending),
3760
4200
  };
3761
4201
  }
3762
- if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateIntentBegins(records)) return null;
4202
+ if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateOutcomeStarts(records)) return null;
3763
4203
  const parents = gitMergeParents();
3764
4204
  if (!parents) return null;
3765
4205
  return {
@@ -3769,10 +4209,10 @@ function gitAbsorbRepairContext(records, decisionEntries) {
3769
4209
  }
3770
4210
 
3771
4211
  function absorbFromGitContext(context, { abandon, dryRun, outputFile }) {
3772
- const baseRecords = context.base ? gitIntentRecords(context.base) : [];
4212
+ const baseRecords = context.base ? gitOutcomeRecords(context.base) : [];
3773
4213
  return absorbFromStreams(
3774
- gitIntentRecords(context.ours),
3775
- gitIntentRecords(context.theirs),
4214
+ gitOutcomeRecords(context.ours),
4215
+ gitOutcomeRecords(context.theirs),
3776
4216
  baseRecords,
3777
4217
  {
3778
4218
  abandon,
@@ -3829,21 +4269,21 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
3829
4269
  baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
3830
4270
  baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
3831
4271
  });
3832
- const repaired = repairDuplicateIntentRecords(
4272
+ const repaired = repairDuplicateOutcomeRecords(
3833
4273
  loaded.records,
3834
4274
  decisionPlan.decisionMap,
3835
4275
  decisionPlan.hashMap
3836
4276
  );
3837
4277
  if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
3838
4278
  fail(
3839
- 'cannot determine which intent records own the duplicate decision; ' +
4279
+ 'cannot determine which outcome records own the duplicate decision; ' +
3840
4280
  'run absorb during the merge or provide the incoming log and decision directory'
3841
4281
  );
3842
4282
  }
3843
4283
  const result = repaired.records;
3844
4284
  const mappings = [...repaired.mappings, ...decisionPlan.mappings];
3845
4285
  const remappedIds = new Set(
3846
- mappings.filter((mapping) => mapping.kind === 'intent').map((mapping) => mapping.to)
4286
+ mappings.filter((mapping) => mapping.kind === 'outcome').map((mapping) => mapping.to)
3847
4287
  );
3848
4288
  const oursRecords = result.filter((record) => !remappedIds.has(record.event.id));
3849
4289
  return finishAbsorb({
@@ -3855,13 +4295,13 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
3855
4295
  abandon,
3856
4296
  dryRun,
3857
4297
  outputFile: oursFile,
3858
- intentCount: remappedIds.size,
4298
+ outcomeCount: remappedIds.size,
3859
4299
  });
3860
4300
  }
3861
4301
 
3862
4302
  const theirs = loadAbsorbSide(otherFile, otherFile);
3863
4303
  if (theirs.conflict) fail(`incoming log still contains conflict markers: ${otherFile}`);
3864
- const otherRoot = path.resolve(path.dirname(otherFile), '..');
4304
+ const otherRoot = path.resolve(path.dirname(otherFile), '..', '..');
3865
4305
  const otherHead = isGitWorkTree(otherRoot) ? gitCapture(['rev-parse', 'HEAD'], otherRoot) : null;
3866
4306
  const gitBase = otherHead ? gitCapture(['merge-base', 'HEAD', otherHead]) : gitMergeBase();
3867
4307
  const baseDecisionIds = gitBase
@@ -3876,21 +4316,23 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
3876
4316
  dryRun,
3877
4317
  outputFile: oursFile,
3878
4318
  oursDecisionEntries: listDecisionEntries(decisionDir()),
3879
- theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '.decision-log')),
4319
+ theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', 'madr')),
3880
4320
  baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
3881
4321
  baseDecisionIds,
3882
4322
  });
3883
4323
  }
3884
4324
 
3885
4325
  function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
3886
- const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true });
3887
- const ours = loadAbsorbSide(oursFile, oursFile);
3888
- 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 });
3889
4329
  if (base.conflict || ours.conflict || theirs.conflict) {
3890
4330
  fail('git merge driver received a log that still contains conflict markers');
3891
4331
  }
3892
4332
  const otherHead =
3893
- gitOtherHead() || gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
4333
+ gitOtherHead() ||
4334
+ gitFindCommitForFile(theirsFile, '.seal/outcomes/events.jsonl') ||
4335
+ gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
3894
4336
  const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
3895
4337
  let followupMessage = null;
3896
4338
  if (!otherHead) {
@@ -4019,20 +4461,21 @@ function executeVerificationCommand(command) {
4019
4461
 
4020
4462
  function runMachineVerification({ allowTrackedCommand = false } = {}) {
4021
4463
  const snapshot = withMutationLocks([logDir()], () => {
4022
- const intent = openIntent(fold(readEvents({ repairTail: true })));
4023
- if (!intent) fail('no intent in progress; nothing to verify');
4024
- if (intent.acceptance.length === 0) {
4025
- 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`);
4026
4468
  }
4027
- if (!intent.verify) fail(`intent ${intent.id} has no verification command`);
4469
+ if (!outcome.verify) fail(`outcome ${outcome.id} has no verification command`);
4028
4470
  const park = inProgressFile();
4029
- const parked = park ? parkedOpenIntent(park) : null;
4030
- const locallyProvenanced = hasMatchingLocalIntentProvenance(intent);
4471
+ const parked = park ? parkedOpenOutcome(park) : null;
4472
+ const locallyProvenanced = hasMatchingLocalOutcomeProvenance(outcome);
4031
4473
  return {
4032
- id: intent.id,
4033
- command: intent.verify,
4474
+ id: outcome.id,
4475
+ command: outcome.verify,
4476
+ contractHash: outcome.contractHash,
4034
4477
  requiresExplicitTrust:
4035
- (!parked || parked.id !== intent.id) && !locallyProvenanced,
4478
+ (!parked || parked.id !== outcome.id) && !locallyProvenanced,
4036
4479
  };
4037
4480
  });
4038
4481
 
@@ -4041,7 +4484,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4041
4484
  if (snapshot.requiresExplicitTrust && !allowTrackedCommand) {
4042
4485
  fail(
4043
4486
  `refusing to execute a verification command that DriftSeal cannot confirm was created locally: ${displayedCommand}\n` +
4044
- 'no matching local intent provenance was found; ' +
4487
+ 'no matching local outcome provenance was found; ' +
4045
4488
  'inspect the command, then re-run with --allow-tracked-command only if you trust it'
4046
4489
  );
4047
4490
  }
@@ -4057,6 +4500,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4057
4500
  verificationId: crypto.randomUUID(),
4058
4501
  ts: new Date().toISOString(),
4059
4502
  command: snapshot.command,
4503
+ contractHash: snapshot.contractHash,
4060
4504
  passed,
4061
4505
  exitCode,
4062
4506
  signal,
@@ -4068,23 +4512,764 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4068
4512
  head: gitCapture(['rev-parse', 'HEAD']),
4069
4513
  };
4070
4514
 
4071
- const intent = withMutationLocks([logDir()], () => {
4515
+ const outcome = withMutationLocks([logDir()], () => {
4072
4516
  const events = readEvents({ repairTail: true });
4073
- const current = openIntent(fold(events));
4074
- if (!current || current.id !== snapshot.id || current.verify !== snapshot.command) {
4075
- 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`);
4076
4521
  }
4077
4522
  events.push(appendEvent(verificationEvent));
4078
4523
  return fold(events).find((candidate) => candidate.id === snapshot.id);
4079
4524
  });
4080
4525
  printLine(`${snapshot.id} verification ${passed ? 'passed' : 'failed'} (exit ${exitCode})`);
4081
4526
  return {
4082
- intent: publicIntent(intent),
4083
- verification: publicVerification(intent.verification),
4527
+ outcome: publicOutcome(outcome),
4528
+ verification: publicVerification(outcome.verification),
4084
4529
  exitCode,
4085
4530
  };
4086
4531
  }
4087
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
+
4088
5273
  const commands = {
4089
5274
  begin(argv) {
4090
5275
  const { positionals, flags } = parseArgs(argv, {
@@ -4093,8 +5278,8 @@ const commands = {
4093
5278
  decision: 'multiple',
4094
5279
  force: 'boolean',
4095
5280
  }, 'begin');
4096
- const intent = positionals.join(' ').trim();
4097
- if (!intent) {
5281
+ const outcome = positionals.join(' ').trim();
5282
+ if (!outcome) {
4098
5283
  fail(usageFor('begin'));
4099
5284
  }
4100
5285
  const acceptance = [...new Set((flags.accept || []).map((criterion) => criterion.trim()))];
@@ -4112,18 +5297,18 @@ const commands = {
4112
5297
 
4113
5298
  const events = readEvents({ repairTail: true });
4114
5299
  const records = fold(events);
4115
- // 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.
4116
5301
  const open = records.filter((record) => record.status === 'in_progress');
4117
5302
  if (open.length > 1 && !flags.force) {
4118
5303
  fail(
4119
- `multiple intents in progress: ${open.map((record) => record.id).join(', ')}\n` +
5304
+ `multiple outcomes in progress: ${open.map((record) => record.id).join(', ')}\n` +
4120
5305
  'resolve them with driftseal absorb --abandon-ours or --abandon-theirs, ' +
4121
5306
  'or re-run with --force to abandon all of them'
4122
5307
  );
4123
5308
  }
4124
5309
  if (open.length === 1 && !flags.force) {
4125
5310
  fail(
4126
- `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` +
4127
5312
  `end it first (driftseal end) or re-run with --force to abandon it`
4128
5313
  );
4129
5314
  }
@@ -4143,7 +5328,7 @@ const commands = {
4143
5328
  type: 'begin',
4144
5329
  id,
4145
5330
  ts: new Date().toISOString(),
4146
- intent,
5331
+ outcome,
4147
5332
  acceptance,
4148
5333
  verify: flags.verify || null,
4149
5334
  decisions,
@@ -4151,7 +5336,44 @@ const commands = {
4151
5336
  }));
4152
5337
  const record = fold(events).find((candidate) => candidate.id === id);
4153
5338
  printLine(id);
4154
- 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);
4155
5377
  },
4156
5378
 
4157
5379
  verify(argv) {
@@ -4176,16 +5398,26 @@ const commands = {
4176
5398
  fail(`invalid status "${status}" (expected: ${END_STATUSES.join(', ')})`);
4177
5399
  }
4178
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
+
4179
5411
  let events = readEvents({ repairTail: true });
4180
5412
  let records = fold(events);
4181
5413
  let target;
4182
5414
  if (positionals.length > 0) {
4183
5415
  target = records.find((r) => r.id === positionals[0]);
4184
- if (!target) fail(`unknown intent id: ${positionals[0]}`);
4185
- 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})`);
4186
5418
  } else {
4187
- target = openIntent(records);
4188
- 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');
4189
5421
  }
4190
5422
 
4191
5423
  let completionWorkspace = null;
@@ -4193,14 +5425,15 @@ const commands = {
4193
5425
  if (status === 'completed' && target.acceptance.length > 0) {
4194
5426
  if (!target.verification || !target.verification.passed) {
4195
5427
  fail(
4196
- `cannot complete acceptance-bound intent ${target.id} without successful machine verification; ` +
5428
+ `cannot complete acceptance-bound outcome ${target.id} without successful machine verification; ` +
4197
5429
  'run: driftseal verify'
4198
5430
  );
4199
5431
  }
4200
5432
  completionWorkspace = workspaceFingerprint();
4201
- if (completionWorkspace !== target.verification.workspace) {
5433
+ if (completionWorkspace !== target.verification.workspace ||
5434
+ target.verification.contractHash !== target.contractHash) {
4202
5435
  fail(
4203
- `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; ` +
4204
5437
  'run: driftseal verify'
4205
5438
  );
4206
5439
  }
@@ -4217,7 +5450,7 @@ const commands = {
4217
5450
  );
4218
5451
  const record = fold(events).find((candidate) => candidate.id === target.id);
4219
5452
  printLine(`${target.id} ${terminalStatus}`);
4220
- return publicIntent(record);
5453
+ return publicOutcome(record);
4221
5454
  }
4222
5455
 
4223
5456
  if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
@@ -4247,7 +5480,7 @@ const commands = {
4247
5480
  }
4248
5481
  if (problems.length > 0) {
4249
5482
  fail(
4250
- `cannot close linked intent ${target.id} as ${status}:\n` +
5483
+ `cannot close linked outcome ${target.id} as ${status}:\n` +
4251
5484
  problems.map((problem) => ` - ${problem}`).join('\n') +
4252
5485
  `\nrun: driftseal decision update <id> --note "<what changed or was confirmed>"`
4253
5486
  );
@@ -4263,29 +5496,40 @@ const commands = {
4263
5496
  verifyResult: flags['verify-result'] || null,
4264
5497
  verificationId: completionVerificationId,
4265
5498
  workspace: completionWorkspace,
5499
+ contractHash: target.contractHash,
4266
5500
  head: gitCapture(['rev-parse', 'HEAD']),
4267
5501
  }));
4268
5502
  const record = fold(events).find((candidate) => candidate.id === target.id);
4269
5503
  printLine(`${target.id} ${status}`);
4270
- return publicIntent(record);
5504
+ return publicOutcome(record);
4271
5505
  },
4272
5506
 
4273
5507
  status(argv, { readOnly = false } = {}) {
4274
5508
  const { positionals } = parseArgs(argv, {}, 'status');
4275
5509
  if (positionals.length > 0) fail(usageFor('status'));
4276
- 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 })));
4277
5517
  if (!open) {
4278
- printLine('no intent in progress');
5518
+ printLine('no outcome in progress');
4279
5519
  return null;
4280
5520
  }
4281
5521
  printLine(render(open));
4282
- return publicIntent(open);
5522
+ return publicOutcome(open);
4283
5523
  },
4284
5524
 
4285
5525
  log(argv, { readOnly = false } = {}) {
4286
5526
  const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' }, 'log');
4287
5527
  if (positionals.length > 0) fail(usageFor('log'));
4288
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
+ }
4289
5533
  if (!flags.all) records = records.filter((record) => !record.reclaimed);
4290
5534
  if (flags.last) {
4291
5535
  const n = positiveInteger(flags.last, '--last');
@@ -4296,7 +5540,7 @@ const commands = {
4296
5540
  return [];
4297
5541
  }
4298
5542
  printLine(records.map(render).join('\n\n'));
4299
- return records.map(publicIntent);
5543
+ return records.map(publicOutcome);
4300
5544
  },
4301
5545
 
4302
5546
  reclaim(argv) {
@@ -4321,16 +5565,16 @@ const commands = {
4321
5565
  const ids = [...new Set(positionals)];
4322
5566
  targets = ids.map((id) => {
4323
5567
  const record = records.find((candidate) => candidate.id === id);
4324
- if (!record) fail(`unknown intent id: ${id}`);
5568
+ if (!record) fail(`unknown outcome id: ${id}`);
4325
5569
  if (record.status === 'in_progress') {
4326
- fail(`cannot reclaim intent ${id} while it is in_progress`);
5570
+ fail(`cannot reclaim outcome ${id} while it is in_progress`);
4327
5571
  }
4328
- if (record.reclaimed) fail(`intent ${id} is already reclaimed`);
5572
+ if (record.reclaimed) fail(`outcome ${id} is already reclaimed`);
4329
5573
  const routine = ['failed', 'abandoned'].includes(record.status) &&
4330
5574
  record.decisions.length === 0;
4331
5575
  if (!routine && !flags.force) {
4332
5576
  fail(
4333
- `intent ${id} is ${record.status}` +
5577
+ `outcome ${id} is ${record.status}` +
4334
5578
  (record.decisions.length > 0 ? ' and linked to decisions' : '') +
4335
5579
  '; re-run with --force to reclaim it anyway'
4336
5580
  );
@@ -4338,7 +5582,7 @@ const commands = {
4338
5582
  return record;
4339
5583
  });
4340
5584
  } else {
4341
- if (flags.force) fail('--force requires explicit intent ids');
5585
+ if (flags.force) fail('--force requires explicit outcome ids');
4342
5586
  const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
4343
5587
  targets = records.filter(
4344
5588
  (record) =>
@@ -4349,14 +5593,14 @@ const commands = {
4349
5593
  Date.parse(record.tsEnd) < cutoff
4350
5594
  );
4351
5595
  if (targets.length === 0) {
4352
- printLine('no reclaimable intents');
5596
+ printLine('no reclaimable outcomes');
4353
5597
  return [];
4354
5598
  }
4355
5599
  }
4356
5600
 
4357
5601
  if (flags['dry-run']) {
4358
- printLine(targets.map((record) => `${record.id} ${record.status} — ${record.intent}`).join('\n'));
4359
- return targets.map(publicIntent);
5602
+ printLine(targets.map((record) => `${record.id} ${record.status} — ${record.outcome}`).join('\n'));
5603
+ return targets.map(publicOutcome);
4360
5604
  }
4361
5605
 
4362
5606
  let events = readEvents({ repairTail: true });
@@ -4374,7 +5618,7 @@ const commands = {
4374
5618
  targets.some((target) => target.id === record.id)
4375
5619
  );
4376
5620
  printLine(targets.map((record) => `${record.id} reclaimed`).join('\n'));
4377
- return reclaimed.map(publicIntent);
5621
+ return reclaimed.map(publicOutcome);
4378
5622
  },
4379
5623
 
4380
5624
  unreclaim(argv) {
@@ -4385,8 +5629,8 @@ const commands = {
4385
5629
  }
4386
5630
  const events = readEvents({ repairTail: true });
4387
5631
  const record = fold(events).find((candidate) => candidate.id === positionals[0]);
4388
- if (!record) fail(`unknown intent id: ${positionals[0]}`);
4389
- 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`);
4390
5634
  events.push(
4391
5635
  appendEvent({
4392
5636
  type: 'unreclaim',
@@ -4397,7 +5641,7 @@ const commands = {
4397
5641
  );
4398
5642
  const restored = fold(events).find((candidate) => candidate.id === record.id);
4399
5643
  printLine(`${record.id} unreclaimed`);
4400
- return publicIntent(restored);
5644
+ return publicOutcome(restored);
4401
5645
  },
4402
5646
 
4403
5647
  decision(argv) {
@@ -4437,6 +5681,7 @@ const commands = {
4437
5681
  options: flags.option || [],
4438
5682
  consequences: flags.consequence || [],
4439
5683
  });
5684
+ ensureV2OutcomeLogExists();
4440
5685
  ensureDirectoryDurable(decisionDir());
4441
5686
  atomicCreateFile(path.join(decisionDir(), file), content);
4442
5687
  const decision = findDecision(String(id));
@@ -4453,22 +5698,22 @@ const commands = {
4453
5698
 
4454
5699
  let events = readEvents({ repairTail: true });
4455
5700
  let records = fold(events);
4456
- let intent = openIntent(records);
4457
- if (!intent) fail('decision update requires an intent in progress');
4458
- 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);
4459
5704
  records = fold(events);
4460
- intent = openIntent(records);
5705
+ outcome = openOutcome(records);
4461
5706
  const index = decisionIndex();
4462
5707
  const decision = findDecision(positionals[0], index);
4463
- if (!intent.decisions.includes(decision.id)) {
4464
- 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}`);
4465
5710
  }
4466
5711
 
4467
5712
  const status = (flags.status || decision.status).toLowerCase();
4468
5713
  if (!DECISION_STATUSES.includes(status)) {
4469
5714
  fail(`invalid decision status "${status}" (expected: ${DECISION_STATUSES.join(', ')})`);
4470
5715
  }
4471
- const update = prepareDecisionReconciliation(decision, intent.id, status, note);
5716
+ const update = prepareDecisionReconciliation(decision, outcome.id, status, note);
4472
5717
  const { target, content, ...prepareEvent } = update;
4473
5718
  appendEvent(prepareEvent);
4474
5719
  if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_RECONCILIATION_PREPARE === '1') {
@@ -4480,7 +5725,7 @@ const commands = {
4480
5725
  }
4481
5726
  appendEvent(reconciliationEvent('decision_reconcile_commit', update));
4482
5727
  const reconciled = findDecision(decision.id);
4483
- printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
5728
+ printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${outcome.id})`);
4484
5729
  return publicDecision(reconciled, { includeContent: true });
4485
5730
  }
4486
5731
 
@@ -4578,6 +5823,60 @@ const commands = {
4578
5823
  return absorbLogs(positionals[0], flags.decisions, { abandon, dryRun });
4579
5824
  },
4580
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
+
4581
5880
  init(argv) {
4582
5881
  const { positionals, flags } = parseArgs(argv, { lang: 'single', 'local-log': 'boolean' }, 'init');
4583
5882
  if (positionals.length > 0) fail(usageFor('init'));
@@ -4602,11 +5901,15 @@ const commands = {
4602
5901
  content: updated,
4603
5902
  marker: INTENT_PROTOCOL_MARKER,
4604
5903
  endMarker: INTENT_PROTOCOL_END,
4605
- versionPattern: /^<!-- driftseal-version: (\d+) -->\r?$/m,
5904
+ versionPattern: /^<!-- driftseal-version: (\d+(?:\.\d+)?) -->\r?$/m,
4606
5905
  replacement: intentBlock,
4607
5906
  knownManagedBlocks: [
4608
5907
  ...sourceLanguages.flatMap((source) => [
4609
5908
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
5909
+ protocolEol(v1IntentProtocolBlock(14, source), eol),
5910
+ protocolEol(v1IntentProtocolBlock(14, source, true), eol),
5911
+ protocolEol(previousIntentProtocolBlock(13, source), eol),
5912
+ protocolEol(previousIntentProtocolBlock(13, source, true), eol),
4610
5913
  protocolEol(previousIntentProtocolBlock(12, source), eol),
4611
5914
  protocolEol(previousIntentProtocolBlock(12, source, true), eol),
4612
5915
  protocolEol(previousIntentProtocolBlock(11, source), eol),
@@ -4629,11 +5932,15 @@ const commands = {
4629
5932
  content: updated,
4630
5933
  marker: DECISION_PROTOCOL_MARKER,
4631
5934
  endMarker: DECISION_PROTOCOL_END,
4632
- versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
5935
+ versionPattern: /^<!-- driftseal-decisions-version: (\d+(?:\.\d+)?) -->\r?$/m,
4633
5936
  replacement: decisionBlock,
4634
5937
  knownManagedBlocks: [
4635
5938
  ...sourceLanguages.flatMap((source) => [
4636
5939
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
5940
+ protocolEol(v1DecisionProtocolBlock(14, source), eol),
5941
+ protocolEol(v1DecisionProtocolBlock(14, source, true), eol),
5942
+ protocolEol(previousDecisionProtocolBlock(13, source), eol),
5943
+ protocolEol(previousDecisionProtocolBlock(13, source, true), eol),
4637
5944
  protocolEol(previousDecisionProtocolBlock(12, source), eol),
4638
5945
  protocolEol(previousDecisionProtocolBlock(12, source, true), eol),
4639
5946
  protocolEol(previousDecisionProtocolBlock(11, source), eol),
@@ -4689,37 +5996,39 @@ const commands = {
4689
5996
  printLine(`Configured git merge attribute: ${attributes.target}`);
4690
5997
  }
4691
5998
  if (driver.changed) {
4692
- printLine('Configured local git merge driver for DriftSeal intent logs');
5999
+ printLine('Configured local git merge driver for DriftSeal outcome logs');
4693
6000
  }
4694
6001
  return { changed: true, target };
4695
6002
  },
4696
6003
 
4697
6004
  help() {
4698
- printLine(`DriftSeal — Seal the intent. Stop the drift.
6005
+ printLine(`DriftSeal — Seal the outcome. Stop the drift.
4699
6006
 
4700
- Intent-level write-ahead log for agent sessions.
6007
+ Outcome-level write-ahead log for agent sessions.
4701
6008
 
4702
6009
  usage:
4703
- driftseal begin "<intent>" [--accept "<observable outcome>"] [--verify "<command>"]
6010
+ driftseal begin "<outcome>" [--accept "<observable result>"] [--verify "<command>"]
4704
6011
  [--decision <id>] [--force]
6012
+ driftseal extend "<same-outcome addition>" [--accept "<observable result>"]
6013
+ [--verify "<cumulative command>"] [--decision <id>]
4705
6014
  driftseal verify [--allow-tracked-command]
4706
6015
  run the declared command and bind its result
4707
- to the current Git-visible workspace contents
6016
+ to the current contract and Git-visible workspace
4708
6017
  driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
4709
- driftseal status show the intent currently in progress (re-anchor after drift)
4710
- 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)
4711
6020
  driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]
4712
6021
  hide meaningless closed records without deleting them
4713
6022
  driftseal unreclaim <id> --reason "<why>"
4714
6023
  restore a reclaimed record to the visible log
4715
6024
  driftseal absorb [other-events.jsonl] [--decisions <dir>]
4716
6025
  [--abandon-theirs | --abandon-ours] [--dry-run]
4717
- merge another intent log, remapping colliding ids
6026
+ merge another outcome log, remapping colliding ids
4718
6027
  driftseal absorb --git <base> <ours> <theirs>
4719
- git merge driver for .intent-log/events.jsonl
6028
+ git merge driver for .seal/outcomes/events.jsonl
4720
6029
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
4721
6030
  driftseal decision update <id> [--status STATUS] --note "..."
4722
- reconcile a linked decision in the open intent
6031
+ reconcile a linked decision in the open outcome
4723
6032
  driftseal decision list [--status STATUS] [--last N | --count]
4724
6033
  list or count filtered MADR decision records
4725
6034
  driftseal decision show <id> print one MADR decision record
@@ -4736,8 +6045,17 @@ usage:
4736
6045
  emit the reminder a lifecycle hook injects; never blocks
4737
6046
  driftseal init [--lang <tag>] [--local-log]
4738
6047
  inject protocols into ./AGENTS.md and configure the git merge driver
4739
- --lang sets the intent/decision log language (BCP 47, default: en)
6048
+ --lang sets the outcome/MADR log language (BCP 47, default: en)
4740
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)
4741
6059
  driftseal --version | -V print the installed DriftSeal version
4742
6060
  driftseal help
4743
6061
 
@@ -4747,9 +6065,11 @@ decision add options:
4747
6065
  --option "..." repeat for each considered option
4748
6066
  --consequence "..." repeat for each consequence
4749
6067
 
4750
- intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
4751
- decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory
4752
- 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.`);
4753
6073
  return null;
4754
6074
  },
4755
6075
 
@@ -4773,12 +6093,14 @@ function requestedEndStatus(argv) {
4773
6093
  */
4774
6094
  const VALUE_TAKING_FLAGS = {
4775
6095
  begin: ['--accept', '--verify', '-v', '--decision'],
6096
+ extend: ['--accept', '--verify', '-v', '--decision'],
4776
6097
  end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
4777
6098
  log: ['--last', '-n'],
4778
6099
  reclaim: ['--reason', '-r', '--older-than'],
4779
6100
  unreclaim: ['--reason', '-r'],
4780
6101
  absorb: ['--decisions'],
4781
6102
  init: ['--lang'],
6103
+ migrate: ['--source-log', '--source-decisions', '--destination', '--plan', '--plan-json'],
4782
6104
  'decision add': ['--context', '-c', '--outcome', '-o', '--status', '-s', '--driver', '--option', '--consequence'],
4783
6105
  'decision update': ['--status', '-s', '--note', '-n'],
4784
6106
  'decision list': ['--last', '-n', '--status', '-s'],
@@ -4809,8 +6131,16 @@ function mutationResources(cmd, argv) {
4809
6131
  if (cmd === 'mcp') return [parseMcpInstallRequest(argv).configDir];
4810
6132
  if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
4811
6133
  if (cmd === 'init') return [process.cwd()];
6134
+ if (cmd === 'migrate') return [process.cwd()];
4812
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
+ }
4813
6140
  if (cmd === 'absorb') return [logDir(), decisionDir()];
6141
+ if (cmd === 'end' && legacyParkedIntent()) {
6142
+ return [path.dirname(legacyIntentLogFile())];
6143
+ }
4814
6144
  if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
4815
6145
  return [logDir()];
4816
6146
  }
@@ -4820,6 +6150,258 @@ function mutationResources(cmd, argv) {
4820
6150
  return [logDir(), decisionDir()];
4821
6151
  }
4822
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
+
4823
6405
  function dispatch(argv) {
4824
6406
  const [cmd, ...rest] = argv;
4825
6407
  if (cmd === '--version' || cmd === '-V') {
@@ -4836,8 +6418,10 @@ function dispatch(argv) {
4836
6418
  // probe is spec-aware so it never bypasses the lock for a real mutation: a
4837
6419
  // --help token consumed as a flag value is left for parseArgs to reject.
4838
6420
  if (wantsHelpBeforeLock(cmd, rest)) return { data: fn(rest), exitCode: 0 };
6421
+ assertV2RepositoryReady(cmd, rest);
4839
6422
  const mutates =
4840
- ['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') ||
4841
6425
  (cmd === 'hook' && rest[0] === 'install') ||
4842
6426
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
4843
6427
  const readsIntentLog =
@@ -4854,6 +6438,8 @@ function dispatch(argv) {
4854
6438
  const hookFile = hookLogFile();
4855
6439
  if (!hookFile) return { data: fn(rest), exitCode: 0 };
4856
6440
  resources = [path.dirname(hookFile)];
6441
+ } else if (legacyParkedIntent()) {
6442
+ resources = [path.dirname(legacyIntentLogFile())];
4857
6443
  } else {
4858
6444
  resources = [logDir()];
4859
6445
  }
@@ -4930,6 +6516,10 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4930
6516
  try {
4931
6517
  process.chdir(fixedRoot);
4932
6518
  if (isolateStorage) {
6519
+ isolatedV1Detection = {
6520
+ home: process.env.DRIFTSEAL_HOME || null,
6521
+ decisions: process.env.DRIFTSEAL_DECISION_HOME || null,
6522
+ };
4933
6523
  delete process.env.DRIFTSEAL_HOME;
4934
6524
  delete process.env.DRIFTSEAL_DECISION_HOME;
4935
6525
  }
@@ -4950,6 +6540,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4950
6540
  } finally {
4951
6541
  activeOutput = previousOutput;
4952
6542
  process.chdir(previousCwd);
6543
+ isolatedV1Detection = null;
4953
6544
  if (previousIntentHome === undefined) delete process.env.DRIFTSEAL_HOME;
4954
6545
  else process.env.DRIFTSEAL_HOME = previousIntentHome;
4955
6546
  if (previousDecisionHome === undefined) delete process.env.DRIFTSEAL_DECISION_HOME;
@@ -4978,14 +6569,21 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
4978
6569
  status() {
4979
6570
  return call(['status']);
4980
6571
  },
4981
- begin({ intent, acceptance = [], verify, decisions = [], force = false }) {
4982
- const argv = ['begin', intent];
6572
+ begin({ outcome, acceptance = [], verify, decisions = [], force = false }) {
6573
+ const argv = ['begin', outcome];
4983
6574
  for (const criterion of acceptance) appendFlag(argv, '--accept', criterion);
4984
6575
  appendFlag(argv, '--verify', verify);
4985
6576
  for (const decision of decisions) appendFlag(argv, '--decision', decision);
4986
6577
  if (force) argv.push('--force');
4987
6578
  return call(argv);
4988
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
+ },
4989
6587
  verify({ allowTrackedCommand = false } = {}) {
4990
6588
  return call(['verify', ...(allowTrackedCommand ? ['--allow-tracked-command'] : [])]);
4991
6589
  },
@@ -5047,6 +6645,27 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
5047
6645
  decisionShow({ id }) {
5048
6646
  return call(['decision', 'show', String(id)]);
5049
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
+ },
5050
6669
  init() {
5051
6670
  return call(['init']);
5052
6671
  },