driftseal 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/driftseal.js CHANGED
@@ -34,12 +34,48 @@ const DECISION_STATUSES = [
34
34
  'deprecated',
35
35
  'superseded',
36
36
  ];
37
- const EVENT_SCHEMA_VERSION = 2;
38
- const PROTOCOL_VERSION = 5;
37
+ const EVENT_SCHEMA_VERSION = 3;
38
+ const PROTOCOL_VERSION = 7;
39
39
  const LOCK_STALE_MS = 30 * 60 * 1000;
40
40
  const LOCK_INIT_STALE_MS = 5 * 1000;
41
41
  const MAX_DECISION_SLUG_LENGTH = 180;
42
42
 
43
+ class DriftSealError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'DriftSealError';
47
+ }
48
+ }
49
+
50
+ let activeOutput = null;
51
+
52
+ function printLine(value = '') {
53
+ const text = String(value);
54
+ if (activeOutput) {
55
+ activeOutput.stdout += text + '\n';
56
+ return;
57
+ }
58
+ console.log(text);
59
+ }
60
+
61
+ function printError(value = '') {
62
+ const text = String(value);
63
+ if (activeOutput) {
64
+ activeOutput.stderr += text + '\n';
65
+ return;
66
+ }
67
+ console.error(text);
68
+ }
69
+
70
+ function writeOutput(value) {
71
+ const text = String(value);
72
+ if (activeOutput) {
73
+ activeOutput.stdout += text;
74
+ return;
75
+ }
76
+ process.stdout.write(text);
77
+ }
78
+
43
79
  if (process.env._DRIFTSEAL_TEST_UMASK) {
44
80
  process.umask(Number.parseInt(process.env._DRIFTSEAL_TEST_UMASK, 8));
45
81
  }
@@ -94,6 +130,13 @@ function normalizeEvent(event, line) {
94
130
  return event;
95
131
  }
96
132
 
133
+ if (event.type === 'reclaim' || event.type === 'unreclaim') {
134
+ if (typeof event.reason !== 'string' || event.reason.trim().length === 0) {
135
+ fail(`invalid ${event.type} event on log line ${line}`);
136
+ }
137
+ return event;
138
+ }
139
+
97
140
  if (
98
141
  event.type === 'decision_reconcile' ||
99
142
  event.type === 'decision_reconcile_prepare' ||
@@ -176,7 +219,8 @@ function readEvents({ repairTail = false } = {}) {
176
219
  .map((line, i) => {
177
220
  try {
178
221
  return normalizeEvent(JSON.parse(line), i + 1);
179
- } catch {
222
+ } catch (err) {
223
+ if (err instanceof DriftSealError) throw err;
180
224
  fail(`corrupt log line ${i + 1} in ${file}`);
181
225
  }
182
226
  });
@@ -529,8 +573,28 @@ function fold(events) {
529
573
  tsEnd: null,
530
574
  note: null,
531
575
  verifyResult: null,
576
+ reclaimed: false,
577
+ reclaimReason: null,
578
+ reclaimedAt: null,
532
579
  });
533
580
  order.push(ev.id);
581
+ } else if (ev.type === 'reclaim' || ev.type === 'unreclaim') {
582
+ const rec = records.get(ev.id);
583
+ if (!rec) fail(`${ev.type} event references unknown intent id: ${ev.id}`);
584
+ if (ev.type === 'reclaim') {
585
+ if (rec.status === 'in_progress') {
586
+ fail(`cannot reclaim intent ${ev.id} while it is in_progress`);
587
+ }
588
+ if (rec.reclaimed) fail(`duplicate reclaim event for intent id: ${ev.id}`);
589
+ rec.reclaimed = true;
590
+ rec.reclaimReason = ev.reason;
591
+ rec.reclaimedAt = ev.ts;
592
+ } else {
593
+ if (!rec.reclaimed) fail(`unreclaim event for intent id that is not reclaimed: ${ev.id}`);
594
+ rec.reclaimed = false;
595
+ rec.reclaimReason = null;
596
+ rec.reclaimedAt = null;
597
+ }
534
598
  } else if (ev.type === 'end') {
535
599
  const rec = records.get(ev.id);
536
600
  if (!rec) fail(`end event references unknown intent id: ${ev.id}`);
@@ -928,8 +992,7 @@ function closeIntentAsEscape(events, record, requestedStatus, note, verifyResult
928
992
  }
929
993
 
930
994
  function fail(msg) {
931
- console.error(`driftseal: error: ${msg}`);
932
- process.exit(1);
995
+ throw new DriftSealError(msg);
933
996
  }
934
997
 
935
998
  function positiveInteger(value, flag) {
@@ -1002,9 +1065,39 @@ function render(rec) {
1002
1065
  if (rec.verifyResult) lines.push(` verify-result: ${rec.verifyResult}`);
1003
1066
  if (rec.note) lines.push(` note: ${rec.note}`);
1004
1067
  lines.push(` began: ${rec.tsBegin}` + (rec.tsEnd ? ` ended: ${rec.tsEnd}` : ''));
1068
+ if (rec.reclaimed) lines.push(` reclaimed: ${rec.reclaimReason}`);
1005
1069
  return lines.join('\n');
1006
1070
  }
1007
1071
 
1072
+ function publicIntent(rec) {
1073
+ if (!rec) return null;
1074
+ return {
1075
+ id: rec.id,
1076
+ intent: rec.intent,
1077
+ verify: rec.verify,
1078
+ decisions: [...rec.decisions],
1079
+ status: rec.status,
1080
+ note: rec.note,
1081
+ verifyResult: rec.verifyResult,
1082
+ beganAt: rec.tsBegin,
1083
+ endedAt: rec.tsEnd,
1084
+ reclaimed: rec.reclaimed,
1085
+ reclaimReason: rec.reclaimReason,
1086
+ reclaimedAt: rec.reclaimedAt,
1087
+ };
1088
+ }
1089
+
1090
+ function publicDecision(decision, { includeContent = false } = {}) {
1091
+ const record = {
1092
+ id: decision.id,
1093
+ title: decision.title,
1094
+ status: decision.status,
1095
+ file: decision.file,
1096
+ };
1097
+ if (includeContent) record.content = decision.content;
1098
+ return record;
1099
+ }
1100
+
1008
1101
  const INTENT_PROTOCOL_MARKER = '<!-- driftseal -->';
1009
1102
  const INTENT_PROTOCOL_END = '<!-- /driftseal -->';
1010
1103
  const DECISION_PROTOCOL_MARKER = '<!-- driftseal-decisions -->';
@@ -1040,14 +1133,38 @@ This repo uses DriftSeal (\`driftseal\`) to prevent agent drift. Every work roun
1040
1133
  just-closed log finalizes that round without requiring a new intent. Any content
1041
1134
  change made while preparing the commit does require a new intent.
1042
1135
  4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1043
- doing anything else. The open intent is the source of truth.
1136
+ doing anything else. The open intent is the source of truth: resume it when its
1137
+ objective still matches the current task; otherwise close it (\`partial\` or
1138
+ \`abandoned\`, with a note) and \`begin\` a new one.
1139
+
1140
+ **Log access goes only through DriftSeal.** Never read, edit, move, or delete
1141
+ \`.intent-log/events.jsonl\` (or anything under \`$DRIFTSEAL_HOME\`) directly; use
1142
+ \`driftseal\` commands or the MCP tools. Retire meaningless closed records with
1143
+ \`driftseal reclaim [id ...] --reason "<why>"\` — it appends a marker, never
1144
+ deletes log lines; \`driftseal unreclaim <id> --reason "<why>"\` restores one.
1044
1145
 
1045
1146
  Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
1046
1147
  ${INTENT_PROTOCOL_END}`;
1047
1148
  }
1048
1149
 
1049
1150
  function previousIntentProtocolBlock(version) {
1050
- const v4 = intentProtocolBlock(version).replace(
1151
+ const v6 = intentProtocolBlock(version).replace(
1152
+ 'doing anything else. The open intent is the source of truth: resume it when its\n' +
1153
+ ' objective still matches the current task; otherwise close it (`partial` or\n' +
1154
+ ' `abandoned`, with a note) and `begin` a new one.',
1155
+ 'doing anything else. The open intent is the source of truth.'
1156
+ );
1157
+ if (version >= 6) return v6;
1158
+ const v5 = v6.replace(
1159
+ '\n**Log access goes only through DriftSeal.** Never read, edit, move, or delete\n' +
1160
+ '`.intent-log/events.jsonl` (or anything under `$DRIFTSEAL_HOME`) directly; use\n' +
1161
+ '`driftseal` commands or the MCP tools. Retire meaningless closed records with\n' +
1162
+ '`driftseal reclaim [id ...] --reason "<why>"` — it appends a marker, never\n' +
1163
+ 'deletes log lines; `driftseal unreclaim <id> --reason "<why>"` restores one.\n',
1164
+ ''
1165
+ );
1166
+ if (version >= 5) return v5;
1167
+ const v4 = v5.replace(
1051
1168
  '1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
1052
1169
  ' making any other change that may need a rollback:\n' +
1053
1170
  ' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
@@ -1082,7 +1199,7 @@ revisiting, non-obvious rationale behind a long-lived or costly-to-reverse accep
1082
1199
  choice, or a deprecated or superseded decision. Do not record routine, local,
1083
1200
  readily reversible choices.
1084
1201
 
1085
- \`driftseal decision add "<title>" --context "<problem and constraints>" --outcome "<decision and rationale>" --option "<considered option>" --consequence "<result>"\`
1202
+ \`driftseal decision add "<title>" --context "<problem and constraints>" --outcome "<decision and rationale>" --driver "<decision driver>" --option "<considered option>" --consequence "<result>"\`
1086
1203
 
1087
1204
  Add one \`--driver\`, \`--option\`, or \`--consequence\` flag per item. Use
1088
1205
  \`--status proposed|accepted|rejected|deferred|deprecated|superseded\` when needed.
@@ -1147,6 +1264,10 @@ When an intent declares an existing decision with \`--decision <id>\`, use
1147
1264
  Commit \`.decision-log/\` with the code.`;
1148
1265
  }
1149
1266
 
1267
+ function previousDecisionProtocolBlock(version) {
1268
+ return decisionProtocolBlock(version).replace(' --driver "<decision driver>"', '');
1269
+ }
1270
+
1150
1271
  function upgradeManagedBlock({
1151
1272
  content,
1152
1273
  marker,
@@ -1232,19 +1353,21 @@ const commands = {
1232
1353
  'superseded by --force',
1233
1354
  null
1234
1355
  );
1235
- console.error(`driftseal: ${status} ${open.id}`);
1356
+ printError(`driftseal: ${status} ${open.id}`);
1236
1357
  }
1237
1358
 
1238
1359
  const id = nextId(events);
1239
- appendEvent({
1360
+ events.push(appendEvent({
1240
1361
  type: 'begin',
1241
1362
  id,
1242
1363
  ts: new Date().toISOString(),
1243
1364
  intent,
1244
1365
  verify: flags.verify || null,
1245
1366
  decisions,
1246
- });
1247
- console.log(id);
1367
+ }));
1368
+ const record = fold(events).find((candidate) => candidate.id === id);
1369
+ printLine(id);
1370
+ return publicIntent(record);
1248
1371
  },
1249
1372
 
1250
1373
  end(argv) {
@@ -1279,8 +1402,9 @@ const commands = {
1279
1402
  flags.note,
1280
1403
  flags['verify-result']
1281
1404
  );
1282
- console.log(`${target.id} ${terminalStatus}`);
1283
- return;
1405
+ const record = fold(events).find((candidate) => candidate.id === target.id);
1406
+ printLine(`${target.id} ${terminalStatus}`);
1407
+ return publicIntent(record);
1284
1408
  }
1285
1409
 
1286
1410
  if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
@@ -1317,15 +1441,17 @@ const commands = {
1317
1441
  }
1318
1442
  }
1319
1443
 
1320
- appendEvent({
1444
+ events.push(appendEvent({
1321
1445
  type: 'end',
1322
1446
  id: target.id,
1323
1447
  ts: new Date().toISOString(),
1324
1448
  status,
1325
1449
  note: flags.note || null,
1326
1450
  verifyResult: flags['verify-result'] || null,
1327
- });
1328
- console.log(`${target.id} ${status}`);
1451
+ }));
1452
+ const record = fold(events).find((candidate) => candidate.id === target.id);
1453
+ printLine(`${target.id} ${status}`);
1454
+ return publicIntent(record);
1329
1455
  },
1330
1456
 
1331
1457
  status(argv) {
@@ -1333,25 +1459,131 @@ const commands = {
1333
1459
  if (positionals.length > 0) fail('usage: driftseal status');
1334
1460
  const open = openIntent(fold(readEvents({ repairTail: true })));
1335
1461
  if (!open) {
1336
- console.log('no intent in progress');
1337
- return;
1462
+ printLine('no intent in progress');
1463
+ return null;
1338
1464
  }
1339
- console.log(render(open));
1465
+ printLine(render(open));
1466
+ return publicIntent(open);
1340
1467
  },
1341
1468
 
1342
1469
  log(argv) {
1343
- const { positionals, flags } = parseArgs(argv, { last: '-n' });
1344
- if (positionals.length > 0) fail('usage: driftseal log [--last N]');
1470
+ const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' });
1471
+ if (positionals.length > 0) fail('usage: driftseal log [--last N] [--all]');
1345
1472
  let records = fold(readEvents({ repairTail: true }));
1473
+ if (!flags.all) records = records.filter((record) => !record.reclaimed);
1346
1474
  if (flags.last) {
1347
1475
  const n = positiveInteger(flags.last, '--last');
1348
1476
  records = records.slice(-n);
1349
1477
  }
1350
1478
  if (records.length === 0) {
1351
- console.log('log is empty');
1352
- return;
1479
+ printLine('log is empty');
1480
+ return [];
1353
1481
  }
1354
- console.log(records.map(render).join('\n\n'));
1482
+ printLine(records.map(render).join('\n\n'));
1483
+ return records.map(publicIntent);
1484
+ },
1485
+
1486
+ reclaim(argv) {
1487
+ const { positionals, flags } = parseArgs(argv, {
1488
+ reason: '-r',
1489
+ 'older-than': 'single',
1490
+ force: 'boolean',
1491
+ 'dry-run': 'boolean',
1492
+ });
1493
+ const reason = flags.reason && flags.reason.trim();
1494
+ if (!reason) {
1495
+ fail(
1496
+ 'usage: driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]'
1497
+ );
1498
+ }
1499
+ let olderThanDays = 7;
1500
+ if (flags['older-than'] !== undefined) {
1501
+ olderThanDays = positiveInteger(flags['older-than'], '--older-than');
1502
+ }
1503
+
1504
+ const records = fold(readEvents({ repairTail: true }));
1505
+ let targets;
1506
+ if (positionals.length > 0) {
1507
+ const ids = [...new Set(positionals)];
1508
+ targets = ids.map((id) => {
1509
+ const record = records.find((candidate) => candidate.id === id);
1510
+ if (!record) fail(`unknown intent id: ${id}`);
1511
+ if (record.status === 'in_progress') {
1512
+ fail(`cannot reclaim intent ${id} while it is in_progress`);
1513
+ }
1514
+ if (record.reclaimed) fail(`intent ${id} is already reclaimed`);
1515
+ const routine = ['failed', 'abandoned'].includes(record.status) &&
1516
+ record.decisions.length === 0;
1517
+ if (!routine && !flags.force) {
1518
+ fail(
1519
+ `intent ${id} is ${record.status}` +
1520
+ (record.decisions.length > 0 ? ' and linked to decisions' : '') +
1521
+ '; re-run with --force to reclaim it anyway'
1522
+ );
1523
+ }
1524
+ return record;
1525
+ });
1526
+ } else {
1527
+ if (flags.force) fail('--force requires explicit intent ids');
1528
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
1529
+ targets = records.filter(
1530
+ (record) =>
1531
+ record.status !== 'in_progress' &&
1532
+ ['failed', 'abandoned'].includes(record.status) &&
1533
+ record.decisions.length === 0 &&
1534
+ !record.reclaimed &&
1535
+ Date.parse(record.tsEnd) < cutoff
1536
+ );
1537
+ if (targets.length === 0) {
1538
+ printLine('no reclaimable intents');
1539
+ return [];
1540
+ }
1541
+ }
1542
+
1543
+ if (flags['dry-run']) {
1544
+ printLine(targets.map((record) => `${record.id} ${record.status} — ${record.intent}`).join('\n'));
1545
+ return targets.map(publicIntent);
1546
+ }
1547
+
1548
+ let events = readEvents({ repairTail: true });
1549
+ for (const record of targets) {
1550
+ events.push(
1551
+ appendEvent({
1552
+ type: 'reclaim',
1553
+ id: record.id,
1554
+ ts: new Date().toISOString(),
1555
+ reason,
1556
+ })
1557
+ );
1558
+ }
1559
+ const reclaimed = fold(events).filter((record) =>
1560
+ targets.some((target) => target.id === record.id)
1561
+ );
1562
+ printLine(targets.map((record) => `${record.id} reclaimed`).join('\n'));
1563
+ return reclaimed.map(publicIntent);
1564
+ },
1565
+
1566
+ unreclaim(argv) {
1567
+ const { positionals, flags } = parseArgs(argv, { reason: '-r' });
1568
+ const reason = flags.reason && flags.reason.trim();
1569
+ if (positionals.length !== 1 || !reason) {
1570
+ fail('usage: driftseal unreclaim <id> --reason "<why>"');
1571
+ }
1572
+ const events = readEvents({ repairTail: true });
1573
+ const record = fold(events).find((candidate) => candidate.id === positionals[0]);
1574
+ if (!record) fail(`unknown intent id: ${positionals[0]}`);
1575
+ if (!record.reclaimed) fail(`intent ${positionals[0]} is not reclaimed`);
1576
+ events.push(
1577
+ appendEvent({
1578
+ type: 'unreclaim',
1579
+ id: record.id,
1580
+ ts: new Date().toISOString(),
1581
+ reason,
1582
+ })
1583
+ );
1584
+ const restored = fold(events).find((candidate) => candidate.id === record.id);
1585
+ printLine(`${record.id} unreclaimed`);
1586
+ return publicIntent(restored);
1355
1587
  },
1356
1588
 
1357
1589
  decision(argv) {
@@ -1392,8 +1624,9 @@ const commands = {
1392
1624
  });
1393
1625
  ensureDirectoryDurable(decisionDir());
1394
1626
  atomicCreateFile(path.join(decisionDir(), file), content);
1395
- console.log(path.join(decisionDir(), file));
1396
- return;
1627
+ const decision = findDecision(String(id));
1628
+ printLine(path.join(decisionDir(), file));
1629
+ return publicDecision(decision, { includeContent: true });
1397
1630
  }
1398
1631
 
1399
1632
  if (subcommand === 'update') {
@@ -1431,8 +1664,9 @@ const commands = {
1431
1664
  fail('simulated interruption after decision write');
1432
1665
  }
1433
1666
  appendEvent(reconciliationEvent('decision_reconcile_commit', update));
1434
- console.log(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
1435
- return;
1667
+ const reconciled = findDecision(decision.id);
1668
+ printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
1669
+ return publicDecision(reconciled, { includeContent: true });
1436
1670
  }
1437
1671
 
1438
1672
  if (subcommand === 'list') {
@@ -1448,8 +1682,8 @@ const commands = {
1448
1682
  }
1449
1683
  const index = decisionIndex();
1450
1684
  if (flags.count && !status) {
1451
- console.log(index.length);
1452
- return;
1685
+ printLine(index.length);
1686
+ return { count: index.length };
1453
1687
  }
1454
1688
  let records = decisionCatalog(!status && last ? index.slice(-last) : index);
1455
1689
  if (status) {
@@ -1457,19 +1691,19 @@ const commands = {
1457
1691
  }
1458
1692
  if (status && last) records = records.slice(-last);
1459
1693
  if (flags.count) {
1460
- console.log(records.length);
1461
- return;
1694
+ printLine(records.length);
1695
+ return { count: records.length };
1462
1696
  }
1463
1697
  if (records.length === 0) {
1464
- console.log(status ? `no decision records with status ${status}` : 'decision log is empty');
1465
- return;
1698
+ printLine(status ? `no decision records with status ${status}` : 'decision log is empty');
1699
+ return [];
1466
1700
  }
1467
- console.log(
1701
+ printLine(
1468
1702
  records
1469
1703
  .map((record) => `[${record.id}] ${titleCase(record.status)} — ${record.title}\n ${record.file}`)
1470
1704
  .join('\n')
1471
1705
  );
1472
- return;
1706
+ return records.map(publicDecision);
1473
1707
  }
1474
1708
 
1475
1709
  if (subcommand === 'show') {
@@ -1478,8 +1712,8 @@ const commands = {
1478
1712
  fail('usage: driftseal decision show <id>');
1479
1713
  }
1480
1714
  const decision = findDecision(positionals[0]);
1481
- process.stdout.write(decision.content);
1482
- return;
1715
+ writeOutput(decision.content);
1716
+ return publicDecision(decision, { includeContent: true });
1483
1717
  }
1484
1718
 
1485
1719
  fail('usage: driftseal decision add|update|list|show (run: driftseal help)');
@@ -1505,6 +1739,8 @@ const commands = {
1505
1739
  protocolEol(previousIntentProtocolBlock(2), eol),
1506
1740
  protocolEol(previousIntentProtocolBlock(3), eol),
1507
1741
  protocolEol(previousIntentProtocolBlock(4), eol),
1742
+ protocolEol(previousIntentProtocolBlock(5), eol),
1743
+ protocolEol(previousIntentProtocolBlock(6), eol),
1508
1744
  ],
1509
1745
  knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
1510
1746
  });
@@ -1516,9 +1752,11 @@ const commands = {
1516
1752
  versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
1517
1753
  replacement: decisionBlock,
1518
1754
  knownManagedBlocks: [
1519
- protocolEol(decisionProtocolBlock(2), eol),
1520
- protocolEol(decisionProtocolBlock(3), eol),
1521
- protocolEol(decisionProtocolBlock(4), eol),
1755
+ protocolEol(previousDecisionProtocolBlock(2), eol),
1756
+ protocolEol(previousDecisionProtocolBlock(3), eol),
1757
+ protocolEol(previousDecisionProtocolBlock(4), eol),
1758
+ protocolEol(previousDecisionProtocolBlock(5), eol),
1759
+ protocolEol(previousDecisionProtocolBlock(6), eol),
1522
1760
  ],
1523
1761
  knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
1524
1762
  });
@@ -1539,15 +1777,16 @@ const commands = {
1539
1777
  }
1540
1778
 
1541
1779
  if (updated === current) {
1542
- console.log('AGENTS.md already contains the DriftSeal protocols; nothing to do');
1543
- return;
1780
+ printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
1781
+ return { changed: false, target };
1544
1782
  }
1545
1783
  atomicWriteFile(target, updated);
1546
- console.log(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
1784
+ printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
1785
+ return { changed: true, target };
1547
1786
  },
1548
1787
 
1549
1788
  help() {
1550
- console.log(`DriftSeal — Seal the intent. Stop the drift.
1789
+ printLine(`DriftSeal — Seal the intent. Stop the drift.
1551
1790
 
1552
1791
  Intent-level write-ahead log for agent sessions.
1553
1792
 
@@ -1555,7 +1794,11 @@ usage:
1555
1794
  driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]
1556
1795
  driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
1557
1796
  driftseal status show the intent currently in progress (re-anchor after drift)
1558
- driftseal log [--last N] show intent history
1797
+ driftseal log [--last N] [--all] show intent history (--all includes reclaimed records)
1798
+ driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]
1799
+ hide meaningless closed records without deleting them
1800
+ driftseal unreclaim <id> --reason "<why>"
1801
+ restore a reclaimed record to the visible log
1559
1802
  driftseal decision add "<title>" --context "..." --outcome "..." [options]
1560
1803
  driftseal decision update <id> [--status STATUS] --note "..."
1561
1804
  reconcile a linked decision in the open intent
@@ -1573,6 +1816,7 @@ decision add options:
1573
1816
 
1574
1817
  intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
1575
1818
  decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory`);
1819
+ return null;
1576
1820
  },
1577
1821
  };
1578
1822
 
@@ -1586,6 +1830,7 @@ function requestedEndStatus(argv) {
1586
1830
 
1587
1831
  function mutationResources(cmd, argv) {
1588
1832
  if (cmd === 'init') return [process.cwd()];
1833
+ if (cmd === 'reclaim' || cmd === 'unreclaim') return [logDir()];
1589
1834
  if (cmd === 'begin' && !argv.some((arg) => arg === '--decision' || arg.startsWith('--decision='))) {
1590
1835
  return [logDir()];
1591
1836
  }
@@ -1595,24 +1840,168 @@ function mutationResources(cmd, argv) {
1595
1840
  return [logDir(), decisionDir()];
1596
1841
  }
1597
1842
 
1598
- function main() {
1599
- const [cmd, ...rest] = process.argv.slice(2);
1843
+ function dispatch(argv) {
1844
+ const [cmd, ...rest] = argv;
1600
1845
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1601
- commands.help();
1602
- process.exit(cmd ? 0 : 1);
1846
+ return { data: commands.help(), exitCode: cmd ? 0 : 1 };
1603
1847
  }
1604
1848
  const fn = commands[cmd];
1605
1849
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
1606
1850
  const mutates =
1607
- ['begin', 'end', 'init'].includes(cmd) ||
1851
+ ['begin', 'end', 'init', 'reclaim', 'unreclaim'].includes(cmd) ||
1608
1852
  (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
1609
1853
  const readsIntentLog = ['status', 'log'].includes(cmd);
1610
1854
  if (mutates || readsIntentLog) {
1611
1855
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
1612
- withMutationLocks(resources, () => fn(rest));
1613
- } else {
1614
- fn(rest);
1856
+ return { data: withMutationLocks(resources, () => fn(rest)), exitCode: 0 };
1615
1857
  }
1858
+ return { data: fn(rest), exitCode: 0 };
1616
1859
  }
1617
1860
 
1618
- main();
1861
+ function repositoryRoot(root) {
1862
+ if (typeof root !== 'string' || root.trim().length === 0) {
1863
+ fail('repository root must be a non-empty path');
1864
+ }
1865
+ const resolved = path.resolve(root);
1866
+ let stat;
1867
+ try {
1868
+ stat = fs.statSync(resolved);
1869
+ } catch {
1870
+ fail(`repository root does not exist: ${resolved}`);
1871
+ }
1872
+ if (!stat.isDirectory()) fail(`repository root is not a directory: ${resolved}`);
1873
+ return fs.realpathSync(resolved);
1874
+ }
1875
+
1876
+ function runCommand(argv, { root = process.cwd(), isolateStorage = false, capture = true } = {}) {
1877
+ if (!Array.isArray(argv) || argv.some((arg) => typeof arg !== 'string')) {
1878
+ fail('command arguments must be an array of strings');
1879
+ }
1880
+ if (capture && activeOutput) fail('nested captured DriftSeal commands are not supported');
1881
+
1882
+ const fixedRoot = repositoryRoot(root);
1883
+ const previousCwd = process.cwd();
1884
+ const previousIntentHome = process.env.DRIFTSEAL_HOME;
1885
+ const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
1886
+ const output = { stdout: '', stderr: '', data: null, exitCode: 0 };
1887
+ const previousOutput = activeOutput;
1888
+
1889
+ try {
1890
+ process.chdir(fixedRoot);
1891
+ if (isolateStorage) {
1892
+ delete process.env.DRIFTSEAL_HOME;
1893
+ delete process.env.DRIFTSEAL_DECISION_HOME;
1894
+ }
1895
+ if (capture) activeOutput = output;
1896
+ const result = dispatch(argv);
1897
+ output.data = result.data;
1898
+ output.exitCode = result.exitCode;
1899
+ return output;
1900
+ } catch (err) {
1901
+ if (capture) {
1902
+ err.stdout = output.stdout;
1903
+ err.stderr = output.stderr;
1904
+ }
1905
+ throw err;
1906
+ } finally {
1907
+ activeOutput = previousOutput;
1908
+ process.chdir(previousCwd);
1909
+ if (previousIntentHome === undefined) delete process.env.DRIFTSEAL_HOME;
1910
+ else process.env.DRIFTSEAL_HOME = previousIntentHome;
1911
+ if (previousDecisionHome === undefined) delete process.env.DRIFTSEAL_DECISION_HOME;
1912
+ else process.env.DRIFTSEAL_DECISION_HOME = previousDecisionHome;
1913
+ }
1914
+ }
1915
+
1916
+ function appendFlag(argv, flag, value) {
1917
+ if (value !== undefined && value !== null && value !== '') argv.push(flag, String(value));
1918
+ }
1919
+
1920
+ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
1921
+ const fixedRoot = repositoryRoot(root);
1922
+ const call = (argv) => runCommand(argv, { root: fixedRoot, isolateStorage, capture: true }).data;
1923
+ return Object.freeze({
1924
+ root: fixedRoot,
1925
+ status() {
1926
+ return call(['status']);
1927
+ },
1928
+ begin({ intent, verify, decisions = [], force = false }) {
1929
+ const argv = ['begin', intent];
1930
+ appendFlag(argv, '--verify', verify);
1931
+ for (const decision of decisions) appendFlag(argv, '--decision', decision);
1932
+ if (force) argv.push('--force');
1933
+ return call(argv);
1934
+ },
1935
+ end({ id, status, note, verifyResult } = {}) {
1936
+ const argv = ['end'];
1937
+ if (id) argv.push(String(id));
1938
+ appendFlag(argv, '--status', status);
1939
+ appendFlag(argv, '--note', note);
1940
+ appendFlag(argv, '--verify-result', verifyResult);
1941
+ return call(argv);
1942
+ },
1943
+ log({ last, all = false } = {}) {
1944
+ const argv = ['log'];
1945
+ appendFlag(argv, '--last', last);
1946
+ if (all) argv.push('--all');
1947
+ return call(argv);
1948
+ },
1949
+ reclaim({ ids = [], reason, olderThan, force = false, dryRun = false }) {
1950
+ const argv = ['reclaim', ...ids.map(String), '--reason', reason];
1951
+ appendFlag(argv, '--older-than', olderThan);
1952
+ if (force) argv.push('--force');
1953
+ if (dryRun) argv.push('--dry-run');
1954
+ return call(argv);
1955
+ },
1956
+ unreclaim({ id, reason }) {
1957
+ return call(['unreclaim', String(id), '--reason', reason]);
1958
+ },
1959
+ decisionAdd({ title, context, outcome, status, drivers = [], options = [], consequences = [] }) {
1960
+ const argv = ['decision', 'add', title, '--context', context, '--outcome', outcome];
1961
+ appendFlag(argv, '--status', status);
1962
+ for (const driver of drivers) appendFlag(argv, '--driver', driver);
1963
+ for (const option of options) appendFlag(argv, '--option', option);
1964
+ for (const consequence of consequences) appendFlag(argv, '--consequence', consequence);
1965
+ return call(argv);
1966
+ },
1967
+ decisionUpdate({ id, status, note }) {
1968
+ const argv = ['decision', 'update', String(id), '--note', note];
1969
+ appendFlag(argv, '--status', status);
1970
+ return call(argv);
1971
+ },
1972
+ decisionList({ status, last, count = false } = {}) {
1973
+ const argv = ['decision', 'list'];
1974
+ appendFlag(argv, '--status', status);
1975
+ appendFlag(argv, '--last', last);
1976
+ if (count) argv.push('--count');
1977
+ return call(argv);
1978
+ },
1979
+ decisionShow({ id }) {
1980
+ return call(['decision', 'show', String(id)]);
1981
+ },
1982
+ init() {
1983
+ return call(['init']);
1984
+ },
1985
+ });
1986
+ }
1987
+
1988
+ function main() {
1989
+ try {
1990
+ const result = dispatch(process.argv.slice(2));
1991
+ process.exitCode = result.exitCode;
1992
+ } catch (err) {
1993
+ const message = err && err.message ? err.message : String(err);
1994
+ console.error(`driftseal: error: ${message}`);
1995
+ process.exitCode = 1;
1996
+ }
1997
+ }
1998
+
1999
+ module.exports = {
2000
+ DECISION_STATUSES,
2001
+ END_STATUSES,
2002
+ DriftSealError,
2003
+ createApi,
2004
+ runCommand,
2005
+ };
2006
+
2007
+ if (require.main === module) main();