@basou/core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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")
@@ -1062,6 +1069,10 @@ var SessionMetricsSchema = z4.object({
1062
1069
  active_time_method: z4.string().optional(),
1063
1070
  machine_active_time_ms: z4.number().int().nonnegative().optional()
1064
1071
  });
1072
+ var SessionIntegritySchema = z4.object({
1073
+ head_hash: z4.string(),
1074
+ event_count: z4.number().int().nonnegative()
1075
+ }).strict();
1065
1076
  var SessionInnerSchema = z4.object({
1066
1077
  id: SessionIdSchema,
1067
1078
  label: z4.string().optional(),
@@ -1077,7 +1088,8 @@ var SessionInnerSchema = z4.object({
1077
1088
  related_files: z4.array(z4.string()).default([]),
1078
1089
  events_log: z4.string().default("events.jsonl"),
1079
1090
  summary: z4.string().nullable().optional(),
1080
- metrics: SessionMetricsSchema.optional()
1091
+ metrics: SessionMetricsSchema.optional(),
1092
+ integrity: SessionIntegritySchema.optional()
1081
1093
  });
1082
1094
  var SessionSchema = z4.object({
1083
1095
  schema_version: SchemaVersionSchema,
@@ -1286,9 +1298,50 @@ function shortDecisionSessionId(sessionId) {
1286
1298
  return sessionId.slice(0, 10);
1287
1299
  }
1288
1300
 
1301
+ // src/events/chain.ts
1302
+ import { createHash } from "crypto";
1303
+ var GENESIS_PREFIX = "basou:event-chain:v1:";
1304
+ function genesisHash(sessionId) {
1305
+ return createHash("sha256").update(`${GENESIS_PREFIX}${sessionId}`, "utf8").digest("hex");
1306
+ }
1307
+ function lineHash(rawLine) {
1308
+ const hash = createHash("sha256");
1309
+ if (typeof rawLine === "string") {
1310
+ hash.update(rawLine, "utf8");
1311
+ } else {
1312
+ hash.update(rawLine);
1313
+ }
1314
+ return hash.digest("hex");
1315
+ }
1316
+ function serializeEventLine(event) {
1317
+ return JSON.stringify(event);
1318
+ }
1319
+ function chainEvents(events, sessionId) {
1320
+ let prev = genesisHash(sessionId);
1321
+ const lines = [];
1322
+ for (const event of events) {
1323
+ const chained = { ...event, prev_hash: prev };
1324
+ const line = serializeEventLine(chained);
1325
+ lines.push(line);
1326
+ prev = lineHash(line);
1327
+ }
1328
+ return { lines, headHash: prev, count: lines.length };
1329
+ }
1330
+ function chainRawJsonLines(rawLines, sessionId) {
1331
+ let prev = genesisHash(sessionId);
1332
+ const lines = [];
1333
+ for (const rawLine of rawLines) {
1334
+ const parsed = JSON.parse(rawLine);
1335
+ const line = JSON.stringify({ ...parsed, prev_hash: prev });
1336
+ lines.push(line);
1337
+ prev = lineHash(line);
1338
+ }
1339
+ return { lines, headHash: prev, count: lines.length };
1340
+ }
1341
+
1289
1342
  // src/events/event-writer.ts
1290
1343
  import { appendFile } from "fs/promises";
1291
- import { join as join5 } from "path";
1344
+ import { basename, join as join5 } from "path";
1292
1345
  async function appendEvent(sessionDir, event) {
1293
1346
  let validated;
1294
1347
  try {
@@ -1296,7 +1349,7 @@ async function appendEvent(sessionDir, event) {
1296
1349
  } catch (error) {
1297
1350
  throw new Error("Invalid Basou event payload", { cause: error });
1298
1351
  }
1299
- const line = `${JSON.stringify(validated)}
1352
+ const line = `${serializeEventLine(validated)}
1300
1353
  `;
1301
1354
  try {
1302
1355
  await appendFile(join5(sessionDir, "events.jsonl"), line, "utf8");
@@ -1304,7 +1357,7 @@ async function appendEvent(sessionDir, event) {
1304
1357
  throw new Error("Failed to append event to events.jsonl", { cause: error });
1305
1358
  }
1306
1359
  }
1307
- async function writeEventsBulk(sessionDir, events) {
1360
+ async function writeEventsBulk(sessionDir, events, options = {}) {
1308
1361
  const validated = [];
1309
1362
  try {
1310
1363
  for (const event of events) {
@@ -1314,13 +1367,153 @@ async function writeEventsBulk(sessionDir, events) {
1314
1367
  throw new Error("Invalid Basou event payload", { cause: error });
1315
1368
  }
1316
1369
  const filePath = join5(sessionDir, "events.jsonl");
1317
- const body = validated.length > 0 ? `${validated.map((e) => JSON.stringify(e)).join("\n")}
1370
+ let body;
1371
+ let result = null;
1372
+ if (options.chain === true) {
1373
+ const { lines, headHash, count } = chainEvents(validated, basename(sessionDir));
1374
+ body = lines.length > 0 ? `${lines.join("\n")}
1318
1375
  ` : "";
1376
+ result = count > 0 ? { headHash, count } : null;
1377
+ } else {
1378
+ body = validated.length > 0 ? `${validated.map(serializeEventLine).join("\n")}
1379
+ ` : "";
1380
+ }
1319
1381
  try {
1320
1382
  await atomicReplace(filePath, body);
1321
1383
  } catch (error) {
1322
1384
  throw new Error("Failed to write events.jsonl", { cause: error });
1323
1385
  }
1386
+ return result;
1387
+ }
1388
+
1389
+ // src/events/verify.ts
1390
+ import { readFile as readFile2 } from "fs/promises";
1391
+ import { join as join6 } from "path";
1392
+ async function verifyEventsChain(paths, sessionId) {
1393
+ const sessionDir = join6(paths.sessions, sessionId);
1394
+ let raw = null;
1395
+ try {
1396
+ raw = await readFile2(join6(sessionDir, "events.jsonl"));
1397
+ } catch (error) {
1398
+ if (!findErrorCode(error, "ENOENT")) {
1399
+ throw new Error("Failed to read events.jsonl", { cause: error });
1400
+ }
1401
+ }
1402
+ let anchor;
1403
+ try {
1404
+ const session = await readSessionYaml(paths, sessionId);
1405
+ anchor = { kind: "present", integrity: session.session.integrity };
1406
+ } catch (error) {
1407
+ if (error instanceof Error && error.message === "YAML file not found") {
1408
+ anchor = { kind: "absent" };
1409
+ } else {
1410
+ anchor = { kind: "unreadable" };
1411
+ }
1412
+ }
1413
+ const terminated = raw === null || raw.length === 0 || raw[raw.length - 1] === 10;
1414
+ const segments = raw === null ? [] : splitLinesBytes(raw);
1415
+ const tailFragment = !terminated && segments.length > 0 ? segments.pop() : null;
1416
+ const lines = segments;
1417
+ const carriesPrevHash = (s) => {
1418
+ try {
1419
+ const obj = JSON.parse(s.toString("utf8"));
1420
+ return typeof obj === "object" && obj !== null && "prev_hash" in obj;
1421
+ } catch {
1422
+ return false;
1423
+ }
1424
+ };
1425
+ const chained = lines.some((l) => l.length > 0 && carriesPrevHash(l)) || tailFragment !== null && carriesPrevHash(tailFragment);
1426
+ if (!chained) {
1427
+ if (anchor.kind === "present" && anchor.integrity !== void 0) {
1428
+ return {
1429
+ status: "tampered",
1430
+ eventCount: lines.length,
1431
+ reason: "anchor_without_chain"
1432
+ };
1433
+ }
1434
+ if (raw === null || raw.length === 0) {
1435
+ return { status: "empty", eventCount: 0 };
1436
+ }
1437
+ return { status: "unchained", eventCount: lines.length };
1438
+ }
1439
+ let expected = genesisHash(sessionId);
1440
+ for (let i = 0; i < lines.length; i++) {
1441
+ const line = lines[i];
1442
+ const lineNo = i + 1;
1443
+ if (line.length === 0) {
1444
+ return { status: "tampered", eventCount: lines.length, reason: "blank_line", line: lineNo };
1445
+ }
1446
+ let parsed;
1447
+ try {
1448
+ parsed = JSON.parse(line.toString("utf8"));
1449
+ } catch {
1450
+ return {
1451
+ status: "tampered",
1452
+ eventCount: lines.length,
1453
+ reason: "malformed_line",
1454
+ line: lineNo
1455
+ };
1456
+ }
1457
+ const record = parsed;
1458
+ if (typeof record.prev_hash !== "string") {
1459
+ return {
1460
+ status: "tampered",
1461
+ eventCount: lines.length,
1462
+ reason: "missing_prev_hash",
1463
+ line: lineNo
1464
+ };
1465
+ }
1466
+ if (record.prev_hash !== expected) {
1467
+ return {
1468
+ status: "tampered",
1469
+ eventCount: lines.length,
1470
+ reason: i === 0 ? "genesis_mismatch" : "broken_link",
1471
+ line: lineNo
1472
+ };
1473
+ }
1474
+ if (record.session_id !== sessionId) {
1475
+ return {
1476
+ status: "tampered",
1477
+ eventCount: lines.length,
1478
+ reason: "session_id_mismatch",
1479
+ line: lineNo
1480
+ };
1481
+ }
1482
+ expected = lineHash(line);
1483
+ }
1484
+ if (tailFragment !== null || !terminated) {
1485
+ return {
1486
+ status: "tampered",
1487
+ eventCount: lines.length,
1488
+ reason: "torn_tail",
1489
+ line: lines.length + 1
1490
+ };
1491
+ }
1492
+ if (anchor.kind === "absent") {
1493
+ return { status: "incomplete", eventCount: lines.length, reason: "yaml_missing" };
1494
+ }
1495
+ if (anchor.kind === "unreadable") {
1496
+ return { status: "tampered", eventCount: lines.length, reason: "yaml_unreadable" };
1497
+ }
1498
+ if (anchor.integrity === void 0) {
1499
+ return { status: "tampered", eventCount: lines.length, reason: "anchor_missing" };
1500
+ }
1501
+ if (anchor.integrity.event_count !== lines.length || anchor.integrity.head_hash !== expected) {
1502
+ return { status: "tampered", eventCount: lines.length, reason: "anchor_mismatch" };
1503
+ }
1504
+ return { status: "verified", eventCount: lines.length };
1505
+ }
1506
+ function splitLinesBytes(buf) {
1507
+ const out = [];
1508
+ let start = 0;
1509
+ for (let i = 0; i < buf.length; i++) {
1510
+ if (buf[i] === 10) {
1511
+ out.push(buf.subarray(start, i));
1512
+ start = i + 1;
1513
+ }
1514
+ }
1515
+ if (start < buf.length) out.push(buf.subarray(start));
1516
+ return out;
1324
1517
  }
1325
1518
 
1326
1519
  // src/git/snapshot.ts
@@ -1617,12 +1810,12 @@ function parseDiffNameStatus(raw) {
1617
1810
  }
1618
1811
 
1619
1812
  // src/handoff/handoff-renderer.ts
1620
- import { join as join10 } from "path";
1813
+ import { join as join11 } from "path";
1621
1814
 
1622
1815
  // 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";
1816
+ import { createHash as createHash2 } from "crypto";
1817
+ import { mkdir as mkdir3, readdir as readdir3, readFile as readFile6, rename as rename2, stat as stat2, unlink as unlink3 } from "fs/promises";
1818
+ import { join as join10 } from "path";
1626
1819
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
1627
1820
  import { z as z8 } from "zod";
1628
1821
 
@@ -1666,7 +1859,7 @@ var TaskSchema = z6.object({
1666
1859
  // src/storage/ad-hoc-session.ts
1667
1860
  import { mkdir, rm } from "fs/promises";
1668
1861
  import { homedir } from "os";
1669
- import { join as join6 } from "path";
1862
+ import { join as join7 } from "path";
1670
1863
 
1671
1864
  // src/lib/path-sanitizer.ts
1672
1865
  import { posix as path } from "path";
@@ -1746,13 +1939,13 @@ async function createAdHocSessionWithEvent(input) {
1746
1939
  taskId: input.taskId ?? null
1747
1940
  })
1748
1941
  );
1749
- const sessionDir = join6(input.paths.sessions, sessionId);
1942
+ const sessionDir = join7(input.paths.sessions, sessionId);
1750
1943
  try {
1751
1944
  await mkdir(sessionDir, { recursive: true });
1752
1945
  } catch (error) {
1753
1946
  throw new Error("Failed to create session directory", { cause: error });
1754
1947
  }
1755
- const sessionYamlPath = join6(sessionDir, "session.yaml");
1948
+ const sessionYamlPath = join7(sessionDir, "session.yaml");
1756
1949
  try {
1757
1950
  await linkYamlFile(sessionYamlPath, initialSession);
1758
1951
  } catch (error) {
@@ -1875,7 +2068,7 @@ async function appendEventToExistingSession(input) {
1875
2068
  }
1876
2069
  const eventId = prefixedUlid("evt");
1877
2070
  const event = assertTargetEventIdentity(input.eventBuilder(eventId), input.sessionId, eventId);
1878
- const sessionDir = join6(input.paths.sessions, input.sessionId);
2071
+ const sessionDir = join7(input.paths.sessions, input.sessionId);
1879
2072
  await appendEvent(sessionDir, event);
1880
2073
  return { eventId, sessionStatus: status };
1881
2074
  }
@@ -1890,8 +2083,8 @@ function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
1890
2083
  }
1891
2084
 
1892
2085
  // src/storage/lockfile.ts
1893
- import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1894
- import { join as join7 } from "path";
2086
+ import { mkdir as mkdir2, readFile as readFile4, unlink as unlink2 } from "fs/promises";
2087
+ import { dirname as dirname2, join as join8 } from "path";
1895
2088
  var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
1896
2089
  async function acquireLock(paths, scope, resourceId) {
1897
2090
  const lockPath = lockfilePath(paths, scope, resourceId);
@@ -1903,6 +2096,19 @@ async function acquireLock(paths, scope, resourceId) {
1903
2096
  try {
1904
2097
  await atomicCreate(lockPath, serialised);
1905
2098
  } catch (error) {
2099
+ if (findErrorCode(error, "ENOENT")) {
2100
+ try {
2101
+ await mkdir2(dirname2(lockPath), { recursive: true });
2102
+ await atomicCreate(lockPath, serialised);
2103
+ return {
2104
+ release: async () => {
2105
+ await unlink2(lockPath).catch(() => void 0);
2106
+ }
2107
+ };
2108
+ } catch (retryError) {
2109
+ throw new Error("Failed to acquire lock", { cause: retryError });
2110
+ }
2111
+ }
1906
2112
  if (!findErrorCode(error, "EEXIST")) {
1907
2113
  throw error;
1908
2114
  }
@@ -1926,7 +2132,7 @@ async function acquireLock(paths, scope, resourceId) {
1926
2132
  async function isStaleLock(lockPath) {
1927
2133
  let body;
1928
2134
  try {
1929
- const raw = await readFile3(lockPath, "utf8");
2135
+ const raw = await readFile4(lockPath, "utf8");
1930
2136
  const parsed = JSON.parse(raw);
1931
2137
  if (typeof parsed !== "object" || parsed === null) return true;
1932
2138
  const candidate = parsed;
@@ -1952,12 +2158,12 @@ async function isStaleLock(lockPath) {
1952
2158
  function lockfilePath(paths, scope, resourceId) {
1953
2159
  const sep = resourceId.indexOf("_");
1954
2160
  const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
1955
- return join7(paths.locks, `${scope}_${ulid2}.lock`);
2161
+ return join8(paths.locks, `${scope}_${ulid2}.lock`);
1956
2162
  }
1957
2163
 
1958
2164
  // src/storage/task-index.ts
1959
- import { readFile as readFile4 } from "fs/promises";
1960
- import { join as join8 } from "path";
2165
+ import { readFile as readFile5 } from "fs/promises";
2166
+ import { join as join9 } from "path";
1961
2167
 
1962
2168
  // src/schemas/task-index.schema.ts
1963
2169
  import { z as z7 } from "zod";
@@ -1976,13 +2182,13 @@ var TASK_INDEX_SCHEMA_VERSION = "0.1.0";
1976
2182
 
1977
2183
  // src/storage/task-index.ts
1978
2184
  function taskIndexPath(paths) {
1979
- return join8(paths.tasks, "index.json");
2185
+ return join9(paths.tasks, "index.json");
1980
2186
  }
1981
2187
  async function readTaskIndex(paths) {
1982
2188
  const filePath = taskIndexPath(paths);
1983
2189
  let raw;
1984
2190
  try {
1985
- raw = await readFile4(filePath, "utf8");
2191
+ raw = await readFile5(filePath, "utf8");
1986
2192
  } catch (error) {
1987
2193
  if (findErrorCode(error, "ENOENT")) {
1988
2194
  throw new Error("Task index not found", { cause: error });
@@ -2090,10 +2296,10 @@ function splitFrontMatter(raw) {
2090
2296
  return { yamlText, body };
2091
2297
  }
2092
2298
  async function readTaskFile(paths, taskId) {
2093
- const filePath = join9(paths.tasks, `${taskId}.md`);
2299
+ const filePath = join10(paths.tasks, `${taskId}.md`);
2094
2300
  let raw;
2095
2301
  try {
2096
- raw = await readFile5(filePath, "utf8");
2302
+ raw = await readFile6(filePath, "utf8");
2097
2303
  } catch (error) {
2098
2304
  if (findErrorCode(error, "ENOENT")) {
2099
2305
  throw new Error("Task file not found", { cause: error });
@@ -2123,7 +2329,7 @@ async function readTaskFile(paths, taskId) {
2123
2329
  }
2124
2330
  async function writeTaskFile(paths, taskId, doc, options) {
2125
2331
  const validated = TaskSchema.parse(doc.task);
2126
- const filePath = join9(paths.tasks, `${taskId}.md`);
2332
+ const filePath = join10(paths.tasks, `${taskId}.md`);
2127
2333
  const yamlText = stringifyYaml(validated);
2128
2334
  const trimmedBody = doc.body.length === 0 ? "" : `
2129
2335
  ${doc.body.endsWith("\n") ? doc.body : `${doc.body}
@@ -2208,7 +2414,7 @@ async function safeUpdateTaskIndex(paths, op) {
2208
2414
  }
2209
2415
  var ARCHIVE_DIR_NAME = "archive";
2210
2416
  function archiveTasksDir(paths) {
2211
- return join9(paths.tasks, ARCHIVE_DIR_NAME);
2417
+ return join10(paths.tasks, ARCHIVE_DIR_NAME);
2212
2418
  }
2213
2419
  async function enumerateArchivedTaskIds(paths) {
2214
2420
  let entries;
@@ -2238,10 +2444,10 @@ async function readTaskFileWithArchiveFallback(paths, taskId) {
2238
2444
  throw error;
2239
2445
  }
2240
2446
  }
2241
- const archiveFilePath = join9(archiveTasksDir(paths), `${taskId}.md`);
2447
+ const archiveFilePath = join10(archiveTasksDir(paths), `${taskId}.md`);
2242
2448
  let raw;
2243
2449
  try {
2244
- raw = await readFile5(archiveFilePath, "utf8");
2450
+ raw = await readFile6(archiveFilePath, "utf8");
2245
2451
  } catch (error) {
2246
2452
  if (findErrorCode(error, "ENOENT")) {
2247
2453
  throw new Error("Task file not found", { cause: error });
@@ -2532,7 +2738,7 @@ async function createTaskAttachLocked(input) {
2532
2738
  ...sessionDoc,
2533
2739
  session: { ...sessionDoc.session, task_id: input.taskId }
2534
2740
  };
2535
- await overwriteYamlFile(join9(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2741
+ await overwriteYamlFile(join10(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2536
2742
  } catch (error) {
2537
2743
  throw new TaskWriteAfterEventError({
2538
2744
  taskId: input.taskId,
@@ -2791,17 +2997,17 @@ function buildUpdatedDoc(input) {
2791
2997
  return { task: next, body: input.currentDoc.body };
2792
2998
  }
2793
2999
  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");
3000
+ const filePath = join10(paths.tasks, `${taskId}.md`);
3001
+ const [stats, raw] = await Promise.all([stat2(filePath), readFile6(filePath)]);
3002
+ const hash = createHash2("sha256").update(raw).digest("hex");
2797
3003
  return { mtimeMs: stats.mtimeMs, hash };
2798
3004
  }
2799
3005
  async function readTaskFileWithSnapshot(paths, taskId) {
2800
- const filePath = join9(paths.tasks, `${taskId}.md`);
3006
+ const filePath = join10(paths.tasks, `${taskId}.md`);
2801
3007
  let rawBuffer;
2802
3008
  let stats;
2803
3009
  try {
2804
- [rawBuffer, stats] = await Promise.all([readFile5(filePath), stat2(filePath)]);
3010
+ [rawBuffer, stats] = await Promise.all([readFile6(filePath), stat2(filePath)]);
2805
3011
  } catch (error) {
2806
3012
  if (findErrorCode(error, "ENOENT")) {
2807
3013
  throw new Error("Task file not found", { cause: error });
@@ -2809,7 +3015,7 @@ async function readTaskFileWithSnapshot(paths, taskId) {
2809
3015
  throw new Error("Failed to read task file", { cause: error });
2810
3016
  }
2811
3017
  const raw = rawBuffer.toString("utf8");
2812
- const hash = createHash("sha256").update(rawBuffer).digest("hex");
3018
+ const hash = createHash2("sha256").update(rawBuffer).digest("hex");
2813
3019
  let split;
2814
3020
  try {
2815
3021
  split = splitFrontMatter(raw);
@@ -3289,7 +3495,7 @@ async function deleteTaskLocked(input) {
3289
3495
  });
3290
3496
  const eventId = adHoc.targetEventIds[0];
3291
3497
  try {
3292
- await unlink3(join9(input.paths.tasks, `${input.taskId}.md`));
3498
+ await unlink3(join10(input.paths.tasks, `${input.taskId}.md`));
3293
3499
  } catch (error) {
3294
3500
  throw new TaskWriteAfterEventError({
3295
3501
  taskId: input.taskId,
@@ -3359,10 +3565,10 @@ async function archiveTaskLocked(input) {
3359
3565
  { task: next, body: doc.body },
3360
3566
  { mode: "overwrite" }
3361
3567
  );
3362
- await mkdir2(archiveTasksDir(input.paths), { recursive: true });
3568
+ await mkdir3(archiveTasksDir(input.paths), { recursive: true });
3363
3569
  await rename2(
3364
- join9(input.paths.tasks, `${input.taskId}.md`),
3365
- join9(archiveTasksDir(input.paths), `${input.taskId}.md`)
3570
+ join10(input.paths.tasks, `${input.taskId}.md`),
3571
+ join10(archiveTasksDir(input.paths), `${input.taskId}.md`)
3366
3572
  );
3367
3573
  } catch (error) {
3368
3574
  throw new TaskWriteAfterEventError({
@@ -3398,7 +3604,7 @@ async function renderHandoff(input) {
3398
3604
  const tasksCreated = [];
3399
3605
  const tasksStatusChanged = [];
3400
3606
  for (const entry of entries) {
3401
- const sessionDir = join10(input.paths.sessions, entry.sessionId);
3607
+ const sessionDir = join11(input.paths.sessions, entry.sessionId);
3402
3608
  try {
3403
3609
  for await (const ev of replayEvents(sessionDir, {
3404
3610
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
@@ -3952,7 +4158,12 @@ var SessionInnerImportSchema = z10.object({
3952
4158
  related_files: z10.array(z10.string()).default([]),
3953
4159
  events_log: z10.string().optional(),
3954
4160
  summary: z10.string().nullable().optional(),
3955
- metrics: SessionMetricsSchema.optional()
4161
+ metrics: SessionMetricsSchema.optional(),
4162
+ // Accepted so a payload assembled from an on-disk chained session.yaml
4163
+ // round-trips, and DISCARDED by the importer (buildSessionRecord never
4164
+ // copies it): the integrity anchor is computed at write time, never
4165
+ // imported. Mirrors the accept-and-discard of `prev_hash` on events.
4166
+ integrity: SessionIntegritySchema.optional()
3956
4167
  }).strict();
3957
4168
  var SessionImportPayloadSchema = z10.object({
3958
4169
  schema_version: z10.string(),
@@ -4034,7 +4245,7 @@ function serializeJsonSchema(schema) {
4034
4245
  }
4035
4246
 
4036
4247
  // src/stats/work-stats.ts
4037
- import { join as join11 } from "path";
4248
+ import { join as join12 } from "path";
4038
4249
  function resolveTimeZone(timeZone) {
4039
4250
  if (timeZone !== void 0 && timeZone.length > 0) return timeZone;
4040
4251
  return Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -4065,7 +4276,7 @@ async function computeWorkStats(input) {
4065
4276
  const events = [];
4066
4277
  let eventsUnreadable = false;
4067
4278
  try {
4068
- for await (const ev of replayEvents(join11(input.paths.sessions, entry.sessionId), {
4279
+ for await (const ev of replayEvents(join12(input.paths.sessions, entry.sessionId), {
4069
4280
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
4070
4281
  })) {
4071
4282
  events.push(ev);
@@ -4320,28 +4531,28 @@ function tzDate(ms, timeZone) {
4320
4531
  }
4321
4532
 
4322
4533
  // src/storage/basou-dir.ts
4323
- import { lstat as lstat3, mkdir as mkdir3 } from "fs/promises";
4324
- import { join as join12 } from "path";
4534
+ import { lstat as lstat3, mkdir as mkdir4 } from "fs/promises";
4535
+ import { join as join13 } from "path";
4325
4536
  function basouPaths(repositoryRoot) {
4326
- const root = join12(repositoryRoot, ".basou");
4327
- const approvalsBase = join12(root, "approvals");
4537
+ const root = join13(repositoryRoot, ".basou");
4538
+ const approvalsBase = join13(root, "approvals");
4328
4539
  return {
4329
4540
  root,
4330
- sessions: join12(root, "sessions"),
4331
- tasks: join12(root, "tasks"),
4541
+ sessions: join13(root, "sessions"),
4542
+ tasks: join13(root, "tasks"),
4332
4543
  approvals: {
4333
- pending: join12(approvalsBase, "pending"),
4334
- resolved: join12(approvalsBase, "resolved")
4544
+ pending: join13(approvalsBase, "pending"),
4545
+ resolved: join13(approvalsBase, "resolved")
4335
4546
  },
4336
- locks: join12(root, "locks"),
4337
- logs: join12(root, "logs"),
4338
- raw: join12(root, "raw"),
4339
- tmp: join12(root, "tmp"),
4547
+ locks: join13(root, "locks"),
4548
+ logs: join13(root, "logs"),
4549
+ raw: join13(root, "raw"),
4550
+ tmp: join13(root, "tmp"),
4340
4551
  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")
4552
+ manifest: join13(root, "manifest.yaml"),
4553
+ status: join13(root, "status.json"),
4554
+ handoff: join13(root, "handoff.md"),
4555
+ decisions: join13(root, "decisions.md")
4345
4556
  }
4346
4557
  };
4347
4558
  }
@@ -4382,7 +4593,7 @@ async function ensureBasouDirectory(repositoryRoot) {
4382
4593
  }
4383
4594
  async function mkdirLabeled(target, label) {
4384
4595
  try {
4385
- await mkdir3(target, { recursive: true });
4596
+ await mkdir4(target, { recursive: true });
4386
4597
  } catch (error) {
4387
4598
  if (hasErrorCode3(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
4388
4599
  throw new Error(`${label} exists but is not a directory`, { cause: error });
@@ -4397,16 +4608,16 @@ function hasErrorCode3(error) {
4397
4608
  }
4398
4609
 
4399
4610
  // src/storage/gitignore.ts
4400
- import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
4401
- import { join as join13 } from "path";
4611
+ import { readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
4612
+ import { join as join14 } from "path";
4402
4613
  var MARKER = "# Basou - default ignore";
4403
4614
  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
4615
  async function appendBasouGitignore(repositoryRoot) {
4405
- const gitignorePath = join13(repositoryRoot, ".gitignore");
4616
+ const gitignorePath = join14(repositoryRoot, ".gitignore");
4406
4617
  let body;
4407
4618
  let existed;
4408
4619
  try {
4409
- body = await readFile6(gitignorePath, "utf8");
4620
+ body = await readFile7(gitignorePath, "utf8");
4410
4621
  existed = true;
4411
4622
  } catch (error) {
4412
4623
  if (hasErrorCode4(error) && error.code === "ENOENT") {
@@ -4512,12 +4723,12 @@ function hasErrorCode5(error) {
4512
4723
  }
4513
4724
 
4514
4725
  // src/storage/markdown-store.ts
4515
- import { readFile as readFile7 } from "fs/promises";
4726
+ import { readFile as readFile8 } from "fs/promises";
4516
4727
  var GENERATED_START = "<!-- BASOU:GENERATED:START -->";
4517
4728
  var GENERATED_END = "<!-- BASOU:GENERATED:END -->";
4518
4729
  async function readMarkdownFile(filePath) {
4519
4730
  try {
4520
- return await readFile7(filePath, "utf8");
4731
+ return await readFile8(filePath, "utf8");
4521
4732
  } catch (error) {
4522
4733
  if (hasErrorCode6(error) && error.code === "ENOENT") return null;
4523
4734
  throw new Error("Failed to read markdown file", { cause: error });
@@ -4615,9 +4826,9 @@ function hasErrorCode6(error) {
4615
4826
  }
4616
4827
 
4617
4828
  // src/storage/session-import.ts
4618
- import { mkdir as mkdir4, rm as rm2 } from "fs/promises";
4829
+ import { mkdir as mkdir5, readFile as readFile9, rm as rm2 } from "fs/promises";
4619
4830
  import { homedir as homedir2 } from "os";
4620
- import { join as join14 } from "path";
4831
+ import { join as join15 } from "path";
4621
4832
  async function importSessionFromJson(paths, manifest, payload, options) {
4622
4833
  if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
4623
4834
  throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
@@ -4642,21 +4853,22 @@ async function importSessionFromJson(paths, manifest, payload, options) {
4642
4853
  pathSanitizeReport
4643
4854
  };
4644
4855
  }
4645
- const sessionDir = join14(paths.sessions, newSessionId);
4856
+ const sessionDir = join15(paths.sessions, newSessionId);
4646
4857
  try {
4647
- await mkdir4(sessionDir, { recursive: true });
4858
+ await mkdir5(sessionDir, { recursive: true });
4648
4859
  } catch (error) {
4649
4860
  throw new Error("Failed to create session directory", { cause: error });
4650
4861
  }
4862
+ let chainResult;
4651
4863
  try {
4652
- await writeEventsBulk(sessionDir, rewrittenEvents);
4864
+ chainResult = await writeEventsBulk(sessionDir, rewrittenEvents, { chain: true });
4653
4865
  } catch (error) {
4654
4866
  await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
4655
4867
  throw error;
4656
4868
  }
4657
4869
  try {
4658
- const sessionYamlPath = join14(sessionDir, "session.yaml");
4659
- await linkYamlFile(sessionYamlPath, sessionRecord);
4870
+ const sessionYamlPath = join15(sessionDir, "session.yaml");
4871
+ await linkYamlFile(sessionYamlPath, withIntegrity(sessionRecord, chainResult));
4660
4872
  } catch (error) {
4661
4873
  await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
4662
4874
  if (findErrorCode(error, "EEXIST")) {
@@ -4713,6 +4925,16 @@ function assertChronologicalOrder(events) {
4713
4925
  }
4714
4926
  }
4715
4927
  }
4928
+ function withIntegrity(record, chainResult) {
4929
+ if (chainResult === null) return record;
4930
+ return {
4931
+ ...record,
4932
+ session: {
4933
+ ...record.session,
4934
+ integrity: { head_hash: chainResult.headHash, event_count: chainResult.count }
4935
+ }
4936
+ };
4937
+ }
4716
4938
  function buildSessionRecord(input, manifest, newSessionId, options) {
4717
4939
  const home = homedir2();
4718
4940
  const workingDirectoryRaw = input.working_directory;
@@ -4813,9 +5035,13 @@ function reuseDerivedIds(priorDerived, freshDerived, sessionId) {
4813
5035
  async function reimportPreservingId(paths, manifest, priorSessionId, freshPayload, options = {}) {
4814
5036
  const sessionId = priorSessionId;
4815
5037
  const importSource = freshPayload.session.source.kind;
4816
- const sessionDir = join14(paths.sessions, priorSessionId);
5038
+ const sessionDir = join15(paths.sessions, priorSessionId);
4817
5039
  const lock = options.dryRun === true ? null : await acquireLock(paths, "session", priorSessionId);
4818
5040
  try {
5041
+ const priorVerdict = await verifyEventsChain(paths, priorSessionId);
5042
+ if (priorVerdict.status === "tampered") {
5043
+ return { status: "skipped", reason: "prior_chain_broken" };
5044
+ }
4819
5045
  let priorUnreadable = false;
4820
5046
  const priorEvents = await readAllEvents(sessionDir, {
4821
5047
  onWarning: () => {
@@ -4843,20 +5069,35 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
4843
5069
  const { record } = buildSessionRecord(freshPayload.session, manifest, sessionId, {});
4844
5070
  const preservedInner = {
4845
5071
  ...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.
5072
+ // Defensive: keep any task_id already present on the prior yaml so a
5073
+ // re-derive never drops a link, whatever wrote it.
4849
5074
  task_id: prior.session.task_id ?? null,
4850
5075
  // Re-derivation always yields a null summary; keep a prior non-null one.
4851
5076
  summary: prior.session.summary ?? record.session.summary ?? null
4852
5077
  };
4853
5078
  const updatedRecord = { schema_version: "0.1.0", session: preservedInner };
4854
5079
  if (options.dryRun !== true) {
4855
- await writeEventsBulk(sessionDir, mergedEvents);
5080
+ const eventsPath = join15(sessionDir, "events.jsonl");
5081
+ let priorEventsRaw = null;
5082
+ try {
5083
+ priorEventsRaw = await readFile9(eventsPath);
5084
+ } catch (error) {
5085
+ if (!findErrorCode(error, "ENOENT")) {
5086
+ throw new Error("Failed to read events.jsonl", { cause: error });
5087
+ }
5088
+ }
5089
+ const chainResult = await writeEventsBulk(sessionDir, mergedEvents, { chain: true });
4856
5090
  try {
4857
- await overwriteYamlFile(join14(sessionDir, "session.yaml"), updatedRecord);
5091
+ await overwriteYamlFile(
5092
+ join15(sessionDir, "session.yaml"),
5093
+ withIntegrity(updatedRecord, chainResult)
5094
+ );
4858
5095
  } catch (error) {
4859
- await writeEventsBulk(sessionDir, priorEvents).catch(() => void 0);
5096
+ if (priorEventsRaw !== null) {
5097
+ await atomicReplace(eventsPath, priorEventsRaw).catch(() => void 0);
5098
+ } else {
5099
+ await rm2(eventsPath, { force: true }).catch(() => void 0);
5100
+ }
4860
5101
  throw error;
4861
5102
  }
4862
5103
  }
@@ -4871,6 +5112,100 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
4871
5112
  await lock?.release();
4872
5113
  }
4873
5114
  }
5115
+ async function rechainSessionInPlace(paths, sessionId, options = {}) {
5116
+ const sessionDir = join15(paths.sessions, sessionId);
5117
+ let lock;
5118
+ try {
5119
+ lock = await acquireLock(paths, "session", sessionId);
5120
+ } catch (error) {
5121
+ if (error instanceof Error && error.message === "Lock is held by another process") {
5122
+ throw error;
5123
+ }
5124
+ throw new Error("Failed to acquire lock", { cause: error });
5125
+ }
5126
+ try {
5127
+ let record;
5128
+ try {
5129
+ record = await readSessionYaml(paths, sessionId);
5130
+ } catch (error) {
5131
+ if (error instanceof Error && error.message === "YAML file not found") {
5132
+ return { status: "skipped", reason: "yaml_missing" };
5133
+ }
5134
+ return { status: "skipped", reason: "yaml_unreadable" };
5135
+ }
5136
+ if (record.session.status !== "imported") {
5137
+ return { status: "skipped", reason: "not_imported" };
5138
+ }
5139
+ const verdict = await verifyEventsChain(paths, sessionId);
5140
+ if (verdict.status === "verified") {
5141
+ return { status: "skipped", reason: "already_chained" };
5142
+ }
5143
+ if (verdict.status === "empty") {
5144
+ return { status: "skipped", reason: "empty" };
5145
+ }
5146
+ if (verdict.status !== "unchained") {
5147
+ return { status: "skipped", reason: "tampered" };
5148
+ }
5149
+ const eventsPath = join15(sessionDir, "events.jsonl");
5150
+ let priorRaw;
5151
+ try {
5152
+ priorRaw = await readFile9(eventsPath);
5153
+ } catch (error) {
5154
+ throw new Error("Failed to read events.jsonl", { cause: error });
5155
+ }
5156
+ if (priorRaw.length === 0 || priorRaw[priorRaw.length - 1] !== 10) {
5157
+ return { status: "skipped", reason: "events_unreadable" };
5158
+ }
5159
+ const text = priorRaw.toString("utf8");
5160
+ if (!priorRaw.equals(Buffer.from(text, "utf8"))) {
5161
+ return { status: "skipped", reason: "events_unreadable" };
5162
+ }
5163
+ const rawLines = text.slice(0, -1).split("\n");
5164
+ for (const line of rawLines) {
5165
+ if (line.trim().length === 0) {
5166
+ return { status: "skipped", reason: "events_unreadable" };
5167
+ }
5168
+ let parsed;
5169
+ try {
5170
+ parsed = JSON.parse(line);
5171
+ } catch {
5172
+ return { status: "skipped", reason: "events_unreadable" };
5173
+ }
5174
+ if (JSON.stringify(parsed) !== line) {
5175
+ return { status: "skipped", reason: "events_unreadable" };
5176
+ }
5177
+ if (!EventSchema.safeParse(parsed).success) {
5178
+ return { status: "skipped", reason: "events_unreadable" };
5179
+ }
5180
+ if (parsed.session_id !== sessionId) {
5181
+ return { status: "skipped", reason: "session_id_mismatch" };
5182
+ }
5183
+ }
5184
+ if (options.dryRun === true) {
5185
+ return { status: "rechained", eventCount: rawLines.length };
5186
+ }
5187
+ const chainResult = chainRawJsonLines(rawLines, sessionId);
5188
+ const body = `${chainResult.lines.join("\n")}
5189
+ `;
5190
+ try {
5191
+ await atomicReplace(eventsPath, body);
5192
+ } catch (error) {
5193
+ throw new Error("Failed to write events.jsonl", { cause: error });
5194
+ }
5195
+ try {
5196
+ await overwriteYamlFile(
5197
+ join15(sessionDir, "session.yaml"),
5198
+ withIntegrity(record, { headHash: chainResult.headHash, count: chainResult.count })
5199
+ );
5200
+ } catch (error) {
5201
+ await atomicReplace(eventsPath, priorRaw).catch(() => void 0);
5202
+ throw error;
5203
+ }
5204
+ return { status: "rechained", eventCount: chainResult.count };
5205
+ } finally {
5206
+ await lock.release();
5207
+ }
5208
+ }
4874
5209
 
4875
5210
  // src/index.ts
4876
5211
  var BASOU_CORE_VERSION = "0.1.0";
@@ -4900,6 +5235,7 @@ export {
4900
5235
  SessionIdSchema,
4901
5236
  SessionImportPayloadSchema,
4902
5237
  SessionInnerImportSchema,
5238
+ SessionIntegritySchema,
4903
5239
  SessionMetricsSchema,
4904
5240
  SessionSchema,
4905
5241
  SessionSourceKindSchema,
@@ -4919,6 +5255,8 @@ export {
4919
5255
  basouPaths,
4920
5256
  buildJsonSchemas,
4921
5257
  buildStatusSnapshot,
5258
+ chainEvents,
5259
+ chainRawJsonLines,
4922
5260
  classifySuspect,
4923
5261
  claudeCodeAdapterMetadata,
4924
5262
  claudeTranscriptToImportPayload,
@@ -4935,12 +5273,14 @@ export {
4935
5273
  enumerateSessionDirs,
4936
5274
  enumerateTaskIds,
4937
5275
  findErrorCode,
5276
+ genesisHash,
4938
5277
  getDiff,
4939
5278
  getSnapshot,
4940
5279
  importSessionFromJson,
4941
5280
  isImportDerivedSource,
4942
5281
  isLazyExpired,
4943
5282
  isValidPrefixedId,
5283
+ lineHash,
4944
5284
  linkYamlFile,
4945
5285
  loadApproval,
4946
5286
  loadSessionEntries,
@@ -4957,6 +5297,7 @@ export {
4957
5297
  readTaskFile,
4958
5298
  readTaskFileWithArchiveFallback,
4959
5299
  readYamlFile,
5300
+ rechainSessionInPlace,
4960
5301
  reconcileAllTasks,
4961
5302
  reconcileTask,
4962
5303
  refreshTaskLinkedSessions,
@@ -4972,12 +5313,14 @@ export {
4972
5313
  sanitizePath,
4973
5314
  sanitizeRelatedFiles,
4974
5315
  sanitizeWorkingDirectory,
5316
+ serializeEventLine,
4975
5317
  serializeJsonSchema,
4976
5318
  sessionWorkStatsFromEvents,
4977
5319
  summarizeAdapterOutput,
4978
5320
  tryRemoteUrl,
4979
5321
  ulid,
4980
5322
  updateTaskStatusWithEvent,
5323
+ verifyEventsChain,
4981
5324
  writeEventsBulk,
4982
5325
  writeManifest,
4983
5326
  writeMarkdownFile,