@basou/cli 0.8.0 → 0.10.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/index.js CHANGED
@@ -115,7 +115,8 @@ import { join } from "path";
115
115
  import {
116
116
  ApprovalSchema,
117
117
  ApprovalStatusSchema,
118
- appendEvent,
118
+ acquireLock,
119
+ appendChainedEventLocked,
119
120
  assertBasouRootSafe,
120
121
  basouPaths,
121
122
  enumerateApprovals,
@@ -124,6 +125,7 @@ import {
124
125
  linkYamlFile,
125
126
  loadApproval,
126
127
  prefixedUlid,
128
+ readSessionYaml,
127
129
  readYamlFile,
128
130
  replayEvents,
129
131
  resolveRepositoryRoot
@@ -319,46 +321,63 @@ async function doRunApprovalResolve(idInput, options, ctx, decision) {
319
321
  if (approval.status !== "pending") {
320
322
  throw new Error(`Approval status mismatch: pending YAML has status=${approval.status}`);
321
323
  }
322
- const sessionDir = join(paths.sessions, approval.session_id);
323
- for await (const ev of replayEvents(sessionDir, {
324
- onWarning: (w) => printReplayWarning(w, approval.session_id)
325
- })) {
326
- if (isApprovalEvent(ev) && ev.approval_id === approval.id && (ev.type === "approval_approved" || ev.type === "approval_rejected" || ev.type === "approval_expired")) {
327
- throw new Error(`Approval already resolved (per events.jsonl): ${idInput}`);
328
- }
329
- }
330
324
  const now = /* @__PURE__ */ new Date();
331
- if (isLazyExpired(approval, now)) {
332
- throw new Error(`Approval already expired: ${idInput}`);
333
- }
334
325
  const occurredAt = now.toISOString();
335
326
  const eventId = prefixedUlid("evt");
336
- if (decision === "approve") {
337
- const note = options.note ?? null;
338
- await appendEvent(sessionDir, {
339
- schema_version: "0.1.0",
340
- id: eventId,
341
- session_id: approval.session_id,
342
- occurred_at: occurredAt,
343
- source: "local-cli",
344
- type: "approval_approved",
345
- approval_id: approval.id,
346
- resolver: "local-cli",
347
- note
348
- });
349
- } else {
350
- const reason = options.reason;
351
- await appendEvent(sessionDir, {
352
- schema_version: "0.1.0",
353
- id: eventId,
354
- session_id: approval.session_id,
355
- occurred_at: occurredAt,
356
- source: "local-cli",
357
- type: "approval_rejected",
358
- approval_id: approval.id,
359
- resolver: "local-cli",
360
- reason
361
- });
327
+ const sessionLock = await acquireLock(paths, "session", approval.session_id);
328
+ try {
329
+ const sessionDir = join(paths.sessions, approval.session_id);
330
+ for await (const ev of replayEvents(sessionDir, {
331
+ onWarning: (w) => printReplayWarning(w, approval.session_id)
332
+ })) {
333
+ if (isApprovalEvent(ev) && ev.approval_id === approval.id && (ev.type === "approval_approved" || ev.type === "approval_rejected" || ev.type === "approval_expired")) {
334
+ throw new Error(`Approval already resolved (per events.jsonl): ${idInput}`);
335
+ }
336
+ }
337
+ if (isLazyExpired(approval, now)) {
338
+ throw new Error(`Approval already expired: ${idInput}`);
339
+ }
340
+ let sessionStatus = null;
341
+ try {
342
+ sessionStatus = (await readSessionYaml(paths, approval.session_id)).session.status;
343
+ } catch {
344
+ sessionStatus = null;
345
+ }
346
+ const attachable = sessionStatus === "initialized" || sessionStatus === "running" || sessionStatus === "waiting_approval";
347
+ if (sessionStatus !== null && !attachable) {
348
+ throw new Error(
349
+ `Cannot resolve an approval for a session that is not active (status=${sessionStatus}): ${idInput}`
350
+ );
351
+ }
352
+ if (decision === "approve") {
353
+ const note = options.note ?? null;
354
+ await appendChainedEventLocked(paths, approval.session_id, {
355
+ schema_version: "0.1.0",
356
+ id: eventId,
357
+ session_id: approval.session_id,
358
+ occurred_at: occurredAt,
359
+ source: "local-cli",
360
+ type: "approval_approved",
361
+ approval_id: approval.id,
362
+ resolver: "local-cli",
363
+ note
364
+ });
365
+ } else {
366
+ const reason = options.reason;
367
+ await appendChainedEventLocked(paths, approval.session_id, {
368
+ schema_version: "0.1.0",
369
+ id: eventId,
370
+ session_id: approval.session_id,
371
+ occurred_at: occurredAt,
372
+ source: "local-cli",
373
+ type: "approval_rejected",
374
+ approval_id: approval.id,
375
+ resolver: "local-cli",
376
+ reason
377
+ });
378
+ }
379
+ } finally {
380
+ await sessionLock.release();
362
381
  }
363
382
  const resolvedApproval = decision === "approve" ? {
364
383
  ...approval,
@@ -615,7 +634,7 @@ function printNoApprovals(options) {
615
634
 
616
635
  // src/commands/decision.ts
617
636
  import {
618
- acquireLock,
637
+ acquireLock as acquireLock2,
619
638
  appendEventToExistingSession,
620
639
  assertBasouRootSafe as assertBasouRootSafe2,
621
640
  basouPaths as basouPaths2,
@@ -680,7 +699,7 @@ async function doRunDecisionRecord(options, ctx) {
680
699
  if (options.session !== void 0) {
681
700
  const sessionId = await resolveSessionId(paths, options.session);
682
701
  const sesId = sessionId;
683
- const sessionLock = await acquireLock(paths, "session", sesId);
702
+ const sessionLock = await acquireLock2(paths, "session", sesId);
684
703
  let result;
685
704
  try {
686
705
  result = await appendEventToExistingSession({
@@ -944,10 +963,12 @@ import { mkdir } from "fs/promises";
944
963
  import { homedir } from "os";
945
964
  import { join as join2 } from "path";
946
965
  import {
966
+ acquireLock as acquireLock3,
947
967
  assertBasouRootSafe as assertBasouRootSafe4,
948
968
  basouPaths as basouPaths4,
949
969
  ChildProcessRunner,
950
- appendEvent as coreAppendEvent,
970
+ appendChainedEvent as coreAppendChainedEvent,
971
+ finalizeSessionYaml,
951
972
  getSnapshot,
952
973
  overwriteYamlFile,
953
974
  parseDuration,
@@ -973,7 +994,6 @@ function registerExecCommand(program2) {
973
994
  async function runExec(command, args, options, ctx = {}) {
974
995
  const runner = ctx.runner ?? new ChildProcessRunner();
975
996
  const now = ctx.now ?? (() => /* @__PURE__ */ new Date());
976
- const appendEvent2 = ctx.appendEvent ?? coreAppendEvent;
977
997
  const cwd = options.cwd ?? process.cwd();
978
998
  const timeout_ms = options.timeout !== void 0 ? parseDuration(options.timeout) : void 0;
979
999
  const repoRoot = await resolveRepositoryRootForExec(cwd);
@@ -983,6 +1003,9 @@ async function runExec(command, args, options, ctx = {}) {
983
1003
  const sessionId = prefixedUlid3("ses");
984
1004
  const sessionDir = join2(paths.sessions, sessionId);
985
1005
  await mkdir(sessionDir, { recursive: true });
1006
+ const appendEvent = ctx.appendEvent ?? (async (_sessionDir, event) => {
1007
+ await coreAppendChainedEvent(paths, sessionId, event);
1008
+ });
986
1009
  const startedAt = now().toISOString();
987
1010
  const sessionYamlPath = join2(sessionDir, "session.yaml");
988
1011
  const session = buildInitialSession({
@@ -994,7 +1017,7 @@ async function runExec(command, args, options, ctx = {}) {
994
1017
  startedAt
995
1018
  });
996
1019
  await writeYamlFile(sessionYamlPath, session);
997
- await appendEvent2(sessionDir, {
1020
+ await appendEvent(sessionDir, {
998
1021
  schema_version: "0.1.0",
999
1022
  type: "session_started",
1000
1023
  id: prefixedUlid3("evt"),
@@ -1003,10 +1026,10 @@ async function runExec(command, args, options, ctx = {}) {
1003
1026
  source: "terminal-recording"
1004
1027
  });
1005
1028
  if (options.snapshot !== false) {
1006
- await tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent2);
1029
+ await tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent);
1007
1030
  }
1008
1031
  const runningAt = now().toISOString();
1009
- await appendEvent2(sessionDir, {
1032
+ await appendEvent(sessionDir, {
1010
1033
  schema_version: "0.1.0",
1011
1034
  type: "session_status_changed",
1012
1035
  id: prefixedUlid3("evt"),
@@ -1016,9 +1039,14 @@ async function runExec(command, args, options, ctx = {}) {
1016
1039
  from: "initialized",
1017
1040
  to: "running"
1018
1041
  });
1019
- await mutateSessionYaml(sessionYamlPath, (s) => {
1020
- s.session.status = "running";
1021
- });
1042
+ const runningLock = await acquireLock3(paths, "session", sessionId);
1043
+ try {
1044
+ await mutateSessionYaml(sessionYamlPath, (s) => {
1045
+ s.session.status = "running";
1046
+ });
1047
+ } finally {
1048
+ await runningLock.release();
1049
+ }
1022
1050
  const controller = new AbortController();
1023
1051
  let signalReceived = null;
1024
1052
  let activeChild = null;
@@ -1054,7 +1082,7 @@ async function runExec(command, args, options, ctx = {}) {
1054
1082
  }
1055
1083
  });
1056
1084
  } catch (spawnError) {
1057
- await finalizeSessionAsFailed(sessionDir, sessionYamlPath, sessionId, appendEvent2, {
1085
+ await finalizeSessionAsFailed(paths, sessionDir, sessionId, appendEvent, {
1058
1086
  command,
1059
1087
  args,
1060
1088
  cwd,
@@ -1070,7 +1098,7 @@ async function runExec(command, args, options, ctx = {}) {
1070
1098
  activeChild = null;
1071
1099
  }
1072
1100
  const endedAt = now().toISOString();
1073
- await appendEvent2(sessionDir, {
1101
+ await appendEvent(sessionDir, {
1074
1102
  schema_version: "0.1.0",
1075
1103
  type: "command_executed",
1076
1104
  id: prefixedUlid3("evt"),
@@ -1086,10 +1114,10 @@ async function runExec(command, args, options, ctx = {}) {
1086
1114
  duration_ms: result.duration_ms
1087
1115
  });
1088
1116
  if (options.snapshot !== false) {
1089
- await tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent2);
1117
+ await tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent);
1090
1118
  }
1091
1119
  const finalStatus = decideFinalStatus(result, signalReceived);
1092
- await appendEvent2(sessionDir, {
1120
+ await appendEvent(sessionDir, {
1093
1121
  schema_version: "0.1.0",
1094
1122
  type: "session_status_changed",
1095
1123
  id: prefixedUlid3("evt"),
@@ -1099,7 +1127,7 @@ async function runExec(command, args, options, ctx = {}) {
1099
1127
  from: "running",
1100
1128
  to: finalStatus
1101
1129
  });
1102
- await appendEvent2(sessionDir, {
1130
+ await appendEvent(sessionDir, {
1103
1131
  schema_version: "0.1.0",
1104
1132
  type: "session_ended",
1105
1133
  id: prefixedUlid3("evt"),
@@ -1108,7 +1136,7 @@ async function runExec(command, args, options, ctx = {}) {
1108
1136
  source: "terminal-recording",
1109
1137
  ...result.exit_code !== null ? { exit_code: result.exit_code } : {}
1110
1138
  });
1111
- await mutateSessionYaml(sessionYamlPath, (s) => {
1139
+ await finalizeSessionYaml(paths, sessionId, (s) => {
1112
1140
  s.session.status = finalStatus;
1113
1141
  s.session.ended_at = endedAt;
1114
1142
  s.session.invocation.exit_code = result.exit_code;
@@ -1138,7 +1166,7 @@ function signalToExitCode(sig) {
1138
1166
  const num = SIGNUM_MAP[sig] ?? 1;
1139
1167
  return 128 + num;
1140
1168
  }
1141
- async function tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent2) {
1169
+ async function tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent) {
1142
1170
  let snapshot;
1143
1171
  try {
1144
1172
  snapshot = await getSnapshot(repoRoot);
@@ -1146,7 +1174,7 @@ async function tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, append
1146
1174
  console.warn(normalizeGitSnapshotSkipMessage(error));
1147
1175
  return;
1148
1176
  }
1149
- await appendEvent2(sessionDir, {
1177
+ await appendEvent(sessionDir, {
1150
1178
  schema_version: "0.1.0",
1151
1179
  type: "git_snapshot",
1152
1180
  id: prefixedUlid3("evt"),
@@ -1202,8 +1230,8 @@ async function mutateSessionYaml(filePath, mutator) {
1202
1230
  const validated = SessionSchema.parse(parsed);
1203
1231
  await overwriteYamlFile(filePath, validated);
1204
1232
  }
1205
- async function finalizeSessionAsFailed(sessionDir, sessionYamlPath, sessionId, appendEvent2, ctx) {
1206
- await appendEvent2(sessionDir, {
1233
+ async function finalizeSessionAsFailed(paths, sessionDir, sessionId, appendEvent, ctx) {
1234
+ await appendEvent(sessionDir, {
1207
1235
  schema_version: "0.1.0",
1208
1236
  type: "command_executed",
1209
1237
  id: prefixedUlid3("evt"),
@@ -1218,7 +1246,7 @@ async function finalizeSessionAsFailed(sessionDir, sessionYamlPath, sessionId, a
1218
1246
  ...ctx.signalReceived !== null ? { received_signal: ctx.signalReceived } : {},
1219
1247
  duration_ms: 0
1220
1248
  });
1221
- await appendEvent2(sessionDir, {
1249
+ await appendEvent(sessionDir, {
1222
1250
  schema_version: "0.1.0",
1223
1251
  type: "session_status_changed",
1224
1252
  id: prefixedUlid3("evt"),
@@ -1228,7 +1256,7 @@ async function finalizeSessionAsFailed(sessionDir, sessionYamlPath, sessionId, a
1228
1256
  from: "running",
1229
1257
  to: "failed"
1230
1258
  });
1231
- await appendEvent2(sessionDir, {
1259
+ await appendEvent(sessionDir, {
1232
1260
  schema_version: "0.1.0",
1233
1261
  type: "session_ended",
1234
1262
  id: prefixedUlid3("evt"),
@@ -1236,7 +1264,7 @@ async function finalizeSessionAsFailed(sessionDir, sessionYamlPath, sessionId, a
1236
1264
  occurred_at: ctx.occurredAt,
1237
1265
  source: "terminal-recording"
1238
1266
  });
1239
- await mutateSessionYaml(sessionYamlPath, (s) => {
1267
+ await finalizeSessionYaml(paths, sessionId, (s) => {
1240
1268
  s.session.status = "failed";
1241
1269
  s.session.ended_at = ctx.occurredAt;
1242
1270
  s.session.invocation.exit_code = null;
@@ -1342,7 +1370,7 @@ import {
1342
1370
  findErrorCode as findErrorCode5,
1343
1371
  importSessionFromJson,
1344
1372
  readManifest as readManifest3,
1345
- readSessionYaml,
1373
+ readSessionYaml as readSessionYaml2,
1346
1374
  reimportPreservingId,
1347
1375
  resolveRepositoryRoot as resolveRepositoryRoot6,
1348
1376
  SessionImportPayloadSchema
@@ -1524,7 +1552,7 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
1524
1552
  dryRun: options.dryRun === true
1525
1553
  });
1526
1554
  if (outcome.status === "skipped") {
1527
- const detail = outcome.reason === "prior_events_unreadable" ? "prior events.jsonl has unreadable lines" : "source changed in a non-append way (derived events would be dropped)";
1555
+ 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)";
1528
1556
  console.error(`Import: ${externalId} ${detail}; re-import skipped`);
1529
1557
  counts.skippedNoAction++;
1530
1558
  continue;
@@ -1612,7 +1640,7 @@ async function loadExistingByExternalId(paths, sourceKind) {
1612
1640
  for (const sessionId of sessionIds) {
1613
1641
  let session;
1614
1642
  try {
1615
- session = await readSessionYaml(paths, sessionId);
1643
+ session = await readSessionYaml2(paths, sessionId);
1616
1644
  } catch {
1617
1645
  continue;
1618
1646
  }
@@ -2388,11 +2416,13 @@ import { mkdir as mkdir2 } from "fs/promises";
2388
2416
  import { homedir as homedir4 } from "os";
2389
2417
  import { join as join5 } from "path";
2390
2418
  import {
2419
+ acquireLock as acquireLock4,
2391
2420
  assertBasouRootSafe as assertBasouRootSafe8,
2392
2421
  basouPaths as basouPaths8,
2393
2422
  ChildProcessRunner as ChildProcessRunner2,
2394
2423
  claudeCodeAdapterMetadata,
2395
- appendEvent as coreAppendEvent2,
2424
+ appendChainedEvent as coreAppendChainedEvent2,
2425
+ finalizeSessionYaml as finalizeSessionYaml2,
2396
2426
  getDiff,
2397
2427
  getSnapshot as getSnapshot2,
2398
2428
  overwriteYamlFile as overwriteYamlFile2,
@@ -2428,7 +2458,6 @@ function registerRunCommand(program2, ctx = {}) {
2428
2458
  async function runClaudeCode(args, options, ctx = {}) {
2429
2459
  const runner = ctx.runner ?? new ChildProcessRunner2();
2430
2460
  const now = ctx.now ?? (() => /* @__PURE__ */ new Date());
2431
- const appendEvent2 = ctx.appendEvent ?? coreAppendEvent2;
2432
2461
  const resolveCommand = ctx.resolveCommand ?? resolveClaudeCodeCommand;
2433
2462
  const getDiffFn = ctx.getDiff ?? getDiff;
2434
2463
  const { command } = await resolveCommand();
@@ -2440,6 +2469,9 @@ async function runClaudeCode(args, options, ctx = {}) {
2440
2469
  const sessionId = prefixedUlid4("ses");
2441
2470
  const sessionDir = join5(paths.sessions, sessionId);
2442
2471
  await mkdir2(sessionDir, { recursive: true });
2472
+ const appendEvent = ctx.appendEvent ?? (async (_sessionDir, event) => {
2473
+ await coreAppendChainedEvent2(paths, sessionId, event);
2474
+ });
2443
2475
  const startedAt = now().toISOString();
2444
2476
  const sessionYamlPath = join5(sessionDir, "session.yaml");
2445
2477
  const session = buildInitialSession2({
@@ -2451,7 +2483,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2451
2483
  startedAt
2452
2484
  });
2453
2485
  await writeYamlFile2(sessionYamlPath, session);
2454
- await appendEvent2(sessionDir, {
2486
+ await appendEvent(sessionDir, {
2455
2487
  schema_version: "0.1.0",
2456
2488
  type: "session_started",
2457
2489
  id: prefixedUlid4("evt"),
@@ -2461,10 +2493,10 @@ async function runClaudeCode(args, options, ctx = {}) {
2461
2493
  });
2462
2494
  let preSnapshot = null;
2463
2495
  if (options.snapshot !== false) {
2464
- preSnapshot = await tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent2);
2496
+ preSnapshot = await tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent);
2465
2497
  }
2466
2498
  const runningAt = now().toISOString();
2467
- await appendEvent2(sessionDir, {
2499
+ await appendEvent(sessionDir, {
2468
2500
  schema_version: "0.1.0",
2469
2501
  type: "session_status_changed",
2470
2502
  id: prefixedUlid4("evt"),
@@ -2474,9 +2506,14 @@ async function runClaudeCode(args, options, ctx = {}) {
2474
2506
  from: "initialized",
2475
2507
  to: "running"
2476
2508
  });
2477
- await mutateSessionYaml2(sessionYamlPath, (s) => {
2478
- s.session.status = "running";
2479
- });
2509
+ const runningLock = await acquireLock4(paths, "session", sessionId);
2510
+ try {
2511
+ await mutateSessionYaml2(sessionYamlPath, (s) => {
2512
+ s.session.status = "running";
2513
+ });
2514
+ } finally {
2515
+ await runningLock.release();
2516
+ }
2480
2517
  const controller = new AbortController();
2481
2518
  let signalReceived = null;
2482
2519
  let activeChild = null;
@@ -2511,7 +2548,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2511
2548
  }
2512
2549
  });
2513
2550
  } catch (spawnError) {
2514
- await finalizeSessionAsFailed2(sessionDir, sessionYamlPath, sessionId, appendEvent2, {
2551
+ await finalizeSessionAsFailed2(paths, sessionDir, sessionId, appendEvent, {
2515
2552
  command,
2516
2553
  args,
2517
2554
  cwd: repoRoot,
@@ -2527,7 +2564,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2527
2564
  activeChild = null;
2528
2565
  }
2529
2566
  const endedAt = now().toISOString();
2530
- await appendEvent2(sessionDir, {
2567
+ await appendEvent(sessionDir, {
2531
2568
  schema_version: "0.1.0",
2532
2569
  type: "command_executed",
2533
2570
  id: prefixedUlid4("evt"),
@@ -2544,7 +2581,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2544
2581
  });
2545
2582
  let postSnapshot = null;
2546
2583
  if (options.snapshot !== false) {
2547
- postSnapshot = await tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent2);
2584
+ postSnapshot = await tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent);
2548
2585
  }
2549
2586
  let diff = null;
2550
2587
  if (preSnapshot !== null && postSnapshot !== null) {
@@ -2555,7 +2592,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2555
2592
  preSnapshot.head,
2556
2593
  postSnapshot.head,
2557
2594
  now().toISOString(),
2558
- appendEvent2,
2595
+ appendEvent,
2559
2596
  getDiffFn
2560
2597
  );
2561
2598
  }
@@ -2565,7 +2602,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2565
2602
  homedir: homedir4()
2566
2603
  }).sanitized;
2567
2604
  const finalStatus = decideFinalStatus2(result, signalReceived);
2568
- await appendEvent2(sessionDir, {
2605
+ await appendEvent(sessionDir, {
2569
2606
  schema_version: "0.1.0",
2570
2607
  type: "session_status_changed",
2571
2608
  id: prefixedUlid4("evt"),
@@ -2575,7 +2612,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2575
2612
  from: "running",
2576
2613
  to: finalStatus
2577
2614
  });
2578
- await appendEvent2(sessionDir, {
2615
+ await appendEvent(sessionDir, {
2579
2616
  schema_version: "0.1.0",
2580
2617
  type: "session_ended",
2581
2618
  id: prefixedUlid4("evt"),
@@ -2584,7 +2621,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2584
2621
  source: claudeCodeAdapterMetadata.kind,
2585
2622
  ...result.exit_code !== null ? { exit_code: result.exit_code } : {}
2586
2623
  });
2587
- await mutateSessionYaml2(sessionYamlPath, (s) => {
2624
+ await finalizeSessionYaml2(paths, sessionId, (s) => {
2588
2625
  s.session.status = finalStatus;
2589
2626
  s.session.ended_at = endedAt;
2590
2627
  s.session.invocation.exit_code = result.exit_code;
@@ -2613,7 +2650,7 @@ function signalToExitCode2(sig) {
2613
2650
  const num = SIGNUM_MAP2[sig] ?? 1;
2614
2651
  return 128 + num;
2615
2652
  }
2616
- async function tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent2) {
2653
+ async function tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent) {
2617
2654
  let snapshot;
2618
2655
  try {
2619
2656
  snapshot = await getSnapshot2(repoRoot);
@@ -2621,7 +2658,7 @@ async function tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appen
2621
2658
  console.warn(normalizeGitSnapshotSkipMessage2(error));
2622
2659
  return null;
2623
2660
  }
2624
- await appendEvent2(sessionDir, {
2661
+ await appendEvent(sessionDir, {
2625
2662
  schema_version: "0.1.0",
2626
2663
  type: "git_snapshot",
2627
2664
  id: prefixedUlid4("evt"),
@@ -2632,7 +2669,7 @@ async function tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appen
2632
2669
  });
2633
2670
  return snapshot;
2634
2671
  }
2635
- async function tryAppendFileChangedEvents(sessionDir, sessionId, repoRoot, baseRef, headRef, occurredAt, appendEvent2, getDiffFn) {
2672
+ async function tryAppendFileChangedEvents(sessionDir, sessionId, repoRoot, baseRef, headRef, occurredAt, appendEvent, getDiffFn) {
2636
2673
  let diff;
2637
2674
  try {
2638
2675
  diff = await getDiffFn(repoRoot, baseRef, headRef);
@@ -2641,7 +2678,7 @@ async function tryAppendFileChangedEvents(sessionDir, sessionId, repoRoot, baseR
2641
2678
  return null;
2642
2679
  }
2643
2680
  for (const change of diff.changed_files) {
2644
- await appendEvent2(sessionDir, {
2681
+ await appendEvent(sessionDir, {
2645
2682
  schema_version: "0.1.0",
2646
2683
  type: "file_changed",
2647
2684
  id: prefixedUlid4("evt"),
@@ -2724,8 +2761,8 @@ async function mutateSessionYaml2(filePath, mutator) {
2724
2761
  const validated = SessionSchema2.parse(parsed);
2725
2762
  await overwriteYamlFile2(filePath, validated);
2726
2763
  }
2727
- async function finalizeSessionAsFailed2(sessionDir, sessionYamlPath, sessionId, appendEvent2, ctx) {
2728
- await appendEvent2(sessionDir, {
2764
+ async function finalizeSessionAsFailed2(paths, sessionDir, sessionId, appendEvent, ctx) {
2765
+ await appendEvent(sessionDir, {
2729
2766
  schema_version: "0.1.0",
2730
2767
  type: "command_executed",
2731
2768
  id: prefixedUlid4("evt"),
@@ -2740,7 +2777,7 @@ async function finalizeSessionAsFailed2(sessionDir, sessionYamlPath, sessionId,
2740
2777
  ...ctx.signalReceived !== null ? { received_signal: ctx.signalReceived } : {},
2741
2778
  duration_ms: 0
2742
2779
  });
2743
- await appendEvent2(sessionDir, {
2780
+ await appendEvent(sessionDir, {
2744
2781
  schema_version: "0.1.0",
2745
2782
  type: "session_status_changed",
2746
2783
  id: prefixedUlid4("evt"),
@@ -2750,7 +2787,7 @@ async function finalizeSessionAsFailed2(sessionDir, sessionYamlPath, sessionId,
2750
2787
  from: "running",
2751
2788
  to: "failed"
2752
2789
  });
2753
- await appendEvent2(sessionDir, {
2790
+ await appendEvent(sessionDir, {
2754
2791
  schema_version: "0.1.0",
2755
2792
  type: "session_ended",
2756
2793
  id: prefixedUlid4("evt"),
@@ -2758,7 +2795,7 @@ async function finalizeSessionAsFailed2(sessionDir, sessionYamlPath, sessionId,
2758
2795
  occurred_at: ctx.occurredAt,
2759
2796
  source: claudeCodeAdapterMetadata.kind
2760
2797
  });
2761
- await mutateSessionYaml2(sessionYamlPath, (s) => {
2798
+ await finalizeSessionYaml2(paths, sessionId, (s) => {
2762
2799
  s.session.status = "failed";
2763
2800
  s.session.ended_at = ctx.occurredAt;
2764
2801
  s.session.invocation.exit_code = null;
@@ -2781,16 +2818,18 @@ async function resolveRepositoryRootForRun(cwd) {
2781
2818
  import { readFile as readFile2 } from "fs/promises";
2782
2819
  import { basename as basename3, isAbsolute, join as join6, relative as relative2 } from "path";
2783
2820
  import {
2784
- acquireLock as acquireLock2,
2821
+ acquireLock as acquireLock5,
2785
2822
  appendEventToExistingSession as appendEventToExistingSession2,
2786
2823
  assertBasouRootSafe as assertBasouRootSafe9,
2787
2824
  basouPaths as basouPaths9,
2825
+ enumerateSessionDirs as enumerateSessionDirs2,
2788
2826
  findErrorCode as findErrorCode8,
2789
2827
  importSessionFromJson as importSessionFromJson2,
2790
2828
  loadSessionEntries,
2791
2829
  readAllEvents,
2792
2830
  readManifest as readManifest5,
2793
2831
  readYamlFile as readYamlFile4,
2832
+ rechainSessionInPlace,
2794
2833
  resolveRepositoryRoot as resolveRepositoryRoot10,
2795
2834
  resolveSessionId as resolveSessionId2,
2796
2835
  resolveTaskId,
@@ -2839,6 +2878,11 @@ function registerSessionCommand(program2) {
2839
2878
  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) => {
2840
2879
  await runSessionNote(sessionIdInput, options);
2841
2880
  });
2881
+ session.command("rechain").description(
2882
+ "Add the tamper-evidence hash chain, in place, to imported sessions created before chaining existed"
2883
+ ).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) => {
2884
+ await runSessionRechain(options);
2885
+ });
2842
2886
  }
2843
2887
  async function runSessionList(options, ctx = {}) {
2844
2888
  try {
@@ -3324,7 +3368,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
3324
3368
  }
3325
3369
  const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3326
3370
  const sesId = sessionId;
3327
- const sessionLock = await acquireLock2(paths, "session", sesId);
3371
+ const sessionLock = await acquireLock5(paths, "session", sesId);
3328
3372
  let result;
3329
3373
  try {
3330
3374
  result = await appendEventToExistingSession2({
@@ -3380,6 +3424,74 @@ function printSessionNoteResult(options, sessionId, eventId, sessionStatus, body
3380
3424
  const preview = body.length > NOTE_BODY_PREVIEW_LIMIT ? `${body.slice(0, NOTE_BODY_PREVIEW_HEAD)}...` : body;
3381
3425
  console.log(`Added note to session ${sid} (${sessionStatus}): ${preview}`);
3382
3426
  }
3427
+ async function runSessionRechain(options, ctx = {}) {
3428
+ try {
3429
+ await doRunSessionRechain(options, ctx);
3430
+ } catch (error) {
3431
+ renderCliError(error, { verbose: isVerbose(options) });
3432
+ process.exitCode = 1;
3433
+ }
3434
+ }
3435
+ async function doRunSessionRechain(options, ctx) {
3436
+ if (options.session !== void 0 && options.all === true) {
3437
+ throw new Error("Specify either --session <id> or --all, not both");
3438
+ }
3439
+ if (options.session === void 0 && options.all !== true) {
3440
+ throw new Error("Specify --session <id> or --all");
3441
+ }
3442
+ const cwd = ctx.cwd ?? process.cwd();
3443
+ const repositoryRoot = await resolveRepositoryRootForSession(cwd, "rechain");
3444
+ const paths = basouPaths9(repositoryRoot);
3445
+ await assertWorkspaceInitialized7(paths.root);
3446
+ const sessionIds = options.session !== void 0 ? [await resolveSessionId2(paths, options.session)] : await enumerateSessionDirs2(paths);
3447
+ const dryRun = options.dryRun === true;
3448
+ const rows = [];
3449
+ for (const sessionId of sessionIds) {
3450
+ let outcome;
3451
+ try {
3452
+ outcome = await rechainSessionInPlace(paths, sessionId, { dryRun });
3453
+ } catch (error) {
3454
+ rows.push({
3455
+ session_id: sessionId,
3456
+ status: "error",
3457
+ message: error instanceof Error ? error.message : "Unknown error"
3458
+ });
3459
+ continue;
3460
+ }
3461
+ if (outcome.status === "rechained") {
3462
+ rows.push({ session_id: sessionId, status: "rechained", event_count: outcome.eventCount });
3463
+ } else {
3464
+ rows.push({ session_id: sessionId, status: "skipped", reason: outcome.reason });
3465
+ }
3466
+ }
3467
+ const tamperedCount = rows.filter((r) => r.reason === "tampered").length;
3468
+ const errorCount = rows.filter((r) => r.status === "error").length;
3469
+ if (options.json === true) {
3470
+ console.log(JSON.stringify(rows, null, 2));
3471
+ } else {
3472
+ for (const row of rows) {
3473
+ console.log(`${row.session_id} ${renderRechainRow(row, dryRun)}`);
3474
+ }
3475
+ const rechained = rows.filter((r) => r.status === "rechained").length;
3476
+ const skipped = rows.filter((r) => r.status === "skipped").length;
3477
+ console.log(
3478
+ `Sessions: ${rows.length} total \u2014 ${rechained} ${dryRun ? "would be rechained" : "rechained"}, ${skipped} skipped, ${errorCount} errors`
3479
+ );
3480
+ }
3481
+ if (tamperedCount > 0 || errorCount > 0) {
3482
+ process.exitCode = 1;
3483
+ }
3484
+ }
3485
+ function renderRechainRow(row, dryRun) {
3486
+ switch (row.status) {
3487
+ case "rechained":
3488
+ return `${dryRun ? "would rechain" : "rechained"} (${row.event_count} events)`;
3489
+ case "skipped":
3490
+ return row.reason === "tampered" ? "skipped (TAMPERED \u2014 inspect with 'basou verify')" : `skipped (${row.reason})`;
3491
+ case "error":
3492
+ return `error (${row.message})`;
3493
+ }
3494
+ }
3383
3495
 
3384
3496
  // src/commands/stats.ts
3385
3497
  import {
@@ -4716,9 +4828,107 @@ function maxLen3(values, floor) {
4716
4828
  return max;
4717
4829
  }
4718
4830
 
4831
+ // src/commands/verify.ts
4832
+ import {
4833
+ assertBasouRootSafe as assertBasouRootSafe13,
4834
+ basouPaths as basouPaths13,
4835
+ enumerateSessionDirs as enumerateSessionDirs3,
4836
+ findErrorCode as findErrorCode12,
4837
+ resolveRepositoryRoot as resolveRepositoryRoot14,
4838
+ resolveSessionId as resolveSessionId4,
4839
+ verifyEventsChain
4840
+ } from "@basou/core";
4841
+ function registerVerifyCommand(program2) {
4842
+ program2.command("verify").description("Verify the tamper-evidence hash chain of sessions' event logs (read-only)").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) => {
4843
+ await runVerify(opts);
4844
+ });
4845
+ }
4846
+ async function runVerify(options, ctx = {}) {
4847
+ try {
4848
+ await doRunVerify(options, ctx);
4849
+ } catch (error) {
4850
+ renderCliError(error, { verbose: isVerbose(options) });
4851
+ process.exitCode = 1;
4852
+ }
4853
+ }
4854
+ async function doRunVerify(options, ctx) {
4855
+ if (options.session !== void 0 && options.all === true) {
4856
+ throw new Error("Specify either --session <id> or --all, not both");
4857
+ }
4858
+ const cwd = ctx.cwd ?? process.cwd();
4859
+ const repositoryRoot = await resolveRepositoryRootForVerify(cwd);
4860
+ const paths = basouPaths13(repositoryRoot);
4861
+ await assertWorkspaceInitialized10(paths.root);
4862
+ const sessionIds = options.session !== void 0 ? [await resolveSessionId4(paths, options.session)] : await enumerateSessionDirs3(paths);
4863
+ const rows = [];
4864
+ for (const sessionId of sessionIds) {
4865
+ const verdict = await verifyEventsChain(paths, sessionId);
4866
+ rows.push({
4867
+ session_id: sessionId,
4868
+ status: verdict.status,
4869
+ event_count: verdict.eventCount,
4870
+ ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
4871
+ ...verdict.line !== void 0 ? { line: verdict.line } : {}
4872
+ });
4873
+ }
4874
+ const tamperedCount = rows.filter((r) => r.status === "tampered").length;
4875
+ if (options.json === true) {
4876
+ console.log(JSON.stringify(rows, null, 2));
4877
+ } else {
4878
+ for (const row of rows) {
4879
+ console.log(`${row.session_id} ${renderVerdict(row)}`);
4880
+ }
4881
+ const tally = (status) => rows.filter((r) => r.status === status).length;
4882
+ console.log(
4883
+ `Sessions: ${rows.length} total \u2014 ${tally("verified")} verified, ${tally("unchained")} unchained, ${tally("empty")} empty, ${tally("incomplete")} incomplete, ${tally("in_progress")} in_progress, ${tamperedCount} tampered`
4884
+ );
4885
+ }
4886
+ if (tamperedCount > 0) {
4887
+ process.exitCode = 1;
4888
+ }
4889
+ }
4890
+ function renderVerdict(row) {
4891
+ switch (row.status) {
4892
+ case "verified":
4893
+ return `verified (${row.event_count} events)`;
4894
+ case "tampered":
4895
+ return row.line !== void 0 ? `TAMPERED (${row.reason} at line ${row.line})` : `TAMPERED (${row.reason})`;
4896
+ case "incomplete":
4897
+ return "incomplete (session.yaml missing; re-import to repair)";
4898
+ case "in_progress":
4899
+ return `in_progress (${row.event_count} events; live session, anchor written at finalize)`;
4900
+ case "unchained":
4901
+ return "unchained (session created before event-log chaining)";
4902
+ case "empty":
4903
+ return "empty";
4904
+ }
4905
+ }
4906
+ async function resolveRepositoryRootForVerify(cwd) {
4907
+ try {
4908
+ return await resolveRepositoryRoot14(cwd);
4909
+ } catch (error) {
4910
+ if (error instanceof Error && error.message === "Not a git repository") {
4911
+ throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou verify'.", {
4912
+ cause: error
4913
+ });
4914
+ }
4915
+ throw error;
4916
+ }
4917
+ }
4918
+ async function assertWorkspaceInitialized10(basouRoot) {
4919
+ try {
4920
+ await assertBasouRootSafe13(basouRoot);
4921
+ } catch (error) {
4922
+ if (findErrorCode12(error, "ENOENT")) {
4923
+ throw new Error("Workspace not initialized. Run 'basou init' first.");
4924
+ }
4925
+ throw error;
4926
+ }
4927
+ }
4928
+
4719
4929
  // src/commands/view.ts
4720
4930
  import { spawn } from "child_process";
4721
- import { assertBasouRootSafe as assertBasouRootSafe13, basouPaths as basouPaths13, findErrorCode as findErrorCode13, resolveRepositoryRoot as resolveRepositoryRoot14 } from "@basou/core";
4931
+ import { assertBasouRootSafe as assertBasouRootSafe14, basouPaths as basouPaths14, findErrorCode as findErrorCode14, resolveRepositoryRoot as resolveRepositoryRoot15 } from "@basou/core";
4722
4932
  import { InvalidArgumentError as InvalidArgumentError5 } from "commander";
4723
4933
 
4724
4934
  // src/lib/view-server.ts
@@ -4727,7 +4937,7 @@ import { join as join8 } from "path";
4727
4937
  import {
4728
4938
  computeWorkStats as computeWorkStats2,
4729
4939
  enumerateApprovals as enumerateApprovals2,
4730
- findErrorCode as findErrorCode12,
4940
+ findErrorCode as findErrorCode13,
4731
4941
  isLazyExpired as isLazyExpired2,
4732
4942
  loadApproval as loadApproval2,
4733
4943
  loadSessionEntries as loadSessionEntries3,
@@ -4735,7 +4945,7 @@ import {
4735
4945
  readAllEvents as readAllEvents2,
4736
4946
  readManifest as readManifest8,
4737
4947
  readMarkdownFile as readMarkdownFile4,
4738
- readSessionYaml as readSessionYaml2,
4948
+ readSessionYaml as readSessionYaml3,
4739
4949
  readTaskFile as readTaskFile2,
4740
4950
  renderDecisions as renderDecisions3,
4741
4951
  renderHandoff as renderHandoff3
@@ -5329,7 +5539,7 @@ async function overview(deps) {
5329
5539
  try {
5330
5540
  manifest = await readManifest8(deps.paths);
5331
5541
  } catch (error) {
5332
- if (findErrorCode12(error, "ENOENT")) {
5542
+ if (findErrorCode13(error, "ENOENT")) {
5333
5543
  return { initialized: false, repoRoot: deps.repoRoot };
5334
5544
  }
5335
5545
  throw error;
@@ -5376,7 +5586,7 @@ async function sessionsList(deps) {
5376
5586
  async function sessionDetail(deps, sessionId) {
5377
5587
  let session;
5378
5588
  try {
5379
- session = await readSessionYaml2(deps.paths, sessionId);
5589
+ session = await readSessionYaml3(deps.paths, sessionId);
5380
5590
  } catch (error) {
5381
5591
  if (error instanceof Error && error.message === "YAML file not found") {
5382
5592
  throw new HttpError(404, "Session not found");
@@ -5544,8 +5754,8 @@ async function runView(options, ctx = {}) {
5544
5754
  async function doRunView(options, ctx) {
5545
5755
  const cwd = ctx.cwd ?? process.cwd();
5546
5756
  const repositoryRoot = await resolveRepositoryRootForView(cwd);
5547
- const paths = basouPaths13(repositoryRoot);
5548
- await assertWorkspaceInitialized10(paths.root);
5757
+ const paths = basouPaths14(repositoryRoot);
5758
+ await assertWorkspaceInitialized11(paths.root);
5549
5759
  const deps = {
5550
5760
  paths,
5551
5761
  repoRoot: repositoryRoot,
@@ -5576,7 +5786,7 @@ async function startListening(port, deps) {
5576
5786
  try {
5577
5787
  return await startViewServer({ port, deps });
5578
5788
  } catch (error) {
5579
- if (findErrorCode13(error, "EADDRINUSE")) {
5789
+ if (findErrorCode14(error, "EADDRINUSE")) {
5580
5790
  throw new Error(`Port ${port} is already in use. Pass --port <n> to choose another.`, {
5581
5791
  cause: error
5582
5792
  });
@@ -5627,7 +5837,7 @@ function waitForShutdown(signal) {
5627
5837
  }
5628
5838
  async function resolveRepositoryRootForView(cwd) {
5629
5839
  try {
5630
- return await resolveRepositoryRoot14(cwd);
5840
+ return await resolveRepositoryRoot15(cwd);
5631
5841
  } catch (error) {
5632
5842
  if (error instanceof Error && error.message === "Not a git repository") {
5633
5843
  throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou view'.", {
@@ -5637,11 +5847,11 @@ async function resolveRepositoryRootForView(cwd) {
5637
5847
  throw error;
5638
5848
  }
5639
5849
  }
5640
- async function assertWorkspaceInitialized10(basouRoot) {
5850
+ async function assertWorkspaceInitialized11(basouRoot) {
5641
5851
  try {
5642
- await assertBasouRootSafe13(basouRoot);
5852
+ await assertBasouRootSafe14(basouRoot);
5643
5853
  } catch (error) {
5644
- if (findErrorCode13(error, "ENOENT")) {
5854
+ if (findErrorCode14(error, "ENOENT")) {
5645
5855
  throw new Error("Workspace not initialized. Run 'basou init' first.");
5646
5856
  }
5647
5857
  throw error;
@@ -5663,6 +5873,7 @@ function buildProgram() {
5663
5873
  registerSessionCommand(program2);
5664
5874
  registerImportCommand(program2);
5665
5875
  registerRefreshCommand(program2);
5876
+ registerVerifyCommand(program2);
5666
5877
  registerViewCommand(program2);
5667
5878
  registerApprovalCommand(program2);
5668
5879
  registerDecisionCommand(program2);