@basou/core 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
@@ -767,7 +767,7 @@ function isLazyExpired(approval, now) {
767
767
 
768
768
  // src/decisions/decisions-renderer.ts
769
769
  import { lstat } from "fs/promises";
770
- import { dirname, join as join4, resolve } from "path";
770
+ import { dirname as dirname2, join as join6, resolve } from "path";
771
771
 
772
772
  // src/events/event-replay.ts
773
773
  import { createReadStream } from "fs";
@@ -781,7 +781,14 @@ var BaseEventSchema = z3.object({
781
781
  id: EventIdSchema,
782
782
  session_id: SessionIdSchema,
783
783
  occurred_at: IsoTimestampSchema,
784
- source: EventSourceSchema
784
+ source: EventSourceSchema,
785
+ // Tamper-evidence back-pointer (hex sha-256 of the PREVIOUS event line's
786
+ // written bytes; the first line carries the session-bound genesis hash).
787
+ // Present only on sessions written with chaining enabled (import paths);
788
+ // live/ad-hoc sessions omit it. Declared on the base so the `.strict()`
789
+ // variants below treat it as a known key. Additive optional => no
790
+ // schema_version bump.
791
+ prev_hash: z3.string().optional()
785
792
  });
786
793
  var SessionStartedEventSchema = BaseEventSchema.extend({
787
794
  type: z3.literal("session_started")
@@ -1006,7 +1013,208 @@ async function readAllEvents(sessionDir, options = {}) {
1006
1013
 
1007
1014
  // src/storage/sessions.ts
1008
1015
  import { readdir as readdir2 } from "fs/promises";
1009
- import { join as join3 } from "path";
1016
+ import { join as join5 } from "path";
1017
+
1018
+ // src/events/chained-append.ts
1019
+ import { appendFile, readFile as readFile3 } from "fs/promises";
1020
+ import { join as join4 } from "path";
1021
+
1022
+ // src/storage/lockfile.ts
1023
+ import { mkdir, readFile as readFile2, unlink as unlink2 } from "fs/promises";
1024
+ import { dirname, join as join3 } from "path";
1025
+ var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
1026
+ async function acquireLock(paths, scope, resourceId) {
1027
+ const lockPath = lockfilePath(paths, scope, resourceId);
1028
+ const body = {
1029
+ pid: process.pid,
1030
+ acquired_at: (/* @__PURE__ */ new Date()).toISOString()
1031
+ };
1032
+ const serialised = JSON.stringify(body);
1033
+ try {
1034
+ await atomicCreate(lockPath, serialised);
1035
+ } catch (error) {
1036
+ if (findErrorCode(error, "ENOENT")) {
1037
+ try {
1038
+ await mkdir(dirname(lockPath), { recursive: true });
1039
+ await atomicCreate(lockPath, serialised);
1040
+ return {
1041
+ release: async () => {
1042
+ await unlink2(lockPath).catch(() => void 0);
1043
+ }
1044
+ };
1045
+ } catch (retryError) {
1046
+ throw new Error("Failed to acquire lock", { cause: retryError });
1047
+ }
1048
+ }
1049
+ if (!findErrorCode(error, "EEXIST")) {
1050
+ throw error;
1051
+ }
1052
+ const stale = await isStaleLock(lockPath);
1053
+ if (!stale) {
1054
+ throw new Error("Lock is held by another process", { cause: error });
1055
+ }
1056
+ await unlink2(lockPath).catch(() => void 0);
1057
+ try {
1058
+ await atomicCreate(lockPath, serialised);
1059
+ } catch (retryError) {
1060
+ throw new Error("Lock is held by another process", { cause: retryError });
1061
+ }
1062
+ }
1063
+ return {
1064
+ release: async () => {
1065
+ await unlink2(lockPath).catch(() => void 0);
1066
+ }
1067
+ };
1068
+ }
1069
+ async function isStaleLock(lockPath) {
1070
+ let body;
1071
+ try {
1072
+ const raw = await readFile2(lockPath, "utf8");
1073
+ const parsed = JSON.parse(raw);
1074
+ if (typeof parsed !== "object" || parsed === null) return true;
1075
+ const candidate = parsed;
1076
+ if (typeof candidate.pid !== "number" || typeof candidate.acquired_at !== "string") {
1077
+ return true;
1078
+ }
1079
+ body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
1080
+ } catch {
1081
+ return true;
1082
+ }
1083
+ const ageMs = Date.now() - Date.parse(body.acquired_at);
1084
+ if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
1085
+ return true;
1086
+ }
1087
+ try {
1088
+ process.kill(body.pid, 0);
1089
+ return false;
1090
+ } catch (error) {
1091
+ if (findErrorCode(error, "ESRCH")) return true;
1092
+ return false;
1093
+ }
1094
+ }
1095
+ function lockfilePath(paths, scope, resourceId) {
1096
+ const sep = resourceId.indexOf("_");
1097
+ const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
1098
+ return join3(paths.locks, `${scope}_${ulid2}.lock`);
1099
+ }
1100
+
1101
+ // src/events/chain.ts
1102
+ import { createHash } from "crypto";
1103
+ var GENESIS_PREFIX = "basou:event-chain:v1:";
1104
+ function genesisHash(sessionId) {
1105
+ return createHash("sha256").update(`${GENESIS_PREFIX}${sessionId}`, "utf8").digest("hex");
1106
+ }
1107
+ function lineHash(rawLine) {
1108
+ const hash = createHash("sha256");
1109
+ if (typeof rawLine === "string") {
1110
+ hash.update(rawLine, "utf8");
1111
+ } else {
1112
+ hash.update(rawLine);
1113
+ }
1114
+ return hash.digest("hex");
1115
+ }
1116
+ function serializeEventLine(event) {
1117
+ return JSON.stringify(event);
1118
+ }
1119
+ function chainEvents(events, sessionId) {
1120
+ let prev = genesisHash(sessionId);
1121
+ const lines = [];
1122
+ for (const event of events) {
1123
+ const chained = { ...event, prev_hash: prev };
1124
+ const line = serializeEventLine(chained);
1125
+ lines.push(line);
1126
+ prev = lineHash(line);
1127
+ }
1128
+ return { lines, headHash: prev, count: lines.length };
1129
+ }
1130
+ function chainRawJsonLines(rawLines, sessionId) {
1131
+ let prev = genesisHash(sessionId);
1132
+ const lines = [];
1133
+ for (const rawLine of rawLines) {
1134
+ const parsed = JSON.parse(rawLine);
1135
+ const line = JSON.stringify({ ...parsed, prev_hash: prev });
1136
+ lines.push(line);
1137
+ prev = lineHash(line);
1138
+ }
1139
+ return { lines, headHash: prev, count: lines.length };
1140
+ }
1141
+
1142
+ // src/events/chained-append.ts
1143
+ function splitLinesBytes(buf) {
1144
+ const out = [];
1145
+ let start = 0;
1146
+ for (let i = 0; i < buf.length; i++) {
1147
+ if (buf[i] === 10) {
1148
+ out.push(buf.subarray(start, i));
1149
+ start = i + 1;
1150
+ }
1151
+ }
1152
+ if (start < buf.length) out.push(buf.subarray(start));
1153
+ return out;
1154
+ }
1155
+ function carriesPrevHash(line) {
1156
+ try {
1157
+ const obj = JSON.parse(line.toString("utf8"));
1158
+ return typeof obj === "object" && obj !== null && "prev_hash" in obj;
1159
+ } catch {
1160
+ return false;
1161
+ }
1162
+ }
1163
+ async function inspectChainTail(paths, sessionId) {
1164
+ const filePath = join4(paths.sessions, sessionId, "events.jsonl");
1165
+ let raw;
1166
+ try {
1167
+ raw = await readFile3(filePath);
1168
+ } catch (error) {
1169
+ if (findErrorCode(error, "ENOENT")) {
1170
+ return { chained: true, head: genesisHash(sessionId), count: 0 };
1171
+ }
1172
+ throw new Error("Failed to read events.jsonl", { cause: error });
1173
+ }
1174
+ if (raw.length === 0) {
1175
+ return { chained: true, head: genesisHash(sessionId), count: 0 };
1176
+ }
1177
+ if (raw[raw.length - 1] !== 10) {
1178
+ throw new Error("Unterminated final line in events.jsonl");
1179
+ }
1180
+ const lines = splitLinesBytes(raw);
1181
+ const first = lines[0];
1182
+ const last = lines[lines.length - 1];
1183
+ const firstChained = carriesPrevHash(first);
1184
+ if (firstChained !== carriesPrevHash(last)) {
1185
+ throw new Error("events.jsonl is partially chained");
1186
+ }
1187
+ return {
1188
+ chained: firstChained,
1189
+ head: firstChained ? lineHash(last) : genesisHash(sessionId),
1190
+ count: lines.length
1191
+ };
1192
+ }
1193
+ async function appendChainedEventLocked(paths, sessionId, event) {
1194
+ let validated;
1195
+ try {
1196
+ validated = EventSchema.parse(event);
1197
+ } catch (error) {
1198
+ throw new Error("Invalid Basou event payload", { cause: error });
1199
+ }
1200
+ const tail = await inspectChainTail(paths, sessionId);
1201
+ const line = tail.chained ? serializeEventLine({ ...validated, prev_hash: tail.head }) : serializeEventLine(validated);
1202
+ try {
1203
+ await appendFile(join4(paths.sessions, sessionId, "events.jsonl"), `${line}
1204
+ `, "utf8");
1205
+ } catch (error) {
1206
+ throw new Error("Failed to append event to events.jsonl", { cause: error });
1207
+ }
1208
+ return { chained: tail.chained };
1209
+ }
1210
+ async function appendChainedEvent(paths, sessionId, event) {
1211
+ const lock = await acquireLock(paths, "session", sessionId);
1212
+ try {
1213
+ return await appendChainedEventLocked(paths, sessionId, event);
1214
+ } finally {
1215
+ await lock.release();
1216
+ }
1217
+ }
1010
1218
 
1011
1219
  // src/schemas/session.schema.ts
1012
1220
  import { z as z4 } from "zod";
@@ -1062,6 +1270,10 @@ var SessionMetricsSchema = z4.object({
1062
1270
  active_time_method: z4.string().optional(),
1063
1271
  machine_active_time_ms: z4.number().int().nonnegative().optional()
1064
1272
  });
1273
+ var SessionIntegritySchema = z4.object({
1274
+ head_hash: z4.string(),
1275
+ event_count: z4.number().int().nonnegative()
1276
+ }).strict();
1065
1277
  var SessionInnerSchema = z4.object({
1066
1278
  id: SessionIdSchema,
1067
1279
  label: z4.string().optional(),
@@ -1077,7 +1289,8 @@ var SessionInnerSchema = z4.object({
1077
1289
  related_files: z4.array(z4.string()).default([]),
1078
1290
  events_log: z4.string().default("events.jsonl"),
1079
1291
  summary: z4.string().nullable().optional(),
1080
- metrics: SessionMetricsSchema.optional()
1292
+ metrics: SessionMetricsSchema.optional(),
1293
+ integrity: SessionIntegritySchema.optional()
1081
1294
  });
1082
1295
  var SessionSchema = z4.object({
1083
1296
  schema_version: SchemaVersionSchema,
@@ -1096,7 +1309,7 @@ async function enumerateSessionDirs(paths) {
1096
1309
  }
1097
1310
  }
1098
1311
  async function readSessionYaml(paths, sessionId) {
1099
- const filePath = join3(paths.sessions, sessionId, "session.yaml");
1312
+ const filePath = join5(paths.sessions, sessionId, "session.yaml");
1100
1313
  let raw;
1101
1314
  try {
1102
1315
  raw = await readYamlFile(filePath);
@@ -1110,11 +1323,26 @@ async function readSessionYaml(paths, sessionId) {
1110
1323
  }
1111
1324
  return result.data;
1112
1325
  }
1326
+ async function finalizeSessionYaml(paths, sessionId, mutate) {
1327
+ const lock = await acquireLock(paths, "session", sessionId);
1328
+ try {
1329
+ const session = await readSessionYaml(paths, sessionId);
1330
+ mutate(session);
1331
+ const tail = await inspectChainTail(paths, sessionId);
1332
+ if (tail.chained && tail.count > 0) {
1333
+ session.session.integrity = { head_hash: tail.head, event_count: tail.count };
1334
+ }
1335
+ const validated = SessionSchema.parse(session);
1336
+ await overwriteYamlFile(join5(paths.sessions, sessionId, "session.yaml"), validated);
1337
+ } finally {
1338
+ await lock.release();
1339
+ }
1340
+ }
1113
1341
  async function classifySuspect(paths, sessionId, session, now, onWarning) {
1114
1342
  if (session.session.status !== "running") {
1115
1343
  return { suspect: false, suspectReason: null };
1116
1344
  }
1117
- const sessionDir = join3(paths.sessions, sessionId);
1345
+ const sessionDir = join5(paths.sessions, sessionId);
1118
1346
  let endedFound = false;
1119
1347
  let lastEventOccurredAt = null;
1120
1348
  const replayOpts = onWarning !== void 0 ? { onWarning } : {};
@@ -1182,7 +1410,7 @@ async function renderDecisions(input) {
1182
1410
  const decisions = [];
1183
1411
  const knownEventIds = /* @__PURE__ */ new Set();
1184
1412
  for (const entry of entries) {
1185
- const sessionDir = join4(input.paths.sessions, entry.sessionId);
1413
+ const sessionDir = join6(input.paths.sessions, entry.sessionId);
1186
1414
  try {
1187
1415
  for await (const ev of replayEvents(sessionDir, {
1188
1416
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
@@ -1212,7 +1440,7 @@ async function renderDecisions(input) {
1212
1440
  const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
1213
1441
  return c !== 0 ? c : a.decisionId.localeCompare(b.decisionId);
1214
1442
  });
1215
- const repoRoot = dirname(input.paths.root);
1443
+ const repoRoot = dirname2(input.paths.root);
1216
1444
  const fileExistenceCache = /* @__PURE__ */ new Map();
1217
1445
  async function fileExists(relPath) {
1218
1446
  const cached = fileExistenceCache.get(relPath);
@@ -1287,8 +1515,8 @@ function shortDecisionSessionId(sessionId) {
1287
1515
  }
1288
1516
 
1289
1517
  // src/events/event-writer.ts
1290
- import { appendFile } from "fs/promises";
1291
- import { join as join5 } from "path";
1518
+ import { appendFile as appendFile2 } from "fs/promises";
1519
+ import { basename, join as join7 } from "path";
1292
1520
  async function appendEvent(sessionDir, event) {
1293
1521
  let validated;
1294
1522
  try {
@@ -1296,15 +1524,15 @@ async function appendEvent(sessionDir, event) {
1296
1524
  } catch (error) {
1297
1525
  throw new Error("Invalid Basou event payload", { cause: error });
1298
1526
  }
1299
- const line = `${JSON.stringify(validated)}
1527
+ const line = `${serializeEventLine(validated)}
1300
1528
  `;
1301
1529
  try {
1302
- await appendFile(join5(sessionDir, "events.jsonl"), line, "utf8");
1530
+ await appendFile2(join7(sessionDir, "events.jsonl"), line, "utf8");
1303
1531
  } catch (error) {
1304
1532
  throw new Error("Failed to append event to events.jsonl", { cause: error });
1305
1533
  }
1306
1534
  }
1307
- async function writeEventsBulk(sessionDir, events) {
1535
+ async function writeEventsBulk(sessionDir, events, options = {}) {
1308
1536
  const validated = [];
1309
1537
  try {
1310
1538
  for (const event of events) {
@@ -1313,14 +1541,182 @@ async function writeEventsBulk(sessionDir, events) {
1313
1541
  } catch (error) {
1314
1542
  throw new Error("Invalid Basou event payload", { cause: error });
1315
1543
  }
1316
- const filePath = join5(sessionDir, "events.jsonl");
1317
- const body = validated.length > 0 ? `${validated.map((e) => JSON.stringify(e)).join("\n")}
1544
+ const filePath = join7(sessionDir, "events.jsonl");
1545
+ let body;
1546
+ let result = null;
1547
+ if (options.chain === true) {
1548
+ const { lines, headHash, count } = chainEvents(validated, basename(sessionDir));
1549
+ body = lines.length > 0 ? `${lines.join("\n")}
1550
+ ` : "";
1551
+ result = count > 0 ? { headHash, count } : null;
1552
+ } else {
1553
+ body = validated.length > 0 ? `${validated.map(serializeEventLine).join("\n")}
1318
1554
  ` : "";
1555
+ }
1319
1556
  try {
1320
1557
  await atomicReplace(filePath, body);
1321
1558
  } catch (error) {
1322
1559
  throw new Error("Failed to write events.jsonl", { cause: error });
1323
1560
  }
1561
+ return result;
1562
+ }
1563
+
1564
+ // src/events/verify.ts
1565
+ import { readFile as readFile4 } from "fs/promises";
1566
+ import { join as join8 } from "path";
1567
+ var STRICT_STATUSES = /* @__PURE__ */ new Set([
1568
+ "completed",
1569
+ "failed",
1570
+ "interrupted",
1571
+ "imported",
1572
+ "archived"
1573
+ ]);
1574
+ function isLiveStatus(status) {
1575
+ return !STRICT_STATUSES.has(status);
1576
+ }
1577
+ async function verifyEventsChain(paths, sessionId) {
1578
+ const first = await verifyOnce(paths, sessionId);
1579
+ if (first.status === "tampered" && first.reason === "anchor_mismatch") {
1580
+ return await verifyOnce(paths, sessionId);
1581
+ }
1582
+ return first;
1583
+ }
1584
+ async function verifyOnce(paths, sessionId) {
1585
+ const sessionDir = join8(paths.sessions, sessionId);
1586
+ let raw = null;
1587
+ try {
1588
+ raw = await readFile4(join8(sessionDir, "events.jsonl"));
1589
+ } catch (error) {
1590
+ if (!findErrorCode(error, "ENOENT")) {
1591
+ throw new Error("Failed to read events.jsonl", { cause: error });
1592
+ }
1593
+ }
1594
+ let anchor;
1595
+ try {
1596
+ const session = await readSessionYaml(paths, sessionId);
1597
+ anchor = {
1598
+ kind: "present",
1599
+ integrity: session.session.integrity,
1600
+ status: session.session.status
1601
+ };
1602
+ } catch (error) {
1603
+ if (error instanceof Error && error.message === "YAML file not found") {
1604
+ anchor = { kind: "absent" };
1605
+ } else {
1606
+ anchor = { kind: "unreadable" };
1607
+ }
1608
+ }
1609
+ const terminated = raw === null || raw.length === 0 || raw[raw.length - 1] === 10;
1610
+ const segments = raw === null ? [] : splitLinesBytes2(raw);
1611
+ const tailFragment = !terminated && segments.length > 0 ? segments.pop() : null;
1612
+ const lines = segments;
1613
+ const carriesPrevHash2 = (s) => {
1614
+ try {
1615
+ const obj = JSON.parse(s.toString("utf8"));
1616
+ return typeof obj === "object" && obj !== null && "prev_hash" in obj;
1617
+ } catch {
1618
+ return false;
1619
+ }
1620
+ };
1621
+ const chained = lines.some((l) => l.length > 0 && carriesPrevHash2(l)) || tailFragment !== null && carriesPrevHash2(tailFragment);
1622
+ if (!chained) {
1623
+ if (anchor.kind === "present" && anchor.integrity !== void 0) {
1624
+ return {
1625
+ status: "tampered",
1626
+ eventCount: lines.length,
1627
+ reason: "anchor_without_chain"
1628
+ };
1629
+ }
1630
+ if (raw === null || raw.length === 0) {
1631
+ return { status: "empty", eventCount: 0 };
1632
+ }
1633
+ return { status: "unchained", eventCount: lines.length };
1634
+ }
1635
+ let expected = genesisHash(sessionId);
1636
+ for (let i = 0; i < lines.length; i++) {
1637
+ const line = lines[i];
1638
+ const lineNo = i + 1;
1639
+ if (line.length === 0) {
1640
+ return { status: "tampered", eventCount: lines.length, reason: "blank_line", line: lineNo };
1641
+ }
1642
+ let parsed;
1643
+ try {
1644
+ parsed = JSON.parse(line.toString("utf8"));
1645
+ } catch {
1646
+ return {
1647
+ status: "tampered",
1648
+ eventCount: lines.length,
1649
+ reason: "malformed_line",
1650
+ line: lineNo
1651
+ };
1652
+ }
1653
+ const record = parsed;
1654
+ if (typeof record.prev_hash !== "string") {
1655
+ return {
1656
+ status: "tampered",
1657
+ eventCount: lines.length,
1658
+ reason: "missing_prev_hash",
1659
+ line: lineNo
1660
+ };
1661
+ }
1662
+ if (record.prev_hash !== expected) {
1663
+ return {
1664
+ status: "tampered",
1665
+ eventCount: lines.length,
1666
+ reason: i === 0 ? "genesis_mismatch" : "broken_link",
1667
+ line: lineNo
1668
+ };
1669
+ }
1670
+ if (record.session_id !== sessionId) {
1671
+ return {
1672
+ status: "tampered",
1673
+ eventCount: lines.length,
1674
+ reason: "session_id_mismatch",
1675
+ line: lineNo
1676
+ };
1677
+ }
1678
+ expected = lineHash(line);
1679
+ }
1680
+ const live = anchor.kind === "present" && isLiveStatus(anchor.status);
1681
+ if (tailFragment !== null || !terminated) {
1682
+ if (live) {
1683
+ return { status: "in_progress", eventCount: lines.length };
1684
+ }
1685
+ return {
1686
+ status: "tampered",
1687
+ eventCount: lines.length,
1688
+ reason: "torn_tail",
1689
+ line: lines.length + 1
1690
+ };
1691
+ }
1692
+ if (anchor.kind === "absent") {
1693
+ return { status: "incomplete", eventCount: lines.length, reason: "yaml_missing" };
1694
+ }
1695
+ if (anchor.kind === "unreadable") {
1696
+ return { status: "tampered", eventCount: lines.length, reason: "yaml_unreadable" };
1697
+ }
1698
+ if (live) {
1699
+ return { status: "in_progress", eventCount: lines.length };
1700
+ }
1701
+ if (anchor.integrity === void 0) {
1702
+ return { status: "tampered", eventCount: lines.length, reason: "anchor_missing" };
1703
+ }
1704
+ if (anchor.integrity.event_count !== lines.length || anchor.integrity.head_hash !== expected) {
1705
+ return { status: "tampered", eventCount: lines.length, reason: "anchor_mismatch" };
1706
+ }
1707
+ return { status: "verified", eventCount: lines.length };
1708
+ }
1709
+ function splitLinesBytes2(buf) {
1710
+ const out = [];
1711
+ let start = 0;
1712
+ for (let i = 0; i < buf.length; i++) {
1713
+ if (buf[i] === 10) {
1714
+ out.push(buf.subarray(start, i));
1715
+ start = i + 1;
1716
+ }
1717
+ }
1718
+ if (start < buf.length) out.push(buf.subarray(start));
1719
+ return out;
1324
1720
  }
1325
1721
 
1326
1722
  // src/git/snapshot.ts
@@ -1617,12 +2013,12 @@ function parseDiffNameStatus(raw) {
1617
2013
  }
1618
2014
 
1619
2015
  // src/handoff/handoff-renderer.ts
1620
- import { join as join10 } from "path";
2016
+ import { join as join12 } from "path";
1621
2017
 
1622
2018
  // src/storage/tasks.ts
1623
- import { createHash } from "crypto";
1624
- import { mkdir as mkdir2, readdir as readdir3, readFile as readFile5, rename as rename2, stat as stat2, unlink as unlink3 } from "fs/promises";
1625
- import { join as join9 } from "path";
2019
+ import { createHash as createHash2 } from "crypto";
2020
+ import { mkdir as mkdir3, readdir as readdir3, readFile as readFile7, rename as rename2, stat as stat2, unlink as unlink3 } from "fs/promises";
2021
+ import { join as join11 } from "path";
1626
2022
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
1627
2023
  import { z as z8 } from "zod";
1628
2024
 
@@ -1664,9 +2060,9 @@ var TaskSchema = z6.object({
1664
2060
  });
1665
2061
 
1666
2062
  // src/storage/ad-hoc-session.ts
1667
- import { mkdir, rm } from "fs/promises";
2063
+ import { mkdir as mkdir2, rm } from "fs/promises";
1668
2064
  import { homedir } from "os";
1669
- import { join as join6 } from "path";
2065
+ import { join as join9 } from "path";
1670
2066
 
1671
2067
  // src/lib/path-sanitizer.ts
1672
2068
  import { posix as path } from "path";
@@ -1746,87 +2142,94 @@ async function createAdHocSessionWithEvent(input) {
1746
2142
  taskId: input.taskId ?? null
1747
2143
  })
1748
2144
  );
1749
- const sessionDir = join6(input.paths.sessions, sessionId);
2145
+ const sessionDir = join9(input.paths.sessions, sessionId);
2146
+ const sessionYamlPath = join9(sessionDir, "session.yaml");
2147
+ const lock = await acquireLock(input.paths, "session", sessionId);
2148
+ let bulkResult = null;
1750
2149
  try {
1751
- await mkdir(sessionDir, { recursive: true });
1752
- } catch (error) {
1753
- throw new Error("Failed to create session directory", { cause: error });
1754
- }
1755
- const sessionYamlPath = join6(sessionDir, "session.yaml");
1756
- try {
1757
- await linkYamlFile(sessionYamlPath, initialSession);
1758
- } catch (error) {
1759
- await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
1760
- if (findErrorCode(error, "EEXIST")) {
1761
- throw new Error("Session directory collision (retry the command)", {
1762
- cause: error
1763
- });
2150
+ try {
2151
+ await mkdir2(sessionDir, { recursive: true });
2152
+ } catch (error) {
2153
+ throw new Error("Failed to create session directory", { cause: error });
1764
2154
  }
1765
- throw error;
1766
- }
1767
- try {
1768
- const targetEvents = input.targetEventBuilders.map((build, index) => {
1769
- const targetEventId = targetEventIds[index];
1770
- return assertTargetEventIdentity(build(sessionId, targetEventId), sessionId, targetEventId);
1771
- });
1772
- const events = [
1773
- {
1774
- schema_version: "0.1.0",
1775
- id: startedEventId,
1776
- session_id: sessionId,
1777
- occurred_at: input.occurredAt,
1778
- source: "local-cli",
1779
- type: "session_started"
1780
- },
1781
- {
1782
- schema_version: "0.1.0",
1783
- id: statusToRunningEventId,
1784
- session_id: sessionId,
1785
- occurred_at: input.occurredAt,
1786
- source: "local-cli",
1787
- type: "session_status_changed",
1788
- from: "initialized",
1789
- to: "running"
1790
- },
1791
- ...targetEvents,
1792
- {
1793
- schema_version: "0.1.0",
1794
- id: statusToCompletedEventId,
1795
- session_id: sessionId,
1796
- occurred_at: input.occurredAt,
1797
- source: "local-cli",
1798
- type: "session_status_changed",
1799
- from: "running",
1800
- to: "completed"
1801
- },
1802
- {
1803
- schema_version: "0.1.0",
1804
- id: endedEventId,
1805
- session_id: sessionId,
1806
- occurred_at: input.occurredAt,
1807
- source: "local-cli",
1808
- type: "session_ended",
1809
- exit_code: 0
1810
- }
1811
- ];
1812
- await writeEventsBulk(sessionDir, events);
1813
- } catch (error) {
1814
- await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
1815
- throw error;
1816
- }
1817
- try {
1818
- const finalSession = SessionSchema.parse({
1819
- ...initialSession,
1820
- session: {
1821
- ...initialSession.session,
1822
- status: "completed",
1823
- ended_at: input.occurredAt,
1824
- invocation: { ...initialSession.session.invocation, exit_code: 0 }
2155
+ try {
2156
+ await linkYamlFile(sessionYamlPath, initialSession);
2157
+ } catch (error) {
2158
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
2159
+ if (findErrorCode(error, "EEXIST")) {
2160
+ throw new Error("Session directory collision (retry the command)", {
2161
+ cause: error
2162
+ });
1825
2163
  }
1826
- });
1827
- await overwriteYamlFile(sessionYamlPath, finalSession);
1828
- } catch (error) {
1829
- throw new FailedToFinalizeError(sessionId, targetEventIds, error);
2164
+ throw error;
2165
+ }
2166
+ try {
2167
+ const targetEvents = input.targetEventBuilders.map((build, index) => {
2168
+ const targetEventId = targetEventIds[index];
2169
+ return assertTargetEventIdentity(build(sessionId, targetEventId), sessionId, targetEventId);
2170
+ });
2171
+ const events = [
2172
+ {
2173
+ schema_version: "0.1.0",
2174
+ id: startedEventId,
2175
+ session_id: sessionId,
2176
+ occurred_at: input.occurredAt,
2177
+ source: "local-cli",
2178
+ type: "session_started"
2179
+ },
2180
+ {
2181
+ schema_version: "0.1.0",
2182
+ id: statusToRunningEventId,
2183
+ session_id: sessionId,
2184
+ occurred_at: input.occurredAt,
2185
+ source: "local-cli",
2186
+ type: "session_status_changed",
2187
+ from: "initialized",
2188
+ to: "running"
2189
+ },
2190
+ ...targetEvents,
2191
+ {
2192
+ schema_version: "0.1.0",
2193
+ id: statusToCompletedEventId,
2194
+ session_id: sessionId,
2195
+ occurred_at: input.occurredAt,
2196
+ source: "local-cli",
2197
+ type: "session_status_changed",
2198
+ from: "running",
2199
+ to: "completed"
2200
+ },
2201
+ {
2202
+ schema_version: "0.1.0",
2203
+ id: endedEventId,
2204
+ session_id: sessionId,
2205
+ occurred_at: input.occurredAt,
2206
+ source: "local-cli",
2207
+ type: "session_ended",
2208
+ exit_code: 0
2209
+ }
2210
+ ];
2211
+ bulkResult = await writeEventsBulk(sessionDir, events, { chain: true });
2212
+ } catch (error) {
2213
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
2214
+ throw error;
2215
+ }
2216
+ try {
2217
+ const finalSession = SessionSchema.parse({
2218
+ ...initialSession,
2219
+ session: {
2220
+ ...initialSession.session,
2221
+ status: "completed",
2222
+ ended_at: input.occurredAt,
2223
+ invocation: { ...initialSession.session.invocation, exit_code: 0 },
2224
+ ...bulkResult !== null ? { integrity: { head_hash: bulkResult.headHash, event_count: bulkResult.count } } : {}
2225
+ }
2226
+ });
2227
+ await overwriteYamlFile(sessionYamlPath, finalSession);
2228
+ } catch (error) {
2229
+ throw new FailedToFinalizeError(sessionId, targetEventIds, error);
2230
+ }
2231
+ } finally {
2232
+ await lock.release();
1830
2233
  }
1831
2234
  return {
1832
2235
  sessionId,
@@ -1875,8 +2278,7 @@ async function appendEventToExistingSession(input) {
1875
2278
  }
1876
2279
  const eventId = prefixedUlid("evt");
1877
2280
  const event = assertTargetEventIdentity(input.eventBuilder(eventId), input.sessionId, eventId);
1878
- const sessionDir = join6(input.paths.sessions, input.sessionId);
1879
- await appendEvent(sessionDir, event);
2281
+ await appendChainedEventLocked(input.paths, input.sessionId, event);
1880
2282
  return { eventId, sessionStatus: status };
1881
2283
  }
1882
2284
  function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
@@ -1889,75 +2291,9 @@ function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
1889
2291
  return event;
1890
2292
  }
1891
2293
 
1892
- // src/storage/lockfile.ts
1893
- import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1894
- import { join as join7 } from "path";
1895
- var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
1896
- async function acquireLock(paths, scope, resourceId) {
1897
- const lockPath = lockfilePath(paths, scope, resourceId);
1898
- const body = {
1899
- pid: process.pid,
1900
- acquired_at: (/* @__PURE__ */ new Date()).toISOString()
1901
- };
1902
- const serialised = JSON.stringify(body);
1903
- try {
1904
- await atomicCreate(lockPath, serialised);
1905
- } catch (error) {
1906
- if (!findErrorCode(error, "EEXIST")) {
1907
- throw error;
1908
- }
1909
- const stale = await isStaleLock(lockPath);
1910
- if (!stale) {
1911
- throw new Error("Lock is held by another process", { cause: error });
1912
- }
1913
- await unlink2(lockPath).catch(() => void 0);
1914
- try {
1915
- await atomicCreate(lockPath, serialised);
1916
- } catch (retryError) {
1917
- throw new Error("Lock is held by another process", { cause: retryError });
1918
- }
1919
- }
1920
- return {
1921
- release: async () => {
1922
- await unlink2(lockPath).catch(() => void 0);
1923
- }
1924
- };
1925
- }
1926
- async function isStaleLock(lockPath) {
1927
- let body;
1928
- try {
1929
- const raw = await readFile3(lockPath, "utf8");
1930
- const parsed = JSON.parse(raw);
1931
- if (typeof parsed !== "object" || parsed === null) return true;
1932
- const candidate = parsed;
1933
- if (typeof candidate.pid !== "number" || typeof candidate.acquired_at !== "string") {
1934
- return true;
1935
- }
1936
- body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
1937
- } catch {
1938
- return true;
1939
- }
1940
- const ageMs = Date.now() - Date.parse(body.acquired_at);
1941
- if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
1942
- return true;
1943
- }
1944
- try {
1945
- process.kill(body.pid, 0);
1946
- return false;
1947
- } catch (error) {
1948
- if (findErrorCode(error, "ESRCH")) return true;
1949
- return false;
1950
- }
1951
- }
1952
- function lockfilePath(paths, scope, resourceId) {
1953
- const sep = resourceId.indexOf("_");
1954
- const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
1955
- return join7(paths.locks, `${scope}_${ulid2}.lock`);
1956
- }
1957
-
1958
2294
  // src/storage/task-index.ts
1959
- import { readFile as readFile4 } from "fs/promises";
1960
- import { join as join8 } from "path";
2295
+ import { readFile as readFile6 } from "fs/promises";
2296
+ import { join as join10 } from "path";
1961
2297
 
1962
2298
  // src/schemas/task-index.schema.ts
1963
2299
  import { z as z7 } from "zod";
@@ -1976,13 +2312,13 @@ var TASK_INDEX_SCHEMA_VERSION = "0.1.0";
1976
2312
 
1977
2313
  // src/storage/task-index.ts
1978
2314
  function taskIndexPath(paths) {
1979
- return join8(paths.tasks, "index.json");
2315
+ return join10(paths.tasks, "index.json");
1980
2316
  }
1981
2317
  async function readTaskIndex(paths) {
1982
2318
  const filePath = taskIndexPath(paths);
1983
2319
  let raw;
1984
2320
  try {
1985
- raw = await readFile4(filePath, "utf8");
2321
+ raw = await readFile6(filePath, "utf8");
1986
2322
  } catch (error) {
1987
2323
  if (findErrorCode(error, "ENOENT")) {
1988
2324
  throw new Error("Task index not found", { cause: error });
@@ -2090,10 +2426,10 @@ function splitFrontMatter(raw) {
2090
2426
  return { yamlText, body };
2091
2427
  }
2092
2428
  async function readTaskFile(paths, taskId) {
2093
- const filePath = join9(paths.tasks, `${taskId}.md`);
2429
+ const filePath = join11(paths.tasks, `${taskId}.md`);
2094
2430
  let raw;
2095
2431
  try {
2096
- raw = await readFile5(filePath, "utf8");
2432
+ raw = await readFile7(filePath, "utf8");
2097
2433
  } catch (error) {
2098
2434
  if (findErrorCode(error, "ENOENT")) {
2099
2435
  throw new Error("Task file not found", { cause: error });
@@ -2123,7 +2459,7 @@ async function readTaskFile(paths, taskId) {
2123
2459
  }
2124
2460
  async function writeTaskFile(paths, taskId, doc, options) {
2125
2461
  const validated = TaskSchema.parse(doc.task);
2126
- const filePath = join9(paths.tasks, `${taskId}.md`);
2462
+ const filePath = join11(paths.tasks, `${taskId}.md`);
2127
2463
  const yamlText = stringifyYaml(validated);
2128
2464
  const trimmedBody = doc.body.length === 0 ? "" : `
2129
2465
  ${doc.body.endsWith("\n") ? doc.body : `${doc.body}
@@ -2208,7 +2544,7 @@ async function safeUpdateTaskIndex(paths, op) {
2208
2544
  }
2209
2545
  var ARCHIVE_DIR_NAME = "archive";
2210
2546
  function archiveTasksDir(paths) {
2211
- return join9(paths.tasks, ARCHIVE_DIR_NAME);
2547
+ return join11(paths.tasks, ARCHIVE_DIR_NAME);
2212
2548
  }
2213
2549
  async function enumerateArchivedTaskIds(paths) {
2214
2550
  let entries;
@@ -2238,10 +2574,10 @@ async function readTaskFileWithArchiveFallback(paths, taskId) {
2238
2574
  throw error;
2239
2575
  }
2240
2576
  }
2241
- const archiveFilePath = join9(archiveTasksDir(paths), `${taskId}.md`);
2577
+ const archiveFilePath = join11(archiveTasksDir(paths), `${taskId}.md`);
2242
2578
  let raw;
2243
2579
  try {
2244
- raw = await readFile5(archiveFilePath, "utf8");
2580
+ raw = await readFile7(archiveFilePath, "utf8");
2245
2581
  } catch (error) {
2246
2582
  if (findErrorCode(error, "ENOENT")) {
2247
2583
  throw new Error("Task file not found", { cause: error });
@@ -2532,7 +2868,7 @@ async function createTaskAttachLocked(input) {
2532
2868
  ...sessionDoc,
2533
2869
  session: { ...sessionDoc.session, task_id: input.taskId }
2534
2870
  };
2535
- await overwriteYamlFile(join9(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2871
+ await overwriteYamlFile(join11(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2536
2872
  } catch (error) {
2537
2873
  throw new TaskWriteAfterEventError({
2538
2874
  taskId: input.taskId,
@@ -2791,17 +3127,17 @@ function buildUpdatedDoc(input) {
2791
3127
  return { task: next, body: input.currentDoc.body };
2792
3128
  }
2793
3129
  async function computeTaskMdSnapshot(paths, taskId) {
2794
- const filePath = join9(paths.tasks, `${taskId}.md`);
2795
- const [stats, raw] = await Promise.all([stat2(filePath), readFile5(filePath)]);
2796
- const hash = createHash("sha256").update(raw).digest("hex");
3130
+ const filePath = join11(paths.tasks, `${taskId}.md`);
3131
+ const [stats, raw] = await Promise.all([stat2(filePath), readFile7(filePath)]);
3132
+ const hash = createHash2("sha256").update(raw).digest("hex");
2797
3133
  return { mtimeMs: stats.mtimeMs, hash };
2798
3134
  }
2799
3135
  async function readTaskFileWithSnapshot(paths, taskId) {
2800
- const filePath = join9(paths.tasks, `${taskId}.md`);
3136
+ const filePath = join11(paths.tasks, `${taskId}.md`);
2801
3137
  let rawBuffer;
2802
3138
  let stats;
2803
3139
  try {
2804
- [rawBuffer, stats] = await Promise.all([readFile5(filePath), stat2(filePath)]);
3140
+ [rawBuffer, stats] = await Promise.all([readFile7(filePath), stat2(filePath)]);
2805
3141
  } catch (error) {
2806
3142
  if (findErrorCode(error, "ENOENT")) {
2807
3143
  throw new Error("Task file not found", { cause: error });
@@ -2809,7 +3145,7 @@ async function readTaskFileWithSnapshot(paths, taskId) {
2809
3145
  throw new Error("Failed to read task file", { cause: error });
2810
3146
  }
2811
3147
  const raw = rawBuffer.toString("utf8");
2812
- const hash = createHash("sha256").update(rawBuffer).digest("hex");
3148
+ const hash = createHash2("sha256").update(rawBuffer).digest("hex");
2813
3149
  let split;
2814
3150
  try {
2815
3151
  split = splitFrontMatter(raw);
@@ -3289,7 +3625,7 @@ async function deleteTaskLocked(input) {
3289
3625
  });
3290
3626
  const eventId = adHoc.targetEventIds[0];
3291
3627
  try {
3292
- await unlink3(join9(input.paths.tasks, `${input.taskId}.md`));
3628
+ await unlink3(join11(input.paths.tasks, `${input.taskId}.md`));
3293
3629
  } catch (error) {
3294
3630
  throw new TaskWriteAfterEventError({
3295
3631
  taskId: input.taskId,
@@ -3359,10 +3695,10 @@ async function archiveTaskLocked(input) {
3359
3695
  { task: next, body: doc.body },
3360
3696
  { mode: "overwrite" }
3361
3697
  );
3362
- await mkdir2(archiveTasksDir(input.paths), { recursive: true });
3698
+ await mkdir3(archiveTasksDir(input.paths), { recursive: true });
3363
3699
  await rename2(
3364
- join9(input.paths.tasks, `${input.taskId}.md`),
3365
- join9(archiveTasksDir(input.paths), `${input.taskId}.md`)
3700
+ join11(input.paths.tasks, `${input.taskId}.md`),
3701
+ join11(archiveTasksDir(input.paths), `${input.taskId}.md`)
3366
3702
  );
3367
3703
  } catch (error) {
3368
3704
  throw new TaskWriteAfterEventError({
@@ -3398,7 +3734,7 @@ async function renderHandoff(input) {
3398
3734
  const tasksCreated = [];
3399
3735
  const tasksStatusChanged = [];
3400
3736
  for (const entry of entries) {
3401
- const sessionDir = join10(input.paths.sessions, entry.sessionId);
3737
+ const sessionDir = join12(input.paths.sessions, entry.sessionId);
3402
3738
  try {
3403
3739
  for await (const ev of replayEvents(sessionDir, {
3404
3740
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
@@ -3952,7 +4288,12 @@ var SessionInnerImportSchema = z10.object({
3952
4288
  related_files: z10.array(z10.string()).default([]),
3953
4289
  events_log: z10.string().optional(),
3954
4290
  summary: z10.string().nullable().optional(),
3955
- metrics: SessionMetricsSchema.optional()
4291
+ metrics: SessionMetricsSchema.optional(),
4292
+ // Accepted so a payload assembled from an on-disk chained session.yaml
4293
+ // round-trips, and DISCARDED by the importer (buildSessionRecord never
4294
+ // copies it): the integrity anchor is computed at write time, never
4295
+ // imported. Mirrors the accept-and-discard of `prev_hash` on events.
4296
+ integrity: SessionIntegritySchema.optional()
3956
4297
  }).strict();
3957
4298
  var SessionImportPayloadSchema = z10.object({
3958
4299
  schema_version: z10.string(),
@@ -4034,7 +4375,7 @@ function serializeJsonSchema(schema) {
4034
4375
  }
4035
4376
 
4036
4377
  // src/stats/work-stats.ts
4037
- import { join as join11 } from "path";
4378
+ import { join as join13 } from "path";
4038
4379
  function resolveTimeZone(timeZone) {
4039
4380
  if (timeZone !== void 0 && timeZone.length > 0) return timeZone;
4040
4381
  return Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -4065,7 +4406,7 @@ async function computeWorkStats(input) {
4065
4406
  const events = [];
4066
4407
  let eventsUnreadable = false;
4067
4408
  try {
4068
- for await (const ev of replayEvents(join11(input.paths.sessions, entry.sessionId), {
4409
+ for await (const ev of replayEvents(join13(input.paths.sessions, entry.sessionId), {
4069
4410
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
4070
4411
  })) {
4071
4412
  events.push(ev);
@@ -4320,28 +4661,28 @@ function tzDate(ms, timeZone) {
4320
4661
  }
4321
4662
 
4322
4663
  // src/storage/basou-dir.ts
4323
- import { lstat as lstat3, mkdir as mkdir3 } from "fs/promises";
4324
- import { join as join12 } from "path";
4664
+ import { lstat as lstat3, mkdir as mkdir4 } from "fs/promises";
4665
+ import { join as join14 } from "path";
4325
4666
  function basouPaths(repositoryRoot) {
4326
- const root = join12(repositoryRoot, ".basou");
4327
- const approvalsBase = join12(root, "approvals");
4667
+ const root = join14(repositoryRoot, ".basou");
4668
+ const approvalsBase = join14(root, "approvals");
4328
4669
  return {
4329
4670
  root,
4330
- sessions: join12(root, "sessions"),
4331
- tasks: join12(root, "tasks"),
4671
+ sessions: join14(root, "sessions"),
4672
+ tasks: join14(root, "tasks"),
4332
4673
  approvals: {
4333
- pending: join12(approvalsBase, "pending"),
4334
- resolved: join12(approvalsBase, "resolved")
4674
+ pending: join14(approvalsBase, "pending"),
4675
+ resolved: join14(approvalsBase, "resolved")
4335
4676
  },
4336
- locks: join12(root, "locks"),
4337
- logs: join12(root, "logs"),
4338
- raw: join12(root, "raw"),
4339
- tmp: join12(root, "tmp"),
4677
+ locks: join14(root, "locks"),
4678
+ logs: join14(root, "logs"),
4679
+ raw: join14(root, "raw"),
4680
+ tmp: join14(root, "tmp"),
4340
4681
  files: {
4341
- manifest: join12(root, "manifest.yaml"),
4342
- status: join12(root, "status.json"),
4343
- handoff: join12(root, "handoff.md"),
4344
- decisions: join12(root, "decisions.md")
4682
+ manifest: join14(root, "manifest.yaml"),
4683
+ status: join14(root, "status.json"),
4684
+ handoff: join14(root, "handoff.md"),
4685
+ decisions: join14(root, "decisions.md")
4345
4686
  }
4346
4687
  };
4347
4688
  }
@@ -4382,7 +4723,7 @@ async function ensureBasouDirectory(repositoryRoot) {
4382
4723
  }
4383
4724
  async function mkdirLabeled(target, label) {
4384
4725
  try {
4385
- await mkdir3(target, { recursive: true });
4726
+ await mkdir4(target, { recursive: true });
4386
4727
  } catch (error) {
4387
4728
  if (hasErrorCode3(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
4388
4729
  throw new Error(`${label} exists but is not a directory`, { cause: error });
@@ -4397,16 +4738,16 @@ function hasErrorCode3(error) {
4397
4738
  }
4398
4739
 
4399
4740
  // src/storage/gitignore.ts
4400
- import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
4401
- import { join as join13 } from "path";
4741
+ import { readFile as readFile8, writeFile as writeFile2 } from "fs/promises";
4742
+ import { join as join15 } from "path";
4402
4743
  var MARKER = "# Basou - default ignore";
4403
4744
  var BASOU_GITIGNORE_BLOCK = "# Basou - default ignore\n.basou/logs/\n.basou/raw/\n.basou/tmp/\n.basou/locks/\n.basou/status.json\n.basou/sessions/*/events.jsonl\n.basou/sessions/*/artifacts/\n.basou/approvals/pending/\n.basou/approvals/resolved/\n\n# Basou - default commit\n# .basou/manifest.yaml\n# .basou/handoff.md\n# .basou/decisions.md\n# .basou/tasks/\n# .basou/sessions/*/session.yaml\n# .basou/sessions/*/transcript.md\n# .basou/sessions/*/changed-files.json\n";
4404
4745
  async function appendBasouGitignore(repositoryRoot) {
4405
- const gitignorePath = join13(repositoryRoot, ".gitignore");
4746
+ const gitignorePath = join15(repositoryRoot, ".gitignore");
4406
4747
  let body;
4407
4748
  let existed;
4408
4749
  try {
4409
- body = await readFile6(gitignorePath, "utf8");
4750
+ body = await readFile8(gitignorePath, "utf8");
4410
4751
  existed = true;
4411
4752
  } catch (error) {
4412
4753
  if (hasErrorCode4(error) && error.code === "ENOENT") {
@@ -4512,12 +4853,12 @@ function hasErrorCode5(error) {
4512
4853
  }
4513
4854
 
4514
4855
  // src/storage/markdown-store.ts
4515
- import { readFile as readFile7 } from "fs/promises";
4856
+ import { readFile as readFile9 } from "fs/promises";
4516
4857
  var GENERATED_START = "<!-- BASOU:GENERATED:START -->";
4517
4858
  var GENERATED_END = "<!-- BASOU:GENERATED:END -->";
4518
4859
  async function readMarkdownFile(filePath) {
4519
4860
  try {
4520
- return await readFile7(filePath, "utf8");
4861
+ return await readFile9(filePath, "utf8");
4521
4862
  } catch (error) {
4522
4863
  if (hasErrorCode6(error) && error.code === "ENOENT") return null;
4523
4864
  throw new Error("Failed to read markdown file", { cause: error });
@@ -4615,9 +4956,9 @@ function hasErrorCode6(error) {
4615
4956
  }
4616
4957
 
4617
4958
  // src/storage/session-import.ts
4618
- import { mkdir as mkdir4, rm as rm2 } from "fs/promises";
4959
+ import { mkdir as mkdir5, readFile as readFile10, rm as rm2 } from "fs/promises";
4619
4960
  import { homedir as homedir2 } from "os";
4620
- import { join as join14 } from "path";
4961
+ import { join as join16 } from "path";
4621
4962
  async function importSessionFromJson(paths, manifest, payload, options) {
4622
4963
  if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
4623
4964
  throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
@@ -4642,21 +4983,22 @@ async function importSessionFromJson(paths, manifest, payload, options) {
4642
4983
  pathSanitizeReport
4643
4984
  };
4644
4985
  }
4645
- const sessionDir = join14(paths.sessions, newSessionId);
4986
+ const sessionDir = join16(paths.sessions, newSessionId);
4646
4987
  try {
4647
- await mkdir4(sessionDir, { recursive: true });
4988
+ await mkdir5(sessionDir, { recursive: true });
4648
4989
  } catch (error) {
4649
4990
  throw new Error("Failed to create session directory", { cause: error });
4650
4991
  }
4992
+ let chainResult;
4651
4993
  try {
4652
- await writeEventsBulk(sessionDir, rewrittenEvents);
4994
+ chainResult = await writeEventsBulk(sessionDir, rewrittenEvents, { chain: true });
4653
4995
  } catch (error) {
4654
4996
  await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
4655
4997
  throw error;
4656
4998
  }
4657
4999
  try {
4658
- const sessionYamlPath = join14(sessionDir, "session.yaml");
4659
- await linkYamlFile(sessionYamlPath, sessionRecord);
5000
+ const sessionYamlPath = join16(sessionDir, "session.yaml");
5001
+ await linkYamlFile(sessionYamlPath, withIntegrity(sessionRecord, chainResult));
4660
5002
  } catch (error) {
4661
5003
  await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
4662
5004
  if (findErrorCode(error, "EEXIST")) {
@@ -4713,6 +5055,16 @@ function assertChronologicalOrder(events) {
4713
5055
  }
4714
5056
  }
4715
5057
  }
5058
+ function withIntegrity(record, chainResult) {
5059
+ if (chainResult === null) return record;
5060
+ return {
5061
+ ...record,
5062
+ session: {
5063
+ ...record.session,
5064
+ integrity: { head_hash: chainResult.headHash, event_count: chainResult.count }
5065
+ }
5066
+ };
5067
+ }
4716
5068
  function buildSessionRecord(input, manifest, newSessionId, options) {
4717
5069
  const home = homedir2();
4718
5070
  const workingDirectoryRaw = input.working_directory;
@@ -4813,9 +5165,13 @@ function reuseDerivedIds(priorDerived, freshDerived, sessionId) {
4813
5165
  async function reimportPreservingId(paths, manifest, priorSessionId, freshPayload, options = {}) {
4814
5166
  const sessionId = priorSessionId;
4815
5167
  const importSource = freshPayload.session.source.kind;
4816
- const sessionDir = join14(paths.sessions, priorSessionId);
5168
+ const sessionDir = join16(paths.sessions, priorSessionId);
4817
5169
  const lock = options.dryRun === true ? null : await acquireLock(paths, "session", priorSessionId);
4818
5170
  try {
5171
+ const priorVerdict = await verifyEventsChain(paths, priorSessionId);
5172
+ if (priorVerdict.status === "tampered") {
5173
+ return { status: "skipped", reason: "prior_chain_broken" };
5174
+ }
4819
5175
  let priorUnreadable = false;
4820
5176
  const priorEvents = await readAllEvents(sessionDir, {
4821
5177
  onWarning: () => {
@@ -4843,20 +5199,35 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
4843
5199
  const { record } = buildSessionRecord(freshPayload.session, manifest, sessionId, {});
4844
5200
  const preservedInner = {
4845
5201
  ...record.session,
4846
- // A human may have linked this imported session to a task
4847
- // (`basou task link` updates session.yaml.task_id even for imported
4848
- // sessions); never drop that link on a re-derive.
5202
+ // Defensive: keep any task_id already present on the prior yaml so a
5203
+ // re-derive never drops a link, whatever wrote it.
4849
5204
  task_id: prior.session.task_id ?? null,
4850
5205
  // Re-derivation always yields a null summary; keep a prior non-null one.
4851
5206
  summary: prior.session.summary ?? record.session.summary ?? null
4852
5207
  };
4853
5208
  const updatedRecord = { schema_version: "0.1.0", session: preservedInner };
4854
5209
  if (options.dryRun !== true) {
4855
- await writeEventsBulk(sessionDir, mergedEvents);
5210
+ const eventsPath = join16(sessionDir, "events.jsonl");
5211
+ let priorEventsRaw = null;
4856
5212
  try {
4857
- await overwriteYamlFile(join14(sessionDir, "session.yaml"), updatedRecord);
5213
+ priorEventsRaw = await readFile10(eventsPath);
4858
5214
  } catch (error) {
4859
- await writeEventsBulk(sessionDir, priorEvents).catch(() => void 0);
5215
+ if (!findErrorCode(error, "ENOENT")) {
5216
+ throw new Error("Failed to read events.jsonl", { cause: error });
5217
+ }
5218
+ }
5219
+ const chainResult = await writeEventsBulk(sessionDir, mergedEvents, { chain: true });
5220
+ try {
5221
+ await overwriteYamlFile(
5222
+ join16(sessionDir, "session.yaml"),
5223
+ withIntegrity(updatedRecord, chainResult)
5224
+ );
5225
+ } catch (error) {
5226
+ if (priorEventsRaw !== null) {
5227
+ await atomicReplace(eventsPath, priorEventsRaw).catch(() => void 0);
5228
+ } else {
5229
+ await rm2(eventsPath, { force: true }).catch(() => void 0);
5230
+ }
4860
5231
  throw error;
4861
5232
  }
4862
5233
  }
@@ -4871,6 +5242,100 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
4871
5242
  await lock?.release();
4872
5243
  }
4873
5244
  }
5245
+ async function rechainSessionInPlace(paths, sessionId, options = {}) {
5246
+ const sessionDir = join16(paths.sessions, sessionId);
5247
+ let lock;
5248
+ try {
5249
+ lock = await acquireLock(paths, "session", sessionId);
5250
+ } catch (error) {
5251
+ if (error instanceof Error && error.message === "Lock is held by another process") {
5252
+ throw error;
5253
+ }
5254
+ throw new Error("Failed to acquire lock", { cause: error });
5255
+ }
5256
+ try {
5257
+ let record;
5258
+ try {
5259
+ record = await readSessionYaml(paths, sessionId);
5260
+ } catch (error) {
5261
+ if (error instanceof Error && error.message === "YAML file not found") {
5262
+ return { status: "skipped", reason: "yaml_missing" };
5263
+ }
5264
+ return { status: "skipped", reason: "yaml_unreadable" };
5265
+ }
5266
+ if (record.session.status !== "imported") {
5267
+ return { status: "skipped", reason: "not_imported" };
5268
+ }
5269
+ const verdict = await verifyEventsChain(paths, sessionId);
5270
+ if (verdict.status === "verified") {
5271
+ return { status: "skipped", reason: "already_chained" };
5272
+ }
5273
+ if (verdict.status === "empty") {
5274
+ return { status: "skipped", reason: "empty" };
5275
+ }
5276
+ if (verdict.status !== "unchained") {
5277
+ return { status: "skipped", reason: "tampered" };
5278
+ }
5279
+ const eventsPath = join16(sessionDir, "events.jsonl");
5280
+ let priorRaw;
5281
+ try {
5282
+ priorRaw = await readFile10(eventsPath);
5283
+ } catch (error) {
5284
+ throw new Error("Failed to read events.jsonl", { cause: error });
5285
+ }
5286
+ if (priorRaw.length === 0 || priorRaw[priorRaw.length - 1] !== 10) {
5287
+ return { status: "skipped", reason: "events_unreadable" };
5288
+ }
5289
+ const text = priorRaw.toString("utf8");
5290
+ if (!priorRaw.equals(Buffer.from(text, "utf8"))) {
5291
+ return { status: "skipped", reason: "events_unreadable" };
5292
+ }
5293
+ const rawLines = text.slice(0, -1).split("\n");
5294
+ for (const line of rawLines) {
5295
+ if (line.trim().length === 0) {
5296
+ return { status: "skipped", reason: "events_unreadable" };
5297
+ }
5298
+ let parsed;
5299
+ try {
5300
+ parsed = JSON.parse(line);
5301
+ } catch {
5302
+ return { status: "skipped", reason: "events_unreadable" };
5303
+ }
5304
+ if (JSON.stringify(parsed) !== line) {
5305
+ return { status: "skipped", reason: "events_unreadable" };
5306
+ }
5307
+ if (!EventSchema.safeParse(parsed).success) {
5308
+ return { status: "skipped", reason: "events_unreadable" };
5309
+ }
5310
+ if (parsed.session_id !== sessionId) {
5311
+ return { status: "skipped", reason: "session_id_mismatch" };
5312
+ }
5313
+ }
5314
+ if (options.dryRun === true) {
5315
+ return { status: "rechained", eventCount: rawLines.length };
5316
+ }
5317
+ const chainResult = chainRawJsonLines(rawLines, sessionId);
5318
+ const body = `${chainResult.lines.join("\n")}
5319
+ `;
5320
+ try {
5321
+ await atomicReplace(eventsPath, body);
5322
+ } catch (error) {
5323
+ throw new Error("Failed to write events.jsonl", { cause: error });
5324
+ }
5325
+ try {
5326
+ await overwriteYamlFile(
5327
+ join16(sessionDir, "session.yaml"),
5328
+ withIntegrity(record, { headHash: chainResult.headHash, count: chainResult.count })
5329
+ );
5330
+ } catch (error) {
5331
+ await atomicReplace(eventsPath, priorRaw).catch(() => void 0);
5332
+ throw error;
5333
+ }
5334
+ return { status: "rechained", eventCount: chainResult.count };
5335
+ } finally {
5336
+ await lock.release();
5337
+ }
5338
+ }
4874
5339
 
4875
5340
  // src/index.ts
4876
5341
  var BASOU_CORE_VERSION = "0.1.0";
@@ -4900,6 +5365,7 @@ export {
4900
5365
  SessionIdSchema,
4901
5366
  SessionImportPayloadSchema,
4902
5367
  SessionInnerImportSchema,
5368
+ SessionIntegritySchema,
4903
5369
  SessionMetricsSchema,
4904
5370
  SessionSchema,
4905
5371
  SessionSourceKindSchema,
@@ -4912,6 +5378,8 @@ export {
4912
5378
  WorkspaceIdSchema,
4913
5379
  acquireLock,
4914
5380
  appendBasouGitignore,
5381
+ appendChainedEvent,
5382
+ appendChainedEventLocked,
4915
5383
  appendEvent,
4916
5384
  appendEventToExistingSession,
4917
5385
  archiveTask,
@@ -4919,6 +5387,8 @@ export {
4919
5387
  basouPaths,
4920
5388
  buildJsonSchemas,
4921
5389
  buildStatusSnapshot,
5390
+ chainEvents,
5391
+ chainRawJsonLines,
4922
5392
  classifySuspect,
4923
5393
  claudeCodeAdapterMetadata,
4924
5394
  claudeTranscriptToImportPayload,
@@ -4934,13 +5404,17 @@ export {
4934
5404
  enumerateArchivedTaskIds,
4935
5405
  enumerateSessionDirs,
4936
5406
  enumerateTaskIds,
5407
+ finalizeSessionYaml,
4937
5408
  findErrorCode,
5409
+ genesisHash,
4938
5410
  getDiff,
4939
5411
  getSnapshot,
4940
5412
  importSessionFromJson,
5413
+ inspectChainTail,
4941
5414
  isImportDerivedSource,
4942
5415
  isLazyExpired,
4943
5416
  isValidPrefixedId,
5417
+ lineHash,
4944
5418
  linkYamlFile,
4945
5419
  loadApproval,
4946
5420
  loadSessionEntries,
@@ -4957,6 +5431,7 @@ export {
4957
5431
  readTaskFile,
4958
5432
  readTaskFileWithArchiveFallback,
4959
5433
  readYamlFile,
5434
+ rechainSessionInPlace,
4960
5435
  reconcileAllTasks,
4961
5436
  reconcileTask,
4962
5437
  refreshTaskLinkedSessions,
@@ -4972,12 +5447,14 @@ export {
4972
5447
  sanitizePath,
4973
5448
  sanitizeRelatedFiles,
4974
5449
  sanitizeWorkingDirectory,
5450
+ serializeEventLine,
4975
5451
  serializeJsonSchema,
4976
5452
  sessionWorkStatsFromEvents,
4977
5453
  summarizeAdapterOutput,
4978
5454
  tryRemoteUrl,
4979
5455
  ulid,
4980
5456
  updateTaskStatusWithEvent,
5457
+ verifyEventsChain,
4981
5458
  writeEventsBulk,
4982
5459
  writeManifest,
4983
5460
  writeMarkdownFile,