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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/README.zh-CN.md +135 -0
- package/bin/driftseal.js +1601 -0
- package/package.json +46 -0
- package/skills/use-driftseal/SKILL.md +156 -0
package/bin/driftseal.js
ADDED
|
@@ -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();
|