driftseal 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +225 -341
- package/README.zh-CN.md +199 -298
- package/bin/driftseal-mcp.js +156 -62
- package/bin/driftseal.js +1867 -336
- 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:
|
|
@@ -1949,7 +2231,7 @@ ${INTENT_PROTOCOL_END}`;
|
|
|
1949
2231
|
}
|
|
1950
2232
|
|
|
1951
2233
|
function previousIntentProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
|
|
1952
|
-
const v13 =
|
|
2234
|
+
const v13 = v1IntentProtocolBlock(version, language, localLog)
|
|
1953
2235
|
.replace(
|
|
1954
2236
|
'1. **Write intent first**, before changing durable project content:\n' +
|
|
1955
2237
|
' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
|
|
@@ -2157,6 +2439,31 @@ function decisionProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LO
|
|
|
2157
2439
|
|
|
2158
2440
|
## Agent protocol: decision log
|
|
2159
2441
|
|
|
2442
|
+
Record a MADR only when it preserves context that the outcome log and Git cannot
|
|
2443
|
+
recover: rejected or deferred paths worth revisiting, non-obvious rationale for
|
|
2444
|
+
long-lived or costly-to-reverse choices, and deprecated or superseded decisions.
|
|
2445
|
+
Do not record routine, local, readily reversible choices.
|
|
2446
|
+
|
|
2447
|
+
${decisionLogLanguageParagraph(language)}
|
|
2448
|
+
|
|
2449
|
+
\`driftseal decision add "<title>" --context "<problem and constraints>" --outcome "<decision and rationale>" --driver "<decision driver>" --option "<considered option>" --consequence "<result>"\`
|
|
2450
|
+
|
|
2451
|
+
Use \`proposed|accepted|rejected|deferred|deprecated|superseded\` statuses. Link
|
|
2452
|
+
existing MADRs from \`begin\` or \`extend\`, then reconcile each linked record
|
|
2453
|
+
with \`driftseal decision update\` before successful or partial closure. After a
|
|
2454
|
+
merge, \`driftseal absorb\` remaps colliding ids; it never auto-merges concurrent
|
|
2455
|
+
edits of a shared MADR.
|
|
2456
|
+
${localLog ? 'Keep `.seal/madr/` local and untracked.' : 'Commit `.seal/madr/` with the code.'}
|
|
2457
|
+
${DECISION_PROTOCOL_END}`;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function v1DecisionProtocolBlock(version = 14, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
|
|
2461
|
+
return `${DECISION_PROTOCOL_MARKER}
|
|
2462
|
+
<!-- driftseal-decisions-version: ${version} -->
|
|
2463
|
+
<!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
|
|
2464
|
+
|
|
2465
|
+
## Agent protocol: decision log
|
|
2466
|
+
|
|
2160
2467
|
Record a MADR document only when it preserves decision context that cannot be
|
|
2161
2468
|
recovered from the intent log and Git history: a rejected or deferred path worth
|
|
2162
2469
|
revisiting, non-obvious rationale behind a long-lived or costly-to-reverse accepted
|
|
@@ -2233,8 +2540,8 @@ Commit \`.decision-log/\` with the code.`;
|
|
|
2233
2540
|
}
|
|
2234
2541
|
|
|
2235
2542
|
function previousDecisionProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
|
|
2236
|
-
if (version >= 11) return
|
|
2237
|
-
const v10 = stripDecisionLogLanguage(
|
|
2543
|
+
if (version >= 11) return v1DecisionProtocolBlock(version, language, localLog);
|
|
2544
|
+
const v10 = stripDecisionLogLanguage(v1DecisionProtocolBlock(version, language, localLog), language);
|
|
2238
2545
|
if (version >= 9) return v10;
|
|
2239
2546
|
const v8 = v10.replace(
|
|
2240
2547
|
'\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
|
|
@@ -2268,11 +2575,17 @@ function upgradeManagedBlock({
|
|
|
2268
2575
|
if (!versionMatch) {
|
|
2269
2576
|
fail(`cannot safely upgrade unversioned managed protocol block beginning with ${marker}`);
|
|
2270
2577
|
}
|
|
2271
|
-
const version =
|
|
2272
|
-
if (
|
|
2578
|
+
const version = versionMatch[1];
|
|
2579
|
+
if (!/^\d+(?:\.\d+)?$/.test(version)) {
|
|
2273
2580
|
fail(`invalid protocol version in block beginning with ${marker}`);
|
|
2274
2581
|
}
|
|
2275
|
-
|
|
2582
|
+
const futureV2 = version.includes('.') && (() => {
|
|
2583
|
+
const [major, minor] = version.split('.').map(Number);
|
|
2584
|
+
const [supportedMajor, supportedMinor] = PROTOCOL_VERSION.split('.').map(Number);
|
|
2585
|
+
return major > supportedMajor || (major === supportedMajor && minor > supportedMinor);
|
|
2586
|
+
})();
|
|
2587
|
+
const futureV1 = !version.includes('.') && Number(version) > 14;
|
|
2588
|
+
if (futureV2 || futureV1) {
|
|
2276
2589
|
fail(
|
|
2277
2590
|
`protocol version ${version} requires a newer DriftSeal client (supported: ${PROTOCOL_VERSION})`
|
|
2278
2591
|
);
|
|
@@ -2543,7 +2856,8 @@ const SKILL_RELEASE_DIGESTS = new Set([
|
|
|
2543
2856
|
'cc98b9348ec222320bfcd285ba3f1f499a42d15b31e9b1d83c35f0206b2d5ba9', // ca16785 CLI-first skill integration
|
|
2544
2857
|
'0fd870f8c1b81f8386d986d64742679d56cd1d317c02890830c81876eb9227d6', // da8afd2 1.1.0 absorb
|
|
2545
2858
|
'72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
|
|
2546
|
-
'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb
|
|
2859
|
+
'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb
|
|
2860
|
+
'42a0549dff21483c0508ea4a79658e7bf05cd98f8af4238c95dbd23cdcde7ee6', // 2.0.0 outcome workflow
|
|
2547
2861
|
]);
|
|
2548
2862
|
|
|
2549
2863
|
function skillInstallUsage() {
|
|
@@ -2964,7 +3278,7 @@ function hookLogFile() {
|
|
|
2964
3278
|
let current = path.resolve(process.cwd());
|
|
2965
3279
|
const root = gitWorktreeRoot(current);
|
|
2966
3280
|
while (true) {
|
|
2967
|
-
const candidate = path.join(current, '.
|
|
3281
|
+
const candidate = path.join(current, '.seal', 'outcomes', 'events.jsonl');
|
|
2968
3282
|
if (fs.existsSync(candidate)) return candidate;
|
|
2969
3283
|
if (root && path.resolve(root) === current) {
|
|
2970
3284
|
const park = worktreeInProgressFile(current);
|
|
@@ -2976,7 +3290,7 @@ function hookLogFile() {
|
|
|
2976
3290
|
}
|
|
2977
3291
|
}
|
|
2978
3292
|
|
|
2979
|
-
/** Advisory reminder text; null when no ancestor has an
|
|
3293
|
+
/** Advisory reminder text; null when no ancestor has an outcome log yet. */
|
|
2980
3294
|
function hookReminder(event, { readOnly = false } = {}) {
|
|
2981
3295
|
const file = hookLogFile();
|
|
2982
3296
|
if (!file) return null;
|
|
@@ -2984,27 +3298,27 @@ function hookReminder(event, { readOnly = false } = {}) {
|
|
|
2984
3298
|
return (
|
|
2985
3299
|
'DriftSeal reminder: if this round will change durable project content in this workspace ' +
|
|
2986
3300
|
'(code, configuration, documentation, dependencies), ' +
|
|
2987
|
-
'begin an
|
|
3301
|
+
'begin an outcome first: driftseal begin "<coherent outcome>" --accept "<observable result>" ' +
|
|
2988
3302
|
'--verify "<command>". ' +
|
|
2989
3303
|
'Questions, read-only exploration, single-step checks, temporary work outside durable ' +
|
|
2990
3304
|
'project content, and external state changes that do not write project content here need ' +
|
|
2991
|
-
'no
|
|
3305
|
+
'no outcome — skip this reminder when it does not apply.'
|
|
2992
3306
|
);
|
|
2993
3307
|
}
|
|
2994
|
-
const open =
|
|
3308
|
+
const open = openOutcome(fold(readEvents({ file, readOnly })));
|
|
2995
3309
|
if (open) {
|
|
2996
3310
|
const reconciliation = open.decisions.length > 0 ? 'reconcile every linked decision, then ' : '';
|
|
2997
3311
|
const verification = open.acceptance.length > 0
|
|
2998
3312
|
? `${reconciliation}inspect and run driftseal verify, then close it with driftseal end`
|
|
2999
3313
|
: `${reconciliation}run the declared verification, then close it with driftseal end`;
|
|
3000
3314
|
return (
|
|
3001
|
-
`DriftSeal reminder:
|
|
3315
|
+
`DriftSeal reminder: outcome ${open.id} is still in_progress: "${open.outcome}". ` +
|
|
3002
3316
|
`If its work is done, ${verification}; ` +
|
|
3003
3317
|
'if this turn was unrelated, ignore this reminder.'
|
|
3004
3318
|
);
|
|
3005
3319
|
}
|
|
3006
3320
|
return (
|
|
3007
|
-
'DriftSeal reminder: no
|
|
3321
|
+
'DriftSeal reminder: no outcome is open. If this round changed files without one, consider ' +
|
|
3008
3322
|
'whether the work should have been logged; ignore this reminder when nothing changed.'
|
|
3009
3323
|
);
|
|
3010
3324
|
}
|
|
@@ -3078,7 +3392,7 @@ function isGitWorkTree(cwd = process.cwd()) {
|
|
|
3078
3392
|
* Hash the material Git-visible workspace contents rather than trusting the
|
|
3079
3393
|
* current commit alone. Any tracked or untracked (non-ignored) content change
|
|
3080
3394
|
* makes the verification stale.
|
|
3081
|
-
* The
|
|
3395
|
+
* The outcome event log is excluded because recording verification and closure
|
|
3082
3396
|
* necessarily appends to it.
|
|
3083
3397
|
*/
|
|
3084
3398
|
function workspaceFingerprint(cwd = process.cwd()) {
|
|
@@ -3148,8 +3462,8 @@ function workspaceFingerprint(cwd = process.cwd()) {
|
|
|
3148
3462
|
*
|
|
3149
3463
|
* Paths are read from `ls-files -z` with `:(literal)` pathspecs because git's
|
|
3150
3464
|
* human-readable listing C-quotes non-ASCII names and treats `*?[\` as
|
|
3151
|
-
* wildcards. The printed remediation uses the fixed
|
|
3152
|
-
*
|
|
3465
|
+
* wildcards. The printed remediation uses the fixed name `.seal` and is meant
|
|
3466
|
+
* to be run from this directory: git resolves
|
|
3153
3467
|
* those pathspecs against the init cwd, so the command stays paste-safe in
|
|
3154
3468
|
* POSIX shells, cmd.exe, and PowerShell without embedding the repo-relative
|
|
3155
3469
|
* prefix or any shell quoting.
|
|
@@ -3160,7 +3474,7 @@ function warnIfDefaultLogsTracked(cwd = process.cwd()) {
|
|
|
3160
3474
|
if (!root) return;
|
|
3161
3475
|
const prefix = gitCaptureLine(['rev-parse', '--show-prefix'], cwd);
|
|
3162
3476
|
if (prefix === null) return;
|
|
3163
|
-
const logNames = ['.
|
|
3477
|
+
const logNames = ['.seal'];
|
|
3164
3478
|
const logDirs = logNames.map((name) => `${prefix}${name}`);
|
|
3165
3479
|
const listing = gitCaptureRaw(
|
|
3166
3480
|
['ls-files', '-z', '--', ...logDirs.map((name) => `:(literal)${name}`)],
|
|
@@ -3223,7 +3537,7 @@ function gitMergeParents(cwd = process.cwd()) {
|
|
|
3223
3537
|
}
|
|
3224
3538
|
|
|
3225
3539
|
function gitDecisionIds(treeish, cwd = process.cwd()) {
|
|
3226
|
-
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.
|
|
3540
|
+
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.seal/madr'], cwd);
|
|
3227
3541
|
if (!out) return new Set();
|
|
3228
3542
|
const ids = new Set();
|
|
3229
3543
|
for (const file of out.split('\n')) {
|
|
@@ -3234,7 +3548,7 @@ function gitDecisionIds(treeish, cwd = process.cwd()) {
|
|
|
3234
3548
|
}
|
|
3235
3549
|
|
|
3236
3550
|
function gitDecisionEntries(treeish, cwd = process.cwd()) {
|
|
3237
|
-
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.
|
|
3551
|
+
const out = gitCapture(['ls-tree', '-r', '--name-only', treeish, '.seal/madr'], cwd);
|
|
3238
3552
|
if (!out) return [];
|
|
3239
3553
|
const entries = [];
|
|
3240
3554
|
for (const file of out.split('\n')) {
|
|
@@ -3253,9 +3567,9 @@ function gitDecisionEntries(treeish, cwd = process.cwd()) {
|
|
|
3253
3567
|
return entries;
|
|
3254
3568
|
}
|
|
3255
3569
|
|
|
3256
|
-
function
|
|
3257
|
-
const content = gitReadFile(treeish, '.
|
|
3258
|
-
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`);
|
|
3259
3573
|
}
|
|
3260
3574
|
|
|
3261
3575
|
function canonicalEvent(event) {
|
|
@@ -3374,10 +3688,14 @@ function hasDuplicateDecisionIds(entries) {
|
|
|
3374
3688
|
return false;
|
|
3375
3689
|
}
|
|
3376
3690
|
|
|
3377
|
-
function
|
|
3691
|
+
function isOutcomeStart(event) {
|
|
3692
|
+
return event.type === 'begin' || event.type === 'import';
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
function hasDuplicateOutcomeStarts(records) {
|
|
3378
3696
|
const seen = new Set();
|
|
3379
3697
|
for (const record of records) {
|
|
3380
|
-
if (record.event
|
|
3698
|
+
if (!isOutcomeStart(record.event)) continue;
|
|
3381
3699
|
if (seen.has(record.event.id)) return true;
|
|
3382
3700
|
seen.add(record.event.id);
|
|
3383
3701
|
}
|
|
@@ -3500,26 +3818,57 @@ function remapEvent(event, intentMap, decisionMap, hashMap = new Map()) {
|
|
|
3500
3818
|
return next;
|
|
3501
3819
|
}
|
|
3502
3820
|
|
|
3821
|
+
function rebindV2ContractHashes(records) {
|
|
3822
|
+
const contracts = new Map();
|
|
3823
|
+
return records.map((record) => {
|
|
3824
|
+
let event = record.event;
|
|
3825
|
+
if (event.type === 'begin' && event.logVersion === LOG_VERSION) {
|
|
3826
|
+
contracts.set(event.id, newOutcomeRecord(event));
|
|
3827
|
+
} else if (event.type === 'extend' && event.logVersion === LOG_VERSION) {
|
|
3828
|
+
const state = contracts.get(event.id);
|
|
3829
|
+
if (state) {
|
|
3830
|
+
state.extensions.push({
|
|
3831
|
+
extension: event.extension,
|
|
3832
|
+
acceptance: event.acceptance,
|
|
3833
|
+
verify: event.verify,
|
|
3834
|
+
decisions: event.decisions,
|
|
3835
|
+
});
|
|
3836
|
+
state.acceptance = [...new Set([...state.acceptance, ...event.acceptance])];
|
|
3837
|
+
if (event.verify) state.verify = event.verify;
|
|
3838
|
+
state.decisions = [...new Set([...state.decisions, ...event.decisions])];
|
|
3839
|
+
state.contractHash = outcomeContractHash(state);
|
|
3840
|
+
}
|
|
3841
|
+
} else if (event.logVersion === LOG_VERSION &&
|
|
3842
|
+
(event.type === 'verify' || (event.type === 'end' && event.status === 'completed'))) {
|
|
3843
|
+
const state = contracts.get(event.id);
|
|
3844
|
+
if (state && event.contractHash !== state.contractHash) {
|
|
3845
|
+
event = { ...event, contractHash: state.contractHash };
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
return event === record.event ? record : { event };
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3503
3852
|
function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
|
|
3504
3853
|
const intentMap = new Map();
|
|
3505
3854
|
const mappings = [];
|
|
3506
3855
|
const used = [...oursUsedEvents];
|
|
3507
3856
|
const records = theirsNew.map((record) => {
|
|
3508
3857
|
let event = record.event;
|
|
3509
|
-
if (event
|
|
3510
|
-
const { date } =
|
|
3858
|
+
if (isOutcomeStart(event) && used.some((item) => isOutcomeStart(item) && item.id === event.id)) {
|
|
3859
|
+
const { date } = parseOutcomeId(event.id);
|
|
3511
3860
|
const newId = nextIdForDate(date, used);
|
|
3512
3861
|
intentMap.set(event.id, newId);
|
|
3513
|
-
mappings.push({ kind: '
|
|
3862
|
+
mappings.push({ kind: 'outcome', from: event.id, to: newId });
|
|
3514
3863
|
}
|
|
3515
3864
|
event = remapEvent(event, intentMap, decisionMap, hashMap);
|
|
3516
3865
|
used.push(event);
|
|
3517
3866
|
return { event };
|
|
3518
3867
|
});
|
|
3519
|
-
return { records, mappings };
|
|
3868
|
+
return { records: rebindV2ContractHashes(records), mappings };
|
|
3520
3869
|
}
|
|
3521
3870
|
|
|
3522
|
-
function
|
|
3871
|
+
function repairDuplicateOutcomeRecords(records, decisionMap, hashMap = new Map()) {
|
|
3523
3872
|
const seenBegins = new Set();
|
|
3524
3873
|
const intentMap = new Map();
|
|
3525
3874
|
const used = [];
|
|
@@ -3528,13 +3877,13 @@ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map())
|
|
|
3528
3877
|
let incomingSide = false;
|
|
3529
3878
|
for (const record of records) {
|
|
3530
3879
|
let event = record.event;
|
|
3531
|
-
if (event
|
|
3880
|
+
if (isOutcomeStart(event) && seenBegins.has(event.id)) {
|
|
3532
3881
|
incomingSide = true;
|
|
3533
|
-
const { date } =
|
|
3882
|
+
const { date } = parseOutcomeId(event.id);
|
|
3534
3883
|
const newId = nextIdForDate(date, used);
|
|
3535
3884
|
intentMap.set(event.id, newId);
|
|
3536
|
-
mappings.push({ kind: '
|
|
3537
|
-
} else if (event
|
|
3885
|
+
mappings.push({ kind: 'outcome', from: event.id, to: newId });
|
|
3886
|
+
} else if (isOutcomeStart(event)) {
|
|
3538
3887
|
seenBegins.add(event.id);
|
|
3539
3888
|
}
|
|
3540
3889
|
const remapped = remapEvent(
|
|
@@ -3547,7 +3896,7 @@ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map())
|
|
|
3547
3896
|
result.push(changed ? { event: remapped } : record);
|
|
3548
3897
|
used.push(result.at(-1).event);
|
|
3549
3898
|
}
|
|
3550
|
-
return { records: result, mappings, incomingSide };
|
|
3899
|
+
return { records: rebindV2ContractHashes(result), mappings, incomingSide };
|
|
3551
3900
|
}
|
|
3552
3901
|
|
|
3553
3902
|
function serializeRecords(records) {
|
|
@@ -3575,19 +3924,19 @@ function applyDecisionCopies(copies, dryRun) {
|
|
|
3575
3924
|
}
|
|
3576
3925
|
}
|
|
3577
3926
|
|
|
3578
|
-
function
|
|
3579
|
-
return records.filter((record) => record.event.type
|
|
3927
|
+
function countAbsorbedOutcomes(records) {
|
|
3928
|
+
return records.filter((record) => ['begin', 'import'].includes(record.event.type)).length;
|
|
3580
3929
|
}
|
|
3581
3930
|
|
|
3582
|
-
function printAbsorbReport({ mappings, abandoned,
|
|
3583
|
-
const
|
|
3931
|
+
function printAbsorbReport({ mappings, abandoned, outcomeCount }) {
|
|
3932
|
+
const remappedOutcomes = mappings.filter((mapping) => mapping.kind === 'outcome').length;
|
|
3584
3933
|
const remappedDecisions = mappings.filter((mapping) => mapping.kind === 'decision').length;
|
|
3585
3934
|
printLine(
|
|
3586
|
-
`absorbed ${
|
|
3935
|
+
`absorbed ${outcomeCount} outcome(s), remapped ${remappedOutcomes} outcome id(s), ${remappedDecisions} decision id(s)`
|
|
3587
3936
|
);
|
|
3588
3937
|
for (const mapping of mappings) {
|
|
3589
3938
|
const side = mapping.side || 'theirs';
|
|
3590
|
-
if (mapping.kind === '
|
|
3939
|
+
if (mapping.kind === 'outcome') printLine(`${mapping.from} (${side}) -> ${mapping.to}`);
|
|
3591
3940
|
else printLine(`decision ${mapping.from} (${side}) -> ${mapping.to}`);
|
|
3592
3941
|
}
|
|
3593
3942
|
if (abandoned) printLine(`abandoned ${abandoned} during absorb`);
|
|
@@ -3596,6 +3945,7 @@ function printAbsorbReport({ mappings, abandoned, intentCount }) {
|
|
|
3596
3945
|
function abandonOpenIntent(records, targetId, side) {
|
|
3597
3946
|
records.push({
|
|
3598
3947
|
event: {
|
|
3948
|
+
logVersion: LOG_VERSION,
|
|
3599
3949
|
schemaVersion: EVENT_SCHEMA_VERSION,
|
|
3600
3950
|
type: 'end',
|
|
3601
3951
|
id: targetId,
|
|
@@ -3616,13 +3966,13 @@ function resolveOpenIntents(
|
|
|
3616
3966
|
abandon,
|
|
3617
3967
|
{ allowConflict = false, overlay = [], parkedOpen = null } = {}
|
|
3618
3968
|
) {
|
|
3619
|
-
const oursOpen =
|
|
3620
|
-
const theirsOpen =
|
|
3969
|
+
const oursOpen = openOutcome(fold(oursRecords.map((record) => record.event)));
|
|
3970
|
+
const theirsOpen = openOutcome(fold(theirsRecords.map((record) => record.event)));
|
|
3621
3971
|
try {
|
|
3622
|
-
|
|
3972
|
+
openOutcome(fold([...result, ...overlay].map((record) => record.event)));
|
|
3623
3973
|
return { abandoned: null, conflict: false, parkedClosed: false };
|
|
3624
3974
|
} catch (err) {
|
|
3625
|
-
if (!(err instanceof DriftSealError) || !/multiple
|
|
3975
|
+
if (!(err instanceof DriftSealError) || !/multiple outcomes in progress/.test(err.message)) {
|
|
3626
3976
|
throw err;
|
|
3627
3977
|
}
|
|
3628
3978
|
if (abandon === 'theirs' && theirsOpen) {
|
|
@@ -3666,19 +4016,25 @@ function mergeRecordStreams(ours, theirs, baseRecords) {
|
|
|
3666
4016
|
}
|
|
3667
4017
|
|
|
3668
4018
|
function GITATTRIBUTES_MERGE_LINE() {
|
|
3669
|
-
return '.
|
|
4019
|
+
return '.seal/outcomes/events.jsonl merge=driftseal';
|
|
3670
4020
|
}
|
|
3671
4021
|
|
|
3672
4022
|
function ensureGitAttributes() {
|
|
3673
4023
|
const target = path.join(process.cwd(), '.gitattributes');
|
|
3674
4024
|
const line = GITATTRIBUTES_MERGE_LINE();
|
|
4025
|
+
const legacyLine = '.intent-log/events.jsonl merge=driftseal';
|
|
3675
4026
|
const existed = fs.existsSync(target);
|
|
3676
4027
|
const current = existed ? fs.readFileSync(target, 'utf8') : '';
|
|
3677
4028
|
const eol = current.includes('\r\n') ? '\r\n' : '\n';
|
|
3678
4029
|
const lines = current.split(/\r?\n/);
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
4030
|
+
const filtered = lines.filter((entry) => entry.trim() !== legacyLine);
|
|
4031
|
+
if (!filtered.some((entry) => entry.trim() === line)) {
|
|
4032
|
+
const insertion = filtered.length > 0 && filtered.at(-1) === '' ? filtered.length - 1 : filtered.length;
|
|
4033
|
+
filtered.splice(insertion, 0, line);
|
|
4034
|
+
}
|
|
4035
|
+
let next = filtered.join(eol);
|
|
4036
|
+
if (!next.endsWith(eol)) next += eol;
|
|
4037
|
+
if (next === current) return { changed: false, target };
|
|
3682
4038
|
atomicWriteFile(target, next);
|
|
3683
4039
|
return { changed: true, target };
|
|
3684
4040
|
}
|
|
@@ -3687,7 +4043,7 @@ function ensureGitMergeDriver() {
|
|
|
3687
4043
|
if (!isGitWorkTree()) return { changed: false, configured: false };
|
|
3688
4044
|
const name = gitCapture(['config', '--local', '--get', 'merge.driftseal.name']);
|
|
3689
4045
|
const driver = gitCapture(['config', '--local', '--get', 'merge.driftseal.driver']);
|
|
3690
|
-
const expectedName = 'DriftSeal
|
|
4046
|
+
const expectedName = 'DriftSeal outcome log merge';
|
|
3691
4047
|
const expectedDriver = 'driftseal absorb --git %O %A %B';
|
|
3692
4048
|
if (name === expectedName && driver === expectedDriver) {
|
|
3693
4049
|
return { changed: false, configured: true };
|
|
@@ -3708,10 +4064,10 @@ function absorbUsage() {
|
|
|
3708
4064
|
);
|
|
3709
4065
|
}
|
|
3710
4066
|
|
|
3711
|
-
function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false } = {}) {
|
|
4067
|
+
function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false, allowLegacy = false } = {}) {
|
|
3712
4068
|
if (!fs.existsSync(file)) {
|
|
3713
4069
|
if (allowMissing) return { records: [], conflict: false };
|
|
3714
|
-
fail(`
|
|
4070
|
+
fail(`outcome log not found: ${file}`);
|
|
3715
4071
|
}
|
|
3716
4072
|
let content = fs.readFileSync(file, 'utf8');
|
|
3717
4073
|
if (repairTail && !/^<<<<<<< /m.test(content)) {
|
|
@@ -3721,12 +4077,12 @@ function loadAbsorbSide(file, label, { repairTail = false, allowMissing = false
|
|
|
3721
4077
|
const conflict = parseConflictContent(content);
|
|
3722
4078
|
if (conflict) {
|
|
3723
4079
|
return {
|
|
3724
|
-
ours: parseJsonlRecords(conflict.oursText, `${label} ours
|
|
3725
|
-
theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs
|
|
4080
|
+
ours: parseJsonlRecords(conflict.oursText, `${label} ours`, { allowLegacy }),
|
|
4081
|
+
theirs: parseJsonlRecords(conflict.theirsText, `${label} theirs`, { allowLegacy }),
|
|
3726
4082
|
conflict: true,
|
|
3727
4083
|
};
|
|
3728
4084
|
}
|
|
3729
|
-
return { records: parseJsonlRecords(content, label), conflict: false };
|
|
4085
|
+
return { records: parseJsonlRecords(content, label, { allowLegacy }), conflict: false };
|
|
3730
4086
|
}
|
|
3731
4087
|
|
|
3732
4088
|
function finishAbsorb({
|
|
@@ -3738,18 +4094,18 @@ function finishAbsorb({
|
|
|
3738
4094
|
abandon,
|
|
3739
4095
|
dryRun,
|
|
3740
4096
|
outputFile,
|
|
3741
|
-
|
|
4097
|
+
outcomeCount,
|
|
3742
4098
|
allowConflict = false,
|
|
3743
4099
|
followupMessage = null,
|
|
3744
4100
|
}) {
|
|
3745
|
-
// An
|
|
4101
|
+
// An outcome parked in Git metadata is part of our side even though the log never saw it.
|
|
3746
4102
|
const park = shouldAttachInProgress(outputFile) ? inProgressFile() : null;
|
|
3747
4103
|
const plan = planInProgressOverlay(result.map((record) => record.event), park, {
|
|
3748
4104
|
repairTail: true,
|
|
3749
4105
|
});
|
|
3750
4106
|
const overlay = plan && !plan.alreadyCommitted ? plan.records : [];
|
|
3751
4107
|
const parkedOpen =
|
|
3752
|
-
overlay.length > 0 ?
|
|
4108
|
+
overlay.length > 0 ? openOutcome(fold(overlay.map((record) => record.event))) : null;
|
|
3753
4109
|
const parkMappings = plan
|
|
3754
4110
|
? plan.mappings.map((mapping) => ({ ...mapping, side: 'parked' }))
|
|
3755
4111
|
: [];
|
|
@@ -3767,7 +4123,7 @@ function finishAbsorb({
|
|
|
3767
4123
|
const merged = flushOverlay ? [...result, ...overlay] : result;
|
|
3768
4124
|
const effective = [...result, ...overlay].map((record) => record.event);
|
|
3769
4125
|
fold(effective);
|
|
3770
|
-
if (!conflict)
|
|
4126
|
+
if (!conflict) openOutcome(fold(effective));
|
|
3771
4127
|
if (!dryRun) {
|
|
3772
4128
|
writeJsonl(outputFile, merged);
|
|
3773
4129
|
applyDecisionCopies(copies, dryRun);
|
|
@@ -3777,7 +4133,7 @@ function finishAbsorb({
|
|
|
3777
4133
|
}
|
|
3778
4134
|
}
|
|
3779
4135
|
if (
|
|
3780
|
-
|
|
4136
|
+
outcomeCount === 0 &&
|
|
3781
4137
|
allMappings.length === 0 &&
|
|
3782
4138
|
copies.length === 0 &&
|
|
3783
4139
|
!abandoned &&
|
|
@@ -3788,11 +4144,11 @@ function finishAbsorb({
|
|
|
3788
4144
|
printAbsorbReport({
|
|
3789
4145
|
mappings: allMappings,
|
|
3790
4146
|
abandoned,
|
|
3791
|
-
|
|
4147
|
+
outcomeCount,
|
|
3792
4148
|
});
|
|
3793
4149
|
}
|
|
3794
4150
|
if (conflict) {
|
|
3795
|
-
printLine('multiple
|
|
4151
|
+
printLine('multiple outcomes remain in progress; re-run with --abandon-theirs or --abandon-ours');
|
|
3796
4152
|
}
|
|
3797
4153
|
if (followupMessage) printLine(followupMessage);
|
|
3798
4154
|
return {
|
|
@@ -3830,7 +4186,7 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
|
|
|
3830
4186
|
outputFile: options.outputFile,
|
|
3831
4187
|
allowConflict: options.allowConflict,
|
|
3832
4188
|
followupMessage: options.followupMessage,
|
|
3833
|
-
|
|
4189
|
+
outcomeCount: streams.theirsNew.filter((record) => ['begin', 'import'].includes(record.event.type)).length,
|
|
3834
4190
|
});
|
|
3835
4191
|
}
|
|
3836
4192
|
|
|
@@ -3843,7 +4199,7 @@ function gitAbsorbRepairContext(records, decisionEntries) {
|
|
|
3843
4199
|
base: gitMergeBaseFor('HEAD', pending),
|
|
3844
4200
|
};
|
|
3845
4201
|
}
|
|
3846
|
-
if (!hasDuplicateDecisionIds(decisionEntries) && !
|
|
4202
|
+
if (!hasDuplicateDecisionIds(decisionEntries) && !hasDuplicateOutcomeStarts(records)) return null;
|
|
3847
4203
|
const parents = gitMergeParents();
|
|
3848
4204
|
if (!parents) return null;
|
|
3849
4205
|
return {
|
|
@@ -3853,10 +4209,10 @@ function gitAbsorbRepairContext(records, decisionEntries) {
|
|
|
3853
4209
|
}
|
|
3854
4210
|
|
|
3855
4211
|
function absorbFromGitContext(context, { abandon, dryRun, outputFile }) {
|
|
3856
|
-
const baseRecords = context.base ?
|
|
4212
|
+
const baseRecords = context.base ? gitOutcomeRecords(context.base) : [];
|
|
3857
4213
|
return absorbFromStreams(
|
|
3858
|
-
|
|
3859
|
-
|
|
4214
|
+
gitOutcomeRecords(context.ours),
|
|
4215
|
+
gitOutcomeRecords(context.theirs),
|
|
3860
4216
|
baseRecords,
|
|
3861
4217
|
{
|
|
3862
4218
|
abandon,
|
|
@@ -3913,21 +4269,21 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
|
3913
4269
|
baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
3914
4270
|
baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
|
|
3915
4271
|
});
|
|
3916
|
-
const repaired =
|
|
4272
|
+
const repaired = repairDuplicateOutcomeRecords(
|
|
3917
4273
|
loaded.records,
|
|
3918
4274
|
decisionPlan.decisionMap,
|
|
3919
4275
|
decisionPlan.hashMap
|
|
3920
4276
|
);
|
|
3921
4277
|
if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
|
|
3922
4278
|
fail(
|
|
3923
|
-
'cannot determine which
|
|
4279
|
+
'cannot determine which outcome records own the duplicate decision; ' +
|
|
3924
4280
|
'run absorb during the merge or provide the incoming log and decision directory'
|
|
3925
4281
|
);
|
|
3926
4282
|
}
|
|
3927
4283
|
const result = repaired.records;
|
|
3928
4284
|
const mappings = [...repaired.mappings, ...decisionPlan.mappings];
|
|
3929
4285
|
const remappedIds = new Set(
|
|
3930
|
-
mappings.filter((mapping) => mapping.kind === '
|
|
4286
|
+
mappings.filter((mapping) => mapping.kind === 'outcome').map((mapping) => mapping.to)
|
|
3931
4287
|
);
|
|
3932
4288
|
const oursRecords = result.filter((record) => !remappedIds.has(record.event.id));
|
|
3933
4289
|
return finishAbsorb({
|
|
@@ -3939,13 +4295,13 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
|
3939
4295
|
abandon,
|
|
3940
4296
|
dryRun,
|
|
3941
4297
|
outputFile: oursFile,
|
|
3942
|
-
|
|
4298
|
+
outcomeCount: remappedIds.size,
|
|
3943
4299
|
});
|
|
3944
4300
|
}
|
|
3945
4301
|
|
|
3946
4302
|
const theirs = loadAbsorbSide(otherFile, otherFile);
|
|
3947
4303
|
if (theirs.conflict) fail(`incoming log still contains conflict markers: ${otherFile}`);
|
|
3948
|
-
const otherRoot = path.resolve(path.dirname(otherFile), '..');
|
|
4304
|
+
const otherRoot = path.resolve(path.dirname(otherFile), '..', '..');
|
|
3949
4305
|
const otherHead = isGitWorkTree(otherRoot) ? gitCapture(['rev-parse', 'HEAD'], otherRoot) : null;
|
|
3950
4306
|
const gitBase = otherHead ? gitCapture(['merge-base', 'HEAD', otherHead]) : gitMergeBase();
|
|
3951
4307
|
const baseDecisionIds = gitBase
|
|
@@ -3960,21 +4316,23 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
|
|
|
3960
4316
|
dryRun,
|
|
3961
4317
|
outputFile: oursFile,
|
|
3962
4318
|
oursDecisionEntries: listDecisionEntries(decisionDir()),
|
|
3963
|
-
theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', '
|
|
4319
|
+
theirsDecisionEntries: listDecisionEntries(otherDecisions || path.join(path.dirname(otherFile), '..', 'madr')),
|
|
3964
4320
|
baseDecisionEntries: gitBase ? gitDecisionEntries(gitBase) : [],
|
|
3965
4321
|
baseDecisionIds,
|
|
3966
4322
|
});
|
|
3967
4323
|
}
|
|
3968
4324
|
|
|
3969
4325
|
function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
|
|
3970
|
-
const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true });
|
|
3971
|
-
const ours = loadAbsorbSide(oursFile, oursFile);
|
|
3972
|
-
const theirs = loadAbsorbSide(theirsFile, theirsFile);
|
|
4326
|
+
const base = loadAbsorbSide(baseFile, baseFile, { allowMissing: true, allowLegacy: true });
|
|
4327
|
+
const ours = loadAbsorbSide(oursFile, oursFile, { allowLegacy: true });
|
|
4328
|
+
const theirs = loadAbsorbSide(theirsFile, theirsFile, { allowLegacy: true });
|
|
3973
4329
|
if (base.conflict || ours.conflict || theirs.conflict) {
|
|
3974
4330
|
fail('git merge driver received a log that still contains conflict markers');
|
|
3975
4331
|
}
|
|
3976
4332
|
const otherHead =
|
|
3977
|
-
gitOtherHead() ||
|
|
4333
|
+
gitOtherHead() ||
|
|
4334
|
+
gitFindCommitForFile(theirsFile, '.seal/outcomes/events.jsonl') ||
|
|
4335
|
+
gitFindCommitForFile(theirsFile, '.intent-log/events.jsonl');
|
|
3978
4336
|
const mergeBase = otherHead ? gitMergeBaseFor('HEAD', otherHead) : null;
|
|
3979
4337
|
let followupMessage = null;
|
|
3980
4338
|
if (!otherHead) {
|
|
@@ -4103,20 +4461,21 @@ function executeVerificationCommand(command) {
|
|
|
4103
4461
|
|
|
4104
4462
|
function runMachineVerification({ allowTrackedCommand = false } = {}) {
|
|
4105
4463
|
const snapshot = withMutationLocks([logDir()], () => {
|
|
4106
|
-
const
|
|
4107
|
-
if (!
|
|
4108
|
-
if (
|
|
4109
|
-
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`);
|
|
4110
4468
|
}
|
|
4111
|
-
if (!
|
|
4469
|
+
if (!outcome.verify) fail(`outcome ${outcome.id} has no verification command`);
|
|
4112
4470
|
const park = inProgressFile();
|
|
4113
|
-
const parked = park ?
|
|
4114
|
-
const locallyProvenanced =
|
|
4471
|
+
const parked = park ? parkedOpenOutcome(park) : null;
|
|
4472
|
+
const locallyProvenanced = hasMatchingLocalOutcomeProvenance(outcome);
|
|
4115
4473
|
return {
|
|
4116
|
-
id:
|
|
4117
|
-
command:
|
|
4474
|
+
id: outcome.id,
|
|
4475
|
+
command: outcome.verify,
|
|
4476
|
+
contractHash: outcome.contractHash,
|
|
4118
4477
|
requiresExplicitTrust:
|
|
4119
|
-
(!parked || parked.id !==
|
|
4478
|
+
(!parked || parked.id !== outcome.id) && !locallyProvenanced,
|
|
4120
4479
|
};
|
|
4121
4480
|
});
|
|
4122
4481
|
|
|
@@ -4125,7 +4484,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
|
|
|
4125
4484
|
if (snapshot.requiresExplicitTrust && !allowTrackedCommand) {
|
|
4126
4485
|
fail(
|
|
4127
4486
|
`refusing to execute a verification command that DriftSeal cannot confirm was created locally: ${displayedCommand}\n` +
|
|
4128
|
-
'no matching local
|
|
4487
|
+
'no matching local outcome provenance was found; ' +
|
|
4129
4488
|
'inspect the command, then re-run with --allow-tracked-command only if you trust it'
|
|
4130
4489
|
);
|
|
4131
4490
|
}
|
|
@@ -4141,6 +4500,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
|
|
|
4141
4500
|
verificationId: crypto.randomUUID(),
|
|
4142
4501
|
ts: new Date().toISOString(),
|
|
4143
4502
|
command: snapshot.command,
|
|
4503
|
+
contractHash: snapshot.contractHash,
|
|
4144
4504
|
passed,
|
|
4145
4505
|
exitCode,
|
|
4146
4506
|
signal,
|
|
@@ -4152,23 +4512,764 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
|
|
|
4152
4512
|
head: gitCapture(['rev-parse', 'HEAD']),
|
|
4153
4513
|
};
|
|
4154
4514
|
|
|
4155
|
-
const
|
|
4515
|
+
const outcome = withMutationLocks([logDir()], () => {
|
|
4156
4516
|
const events = readEvents({ repairTail: true });
|
|
4157
|
-
const current =
|
|
4158
|
-
if (!current || current.id !== snapshot.id || current.verify !== snapshot.command
|
|
4159
|
-
|
|
4517
|
+
const current = openOutcome(fold(events));
|
|
4518
|
+
if (!current || current.id !== snapshot.id || current.verify !== snapshot.command ||
|
|
4519
|
+
current.contractHash !== snapshot.contractHash) {
|
|
4520
|
+
fail(`outcome ${snapshot.id} changed while its verification command was running`);
|
|
4160
4521
|
}
|
|
4161
4522
|
events.push(appendEvent(verificationEvent));
|
|
4162
4523
|
return fold(events).find((candidate) => candidate.id === snapshot.id);
|
|
4163
4524
|
});
|
|
4164
4525
|
printLine(`${snapshot.id} verification ${passed ? 'passed' : 'failed'} (exit ${exitCode})`);
|
|
4165
4526
|
return {
|
|
4166
|
-
|
|
4167
|
-
verification: publicVerification(
|
|
4527
|
+
outcome: publicOutcome(outcome),
|
|
4528
|
+
verification: publicVerification(outcome.verification),
|
|
4168
4529
|
exitCode,
|
|
4169
4530
|
};
|
|
4170
4531
|
}
|
|
4171
4532
|
|
|
4533
|
+
const MIGRATION_PLAN_FORMAT = 'driftseal-v1-to-v2-plan';
|
|
4534
|
+
|
|
4535
|
+
function canonicalPath(file) {
|
|
4536
|
+
const resolved = path.resolve(file);
|
|
4537
|
+
const missing = [];
|
|
4538
|
+
let cursor = resolved;
|
|
4539
|
+
while (!fs.existsSync(cursor)) {
|
|
4540
|
+
const parent = path.dirname(cursor);
|
|
4541
|
+
if (parent === cursor) break;
|
|
4542
|
+
missing.push(path.basename(cursor));
|
|
4543
|
+
cursor = parent;
|
|
4544
|
+
}
|
|
4545
|
+
const existing = fs.existsSync(cursor) ? fs.realpathSync(cursor) : cursor;
|
|
4546
|
+
return path.join(existing, ...missing.reverse());
|
|
4547
|
+
}
|
|
4548
|
+
|
|
4549
|
+
function pathContains(parent, candidate) {
|
|
4550
|
+
const relative = path.relative(canonicalPath(parent), canonicalPath(candidate));
|
|
4551
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
function resolveMigrationIdentityPath(location) {
|
|
4555
|
+
if (typeof location === 'string') return canonicalPath(location);
|
|
4556
|
+
if (location?.base === 'repository') return canonicalPath(path.resolve(process.cwd(), location.path));
|
|
4557
|
+
if (location?.base === 'absolute') return canonicalPath(location.path);
|
|
4558
|
+
return null;
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
function encodeMigrationIdentityPath(file) {
|
|
4562
|
+
const resolved = canonicalPath(file);
|
|
4563
|
+
const root = canonicalPath(process.cwd());
|
|
4564
|
+
const relative = path.relative(root, resolved);
|
|
4565
|
+
if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
|
|
4566
|
+
return {
|
|
4567
|
+
base: 'repository',
|
|
4568
|
+
path: (relative || '.').split(path.sep).join('/'),
|
|
4569
|
+
};
|
|
4570
|
+
}
|
|
4571
|
+
return { base: 'absolute', path: resolved };
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
function defaultV1SourceLog() {
|
|
4575
|
+
const home = v1HomeEnv();
|
|
4576
|
+
if (home) {
|
|
4577
|
+
const configured = path.join(home, 'events.jsonl');
|
|
4578
|
+
if (fs.existsSync(configured)) return configured;
|
|
4579
|
+
}
|
|
4580
|
+
return path.join(process.cwd(), '.intent-log', 'events.jsonl');
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
function migrationPaths(flags = {}, { storedSource } = {}) {
|
|
4584
|
+
const sourceLog = canonicalPath(
|
|
4585
|
+
flags['source-log'] ||
|
|
4586
|
+
resolveMigrationIdentityPath(storedSource?.log) ||
|
|
4587
|
+
defaultV1SourceLog()
|
|
4588
|
+
);
|
|
4589
|
+
const sourceDecisions = canonicalPath(
|
|
4590
|
+
flags['source-decisions'] ||
|
|
4591
|
+
resolveMigrationIdentityPath(storedSource?.decisions) ||
|
|
4592
|
+
v1DecisionHomeEnv() ||
|
|
4593
|
+
path.join(process.cwd(), '.decision-log')
|
|
4594
|
+
);
|
|
4595
|
+
const destination = canonicalPath(flags.destination || sealRoot());
|
|
4596
|
+
for (const sourcePath of [sourceLog, sourceDecisions]) {
|
|
4597
|
+
if (pathContains(sourcePath, destination) || pathContains(destination, sourcePath)) {
|
|
4598
|
+
fail(`migration destination overlaps v1 source path: ${destination} and ${sourcePath}`);
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
return { sourceLog, sourceDecisions, destination };
|
|
4602
|
+
}
|
|
4603
|
+
|
|
4604
|
+
function legacyParkFile() {
|
|
4605
|
+
if (!isGitWorkTree()) return null;
|
|
4606
|
+
const gitPath = gitCapture(['rev-parse', '--git-path', 'driftseal-in-progress.jsonl']);
|
|
4607
|
+
return gitPath ? path.resolve(process.cwd(), gitPath) : null;
|
|
4608
|
+
}
|
|
4609
|
+
|
|
4610
|
+
function legacyIntentLogFile() {
|
|
4611
|
+
return path.resolve(process.cwd(), '.intent-log', 'events.jsonl');
|
|
4612
|
+
}
|
|
4613
|
+
|
|
4614
|
+
function legacyParkedIntent() {
|
|
4615
|
+
const park = legacyParkFile();
|
|
4616
|
+
if (!park || !fs.existsSync(park)) return null;
|
|
4617
|
+
try {
|
|
4618
|
+
const records = parseJsonlRecords(fs.readFileSync(park, 'utf8'), park, { allowLegacy: true });
|
|
4619
|
+
return openOutcome(fold(records.map((record) => record.event)));
|
|
4620
|
+
} catch {
|
|
4621
|
+
return null;
|
|
4622
|
+
}
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
function closeLegacyParkedIntent(status, note) {
|
|
4626
|
+
const parked = legacyParkedIntent();
|
|
4627
|
+
if (!parked) fail('no parked v1 intent to close');
|
|
4628
|
+
const park = legacyParkFile();
|
|
4629
|
+
const log = legacyIntentLogFile();
|
|
4630
|
+
const parkRecords = parseJsonlRecords(fs.readFileSync(park, 'utf8'), park, { allowLegacy: true });
|
|
4631
|
+
const logRecords = fs.existsSync(log)
|
|
4632
|
+
? parseJsonlRecords(fs.readFileSync(log, 'utf8'), log, { allowLegacy: true })
|
|
4633
|
+
: [];
|
|
4634
|
+
const endEvent = {
|
|
4635
|
+
schemaVersion: LEGACY_EVENT_SCHEMA_VERSION,
|
|
4636
|
+
type: 'end',
|
|
4637
|
+
id: parked.id,
|
|
4638
|
+
ts: new Date().toISOString(),
|
|
4639
|
+
status,
|
|
4640
|
+
note: note || null,
|
|
4641
|
+
};
|
|
4642
|
+
writeJsonl(log, [
|
|
4643
|
+
...logRecords,
|
|
4644
|
+
...parkRecords,
|
|
4645
|
+
{ raw: JSON.stringify(endEvent), event: normalizeEvent(endEvent, logRecords.length + parkRecords.length + 1) },
|
|
4646
|
+
]);
|
|
4647
|
+
fs.unlinkSync(park);
|
|
4648
|
+
fsyncDirectory(path.dirname(park));
|
|
4649
|
+
const closed = fold([...logRecords, ...parkRecords].map((record) => record.event).concat(normalizeEvent(endEvent, 1)))
|
|
4650
|
+
.find((record) => record.id === parked.id);
|
|
4651
|
+
return closed || { ...parked, status, note: note || null, tsEnd: endEvent.ts };
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4654
|
+
function migrationDecisionFiles(directory) {
|
|
4655
|
+
if (!fs.existsSync(directory)) return [];
|
|
4656
|
+
return fs.readdirSync(directory, { withFileTypes: true })
|
|
4657
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
4658
|
+
.map((entry) => {
|
|
4659
|
+
const file = path.join(directory, entry.name);
|
|
4660
|
+
return { name: entry.name, file, bytes: fs.readFileSync(file) };
|
|
4661
|
+
})
|
|
4662
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
function migrationMadrManifest(decisions) {
|
|
4666
|
+
return decisions.map((decision) => ({
|
|
4667
|
+
name: decision.name,
|
|
4668
|
+
sha256: crypto.createHash('sha256').update(decision.bytes).digest('hex'),
|
|
4669
|
+
bytes: decision.bytes.length,
|
|
4670
|
+
}));
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
function migrationSourceIdentity(snapshot) {
|
|
4674
|
+
return {
|
|
4675
|
+
log: encodeMigrationIdentityPath(snapshot.sourceLog),
|
|
4676
|
+
decisions: encodeMigrationIdentityPath(snapshot.sourceDecisions),
|
|
4677
|
+
logPresent: snapshot.sourceLogPresent,
|
|
4678
|
+
};
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
function migrationSourceMatchesSnapshot(source, snapshot) {
|
|
4682
|
+
return (
|
|
4683
|
+
source.logPresent === snapshot.sourceLogPresent &&
|
|
4684
|
+
sameResolvedPath(resolveMigrationIdentityPath(source.log), snapshot.sourceLog) &&
|
|
4685
|
+
sameResolvedPath(resolveMigrationIdentityPath(source.decisions), snapshot.sourceDecisions)
|
|
4686
|
+
);
|
|
4687
|
+
}
|
|
4688
|
+
|
|
4689
|
+
function manifestDecisionId(name) {
|
|
4690
|
+
const match = name.match(/^(\d+)-/);
|
|
4691
|
+
if (!match) return null;
|
|
4692
|
+
try {
|
|
4693
|
+
return normalizeDecisionId(match[1]);
|
|
4694
|
+
} catch {
|
|
4695
|
+
return null;
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
|
|
4699
|
+
function latestMigrationReconciledHashes(events) {
|
|
4700
|
+
const latestReconciledHash = new Map();
|
|
4701
|
+
for (const record of fold(events)) {
|
|
4702
|
+
for (const update of record.decisionUpdates) {
|
|
4703
|
+
if (update.type === 'decision_reconcile_commit' && typeof update.fileHash === 'string') {
|
|
4704
|
+
latestReconciledHash.set(update.decisionId, update.fileHash);
|
|
4705
|
+
}
|
|
4706
|
+
}
|
|
4707
|
+
}
|
|
4708
|
+
return latestReconciledHash;
|
|
4709
|
+
}
|
|
4710
|
+
|
|
4711
|
+
function validateMigrationMadrManifest(directory, manifest, events = []) {
|
|
4712
|
+
const latestReconciledHash = latestMigrationReconciledHashes(events);
|
|
4713
|
+
for (const entry of manifest) {
|
|
4714
|
+
const file = path.join(directory, entry.name);
|
|
4715
|
+
if (!fs.existsSync(file)) fail(`migrated MADR is missing: ${entry.name}`);
|
|
4716
|
+
const bytes = fs.readFileSync(file);
|
|
4717
|
+
const hash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
4718
|
+
const decisionId = manifestDecisionId(entry.name);
|
|
4719
|
+
const reconciledHash = decisionId ? latestReconciledHash.get(decisionId) : null;
|
|
4720
|
+
const expected = reconciledHash || entry.sha256;
|
|
4721
|
+
if (hash !== expected || (!reconciledHash && bytes.length !== entry.bytes)) {
|
|
4722
|
+
fail(`migrated MADR does not match the migration manifest: ${entry.name}`);
|
|
4723
|
+
}
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
function migrationSourceContent(sourceLog, sourceDecisions) {
|
|
4728
|
+
const resolvedLog = canonicalPath(sourceLog);
|
|
4729
|
+
const resolvedDecisions = canonicalPath(sourceDecisions);
|
|
4730
|
+
const decisions = migrationDecisionFiles(resolvedDecisions);
|
|
4731
|
+
const sourceLogPresent = fs.existsSync(resolvedLog);
|
|
4732
|
+
const rawLog = sourceLogPresent ? fs.readFileSync(resolvedLog, 'utf8') : '';
|
|
4733
|
+
return {
|
|
4734
|
+
sourceLog: resolvedLog,
|
|
4735
|
+
sourceDecisions: resolvedDecisions,
|
|
4736
|
+
decisions,
|
|
4737
|
+
sourceLogPresent,
|
|
4738
|
+
rawLog,
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
function hashMigrationSourceContent(content) {
|
|
4743
|
+
const hash = crypto.createHash('sha256');
|
|
4744
|
+
hash.update(JSON.stringify(migrationSourceIdentity(content)));
|
|
4745
|
+
hash.update('\0');
|
|
4746
|
+
hash.update(content.rawLog);
|
|
4747
|
+
const legacyHash = crypto.createHash('sha256');
|
|
4748
|
+
legacyHash.update(content.sourceLog);
|
|
4749
|
+
legacyHash.update('\0');
|
|
4750
|
+
legacyHash.update(content.rawLog);
|
|
4751
|
+
for (const decision of content.decisions) {
|
|
4752
|
+
hash.update('\0');
|
|
4753
|
+
hash.update(decision.name);
|
|
4754
|
+
hash.update('\0');
|
|
4755
|
+
hash.update(decision.bytes);
|
|
4756
|
+
legacyHash.update('\0');
|
|
4757
|
+
legacyHash.update(decision.name);
|
|
4758
|
+
legacyHash.update('\0');
|
|
4759
|
+
legacyHash.update(decision.bytes);
|
|
4760
|
+
}
|
|
4761
|
+
return {
|
|
4762
|
+
sourceFingerprint: hash.digest('hex'),
|
|
4763
|
+
legacySourceFingerprint: legacyHash.digest('hex'),
|
|
4764
|
+
};
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
function migrationSourceSnapshot(flags = {}, { storedSource } = {}) {
|
|
4768
|
+
const paths = migrationPaths(flags, { storedSource });
|
|
4769
|
+
const content = migrationSourceContent(paths.sourceLog, paths.sourceDecisions);
|
|
4770
|
+
if (!content.sourceLogPresent && content.decisions.length === 0) {
|
|
4771
|
+
fail(`v1 source not found: ${paths.sourceLog} or ${paths.sourceDecisions}`);
|
|
4772
|
+
}
|
|
4773
|
+
const park = legacyParkFile();
|
|
4774
|
+
if (park && fs.existsSync(park)) {
|
|
4775
|
+
fail(
|
|
4776
|
+
'v1 migration requires no open intent; close the parked v1 intent first:\n' +
|
|
4777
|
+
' driftseal end --status abandoned --note "close parked v1 intent before migration"'
|
|
4778
|
+
);
|
|
4779
|
+
}
|
|
4780
|
+
const records = fold(
|
|
4781
|
+
parseJsonlRecords(content.rawLog, content.sourceLog, { allowLegacy: true })
|
|
4782
|
+
.map((record) => record.event)
|
|
4783
|
+
);
|
|
4784
|
+
if (records.some((record) => record.logVersion !== 1)) {
|
|
4785
|
+
fail('migration source is not a v1 intent log');
|
|
4786
|
+
}
|
|
4787
|
+
const open = records.filter((record) => record.status === 'in_progress');
|
|
4788
|
+
if (open.length > 0) {
|
|
4789
|
+
fail(`v1 migration requires every intent to be closed; still open: ${open.map((record) => record.id).join(', ')}`);
|
|
4790
|
+
}
|
|
4791
|
+
return {
|
|
4792
|
+
...paths,
|
|
4793
|
+
...content,
|
|
4794
|
+
records,
|
|
4795
|
+
...hashMigrationSourceContent(content),
|
|
4796
|
+
};
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
function migrationInspection(snapshot) {
|
|
4800
|
+
return {
|
|
4801
|
+
format: MIGRATION_PLAN_FORMAT,
|
|
4802
|
+
sourceFingerprint: snapshot.sourceFingerprint,
|
|
4803
|
+
source: {
|
|
4804
|
+
log: snapshot.sourceLog,
|
|
4805
|
+
decisions: snapshot.sourceDecisions,
|
|
4806
|
+
logPresent: snapshot.sourceLogPresent,
|
|
4807
|
+
},
|
|
4808
|
+
destination: snapshot.destination,
|
|
4809
|
+
records: snapshot.records.map(publicOutcome),
|
|
4810
|
+
decisions: snapshot.decisions.map((decision) => ({ name: decision.name })),
|
|
4811
|
+
planSchema: {
|
|
4812
|
+
format: MIGRATION_PLAN_FORMAT,
|
|
4813
|
+
sourceFingerprint: snapshot.sourceFingerprint,
|
|
4814
|
+
groups: [
|
|
4815
|
+
{
|
|
4816
|
+
outcome: 'One coherent delivered outcome',
|
|
4817
|
+
summary: 'What the grouped v1 work ultimately achieved',
|
|
4818
|
+
sourceIds: ['YYYY-MM-DD-NNN'],
|
|
4819
|
+
},
|
|
4820
|
+
],
|
|
4821
|
+
excluded: [
|
|
4822
|
+
{
|
|
4823
|
+
sourceId: 'YYYY-MM-DD-NNN',
|
|
4824
|
+
reason: 'Only already-reclaimed v1 noise may be excluded from visible outcomes',
|
|
4825
|
+
},
|
|
4826
|
+
],
|
|
4827
|
+
},
|
|
4828
|
+
};
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4831
|
+
function readMigrationPlan(file, inline) {
|
|
4832
|
+
if (!file && !inline) fail('migration apply requires --plan <file>');
|
|
4833
|
+
if (file && inline) fail('migration apply accepts only one of --plan or structured plan input');
|
|
4834
|
+
let plan;
|
|
4835
|
+
try {
|
|
4836
|
+
plan = JSON.parse(inline || fs.readFileSync(path.resolve(file), 'utf8'));
|
|
4837
|
+
} catch (error) {
|
|
4838
|
+
fail(`cannot read migration plan: ${error.message}`);
|
|
4839
|
+
}
|
|
4840
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) fail('migration plan must be a JSON object');
|
|
4841
|
+
return plan;
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
function migrationFingerprints(snapshot) {
|
|
4845
|
+
return [snapshot.sourceFingerprint, snapshot.legacySourceFingerprint];
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4848
|
+
function migrationPlanDigest(sourceFingerprint, groups, excluded) {
|
|
4849
|
+
return contentHash(JSON.stringify({
|
|
4850
|
+
format: MIGRATION_PLAN_FORMAT,
|
|
4851
|
+
sourceFingerprint,
|
|
4852
|
+
groups,
|
|
4853
|
+
excluded,
|
|
4854
|
+
}));
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
function existingMigrationMatchesPlan(existing, validated, snapshot) {
|
|
4858
|
+
if (!migrationFingerprints(snapshot).includes(existing.sourceFingerprint)) return false;
|
|
4859
|
+
if (existing.planDigest === validated.planDigest) return true;
|
|
4860
|
+
return existing.planDigest === migrationPlanDigest(
|
|
4861
|
+
snapshot.legacySourceFingerprint,
|
|
4862
|
+
validated.groups,
|
|
4863
|
+
validated.excluded
|
|
4864
|
+
);
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
function validateMigrationPlan(plan, snapshot) {
|
|
4868
|
+
if (plan.format !== MIGRATION_PLAN_FORMAT) fail(`migration plan format must be ${MIGRATION_PLAN_FORMAT}`);
|
|
4869
|
+
if (!migrationFingerprints(snapshot).includes(plan.sourceFingerprint)) {
|
|
4870
|
+
fail('migration plan source fingerprint does not match the current v1 source');
|
|
4871
|
+
}
|
|
4872
|
+
if (!Array.isArray(plan.groups)) fail('migration plan groups must be an array');
|
|
4873
|
+
if (!Array.isArray(plan.excluded)) fail('migration plan excluded must be an array');
|
|
4874
|
+
const byId = new Map(snapshot.records.map((record) => [record.id, record]));
|
|
4875
|
+
const excluded = new Map();
|
|
4876
|
+
for (const item of plan.excluded) {
|
|
4877
|
+
if (!item || typeof item.sourceId !== 'string' || typeof item.reason !== 'string' || !item.reason.trim()) {
|
|
4878
|
+
fail('each migration exclusion requires sourceId and a non-empty reason');
|
|
4879
|
+
}
|
|
4880
|
+
const record = byId.get(item.sourceId);
|
|
4881
|
+
if (!record) fail(`migration exclusion references unknown v1 intent: ${item.sourceId}`);
|
|
4882
|
+
if (!record.reclaimed) fail(`only reclaimed v1 intents may be excluded: ${item.sourceId}`);
|
|
4883
|
+
if (excluded.has(item.sourceId)) fail(`duplicate migration exclusion: ${item.sourceId}`);
|
|
4884
|
+
excluded.set(item.sourceId, item.reason.trim());
|
|
4885
|
+
}
|
|
4886
|
+
const expected = snapshot.records.filter((record) => !excluded.has(record.id)).map((record) => record.id);
|
|
4887
|
+
const actual = [];
|
|
4888
|
+
const groups = plan.groups.map((group, index) => {
|
|
4889
|
+
if (!group || typeof group.outcome !== 'string' || !group.outcome.trim() ||
|
|
4890
|
+
typeof group.summary !== 'string' || !group.summary.trim() ||
|
|
4891
|
+
!Array.isArray(group.sourceIds) || group.sourceIds.length === 0) {
|
|
4892
|
+
fail(`migration group ${index + 1} requires outcome, summary, and sourceIds`);
|
|
4893
|
+
}
|
|
4894
|
+
const sourceIds = group.sourceIds.map(String);
|
|
4895
|
+
for (const id of sourceIds) {
|
|
4896
|
+
if (!byId.has(id)) fail(`migration group ${index + 1} references unknown v1 intent: ${id}`);
|
|
4897
|
+
if (excluded.has(id)) fail(`migration source ${id} is both grouped and excluded`);
|
|
4898
|
+
actual.push(id);
|
|
4899
|
+
}
|
|
4900
|
+
return { outcome: group.outcome.trim(), summary: group.summary.trim(), sourceIds };
|
|
4901
|
+
});
|
|
4902
|
+
if (actual.length !== new Set(actual).size) fail('migration groups contain a duplicate v1 intent');
|
|
4903
|
+
if (!isDeepStrictEqual(actual, expected)) {
|
|
4904
|
+
fail('migration groups must form an ordered, complete partition of all non-excluded v1 intents');
|
|
4905
|
+
}
|
|
4906
|
+
const excludedItems = [...excluded].map(([sourceId, reason]) => ({ sourceId, reason }));
|
|
4907
|
+
return {
|
|
4908
|
+
groups,
|
|
4909
|
+
excluded: excludedItems,
|
|
4910
|
+
sourceFingerprint: snapshot.sourceFingerprint,
|
|
4911
|
+
planDigest: migrationPlanDigest(snapshot.sourceFingerprint, groups, excludedItems),
|
|
4912
|
+
};
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
function storedV2Event(event) {
|
|
4916
|
+
return { logVersion: LOG_VERSION, schemaVersion: EVENT_SCHEMA_VERSION, ...event };
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
function migrationImportEvent(snapshot, validated, group, events) {
|
|
4920
|
+
const byId = new Map(snapshot.records.map((record) => [record.id, record]));
|
|
4921
|
+
const sources = group.sourceIds.map((id) => byId.get(id));
|
|
4922
|
+
const first = sources[0];
|
|
4923
|
+
const last = sources.at(-1);
|
|
4924
|
+
const date = /^\d{4}-\d{2}-\d{2}/.test(first.tsBegin) ? first.tsBegin.slice(0, 10) : new Date().toISOString().slice(0, 10);
|
|
4925
|
+
const decisions = [...new Set(sources.flatMap((source) => source.decisions))];
|
|
4926
|
+
return storedV2Event({
|
|
4927
|
+
type: 'import',
|
|
4928
|
+
id: nextIdForDate(date, events),
|
|
4929
|
+
ts: new Date().toISOString(),
|
|
4930
|
+
outcome: group.outcome,
|
|
4931
|
+
summary: group.summary,
|
|
4932
|
+
status: last.status,
|
|
4933
|
+
beganAt: first.tsBegin,
|
|
4934
|
+
endedAt: last.tsEnd,
|
|
4935
|
+
decisions,
|
|
4936
|
+
sources: sources.map(publicOutcome),
|
|
4937
|
+
sourceFingerprint: validated.sourceFingerprint,
|
|
4938
|
+
reclaimed: sources.every((source) => source.reclaimed),
|
|
4939
|
+
reclaimReason: sources.every((source) => source.reclaimed)
|
|
4940
|
+
? sources.map((source) => source.reclaimReason).filter(Boolean).join('; ') || 'reclaimed in v1'
|
|
4941
|
+
: null,
|
|
4942
|
+
reclaimedAt: sources.every((source) => source.reclaimed) ? last.reclaimedAt : null,
|
|
4943
|
+
head: last.endHead || first.beginHead || null,
|
|
4944
|
+
});
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4947
|
+
function migrationMarkerEvent(snapshot, validated) {
|
|
4948
|
+
const byId = new Map(snapshot.records.map((record) => [record.id, record]));
|
|
4949
|
+
return storedV2Event({
|
|
4950
|
+
type: 'migration',
|
|
4951
|
+
id: 'v1-to-v2',
|
|
4952
|
+
ts: new Date().toISOString(),
|
|
4953
|
+
sourceFingerprint: validated.sourceFingerprint,
|
|
4954
|
+
planDigest: validated.planDigest,
|
|
4955
|
+
source: migrationSourceIdentity(snapshot),
|
|
4956
|
+
madrManifest: migrationMadrManifest(snapshot.decisions),
|
|
4957
|
+
excluded: validated.excluded.map((item) => ({
|
|
4958
|
+
...item,
|
|
4959
|
+
source: publicOutcome(byId.get(item.sourceId)),
|
|
4960
|
+
})),
|
|
4961
|
+
});
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
function migrationEvents(snapshot, validated) {
|
|
4965
|
+
const events = [];
|
|
4966
|
+
for (const group of validated.groups) {
|
|
4967
|
+
events.push(migrationImportEvent(snapshot, validated, group, events));
|
|
4968
|
+
}
|
|
4969
|
+
events.push(migrationMarkerEvent(snapshot, validated));
|
|
4970
|
+
fold(events);
|
|
4971
|
+
return events;
|
|
4972
|
+
}
|
|
4973
|
+
|
|
4974
|
+
function findMigrationEvent(file) {
|
|
4975
|
+
if (!fs.existsSync(file)) return null;
|
|
4976
|
+
let migration = null;
|
|
4977
|
+
for (const event of readEvents({ file, repairTail: false, readOnly: true })) {
|
|
4978
|
+
if (event.type === 'migration' && event.id === 'v1-to-v2') migration = event;
|
|
4979
|
+
}
|
|
4980
|
+
return migration;
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4983
|
+
function validateStagedMigration(directory, snapshot, { allowReconciled = false } = {}) {
|
|
4984
|
+
const stagedLog = path.join(directory, 'outcomes', 'events.jsonl');
|
|
4985
|
+
const events = readEvents({ file: stagedLog, readOnly: true });
|
|
4986
|
+
fold(events);
|
|
4987
|
+
if (allowReconciled) {
|
|
4988
|
+
validateMigrationMadrManifest(
|
|
4989
|
+
path.join(directory, 'madr'),
|
|
4990
|
+
migrationMadrManifest(snapshot.decisions),
|
|
4991
|
+
events
|
|
4992
|
+
);
|
|
4993
|
+
return;
|
|
4994
|
+
}
|
|
4995
|
+
for (const decision of snapshot.decisions) {
|
|
4996
|
+
const staged = path.join(directory, 'madr', decision.name);
|
|
4997
|
+
if (!fs.existsSync(staged) || !fs.readFileSync(staged).equals(decision.bytes)) {
|
|
4998
|
+
fail(`staged MADR does not match v1 byte-for-byte: ${decision.name}`);
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
5002
|
+
|
|
5003
|
+
function migrationRefreshPlan(destination, snapshot, validated, events) {
|
|
5004
|
+
const records = fold(events);
|
|
5005
|
+
const existingImports = records.filter((record) => record.imported);
|
|
5006
|
+
const sourceRecords = new Map(snapshot.records.map((record) => [record.id, record]));
|
|
5007
|
+
const importedBySource = new Map();
|
|
5008
|
+
const importedByGroup = new Map();
|
|
5009
|
+
for (const record of existingImports) {
|
|
5010
|
+
const key = JSON.stringify(record.imported.sourceIds);
|
|
5011
|
+
if (importedByGroup.has(key)) fail(`duplicate migrated v1 source group: ${record.imported.sourceIds.join(', ')}`);
|
|
5012
|
+
importedByGroup.set(key, record);
|
|
5013
|
+
for (const sourceId of record.imported.sourceIds) {
|
|
5014
|
+
if (importedBySource.has(sourceId)) fail(`v1 source was imported more than once: ${sourceId}`);
|
|
5015
|
+
importedBySource.set(sourceId, record);
|
|
5016
|
+
}
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
const newGroups = [];
|
|
5020
|
+
for (const group of validated.groups) {
|
|
5021
|
+
const key = JSON.stringify(group.sourceIds);
|
|
5022
|
+
const existing = importedByGroup.get(key);
|
|
5023
|
+
const overlap = group.sourceIds.filter((sourceId) => importedBySource.has(sourceId));
|
|
5024
|
+
if (!existing) {
|
|
5025
|
+
if (overlap.length > 0) {
|
|
5026
|
+
fail(`refreshed migration plan regroups already imported v1 sources: ${overlap.join(', ')}`);
|
|
5027
|
+
}
|
|
5028
|
+
newGroups.push(group);
|
|
5029
|
+
continue;
|
|
5030
|
+
}
|
|
5031
|
+
const sources = group.sourceIds.map((sourceId) => sourceRecords.get(sourceId));
|
|
5032
|
+
if (
|
|
5033
|
+
existing.outcome !== group.outcome ||
|
|
5034
|
+
existing.note !== group.summary ||
|
|
5035
|
+
!isDeepStrictEqual(existing.imported.sources, sources.map(publicOutcome))
|
|
5036
|
+
) {
|
|
5037
|
+
fail(`refreshed migration plan changes an already imported v1 source group: ${group.sourceIds.join(', ')}`);
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
for (const record of existingImports) {
|
|
5041
|
+
if (!validated.groups.some((group) => isDeepStrictEqual(group.sourceIds, record.imported.sourceIds))) {
|
|
5042
|
+
fail(`refreshed migration plan omits an already imported v1 source group: ${record.imported.sourceIds.join(', ')}`);
|
|
5043
|
+
}
|
|
5044
|
+
}
|
|
5045
|
+
|
|
5046
|
+
const latestReconciledHash = latestMigrationReconciledHashes(events);
|
|
5047
|
+
const missingDecisions = [];
|
|
5048
|
+
for (const decision of snapshot.decisions) {
|
|
5049
|
+
const file = path.join(destination, 'madr', decision.name);
|
|
5050
|
+
if (!fs.existsSync(file)) {
|
|
5051
|
+
missingDecisions.push(decision);
|
|
5052
|
+
continue;
|
|
5053
|
+
}
|
|
5054
|
+
const bytes = fs.readFileSync(file);
|
|
5055
|
+
if (bytes.equals(decision.bytes)) continue;
|
|
5056
|
+
const decisionId = manifestDecisionId(decision.name);
|
|
5057
|
+
const hash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
5058
|
+
if (!decisionId || latestReconciledHash.get(decisionId) !== hash) {
|
|
5059
|
+
fail(`migrated MADR conflicts with the current v1 source: ${decision.name}`);
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
return { newGroups, missingDecisions };
|
|
5063
|
+
}
|
|
5064
|
+
|
|
5065
|
+
function refreshMigration(destination, snapshot, validated, existing) {
|
|
5066
|
+
if (existing.source !== undefined && !migrationSourceMatchesSnapshot(existing.source, snapshot)) {
|
|
5067
|
+
fail('staged migration source identity does not match the current v1 source paths');
|
|
5068
|
+
}
|
|
5069
|
+
const destinationLog = path.join(destination, 'outcomes', 'events.jsonl');
|
|
5070
|
+
const events = readEvents({ file: destinationLog, readOnly: true });
|
|
5071
|
+
const refresh = migrationRefreshPlan(destination, snapshot, validated, events);
|
|
5072
|
+
const madrDirectory = path.join(destination, 'madr');
|
|
5073
|
+
ensureDirectoryDurable(madrDirectory);
|
|
5074
|
+
for (const decision of refresh.missingDecisions) {
|
|
5075
|
+
atomicWriteFile(path.join(madrDirectory, decision.name), decision.bytes);
|
|
5076
|
+
}
|
|
5077
|
+
for (const group of refresh.newGroups) {
|
|
5078
|
+
const event = migrationImportEvent(snapshot, validated, group, events);
|
|
5079
|
+
events.push(appendEventTo(destinationLog, event));
|
|
5080
|
+
}
|
|
5081
|
+
fold(events);
|
|
5082
|
+
appendEventTo(destinationLog, migrationMarkerEvent(snapshot, validated));
|
|
5083
|
+
validateStagedMigration(destination, snapshot, { allowReconciled: true });
|
|
5084
|
+
printLine(
|
|
5085
|
+
`refreshed staged v1-to-v2 migration with ${refresh.newGroups.length} additional outcome(s) ` +
|
|
5086
|
+
`and ${refresh.missingDecisions.length} MADR file(s)`
|
|
5087
|
+
);
|
|
5088
|
+
return {
|
|
5089
|
+
changed: true,
|
|
5090
|
+
refreshed: true,
|
|
5091
|
+
destination,
|
|
5092
|
+
sourceFingerprint: snapshot.sourceFingerprint,
|
|
5093
|
+
planDigest: validated.planDigest,
|
|
5094
|
+
importedOutcomes: refresh.newGroups.length,
|
|
5095
|
+
copiedDecisions: refresh.missingDecisions.length,
|
|
5096
|
+
};
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
function applyMigration(snapshot, validated) {
|
|
5100
|
+
const destination = snapshot.destination;
|
|
5101
|
+
const destinationLog = path.join(destination, 'outcomes', 'events.jsonl');
|
|
5102
|
+
if (fs.existsSync(destination)) {
|
|
5103
|
+
const existing = findMigrationEvent(destinationLog);
|
|
5104
|
+
if (existing && existingMigrationMatchesPlan(existing, validated, snapshot)) {
|
|
5105
|
+
validateStagedMigration(destination, snapshot, { allowReconciled: true });
|
|
5106
|
+
const manifest = migrationMadrManifest(snapshot.decisions);
|
|
5107
|
+
const source = migrationSourceIdentity(snapshot);
|
|
5108
|
+
if (existing.source !== undefined && !migrationSourceMatchesSnapshot(existing.source, snapshot)) {
|
|
5109
|
+
fail('staged migration source identity does not match the current v1 source paths');
|
|
5110
|
+
}
|
|
5111
|
+
if (
|
|
5112
|
+
existing.madrManifest === undefined ||
|
|
5113
|
+
existing.source === undefined ||
|
|
5114
|
+
!isDeepStrictEqual(existing.source, source) ||
|
|
5115
|
+
existing.sourceFingerprint !== snapshot.sourceFingerprint ||
|
|
5116
|
+
existing.planDigest !== validated.planDigest
|
|
5117
|
+
) {
|
|
5118
|
+
appendEventTo(destinationLog, {
|
|
5119
|
+
type: 'migration',
|
|
5120
|
+
id: 'v1-to-v2',
|
|
5121
|
+
ts: new Date().toISOString(),
|
|
5122
|
+
sourceFingerprint: snapshot.sourceFingerprint,
|
|
5123
|
+
planDigest: validated.planDigest,
|
|
5124
|
+
source,
|
|
5125
|
+
madrManifest: manifest,
|
|
5126
|
+
excluded: existing.excluded,
|
|
5127
|
+
});
|
|
5128
|
+
printLine('upgraded the staged v1-to-v2 migration with source identity and a MADR integrity manifest');
|
|
5129
|
+
return {
|
|
5130
|
+
changed: true,
|
|
5131
|
+
upgraded: true,
|
|
5132
|
+
destination,
|
|
5133
|
+
sourceFingerprint: snapshot.sourceFingerprint,
|
|
5134
|
+
planDigest: validated.planDigest,
|
|
5135
|
+
};
|
|
5136
|
+
}
|
|
5137
|
+
if (!isDeepStrictEqual(existing.madrManifest, manifest)) {
|
|
5138
|
+
fail('staged migration MADR manifest does not match the current v1 source');
|
|
5139
|
+
}
|
|
5140
|
+
printLine('v1-to-v2 migration is already staged with the same source and plan');
|
|
5141
|
+
return { changed: false, destination, sourceFingerprint: snapshot.sourceFingerprint, planDigest: validated.planDigest };
|
|
5142
|
+
}
|
|
5143
|
+
if (existing) return refreshMigration(destination, snapshot, validated, existing);
|
|
5144
|
+
fail(`migration destination already exists with different content: ${destination}`);
|
|
5145
|
+
}
|
|
5146
|
+
ensureDirectoryDurable(path.dirname(destination));
|
|
5147
|
+
const temporary = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.migrate-`));
|
|
5148
|
+
try {
|
|
5149
|
+
const outcomeDirectory = path.join(temporary, 'outcomes');
|
|
5150
|
+
const madrDirectory = path.join(temporary, 'madr');
|
|
5151
|
+
ensureDirectoryDurable(outcomeDirectory);
|
|
5152
|
+
ensureDirectoryDurable(madrDirectory);
|
|
5153
|
+
writeJsonl(path.join(outcomeDirectory, 'events.jsonl'), migrationEvents(snapshot, validated).map((event) => ({ event })));
|
|
5154
|
+
for (const decision of snapshot.decisions) {
|
|
5155
|
+
fs.copyFileSync(decision.file, path.join(madrDirectory, decision.name));
|
|
5156
|
+
}
|
|
5157
|
+
validateStagedMigration(temporary, snapshot);
|
|
5158
|
+
fs.renameSync(temporary, destination);
|
|
5159
|
+
fsyncDirectory(path.dirname(destination));
|
|
5160
|
+
} catch (error) {
|
|
5161
|
+
try { fs.rmSync(temporary, { recursive: true, force: true }); } catch {}
|
|
5162
|
+
throw error;
|
|
5163
|
+
}
|
|
5164
|
+
const initResult = commands.init([]);
|
|
5165
|
+
printLine(`staged v1-to-v2 migration at ${destination}`);
|
|
5166
|
+
printLine('DriftSeal did not delete v1 data; review the staged outcome log, then remove the old paths manually.');
|
|
5167
|
+
return {
|
|
5168
|
+
changed: true,
|
|
5169
|
+
destination,
|
|
5170
|
+
sourceFingerprint: validated.sourceFingerprint,
|
|
5171
|
+
planDigest: validated.planDigest,
|
|
5172
|
+
importedOutcomes: validated.groups.length,
|
|
5173
|
+
excluded: validated.excluded.length,
|
|
5174
|
+
init: initResult,
|
|
5175
|
+
};
|
|
5176
|
+
}
|
|
5177
|
+
|
|
5178
|
+
function gitTracksPath(target) {
|
|
5179
|
+
if (!isGitWorkTree()) return false;
|
|
5180
|
+
const root = gitWorktreeRoot();
|
|
5181
|
+
if (!root) return false;
|
|
5182
|
+
const resolved = canonicalPath(target);
|
|
5183
|
+
if (canonicalPath(root) !== resolved && !pathContains(root, resolved)) return false;
|
|
5184
|
+
const relative = path.relative(root, resolved).split(path.sep).join('/');
|
|
5185
|
+
const listing = gitCaptureRaw(
|
|
5186
|
+
['ls-files', '-z', '--', `:(literal)${relative}`, `:(literal)${relative}/`],
|
|
5187
|
+
root
|
|
5188
|
+
);
|
|
5189
|
+
if (!listing) return false;
|
|
5190
|
+
return listing.split('\0').some((file) => file.length > 0);
|
|
5191
|
+
}
|
|
5192
|
+
|
|
5193
|
+
function displayV1RemovalPath(target) {
|
|
5194
|
+
const cwd = canonicalPath(process.cwd());
|
|
5195
|
+
const resolved = canonicalPath(target);
|
|
5196
|
+
const relative = path.relative(cwd, resolved).split(path.sep).join('/');
|
|
5197
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative;
|
|
5198
|
+
return resolved;
|
|
5199
|
+
}
|
|
5200
|
+
|
|
5201
|
+
function v1RemovalHint(snapshot) {
|
|
5202
|
+
const items = [];
|
|
5203
|
+
if (snapshot.sourceLogPresent && fs.existsSync(path.dirname(snapshot.sourceLog))) {
|
|
5204
|
+
items.push(path.dirname(snapshot.sourceLog));
|
|
5205
|
+
}
|
|
5206
|
+
if (fs.existsSync(snapshot.sourceDecisions)) items.push(snapshot.sourceDecisions);
|
|
5207
|
+
if (items.length === 0) return null;
|
|
5208
|
+
const tracked = items.every((item) => gitTracksPath(item));
|
|
5209
|
+
const shown = items.map((item) => {
|
|
5210
|
+
const relative = displayV1RemovalPath(item);
|
|
5211
|
+
return /[\s"'$]/.test(relative) ? JSON.stringify(relative) : relative;
|
|
5212
|
+
});
|
|
5213
|
+
const command = tracked && isGitWorkTree()
|
|
5214
|
+
? `git rm -r -- ${shown.join(' ')}`
|
|
5215
|
+
: `rm -rf -- ${shown.join(' ')}`;
|
|
5216
|
+
return `after explicit user approval, remove the v1 source paths manually: ${command}`;
|
|
5217
|
+
}
|
|
5218
|
+
|
|
5219
|
+
function checkMigration(snapshot, { sourceMissing = false } = {}) {
|
|
5220
|
+
const destinationLog = path.join(snapshot.destination, 'outcomes', 'events.jsonl');
|
|
5221
|
+
const migration = findMigrationEvent(destinationLog);
|
|
5222
|
+
if (!migration) fail(`no staged v1-to-v2 migration found at ${snapshot.destination}`);
|
|
5223
|
+
const destinationEvents = readEvents({ file: destinationLog, readOnly: true });
|
|
5224
|
+
fold(destinationEvents);
|
|
5225
|
+
const migratedMadr = path.join(snapshot.destination, 'madr');
|
|
5226
|
+
if (sourceMissing) {
|
|
5227
|
+
if (migration.source === undefined) {
|
|
5228
|
+
fail(
|
|
5229
|
+
'staged migration has no source path identity; restore the v1 source and re-run apply with the approved plan before deleting it'
|
|
5230
|
+
);
|
|
5231
|
+
}
|
|
5232
|
+
if (migration.madrManifest === undefined) {
|
|
5233
|
+
fail(
|
|
5234
|
+
'staged migration has no MADR integrity manifest; restore the v1 source and re-run apply with the approved plan before deleting it'
|
|
5235
|
+
);
|
|
5236
|
+
}
|
|
5237
|
+
validateMigrationMadrManifest(migratedMadr, migration.madrManifest, destinationEvents);
|
|
5238
|
+
} else {
|
|
5239
|
+
const expectedManifest = migrationMadrManifest(snapshot.decisions);
|
|
5240
|
+
validateMigrationMadrManifest(migratedMadr, expectedManifest, destinationEvents);
|
|
5241
|
+
if (migration.madrManifest !== undefined) {
|
|
5242
|
+
if (!isDeepStrictEqual(migration.madrManifest, expectedManifest)) {
|
|
5243
|
+
fail('staged migration MADR manifest does not match the current v1 source');
|
|
5244
|
+
}
|
|
5245
|
+
validateMigrationMadrManifest(migratedMadr, migration.madrManifest, destinationEvents);
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
if (migration.source !== undefined && !migrationSourceMatchesSnapshot(migration.source, snapshot)) {
|
|
5249
|
+
fail('staged migration source identity does not match the v1 source paths used for check');
|
|
5250
|
+
}
|
|
5251
|
+
if (
|
|
5252
|
+
!sourceMissing &&
|
|
5253
|
+
![snapshot.sourceFingerprint, snapshot.legacySourceFingerprint].includes(migration.sourceFingerprint)
|
|
5254
|
+
) {
|
|
5255
|
+
fail('staged migration no longer matches the v1 source fingerprint');
|
|
5256
|
+
}
|
|
5257
|
+
if (sourceMissing) {
|
|
5258
|
+
printLine('v1-to-v2 migration complete; v1 source paths are absent and the v2 log is valid');
|
|
5259
|
+
} else {
|
|
5260
|
+
printLine('v1-to-v2 migration is valid and staged side-by-side with v1');
|
|
5261
|
+
const hint = v1RemovalHint(snapshot);
|
|
5262
|
+
if (hint) printLine(hint);
|
|
5263
|
+
}
|
|
5264
|
+
return {
|
|
5265
|
+
valid: true,
|
|
5266
|
+
complete: sourceMissing,
|
|
5267
|
+
destination: snapshot.destination,
|
|
5268
|
+
sourceFingerprint: migration.sourceFingerprint,
|
|
5269
|
+
planDigest: migration.planDigest,
|
|
5270
|
+
};
|
|
5271
|
+
}
|
|
5272
|
+
|
|
4172
5273
|
const commands = {
|
|
4173
5274
|
begin(argv) {
|
|
4174
5275
|
const { positionals, flags } = parseArgs(argv, {
|
|
@@ -4177,8 +5278,8 @@ const commands = {
|
|
|
4177
5278
|
decision: 'multiple',
|
|
4178
5279
|
force: 'boolean',
|
|
4179
5280
|
}, 'begin');
|
|
4180
|
-
const
|
|
4181
|
-
if (!
|
|
5281
|
+
const outcome = positionals.join(' ').trim();
|
|
5282
|
+
if (!outcome) {
|
|
4182
5283
|
fail(usageFor('begin'));
|
|
4183
5284
|
}
|
|
4184
5285
|
const acceptance = [...new Set((flags.accept || []).map((criterion) => criterion.trim()))];
|
|
@@ -4196,18 +5297,18 @@ const commands = {
|
|
|
4196
5297
|
|
|
4197
5298
|
const events = readEvents({ repairTail: true });
|
|
4198
5299
|
const records = fold(events);
|
|
4199
|
-
// A parked
|
|
5300
|
+
// A parked outcome and a merged-in one can both be open; --force clears every one of them.
|
|
4200
5301
|
const open = records.filter((record) => record.status === 'in_progress');
|
|
4201
5302
|
if (open.length > 1 && !flags.force) {
|
|
4202
5303
|
fail(
|
|
4203
|
-
|
|
5304
|
+
`multiple outcomes in progress: ${open.map((record) => record.id).join(', ')}\n` +
|
|
4204
5305
|
'resolve them with driftseal absorb --abandon-ours or --abandon-theirs, ' +
|
|
4205
5306
|
'or re-run with --force to abandon all of them'
|
|
4206
5307
|
);
|
|
4207
5308
|
}
|
|
4208
5309
|
if (open.length === 1 && !flags.force) {
|
|
4209
5310
|
fail(
|
|
4210
|
-
`
|
|
5311
|
+
`outcome ${open[0].id} is still in_progress: "${open[0].outcome}"\n` +
|
|
4211
5312
|
`end it first (driftseal end) or re-run with --force to abandon it`
|
|
4212
5313
|
);
|
|
4213
5314
|
}
|
|
@@ -4227,7 +5328,7 @@ const commands = {
|
|
|
4227
5328
|
type: 'begin',
|
|
4228
5329
|
id,
|
|
4229
5330
|
ts: new Date().toISOString(),
|
|
4230
|
-
|
|
5331
|
+
outcome,
|
|
4231
5332
|
acceptance,
|
|
4232
5333
|
verify: flags.verify || null,
|
|
4233
5334
|
decisions,
|
|
@@ -4235,7 +5336,44 @@ const commands = {
|
|
|
4235
5336
|
}));
|
|
4236
5337
|
const record = fold(events).find((candidate) => candidate.id === id);
|
|
4237
5338
|
printLine(id);
|
|
4238
|
-
return
|
|
5339
|
+
return publicOutcome(record);
|
|
5340
|
+
},
|
|
5341
|
+
|
|
5342
|
+
extend(argv) {
|
|
5343
|
+
const { positionals, flags } = parseArgs(argv, {
|
|
5344
|
+
accept: 'multiple',
|
|
5345
|
+
verify: '-v',
|
|
5346
|
+
decision: 'multiple',
|
|
5347
|
+
}, 'extend');
|
|
5348
|
+
const extension = positionals.join(' ').trim();
|
|
5349
|
+
if (!extension) fail(usageFor('extend'));
|
|
5350
|
+
const acceptance = [...new Set((flags.accept || []).map((criterion) => criterion.trim()))];
|
|
5351
|
+
if (acceptance.some((criterion) => criterion.length === 0)) {
|
|
5352
|
+
fail('--accept requires a non-empty observable result');
|
|
5353
|
+
}
|
|
5354
|
+
if (acceptance.length > 0 && (!flags.verify || flags.verify.trim().length === 0)) {
|
|
5355
|
+
fail('extending acceptance requires --verify with a cumulative verification command');
|
|
5356
|
+
}
|
|
5357
|
+
const events = readEvents({ repairTail: true });
|
|
5358
|
+
const current = openOutcome(fold(events));
|
|
5359
|
+
if (!current) fail('no outcome in progress; begin one before extending it');
|
|
5360
|
+
const requestedDecisions = flags.decision || [];
|
|
5361
|
+
const index = requestedDecisions.length > 0 ? decisionIndex() : [];
|
|
5362
|
+
const decisions = [...new Set(requestedDecisions.map((id) => findDecision(id, index).id))]
|
|
5363
|
+
.filter((id) => !current.decisions.includes(id));
|
|
5364
|
+
events.push(appendEvent({
|
|
5365
|
+
type: 'extend',
|
|
5366
|
+
id: current.id,
|
|
5367
|
+
ts: new Date().toISOString(),
|
|
5368
|
+
extension,
|
|
5369
|
+
acceptance: acceptance.filter((criterion) => !current.acceptance.includes(criterion)),
|
|
5370
|
+
verify: flags.verify || null,
|
|
5371
|
+
decisions,
|
|
5372
|
+
head: gitCapture(['rev-parse', 'HEAD']),
|
|
5373
|
+
}));
|
|
5374
|
+
const record = fold(events).find((candidate) => candidate.id === current.id);
|
|
5375
|
+
printLine(`${current.id} extended`);
|
|
5376
|
+
return publicOutcome(record);
|
|
4239
5377
|
},
|
|
4240
5378
|
|
|
4241
5379
|
verify(argv) {
|
|
@@ -4260,16 +5398,26 @@ const commands = {
|
|
|
4260
5398
|
fail(`invalid status "${status}" (expected: ${END_STATUSES.join(', ')})`);
|
|
4261
5399
|
}
|
|
4262
5400
|
|
|
5401
|
+
const parkedV1 = legacyParkedIntent();
|
|
5402
|
+
if (parkedV1) {
|
|
5403
|
+
if (positionals.length > 0 && positionals[0] !== parkedV1.id) {
|
|
5404
|
+
fail(`unknown outcome id: ${positionals[0]}`);
|
|
5405
|
+
}
|
|
5406
|
+
const closed = closeLegacyParkedIntent(status, flags.note);
|
|
5407
|
+
printLine(`${closed.id} ${status}`);
|
|
5408
|
+
return publicOutcome(closed);
|
|
5409
|
+
}
|
|
5410
|
+
|
|
4263
5411
|
let events = readEvents({ repairTail: true });
|
|
4264
5412
|
let records = fold(events);
|
|
4265
5413
|
let target;
|
|
4266
5414
|
if (positionals.length > 0) {
|
|
4267
5415
|
target = records.find((r) => r.id === positionals[0]);
|
|
4268
|
-
if (!target) fail(`unknown
|
|
4269
|
-
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})`);
|
|
4270
5418
|
} else {
|
|
4271
|
-
target =
|
|
4272
|
-
if (!target) fail('no
|
|
5419
|
+
target = openOutcome(records);
|
|
5420
|
+
if (!target) fail('no outcome in progress; nothing to end');
|
|
4273
5421
|
}
|
|
4274
5422
|
|
|
4275
5423
|
let completionWorkspace = null;
|
|
@@ -4277,14 +5425,15 @@ const commands = {
|
|
|
4277
5425
|
if (status === 'completed' && target.acceptance.length > 0) {
|
|
4278
5426
|
if (!target.verification || !target.verification.passed) {
|
|
4279
5427
|
fail(
|
|
4280
|
-
`cannot complete acceptance-bound
|
|
5428
|
+
`cannot complete acceptance-bound outcome ${target.id} without successful machine verification; ` +
|
|
4281
5429
|
'run: driftseal verify'
|
|
4282
5430
|
);
|
|
4283
5431
|
}
|
|
4284
5432
|
completionWorkspace = workspaceFingerprint();
|
|
4285
|
-
if (completionWorkspace !== target.verification.workspace
|
|
5433
|
+
if (completionWorkspace !== target.verification.workspace ||
|
|
5434
|
+
target.verification.contractHash !== target.contractHash) {
|
|
4286
5435
|
fail(
|
|
4287
|
-
`cannot complete acceptance-bound
|
|
5436
|
+
`cannot complete acceptance-bound outcome ${target.id}: contract or workspace changed after machine verification; ` +
|
|
4288
5437
|
'run: driftseal verify'
|
|
4289
5438
|
);
|
|
4290
5439
|
}
|
|
@@ -4301,7 +5450,7 @@ const commands = {
|
|
|
4301
5450
|
);
|
|
4302
5451
|
const record = fold(events).find((candidate) => candidate.id === target.id);
|
|
4303
5452
|
printLine(`${target.id} ${terminalStatus}`);
|
|
4304
|
-
return
|
|
5453
|
+
return publicOutcome(record);
|
|
4305
5454
|
}
|
|
4306
5455
|
|
|
4307
5456
|
if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
|
|
@@ -4331,7 +5480,7 @@ const commands = {
|
|
|
4331
5480
|
}
|
|
4332
5481
|
if (problems.length > 0) {
|
|
4333
5482
|
fail(
|
|
4334
|
-
`cannot close linked
|
|
5483
|
+
`cannot close linked outcome ${target.id} as ${status}:\n` +
|
|
4335
5484
|
problems.map((problem) => ` - ${problem}`).join('\n') +
|
|
4336
5485
|
`\nrun: driftseal decision update <id> --note "<what changed or was confirmed>"`
|
|
4337
5486
|
);
|
|
@@ -4347,29 +5496,40 @@ const commands = {
|
|
|
4347
5496
|
verifyResult: flags['verify-result'] || null,
|
|
4348
5497
|
verificationId: completionVerificationId,
|
|
4349
5498
|
workspace: completionWorkspace,
|
|
5499
|
+
contractHash: target.contractHash,
|
|
4350
5500
|
head: gitCapture(['rev-parse', 'HEAD']),
|
|
4351
5501
|
}));
|
|
4352
5502
|
const record = fold(events).find((candidate) => candidate.id === target.id);
|
|
4353
5503
|
printLine(`${target.id} ${status}`);
|
|
4354
|
-
return
|
|
5504
|
+
return publicOutcome(record);
|
|
4355
5505
|
},
|
|
4356
5506
|
|
|
4357
5507
|
status(argv, { readOnly = false } = {}) {
|
|
4358
5508
|
const { positionals } = parseArgs(argv, {}, 'status');
|
|
4359
5509
|
if (positionals.length > 0) fail(usageFor('status'));
|
|
4360
|
-
const
|
|
5510
|
+
const parkedV1 = legacyParkedIntent();
|
|
5511
|
+
if (parkedV1) {
|
|
5512
|
+
printLine(render(parkedV1));
|
|
5513
|
+
printLine('parked v1 intent; close it with: driftseal end --status abandoned --note "close parked v1 intent before migration"');
|
|
5514
|
+
return publicOutcome(parkedV1);
|
|
5515
|
+
}
|
|
5516
|
+
const open = openOutcome(fold(readEvents({ repairTail: true, readOnly })));
|
|
4361
5517
|
if (!open) {
|
|
4362
|
-
printLine('no
|
|
5518
|
+
printLine('no outcome in progress');
|
|
4363
5519
|
return null;
|
|
4364
5520
|
}
|
|
4365
5521
|
printLine(render(open));
|
|
4366
|
-
return
|
|
5522
|
+
return publicOutcome(open);
|
|
4367
5523
|
},
|
|
4368
5524
|
|
|
4369
5525
|
log(argv, { readOnly = false } = {}) {
|
|
4370
5526
|
const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' }, 'log');
|
|
4371
5527
|
if (positionals.length > 0) fail(usageFor('log'));
|
|
4372
5528
|
let records = fold(readEvents({ repairTail: true, readOnly }));
|
|
5529
|
+
const parkedV1 = legacyParkedIntent();
|
|
5530
|
+
if (parkedV1 && !records.some((record) => record.id === parkedV1.id && record.status === 'in_progress')) {
|
|
5531
|
+
records = [...records, parkedV1];
|
|
5532
|
+
}
|
|
4373
5533
|
if (!flags.all) records = records.filter((record) => !record.reclaimed);
|
|
4374
5534
|
if (flags.last) {
|
|
4375
5535
|
const n = positiveInteger(flags.last, '--last');
|
|
@@ -4380,7 +5540,7 @@ const commands = {
|
|
|
4380
5540
|
return [];
|
|
4381
5541
|
}
|
|
4382
5542
|
printLine(records.map(render).join('\n\n'));
|
|
4383
|
-
return records.map(
|
|
5543
|
+
return records.map(publicOutcome);
|
|
4384
5544
|
},
|
|
4385
5545
|
|
|
4386
5546
|
reclaim(argv) {
|
|
@@ -4405,16 +5565,16 @@ const commands = {
|
|
|
4405
5565
|
const ids = [...new Set(positionals)];
|
|
4406
5566
|
targets = ids.map((id) => {
|
|
4407
5567
|
const record = records.find((candidate) => candidate.id === id);
|
|
4408
|
-
if (!record) fail(`unknown
|
|
5568
|
+
if (!record) fail(`unknown outcome id: ${id}`);
|
|
4409
5569
|
if (record.status === 'in_progress') {
|
|
4410
|
-
fail(`cannot reclaim
|
|
5570
|
+
fail(`cannot reclaim outcome ${id} while it is in_progress`);
|
|
4411
5571
|
}
|
|
4412
|
-
if (record.reclaimed) fail(`
|
|
5572
|
+
if (record.reclaimed) fail(`outcome ${id} is already reclaimed`);
|
|
4413
5573
|
const routine = ['failed', 'abandoned'].includes(record.status) &&
|
|
4414
5574
|
record.decisions.length === 0;
|
|
4415
5575
|
if (!routine && !flags.force) {
|
|
4416
5576
|
fail(
|
|
4417
|
-
`
|
|
5577
|
+
`outcome ${id} is ${record.status}` +
|
|
4418
5578
|
(record.decisions.length > 0 ? ' and linked to decisions' : '') +
|
|
4419
5579
|
'; re-run with --force to reclaim it anyway'
|
|
4420
5580
|
);
|
|
@@ -4422,7 +5582,7 @@ const commands = {
|
|
|
4422
5582
|
return record;
|
|
4423
5583
|
});
|
|
4424
5584
|
} else {
|
|
4425
|
-
if (flags.force) fail('--force requires explicit
|
|
5585
|
+
if (flags.force) fail('--force requires explicit outcome ids');
|
|
4426
5586
|
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
4427
5587
|
targets = records.filter(
|
|
4428
5588
|
(record) =>
|
|
@@ -4433,14 +5593,14 @@ const commands = {
|
|
|
4433
5593
|
Date.parse(record.tsEnd) < cutoff
|
|
4434
5594
|
);
|
|
4435
5595
|
if (targets.length === 0) {
|
|
4436
|
-
printLine('no reclaimable
|
|
5596
|
+
printLine('no reclaimable outcomes');
|
|
4437
5597
|
return [];
|
|
4438
5598
|
}
|
|
4439
5599
|
}
|
|
4440
5600
|
|
|
4441
5601
|
if (flags['dry-run']) {
|
|
4442
|
-
printLine(targets.map((record) => `${record.id} ${record.status} — ${record.
|
|
4443
|
-
return targets.map(
|
|
5602
|
+
printLine(targets.map((record) => `${record.id} ${record.status} — ${record.outcome}`).join('\n'));
|
|
5603
|
+
return targets.map(publicOutcome);
|
|
4444
5604
|
}
|
|
4445
5605
|
|
|
4446
5606
|
let events = readEvents({ repairTail: true });
|
|
@@ -4458,7 +5618,7 @@ const commands = {
|
|
|
4458
5618
|
targets.some((target) => target.id === record.id)
|
|
4459
5619
|
);
|
|
4460
5620
|
printLine(targets.map((record) => `${record.id} reclaimed`).join('\n'));
|
|
4461
|
-
return reclaimed.map(
|
|
5621
|
+
return reclaimed.map(publicOutcome);
|
|
4462
5622
|
},
|
|
4463
5623
|
|
|
4464
5624
|
unreclaim(argv) {
|
|
@@ -4469,8 +5629,8 @@ const commands = {
|
|
|
4469
5629
|
}
|
|
4470
5630
|
const events = readEvents({ repairTail: true });
|
|
4471
5631
|
const record = fold(events).find((candidate) => candidate.id === positionals[0]);
|
|
4472
|
-
if (!record) fail(`unknown
|
|
4473
|
-
if (!record.reclaimed) fail(`
|
|
5632
|
+
if (!record) fail(`unknown outcome id: ${positionals[0]}`);
|
|
5633
|
+
if (!record.reclaimed) fail(`outcome ${positionals[0]} is not reclaimed`);
|
|
4474
5634
|
events.push(
|
|
4475
5635
|
appendEvent({
|
|
4476
5636
|
type: 'unreclaim',
|
|
@@ -4481,7 +5641,7 @@ const commands = {
|
|
|
4481
5641
|
);
|
|
4482
5642
|
const restored = fold(events).find((candidate) => candidate.id === record.id);
|
|
4483
5643
|
printLine(`${record.id} unreclaimed`);
|
|
4484
|
-
return
|
|
5644
|
+
return publicOutcome(restored);
|
|
4485
5645
|
},
|
|
4486
5646
|
|
|
4487
5647
|
decision(argv) {
|
|
@@ -4521,6 +5681,7 @@ const commands = {
|
|
|
4521
5681
|
options: flags.option || [],
|
|
4522
5682
|
consequences: flags.consequence || [],
|
|
4523
5683
|
});
|
|
5684
|
+
ensureV2OutcomeLogExists();
|
|
4524
5685
|
ensureDirectoryDurable(decisionDir());
|
|
4525
5686
|
atomicCreateFile(path.join(decisionDir(), file), content);
|
|
4526
5687
|
const decision = findDecision(String(id));
|
|
@@ -4537,22 +5698,22 @@ const commands = {
|
|
|
4537
5698
|
|
|
4538
5699
|
let events = readEvents({ repairTail: true });
|
|
4539
5700
|
let records = fold(events);
|
|
4540
|
-
let
|
|
4541
|
-
if (!
|
|
4542
|
-
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);
|
|
4543
5704
|
records = fold(events);
|
|
4544
|
-
|
|
5705
|
+
outcome = openOutcome(records);
|
|
4545
5706
|
const index = decisionIndex();
|
|
4546
5707
|
const decision = findDecision(positionals[0], index);
|
|
4547
|
-
if (!
|
|
4548
|
-
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}`);
|
|
4549
5710
|
}
|
|
4550
5711
|
|
|
4551
5712
|
const status = (flags.status || decision.status).toLowerCase();
|
|
4552
5713
|
if (!DECISION_STATUSES.includes(status)) {
|
|
4553
5714
|
fail(`invalid decision status "${status}" (expected: ${DECISION_STATUSES.join(', ')})`);
|
|
4554
5715
|
}
|
|
4555
|
-
const update = prepareDecisionReconciliation(decision,
|
|
5716
|
+
const update = prepareDecisionReconciliation(decision, outcome.id, status, note);
|
|
4556
5717
|
const { target, content, ...prepareEvent } = update;
|
|
4557
5718
|
appendEvent(prepareEvent);
|
|
4558
5719
|
if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_RECONCILIATION_PREPARE === '1') {
|
|
@@ -4564,7 +5725,7 @@ const commands = {
|
|
|
4564
5725
|
}
|
|
4565
5726
|
appendEvent(reconciliationEvent('decision_reconcile_commit', update));
|
|
4566
5727
|
const reconciled = findDecision(decision.id);
|
|
4567
|
-
printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${
|
|
5728
|
+
printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${outcome.id})`);
|
|
4568
5729
|
return publicDecision(reconciled, { includeContent: true });
|
|
4569
5730
|
}
|
|
4570
5731
|
|
|
@@ -4662,6 +5823,60 @@ const commands = {
|
|
|
4662
5823
|
return absorbLogs(positionals[0], flags.decisions, { abandon, dryRun });
|
|
4663
5824
|
},
|
|
4664
5825
|
|
|
5826
|
+
migrate(argv) {
|
|
5827
|
+
const [route, action, ...rest] = argv;
|
|
5828
|
+
if (route === '--help' || route === '-h' ||
|
|
5829
|
+
(route === 'v1-to-v2' && (action === '--help' || action === '-h'))) {
|
|
5830
|
+
throw new HelpRequested('migrate');
|
|
5831
|
+
}
|
|
5832
|
+
if (route !== 'v1-to-v2' || !['inspect', 'apply', 'check'].includes(action)) {
|
|
5833
|
+
fail(usageFor('migrate'));
|
|
5834
|
+
}
|
|
5835
|
+
const { positionals, flags } = parseArgs(rest, {
|
|
5836
|
+
'source-log': 'single',
|
|
5837
|
+
'source-decisions': 'single',
|
|
5838
|
+
destination: 'single',
|
|
5839
|
+
plan: 'single',
|
|
5840
|
+
'plan-json': 'single',
|
|
5841
|
+
json: 'boolean',
|
|
5842
|
+
}, 'migrate');
|
|
5843
|
+
if (positionals.length > 0) fail(usageFor('migrate'));
|
|
5844
|
+
if (action === 'inspect') {
|
|
5845
|
+
if (flags.plan || flags['plan-json']) fail('--plan is only valid with migration apply');
|
|
5846
|
+
const inspection = migrationInspection(migrationSourceSnapshot(flags));
|
|
5847
|
+
if (flags.json) printLine(JSON.stringify(inspection, null, 2));
|
|
5848
|
+
else {
|
|
5849
|
+
printLine(`v1 source ${inspection.sourceFingerprint}: ${inspection.records.length} closed intent(s), ${inspection.decisions.length} MADR file(s)`);
|
|
5850
|
+
printLine('Generate a driftseal-v1-to-v2-plan JSON document, then run migrate v1-to-v2 apply --plan <file>.');
|
|
5851
|
+
}
|
|
5852
|
+
return inspection;
|
|
5853
|
+
}
|
|
5854
|
+
if (action === 'apply') {
|
|
5855
|
+
if (flags.json) fail('--json is only valid with migration inspect or check');
|
|
5856
|
+
const destination = canonicalPath(flags.destination || sealRoot());
|
|
5857
|
+
const existing = findMigrationEvent(path.join(destination, 'outcomes', 'events.jsonl'));
|
|
5858
|
+
const snapshot = migrationSourceSnapshot(flags, { storedSource: existing?.source });
|
|
5859
|
+
const validated = validateMigrationPlan(readMigrationPlan(flags.plan, flags['plan-json']), snapshot);
|
|
5860
|
+
return applyMigration(snapshot, validated);
|
|
5861
|
+
}
|
|
5862
|
+
if (flags.plan || flags['plan-json']) fail('--plan is only valid with migration apply');
|
|
5863
|
+
const destination = canonicalPath(flags.destination || sealRoot());
|
|
5864
|
+
const existing = findMigrationEvent(path.join(destination, 'outcomes', 'events.jsonl'));
|
|
5865
|
+
const paths = migrationPaths(flags, { storedSource: existing?.source });
|
|
5866
|
+
let result;
|
|
5867
|
+
if (fs.existsSync(paths.sourceLog) || migrationDecisionFiles(paths.sourceDecisions).length > 0) {
|
|
5868
|
+
result = checkMigration(migrationSourceSnapshot(flags, { storedSource: existing?.source }));
|
|
5869
|
+
} else {
|
|
5870
|
+
result = checkMigration({
|
|
5871
|
+
...paths,
|
|
5872
|
+
decisions: [],
|
|
5873
|
+
sourceLogPresent: existing?.source?.logPresent ?? false,
|
|
5874
|
+
}, { sourceMissing: true });
|
|
5875
|
+
}
|
|
5876
|
+
if (flags.json) printLine(JSON.stringify(result, null, 2));
|
|
5877
|
+
return result;
|
|
5878
|
+
},
|
|
5879
|
+
|
|
4665
5880
|
init(argv) {
|
|
4666
5881
|
const { positionals, flags } = parseArgs(argv, { lang: 'single', 'local-log': 'boolean' }, 'init');
|
|
4667
5882
|
if (positionals.length > 0) fail(usageFor('init'));
|
|
@@ -4686,11 +5901,13 @@ const commands = {
|
|
|
4686
5901
|
content: updated,
|
|
4687
5902
|
marker: INTENT_PROTOCOL_MARKER,
|
|
4688
5903
|
endMarker: INTENT_PROTOCOL_END,
|
|
4689
|
-
versionPattern: /^<!-- driftseal-version: (\d+) -->\r?$/m,
|
|
5904
|
+
versionPattern: /^<!-- driftseal-version: (\d+(?:\.\d+)?) -->\r?$/m,
|
|
4690
5905
|
replacement: intentBlock,
|
|
4691
5906
|
knownManagedBlocks: [
|
|
4692
5907
|
...sourceLanguages.flatMap((source) => [
|
|
4693
5908
|
protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
|
|
5909
|
+
protocolEol(v1IntentProtocolBlock(14, source), eol),
|
|
5910
|
+
protocolEol(v1IntentProtocolBlock(14, source, true), eol),
|
|
4694
5911
|
protocolEol(previousIntentProtocolBlock(13, source), eol),
|
|
4695
5912
|
protocolEol(previousIntentProtocolBlock(13, source, true), eol),
|
|
4696
5913
|
protocolEol(previousIntentProtocolBlock(12, source), eol),
|
|
@@ -4715,11 +5932,13 @@ const commands = {
|
|
|
4715
5932
|
content: updated,
|
|
4716
5933
|
marker: DECISION_PROTOCOL_MARKER,
|
|
4717
5934
|
endMarker: DECISION_PROTOCOL_END,
|
|
4718
|
-
versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
|
|
5935
|
+
versionPattern: /^<!-- driftseal-decisions-version: (\d+(?:\.\d+)?) -->\r?$/m,
|
|
4719
5936
|
replacement: decisionBlock,
|
|
4720
5937
|
knownManagedBlocks: [
|
|
4721
5938
|
...sourceLanguages.flatMap((source) => [
|
|
4722
5939
|
protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
|
|
5940
|
+
protocolEol(v1DecisionProtocolBlock(14, source), eol),
|
|
5941
|
+
protocolEol(v1DecisionProtocolBlock(14, source, true), eol),
|
|
4723
5942
|
protocolEol(previousDecisionProtocolBlock(13, source), eol),
|
|
4724
5943
|
protocolEol(previousDecisionProtocolBlock(13, source, true), eol),
|
|
4725
5944
|
protocolEol(previousDecisionProtocolBlock(12, source), eol),
|
|
@@ -4777,37 +5996,39 @@ const commands = {
|
|
|
4777
5996
|
printLine(`Configured git merge attribute: ${attributes.target}`);
|
|
4778
5997
|
}
|
|
4779
5998
|
if (driver.changed) {
|
|
4780
|
-
printLine('Configured local git merge driver for DriftSeal
|
|
5999
|
+
printLine('Configured local git merge driver for DriftSeal outcome logs');
|
|
4781
6000
|
}
|
|
4782
6001
|
return { changed: true, target };
|
|
4783
6002
|
},
|
|
4784
6003
|
|
|
4785
6004
|
help() {
|
|
4786
|
-
printLine(`DriftSeal — Seal the
|
|
6005
|
+
printLine(`DriftSeal — Seal the outcome. Stop the drift.
|
|
4787
6006
|
|
|
4788
|
-
|
|
6007
|
+
Outcome-level write-ahead log for agent sessions.
|
|
4789
6008
|
|
|
4790
6009
|
usage:
|
|
4791
|
-
driftseal begin "<
|
|
6010
|
+
driftseal begin "<outcome>" [--accept "<observable result>"] [--verify "<command>"]
|
|
4792
6011
|
[--decision <id>] [--force]
|
|
6012
|
+
driftseal extend "<same-outcome addition>" [--accept "<observable result>"]
|
|
6013
|
+
[--verify "<cumulative command>"] [--decision <id>]
|
|
4793
6014
|
driftseal verify [--allow-tracked-command]
|
|
4794
6015
|
run the declared command and bind its result
|
|
4795
|
-
to the current Git-visible workspace
|
|
6016
|
+
to the current contract and Git-visible workspace
|
|
4796
6017
|
driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
|
|
4797
|
-
driftseal status show the
|
|
4798
|
-
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)
|
|
4799
6020
|
driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]
|
|
4800
6021
|
hide meaningless closed records without deleting them
|
|
4801
6022
|
driftseal unreclaim <id> --reason "<why>"
|
|
4802
6023
|
restore a reclaimed record to the visible log
|
|
4803
6024
|
driftseal absorb [other-events.jsonl] [--decisions <dir>]
|
|
4804
6025
|
[--abandon-theirs | --abandon-ours] [--dry-run]
|
|
4805
|
-
merge another
|
|
6026
|
+
merge another outcome log, remapping colliding ids
|
|
4806
6027
|
driftseal absorb --git <base> <ours> <theirs>
|
|
4807
|
-
git merge driver for .
|
|
6028
|
+
git merge driver for .seal/outcomes/events.jsonl
|
|
4808
6029
|
driftseal decision add "<title>" --context "..." --outcome "..." [options]
|
|
4809
6030
|
driftseal decision update <id> [--status STATUS] --note "..."
|
|
4810
|
-
reconcile a linked decision in the open
|
|
6031
|
+
reconcile a linked decision in the open outcome
|
|
4811
6032
|
driftseal decision list [--status STATUS] [--last N | --count]
|
|
4812
6033
|
list or count filtered MADR decision records
|
|
4813
6034
|
driftseal decision show <id> print one MADR decision record
|
|
@@ -4824,8 +6045,17 @@ usage:
|
|
|
4824
6045
|
emit the reminder a lifecycle hook injects; never blocks
|
|
4825
6046
|
driftseal init [--lang <tag>] [--local-log]
|
|
4826
6047
|
inject protocols into ./AGENTS.md and configure the git merge driver
|
|
4827
|
-
--lang sets the
|
|
6048
|
+
--lang sets the outcome/MADR log language (BCP 47, default: en)
|
|
4828
6049
|
--local-log keeps the logs local and untracked instead of committing them
|
|
6050
|
+
driftseal migrate v1-to-v2 inspect [--json] [migration paths]
|
|
6051
|
+
driftseal migrate v1-to-v2 apply --plan <file> [migration paths]
|
|
6052
|
+
driftseal migrate v1-to-v2 check [migration paths]
|
|
6053
|
+
model-assisted, validated migration that never deletes v1 data
|
|
6054
|
+
|
|
6055
|
+
migration paths:
|
|
6056
|
+
--source-log <file> v1 events.jsonl (default: $DRIFTSEAL_HOME/events.jsonl or .intent-log/events.jsonl)
|
|
6057
|
+
--source-decisions <dir> v1 MADR directory (default: $DRIFTSEAL_DECISION_HOME or .decision-log)
|
|
6058
|
+
--destination <dir> v2 seal root (default: $DRIFTSEAL_HOME or .seal)
|
|
4829
6059
|
driftseal --version | -V print the installed DriftSeal version
|
|
4830
6060
|
driftseal help
|
|
4831
6061
|
|
|
@@ -4835,9 +6065,11 @@ decision add options:
|
|
|
4835
6065
|
--option "..." repeat for each considered option
|
|
4836
6066
|
--consequence "..." repeat for each consequence
|
|
4837
6067
|
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
6068
|
+
seal root: $DRIFTSEAL_HOME, or .seal in the current directory
|
|
6069
|
+
outcome log: <seal-root>/outcomes/events.jsonl
|
|
6070
|
+
MADR records: <seal-root>/madr/
|
|
6071
|
+
$DRIFTSEAL_DECISION_HOME is a v1-only default for migration source detection; v2 runtime ignores it.
|
|
6072
|
+
In a Git worktree, begin parks an open outcome in Git metadata until end, so merge does not need a log-only commit.`);
|
|
4841
6073
|
return null;
|
|
4842
6074
|
},
|
|
4843
6075
|
|
|
@@ -4861,12 +6093,14 @@ function requestedEndStatus(argv) {
|
|
|
4861
6093
|
*/
|
|
4862
6094
|
const VALUE_TAKING_FLAGS = {
|
|
4863
6095
|
begin: ['--accept', '--verify', '-v', '--decision'],
|
|
6096
|
+
extend: ['--accept', '--verify', '-v', '--decision'],
|
|
4864
6097
|
end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
|
|
4865
6098
|
log: ['--last', '-n'],
|
|
4866
6099
|
reclaim: ['--reason', '-r', '--older-than'],
|
|
4867
6100
|
unreclaim: ['--reason', '-r'],
|
|
4868
6101
|
absorb: ['--decisions'],
|
|
4869
6102
|
init: ['--lang'],
|
|
6103
|
+
migrate: ['--source-log', '--source-decisions', '--destination', '--plan', '--plan-json'],
|
|
4870
6104
|
'decision add': ['--context', '-c', '--outcome', '-o', '--status', '-s', '--driver', '--option', '--consequence'],
|
|
4871
6105
|
'decision update': ['--status', '-s', '--note', '-n'],
|
|
4872
6106
|
'decision list': ['--last', '-n', '--status', '-s'],
|
|
@@ -4897,8 +6131,16 @@ function mutationResources(cmd, argv) {
|
|
|
4897
6131
|
if (cmd === 'mcp') return [parseMcpInstallRequest(argv).configDir];
|
|
4898
6132
|
if (cmd === 'hook') return [parseHookInstallRequest(argv.slice(1)).configDir];
|
|
4899
6133
|
if (cmd === 'init') return [process.cwd()];
|
|
6134
|
+
if (cmd === 'migrate') return [process.cwd()];
|
|
4900
6135
|
if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
|
|
6136
|
+
if (cmd === 'absorb' && argv[0] === '--git') {
|
|
6137
|
+
const ours = argv[2];
|
|
6138
|
+
return ours ? [path.dirname(path.resolve(ours))] : [process.cwd()];
|
|
6139
|
+
}
|
|
4901
6140
|
if (cmd === 'absorb') return [logDir(), decisionDir()];
|
|
6141
|
+
if (cmd === 'end' && legacyParkedIntent()) {
|
|
6142
|
+
return [path.dirname(legacyIntentLogFile())];
|
|
6143
|
+
}
|
|
4902
6144
|
if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
|
|
4903
6145
|
return [logDir()];
|
|
4904
6146
|
}
|
|
@@ -4908,6 +6150,258 @@ function mutationResources(cmd, argv) {
|
|
|
4908
6150
|
return [logDir(), decisionDir()];
|
|
4909
6151
|
}
|
|
4910
6152
|
|
|
6153
|
+
function usesV2RepositoryState(cmd, rest) {
|
|
6154
|
+
if (
|
|
6155
|
+
['begin', 'extend', 'verify', 'end', 'status', 'log', 'reclaim', 'unreclaim', 'absorb', 'decision', 'init'].includes(cmd)
|
|
6156
|
+
) {
|
|
6157
|
+
return true;
|
|
6158
|
+
}
|
|
6159
|
+
return cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]);
|
|
6160
|
+
}
|
|
6161
|
+
|
|
6162
|
+
function looksLikeV2SealRoot(root) {
|
|
6163
|
+
const resolved = canonicalPath(root);
|
|
6164
|
+
if (fs.existsSync(path.join(resolved, 'outcomes', 'events.jsonl'))) return true;
|
|
6165
|
+
return resolved === canonicalPath(path.join(process.cwd(), '.seal'));
|
|
6166
|
+
}
|
|
6167
|
+
|
|
6168
|
+
function isV2OutcomeLog(file) {
|
|
6169
|
+
const resolved = canonicalPath(file);
|
|
6170
|
+
if (resolved === canonicalPath(logFile()) && looksLikeV2SealRoot(sealRoot())) return true;
|
|
6171
|
+
const directory = path.dirname(resolved);
|
|
6172
|
+
return path.basename(resolved) === 'events.jsonl' &&
|
|
6173
|
+
path.basename(directory) === 'outcomes' &&
|
|
6174
|
+
looksLikeV2SealRoot(path.dirname(directory));
|
|
6175
|
+
}
|
|
6176
|
+
|
|
6177
|
+
function isV2MadrDirectory(directory) {
|
|
6178
|
+
const resolved = canonicalPath(directory);
|
|
6179
|
+
if (resolved === canonicalPath(decisionDir()) && looksLikeV2SealRoot(sealRoot())) return true;
|
|
6180
|
+
return path.basename(resolved) === 'madr' && looksLikeV2SealRoot(path.dirname(resolved));
|
|
6181
|
+
}
|
|
6182
|
+
|
|
6183
|
+
function existingV2SealRoots() {
|
|
6184
|
+
const roots = [];
|
|
6185
|
+
const seen = new Set();
|
|
6186
|
+
const consider = (root) => {
|
|
6187
|
+
const resolved = canonicalPath(root);
|
|
6188
|
+
if (seen.has(resolved)) return;
|
|
6189
|
+
seen.add(resolved);
|
|
6190
|
+
if (fs.existsSync(path.join(resolved, 'outcomes', 'events.jsonl'))) roots.push(resolved);
|
|
6191
|
+
};
|
|
6192
|
+
consider(path.join(process.cwd(), '.seal'));
|
|
6193
|
+
consider(sealRoot());
|
|
6194
|
+
const cwd = canonicalPath(process.cwd());
|
|
6195
|
+
// setup() tests use cwd=os.tmpdir() with DRIFTSEAL_HOME in a child directory.
|
|
6196
|
+
// Never scan the system temp root: sibling test dirs would look like extra lineages.
|
|
6197
|
+
if (cwd !== canonicalPath(os.tmpdir())) {
|
|
6198
|
+
let names = [];
|
|
6199
|
+
try {
|
|
6200
|
+
names = fs.readdirSync(process.cwd());
|
|
6201
|
+
} catch {
|
|
6202
|
+
names = [];
|
|
6203
|
+
}
|
|
6204
|
+
for (const name of names) {
|
|
6205
|
+
if (name === '.git' || name === 'node_modules') continue;
|
|
6206
|
+
consider(path.join(process.cwd(), name));
|
|
6207
|
+
}
|
|
6208
|
+
}
|
|
6209
|
+
return roots;
|
|
6210
|
+
}
|
|
6211
|
+
|
|
6212
|
+
function repositoryOutcomeLogFiles() {
|
|
6213
|
+
const files = [];
|
|
6214
|
+
const seen = new Set();
|
|
6215
|
+
const add = (file) => {
|
|
6216
|
+
const resolved = canonicalPath(file);
|
|
6217
|
+
if (seen.has(resolved)) return;
|
|
6218
|
+
seen.add(resolved);
|
|
6219
|
+
files.push(file);
|
|
6220
|
+
};
|
|
6221
|
+
add(logFile());
|
|
6222
|
+
add(path.join(process.cwd(), '.seal', 'outcomes', 'events.jsonl'));
|
|
6223
|
+
for (const root of existingV2SealRoots()) {
|
|
6224
|
+
add(path.join(root, 'outcomes', 'events.jsonl'));
|
|
6225
|
+
}
|
|
6226
|
+
return files;
|
|
6227
|
+
}
|
|
6228
|
+
|
|
6229
|
+
function repositoryMigrationEvent() {
|
|
6230
|
+
for (const file of repositoryOutcomeLogFiles()) {
|
|
6231
|
+
try {
|
|
6232
|
+
const migration = findMigrationEvent(file);
|
|
6233
|
+
if (migration) return migration;
|
|
6234
|
+
} catch {
|
|
6235
|
+
// Conflicted or corrupt v2 logs are handled by the command, not by v1 detection.
|
|
6236
|
+
}
|
|
6237
|
+
}
|
|
6238
|
+
return null;
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
function v2KnownRecordIds() {
|
|
6242
|
+
const ids = new Set();
|
|
6243
|
+
for (const file of repositoryOutcomeLogFiles()) {
|
|
6244
|
+
if (!fs.existsSync(file)) continue;
|
|
6245
|
+
try {
|
|
6246
|
+
for (const event of readEvents({ file, repairTail: false, readOnly: true })) {
|
|
6247
|
+
if (event.type === 'import' && Array.isArray(event.sources)) {
|
|
6248
|
+
for (const source of event.sources) {
|
|
6249
|
+
if (source && typeof source.id === 'string') ids.add(source.id);
|
|
6250
|
+
}
|
|
6251
|
+
}
|
|
6252
|
+
if (event.type === 'migration' && Array.isArray(event.excluded)) {
|
|
6253
|
+
for (const item of event.excluded) {
|
|
6254
|
+
if (item && typeof item.sourceId === 'string') ids.add(item.sourceId);
|
|
6255
|
+
}
|
|
6256
|
+
}
|
|
6257
|
+
}
|
|
6258
|
+
} catch {
|
|
6259
|
+
// Unreadable v2 logs cannot attest that leftover v1 records are already present.
|
|
6260
|
+
}
|
|
6261
|
+
}
|
|
6262
|
+
return ids;
|
|
6263
|
+
}
|
|
6264
|
+
|
|
6265
|
+
function migrationCoversCandidate(candidate, migration) {
|
|
6266
|
+
if (!migration || typeof migration.sourceFingerprint !== 'string') return false;
|
|
6267
|
+
try {
|
|
6268
|
+
const content = migrationSourceContent(candidate.sourceLog, candidate.sourceDecisions);
|
|
6269
|
+
const hashes = hashMigrationSourceContent(content);
|
|
6270
|
+
if ([hashes.sourceFingerprint, hashes.legacySourceFingerprint].includes(migration.sourceFingerprint)) {
|
|
6271
|
+
return true;
|
|
6272
|
+
}
|
|
6273
|
+
const beginIds = parseJsonlRecords(content.rawLog, content.sourceLog, { allowLegacy: true })
|
|
6274
|
+
.map((record) => record.event)
|
|
6275
|
+
.filter((event) => event.type === 'begin')
|
|
6276
|
+
.map((event) => event.id);
|
|
6277
|
+
const knownIds = v2KnownRecordIds();
|
|
6278
|
+
if (beginIds.some((id) => !knownIds.has(id))) return false;
|
|
6279
|
+
if (Array.isArray(migration.madrManifest)) {
|
|
6280
|
+
const expected = new Map(migration.madrManifest.map((entry) => [entry.name, entry.sha256]));
|
|
6281
|
+
for (const decision of content.decisions) {
|
|
6282
|
+
const sha256 = expected.get(decision.name);
|
|
6283
|
+
if (!sha256) return false;
|
|
6284
|
+
const actual = crypto.createHash('sha256').update(decision.bytes).digest('hex');
|
|
6285
|
+
if (actual !== sha256) return false;
|
|
6286
|
+
}
|
|
6287
|
+
}
|
|
6288
|
+
return beginIds.length > 0 || content.decisions.length > 0;
|
|
6289
|
+
} catch {
|
|
6290
|
+
return false;
|
|
6291
|
+
}
|
|
6292
|
+
}
|
|
6293
|
+
|
|
6294
|
+
function unmigratedV1Source() {
|
|
6295
|
+
const migration = repositoryMigrationEvent();
|
|
6296
|
+
const defaultLog = path.resolve(process.cwd(), '.intent-log', 'events.jsonl');
|
|
6297
|
+
const defaultDecisions = path.resolve(process.cwd(), '.decision-log');
|
|
6298
|
+
const home = v1HomeEnv();
|
|
6299
|
+
const configuredLog = home ? path.resolve(home, 'events.jsonl') : null;
|
|
6300
|
+
const configuredDecisions = v1DecisionHomeEnv()
|
|
6301
|
+
? path.resolve(v1DecisionHomeEnv())
|
|
6302
|
+
: defaultDecisions;
|
|
6303
|
+
const candidates = [{
|
|
6304
|
+
sourceLog: defaultLog,
|
|
6305
|
+
sourceDecisions: defaultDecisions,
|
|
6306
|
+
}];
|
|
6307
|
+
if (configuredLog) {
|
|
6308
|
+
candidates.push({
|
|
6309
|
+
sourceLog: configuredLog,
|
|
6310
|
+
sourceDecisions: configuredDecisions,
|
|
6311
|
+
});
|
|
6312
|
+
} else if (v1DecisionHomeEnv()) {
|
|
6313
|
+
candidates.push({
|
|
6314
|
+
sourceLog: defaultLog,
|
|
6315
|
+
sourceDecisions: configuredDecisions,
|
|
6316
|
+
});
|
|
6317
|
+
}
|
|
6318
|
+
const seen = new Set();
|
|
6319
|
+
const found = [];
|
|
6320
|
+
for (const candidate of candidates) {
|
|
6321
|
+
const key = `${canonicalPath(candidate.sourceLog)}\0${canonicalPath(candidate.sourceDecisions)}`;
|
|
6322
|
+
if (seen.has(key)) continue;
|
|
6323
|
+
seen.add(key);
|
|
6324
|
+
const hasLog = fs.existsSync(candidate.sourceLog) && !isV2OutcomeLog(candidate.sourceLog);
|
|
6325
|
+
const hasDecisions = !isV2MadrDirectory(candidate.sourceDecisions) &&
|
|
6326
|
+
migrationDecisionFiles(candidate.sourceDecisions).length > 0;
|
|
6327
|
+
if (!hasLog && !hasDecisions) continue;
|
|
6328
|
+
if (migration && migrationCoversCandidate(candidate, migration)) continue;
|
|
6329
|
+
found.push({ ...candidate, hasLog, hasDecisions });
|
|
6330
|
+
}
|
|
6331
|
+
if (found.length === 0) return null;
|
|
6332
|
+
const withLog = found.filter((candidate) => candidate.hasLog);
|
|
6333
|
+
const pool = withLog.length > 0 ? withLog : found;
|
|
6334
|
+
if (configuredLog) {
|
|
6335
|
+
const configured = pool.find((candidate) => canonicalPath(candidate.sourceLog) === canonicalPath(configuredLog));
|
|
6336
|
+
if (configured) return configured;
|
|
6337
|
+
}
|
|
6338
|
+
return pool[0];
|
|
6339
|
+
}
|
|
6340
|
+
|
|
6341
|
+
function staleInheritedSealHome() {
|
|
6342
|
+
if (!process.env.DRIFTSEAL_HOME) return null;
|
|
6343
|
+
const home = canonicalPath(process.env.DRIFTSEAL_HOME);
|
|
6344
|
+
if (fs.existsSync(logFile())) return null;
|
|
6345
|
+
const others = existingV2SealRoots().filter((root) => root !== home);
|
|
6346
|
+
if (others.length === 0) return null;
|
|
6347
|
+
const defaultSeal = canonicalPath(path.join(process.cwd(), '.seal'));
|
|
6348
|
+
return {
|
|
6349
|
+
home,
|
|
6350
|
+
seal: others.includes(defaultSeal) ? defaultSeal : others[0],
|
|
6351
|
+
};
|
|
6352
|
+
}
|
|
6353
|
+
|
|
6354
|
+
function suggestedMigrationDestination(source) {
|
|
6355
|
+
const configured = canonicalPath(sealRoot());
|
|
6356
|
+
const defaultSeal = canonicalPath(path.join(process.cwd(), '.seal'));
|
|
6357
|
+
if (
|
|
6358
|
+
pathContains(source.sourceLog, configured) ||
|
|
6359
|
+
pathContains(configured, source.sourceLog) ||
|
|
6360
|
+
pathContains(source.sourceDecisions, configured) ||
|
|
6361
|
+
pathContains(configured, source.sourceDecisions) ||
|
|
6362
|
+
fs.existsSync(path.join(configured, 'events.jsonl'))
|
|
6363
|
+
) {
|
|
6364
|
+
return defaultSeal;
|
|
6365
|
+
}
|
|
6366
|
+
return configured;
|
|
6367
|
+
}
|
|
6368
|
+
|
|
6369
|
+
function unmigratedV1Message(source) {
|
|
6370
|
+
const destination = suggestedMigrationDestination(source);
|
|
6371
|
+
const staged = repositoryMigrationEvent();
|
|
6372
|
+
const mismatch = staged
|
|
6373
|
+
? 'it does not match the already staged v1-to-v2 migration; v2 repository commands are disabled until the extra v1 source is removed\n'
|
|
6374
|
+
: 'v2 repository commands are disabled until migration is staged\n';
|
|
6375
|
+
const parked = legacyParkedIntent();
|
|
6376
|
+
const parkHint = parked
|
|
6377
|
+
? `a parked v1 intent ${parked.id} is still open; close it first:\n` +
|
|
6378
|
+
' driftseal end --status abandoned --note "close parked v1 intent before migration"\n'
|
|
6379
|
+
: '';
|
|
6380
|
+
return (
|
|
6381
|
+
`unmigrated v1 state detected at ${source.sourceLog} or ${source.sourceDecisions}; ` +
|
|
6382
|
+
mismatch +
|
|
6383
|
+
parkHint +
|
|
6384
|
+
`run: driftseal migrate v1-to-v2 inspect --source-log ${JSON.stringify(source.sourceLog)} ` +
|
|
6385
|
+
`--source-decisions ${JSON.stringify(source.sourceDecisions)} --destination ${JSON.stringify(destination)}\n` +
|
|
6386
|
+
'if DRIFTSEAL_HOME points to v1 storage, unset or update it after migration'
|
|
6387
|
+
);
|
|
6388
|
+
}
|
|
6389
|
+
|
|
6390
|
+
function assertV2RepositoryReady(cmd, rest) {
|
|
6391
|
+
if (!usesV2RepositoryState(cmd, rest)) return;
|
|
6392
|
+
if (cmd === 'absorb' && rest[0] === '--git') return;
|
|
6393
|
+
if (['end', 'status', 'log'].includes(cmd) && legacyParkedIntent()) return;
|
|
6394
|
+
const source = unmigratedV1Source();
|
|
6395
|
+
if (source) fail(unmigratedV1Message(source));
|
|
6396
|
+
const staleHome = staleInheritedSealHome();
|
|
6397
|
+
if (!staleHome) return;
|
|
6398
|
+
fail(
|
|
6399
|
+
`DRIFTSEAL_HOME points to ${staleHome.home}, which has no v2 outcome log, ` +
|
|
6400
|
+
`but this repository already has v2 state at ${staleHome.seal}; ` +
|
|
6401
|
+
'unset DRIFTSEAL_HOME or point it at the seal root'
|
|
6402
|
+
);
|
|
6403
|
+
}
|
|
6404
|
+
|
|
4911
6405
|
function dispatch(argv) {
|
|
4912
6406
|
const [cmd, ...rest] = argv;
|
|
4913
6407
|
if (cmd === '--version' || cmd === '-V') {
|
|
@@ -4924,8 +6418,10 @@ function dispatch(argv) {
|
|
|
4924
6418
|
// probe is spec-aware so it never bypasses the lock for a real mutation: a
|
|
4925
6419
|
// --help token consumed as a flag value is left for parseArgs to reject.
|
|
4926
6420
|
if (wantsHelpBeforeLock(cmd, rest)) return { data: fn(rest), exitCode: 0 };
|
|
6421
|
+
assertV2RepositoryReady(cmd, rest);
|
|
4927
6422
|
const mutates =
|
|
4928
|
-
['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
|
|
6423
|
+
['begin', 'extend', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
|
|
6424
|
+
(cmd === 'migrate' && rest[1] === 'apply') ||
|
|
4929
6425
|
(cmd === 'hook' && rest[0] === 'install') ||
|
|
4930
6426
|
(cmd === 'decision' && ['add', 'update'].includes(rest[0]));
|
|
4931
6427
|
const readsIntentLog =
|
|
@@ -4942,6 +6438,8 @@ function dispatch(argv) {
|
|
|
4942
6438
|
const hookFile = hookLogFile();
|
|
4943
6439
|
if (!hookFile) return { data: fn(rest), exitCode: 0 };
|
|
4944
6440
|
resources = [path.dirname(hookFile)];
|
|
6441
|
+
} else if (legacyParkedIntent()) {
|
|
6442
|
+
resources = [path.dirname(legacyIntentLogFile())];
|
|
4945
6443
|
} else {
|
|
4946
6444
|
resources = [logDir()];
|
|
4947
6445
|
}
|
|
@@ -5018,6 +6516,10 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
|
|
|
5018
6516
|
try {
|
|
5019
6517
|
process.chdir(fixedRoot);
|
|
5020
6518
|
if (isolateStorage) {
|
|
6519
|
+
isolatedV1Detection = {
|
|
6520
|
+
home: process.env.DRIFTSEAL_HOME || null,
|
|
6521
|
+
decisions: process.env.DRIFTSEAL_DECISION_HOME || null,
|
|
6522
|
+
};
|
|
5021
6523
|
delete process.env.DRIFTSEAL_HOME;
|
|
5022
6524
|
delete process.env.DRIFTSEAL_DECISION_HOME;
|
|
5023
6525
|
}
|
|
@@ -5038,6 +6540,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
|
|
|
5038
6540
|
} finally {
|
|
5039
6541
|
activeOutput = previousOutput;
|
|
5040
6542
|
process.chdir(previousCwd);
|
|
6543
|
+
isolatedV1Detection = null;
|
|
5041
6544
|
if (previousIntentHome === undefined) delete process.env.DRIFTSEAL_HOME;
|
|
5042
6545
|
else process.env.DRIFTSEAL_HOME = previousIntentHome;
|
|
5043
6546
|
if (previousDecisionHome === undefined) delete process.env.DRIFTSEAL_DECISION_HOME;
|
|
@@ -5066,14 +6569,21 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
|
|
|
5066
6569
|
status() {
|
|
5067
6570
|
return call(['status']);
|
|
5068
6571
|
},
|
|
5069
|
-
begin({
|
|
5070
|
-
const argv = ['begin',
|
|
6572
|
+
begin({ outcome, acceptance = [], verify, decisions = [], force = false }) {
|
|
6573
|
+
const argv = ['begin', outcome];
|
|
5071
6574
|
for (const criterion of acceptance) appendFlag(argv, '--accept', criterion);
|
|
5072
6575
|
appendFlag(argv, '--verify', verify);
|
|
5073
6576
|
for (const decision of decisions) appendFlag(argv, '--decision', decision);
|
|
5074
6577
|
if (force) argv.push('--force');
|
|
5075
6578
|
return call(argv);
|
|
5076
6579
|
},
|
|
6580
|
+
extend({ extension, acceptance = [], verify, decisions = [] }) {
|
|
6581
|
+
const argv = ['extend', extension];
|
|
6582
|
+
for (const criterion of acceptance) appendFlag(argv, '--accept', criterion);
|
|
6583
|
+
appendFlag(argv, '--verify', verify);
|
|
6584
|
+
for (const decision of decisions) appendFlag(argv, '--decision', decision);
|
|
6585
|
+
return call(argv);
|
|
6586
|
+
},
|
|
5077
6587
|
verify({ allowTrackedCommand = false } = {}) {
|
|
5078
6588
|
return call(['verify', ...(allowTrackedCommand ? ['--allow-tracked-command'] : [])]);
|
|
5079
6589
|
},
|
|
@@ -5135,6 +6645,27 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
|
|
|
5135
6645
|
decisionShow({ id }) {
|
|
5136
6646
|
return call(['decision', 'show', String(id)]);
|
|
5137
6647
|
},
|
|
6648
|
+
migrationInspect({ sourceLog, sourceDecisions, destination } = {}) {
|
|
6649
|
+
const argv = ['migrate', 'v1-to-v2', 'inspect'];
|
|
6650
|
+
appendFlag(argv, '--source-log', sourceLog);
|
|
6651
|
+
appendFlag(argv, '--source-decisions', sourceDecisions);
|
|
6652
|
+
appendFlag(argv, '--destination', destination);
|
|
6653
|
+
return call(argv);
|
|
6654
|
+
},
|
|
6655
|
+
migrationApply({ plan, sourceLog, sourceDecisions, destination }) {
|
|
6656
|
+
const argv = ['migrate', 'v1-to-v2', 'apply', '--plan-json', JSON.stringify(plan)];
|
|
6657
|
+
appendFlag(argv, '--source-log', sourceLog);
|
|
6658
|
+
appendFlag(argv, '--source-decisions', sourceDecisions);
|
|
6659
|
+
appendFlag(argv, '--destination', destination);
|
|
6660
|
+
return call(argv);
|
|
6661
|
+
},
|
|
6662
|
+
migrationCheck({ sourceLog, sourceDecisions, destination } = {}) {
|
|
6663
|
+
const argv = ['migrate', 'v1-to-v2', 'check'];
|
|
6664
|
+
appendFlag(argv, '--source-log', sourceLog);
|
|
6665
|
+
appendFlag(argv, '--source-decisions', sourceDecisions);
|
|
6666
|
+
appendFlag(argv, '--destination', destination);
|
|
6667
|
+
return call(argv);
|
|
6668
|
+
},
|
|
5138
6669
|
init() {
|
|
5139
6670
|
return call(['init']);
|
|
5140
6671
|
},
|