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