@basou/cli 0.7.0 → 0.9.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/dist/program.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  linkYamlFile,
18
18
  loadApproval,
19
19
  prefixedUlid,
20
+ readSessionYaml,
20
21
  readYamlFile,
21
22
  replayEvents,
22
23
  resolveRepositoryRoot
@@ -331,6 +332,15 @@ async function doRunApprovalResolve(idInput, options, ctx, decision) {
331
332
  if (isLazyExpired(approval, now)) {
332
333
  throw new Error(`Approval already expired: ${idInput}`);
333
334
  }
335
+ let sessionStatus = null;
336
+ try {
337
+ sessionStatus = (await readSessionYaml(paths, approval.session_id)).session.status;
338
+ } catch {
339
+ sessionStatus = null;
340
+ }
341
+ if (sessionStatus === "imported") {
342
+ throw new Error(`Cannot resolve an approval for an imported session: ${idInput}`);
343
+ }
334
344
  const occurredAt = now.toISOString();
335
345
  const eventId = prefixedUlid("evt");
336
346
  if (decision === "approve") {
@@ -1342,7 +1352,8 @@ import {
1342
1352
  findErrorCode as findErrorCode5,
1343
1353
  importSessionFromJson,
1344
1354
  readManifest as readManifest3,
1345
- readSessionYaml,
1355
+ readSessionYaml as readSessionYaml2,
1356
+ reimportPreservingId,
1346
1357
  resolveRepositoryRoot as resolveRepositoryRoot6,
1347
1358
  SessionImportPayloadSchema
1348
1359
  } from "@basou/core";
@@ -1418,10 +1429,15 @@ async function doRunImportClaudeCode(options, ctx) {
1418
1429
  const externalId = basename(file, ".jsonl");
1419
1430
  return {
1420
1431
  externalId,
1421
- toPayload: async () => claudeTranscriptToImportPayload(await readJsonlRecords(file), {
1422
- workspaceId: manifest.workspace.id,
1423
- externalId
1424
- })
1432
+ sourcePath: file,
1433
+ toPayload: async () => {
1434
+ const { records, sizeBytes } = await readJsonlRecords(file);
1435
+ return claudeTranscriptToImportPayload(records, {
1436
+ workspaceId: manifest.workspace.id,
1437
+ externalId,
1438
+ sourceSizeBytes: sizeBytes
1439
+ });
1440
+ }
1425
1441
  };
1426
1442
  });
1427
1443
  await importDerivedSessions(paths, manifest, options, CLAUDE_IMPORT_SOURCE, candidates);
@@ -1439,10 +1455,15 @@ async function doRunImportCodex(options, ctx) {
1439
1455
  const rollouts = await discoverCodexRollouts(sessionsRoot, projectPaths, options);
1440
1456
  const candidates = rollouts.map(({ file, externalId }) => ({
1441
1457
  externalId,
1442
- toPayload: async () => codexRolloutToImportPayload(await readJsonlRecords(file), {
1443
- workspaceId: manifest.workspace.id,
1444
- externalId
1445
- })
1458
+ sourcePath: file,
1459
+ toPayload: async () => {
1460
+ const { records, sizeBytes } = await readJsonlRecords(file);
1461
+ return codexRolloutToImportPayload(records, {
1462
+ workspaceId: manifest.workspace.id,
1463
+ externalId,
1464
+ sourceSizeBytes: sizeBytes
1465
+ });
1466
+ }
1446
1467
  }));
1447
1468
  await importDerivedSessions(paths, manifest, options, CODEX_IMPORT_SOURCE, candidates);
1448
1469
  }
@@ -1466,41 +1487,76 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
1466
1487
  const existingByExternalId = await loadExistingByExternalId(paths, sourceKind);
1467
1488
  const seenThisRun = /* @__PURE__ */ new Set();
1468
1489
  const results = [];
1469
- let skippedNoAction = 0;
1470
- let skippedExisting = 0;
1471
- let replaced = 0;
1490
+ const counts = {
1491
+ skippedNoAction: 0,
1492
+ skippedExisting: 0,
1493
+ replaced: 0,
1494
+ reimported: 0,
1495
+ skippedLegacy: 0,
1496
+ skippedDecreased: 0,
1497
+ skippedDuplicate: 0
1498
+ };
1472
1499
  let sanitizedPaths = 0;
1473
- for (const { externalId, toPayload } of candidates) {
1500
+ const validate = (payload) => {
1501
+ if (payload === null) return null;
1502
+ const parsed = SessionImportPayloadSchema.safeParse(payload);
1503
+ if (!parsed.success) {
1504
+ throw new Error("Invalid import payload", { cause: parsed.error });
1505
+ }
1506
+ if (parsed.data.schema_version !== "0.1.0") {
1507
+ throw new Error(`Unsupported import schema_version: ${parsed.data.schema_version}`);
1508
+ }
1509
+ return parsed.data;
1510
+ };
1511
+ for (const { externalId, sourcePath, toPayload } of candidates) {
1474
1512
  if (seenThisRun.has(externalId)) {
1475
- skippedExisting++;
1513
+ counts.skippedExisting++;
1476
1514
  continue;
1477
1515
  }
1478
- const priorSessionIds = existingByExternalId.get(externalId) ?? [];
1479
- if (priorSessionIds.length > 0 && options.force !== true) {
1480
- skippedExisting++;
1516
+ const priors = existingByExternalId.get(externalId) ?? [];
1517
+ if (priors.length > 0 && options.force !== true) {
1518
+ const prior = await classifyReimport(priors, sourcePath, externalId, counts);
1519
+ if (prior === null) continue;
1520
+ const payload2 = validate(await toPayload());
1521
+ if (payload2 === null) {
1522
+ counts.skippedNoAction++;
1523
+ continue;
1524
+ }
1525
+ const readSize = payload2.session.source.source_size_bytes;
1526
+ if (prior.sourceSizeBytes !== void 0 && readSize !== void 0 && readSize <= prior.sourceSizeBytes) {
1527
+ console.error(
1528
+ `Import: ${externalId} source changed during read (now ${readSize} <= ${prior.sourceSizeBytes} bytes); re-import skipped`
1529
+ );
1530
+ counts.skippedDecreased++;
1531
+ continue;
1532
+ }
1533
+ const outcome = await reimportPreservingId(paths, manifest, prior.sessionId, payload2, {
1534
+ dryRun: options.dryRun === true
1535
+ });
1536
+ if (outcome.status === "skipped") {
1537
+ const detail = outcome.reason === "prior_events_unreadable" ? "prior events.jsonl has unreadable lines" : outcome.reason === "prior_chain_broken" ? "prior events.jsonl failed hash-chain verification (run 'basou verify')" : "source changed in a non-append way (derived events would be dropped)";
1538
+ console.error(`Import: ${externalId} ${detail}; re-import skipped`);
1539
+ counts.skippedNoAction++;
1540
+ continue;
1541
+ }
1542
+ counts.reimported++;
1543
+ seenThisRun.add(externalId);
1481
1544
  continue;
1482
1545
  }
1483
- const payload = await toPayload();
1546
+ const payload = validate(await toPayload());
1484
1547
  if (payload === null) {
1485
- skippedNoAction++;
1548
+ counts.skippedNoAction++;
1486
1549
  continue;
1487
1550
  }
1488
- const parsed = SessionImportPayloadSchema.safeParse(payload);
1489
- if (!parsed.success) {
1490
- throw new Error("Invalid import payload", { cause: parsed.error });
1491
- }
1492
- if (parsed.data.schema_version !== "0.1.0") {
1493
- throw new Error(`Unsupported import schema_version: ${parsed.data.schema_version}`);
1494
- }
1495
- if (priorSessionIds.length > 0 && options.force === true) {
1551
+ if (priors.length > 0 && options.force === true) {
1496
1552
  if (options.dryRun !== true) {
1497
- for (const sid of priorSessionIds) {
1498
- await rm(join3(paths.sessions, sid), { recursive: true, force: true });
1553
+ for (const { sessionId } of priors) {
1554
+ await rm(join3(paths.sessions, sessionId), { recursive: true, force: true });
1499
1555
  }
1500
1556
  }
1501
- replaced++;
1557
+ counts.replaced++;
1502
1558
  }
1503
- const result = await importSessionFromJson(paths, manifest, parsed.data, {
1559
+ const result = await importSessionFromJson(paths, manifest, payload, {
1504
1560
  dryRun: options.dryRun === true
1505
1561
  });
1506
1562
  results.push(result);
@@ -1510,17 +1566,52 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
1510
1566
  if (sanitizedPaths > 0) {
1511
1567
  console.error(`Imported sessions: ${sanitizedPaths} path(s) sanitized`);
1512
1568
  }
1513
- printImportResult(options, results, { skippedNoAction, skippedExisting, replaced });
1569
+ printImportResult(options, results, counts);
1570
+ }
1571
+ async function classifyReimport(priors, sourcePath, externalId, counts) {
1572
+ if (priors.length > 1) {
1573
+ console.error(
1574
+ `Import: ${externalId} has ${priors.length} prior sessions; re-import skipped (use --force)`
1575
+ );
1576
+ counts.skippedDuplicate++;
1577
+ return null;
1578
+ }
1579
+ const prior = priors[0];
1580
+ if (prior === void 0) {
1581
+ counts.skippedExisting++;
1582
+ return null;
1583
+ }
1584
+ const currentSize = await statSize(sourcePath);
1585
+ if (currentSize === void 0) {
1586
+ counts.skippedExisting++;
1587
+ return null;
1588
+ }
1589
+ if (prior.sourceSizeBytes === void 0) {
1590
+ counts.skippedLegacy++;
1591
+ return null;
1592
+ }
1593
+ if (currentSize === prior.sourceSizeBytes) {
1594
+ counts.skippedExisting++;
1595
+ return null;
1596
+ }
1597
+ if (currentSize < prior.sourceSizeBytes) {
1598
+ console.error(
1599
+ `Import: ${externalId} source shrank (${currentSize} < ${prior.sourceSizeBytes} bytes); re-import skipped (use --force to replace)`
1600
+ );
1601
+ counts.skippedDecreased++;
1602
+ return null;
1603
+ }
1604
+ return prior;
1514
1605
  }
1515
1606
  function encodeProjectDir(projectPath) {
1516
1607
  return projectPath.replaceAll("/", "-");
1517
1608
  }
1518
1609
  async function loadExistingByExternalId(paths, sourceKind) {
1519
1610
  const byExternalId = /* @__PURE__ */ new Map();
1520
- const add = (externalId, sessionId) => {
1611
+ const add = (externalId, prior) => {
1521
1612
  const list = byExternalId.get(externalId);
1522
- if (list === void 0) byExternalId.set(externalId, [sessionId]);
1523
- else list.push(sessionId);
1613
+ if (list === void 0) byExternalId.set(externalId, [prior]);
1614
+ else list.push(prior);
1524
1615
  };
1525
1616
  let sessionIds;
1526
1617
  try {
@@ -1531,19 +1622,21 @@ async function loadExistingByExternalId(paths, sourceKind) {
1531
1622
  for (const sessionId of sessionIds) {
1532
1623
  let session;
1533
1624
  try {
1534
- session = await readSessionYaml(paths, sessionId);
1625
+ session = await readSessionYaml2(paths, sessionId);
1535
1626
  } catch {
1536
1627
  continue;
1537
1628
  }
1538
1629
  if (session.session.source.kind !== sourceKind) continue;
1630
+ const sourceSizeBytes = session.session.source.source_size_bytes;
1631
+ const prior = sourceSizeBytes !== void 0 ? { sessionId, sourceSizeBytes } : { sessionId };
1539
1632
  const ext = session.session.source.external_id;
1540
1633
  if (typeof ext === "string" && ext.length > 0) {
1541
- add(ext, sessionId);
1634
+ add(ext, prior);
1542
1635
  continue;
1543
1636
  }
1544
1637
  const label = session.session.label;
1545
1638
  const match = typeof label === "string" ? label.match(/^claude-code import (\S+)$/) : null;
1546
- if (match?.[1] !== void 0) add(match[1], sessionId);
1639
+ if (match?.[1] !== void 0) add(match[1], prior);
1547
1640
  }
1548
1641
  return byExternalId;
1549
1642
  }
@@ -1589,6 +1682,14 @@ async function pathExists(file) {
1589
1682
  throw error;
1590
1683
  }
1591
1684
  }
1685
+ async function statSize(file) {
1686
+ try {
1687
+ return (await stat(file)).size;
1688
+ } catch (error) {
1689
+ if (findErrorCode5(error, "ENOENT")) return void 0;
1690
+ throw error;
1691
+ }
1692
+ }
1592
1693
  async function discoverCodexRollouts(sessionsRoot, projectPaths, options) {
1593
1694
  const projectSet = new Set(projectPaths);
1594
1695
  const files = await findRolloutFiles(sessionsRoot);
@@ -1667,9 +1768,9 @@ async function readFirstLine(file) {
1667
1768
  }
1668
1769
  }
1669
1770
  async function readJsonlRecords(file) {
1670
- let body;
1771
+ let buffer;
1671
1772
  try {
1672
- body = await readFile(file, "utf8");
1773
+ buffer = await readFile(file);
1673
1774
  } catch (error) {
1674
1775
  if (findErrorCode5(error, "ENOENT")) {
1675
1776
  throw new Error("Source log not found", { cause: error });
@@ -1680,7 +1781,7 @@ async function readJsonlRecords(file) {
1680
1781
  throw new Error("Failed to read source log", { cause: error });
1681
1782
  }
1682
1783
  const records = [];
1683
- for (const line of body.split("\n")) {
1784
+ for (const line of buffer.toString("utf8").split("\n")) {
1684
1785
  const trimmed = line.trim();
1685
1786
  if (trimmed.length === 0) continue;
1686
1787
  try {
@@ -1691,7 +1792,7 @@ async function readJsonlRecords(file) {
1691
1792
  } catch {
1692
1793
  }
1693
1794
  }
1694
- return records;
1795
+ return { records, sizeBytes: buffer.length };
1695
1796
  }
1696
1797
  function isObject(value) {
1697
1798
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1699,7 +1800,15 @@ function isObject(value) {
1699
1800
  function printImportResult(options, results, counts) {
1700
1801
  const isDry = options.dryRun === true;
1701
1802
  const eventTotal = results.reduce((sum, r) => sum + r.eventCount, 0);
1702
- const { skippedNoAction, skippedExisting, replaced } = counts;
1803
+ const {
1804
+ skippedNoAction,
1805
+ skippedExisting,
1806
+ replaced,
1807
+ reimported,
1808
+ skippedLegacy,
1809
+ skippedDecreased,
1810
+ skippedDuplicate
1811
+ } = counts;
1703
1812
  if (options.json === true) {
1704
1813
  console.log(
1705
1814
  JSON.stringify({
@@ -1711,8 +1820,12 @@ function printImportResult(options, results, counts) {
1711
1820
  })),
1712
1821
  imported_count: results.length,
1713
1822
  replaced_count: replaced,
1823
+ reimported_count: reimported,
1714
1824
  skipped_no_action: skippedNoAction,
1715
1825
  skipped_already_imported: skippedExisting,
1826
+ skipped_legacy_untracked: skippedLegacy,
1827
+ skipped_decreased: skippedDecreased,
1828
+ skipped_duplicate: skippedDuplicate,
1716
1829
  event_total: eventTotal,
1717
1830
  dry_run: isDry
1718
1831
  })
@@ -1722,20 +1835,36 @@ function printImportResult(options, results, counts) {
1722
1835
  const skipParts = [];
1723
1836
  if (skippedNoAction > 0) skipParts.push(`${skippedNoAction} with no actions`);
1724
1837
  if (skippedExisting > 0) skipParts.push(`${skippedExisting} already imported`);
1838
+ if (skippedLegacy > 0) skipParts.push(`${skippedLegacy} legacy (untracked size)`);
1839
+ if (skippedDecreased > 0) skipParts.push(`${skippedDecreased} shrank`);
1840
+ if (skippedDuplicate > 0) skipParts.push(`${skippedDuplicate} duplicated`);
1725
1841
  const skipSuffix = skipParts.length > 0 ? `; skipped ${skipParts.join(", ")}` : "";
1726
1842
  const eventsPart = replaced > 0 ? `${eventTotal} events, ${replaced} replaced` : `${eventTotal} events`;
1727
- if (results.length === 0) {
1843
+ if (isDry) {
1844
+ const parts = [];
1845
+ if (results.length > 0) parts.push(`import ${results.length} session(s) (${eventsPart})`);
1846
+ if (reimported > 0) parts.push(`re-import ${reimported} changed session(s)`);
1847
+ const head = parts.length > 0 ? `Dry run: would ${parts.join(", ")}` : "Dry run: no changes";
1848
+ console.log(`${head}${skipSuffix}`);
1849
+ return;
1850
+ }
1851
+ if (results.length === 0 && reimported === 0) {
1728
1852
  console.log(
1729
1853
  skipParts.length > 0 ? `No new sessions imported (skipped ${skipParts.join(", ")})` : "No transcripts found to import"
1730
1854
  );
1731
1855
  return;
1732
1856
  }
1733
- if (isDry) {
1734
- console.log(`Dry run: would import ${results.length} session(s) (${eventsPart})${skipSuffix}`);
1735
- return;
1857
+ const segments = [];
1858
+ if (results.length > 0) {
1859
+ const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
1860
+ segments.push(`Imported ${results.length} session(s)${single} (${eventsPart})`);
1736
1861
  }
1737
- const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
1738
- console.log(`Imported ${results.length} session(s)${single} (${eventsPart})${skipSuffix}`);
1862
+ if (reimported > 0) {
1863
+ segments.push(
1864
+ `${results.length > 0 ? "re-imported" : "Re-imported"} ${reimported} changed session(s)`
1865
+ );
1866
+ }
1867
+ console.log(`${segments.join(", ")}${skipSuffix}`);
1739
1868
  }
1740
1869
  function shortId2(id) {
1741
1870
  if (id.startsWith(SES_PREFIX2)) {
@@ -1908,8 +2037,10 @@ async function runImport(adapter, fn) {
1908
2037
  status: "ran",
1909
2038
  importedCount: readCount(json.imported_count),
1910
2039
  replacedCount: readCount(json.replaced_count),
2040
+ reimportedCount: readCount(json.reimported_count),
1911
2041
  skippedNoAction: readCount(json.skipped_no_action),
1912
2042
  skippedAlreadyImported: readCount(json.skipped_already_imported),
2043
+ skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
1913
2044
  eventTotal: readCount(json.event_total),
1914
2045
  dryRun: json.dry_run === true
1915
2046
  };
@@ -2030,11 +2161,13 @@ function scansEqual(a, b) {
2030
2161
  }
2031
2162
  return true;
2032
2163
  }
2033
- function importedCount(outcome) {
2034
- return outcome.status === "ran" ? outcome.importedCount : 0;
2164
+ function changedCount(outcome) {
2165
+ return outcome.status === "ran" ? outcome.importedCount + outcome.reimportedCount + outcome.replacedCount : 0;
2035
2166
  }
2036
2167
  function describeOutcome(outcome) {
2037
- return outcome.status === "ran" ? `${outcome.adapter} +${outcome.importedCount}` : `${outcome.adapter} skipped`;
2168
+ if (outcome.status !== "ran") return `${outcome.adapter} skipped`;
2169
+ const reimported = outcome.reimportedCount > 0 ? ` ~${outcome.reimportedCount}` : "";
2170
+ return `${outcome.adapter} +${outcome.importedCount}${reimported}`;
2038
2171
  }
2039
2172
  function hms(date) {
2040
2173
  return date.toISOString().slice(11, 19);
@@ -2042,7 +2175,7 @@ function hms(date) {
2042
2175
  async function runImports(deps) {
2043
2176
  const claude = await importClaudeCode(deps.importOptions, deps.ctx);
2044
2177
  const codex = await importCodex(deps.importOptions, deps.ctx);
2045
- return { claude, codex, imported: importedCount(claude) + importedCount(codex) };
2178
+ return { claude, codex, changed: changedCount(claude) + changedCount(codex) };
2046
2179
  }
2047
2180
  async function regenerate(deps) {
2048
2181
  const nowIso = deps.now().toISOString();
@@ -2074,8 +2207,8 @@ async function runRefreshWatch(deps) {
2074
2207
  try {
2075
2208
  const current = await scanSourceLogs(roots);
2076
2209
  if (scansEqual(current, lastScan) && !scansEqual(current, importedScan)) {
2077
- const { claude, codex, imported } = await runImports(deps);
2078
- if (imported > 0) pendingRegen = true;
2210
+ const { claude, codex, changed } = await runImports(deps);
2211
+ if (changed > 0) pendingRegen = true;
2079
2212
  if (pendingRegen) {
2080
2213
  const sessions = await regenerate(deps);
2081
2214
  pendingRegen = false;
@@ -2214,9 +2347,11 @@ function describeImport(outcome) {
2214
2347
  }
2215
2348
  const verb = outcome.dryRun ? "would import" : "imported";
2216
2349
  const parts = [`${outcome.importedCount} session(s)`, `${outcome.eventTotal} events`];
2350
+ if (outcome.reimportedCount > 0) parts.push(`${outcome.reimportedCount} re-imported`);
2217
2351
  if (outcome.replacedCount > 0) parts.push(`${outcome.replacedCount} replaced`);
2218
2352
  if (outcome.skippedAlreadyImported > 0)
2219
2353
  parts.push(`${outcome.skippedAlreadyImported} already imported`);
2354
+ if (outcome.skippedLegacyUntracked > 0) parts.push(`${outcome.skippedLegacyUntracked} legacy`);
2220
2355
  return `${outcome.adapter}: ${verb} ${parts.join(", ")}`;
2221
2356
  }
2222
2357
  function printRefreshSummary(result) {
@@ -2660,12 +2795,14 @@ import {
2660
2795
  appendEventToExistingSession as appendEventToExistingSession2,
2661
2796
  assertBasouRootSafe as assertBasouRootSafe9,
2662
2797
  basouPaths as basouPaths9,
2798
+ enumerateSessionDirs as enumerateSessionDirs2,
2663
2799
  findErrorCode as findErrorCode8,
2664
2800
  importSessionFromJson as importSessionFromJson2,
2665
2801
  loadSessionEntries,
2666
2802
  readAllEvents,
2667
2803
  readManifest as readManifest5,
2668
2804
  readYamlFile as readYamlFile4,
2805
+ rechainSessionInPlace,
2669
2806
  resolveRepositoryRoot as resolveRepositoryRoot10,
2670
2807
  resolveSessionId as resolveSessionId2,
2671
2808
  resolveTaskId,
@@ -2714,6 +2851,11 @@ function registerSessionCommand(program) {
2714
2851
  session.command("note <session_id>").description("Append a note_added event to an existing session").option("--body <text>", "Note body (inline)", parseNoteBodyOption).option("--from-file <path>", "Read note body from a file").option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (sessionIdInput, options) => {
2715
2852
  await runSessionNote(sessionIdInput, options);
2716
2853
  });
2854
+ session.command("rechain").description(
2855
+ "Add the tamper-evidence hash chain, in place, to imported sessions created before chaining existed"
2856
+ ).option("--session <id>", "Rechain a single session (unique id prefix accepted)").option("--all", "Rechain every session in the workspace").option("--dry-run", "Compute the outcomes only; do not write").option("--json", "Output the outcomes as JSON").option("-v, --verbose", "Show error causes").action(async (options) => {
2857
+ await runSessionRechain(options);
2858
+ });
2717
2859
  }
2718
2860
  async function runSessionList(options, ctx = {}) {
2719
2861
  try {
@@ -3255,6 +3397,74 @@ function printSessionNoteResult(options, sessionId, eventId, sessionStatus, body
3255
3397
  const preview = body.length > NOTE_BODY_PREVIEW_LIMIT ? `${body.slice(0, NOTE_BODY_PREVIEW_HEAD)}...` : body;
3256
3398
  console.log(`Added note to session ${sid} (${sessionStatus}): ${preview}`);
3257
3399
  }
3400
+ async function runSessionRechain(options, ctx = {}) {
3401
+ try {
3402
+ await doRunSessionRechain(options, ctx);
3403
+ } catch (error) {
3404
+ renderCliError(error, { verbose: isVerbose(options) });
3405
+ process.exitCode = 1;
3406
+ }
3407
+ }
3408
+ async function doRunSessionRechain(options, ctx) {
3409
+ if (options.session !== void 0 && options.all === true) {
3410
+ throw new Error("Specify either --session <id> or --all, not both");
3411
+ }
3412
+ if (options.session === void 0 && options.all !== true) {
3413
+ throw new Error("Specify --session <id> or --all");
3414
+ }
3415
+ const cwd = ctx.cwd ?? process.cwd();
3416
+ const repositoryRoot = await resolveRepositoryRootForSession(cwd, "rechain");
3417
+ const paths = basouPaths9(repositoryRoot);
3418
+ await assertWorkspaceInitialized7(paths.root);
3419
+ const sessionIds = options.session !== void 0 ? [await resolveSessionId2(paths, options.session)] : await enumerateSessionDirs2(paths);
3420
+ const dryRun = options.dryRun === true;
3421
+ const rows = [];
3422
+ for (const sessionId of sessionIds) {
3423
+ let outcome;
3424
+ try {
3425
+ outcome = await rechainSessionInPlace(paths, sessionId, { dryRun });
3426
+ } catch (error) {
3427
+ rows.push({
3428
+ session_id: sessionId,
3429
+ status: "error",
3430
+ message: error instanceof Error ? error.message : "Unknown error"
3431
+ });
3432
+ continue;
3433
+ }
3434
+ if (outcome.status === "rechained") {
3435
+ rows.push({ session_id: sessionId, status: "rechained", event_count: outcome.eventCount });
3436
+ } else {
3437
+ rows.push({ session_id: sessionId, status: "skipped", reason: outcome.reason });
3438
+ }
3439
+ }
3440
+ const tamperedCount = rows.filter((r) => r.reason === "tampered").length;
3441
+ const errorCount = rows.filter((r) => r.status === "error").length;
3442
+ if (options.json === true) {
3443
+ console.log(JSON.stringify(rows, null, 2));
3444
+ } else {
3445
+ for (const row of rows) {
3446
+ console.log(`${row.session_id} ${renderRechainRow(row, dryRun)}`);
3447
+ }
3448
+ const rechained = rows.filter((r) => r.status === "rechained").length;
3449
+ const skipped = rows.filter((r) => r.status === "skipped").length;
3450
+ console.log(
3451
+ `Sessions: ${rows.length} total \u2014 ${rechained} ${dryRun ? "would be rechained" : "rechained"}, ${skipped} skipped, ${errorCount} errors`
3452
+ );
3453
+ }
3454
+ if (tamperedCount > 0 || errorCount > 0) {
3455
+ process.exitCode = 1;
3456
+ }
3457
+ }
3458
+ function renderRechainRow(row, dryRun) {
3459
+ switch (row.status) {
3460
+ case "rechained":
3461
+ return `${dryRun ? "would rechain" : "rechained"} (${row.event_count} events)`;
3462
+ case "skipped":
3463
+ return row.reason === "tampered" ? "skipped (TAMPERED \u2014 inspect with 'basou verify')" : `skipped (${row.reason})`;
3464
+ case "error":
3465
+ return `error (${row.message})`;
3466
+ }
3467
+ }
3258
3468
 
3259
3469
  // src/commands/stats.ts
3260
3470
  import {
@@ -4591,9 +4801,107 @@ function maxLen3(values, floor) {
4591
4801
  return max;
4592
4802
  }
4593
4803
 
4804
+ // src/commands/verify.ts
4805
+ import {
4806
+ assertBasouRootSafe as assertBasouRootSafe13,
4807
+ basouPaths as basouPaths13,
4808
+ enumerateSessionDirs as enumerateSessionDirs3,
4809
+ findErrorCode as findErrorCode12,
4810
+ resolveRepositoryRoot as resolveRepositoryRoot14,
4811
+ resolveSessionId as resolveSessionId4,
4812
+ verifyEventsChain
4813
+ } from "@basou/core";
4814
+ function registerVerifyCommand(program) {
4815
+ program.command("verify").description(
4816
+ "Verify the tamper-evidence hash chain of imported sessions' event logs (read-only)"
4817
+ ).option("--session <id>", "Verify a single session (unique id prefix accepted)").option("--all", "Verify every session (the default when --session is omitted)").option("--json", "Output the verdicts as JSON").option("-v, --verbose", "Show error causes").action(async (opts) => {
4818
+ await runVerify(opts);
4819
+ });
4820
+ }
4821
+ async function runVerify(options, ctx = {}) {
4822
+ try {
4823
+ await doRunVerify(options, ctx);
4824
+ } catch (error) {
4825
+ renderCliError(error, { verbose: isVerbose(options) });
4826
+ process.exitCode = 1;
4827
+ }
4828
+ }
4829
+ async function doRunVerify(options, ctx) {
4830
+ if (options.session !== void 0 && options.all === true) {
4831
+ throw new Error("Specify either --session <id> or --all, not both");
4832
+ }
4833
+ const cwd = ctx.cwd ?? process.cwd();
4834
+ const repositoryRoot = await resolveRepositoryRootForVerify(cwd);
4835
+ const paths = basouPaths13(repositoryRoot);
4836
+ await assertWorkspaceInitialized10(paths.root);
4837
+ const sessionIds = options.session !== void 0 ? [await resolveSessionId4(paths, options.session)] : await enumerateSessionDirs3(paths);
4838
+ const rows = [];
4839
+ for (const sessionId of sessionIds) {
4840
+ const verdict = await verifyEventsChain(paths, sessionId);
4841
+ rows.push({
4842
+ session_id: sessionId,
4843
+ status: verdict.status,
4844
+ event_count: verdict.eventCount,
4845
+ ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
4846
+ ...verdict.line !== void 0 ? { line: verdict.line } : {}
4847
+ });
4848
+ }
4849
+ const tamperedCount = rows.filter((r) => r.status === "tampered").length;
4850
+ if (options.json === true) {
4851
+ console.log(JSON.stringify(rows, null, 2));
4852
+ } else {
4853
+ for (const row of rows) {
4854
+ console.log(`${row.session_id} ${renderVerdict(row)}`);
4855
+ }
4856
+ const tally = (status) => rows.filter((r) => r.status === status).length;
4857
+ console.log(
4858
+ `Sessions: ${rows.length} total \u2014 ${tally("verified")} verified, ${tally("unchained")} unchained, ${tally("empty")} empty, ${tally("incomplete")} incomplete, ${tamperedCount} tampered`
4859
+ );
4860
+ }
4861
+ if (tamperedCount > 0) {
4862
+ process.exitCode = 1;
4863
+ }
4864
+ }
4865
+ function renderVerdict(row) {
4866
+ switch (row.status) {
4867
+ case "verified":
4868
+ return `verified (${row.event_count} events)`;
4869
+ case "tampered":
4870
+ return row.line !== void 0 ? `TAMPERED (${row.reason} at line ${row.line})` : `TAMPERED (${row.reason})`;
4871
+ case "incomplete":
4872
+ return "incomplete (session.yaml missing; re-import to repair)";
4873
+ case "unchained":
4874
+ return "unchained (not an imported session, or imported before chaining)";
4875
+ case "empty":
4876
+ return "empty";
4877
+ }
4878
+ }
4879
+ async function resolveRepositoryRootForVerify(cwd) {
4880
+ try {
4881
+ return await resolveRepositoryRoot14(cwd);
4882
+ } catch (error) {
4883
+ if (error instanceof Error && error.message === "Not a git repository") {
4884
+ throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou verify'.", {
4885
+ cause: error
4886
+ });
4887
+ }
4888
+ throw error;
4889
+ }
4890
+ }
4891
+ async function assertWorkspaceInitialized10(basouRoot) {
4892
+ try {
4893
+ await assertBasouRootSafe13(basouRoot);
4894
+ } catch (error) {
4895
+ if (findErrorCode12(error, "ENOENT")) {
4896
+ throw new Error("Workspace not initialized. Run 'basou init' first.");
4897
+ }
4898
+ throw error;
4899
+ }
4900
+ }
4901
+
4594
4902
  // src/commands/view.ts
4595
4903
  import { spawn } from "child_process";
4596
- import { assertBasouRootSafe as assertBasouRootSafe13, basouPaths as basouPaths13, findErrorCode as findErrorCode13, resolveRepositoryRoot as resolveRepositoryRoot14 } from "@basou/core";
4904
+ import { assertBasouRootSafe as assertBasouRootSafe14, basouPaths as basouPaths14, findErrorCode as findErrorCode14, resolveRepositoryRoot as resolveRepositoryRoot15 } from "@basou/core";
4597
4905
  import { InvalidArgumentError as InvalidArgumentError5 } from "commander";
4598
4906
 
4599
4907
  // src/lib/view-server.ts
@@ -4602,7 +4910,7 @@ import { join as join8 } from "path";
4602
4910
  import {
4603
4911
  computeWorkStats as computeWorkStats2,
4604
4912
  enumerateApprovals as enumerateApprovals2,
4605
- findErrorCode as findErrorCode12,
4913
+ findErrorCode as findErrorCode13,
4606
4914
  isLazyExpired as isLazyExpired2,
4607
4915
  loadApproval as loadApproval2,
4608
4916
  loadSessionEntries as loadSessionEntries3,
@@ -4610,7 +4918,7 @@ import {
4610
4918
  readAllEvents as readAllEvents2,
4611
4919
  readManifest as readManifest8,
4612
4920
  readMarkdownFile as readMarkdownFile4,
4613
- readSessionYaml as readSessionYaml2,
4921
+ readSessionYaml as readSessionYaml3,
4614
4922
  readTaskFile as readTaskFile2,
4615
4923
  renderDecisions as renderDecisions3,
4616
4924
  renderHandoff as renderHandoff3
@@ -5204,7 +5512,7 @@ async function overview(deps) {
5204
5512
  try {
5205
5513
  manifest = await readManifest8(deps.paths);
5206
5514
  } catch (error) {
5207
- if (findErrorCode12(error, "ENOENT")) {
5515
+ if (findErrorCode13(error, "ENOENT")) {
5208
5516
  return { initialized: false, repoRoot: deps.repoRoot };
5209
5517
  }
5210
5518
  throw error;
@@ -5251,7 +5559,7 @@ async function sessionsList(deps) {
5251
5559
  async function sessionDetail(deps, sessionId) {
5252
5560
  let session;
5253
5561
  try {
5254
- session = await readSessionYaml2(deps.paths, sessionId);
5562
+ session = await readSessionYaml3(deps.paths, sessionId);
5255
5563
  } catch (error) {
5256
5564
  if (error instanceof Error && error.message === "YAML file not found") {
5257
5565
  throw new HttpError(404, "Session not found");
@@ -5419,8 +5727,8 @@ async function runView(options, ctx = {}) {
5419
5727
  async function doRunView(options, ctx) {
5420
5728
  const cwd = ctx.cwd ?? process.cwd();
5421
5729
  const repositoryRoot = await resolveRepositoryRootForView(cwd);
5422
- const paths = basouPaths13(repositoryRoot);
5423
- await assertWorkspaceInitialized10(paths.root);
5730
+ const paths = basouPaths14(repositoryRoot);
5731
+ await assertWorkspaceInitialized11(paths.root);
5424
5732
  const deps = {
5425
5733
  paths,
5426
5734
  repoRoot: repositoryRoot,
@@ -5451,7 +5759,7 @@ async function startListening(port, deps) {
5451
5759
  try {
5452
5760
  return await startViewServer({ port, deps });
5453
5761
  } catch (error) {
5454
- if (findErrorCode13(error, "EADDRINUSE")) {
5762
+ if (findErrorCode14(error, "EADDRINUSE")) {
5455
5763
  throw new Error(`Port ${port} is already in use. Pass --port <n> to choose another.`, {
5456
5764
  cause: error
5457
5765
  });
@@ -5502,7 +5810,7 @@ function waitForShutdown(signal) {
5502
5810
  }
5503
5811
  async function resolveRepositoryRootForView(cwd) {
5504
5812
  try {
5505
- return await resolveRepositoryRoot14(cwd);
5813
+ return await resolveRepositoryRoot15(cwd);
5506
5814
  } catch (error) {
5507
5815
  if (error instanceof Error && error.message === "Not a git repository") {
5508
5816
  throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou view'.", {
@@ -5512,11 +5820,11 @@ async function resolveRepositoryRootForView(cwd) {
5512
5820
  throw error;
5513
5821
  }
5514
5822
  }
5515
- async function assertWorkspaceInitialized10(basouRoot) {
5823
+ async function assertWorkspaceInitialized11(basouRoot) {
5516
5824
  try {
5517
- await assertBasouRootSafe13(basouRoot);
5825
+ await assertBasouRootSafe14(basouRoot);
5518
5826
  } catch (error) {
5519
- if (findErrorCode13(error, "ENOENT")) {
5827
+ if (findErrorCode14(error, "ENOENT")) {
5520
5828
  throw new Error("Workspace not initialized. Run 'basou init' first.");
5521
5829
  }
5522
5830
  throw error;
@@ -5538,6 +5846,7 @@ function buildProgram() {
5538
5846
  registerSessionCommand(program);
5539
5847
  registerImportCommand(program);
5540
5848
  registerRefreshCommand(program);
5849
+ registerVerifyCommand(program);
5541
5850
  registerViewCommand(program);
5542
5851
  registerApprovalCommand(program);
5543
5852
  registerDecisionCommand(program);