@basou/core 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +349 -2
- package/dist/index.js +562 -77
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/event.schema.json +57 -0
- package/schemas/session-import.schema.json +80 -0
- package/schemas/session.schema.json +23 -0
package/dist/index.js
CHANGED
|
@@ -224,7 +224,8 @@ function claudeTranscriptToImportPayload(records, options) {
|
|
|
224
224
|
source: {
|
|
225
225
|
kind: CLAUDE_IMPORT_SOURCE,
|
|
226
226
|
version: "0.1.0",
|
|
227
|
-
...externalId !== void 0 ? { external_id: externalId } : {}
|
|
227
|
+
...externalId !== void 0 ? { external_id: externalId } : {},
|
|
228
|
+
...options.sourceSizeBytes !== void 0 ? { source_size_bytes: options.sourceSizeBytes } : {}
|
|
228
229
|
},
|
|
229
230
|
started_at: minTs,
|
|
230
231
|
ended_at: maxTs,
|
|
@@ -457,7 +458,8 @@ function codexRolloutToImportPayload(records, options) {
|
|
|
457
458
|
source: {
|
|
458
459
|
kind: CODEX_IMPORT_SOURCE,
|
|
459
460
|
version: "0.1.0",
|
|
460
|
-
...externalId !== void 0 ? { external_id: externalId } : {}
|
|
461
|
+
...externalId !== void 0 ? { external_id: externalId } : {},
|
|
462
|
+
...options.sourceSizeBytes !== void 0 ? { source_size_bytes: options.sourceSizeBytes } : {}
|
|
461
463
|
},
|
|
462
464
|
started_at: minTs,
|
|
463
465
|
ended_at: maxTs,
|
|
@@ -779,7 +781,14 @@ var BaseEventSchema = z3.object({
|
|
|
779
781
|
id: EventIdSchema,
|
|
780
782
|
session_id: SessionIdSchema,
|
|
781
783
|
occurred_at: IsoTimestampSchema,
|
|
782
|
-
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()
|
|
783
792
|
});
|
|
784
793
|
var SessionStartedEventSchema = BaseEventSchema.extend({
|
|
785
794
|
type: z3.literal("session_started")
|
|
@@ -1032,7 +1041,15 @@ var SessionSourceSchema = z4.object({
|
|
|
1032
1041
|
// Optional id of the originating session in the SOURCE tool's own
|
|
1033
1042
|
// namespace (e.g. the Claude Code session UUID for a `claude-code-import`).
|
|
1034
1043
|
// Lets re-imports of the same source be deduplicated; absent for live runs.
|
|
1035
|
-
external_id: z4.string().optional()
|
|
1044
|
+
external_id: z4.string().optional(),
|
|
1045
|
+
// Byte size of the source native log at import time, recorded so a later
|
|
1046
|
+
// import can detect that an append-only transcript GREW and re-import it
|
|
1047
|
+
// (scoped, preserving the session id) instead of skipping it as already
|
|
1048
|
+
// imported. Additive optional => no schema_version bump (precedent:
|
|
1049
|
+
// external_id, metrics). Absent on sessions imported before this field
|
|
1050
|
+
// existed (treated as legacy: never auto-re-imported, populated on the next
|
|
1051
|
+
// fresh import or `--force`).
|
|
1052
|
+
source_size_bytes: z4.number().int().nonnegative().optional()
|
|
1036
1053
|
});
|
|
1037
1054
|
var InvocationSchema = z4.object({
|
|
1038
1055
|
command: z4.string().min(1),
|
|
@@ -1052,6 +1069,10 @@ var SessionMetricsSchema = z4.object({
|
|
|
1052
1069
|
active_time_method: z4.string().optional(),
|
|
1053
1070
|
machine_active_time_ms: z4.number().int().nonnegative().optional()
|
|
1054
1071
|
});
|
|
1072
|
+
var SessionIntegritySchema = z4.object({
|
|
1073
|
+
head_hash: z4.string(),
|
|
1074
|
+
event_count: z4.number().int().nonnegative()
|
|
1075
|
+
}).strict();
|
|
1055
1076
|
var SessionInnerSchema = z4.object({
|
|
1056
1077
|
id: SessionIdSchema,
|
|
1057
1078
|
label: z4.string().optional(),
|
|
@@ -1067,7 +1088,8 @@ var SessionInnerSchema = z4.object({
|
|
|
1067
1088
|
related_files: z4.array(z4.string()).default([]),
|
|
1068
1089
|
events_log: z4.string().default("events.jsonl"),
|
|
1069
1090
|
summary: z4.string().nullable().optional(),
|
|
1070
|
-
metrics: SessionMetricsSchema.optional()
|
|
1091
|
+
metrics: SessionMetricsSchema.optional(),
|
|
1092
|
+
integrity: SessionIntegritySchema.optional()
|
|
1071
1093
|
});
|
|
1072
1094
|
var SessionSchema = z4.object({
|
|
1073
1095
|
schema_version: SchemaVersionSchema,
|
|
@@ -1276,9 +1298,50 @@ function shortDecisionSessionId(sessionId) {
|
|
|
1276
1298
|
return sessionId.slice(0, 10);
|
|
1277
1299
|
}
|
|
1278
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
|
+
|
|
1279
1342
|
// src/events/event-writer.ts
|
|
1280
1343
|
import { appendFile } from "fs/promises";
|
|
1281
|
-
import { join as join5 } from "path";
|
|
1344
|
+
import { basename, join as join5 } from "path";
|
|
1282
1345
|
async function appendEvent(sessionDir, event) {
|
|
1283
1346
|
let validated;
|
|
1284
1347
|
try {
|
|
@@ -1286,7 +1349,7 @@ async function appendEvent(sessionDir, event) {
|
|
|
1286
1349
|
} catch (error) {
|
|
1287
1350
|
throw new Error("Invalid Basou event payload", { cause: error });
|
|
1288
1351
|
}
|
|
1289
|
-
const line = `${
|
|
1352
|
+
const line = `${serializeEventLine(validated)}
|
|
1290
1353
|
`;
|
|
1291
1354
|
try {
|
|
1292
1355
|
await appendFile(join5(sessionDir, "events.jsonl"), line, "utf8");
|
|
@@ -1294,7 +1357,7 @@ async function appendEvent(sessionDir, event) {
|
|
|
1294
1357
|
throw new Error("Failed to append event to events.jsonl", { cause: error });
|
|
1295
1358
|
}
|
|
1296
1359
|
}
|
|
1297
|
-
async function writeEventsBulk(sessionDir, events) {
|
|
1360
|
+
async function writeEventsBulk(sessionDir, events, options = {}) {
|
|
1298
1361
|
const validated = [];
|
|
1299
1362
|
try {
|
|
1300
1363
|
for (const event of events) {
|
|
@@ -1304,13 +1367,153 @@ async function writeEventsBulk(sessionDir, events) {
|
|
|
1304
1367
|
throw new Error("Invalid Basou event payload", { cause: error });
|
|
1305
1368
|
}
|
|
1306
1369
|
const filePath = join5(sessionDir, "events.jsonl");
|
|
1307
|
-
|
|
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")}
|
|
1375
|
+
` : "";
|
|
1376
|
+
result = count > 0 ? { headHash, count } : null;
|
|
1377
|
+
} else {
|
|
1378
|
+
body = validated.length > 0 ? `${validated.map(serializeEventLine).join("\n")}
|
|
1308
1379
|
` : "";
|
|
1380
|
+
}
|
|
1309
1381
|
try {
|
|
1310
1382
|
await atomicReplace(filePath, body);
|
|
1311
1383
|
} catch (error) {
|
|
1312
1384
|
throw new Error("Failed to write events.jsonl", { cause: error });
|
|
1313
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;
|
|
1314
1517
|
}
|
|
1315
1518
|
|
|
1316
1519
|
// src/git/snapshot.ts
|
|
@@ -1607,12 +1810,12 @@ function parseDiffNameStatus(raw) {
|
|
|
1607
1810
|
}
|
|
1608
1811
|
|
|
1609
1812
|
// src/handoff/handoff-renderer.ts
|
|
1610
|
-
import { join as
|
|
1813
|
+
import { join as join11 } from "path";
|
|
1611
1814
|
|
|
1612
1815
|
// src/storage/tasks.ts
|
|
1613
|
-
import { createHash } from "crypto";
|
|
1614
|
-
import { mkdir as
|
|
1615
|
-
import { join as
|
|
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";
|
|
1616
1819
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
1617
1820
|
import { z as z8 } from "zod";
|
|
1618
1821
|
|
|
@@ -1656,7 +1859,7 @@ var TaskSchema = z6.object({
|
|
|
1656
1859
|
// src/storage/ad-hoc-session.ts
|
|
1657
1860
|
import { mkdir, rm } from "fs/promises";
|
|
1658
1861
|
import { homedir } from "os";
|
|
1659
|
-
import { join as
|
|
1862
|
+
import { join as join7 } from "path";
|
|
1660
1863
|
|
|
1661
1864
|
// src/lib/path-sanitizer.ts
|
|
1662
1865
|
import { posix as path } from "path";
|
|
@@ -1736,13 +1939,13 @@ async function createAdHocSessionWithEvent(input) {
|
|
|
1736
1939
|
taskId: input.taskId ?? null
|
|
1737
1940
|
})
|
|
1738
1941
|
);
|
|
1739
|
-
const sessionDir =
|
|
1942
|
+
const sessionDir = join7(input.paths.sessions, sessionId);
|
|
1740
1943
|
try {
|
|
1741
1944
|
await mkdir(sessionDir, { recursive: true });
|
|
1742
1945
|
} catch (error) {
|
|
1743
1946
|
throw new Error("Failed to create session directory", { cause: error });
|
|
1744
1947
|
}
|
|
1745
|
-
const sessionYamlPath =
|
|
1948
|
+
const sessionYamlPath = join7(sessionDir, "session.yaml");
|
|
1746
1949
|
try {
|
|
1747
1950
|
await linkYamlFile(sessionYamlPath, initialSession);
|
|
1748
1951
|
} catch (error) {
|
|
@@ -1865,7 +2068,7 @@ async function appendEventToExistingSession(input) {
|
|
|
1865
2068
|
}
|
|
1866
2069
|
const eventId = prefixedUlid("evt");
|
|
1867
2070
|
const event = assertTargetEventIdentity(input.eventBuilder(eventId), input.sessionId, eventId);
|
|
1868
|
-
const sessionDir =
|
|
2071
|
+
const sessionDir = join7(input.paths.sessions, input.sessionId);
|
|
1869
2072
|
await appendEvent(sessionDir, event);
|
|
1870
2073
|
return { eventId, sessionStatus: status };
|
|
1871
2074
|
}
|
|
@@ -1880,8 +2083,8 @@ function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
|
|
|
1880
2083
|
}
|
|
1881
2084
|
|
|
1882
2085
|
// src/storage/lockfile.ts
|
|
1883
|
-
import { readFile as
|
|
1884
|
-
import { join as
|
|
2086
|
+
import { mkdir as mkdir2, readFile as readFile4, unlink as unlink2 } from "fs/promises";
|
|
2087
|
+
import { dirname as dirname2, join as join8 } from "path";
|
|
1885
2088
|
var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
|
|
1886
2089
|
async function acquireLock(paths, scope, resourceId) {
|
|
1887
2090
|
const lockPath = lockfilePath(paths, scope, resourceId);
|
|
@@ -1893,6 +2096,19 @@ async function acquireLock(paths, scope, resourceId) {
|
|
|
1893
2096
|
try {
|
|
1894
2097
|
await atomicCreate(lockPath, serialised);
|
|
1895
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
|
+
}
|
|
1896
2112
|
if (!findErrorCode(error, "EEXIST")) {
|
|
1897
2113
|
throw error;
|
|
1898
2114
|
}
|
|
@@ -1916,7 +2132,7 @@ async function acquireLock(paths, scope, resourceId) {
|
|
|
1916
2132
|
async function isStaleLock(lockPath) {
|
|
1917
2133
|
let body;
|
|
1918
2134
|
try {
|
|
1919
|
-
const raw = await
|
|
2135
|
+
const raw = await readFile4(lockPath, "utf8");
|
|
1920
2136
|
const parsed = JSON.parse(raw);
|
|
1921
2137
|
if (typeof parsed !== "object" || parsed === null) return true;
|
|
1922
2138
|
const candidate = parsed;
|
|
@@ -1942,12 +2158,12 @@ async function isStaleLock(lockPath) {
|
|
|
1942
2158
|
function lockfilePath(paths, scope, resourceId) {
|
|
1943
2159
|
const sep = resourceId.indexOf("_");
|
|
1944
2160
|
const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
|
|
1945
|
-
return
|
|
2161
|
+
return join8(paths.locks, `${scope}_${ulid2}.lock`);
|
|
1946
2162
|
}
|
|
1947
2163
|
|
|
1948
2164
|
// src/storage/task-index.ts
|
|
1949
|
-
import { readFile as
|
|
1950
|
-
import { join as
|
|
2165
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2166
|
+
import { join as join9 } from "path";
|
|
1951
2167
|
|
|
1952
2168
|
// src/schemas/task-index.schema.ts
|
|
1953
2169
|
import { z as z7 } from "zod";
|
|
@@ -1966,13 +2182,13 @@ var TASK_INDEX_SCHEMA_VERSION = "0.1.0";
|
|
|
1966
2182
|
|
|
1967
2183
|
// src/storage/task-index.ts
|
|
1968
2184
|
function taskIndexPath(paths) {
|
|
1969
|
-
return
|
|
2185
|
+
return join9(paths.tasks, "index.json");
|
|
1970
2186
|
}
|
|
1971
2187
|
async function readTaskIndex(paths) {
|
|
1972
2188
|
const filePath = taskIndexPath(paths);
|
|
1973
2189
|
let raw;
|
|
1974
2190
|
try {
|
|
1975
|
-
raw = await
|
|
2191
|
+
raw = await readFile5(filePath, "utf8");
|
|
1976
2192
|
} catch (error) {
|
|
1977
2193
|
if (findErrorCode(error, "ENOENT")) {
|
|
1978
2194
|
throw new Error("Task index not found", { cause: error });
|
|
@@ -2080,10 +2296,10 @@ function splitFrontMatter(raw) {
|
|
|
2080
2296
|
return { yamlText, body };
|
|
2081
2297
|
}
|
|
2082
2298
|
async function readTaskFile(paths, taskId) {
|
|
2083
|
-
const filePath =
|
|
2299
|
+
const filePath = join10(paths.tasks, `${taskId}.md`);
|
|
2084
2300
|
let raw;
|
|
2085
2301
|
try {
|
|
2086
|
-
raw = await
|
|
2302
|
+
raw = await readFile6(filePath, "utf8");
|
|
2087
2303
|
} catch (error) {
|
|
2088
2304
|
if (findErrorCode(error, "ENOENT")) {
|
|
2089
2305
|
throw new Error("Task file not found", { cause: error });
|
|
@@ -2113,7 +2329,7 @@ async function readTaskFile(paths, taskId) {
|
|
|
2113
2329
|
}
|
|
2114
2330
|
async function writeTaskFile(paths, taskId, doc, options) {
|
|
2115
2331
|
const validated = TaskSchema.parse(doc.task);
|
|
2116
|
-
const filePath =
|
|
2332
|
+
const filePath = join10(paths.tasks, `${taskId}.md`);
|
|
2117
2333
|
const yamlText = stringifyYaml(validated);
|
|
2118
2334
|
const trimmedBody = doc.body.length === 0 ? "" : `
|
|
2119
2335
|
${doc.body.endsWith("\n") ? doc.body : `${doc.body}
|
|
@@ -2198,7 +2414,7 @@ async function safeUpdateTaskIndex(paths, op) {
|
|
|
2198
2414
|
}
|
|
2199
2415
|
var ARCHIVE_DIR_NAME = "archive";
|
|
2200
2416
|
function archiveTasksDir(paths) {
|
|
2201
|
-
return
|
|
2417
|
+
return join10(paths.tasks, ARCHIVE_DIR_NAME);
|
|
2202
2418
|
}
|
|
2203
2419
|
async function enumerateArchivedTaskIds(paths) {
|
|
2204
2420
|
let entries;
|
|
@@ -2228,10 +2444,10 @@ async function readTaskFileWithArchiveFallback(paths, taskId) {
|
|
|
2228
2444
|
throw error;
|
|
2229
2445
|
}
|
|
2230
2446
|
}
|
|
2231
|
-
const archiveFilePath =
|
|
2447
|
+
const archiveFilePath = join10(archiveTasksDir(paths), `${taskId}.md`);
|
|
2232
2448
|
let raw;
|
|
2233
2449
|
try {
|
|
2234
|
-
raw = await
|
|
2450
|
+
raw = await readFile6(archiveFilePath, "utf8");
|
|
2235
2451
|
} catch (error) {
|
|
2236
2452
|
if (findErrorCode(error, "ENOENT")) {
|
|
2237
2453
|
throw new Error("Task file not found", { cause: error });
|
|
@@ -2522,7 +2738,7 @@ async function createTaskAttachLocked(input) {
|
|
|
2522
2738
|
...sessionDoc,
|
|
2523
2739
|
session: { ...sessionDoc.session, task_id: input.taskId }
|
|
2524
2740
|
};
|
|
2525
|
-
await overwriteYamlFile(
|
|
2741
|
+
await overwriteYamlFile(join10(input.paths.sessions, input.sessionId, "session.yaml"), updated);
|
|
2526
2742
|
} catch (error) {
|
|
2527
2743
|
throw new TaskWriteAfterEventError({
|
|
2528
2744
|
taskId: input.taskId,
|
|
@@ -2781,17 +2997,17 @@ function buildUpdatedDoc(input) {
|
|
|
2781
2997
|
return { task: next, body: input.currentDoc.body };
|
|
2782
2998
|
}
|
|
2783
2999
|
async function computeTaskMdSnapshot(paths, taskId) {
|
|
2784
|
-
const filePath =
|
|
2785
|
-
const [stats, raw] = await Promise.all([stat2(filePath),
|
|
2786
|
-
const hash =
|
|
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");
|
|
2787
3003
|
return { mtimeMs: stats.mtimeMs, hash };
|
|
2788
3004
|
}
|
|
2789
3005
|
async function readTaskFileWithSnapshot(paths, taskId) {
|
|
2790
|
-
const filePath =
|
|
3006
|
+
const filePath = join10(paths.tasks, `${taskId}.md`);
|
|
2791
3007
|
let rawBuffer;
|
|
2792
3008
|
let stats;
|
|
2793
3009
|
try {
|
|
2794
|
-
[rawBuffer, stats] = await Promise.all([
|
|
3010
|
+
[rawBuffer, stats] = await Promise.all([readFile6(filePath), stat2(filePath)]);
|
|
2795
3011
|
} catch (error) {
|
|
2796
3012
|
if (findErrorCode(error, "ENOENT")) {
|
|
2797
3013
|
throw new Error("Task file not found", { cause: error });
|
|
@@ -2799,7 +3015,7 @@ async function readTaskFileWithSnapshot(paths, taskId) {
|
|
|
2799
3015
|
throw new Error("Failed to read task file", { cause: error });
|
|
2800
3016
|
}
|
|
2801
3017
|
const raw = rawBuffer.toString("utf8");
|
|
2802
|
-
const hash =
|
|
3018
|
+
const hash = createHash2("sha256").update(rawBuffer).digest("hex");
|
|
2803
3019
|
let split;
|
|
2804
3020
|
try {
|
|
2805
3021
|
split = splitFrontMatter(raw);
|
|
@@ -3279,7 +3495,7 @@ async function deleteTaskLocked(input) {
|
|
|
3279
3495
|
});
|
|
3280
3496
|
const eventId = adHoc.targetEventIds[0];
|
|
3281
3497
|
try {
|
|
3282
|
-
await unlink3(
|
|
3498
|
+
await unlink3(join10(input.paths.tasks, `${input.taskId}.md`));
|
|
3283
3499
|
} catch (error) {
|
|
3284
3500
|
throw new TaskWriteAfterEventError({
|
|
3285
3501
|
taskId: input.taskId,
|
|
@@ -3349,10 +3565,10 @@ async function archiveTaskLocked(input) {
|
|
|
3349
3565
|
{ task: next, body: doc.body },
|
|
3350
3566
|
{ mode: "overwrite" }
|
|
3351
3567
|
);
|
|
3352
|
-
await
|
|
3568
|
+
await mkdir3(archiveTasksDir(input.paths), { recursive: true });
|
|
3353
3569
|
await rename2(
|
|
3354
|
-
|
|
3355
|
-
|
|
3570
|
+
join10(input.paths.tasks, `${input.taskId}.md`),
|
|
3571
|
+
join10(archiveTasksDir(input.paths), `${input.taskId}.md`)
|
|
3356
3572
|
);
|
|
3357
3573
|
} catch (error) {
|
|
3358
3574
|
throw new TaskWriteAfterEventError({
|
|
@@ -3388,7 +3604,7 @@ async function renderHandoff(input) {
|
|
|
3388
3604
|
const tasksCreated = [];
|
|
3389
3605
|
const tasksStatusChanged = [];
|
|
3390
3606
|
for (const entry of entries) {
|
|
3391
|
-
const sessionDir =
|
|
3607
|
+
const sessionDir = join11(input.paths.sessions, entry.sessionId);
|
|
3392
3608
|
try {
|
|
3393
3609
|
for await (const ev of replayEvents(sessionDir, {
|
|
3394
3610
|
onWarning: (w) => input.onWarning?.(w, entry.sessionId)
|
|
@@ -3922,7 +4138,13 @@ var SessionInnerImportSchema = z10.object({
|
|
|
3922
4138
|
version: z10.literal("0.1.0"),
|
|
3923
4139
|
// Source-tool-native id (e.g. Claude Code session UUID), retained so
|
|
3924
4140
|
// re-imports of the same source can be deduplicated.
|
|
3925
|
-
external_id: z10.string().optional()
|
|
4141
|
+
external_id: z10.string().optional(),
|
|
4142
|
+
// Byte size of the source native log at import time. Declared here too
|
|
4143
|
+
// (not only in session.schema.ts) because this inner `source` object is
|
|
4144
|
+
// a plain z.object: zod strips keys it does not declare, so a field
|
|
4145
|
+
// absent here would be dropped from the parsed payload before persist
|
|
4146
|
+
// and the size could never be stored.
|
|
4147
|
+
source_size_bytes: z10.number().int().nonnegative().optional()
|
|
3926
4148
|
}),
|
|
3927
4149
|
started_at: IsoTimestampSchema,
|
|
3928
4150
|
ended_at: IsoTimestampSchema.optional(),
|
|
@@ -3936,7 +4158,12 @@ var SessionInnerImportSchema = z10.object({
|
|
|
3936
4158
|
related_files: z10.array(z10.string()).default([]),
|
|
3937
4159
|
events_log: z10.string().optional(),
|
|
3938
4160
|
summary: z10.string().nullable().optional(),
|
|
3939
|
-
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()
|
|
3940
4167
|
}).strict();
|
|
3941
4168
|
var SessionImportPayloadSchema = z10.object({
|
|
3942
4169
|
schema_version: z10.string(),
|
|
@@ -4018,7 +4245,7 @@ function serializeJsonSchema(schema) {
|
|
|
4018
4245
|
}
|
|
4019
4246
|
|
|
4020
4247
|
// src/stats/work-stats.ts
|
|
4021
|
-
import { join as
|
|
4248
|
+
import { join as join12 } from "path";
|
|
4022
4249
|
function resolveTimeZone(timeZone) {
|
|
4023
4250
|
if (timeZone !== void 0 && timeZone.length > 0) return timeZone;
|
|
4024
4251
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
@@ -4049,7 +4276,7 @@ async function computeWorkStats(input) {
|
|
|
4049
4276
|
const events = [];
|
|
4050
4277
|
let eventsUnreadable = false;
|
|
4051
4278
|
try {
|
|
4052
|
-
for await (const ev of replayEvents(
|
|
4279
|
+
for await (const ev of replayEvents(join12(input.paths.sessions, entry.sessionId), {
|
|
4053
4280
|
onWarning: (w) => input.onWarning?.(w, entry.sessionId)
|
|
4054
4281
|
})) {
|
|
4055
4282
|
events.push(ev);
|
|
@@ -4304,28 +4531,28 @@ function tzDate(ms, timeZone) {
|
|
|
4304
4531
|
}
|
|
4305
4532
|
|
|
4306
4533
|
// src/storage/basou-dir.ts
|
|
4307
|
-
import { lstat as lstat3, mkdir as
|
|
4308
|
-
import { join as
|
|
4534
|
+
import { lstat as lstat3, mkdir as mkdir4 } from "fs/promises";
|
|
4535
|
+
import { join as join13 } from "path";
|
|
4309
4536
|
function basouPaths(repositoryRoot) {
|
|
4310
|
-
const root =
|
|
4311
|
-
const approvalsBase =
|
|
4537
|
+
const root = join13(repositoryRoot, ".basou");
|
|
4538
|
+
const approvalsBase = join13(root, "approvals");
|
|
4312
4539
|
return {
|
|
4313
4540
|
root,
|
|
4314
|
-
sessions:
|
|
4315
|
-
tasks:
|
|
4541
|
+
sessions: join13(root, "sessions"),
|
|
4542
|
+
tasks: join13(root, "tasks"),
|
|
4316
4543
|
approvals: {
|
|
4317
|
-
pending:
|
|
4318
|
-
resolved:
|
|
4544
|
+
pending: join13(approvalsBase, "pending"),
|
|
4545
|
+
resolved: join13(approvalsBase, "resolved")
|
|
4319
4546
|
},
|
|
4320
|
-
locks:
|
|
4321
|
-
logs:
|
|
4322
|
-
raw:
|
|
4323
|
-
tmp:
|
|
4547
|
+
locks: join13(root, "locks"),
|
|
4548
|
+
logs: join13(root, "logs"),
|
|
4549
|
+
raw: join13(root, "raw"),
|
|
4550
|
+
tmp: join13(root, "tmp"),
|
|
4324
4551
|
files: {
|
|
4325
|
-
manifest:
|
|
4326
|
-
status:
|
|
4327
|
-
handoff:
|
|
4328
|
-
decisions:
|
|
4552
|
+
manifest: join13(root, "manifest.yaml"),
|
|
4553
|
+
status: join13(root, "status.json"),
|
|
4554
|
+
handoff: join13(root, "handoff.md"),
|
|
4555
|
+
decisions: join13(root, "decisions.md")
|
|
4329
4556
|
}
|
|
4330
4557
|
};
|
|
4331
4558
|
}
|
|
@@ -4366,7 +4593,7 @@ async function ensureBasouDirectory(repositoryRoot) {
|
|
|
4366
4593
|
}
|
|
4367
4594
|
async function mkdirLabeled(target, label) {
|
|
4368
4595
|
try {
|
|
4369
|
-
await
|
|
4596
|
+
await mkdir4(target, { recursive: true });
|
|
4370
4597
|
} catch (error) {
|
|
4371
4598
|
if (hasErrorCode3(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
|
|
4372
4599
|
throw new Error(`${label} exists but is not a directory`, { cause: error });
|
|
@@ -4381,16 +4608,16 @@ function hasErrorCode3(error) {
|
|
|
4381
4608
|
}
|
|
4382
4609
|
|
|
4383
4610
|
// src/storage/gitignore.ts
|
|
4384
|
-
import { readFile as
|
|
4385
|
-
import { join as
|
|
4611
|
+
import { readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
|
|
4612
|
+
import { join as join14 } from "path";
|
|
4386
4613
|
var MARKER = "# Basou - default ignore";
|
|
4387
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";
|
|
4388
4615
|
async function appendBasouGitignore(repositoryRoot) {
|
|
4389
|
-
const gitignorePath =
|
|
4616
|
+
const gitignorePath = join14(repositoryRoot, ".gitignore");
|
|
4390
4617
|
let body;
|
|
4391
4618
|
let existed;
|
|
4392
4619
|
try {
|
|
4393
|
-
body = await
|
|
4620
|
+
body = await readFile7(gitignorePath, "utf8");
|
|
4394
4621
|
existed = true;
|
|
4395
4622
|
} catch (error) {
|
|
4396
4623
|
if (hasErrorCode4(error) && error.code === "ENOENT") {
|
|
@@ -4496,12 +4723,12 @@ function hasErrorCode5(error) {
|
|
|
4496
4723
|
}
|
|
4497
4724
|
|
|
4498
4725
|
// src/storage/markdown-store.ts
|
|
4499
|
-
import { readFile as
|
|
4726
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
4500
4727
|
var GENERATED_START = "<!-- BASOU:GENERATED:START -->";
|
|
4501
4728
|
var GENERATED_END = "<!-- BASOU:GENERATED:END -->";
|
|
4502
4729
|
async function readMarkdownFile(filePath) {
|
|
4503
4730
|
try {
|
|
4504
|
-
return await
|
|
4731
|
+
return await readFile8(filePath, "utf8");
|
|
4505
4732
|
} catch (error) {
|
|
4506
4733
|
if (hasErrorCode6(error) && error.code === "ENOENT") return null;
|
|
4507
4734
|
throw new Error("Failed to read markdown file", { cause: error });
|
|
@@ -4599,9 +4826,9 @@ function hasErrorCode6(error) {
|
|
|
4599
4826
|
}
|
|
4600
4827
|
|
|
4601
4828
|
// src/storage/session-import.ts
|
|
4602
|
-
import { mkdir as
|
|
4829
|
+
import { mkdir as mkdir5, readFile as readFile9, rm as rm2 } from "fs/promises";
|
|
4603
4830
|
import { homedir as homedir2 } from "os";
|
|
4604
|
-
import { join as
|
|
4831
|
+
import { join as join15 } from "path";
|
|
4605
4832
|
async function importSessionFromJson(paths, manifest, payload, options) {
|
|
4606
4833
|
if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
|
|
4607
4834
|
throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
|
|
@@ -4626,21 +4853,22 @@ async function importSessionFromJson(paths, manifest, payload, options) {
|
|
|
4626
4853
|
pathSanitizeReport
|
|
4627
4854
|
};
|
|
4628
4855
|
}
|
|
4629
|
-
const sessionDir =
|
|
4856
|
+
const sessionDir = join15(paths.sessions, newSessionId);
|
|
4630
4857
|
try {
|
|
4631
|
-
await
|
|
4858
|
+
await mkdir5(sessionDir, { recursive: true });
|
|
4632
4859
|
} catch (error) {
|
|
4633
4860
|
throw new Error("Failed to create session directory", { cause: error });
|
|
4634
4861
|
}
|
|
4862
|
+
let chainResult;
|
|
4635
4863
|
try {
|
|
4636
|
-
await writeEventsBulk(sessionDir, rewrittenEvents);
|
|
4864
|
+
chainResult = await writeEventsBulk(sessionDir, rewrittenEvents, { chain: true });
|
|
4637
4865
|
} catch (error) {
|
|
4638
4866
|
await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
4639
4867
|
throw error;
|
|
4640
4868
|
}
|
|
4641
4869
|
try {
|
|
4642
|
-
const sessionYamlPath =
|
|
4643
|
-
await linkYamlFile(sessionYamlPath, sessionRecord);
|
|
4870
|
+
const sessionYamlPath = join15(sessionDir, "session.yaml");
|
|
4871
|
+
await linkYamlFile(sessionYamlPath, withIntegrity(sessionRecord, chainResult));
|
|
4644
4872
|
} catch (error) {
|
|
4645
4873
|
await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
4646
4874
|
if (findErrorCode(error, "EEXIST")) {
|
|
@@ -4697,6 +4925,16 @@ function assertChronologicalOrder(events) {
|
|
|
4697
4925
|
}
|
|
4698
4926
|
}
|
|
4699
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
|
+
}
|
|
4700
4938
|
function buildSessionRecord(input, manifest, newSessionId, options) {
|
|
4701
4939
|
const home = homedir2();
|
|
4702
4940
|
const workingDirectoryRaw = input.working_directory;
|
|
@@ -4731,6 +4969,243 @@ function buildSessionRecord(input, manifest, newSessionId, options) {
|
|
|
4731
4969
|
}
|
|
4732
4970
|
};
|
|
4733
4971
|
}
|
|
4972
|
+
var IMPORT_DERIVED_SOURCES = /* @__PURE__ */ new Set([
|
|
4973
|
+
"claude-code-import",
|
|
4974
|
+
"codex-import"
|
|
4975
|
+
]);
|
|
4976
|
+
function isImportDerivedSource(source) {
|
|
4977
|
+
return IMPORT_DERIVED_SOURCES.has(source);
|
|
4978
|
+
}
|
|
4979
|
+
function derivedEventContentKey(event) {
|
|
4980
|
+
const base = `${event.type}\0${event.occurred_at}`;
|
|
4981
|
+
switch (event.type) {
|
|
4982
|
+
case "command_executed":
|
|
4983
|
+
return `${base}\0${event.command}\0${event.args.join("")}\0${event.cwd}`;
|
|
4984
|
+
case "file_changed":
|
|
4985
|
+
return `${base}\0${event.path}\0${event.change_type}`;
|
|
4986
|
+
case "decision_recorded":
|
|
4987
|
+
return `${base}\0${event.title}`;
|
|
4988
|
+
default:
|
|
4989
|
+
return base;
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
function reuseDerivedIds(priorDerived, freshDerived, sessionId) {
|
|
4993
|
+
const priorStarted = priorDerived.find((e) => e.type === "session_started");
|
|
4994
|
+
const priorEnded = priorDerived.find((e) => e.type === "session_ended");
|
|
4995
|
+
let startedUsed = false;
|
|
4996
|
+
let endedUsed = false;
|
|
4997
|
+
const middleByKey = /* @__PURE__ */ new Map();
|
|
4998
|
+
for (const e of priorDerived) {
|
|
4999
|
+
if (e.type === "session_started" || e.type === "session_ended") continue;
|
|
5000
|
+
const key = derivedEventContentKey(e);
|
|
5001
|
+
const list = middleByKey.get(key);
|
|
5002
|
+
if (list === void 0) middleByKey.set(key, [e]);
|
|
5003
|
+
else list.push(e);
|
|
5004
|
+
}
|
|
5005
|
+
let reusedIdCount = 0;
|
|
5006
|
+
const withReusedId = (fresh, prior) => {
|
|
5007
|
+
reusedIdCount++;
|
|
5008
|
+
if (fresh.type === "decision_recorded" && prior.type === "decision_recorded") {
|
|
5009
|
+
return { ...fresh, id: prior.id, session_id: sessionId, decision_id: prior.decision_id };
|
|
5010
|
+
}
|
|
5011
|
+
return { ...fresh, id: prior.id, session_id: sessionId };
|
|
5012
|
+
};
|
|
5013
|
+
const events = freshDerived.map((fresh) => {
|
|
5014
|
+
if (fresh.type === "session_started") {
|
|
5015
|
+
if (priorStarted !== void 0) {
|
|
5016
|
+
startedUsed = true;
|
|
5017
|
+
return withReusedId(fresh, priorStarted);
|
|
5018
|
+
}
|
|
5019
|
+
return { ...fresh, id: prefixedUlid("evt"), session_id: sessionId };
|
|
5020
|
+
}
|
|
5021
|
+
if (fresh.type === "session_ended") {
|
|
5022
|
+
if (priorEnded !== void 0) {
|
|
5023
|
+
endedUsed = true;
|
|
5024
|
+
return withReusedId(fresh, priorEnded);
|
|
5025
|
+
}
|
|
5026
|
+
return { ...fresh, id: prefixedUlid("evt"), session_id: sessionId };
|
|
5027
|
+
}
|
|
5028
|
+
const match = middleByKey.get(derivedEventContentKey(fresh))?.shift();
|
|
5029
|
+
if (match !== void 0) return withReusedId(fresh, match);
|
|
5030
|
+
return { ...fresh, id: prefixedUlid("evt"), session_id: sessionId };
|
|
5031
|
+
});
|
|
5032
|
+
const droppedPriorDerived = priorStarted !== void 0 && !startedUsed || priorEnded !== void 0 && !endedUsed || [...middleByKey.values()].some((q) => q.length > 0);
|
|
5033
|
+
return { events, reusedIdCount, droppedPriorDerived };
|
|
5034
|
+
}
|
|
5035
|
+
async function reimportPreservingId(paths, manifest, priorSessionId, freshPayload, options = {}) {
|
|
5036
|
+
const sessionId = priorSessionId;
|
|
5037
|
+
const importSource = freshPayload.session.source.kind;
|
|
5038
|
+
const sessionDir = join15(paths.sessions, priorSessionId);
|
|
5039
|
+
const lock = options.dryRun === true ? null : await acquireLock(paths, "session", priorSessionId);
|
|
5040
|
+
try {
|
|
5041
|
+
const priorVerdict = await verifyEventsChain(paths, priorSessionId);
|
|
5042
|
+
if (priorVerdict.status === "tampered") {
|
|
5043
|
+
return { status: "skipped", reason: "prior_chain_broken" };
|
|
5044
|
+
}
|
|
5045
|
+
let priorUnreadable = false;
|
|
5046
|
+
const priorEvents = await readAllEvents(sessionDir, {
|
|
5047
|
+
onWarning: () => {
|
|
5048
|
+
priorUnreadable = true;
|
|
5049
|
+
}
|
|
5050
|
+
});
|
|
5051
|
+
if (priorUnreadable) {
|
|
5052
|
+
return { status: "skipped", reason: "prior_events_unreadable" };
|
|
5053
|
+
}
|
|
5054
|
+
const priorDerived = priorEvents.filter((e) => e.source === importSource);
|
|
5055
|
+
const preserved = priorEvents.filter((e) => e.source !== importSource);
|
|
5056
|
+
const {
|
|
5057
|
+
events: rederived,
|
|
5058
|
+
reusedIdCount,
|
|
5059
|
+
droppedPriorDerived
|
|
5060
|
+
} = reuseDerivedIds(priorDerived, freshPayload.events, sessionId);
|
|
5061
|
+
if (droppedPriorDerived) {
|
|
5062
|
+
return { status: "skipped", reason: "prior_derived_dropped" };
|
|
5063
|
+
}
|
|
5064
|
+
const mergedEvents = [...rederived, ...preserved].sort(
|
|
5065
|
+
(a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at)
|
|
5066
|
+
);
|
|
5067
|
+
assertChronologicalOrder(mergedEvents);
|
|
5068
|
+
const prior = await readSessionYaml(paths, priorSessionId);
|
|
5069
|
+
const { record } = buildSessionRecord(freshPayload.session, manifest, sessionId, {});
|
|
5070
|
+
const preservedInner = {
|
|
5071
|
+
...record.session,
|
|
5072
|
+
// Defensive: keep any task_id already present on the prior yaml so a
|
|
5073
|
+
// re-derive never drops a link, whatever wrote it.
|
|
5074
|
+
task_id: prior.session.task_id ?? null,
|
|
5075
|
+
// Re-derivation always yields a null summary; keep a prior non-null one.
|
|
5076
|
+
summary: prior.session.summary ?? record.session.summary ?? null
|
|
5077
|
+
};
|
|
5078
|
+
const updatedRecord = { schema_version: "0.1.0", session: preservedInner };
|
|
5079
|
+
if (options.dryRun !== true) {
|
|
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 });
|
|
5090
|
+
try {
|
|
5091
|
+
await overwriteYamlFile(
|
|
5092
|
+
join15(sessionDir, "session.yaml"),
|
|
5093
|
+
withIntegrity(updatedRecord, chainResult)
|
|
5094
|
+
);
|
|
5095
|
+
} catch (error) {
|
|
5096
|
+
if (priorEventsRaw !== null) {
|
|
5097
|
+
await atomicReplace(eventsPath, priorEventsRaw).catch(() => void 0);
|
|
5098
|
+
} else {
|
|
5099
|
+
await rm2(eventsPath, { force: true }).catch(() => void 0);
|
|
5100
|
+
}
|
|
5101
|
+
throw error;
|
|
5102
|
+
}
|
|
5103
|
+
}
|
|
5104
|
+
return {
|
|
5105
|
+
status: "reimported",
|
|
5106
|
+
sessionId,
|
|
5107
|
+
eventCount: mergedEvents.length,
|
|
5108
|
+
preservedCount: preserved.length,
|
|
5109
|
+
reusedIdCount
|
|
5110
|
+
};
|
|
5111
|
+
} finally {
|
|
5112
|
+
await lock?.release();
|
|
5113
|
+
}
|
|
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
|
+
}
|
|
4734
5209
|
|
|
4735
5210
|
// src/index.ts
|
|
4736
5211
|
var BASOU_CORE_VERSION = "0.1.0";
|
|
@@ -4760,6 +5235,7 @@ export {
|
|
|
4760
5235
|
SessionIdSchema,
|
|
4761
5236
|
SessionImportPayloadSchema,
|
|
4762
5237
|
SessionInnerImportSchema,
|
|
5238
|
+
SessionIntegritySchema,
|
|
4763
5239
|
SessionMetricsSchema,
|
|
4764
5240
|
SessionSchema,
|
|
4765
5241
|
SessionSourceKindSchema,
|
|
@@ -4779,6 +5255,8 @@ export {
|
|
|
4779
5255
|
basouPaths,
|
|
4780
5256
|
buildJsonSchemas,
|
|
4781
5257
|
buildStatusSnapshot,
|
|
5258
|
+
chainEvents,
|
|
5259
|
+
chainRawJsonLines,
|
|
4782
5260
|
classifySuspect,
|
|
4783
5261
|
claudeCodeAdapterMetadata,
|
|
4784
5262
|
claudeTranscriptToImportPayload,
|
|
@@ -4795,11 +5273,14 @@ export {
|
|
|
4795
5273
|
enumerateSessionDirs,
|
|
4796
5274
|
enumerateTaskIds,
|
|
4797
5275
|
findErrorCode,
|
|
5276
|
+
genesisHash,
|
|
4798
5277
|
getDiff,
|
|
4799
5278
|
getSnapshot,
|
|
4800
5279
|
importSessionFromJson,
|
|
5280
|
+
isImportDerivedSource,
|
|
4801
5281
|
isLazyExpired,
|
|
4802
5282
|
isValidPrefixedId,
|
|
5283
|
+
lineHash,
|
|
4803
5284
|
linkYamlFile,
|
|
4804
5285
|
loadApproval,
|
|
4805
5286
|
loadSessionEntries,
|
|
@@ -4816,9 +5297,11 @@ export {
|
|
|
4816
5297
|
readTaskFile,
|
|
4817
5298
|
readTaskFileWithArchiveFallback,
|
|
4818
5299
|
readYamlFile,
|
|
5300
|
+
rechainSessionInPlace,
|
|
4819
5301
|
reconcileAllTasks,
|
|
4820
5302
|
reconcileTask,
|
|
4821
5303
|
refreshTaskLinkedSessions,
|
|
5304
|
+
reimportPreservingId,
|
|
4822
5305
|
renderDecisions,
|
|
4823
5306
|
renderHandoff,
|
|
4824
5307
|
renderWithMarkers,
|
|
@@ -4830,12 +5313,14 @@ export {
|
|
|
4830
5313
|
sanitizePath,
|
|
4831
5314
|
sanitizeRelatedFiles,
|
|
4832
5315
|
sanitizeWorkingDirectory,
|
|
5316
|
+
serializeEventLine,
|
|
4833
5317
|
serializeJsonSchema,
|
|
4834
5318
|
sessionWorkStatsFromEvents,
|
|
4835
5319
|
summarizeAdapterOutput,
|
|
4836
5320
|
tryRemoteUrl,
|
|
4837
5321
|
ulid,
|
|
4838
5322
|
updateTaskStatusWithEvent,
|
|
5323
|
+
verifyEventsChain,
|
|
4839
5324
|
writeEventsBulk,
|
|
4840
5325
|
writeManifest,
|
|
4841
5326
|
writeMarkdownFile,
|