driftseal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1601 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * DriftSeal — Seal the intent. Stop the drift.
6
+ *
7
+ * Intent-level write-ahead log and MADR decision log for agentic coding sessions.
8
+ *
9
+ * Protocol per work round:
10
+ * 1. driftseal begin "<intent>" [--verify "<how to verify>"] (before touching anything)
11
+ * 2. execute the intent
12
+ * 3. driftseal end [--status ...] [--note ...] [--verify-result ...] (reconcile against intent)
13
+ *
14
+ * Events are appended to an append-only JSONL log (WAL semantics):
15
+ * { "type": "begin", "id", "ts", "intent", "verify" }
16
+ * { "type": "end", "id", "ts", "status", "note", "verifyResult" }
17
+ *
18
+ * Intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl in cwd.
19
+ * Decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in cwd.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+ const os = require('os');
26
+ const { execFileSync } = require('child_process');
27
+
28
+ const END_STATUSES = ['completed', 'partial', 'failed', 'abandoned'];
29
+ const DECISION_STATUSES = [
30
+ 'proposed',
31
+ 'accepted',
32
+ 'rejected',
33
+ 'deferred',
34
+ 'deprecated',
35
+ 'superseded',
36
+ ];
37
+ const EVENT_SCHEMA_VERSION = 2;
38
+ const PROTOCOL_VERSION = 4;
39
+ const LOCK_STALE_MS = 30 * 60 * 1000;
40
+ const LOCK_INIT_STALE_MS = 5 * 1000;
41
+ const MAX_DECISION_SLUG_LENGTH = 180;
42
+
43
+ if (process.env._DRIFTSEAL_TEST_UMASK) {
44
+ process.umask(Number.parseInt(process.env._DRIFTSEAL_TEST_UMASK, 8));
45
+ }
46
+
47
+ function logDir() {
48
+ return process.env.DRIFTSEAL_HOME || path.join(process.cwd(), '.intent-log');
49
+ }
50
+
51
+ function logFile() {
52
+ return path.join(logDir(), 'events.jsonl');
53
+ }
54
+
55
+ function decisionDir() {
56
+ return process.env.DRIFTSEAL_DECISION_HOME || path.join(process.cwd(), '.decision-log');
57
+ }
58
+
59
+ function normalizeEvent(event, line) {
60
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
61
+ fail(`invalid event object on log line ${line}`);
62
+ }
63
+ if (
64
+ event.schemaVersion !== undefined &&
65
+ (!Number.isSafeInteger(event.schemaVersion) || event.schemaVersion < 1)
66
+ ) {
67
+ fail(`invalid event schema version on log line ${line}`);
68
+ }
69
+ if (event.schemaVersion > EVENT_SCHEMA_VERSION) {
70
+ fail(
71
+ `event schema ${event.schemaVersion} requires a newer DriftSeal client (supported: ${EVENT_SCHEMA_VERSION})`
72
+ );
73
+ }
74
+ if (typeof event.type !== 'string' || typeof event.id !== 'string' || event.id.length === 0) {
75
+ fail(`invalid event type or intent id on log line ${line}`);
76
+ }
77
+
78
+ if (event.type === 'begin') {
79
+ if (typeof event.intent !== 'string' || event.intent.trim().length === 0) {
80
+ fail(`invalid begin event on log line ${line}`);
81
+ }
82
+ if (!Array.isArray(event.decisions) && event.decisions !== undefined) {
83
+ fail(`invalid decisions list on log line ${line}`);
84
+ }
85
+ const decisions = (event.decisions || []).map(normalizeDecisionId);
86
+ if (new Set(decisions).size !== decisions.length) {
87
+ fail(`duplicate linked decision on log line ${line}`);
88
+ }
89
+ return { ...event, decisions };
90
+ }
91
+
92
+ if (event.type === 'end') {
93
+ if (!END_STATUSES.includes(event.status)) fail(`invalid end event on log line ${line}`);
94
+ return event;
95
+ }
96
+
97
+ if (
98
+ event.type === 'decision_reconcile' ||
99
+ event.type === 'decision_reconcile_prepare' ||
100
+ event.type === 'decision_reconcile_commit' ||
101
+ event.type === 'decision_reconcile_abort' ||
102
+ event.type === 'decision_reconcile_cancel'
103
+ ) {
104
+ const schemaVersion = event.schemaVersion || 1;
105
+ if (event.type === 'decision_reconcile' && schemaVersion >= 2) {
106
+ fail(`legacy decision reconciliation is not valid in schema ${schemaVersion} on log line ${line}`);
107
+ }
108
+ if (
109
+ event.type !== 'decision_reconcile' &&
110
+ (typeof event.reconciliationId !== 'string' || event.reconciliationId.length === 0)
111
+ ) {
112
+ fail(`invalid reconciliation id on log line ${line}`);
113
+ }
114
+ const decisionId = normalizeDecisionId(event.decisionId);
115
+ if (
116
+ event.type !== 'decision_reconcile_abort' &&
117
+ event.type !== 'decision_reconcile_cancel' &&
118
+ !DECISION_STATUSES.includes(event.toStatus)
119
+ ) {
120
+ fail(`invalid reconciliation status on log line ${line}`);
121
+ }
122
+ for (const field of ['oldHash', 'newHash', 'fileHash']) {
123
+ if (event[field] !== undefined && !/^[a-f0-9]{64}$/.test(event[field])) {
124
+ fail(`invalid reconciliation ${field} on log line ${line}`);
125
+ }
126
+ }
127
+ if (
128
+ event.type === 'decision_reconcile_prepare' &&
129
+ (!event.oldHash || !event.newHash || !DECISION_STATUSES.includes(event.fromStatus))
130
+ ) {
131
+ fail(`incomplete reconciliation prepare on log line ${line}`);
132
+ }
133
+ if (
134
+ event.type === 'decision_reconcile_commit' &&
135
+ (!event.fileHash || !DECISION_STATUSES.includes(event.fromStatus))
136
+ ) {
137
+ fail(`incomplete reconciliation commit on log line ${line}`);
138
+ }
139
+ if (
140
+ event.type === 'decision_reconcile_cancel' &&
141
+ !['failed', 'abandoned'].includes(event.intentStatus)
142
+ ) {
143
+ fail(`invalid reconciliation cancellation on log line ${line}`);
144
+ }
145
+ return { ...event, decisionId };
146
+ }
147
+
148
+ fail(`unknown event type "${event.type}" on log line ${line}`);
149
+ }
150
+
151
+ function readEvents({ repairTail = false } = {}) {
152
+ const file = logFile();
153
+ if (!fs.existsSync(file)) return [];
154
+ let content = fs.readFileSync(file, 'utf8');
155
+ const rawLines = content.split('\n');
156
+ if (content.length > 0 && !content.endsWith('\n')) {
157
+ const tail = rawLines.at(-1);
158
+ try {
159
+ JSON.parse(tail);
160
+ } catch {
161
+ if (!repairTail) fail(`corrupt final log line in ${file}`);
162
+ const validLength = content.lastIndexOf('\n') + 1;
163
+ const fd = fs.openSync(file, 'r+');
164
+ try {
165
+ fs.ftruncateSync(fd, Buffer.byteLength(content.slice(0, validLength), 'utf8'));
166
+ fs.fsyncSync(fd);
167
+ } finally {
168
+ fs.closeSync(fd);
169
+ }
170
+ content = content.slice(0, validLength);
171
+ }
172
+ }
173
+ return content
174
+ .split('\n')
175
+ .filter((line) => line.trim().length > 0)
176
+ .map((line, i) => {
177
+ try {
178
+ return normalizeEvent(JSON.parse(line), i + 1);
179
+ } catch {
180
+ fail(`corrupt log line ${i + 1} in ${file}`);
181
+ }
182
+ });
183
+ }
184
+
185
+ function fsyncDirectory(directory) {
186
+ let fd;
187
+ try {
188
+ fd = fs.openSync(directory, 'r');
189
+ fs.fsyncSync(fd);
190
+ } catch (err) {
191
+ if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EPERM'].includes(err.code)) throw err;
192
+ } finally {
193
+ if (fd !== undefined) fs.closeSync(fd);
194
+ }
195
+ }
196
+
197
+ function ensureDirectoryDurable(directory) {
198
+ const target = path.resolve(directory);
199
+ const missing = [];
200
+ let cursor = target;
201
+ while (!fs.existsSync(cursor)) {
202
+ missing.push(cursor);
203
+ const parent = path.dirname(cursor);
204
+ if (parent === cursor) break;
205
+ cursor = parent;
206
+ }
207
+ fs.mkdirSync(target, { recursive: true });
208
+ for (const created of missing.reverse()) fsyncDirectory(path.dirname(created));
209
+ }
210
+
211
+ function appendEvent(event) {
212
+ ensureDirectoryDurable(logDir());
213
+ const file = logFile();
214
+ const existed = fs.existsSync(file);
215
+ const storedEvent = { schemaVersion: EVENT_SCHEMA_VERSION, ...event };
216
+ const line = Buffer.from(
217
+ JSON.stringify(storedEvent) + '\n',
218
+ 'utf8'
219
+ );
220
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
221
+ try {
222
+ const stat = fs.fstatSync(fd);
223
+ if (stat.size > 0) {
224
+ const lastByte = Buffer.alloc(1);
225
+ const readFd = fs.openSync(file, 'r');
226
+ try {
227
+ fs.readSync(readFd, lastByte, 0, 1, stat.size - 1);
228
+ } finally {
229
+ fs.closeSync(readFd);
230
+ }
231
+ if (lastByte[0] !== 0x0a) fs.writeSync(fd, Buffer.from('\n'));
232
+ }
233
+ let offset = 0;
234
+ while (offset < line.length) offset += fs.writeSync(fd, line, offset, line.length - offset);
235
+ fs.fsyncSync(fd);
236
+ } finally {
237
+ fs.closeSync(fd);
238
+ }
239
+ if (!existed) fsyncDirectory(logDir());
240
+ return storedEvent;
241
+ }
242
+
243
+ function contentHash(content) {
244
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
245
+ }
246
+
247
+ function atomicWriteFile(target, content) {
248
+ const existed = fs.existsSync(target);
249
+ const mode = existed ? fs.statSync(target).mode & 0o777 : 0o644;
250
+ const temp = path.join(
251
+ path.dirname(target),
252
+ `.${path.basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`
253
+ );
254
+ let fd;
255
+ try {
256
+ fd = fs.openSync(temp, 'wx', mode);
257
+ if (existed) fs.fchmodSync(fd, mode);
258
+ fs.writeFileSync(fd, content, 'utf8');
259
+ fs.fsyncSync(fd);
260
+ fs.closeSync(fd);
261
+ fd = undefined;
262
+ fs.renameSync(temp, target);
263
+ fsyncDirectory(path.dirname(target));
264
+ } catch (err) {
265
+ if (fd !== undefined) {
266
+ try {
267
+ fs.closeSync(fd);
268
+ } catch {}
269
+ }
270
+ try {
271
+ fs.unlinkSync(temp);
272
+ } catch {}
273
+ throw err;
274
+ }
275
+ }
276
+
277
+ function atomicCreateFile(target, content, mode = 0o644) {
278
+ const temp = path.join(
279
+ path.dirname(target),
280
+ `.${path.basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`
281
+ );
282
+ let fd;
283
+ try {
284
+ fd = fs.openSync(temp, 'wx', mode);
285
+ fs.writeFileSync(fd, content, 'utf8');
286
+ fs.fsyncSync(fd);
287
+ fs.closeSync(fd);
288
+ fd = undefined;
289
+ fs.linkSync(temp, target);
290
+ fs.unlinkSync(temp);
291
+ fsyncDirectory(path.dirname(target));
292
+ } catch (err) {
293
+ if (fd !== undefined) {
294
+ try {
295
+ fs.closeSync(fd);
296
+ } catch {}
297
+ }
298
+ try {
299
+ fs.unlinkSync(temp);
300
+ } catch {}
301
+ throw err;
302
+ }
303
+ }
304
+
305
+ function processIsAlive(pid) {
306
+ try {
307
+ process.kill(pid, 0);
308
+ return true;
309
+ } catch (err) {
310
+ return err.code === 'EPERM';
311
+ }
312
+ }
313
+
314
+ function processStartToken(pid) {
315
+ if (process.env._DRIFTSEAL_TEST_NO_PROCESS_START_TOKEN === '1') return null;
316
+ try {
317
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
318
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
319
+ return fields[19] || null;
320
+ } catch {}
321
+
322
+ if (process.platform === 'darwin') {
323
+ try {
324
+ const started = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], {
325
+ encoding: 'utf8',
326
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C', TZ: 'UTC' },
327
+ stdio: ['ignore', 'pipe', 'ignore'],
328
+ timeout: 1000,
329
+ }).trim();
330
+ return started ? `darwin:${started}` : null;
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ if (process.platform === 'win32') {
337
+ try {
338
+ const script =
339
+ `$start = (Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks; ` +
340
+ '[Console]::Write($start.ToString([Globalization.CultureInfo]::InvariantCulture))';
341
+ const started = execFileSync(
342
+ 'powershell.exe',
343
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
344
+ {
345
+ encoding: 'utf8',
346
+ stdio: ['ignore', 'pipe', 'ignore'],
347
+ timeout: 3000,
348
+ windowsHide: true,
349
+ }
350
+ ).trim();
351
+ return started ? `win32:${started}` : null;
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+
357
+ return null;
358
+ }
359
+
360
+ function clearStaleLock(lock) {
361
+ let stat;
362
+ let owner;
363
+ try {
364
+ stat = fs.statSync(lock);
365
+ owner = JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8'));
366
+ } catch {
367
+ owner = null;
368
+ }
369
+
370
+ const staleAfter = owner ? LOCK_STALE_MS : LOCK_INIT_STALE_MS;
371
+ const oldEnough = stat && Date.now() - stat.mtimeMs > staleAfter;
372
+ if (
373
+ owner &&
374
+ owner.hostname === os.hostname() &&
375
+ Number.isSafeInteger(owner.pid) &&
376
+ owner.pid > 0
377
+ ) {
378
+ const currentStart = processStartToken(owner.pid);
379
+ const alive = processIsAlive(owner.pid);
380
+ const comparable = Boolean(owner.processStart && currentStart);
381
+ if (alive && comparable && owner.processStart === currentStart) return false;
382
+ if (alive && !comparable && !oldEnough) return false;
383
+ } else if (owner && !oldEnough) {
384
+ return false;
385
+ } else if (!owner && !oldEnough) {
386
+ return false;
387
+ }
388
+
389
+ const tombstone = `${lock}.stale.${crypto.randomUUID()}`;
390
+ try {
391
+ fs.renameSync(lock, tombstone);
392
+ } catch (err) {
393
+ return err.code === 'ENOENT';
394
+ }
395
+ fs.rmSync(tombstone, { recursive: true, force: true });
396
+ fsyncDirectory(path.dirname(lock));
397
+ return true;
398
+ }
399
+
400
+ function acquireMutationLock(resource) {
401
+ ensureDirectoryDurable(resource);
402
+ const lock = path.join(resource, '.driftseal.lock');
403
+ for (let attempt = 0; attempt < 2; attempt++) {
404
+ try {
405
+ fs.mkdirSync(lock, { mode: 0o700 });
406
+ break;
407
+ } catch (err) {
408
+ if (err.code !== 'EEXIST' || attempt > 0 || !clearStaleLock(lock)) {
409
+ fail(`another DriftSeal mutation is in progress (lock: ${lock})`);
410
+ }
411
+ }
412
+ }
413
+ let token;
414
+ let ownerFile;
415
+ try {
416
+ if (process.env._DRIFTSEAL_TEST_FAIL_LOCK_OWNER_INIT === '1') {
417
+ throw new Error('simulated lock owner initialization failure');
418
+ }
419
+ token = crypto.randomUUID();
420
+ ownerFile = path.join(lock, 'owner.json');
421
+ const fd = fs.openSync(ownerFile, 'wx', 0o600);
422
+ try {
423
+ fs.writeFileSync(
424
+ fd,
425
+ JSON.stringify({
426
+ token,
427
+ pid: process.pid,
428
+ hostname: os.hostname(),
429
+ processStart: processStartToken(process.pid),
430
+ startedAt: new Date().toISOString(),
431
+ }) + '\n'
432
+ );
433
+ fs.fsyncSync(fd);
434
+ } finally {
435
+ fs.closeSync(fd);
436
+ }
437
+ fsyncDirectory(lock);
438
+ fsyncDirectory(resource);
439
+ } catch (err) {
440
+ try {
441
+ fs.rmSync(lock, { recursive: true, force: true });
442
+ fsyncDirectory(resource);
443
+ } catch {}
444
+ throw err;
445
+ }
446
+
447
+ return () => {
448
+ if (process.env._DRIFTSEAL_TEST_FAIL_LOCK_RELEASE === '1') {
449
+ throw new Error('simulated lock release failure');
450
+ }
451
+ let owner;
452
+ try {
453
+ owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'));
454
+ } catch (err) {
455
+ throw new Error(`cannot verify DriftSeal mutation lock ownership: ${lock}`, { cause: err });
456
+ }
457
+ if (owner.token !== token) {
458
+ throw new Error(`DriftSeal mutation lock ownership changed before release: ${lock}`);
459
+ }
460
+ fs.rmSync(lock, { recursive: true, force: true });
461
+ fsyncDirectory(resource);
462
+ };
463
+ }
464
+
465
+ function withMutationLocks(resources, action) {
466
+ const roots = [
467
+ ...new Set(
468
+ resources.map((resource) => {
469
+ ensureDirectoryDurable(resource);
470
+ return fs.realpathSync(resource);
471
+ })
472
+ ),
473
+ ].sort();
474
+ const releases = [];
475
+
476
+ let cleaned = false;
477
+ const cleanup = () => {
478
+ if (cleaned) return;
479
+ cleaned = true;
480
+ let firstError;
481
+ for (const release of releases.reverse()) {
482
+ try {
483
+ release();
484
+ } catch (err) {
485
+ if (!firstError) firstError = err;
486
+ }
487
+ }
488
+ if (firstError) throw firstError;
489
+ };
490
+ const bestEffortCleanup = () => {
491
+ try {
492
+ cleanup();
493
+ } catch {}
494
+ };
495
+ let actionFailed = false;
496
+ process.once('exit', bestEffortCleanup);
497
+ try {
498
+ for (const root of roots) releases.push(acquireMutationLock(root));
499
+ return action();
500
+ } catch (err) {
501
+ actionFailed = true;
502
+ throw err;
503
+ } finally {
504
+ process.removeListener('exit', bestEffortCleanup);
505
+ if (actionFailed) bestEffortCleanup();
506
+ else cleanup();
507
+ }
508
+ }
509
+
510
+ /** Fold the event stream into one record per intent. */
511
+ function fold(events) {
512
+ const records = new Map();
513
+ const reconciliations = new Map();
514
+ const order = [];
515
+ for (const ev of events) {
516
+ if (ev.type === 'begin') {
517
+ if (records.has(ev.id)) fail(`duplicate begin event for intent id: ${ev.id}`);
518
+ records.set(ev.id, {
519
+ id: ev.id,
520
+ tsBegin: ev.ts,
521
+ intent: ev.intent,
522
+ verify: ev.verify || null,
523
+ decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
524
+ schemaVersion: ev.schemaVersion || 1,
525
+ decisionPrepares: [],
526
+ decisionTerminals: [],
527
+ decisionUpdates: [],
528
+ status: 'in_progress',
529
+ tsEnd: null,
530
+ note: null,
531
+ verifyResult: null,
532
+ });
533
+ order.push(ev.id);
534
+ } else if (ev.type === 'end') {
535
+ const rec = records.get(ev.id);
536
+ if (!rec) fail(`end event references unknown intent id: ${ev.id}`);
537
+ if (rec.status !== 'in_progress') {
538
+ fail(`duplicate end event for intent id: ${ev.id}`);
539
+ }
540
+ const conflictingCancellation = rec.decisionTerminals.find(
541
+ (terminal) =>
542
+ terminal.type === 'decision_reconcile_cancel' &&
543
+ terminal.intentStatus !== ev.status
544
+ );
545
+ if (conflictingCancellation) {
546
+ fail(
547
+ `intent ${ev.id} was closed as ${ev.status} after reconciliation recovery was cancelled for ${conflictingCancellation.intentStatus}`
548
+ );
549
+ }
550
+ if (
551
+ ['completed', 'partial'].includes(ev.status) &&
552
+ rec.decisions.length > 0 &&
553
+ ((rec.schemaVersion >= 2 && (ev.schemaVersion || 1) < 2) ||
554
+ rec.decisions.some(
555
+ (decisionId) => qualifyingDecisionUpdates(rec, decisionId).length === 0
556
+ ))
557
+ ) {
558
+ fail(`linked intent ${ev.id} was closed without reconciling every declared decision`);
559
+ }
560
+ rec.status = ev.status;
561
+ rec.tsEnd = ev.ts;
562
+ rec.note = ev.note || null;
563
+ rec.verifyResult = ev.verifyResult || null;
564
+ } else if (ev.type === 'decision_reconcile_prepare') {
565
+ const rec = records.get(ev.id);
566
+ if (!rec) fail(`decision reconciliation references unknown intent id: ${ev.id}`);
567
+ if (rec.status !== 'in_progress') {
568
+ fail(`decision reconciliation occurred after intent ${ev.id} was closed`);
569
+ }
570
+ if (!rec.decisions.includes(ev.decisionId)) {
571
+ fail(`decision reconciliation references unlinked decision ${ev.decisionId}`);
572
+ }
573
+ if (reconciliations.has(ev.reconciliationId)) {
574
+ fail(`duplicate reconciliation id: ${ev.reconciliationId}`);
575
+ }
576
+ rec.decisionPrepares.push(ev);
577
+ reconciliations.set(ev.reconciliationId, { prepare: ev, terminal: null });
578
+ } else if (ev.type === 'decision_reconcile') {
579
+ const rec = records.get(ev.id);
580
+ if (!rec) fail(`decision reconciliation references unknown intent id: ${ev.id}`);
581
+ if (rec.status !== 'in_progress') {
582
+ fail(`decision reconciliation occurred after intent ${ev.id} was closed`);
583
+ }
584
+ if (rec.schemaVersion >= 2) {
585
+ fail(`linked schema-v2 intent ${rec.id} contains a legacy decision reconciliation`);
586
+ }
587
+ rec.decisionUpdates.push(ev);
588
+ } else if (
589
+ ev.type === 'decision_reconcile_commit' ||
590
+ ev.type === 'decision_reconcile_abort' ||
591
+ ev.type === 'decision_reconcile_cancel'
592
+ ) {
593
+ const rec = records.get(ev.id);
594
+ const reconciliation = reconciliations.get(ev.reconciliationId);
595
+ if (rec && rec.status !== 'in_progress') {
596
+ fail(`decision reconciliation occurred after intent ${ev.id} was closed`);
597
+ }
598
+ if (
599
+ !rec ||
600
+ !reconciliation ||
601
+ reconciliation.prepare.id !== ev.id ||
602
+ reconciliation.prepare.decisionId !== ev.decisionId
603
+ ) {
604
+ fail(`decision reconciliation terminal has no matching prepare: ${ev.reconciliationId}`);
605
+ }
606
+ if (reconciliation.terminal) {
607
+ fail(`decision reconciliation already has a terminal event: ${ev.reconciliationId}`);
608
+ }
609
+ const priorCancellation = rec.decisionTerminals.find(
610
+ (terminal) => terminal.type === 'decision_reconcile_cancel'
611
+ );
612
+ if (
613
+ ev.type === 'decision_reconcile_cancel' &&
614
+ priorCancellation &&
615
+ priorCancellation.intentStatus !== ev.intentStatus
616
+ ) {
617
+ fail(`intent ${ev.id} has conflicting reconciliation cancellation statuses`);
618
+ }
619
+ if (
620
+ ev.type === 'decision_reconcile_commit' &&
621
+ (reconciliation.prepare.newHash !== ev.fileHash ||
622
+ reconciliation.prepare.fromStatus !== ev.fromStatus ||
623
+ reconciliation.prepare.toStatus !== ev.toStatus)
624
+ ) {
625
+ fail(`decision reconciliation commit does not match prepare: ${ev.reconciliationId}`);
626
+ }
627
+ reconciliation.terminal = ev;
628
+ rec.decisionTerminals.push(ev);
629
+ if (ev.type === 'decision_reconcile_commit') rec.decisionUpdates.push(ev);
630
+ }
631
+ }
632
+ return order.map((id) => records.get(id));
633
+ }
634
+
635
+ function qualifyingDecisionUpdates(record, decisionId) {
636
+ return record.decisionUpdates.filter((update) => {
637
+ if (update.decisionId !== decisionId) return false;
638
+ if (record.schemaVersion < 2) return true;
639
+ return (
640
+ update.type === 'decision_reconcile_commit' &&
641
+ (update.schemaVersion || 1) >= 2 &&
642
+ typeof update.fileHash === 'string'
643
+ );
644
+ });
645
+ }
646
+
647
+ function openIntent(records) {
648
+ const open = records.filter((record) => record.status === 'in_progress');
649
+ if (open.length > 1) fail(`multiple intents in progress: ${open.map((record) => record.id).join(', ')}`);
650
+ return open[0] || null;
651
+ }
652
+
653
+ function nextId(events) {
654
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
655
+ let maxSeq = 0;
656
+ for (const ev of events) {
657
+ if (ev.type === 'begin' && typeof ev.id === 'string' && ev.id.startsWith(today + '-')) {
658
+ const seq = parseInt(ev.id.slice(today.length + 1), 10);
659
+ if (Number.isFinite(seq) && seq > maxSeq) maxSeq = seq;
660
+ }
661
+ }
662
+ return `${today}-${String(maxSeq + 1).padStart(3, '0')}`;
663
+ }
664
+
665
+ function normalizeDecisionId(value) {
666
+ if (typeof value !== 'string' || !/^0*[1-9]\d*$/.test(value)) {
667
+ fail(`invalid decision id: ${String(value)}`);
668
+ }
669
+ return value.replace(/^0+/, '').padStart(4, '0');
670
+ }
671
+
672
+ function parseDecision(file, content, fileId) {
673
+ const titleMatch = content.match(/^# ([0-9]+)\. ([^\r\n]+)\r?(?:\n|$)/);
674
+ if (!titleMatch) fail(`decision record must begin with a decision title: ${file}`);
675
+ const titleId = normalizeDecisionId(titleMatch[1]);
676
+ if (titleId !== fileId) {
677
+ fail(`decision id mismatch in ${file}: filename is ${fileId}, title is ${titleId}`);
678
+ }
679
+
680
+ const firstSection = content.match(/^## ([^\r\n]+)\r?$/m);
681
+ if (!firstSection || firstSection[1].trim() !== 'Status') {
682
+ fail(`decision record must use Status as its first section: ${file}`);
683
+ }
684
+ const statusMatch = content.match(
685
+ /^## Status[ \t]*\r?\n(?:[ \t]*\r?\n)+([^\r\n]+)(?=\r?\n|$)/m
686
+ );
687
+ if (!statusMatch || statusMatch[1].trim().startsWith('#')) {
688
+ fail(`decision record has no valid status value: ${file}`);
689
+ }
690
+ const status = statusMatch[1].trim().toLowerCase();
691
+ if (!DECISION_STATUSES.includes(status)) {
692
+ fail(`invalid decision status "${status}" in ${file}`);
693
+ }
694
+ const statusStart = statusMatch.index + statusMatch[0].lastIndexOf(statusMatch[1]);
695
+ return {
696
+ id: fileId,
697
+ title: titleMatch[2].trim(),
698
+ status,
699
+ statusStart,
700
+ statusEnd: statusStart + statusMatch[1].length,
701
+ file,
702
+ content,
703
+ };
704
+ }
705
+
706
+ function compareDecisionEntries(a, b) {
707
+ return a.id.length - b.id.length || a.id.localeCompare(b.id) || a.file.localeCompare(b.file);
708
+ }
709
+
710
+ function decisionIndex() {
711
+ if (!fs.existsSync(decisionDir())) return [];
712
+ const entries = [];
713
+ const ids = new Map();
714
+ for (const entry of fs.readdirSync(decisionDir(), { withFileTypes: true })) {
715
+ const match = entry.name.match(/^(\d{4,})-.*\.md$/);
716
+ if (!match) continue;
717
+ const fullPath = path.join(decisionDir(), entry.name);
718
+ const stat = fs.lstatSync(fullPath);
719
+ if (stat.isSymbolicLink()) fail(`decision record must not be a symbolic link: ${entry.name}`);
720
+ if (!stat.isFile()) fail(`decision record is not a regular file: ${entry.name}`);
721
+ const id = normalizeDecisionId(match[1]);
722
+ if (ids.has(id)) fail(`duplicate decision id ${id}: ${ids.get(id)}, ${entry.name}`);
723
+ ids.set(id, entry.name);
724
+ entries.push({ id, file: entry.name, path: fullPath });
725
+ }
726
+ return entries.sort(compareDecisionEntries);
727
+ }
728
+
729
+ function readDecision(entry) {
730
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
731
+ let fd;
732
+ let content;
733
+ try {
734
+ fd = fs.openSync(entry.path, fs.constants.O_RDONLY | noFollow);
735
+ if (!fs.fstatSync(fd).isFile()) fail(`decision record is not a regular file: ${entry.file}`);
736
+ content = fs.readFileSync(fd, 'utf8');
737
+ } finally {
738
+ if (fd !== undefined) fs.closeSync(fd);
739
+ }
740
+ return parseDecision(entry.file, content, entry.id);
741
+ }
742
+
743
+ function decisionCatalog(index = decisionIndex()) {
744
+ return index.map(readDecision);
745
+ }
746
+
747
+ function nextDecisionId(index = decisionIndex()) {
748
+ if (index.length === 0) return 1n;
749
+ return BigInt(index.at(-1).id) + 1n;
750
+ }
751
+
752
+ function findDecision(value, index = decisionIndex()) {
753
+ const id = normalizeDecisionId(value);
754
+ const entry = index.find((record) => record.id === id);
755
+ if (!entry) fail(`unknown decision id: ${value}`);
756
+ return readDecision(entry);
757
+ }
758
+
759
+ function slugify(value) {
760
+ const slug = value
761
+ .normalize('NFKD')
762
+ .toLowerCase()
763
+ .replace(/[^a-z0-9]+/g, '-')
764
+ .replace(/^-|-$/g, '')
765
+ .slice(0, MAX_DECISION_SLUG_LENGTH)
766
+ .replace(/-$/, '');
767
+ return slug || 'decision';
768
+ }
769
+
770
+ function titleCase(value) {
771
+ return value.charAt(0).toUpperCase() + value.slice(1);
772
+ }
773
+
774
+ function bulletSection(heading, values) {
775
+ const items = values.length > 0 ? values : ['Not recorded.'];
776
+ return `## ${heading}\n\n${items.map((value) => `* ${value.replace(/\s+/g, ' ').trim()}`).join('\n')}`;
777
+ }
778
+
779
+ function renderDecision({ id, title, date, status, context, outcome, drivers, options, consequences }) {
780
+ return [
781
+ `# ${id}. ${title}`,
782
+ `Date: ${date}`,
783
+ `## Status\n\n${titleCase(status)}`,
784
+ `## Context and Problem Statement\n\n${context.trim()}`,
785
+ bulletSection('Decision Drivers', drivers),
786
+ bulletSection('Considered Options', options),
787
+ `## Decision Outcome\n\n${outcome.trim()}`,
788
+ bulletSection('Consequences', consequences),
789
+ ].join('\n\n') + '\n';
790
+ }
791
+
792
+ function prepareDecisionReconciliation(decision, intentId, status, note) {
793
+ const target = path.join(decisionDir(), decision.file);
794
+ const fromStatus = decision.status;
795
+ const reconciliationId = crypto.randomUUID();
796
+ const updated =
797
+ decision.content.slice(0, decision.statusStart) +
798
+ titleCase(status) +
799
+ decision.content.slice(decision.statusEnd);
800
+ const ts = new Date().toISOString();
801
+ const eol = decision.content.includes('\r\n') ? '\r\n' : '\n';
802
+ const normalizedNote = note.trim().replace(/\r\n|\r|\n/g, eol);
803
+ const hasDriftSealHistory = /^<!-- [a-z][a-z0-9-]*-reconciliation: [^>\r\n]+ -->\r?$/m.test(updated);
804
+ const historyHeading = hasDriftSealHistory ? '' : `## Decision History${eol}${eol}`;
805
+ const history = `${historyHeading}<!-- driftseal-reconciliation: ${reconciliationId} -->${eol}### ${ts} — Intent \`${intentId}\`${eol}${eol}Status: ${titleCase(fromStatus)} → ${titleCase(status)}${eol}${eol}${normalizedNote}${eol}`;
806
+ const separator = updated.endsWith(eol + eol) ? '' : updated.endsWith(eol) ? eol : eol + eol;
807
+ const nextContent = updated + separator + history;
808
+ return {
809
+ type: 'decision_reconcile_prepare',
810
+ id: intentId,
811
+ decisionId: decision.id,
812
+ reconciliationId,
813
+ ts,
814
+ fromStatus,
815
+ toStatus: status,
816
+ note,
817
+ oldHash: contentHash(decision.content),
818
+ newHash: contentHash(nextContent),
819
+ target,
820
+ content: nextContent,
821
+ };
822
+ }
823
+
824
+ function reconciliationEvent(type, prepare) {
825
+ return {
826
+ type,
827
+ id: prepare.id,
828
+ decisionId: prepare.decisionId,
829
+ reconciliationId: prepare.reconciliationId,
830
+ ts: new Date().toISOString(),
831
+ fromStatus: prepare.fromStatus,
832
+ toStatus: prepare.toStatus,
833
+ note: prepare.note,
834
+ fileHash: prepare.newHash,
835
+ };
836
+ }
837
+
838
+ function pendingReconciliations(events, intentId) {
839
+ const prepares = new Map();
840
+ const finished = new Set();
841
+ for (const event of events) {
842
+ if (event.id !== intentId) continue;
843
+ if (event.type === 'decision_reconcile_prepare') {
844
+ prepares.set(event.reconciliationId, event);
845
+ } else if (
846
+ event.type === 'decision_reconcile_commit' ||
847
+ event.type === 'decision_reconcile_abort' ||
848
+ event.type === 'decision_reconcile_cancel'
849
+ ) {
850
+ finished.add(event.reconciliationId);
851
+ }
852
+ }
853
+
854
+ return [...prepares.values()].filter(
855
+ (prepare) => !finished.has(prepare.reconciliationId)
856
+ );
857
+ }
858
+
859
+ function recoverPendingReconciliations(events, intentId) {
860
+ const pending = pendingReconciliations(events, intentId);
861
+ if (pending.length === 0) return events;
862
+ const index = decisionIndex();
863
+ for (const prepare of pending) {
864
+ const decision = findDecision(prepare.decisionId, index);
865
+ const currentHash = contentHash(decision.content);
866
+ if (currentHash === prepare.newHash) {
867
+ const commit = reconciliationEvent('decision_reconcile_commit', prepare);
868
+ events.push(appendEvent(commit));
869
+ } else if (currentHash === prepare.oldHash) {
870
+ const abort = {
871
+ type: 'decision_reconcile_abort',
872
+ id: prepare.id,
873
+ decisionId: prepare.decisionId,
874
+ reconciliationId: prepare.reconciliationId,
875
+ ts: new Date().toISOString(),
876
+ note: 'prepared reconciliation did not reach the decision file',
877
+ };
878
+ events.push(appendEvent(abort));
879
+ } else {
880
+ fail(
881
+ `cannot recover reconciliation ${prepare.reconciliationId}: decision ${prepare.decisionId} matches neither the old nor prepared content`
882
+ );
883
+ }
884
+ }
885
+ return events;
886
+ }
887
+
888
+ function cancelPendingReconciliations(events, intentId, intentStatus) {
889
+ for (const prepare of pendingReconciliations(events, intentId)) {
890
+ const cancellation = {
891
+ type: 'decision_reconcile_cancel',
892
+ id: prepare.id,
893
+ decisionId: prepare.decisionId,
894
+ reconciliationId: prepare.reconciliationId,
895
+ ts: new Date().toISOString(),
896
+ intentStatus,
897
+ note: `automatic recovery cancelled because intent closed as ${intentStatus}`,
898
+ };
899
+ events.push(appendEvent(cancellation));
900
+ }
901
+ return events;
902
+ }
903
+
904
+ function escapeCancellationStatus(record) {
905
+ const cancellation = record.decisionTerminals.find(
906
+ (terminal) => terminal.type === 'decision_reconcile_cancel'
907
+ );
908
+ return cancellation ? cancellation.intentStatus : null;
909
+ }
910
+
911
+ function closeIntentAsEscape(events, record, requestedStatus, note, verifyResult) {
912
+ const status = escapeCancellationStatus(record) || requestedStatus;
913
+ cancelPendingReconciliations(events, record.id, status);
914
+ if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_RECONCILIATION_CANCEL === '1') {
915
+ fail('simulated interruption after reconciliation cancellation');
916
+ }
917
+ events.push(
918
+ appendEvent({
919
+ type: 'end',
920
+ id: record.id,
921
+ ts: new Date().toISOString(),
922
+ status,
923
+ note: note || null,
924
+ verifyResult: verifyResult || null,
925
+ })
926
+ );
927
+ return status;
928
+ }
929
+
930
+ function fail(msg) {
931
+ console.error(`driftseal: error: ${msg}`);
932
+ process.exit(1);
933
+ }
934
+
935
+ function positiveInteger(value, flag) {
936
+ const number = Number(value);
937
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(number)) {
938
+ fail(`${flag} requires a positive integer`);
939
+ }
940
+ return number;
941
+ }
942
+
943
+ function looksLikeFlag(value, spec) {
944
+ if (!value) return false;
945
+ if (value.startsWith('--')) return true;
946
+ return /^-.$/.test(value) && Object.values(spec).includes(value);
947
+ }
948
+
949
+ /** Minimal flag parser: positionals + --flag value / --flag=value / -x value */
950
+ function parseArgs(argv, spec) {
951
+ const positionals = [];
952
+ const flags = {};
953
+ const assignFlag = (name, value) => {
954
+ if (spec[name] === 'multiple') {
955
+ if (!flags[name]) flags[name] = [];
956
+ flags[name].push(value);
957
+ return;
958
+ }
959
+ if (Object.hasOwn(flags, name)) fail(`flag --${name} may only be specified once`);
960
+ flags[name] = value;
961
+ };
962
+ for (let i = 0; i < argv.length; i++) {
963
+ const arg = argv[i];
964
+ if (arg.startsWith('--')) {
965
+ const eq = arg.indexOf('=');
966
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
967
+ if (!(name in spec)) fail(`unknown flag: --${name}`);
968
+ if (spec[name] === 'boolean') {
969
+ if (eq !== -1) fail(`flag --${name} does not take a value`);
970
+ assignFlag(name, true);
971
+ } else {
972
+ const value = eq === -1 ? argv[i + 1] : arg.slice(eq + 1);
973
+ if (value === undefined || value === '' || (eq === -1 && looksLikeFlag(value, spec))) {
974
+ fail(`flag --${name} requires a value`);
975
+ }
976
+ if (eq === -1) i++;
977
+ assignFlag(name, value);
978
+ }
979
+ } else if (arg.startsWith('-') && arg.length === 2) {
980
+ const long = Object.keys(spec).find((k) => spec[k] === arg);
981
+ if (!long) fail(`unknown flag: ${arg}`);
982
+ if (spec[long] === 'boolean') {
983
+ assignFlag(long, true);
984
+ continue;
985
+ }
986
+ const value = argv[i + 1];
987
+ if (value === undefined || looksLikeFlag(value, spec)) fail(`flag ${arg} requires a value`);
988
+ i++;
989
+ assignFlag(long, value);
990
+ } else {
991
+ positionals.push(arg);
992
+ }
993
+ }
994
+ return { positionals, flags };
995
+ }
996
+
997
+ function render(rec) {
998
+ const lines = [`[${rec.id}] ${rec.status}`];
999
+ lines.push(` intent: ${rec.intent}`);
1000
+ if (rec.decisions.length > 0) lines.push(` decisions: ${rec.decisions.join(', ')}`);
1001
+ if (rec.verify) lines.push(` verify: ${rec.verify}`);
1002
+ if (rec.verifyResult) lines.push(` verify-result: ${rec.verifyResult}`);
1003
+ if (rec.note) lines.push(` note: ${rec.note}`);
1004
+ lines.push(` began: ${rec.tsBegin}` + (rec.tsEnd ? ` ended: ${rec.tsEnd}` : ''));
1005
+ return lines.join('\n');
1006
+ }
1007
+
1008
+ const INTENT_PROTOCOL_MARKER = '<!-- driftseal -->';
1009
+ const INTENT_PROTOCOL_END = '<!-- /driftseal -->';
1010
+ const DECISION_PROTOCOL_MARKER = '<!-- driftseal-decisions -->';
1011
+ const DECISION_PROTOCOL_END = '<!-- /driftseal-decisions -->';
1012
+
1013
+ function intentProtocolBlock(version = PROTOCOL_VERSION) {
1014
+ return `${INTENT_PROTOCOL_MARKER}
1015
+ <!-- driftseal-version: ${version} -->
1016
+
1017
+ ## Agent protocol: intent write-ahead log
1018
+
1019
+ This repo uses DriftSeal (\`driftseal\`) to prevent agent drift. Every work round:
1020
+
1021
+ 1. **Write intent first**, before modifying a file or running a mutating command:
1022
+ \`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
1023
+ Add one \`--decision <id>\` for each existing decision this round may change.
1024
+ 2. **Execute only the intent.** Scope change? Close the current intent
1025
+ (\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
1026
+ 3. **Verify, then close**: run the declared verification, then
1027
+ \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<verify output>"\`.
1028
+ Never report success without closing the intent.
1029
+ Before closing a linked intent as \`completed\` or \`partial\`, reconcile every
1030
+ declared decision with \`driftseal decision update <id> --status <status> --note "<why>"\`.
1031
+ DriftSeal rejects a successful close when a declared decision was not reconciled.
1032
+ Do not edit a decision after reconciling it; run \`decision update\` again so
1033
+ the final content hash is recorded. Interrupted reconciliation is recovered
1034
+ by the next linked \`decision update\` or successful \`end\`. Closing as
1035
+ \`failed\` or \`abandoned\` cancels pending recovery for that intent.
1036
+ An authorized Git commit that only stages and records the verified changes and
1037
+ just-closed log finalizes that round without requiring a new intent. Any content
1038
+ change made while preparing the commit does require a new intent.
1039
+ 4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1040
+ doing anything else. The open intent is the source of truth.
1041
+
1042
+ Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
1043
+ ${INTENT_PROTOCOL_END}`;
1044
+ }
1045
+
1046
+ function previousIntentProtocolBlock(version) {
1047
+ return intentProtocolBlock(version).replace(
1048
+ ' by the next linked `decision update` or successful `end`. Closing as\n' +
1049
+ ' `failed` or `abandoned` cancels pending recovery for that intent.',
1050
+ ' by the next `decision update` or `end`.'
1051
+ );
1052
+ }
1053
+
1054
+ function protocolEol(content, eol) {
1055
+ return eol === '\n' ? content : content.replace(/\n/g, eol);
1056
+ }
1057
+
1058
+ function decisionProtocolBlock(version = PROTOCOL_VERSION) {
1059
+ return `${DECISION_PROTOCOL_MARKER}
1060
+ <!-- driftseal-decisions-version: ${version} -->
1061
+
1062
+ ## Agent protocol: decision log
1063
+
1064
+ Record a MADR document only when it preserves decision context that cannot be
1065
+ recovered from the intent log and Git history: a rejected or deferred path worth
1066
+ revisiting, non-obvious rationale behind a long-lived or costly-to-reverse accepted
1067
+ choice, or a deprecated or superseded decision. Do not record routine, local,
1068
+ readily reversible choices.
1069
+
1070
+ \`driftseal decision add "<title>" --context "<problem and constraints>" --outcome "<decision and rationale>" --option "<considered option>" --consequence "<result>"\`
1071
+
1072
+ Add one \`--driver\`, \`--option\`, or \`--consequence\` flag per item. Use
1073
+ \`--status proposed|accepted|rejected|deferred|deprecated|superseded\` when needed.
1074
+ Use \`proposed\` for a choice still under active consideration. Use \`deferred\`
1075
+ for a deliberately postponed choice and include its revisit trigger.
1076
+ Count postponed choices with \`driftseal decision list --status deferred --count\`,
1077
+ then review them with \`driftseal decision list --status deferred\`.
1078
+ When an intent declares an existing decision with \`--decision <id>\`, use
1079
+ \`driftseal decision update\` to record its status transition or explicit confirmation.
1080
+ Commit \`.decision-log/\` with the code.
1081
+ ${DECISION_PROTOCOL_END}`;
1082
+ }
1083
+
1084
+ function legacyIntentProtocolBlock() {
1085
+ return `${INTENT_PROTOCOL_MARKER}
1086
+
1087
+ ## Agent protocol: intent write-ahead log
1088
+
1089
+ This repo uses DriftSeal (\`driftseal\`) to prevent agent drift. Every work round:
1090
+
1091
+ 1. **Write intent first**, before modifying a file or running a mutating command:
1092
+ \`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
1093
+ Add one \`--decision <id>\` for each existing decision this round may change.
1094
+ 2. **Execute only the intent.** Scope change? Close the current intent
1095
+ (\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
1096
+ 3. **Verify, then close**: run the declared verification, then
1097
+ \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<verify output>"\`.
1098
+ Never report success without closing the intent.
1099
+ Before closing a linked intent as \`completed\` or \`partial\`, reconcile every
1100
+ declared decision with \`driftseal decision update <id> --status <status> --note "<why>"\`.
1101
+ DriftSeal rejects a successful close when a declared decision was not reconciled.
1102
+ An authorized Git commit that only stages and records the verified changes and
1103
+ just-closed log finalizes that round without requiring a new intent. Any content
1104
+ change made while preparing the commit does require a new intent.
1105
+ 4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1106
+ doing anything else. The open intent is the source of truth.
1107
+
1108
+ Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.`;
1109
+ }
1110
+
1111
+ function legacyDecisionProtocolBlock() {
1112
+ return `${DECISION_PROTOCOL_MARKER}
1113
+
1114
+ ## Agent protocol: decision log
1115
+
1116
+ Record a MADR document only when it preserves decision context that cannot be
1117
+ recovered from the intent log and Git history: a rejected or deferred path worth
1118
+ revisiting, non-obvious rationale behind a long-lived or costly-to-reverse accepted
1119
+ choice, or a deprecated or superseded decision. Do not record routine, local,
1120
+ readily reversible choices.
1121
+
1122
+ \`driftseal decision add "<title>" --context "<problem and constraints>" --outcome "<decision and rationale>" --option "<considered option>" --consequence "<result>"\`
1123
+
1124
+ Add one \`--driver\`, \`--option\`, or \`--consequence\` flag per item. Use
1125
+ \`--status proposed|accepted|rejected|deferred|deprecated|superseded\` when needed.
1126
+ Use \`proposed\` for a choice still under active consideration. Use \`deferred\`
1127
+ for a deliberately postponed choice and include its revisit trigger.
1128
+ Count postponed choices with \`driftseal decision list --status deferred --count\`,
1129
+ then review them with \`driftseal decision list --status deferred\`.
1130
+ When an intent declares an existing decision with \`--decision <id>\`, use
1131
+ \`driftseal decision update\` to record its status transition or explicit confirmation.
1132
+ Commit \`.decision-log/\` with the code.`;
1133
+ }
1134
+
1135
+ function upgradeManagedBlock({
1136
+ content,
1137
+ marker,
1138
+ endMarker,
1139
+ versionPattern,
1140
+ replacement,
1141
+ knownManagedBlocks,
1142
+ knownLegacyBlocks,
1143
+ }) {
1144
+ const start = content.indexOf(marker);
1145
+ if (start === -1) return { content, found: false };
1146
+ if (content.indexOf(marker, start + marker.length) !== -1) {
1147
+ fail(`cannot safely upgrade multiple protocol blocks beginning with ${marker}`);
1148
+ }
1149
+
1150
+ const managedEnd = content.indexOf(endMarker, start);
1151
+ if (managedEnd !== -1) {
1152
+ const after = managedEnd + endMarker.length;
1153
+ const block = content.slice(start, after);
1154
+ const versionMatch = block.match(versionPattern);
1155
+ if (!versionMatch) {
1156
+ fail(`cannot safely upgrade unversioned managed protocol block beginning with ${marker}`);
1157
+ }
1158
+ const version = Number(versionMatch[1]);
1159
+ if (!Number.isSafeInteger(version) || version < 1) {
1160
+ fail(`invalid protocol version in block beginning with ${marker}`);
1161
+ }
1162
+ if (version > PROTOCOL_VERSION) {
1163
+ fail(
1164
+ `protocol version ${version} requires a newer DriftSeal client (supported: ${PROTOCOL_VERSION})`
1165
+ );
1166
+ }
1167
+ if (block !== replacement && !knownManagedBlocks.includes(block)) {
1168
+ fail(`cannot safely upgrade customized protocol block beginning with ${marker}`);
1169
+ }
1170
+ return {
1171
+ content: content.slice(0, start) + replacement + content.slice(after),
1172
+ found: true,
1173
+ };
1174
+ }
1175
+
1176
+ const legacy = knownLegacyBlocks.find((block) => content.startsWith(block, start));
1177
+ if (legacy) {
1178
+ return {
1179
+ content: content.slice(0, start) + replacement + content.slice(start + legacy.length),
1180
+ found: true,
1181
+ };
1182
+ }
1183
+ fail(`cannot safely upgrade customized protocol block beginning with ${marker}`);
1184
+ }
1185
+
1186
+ const commands = {
1187
+ begin(argv) {
1188
+ const { positionals, flags } = parseArgs(argv, {
1189
+ verify: '-v',
1190
+ decision: 'multiple',
1191
+ force: 'boolean',
1192
+ });
1193
+ const intent = positionals.join(' ').trim();
1194
+ if (!intent) {
1195
+ fail('usage: driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]');
1196
+ }
1197
+ const requestedDecisions = flags.decision || [];
1198
+ const index = requestedDecisions.length > 0 ? decisionIndex() : [];
1199
+ const decisions = [
1200
+ ...new Set(requestedDecisions.map((id) => findDecision(id, index).id)),
1201
+ ];
1202
+
1203
+ const events = readEvents({ repairTail: true });
1204
+ const records = fold(events);
1205
+ const open = openIntent(records);
1206
+ if (open) {
1207
+ if (!flags.force) {
1208
+ fail(
1209
+ `intent ${open.id} is still in_progress: "${open.intent}"\n` +
1210
+ `end it first (driftseal end) or re-run with --force to abandon it`
1211
+ );
1212
+ }
1213
+ const status = closeIntentAsEscape(
1214
+ events,
1215
+ open,
1216
+ 'abandoned',
1217
+ 'superseded by --force',
1218
+ null
1219
+ );
1220
+ console.error(`driftseal: ${status} ${open.id}`);
1221
+ }
1222
+
1223
+ const id = nextId(events);
1224
+ appendEvent({
1225
+ type: 'begin',
1226
+ id,
1227
+ ts: new Date().toISOString(),
1228
+ intent,
1229
+ verify: flags.verify || null,
1230
+ decisions,
1231
+ });
1232
+ console.log(id);
1233
+ },
1234
+
1235
+ end(argv) {
1236
+ const { positionals, flags } = parseArgs(argv, {
1237
+ status: '-s',
1238
+ note: '-n',
1239
+ 'verify-result': '-r',
1240
+ });
1241
+ const status = flags.status || 'completed';
1242
+ if (positionals.length > 1) fail('usage: driftseal end [id] [options]');
1243
+ if (!END_STATUSES.includes(status)) {
1244
+ fail(`invalid status "${status}" (expected: ${END_STATUSES.join(', ')})`);
1245
+ }
1246
+
1247
+ let events = readEvents({ repairTail: true });
1248
+ let records = fold(events);
1249
+ let target;
1250
+ if (positionals.length > 0) {
1251
+ target = records.find((r) => r.id === positionals[0]);
1252
+ if (!target) fail(`unknown intent id: ${positionals[0]}`);
1253
+ if (target.status !== 'in_progress') fail(`intent ${target.id} already closed (${target.status})`);
1254
+ } else {
1255
+ target = openIntent(records);
1256
+ if (!target) fail('no intent in progress; nothing to end');
1257
+ }
1258
+
1259
+ if (['failed', 'abandoned'].includes(status)) {
1260
+ const terminalStatus = closeIntentAsEscape(
1261
+ events,
1262
+ target,
1263
+ status,
1264
+ flags.note,
1265
+ flags['verify-result']
1266
+ );
1267
+ console.log(`${target.id} ${terminalStatus}`);
1268
+ return;
1269
+ }
1270
+
1271
+ if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
1272
+ events = recoverPendingReconciliations(events, target.id);
1273
+ records = fold(events);
1274
+ target = records.find((record) => record.id === target.id);
1275
+ }
1276
+
1277
+ if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
1278
+ const problems = [];
1279
+ const index = decisionIndex();
1280
+ for (const decisionId of target.decisions) {
1281
+ const updates = qualifyingDecisionUpdates(target, decisionId);
1282
+ if (updates.length === 0) {
1283
+ problems.push(`decision ${decisionId} was not reconciled`);
1284
+ continue;
1285
+ }
1286
+ const latest = updates.at(-1);
1287
+ const decision = findDecision(decisionId, index);
1288
+ if (latest.fileHash && contentHash(decision.content) !== latest.fileHash) {
1289
+ problems.push(`decision ${decisionId} changed after its latest reconciliation`);
1290
+ } else if (decision.status !== latest.toStatus) {
1291
+ problems.push(
1292
+ `decision ${decisionId} is ${decision.status}, but its latest reconciliation recorded ${latest.toStatus}`
1293
+ );
1294
+ }
1295
+ }
1296
+ if (problems.length > 0) {
1297
+ fail(
1298
+ `cannot close linked intent ${target.id} as ${status}:\n` +
1299
+ problems.map((problem) => ` - ${problem}`).join('\n') +
1300
+ `\nrun: driftseal decision update <id> --note "<what changed or was confirmed>"`
1301
+ );
1302
+ }
1303
+ }
1304
+
1305
+ appendEvent({
1306
+ type: 'end',
1307
+ id: target.id,
1308
+ ts: new Date().toISOString(),
1309
+ status,
1310
+ note: flags.note || null,
1311
+ verifyResult: flags['verify-result'] || null,
1312
+ });
1313
+ console.log(`${target.id} ${status}`);
1314
+ },
1315
+
1316
+ status(argv) {
1317
+ const { positionals } = parseArgs(argv, {});
1318
+ if (positionals.length > 0) fail('usage: driftseal status');
1319
+ const open = openIntent(fold(readEvents({ repairTail: true })));
1320
+ if (!open) {
1321
+ console.log('no intent in progress');
1322
+ return;
1323
+ }
1324
+ console.log(render(open));
1325
+ },
1326
+
1327
+ log(argv) {
1328
+ const { positionals, flags } = parseArgs(argv, { last: '-n' });
1329
+ if (positionals.length > 0) fail('usage: driftseal log [--last N]');
1330
+ let records = fold(readEvents({ repairTail: true }));
1331
+ if (flags.last) {
1332
+ const n = positiveInteger(flags.last, '--last');
1333
+ records = records.slice(-n);
1334
+ }
1335
+ if (records.length === 0) {
1336
+ console.log('log is empty');
1337
+ return;
1338
+ }
1339
+ console.log(records.map(render).join('\n\n'));
1340
+ },
1341
+
1342
+ decision(argv) {
1343
+ const [subcommand, ...rest] = argv;
1344
+ if (subcommand === 'add') {
1345
+ const { positionals, flags } = parseArgs(rest, {
1346
+ context: '-c',
1347
+ outcome: '-o',
1348
+ status: '-s',
1349
+ driver: 'multiple',
1350
+ option: 'multiple',
1351
+ consequence: 'multiple',
1352
+ });
1353
+ const title = positionals.join(' ').replace(/\s+/g, ' ').trim();
1354
+ const context = flags.context && flags.context.trim();
1355
+ const outcome = flags.outcome && flags.outcome.trim();
1356
+ if (!title || !context || !outcome) {
1357
+ fail('usage: driftseal decision add "<title>" --context "..." --outcome "..." [options]');
1358
+ }
1359
+ const status = (flags.status || 'accepted').toLowerCase();
1360
+ if (!DECISION_STATUSES.includes(status)) {
1361
+ fail(`invalid decision status "${status}" (expected: ${DECISION_STATUSES.join(', ')})`);
1362
+ }
1363
+
1364
+ const id = nextDecisionId();
1365
+ const paddedId = String(id).padStart(4, '0');
1366
+ const file = `${paddedId}-${slugify(title)}.md`;
1367
+ const content = renderDecision({
1368
+ id,
1369
+ title,
1370
+ date: new Date().toISOString().slice(0, 10),
1371
+ status,
1372
+ context,
1373
+ outcome,
1374
+ drivers: flags.driver || [],
1375
+ options: flags.option || [],
1376
+ consequences: flags.consequence || [],
1377
+ });
1378
+ ensureDirectoryDurable(decisionDir());
1379
+ atomicCreateFile(path.join(decisionDir(), file), content);
1380
+ console.log(path.join(decisionDir(), file));
1381
+ return;
1382
+ }
1383
+
1384
+ if (subcommand === 'update') {
1385
+ const { positionals, flags } = parseArgs(rest, { status: '-s', note: '-n' });
1386
+ const note = flags.note && flags.note.trim();
1387
+ if (positionals.length !== 1 || !note) {
1388
+ fail('usage: driftseal decision update <id> [--status <status>] --note "<what changed or was confirmed>"');
1389
+ }
1390
+
1391
+ let events = readEvents({ repairTail: true });
1392
+ let records = fold(events);
1393
+ let intent = openIntent(records);
1394
+ if (!intent) fail('decision update requires an intent in progress');
1395
+ events = recoverPendingReconciliations(events, intent.id);
1396
+ records = fold(events);
1397
+ intent = openIntent(records);
1398
+ const index = decisionIndex();
1399
+ const decision = findDecision(positionals[0], index);
1400
+ if (!intent.decisions.includes(decision.id)) {
1401
+ fail(`decision ${decision.id} is not linked to intent ${intent.id}; declare it with driftseal begin --decision ${decision.id}`);
1402
+ }
1403
+
1404
+ const status = (flags.status || decision.status).toLowerCase();
1405
+ if (!DECISION_STATUSES.includes(status)) {
1406
+ fail(`invalid decision status "${status}" (expected: ${DECISION_STATUSES.join(', ')})`);
1407
+ }
1408
+ const update = prepareDecisionReconciliation(decision, intent.id, status, note);
1409
+ const { target, content, ...prepareEvent } = update;
1410
+ appendEvent(prepareEvent);
1411
+ if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_RECONCILIATION_PREPARE === '1') {
1412
+ fail('simulated interruption after reconciliation prepare');
1413
+ }
1414
+ atomicWriteFile(target, content);
1415
+ if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_DECISION_WRITE === '1') {
1416
+ fail('simulated interruption after decision write');
1417
+ }
1418
+ appendEvent(reconciliationEvent('decision_reconcile_commit', update));
1419
+ console.log(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
1420
+ return;
1421
+ }
1422
+
1423
+ if (subcommand === 'list') {
1424
+ const { positionals, flags } = parseArgs(rest, { last: '-n', status: '-s', count: 'boolean' });
1425
+ if (positionals.length > 0) {
1426
+ fail('usage: driftseal decision list [--status STATUS] [--last N | --count]');
1427
+ }
1428
+ if (flags.count && flags.last) fail('--count cannot be combined with --last');
1429
+ const last = flags.last && positiveInteger(flags.last, '--last');
1430
+ const status = flags.status && flags.status.toLowerCase();
1431
+ if (status && !DECISION_STATUSES.includes(status)) {
1432
+ fail(`invalid decision status "${status}" (expected: ${DECISION_STATUSES.join(', ')})`);
1433
+ }
1434
+ const index = decisionIndex();
1435
+ if (flags.count && !status) {
1436
+ console.log(index.length);
1437
+ return;
1438
+ }
1439
+ let records = decisionCatalog(!status && last ? index.slice(-last) : index);
1440
+ if (status) {
1441
+ records = records.filter((record) => record.status === status);
1442
+ }
1443
+ if (status && last) records = records.slice(-last);
1444
+ if (flags.count) {
1445
+ console.log(records.length);
1446
+ return;
1447
+ }
1448
+ if (records.length === 0) {
1449
+ console.log(status ? `no decision records with status ${status}` : 'decision log is empty');
1450
+ return;
1451
+ }
1452
+ console.log(
1453
+ records
1454
+ .map((record) => `[${record.id}] ${titleCase(record.status)} — ${record.title}\n ${record.file}`)
1455
+ .join('\n')
1456
+ );
1457
+ return;
1458
+ }
1459
+
1460
+ if (subcommand === 'show') {
1461
+ const { positionals } = parseArgs(rest, {});
1462
+ if (positionals.length !== 1 || !/^\d+$/.test(positionals[0])) {
1463
+ fail('usage: driftseal decision show <id>');
1464
+ }
1465
+ const decision = findDecision(positionals[0]);
1466
+ process.stdout.write(decision.content);
1467
+ return;
1468
+ }
1469
+
1470
+ fail('usage: driftseal decision add|update|list|show (run: driftseal help)');
1471
+ },
1472
+
1473
+ init(argv) {
1474
+ const { positionals } = parseArgs(argv, {});
1475
+ if (positionals.length > 0) fail('usage: driftseal init');
1476
+ const target = path.join(process.cwd(), 'AGENTS.md');
1477
+ const existed = fs.existsSync(target);
1478
+ const current = existed ? fs.readFileSync(target, 'utf8') : '';
1479
+ const eol = current.includes('\r\n') ? '\r\n' : '\n';
1480
+ const intentBlock = protocolEol(intentProtocolBlock(), eol);
1481
+ const decisionBlock = protocolEol(decisionProtocolBlock(), eol);
1482
+ let updated = current;
1483
+ const intent = upgradeManagedBlock({
1484
+ content: updated,
1485
+ marker: INTENT_PROTOCOL_MARKER,
1486
+ endMarker: INTENT_PROTOCOL_END,
1487
+ versionPattern: /^<!-- driftseal-version: (\d+) -->\r?$/m,
1488
+ replacement: intentBlock,
1489
+ knownManagedBlocks: [
1490
+ protocolEol(previousIntentProtocolBlock(2), eol),
1491
+ protocolEol(previousIntentProtocolBlock(3), eol),
1492
+ ],
1493
+ knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
1494
+ });
1495
+ updated = intent.content;
1496
+ const decision = upgradeManagedBlock({
1497
+ content: updated,
1498
+ marker: DECISION_PROTOCOL_MARKER,
1499
+ endMarker: DECISION_PROTOCOL_END,
1500
+ versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
1501
+ replacement: decisionBlock,
1502
+ knownManagedBlocks: [
1503
+ protocolEol(decisionProtocolBlock(2), eol),
1504
+ protocolEol(decisionProtocolBlock(3), eol),
1505
+ ],
1506
+ knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
1507
+ });
1508
+ updated = decision.content;
1509
+
1510
+ const additions = [];
1511
+ if (!intent.found) additions.push(intentBlock);
1512
+ if (!decision.found) additions.push(decisionBlock);
1513
+ if (additions.length > 0) {
1514
+ if (!existed && updated.length === 0) updated = `# Agent instructions${eol}`;
1515
+ const separator =
1516
+ updated.length === 0 || updated.endsWith(eol + eol)
1517
+ ? ''
1518
+ : updated.endsWith(eol)
1519
+ ? eol
1520
+ : eol + eol;
1521
+ updated += separator + additions.join(eol + eol) + eol;
1522
+ }
1523
+
1524
+ if (updated === current) {
1525
+ console.log('AGENTS.md already contains the DriftSeal protocols; nothing to do');
1526
+ return;
1527
+ }
1528
+ atomicWriteFile(target, updated);
1529
+ console.log(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
1530
+ },
1531
+
1532
+ help() {
1533
+ console.log(`DriftSeal — Seal the intent. Stop the drift.
1534
+
1535
+ Intent-level write-ahead log for agent sessions.
1536
+
1537
+ usage:
1538
+ driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]
1539
+ driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
1540
+ driftseal status show the intent currently in progress (re-anchor after drift)
1541
+ driftseal log [--last N] show intent history
1542
+ driftseal decision add "<title>" --context "..." --outcome "..." [options]
1543
+ driftseal decision update <id> [--status STATUS] --note "..."
1544
+ reconcile a linked decision in the open intent
1545
+ driftseal decision list [--status STATUS] [--last N | --count]
1546
+ list or count filtered MADR decision records
1547
+ driftseal decision show <id> print one MADR decision record
1548
+ driftseal init inject intent and decision protocols into ./AGENTS.md
1549
+ driftseal help
1550
+
1551
+ decision add options:
1552
+ -s, --status proposed|accepted|rejected|deferred|deprecated|superseded (default: accepted)
1553
+ --driver "..." repeat for each decision driver
1554
+ --option "..." repeat for each considered option
1555
+ --consequence "..." repeat for each consequence
1556
+
1557
+ intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
1558
+ decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory`);
1559
+ },
1560
+ };
1561
+
1562
+ function requestedEndStatus(argv) {
1563
+ for (let index = 0; index < argv.length; index++) {
1564
+ if (argv[index] === '--status' || argv[index] === '-s') return argv[index + 1];
1565
+ if (argv[index].startsWith('--status=')) return argv[index].slice('--status='.length);
1566
+ }
1567
+ return 'completed';
1568
+ }
1569
+
1570
+ function mutationResources(cmd, argv) {
1571
+ if (cmd === 'init') return [process.cwd()];
1572
+ if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
1573
+ return [logDir()];
1574
+ }
1575
+ if (cmd === 'end' && ['failed', 'abandoned'].includes(requestedEndStatus(argv))) {
1576
+ return [logDir()];
1577
+ }
1578
+ return [logDir(), decisionDir()];
1579
+ }
1580
+
1581
+ function main() {
1582
+ const [cmd, ...rest] = process.argv.slice(2);
1583
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1584
+ commands.help();
1585
+ process.exit(cmd ? 0 : 1);
1586
+ }
1587
+ const fn = commands[cmd];
1588
+ if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
1589
+ const mutates =
1590
+ ['begin', 'end', 'init'].includes(cmd) ||
1591
+ (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
1592
+ const readsIntentLog = ['status', 'log'].includes(cmd);
1593
+ if (mutates || readsIntentLog) {
1594
+ const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
1595
+ withMutationLocks(resources, () => fn(rest));
1596
+ } else {
1597
+ fn(rest);
1598
+ }
1599
+ }
1600
+
1601
+ main();