@basou/core 0.41.0 → 0.42.1

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
@@ -354,6 +354,251 @@ function isValidPrefixedId(value) {
354
354
  return isValidUlid(ulidPart);
355
355
  }
356
356
 
357
+ // src/schemas/event.schema.ts
358
+ import { z as z2 } from "zod";
359
+
360
+ // src/schemas/shared.schema.ts
361
+ import { z } from "zod";
362
+ var SchemaVersionSchema = z.string().regex(/^0\.\d+\.\d+$/, {
363
+ message: "unsupported .basou format version: this basou reads format major 0 (0.x.y). If this workspace was written by a newer basou, upgrade basou to open it."
364
+ });
365
+ var CacheVersionSchema = z.literal("0.1.0");
366
+ var IsoTimestampSchema = z.string().datetime({ offset: true });
367
+ var createPrefixedIdSchema = (prefix) => {
368
+ const refiner = (value) => isValidPrefixedId(value) && value.startsWith(`${prefix}_`);
369
+ return z.string().refine(refiner, { message: `Expected ${prefix}_<ULID>` }).meta({
370
+ pattern: `^${prefix}_[0-7][0-9A-HJKMNP-TV-Z]{25}$`,
371
+ description: `Basou ${prefix} id: \`${prefix}_\` followed by a 26-character Crockford Base32 ULID.`
372
+ });
373
+ };
374
+ var WorkspaceIdSchema = createPrefixedIdSchema("ws");
375
+ var TaskIdSchema = createPrefixedIdSchema("task");
376
+ var SessionIdSchema = createPrefixedIdSchema("ses");
377
+ var EventIdSchema = createPrefixedIdSchema("evt");
378
+ var ApprovalIdSchema = createPrefixedIdSchema("appr");
379
+ var DecisionIdSchema = createPrefixedIdSchema("decision");
380
+ var RiskLevelSchema = z.enum(["low", "medium", "high", "critical"]);
381
+ var EventSourceSchema = z.string().min(1);
382
+
383
+ // src/schemas/event.schema.ts
384
+ var EVENT_SCHEMA_VERSION = "0.2.0";
385
+ var BaseEventSchema = z2.object({
386
+ schema_version: SchemaVersionSchema,
387
+ id: EventIdSchema,
388
+ session_id: SessionIdSchema,
389
+ occurred_at: IsoTimestampSchema,
390
+ source: EventSourceSchema,
391
+ // Tamper-evidence back-pointer (hex sha-256 of the PREVIOUS event line's
392
+ // written bytes; the first line carries the session-bound genesis hash).
393
+ // Present only on sessions written with chaining enabled (import paths);
394
+ // live/ad-hoc sessions omit it. Declared on the base so the `.strict()`
395
+ // variants below treat it as a known key. Additive optional => no
396
+ // schema_version bump.
397
+ prev_hash: z2.string().optional()
398
+ });
399
+ var SessionStartedEventSchema = BaseEventSchema.extend({
400
+ type: z2.literal("session_started")
401
+ });
402
+ var SessionEndedEventSchema = BaseEventSchema.extend({
403
+ type: z2.literal("session_ended"),
404
+ exit_code: z2.number().int().optional()
405
+ });
406
+ var SessionStatusChangedEventSchema = BaseEventSchema.extend({
407
+ type: z2.literal("session_status_changed"),
408
+ from: z2.string(),
409
+ to: z2.string()
410
+ });
411
+ var ApprovalRequestedEventSchema = BaseEventSchema.extend({
412
+ type: z2.literal("approval_requested"),
413
+ approval_id: ApprovalIdSchema,
414
+ expires_at: IsoTimestampSchema.nullable().default(null),
415
+ risk_level: RiskLevelSchema,
416
+ // `action.kind` is required; additional fields are allowed to support
417
+ // future action shapes (shell_command, external_send, ...).
418
+ action: z2.object({ kind: z2.string() }).passthrough(),
419
+ reason: z2.string(),
420
+ status: z2.literal("pending")
421
+ });
422
+ var ApprovalApprovedEventSchema = BaseEventSchema.extend({
423
+ type: z2.literal("approval_approved"),
424
+ approval_id: ApprovalIdSchema,
425
+ resolver: z2.string().optional(),
426
+ note: z2.string().nullable().optional()
427
+ });
428
+ var ApprovalRejectedEventSchema = BaseEventSchema.extend({
429
+ type: z2.literal("approval_rejected"),
430
+ approval_id: ApprovalIdSchema,
431
+ resolver: z2.string().optional(),
432
+ reason: z2.string()
433
+ });
434
+ var ApprovalExpiredEventSchema = BaseEventSchema.extend({
435
+ type: z2.literal("approval_expired"),
436
+ approval_id: ApprovalIdSchema
437
+ });
438
+ var CommandExecutedEventSchema = BaseEventSchema.extend({
439
+ type: z2.literal("command_executed"),
440
+ command: z2.string().nullable().meta({
441
+ description: "Spawned executable name. null means basou did not observe the executor - not a default, and not a benign value."
442
+ }),
443
+ args: z2.array(z2.string()),
444
+ cwd: z2.string().nullable().meta({
445
+ description: "Working directory the command ran in. null means the directory could not be resolved; it is never the session's directory as a fallback."
446
+ }),
447
+ exit_code: z2.number().int().nullable().meta({
448
+ description: "Child exit code. null means the outcome is unknown (signal-terminated, or never recorded by the source). null is not success."
449
+ }),
450
+ signal: z2.string().nullable().optional(),
451
+ received_signal: z2.string().nullable().optional(),
452
+ // Still accepts 0, because 0.1.0 events carrying it are on disk and are never
453
+ // rewritten; every line read from disk is validated against this schema, so
454
+ // narrowing the domain here would silently drop them. Writers at 0.2.0 and
455
+ // above do not produce it.
456
+ duration_ms: z2.number().int().nonnegative().nullable().meta({
457
+ description: "Observed duration in milliseconds, or null when no duration was observed. Read 0 as unobserved too, on any version: a spawned process cannot run in under half a millisecond, and under schema_version 0.1.0 a writer with nothing to report stored 0 as the floor. Writers at 0.2.0 and above record null instead and never write 0."
458
+ })
459
+ });
460
+ var GitSnapshotEventSchema = BaseEventSchema.extend({
461
+ type: z2.literal("git_snapshot"),
462
+ head: z2.string(),
463
+ branch: z2.string(),
464
+ dirty: z2.boolean(),
465
+ staged: z2.array(z2.string()),
466
+ unstaged: z2.array(z2.string()),
467
+ untracked: z2.array(z2.string()),
468
+ ahead: z2.number().int().nonnegative().optional(),
469
+ behind: z2.number().int().nonnegative().optional()
470
+ });
471
+ var FileChangedEventSchema = BaseEventSchema.extend({
472
+ type: z2.literal("file_changed"),
473
+ path: z2.string(),
474
+ change_type: z2.enum(["added", "modified", "deleted", "renamed"]),
475
+ // Renamed entries record the previous path here. Optional + nullable to
476
+ // keep the wire format stable for added / modified / deleted events.
477
+ old_path: z2.string().nullable().optional()
478
+ });
479
+ var DecisionRecordedEventSchema = BaseEventSchema.extend({
480
+ type: z2.literal("decision_recorded"),
481
+ decision_id: DecisionIdSchema,
482
+ title: z2.string(),
483
+ rationale: z2.string().nullable().optional(),
484
+ alternatives: z2.array(z2.string().min(1)).optional(),
485
+ rejected_reason: z2.string().nullable().optional(),
486
+ linked_events: z2.array(EventIdSchema).optional(),
487
+ linked_files: z2.array(z2.string().min(1).max(4096)).optional(),
488
+ // `track` promotes a decision to a strategic, unfinished DIRECTION ("the next
489
+ // essential thing to build, and why") that orientation/handoff resurface every
490
+ // time until it is explicitly closed with `decision void` / supersede — as
491
+ // opposed to a point-in-time `decision`, which is only ever surfaced as the
492
+ // single latest one. This is the intent-continuity layer: a direction agreed
493
+ // in conversation otherwise sinks into the flat decision list and never carries
494
+ // to the next session. Absent (the default) is a plain `decision`, so all
495
+ // pre-existing decision_recorded events round-trip unchanged (additive optional
496
+ // => no schema_version bump; mirrors `note_added.kind`).
497
+ kind: z2.enum(["decision", "track"]).optional()
498
+ });
499
+ var DecisionVoidedEventSchema = BaseEventSchema.extend({
500
+ type: z2.literal("decision_voided"),
501
+ decision_id: DecisionIdSchema,
502
+ reason: z2.string().nullable().optional(),
503
+ superseded_by: DecisionIdSchema.optional()
504
+ });
505
+ var TaskCreatedEventSchema = BaseEventSchema.extend({
506
+ type: z2.literal("task_created"),
507
+ task_id: TaskIdSchema,
508
+ title: z2.string()
509
+ });
510
+ var TaskStatusChangedEventSchema = BaseEventSchema.extend({
511
+ type: z2.literal("task_status_changed"),
512
+ task_id: TaskIdSchema,
513
+ from: z2.string(),
514
+ to: z2.string()
515
+ });
516
+ var TaskReconciledEventSchema = BaseEventSchema.extend({
517
+ type: z2.literal("task_reconciled"),
518
+ task_id: TaskIdSchema,
519
+ removed_created_in_session: SessionIdSchema.nullable().default(null),
520
+ created_in_session_replacement: SessionIdSchema.nullable().default(null),
521
+ removed_linked_sessions: z2.array(SessionIdSchema).default([])
522
+ }).strict();
523
+ var TaskLinkageRefreshedEventSchema = BaseEventSchema.extend({
524
+ type: z2.literal("task_linkage_refreshed"),
525
+ task_id: TaskIdSchema,
526
+ added_linked_sessions: z2.array(SessionIdSchema).default([]),
527
+ removed_linked_sessions: z2.array(SessionIdSchema).default([]),
528
+ final_count: z2.number().int().nonnegative().optional()
529
+ }).strict();
530
+ var TaskDeletedEventSchema = BaseEventSchema.extend({
531
+ type: z2.literal("task_deleted"),
532
+ task_id: TaskIdSchema,
533
+ title: z2.string().min(1)
534
+ }).strict();
535
+ var TaskArchivedEventSchema = BaseEventSchema.extend({
536
+ type: z2.literal("task_archived"),
537
+ task_id: TaskIdSchema,
538
+ title: z2.string().min(1)
539
+ }).strict();
540
+ var NoteAddedEventSchema = BaseEventSchema.extend({
541
+ type: z2.literal("note_added"),
542
+ body: z2.string(),
543
+ // `next_step` marks a note authored by `basou note` as the operator's resume
544
+ // hint, which orientation surfaces as the next starting point. Absent (the
545
+ // `basou session note` default) is a plain annotation orientation does not
546
+ // surface. Optional so pre-existing note_added events remain valid.
547
+ kind: z2.enum(["note", "next_step"]).optional()
548
+ });
549
+ var ReviewFindingSchema = z2.object({
550
+ title: z2.string().min(1),
551
+ severity: z2.enum(["high", "medium", "low"]).optional(),
552
+ location: z2.string().min(1).optional(),
553
+ summary: z2.string().min(1).optional()
554
+ });
555
+ var ReviewBlockedSchema = z2.object({
556
+ title: z2.string().min(1),
557
+ reason: z2.enum(["spec-deviation", "design-reversal"]),
558
+ why: z2.string().min(1).optional()
559
+ });
560
+ var ReviewRecordedEventSchema = BaseEventSchema.extend({
561
+ type: z2.literal("review_recorded"),
562
+ reviewer: z2.string().min(1),
563
+ target: z2.string().min(1),
564
+ repos: z2.array(z2.string().min(1)).optional(),
565
+ repos_resolved: z2.array(z2.string().min(1)).optional(),
566
+ commits: z2.array(z2.string().min(1)).optional(),
567
+ verdict: z2.enum(["pass", "needs-attention", "fail"]).optional(),
568
+ findings: z2.array(ReviewFindingSchema).optional(),
569
+ blocked: z2.array(ReviewBlockedSchema).optional()
570
+ });
571
+ var AdapterOutputEventSchema = BaseEventSchema.extend({
572
+ type: z2.literal("adapter_output"),
573
+ stream: z2.enum(["stdout", "stderr"]),
574
+ summary: z2.string(),
575
+ raw_ref: z2.string(),
576
+ redacted: z2.boolean().optional()
577
+ }).strict();
578
+ var EventSchema = z2.discriminatedUnion("type", [
579
+ SessionStartedEventSchema,
580
+ SessionEndedEventSchema,
581
+ SessionStatusChangedEventSchema,
582
+ ApprovalRequestedEventSchema,
583
+ ApprovalApprovedEventSchema,
584
+ ApprovalRejectedEventSchema,
585
+ ApprovalExpiredEventSchema,
586
+ CommandExecutedEventSchema,
587
+ GitSnapshotEventSchema,
588
+ FileChangedEventSchema,
589
+ DecisionRecordedEventSchema,
590
+ DecisionVoidedEventSchema,
591
+ TaskCreatedEventSchema,
592
+ TaskStatusChangedEventSchema,
593
+ TaskReconciledEventSchema,
594
+ TaskLinkageRefreshedEventSchema,
595
+ TaskDeletedEventSchema,
596
+ TaskArchivedEventSchema,
597
+ NoteAddedEventSchema,
598
+ ReviewRecordedEventSchema,
599
+ AdapterOutputEventSchema
600
+ ]);
601
+
357
602
  // src/stats/active-time.ts
358
603
  var ACTIVE_GAP_CAP_MS = 5 * 60 * 1e3;
359
604
  var ENGAGED_TURNS_METHOD = "engaged-turns";
@@ -564,7 +809,7 @@ function claudeTranscriptToImportPayload(records, options) {
564
809
  }
565
810
  function baseEvent(occurredAt, sessionId) {
566
811
  return {
567
- schema_version: "0.1.0",
812
+ schema_version: EVENT_SCHEMA_VERSION,
568
813
  id: prefixedUlid("evt"),
569
814
  session_id: sessionId,
570
815
  occurred_at: occurredAt,
@@ -596,7 +841,9 @@ function commandExecutedEvent(occurredAt, sessionId, command, cwd) {
596
841
  // tool results across twelve transcripts). Reading it is its own change and
597
842
  // has not been made, so nothing is claimed here yet.
598
843
  exit_code: null,
599
- duration_ms: 0
844
+ // Not observed: a Bash tool result carries no timing at all, so 0 here would
845
+ // claim a measured duration of zero for every imported command.
846
+ duration_ms: null
600
847
  };
601
848
  }
602
849
  function fileChangedEvent(occurredAt, sessionId, path2, changeType) {
@@ -789,6 +1036,44 @@ function findBasouSessionStartHook(hooksFile) {
789
1036
  return null;
790
1037
  }
791
1038
 
1039
+ // src/schemas/observed-duration.ts
1040
+ function readObservedDuration(ev) {
1041
+ const stored = ev.duration_ms;
1042
+ if (stored === null || stored === 0) return null;
1043
+ return stored;
1044
+ }
1045
+ function writeObservedDuration(measuredMs) {
1046
+ if (measuredMs === null || !Number.isFinite(measuredMs)) return null;
1047
+ const rounded = Math.round(measuredMs);
1048
+ if (rounded <= 0) return null;
1049
+ if (!Number.isSafeInteger(rounded)) return null;
1050
+ return rounded;
1051
+ }
1052
+ var ZERO_DURATION_RETIRED_SINCE = "0.2.0";
1053
+ function hasRetiredZeroDuration(ev) {
1054
+ if (ev.type !== "command_executed") return false;
1055
+ if (ev.duration_ms !== 0) return false;
1056
+ return compareSchemaVersion(ev.schema_version, ZERO_DURATION_RETIRED_SINCE) >= 0;
1057
+ }
1058
+ function compareSchemaVersion(a, b) {
1059
+ const pa = parseSchemaVersion(a);
1060
+ const pb = parseSchemaVersion(b);
1061
+ if (pa === null) return pb === null ? 0 : -1;
1062
+ if (pb === null) return 1;
1063
+ for (let i = 0; i < 3; i++) {
1064
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1065
+ if (d !== 0) return d;
1066
+ }
1067
+ return 0;
1068
+ }
1069
+ function parseSchemaVersion(version) {
1070
+ const parts = version.split(".");
1071
+ if (parts.length !== 3) return null;
1072
+ const nums = parts.map((part) => /^\d+$/.test(part) ? Number.parseInt(part, 10) : Number.NaN);
1073
+ if (nums.some((n) => !Number.isFinite(n))) return null;
1074
+ return [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0];
1075
+ }
1076
+
792
1077
  // src/adapters/codex/rollout-importer.ts
793
1078
  var CODEX_IMPORT_SOURCE = "codex-import";
794
1079
  function codexRolloutToImportPayload(records, options) {
@@ -846,7 +1131,7 @@ function codexRolloutToImportPayload(records, options) {
846
1131
  const scan = scanScript(readString4(payload2.input));
847
1132
  if (scan.commands.length === 0) continue;
848
1133
  const output2 = readCallId(payload2.call_id, outputsByCallId);
849
- const durationMs = scan.toolCallCount === 1 ? parseWallTimeMs(output2) : 0;
1134
+ const durationMs = scan.toolCallCount === 1 ? parseWallTimeMs(output2) : null;
850
1135
  const scriptTsMs = Date.parse(ts);
851
1136
  if (Number.isFinite(scriptTsMs)) engagementTsMs.push(scriptTsMs);
852
1137
  for (const command2 of scan.commands) {
@@ -875,10 +1160,17 @@ function codexRolloutToImportPayload(records, options) {
875
1160
  const output = readCallId(payload2.call_id, outputsByCallId);
876
1161
  const execTsMs = Date.parse(ts);
877
1162
  if (Number.isFinite(execTsMs)) engagementTsMs.push(execTsMs);
1163
+ const exitCode = parseExitCode(output);
878
1164
  derived.push(
879
1165
  commandExecutedEvent2(ts, placeholderSessionId, command.cmd, cwd, {
880
- exitCode: parseExitCode(output),
881
- durationMs: parseWallTimeMs(output)
1166
+ exitCode,
1167
+ // The duration is gated on the SAME token as the exit code. When this
1168
+ // output reports no exit ("Process running with session ID N", or no
1169
+ // outcome line at all), codex handed the turn back while the child was
1170
+ // still running, so its `Wall time` is the interval codex waited and
1171
+ // not how long the command took. A wall-clock duration cannot have been
1172
+ // observed without observing that the process ended.
1173
+ durationMs: exitCode === null ? null : parseWallTimeMs(output)
882
1174
  })
883
1175
  );
884
1176
  }
@@ -955,7 +1247,7 @@ function codexRolloutToImportPayload(records, options) {
955
1247
  }
956
1248
  function baseEvent2(occurredAt, sessionId) {
957
1249
  return {
958
- schema_version: "0.1.0",
1250
+ schema_version: EVENT_SCHEMA_VERSION,
959
1251
  id: prefixedUlid("evt"),
960
1252
  session_id: sessionId,
961
1253
  occurred_at: occurredAt,
@@ -1318,11 +1610,11 @@ function parseExitCode(output) {
1318
1610
  return match?.[1] !== void 0 ? Number.parseInt(match[1], 10) : null;
1319
1611
  }
1320
1612
  function parseWallTimeMs(output) {
1321
- if (output === void 0) return 0;
1613
+ if (output === void 0) return null;
1322
1614
  const match = output.match(/Wall time:?\s*([\d.]+)\s*seconds/);
1323
- if (match?.[1] === void 0) return 0;
1615
+ if (match?.[1] === void 0) return null;
1324
1616
  const seconds = Number.parseFloat(match[1]);
1325
- return Number.isFinite(seconds) ? Math.round(seconds * 1e3) : 0;
1617
+ return writeObservedDuration(Number.isFinite(seconds) ? seconds * 1e3 : null);
1326
1618
  }
1327
1619
  function indexOutputs(records) {
1328
1620
  const byId = /* @__PURE__ */ new Map();
@@ -1361,50 +1653,25 @@ function findErrorCode(error, code, depth = 4) {
1361
1653
  }
1362
1654
 
1363
1655
  // src/schemas/approval.schema.ts
1364
- import { z as z2 } from "zod";
1365
-
1366
- // src/schemas/shared.schema.ts
1367
- import { z } from "zod";
1368
- var SchemaVersionSchema = z.string().regex(/^0\.\d+\.\d+$/, {
1369
- message: "unsupported .basou format version: this basou reads format major 0 (0.x.y). If this workspace was written by a newer basou, upgrade basou to open it."
1370
- });
1371
- var CacheVersionSchema = z.literal("0.1.0");
1372
- var IsoTimestampSchema = z.string().datetime({ offset: true });
1373
- var createPrefixedIdSchema = (prefix) => {
1374
- const refiner = (value) => isValidPrefixedId(value) && value.startsWith(`${prefix}_`);
1375
- return z.string().refine(refiner, { message: `Expected ${prefix}_<ULID>` }).meta({
1376
- pattern: `^${prefix}_[0-7][0-9A-HJKMNP-TV-Z]{25}$`,
1377
- description: `Basou ${prefix} id: \`${prefix}_\` followed by a 26-character Crockford Base32 ULID.`
1378
- });
1379
- };
1380
- var WorkspaceIdSchema = createPrefixedIdSchema("ws");
1381
- var TaskIdSchema = createPrefixedIdSchema("task");
1382
- var SessionIdSchema = createPrefixedIdSchema("ses");
1383
- var EventIdSchema = createPrefixedIdSchema("evt");
1384
- var ApprovalIdSchema = createPrefixedIdSchema("appr");
1385
- var DecisionIdSchema = createPrefixedIdSchema("decision");
1386
- var RiskLevelSchema = z.enum(["low", "medium", "high", "critical"]);
1387
- var EventSourceSchema = z.string().min(1);
1388
-
1389
- // src/schemas/approval.schema.ts
1390
- var ApprovalStatusSchema = z2.enum(["pending", "approved", "rejected", "expired"]);
1391
- var ApprovalSchema = z2.looseObject({
1656
+ import { z as z3 } from "zod";
1657
+ var ApprovalStatusSchema = z3.enum(["pending", "approved", "rejected", "expired"]);
1658
+ var ApprovalSchema = z3.looseObject({
1392
1659
  schema_version: SchemaVersionSchema,
1393
1660
  id: ApprovalIdSchema,
1394
1661
  session_id: SessionIdSchema,
1395
1662
  created_at: IsoTimestampSchema,
1396
1663
  status: ApprovalStatusSchema,
1397
1664
  risk_level: RiskLevelSchema,
1398
- action: z2.looseObject({ kind: z2.string() }).passthrough(),
1399
- reason: z2.string(),
1665
+ action: z3.looseObject({ kind: z3.string() }).passthrough(),
1666
+ reason: z3.string(),
1400
1667
  expires_at: IsoTimestampSchema.nullable().default(null),
1401
1668
  // The four fields below are null while `status === "pending"` and set
1402
1669
  // once a resolver records a decision. Defaulting to null keeps the
1403
1670
  // pending YAML free of explicit nulls if a producer omits them.
1404
- resolver: z2.string().nullable().default(null),
1671
+ resolver: z3.string().nullable().default(null),
1405
1672
  resolved_at: IsoTimestampSchema.nullable().default(null),
1406
- note: z2.string().nullable().default(null),
1407
- rejection_reason: z2.string().nullable().default(null)
1673
+ note: z3.string().nullable().default(null),
1674
+ rejection_reason: z3.string().nullable().default(null)
1408
1675
  });
1409
1676
 
1410
1677
  // src/storage/yaml-store.ts
@@ -1536,221 +1803,12 @@ function isLazyExpired(approval, now) {
1536
1803
 
1537
1804
  // src/decisions/decisions-renderer.ts
1538
1805
  import { lstat as lstat2 } from "fs/promises";
1539
- import { dirname as dirname2, join as join6, resolve } from "path";
1806
+ import { dirname as dirname2, join as join7, resolve } from "path";
1540
1807
 
1541
1808
  // src/events/event-replay.ts
1542
1809
  import { createReadStream } from "fs";
1543
1810
  import { stat } from "fs/promises";
1544
1811
  import { join as join2 } from "path";
1545
-
1546
- // src/schemas/event.schema.ts
1547
- import { z as z3 } from "zod";
1548
- var BaseEventSchema = z3.object({
1549
- schema_version: SchemaVersionSchema,
1550
- id: EventIdSchema,
1551
- session_id: SessionIdSchema,
1552
- occurred_at: IsoTimestampSchema,
1553
- source: EventSourceSchema,
1554
- // Tamper-evidence back-pointer (hex sha-256 of the PREVIOUS event line's
1555
- // written bytes; the first line carries the session-bound genesis hash).
1556
- // Present only on sessions written with chaining enabled (import paths);
1557
- // live/ad-hoc sessions omit it. Declared on the base so the `.strict()`
1558
- // variants below treat it as a known key. Additive optional => no
1559
- // schema_version bump.
1560
- prev_hash: z3.string().optional()
1561
- });
1562
- var SessionStartedEventSchema = BaseEventSchema.extend({
1563
- type: z3.literal("session_started")
1564
- });
1565
- var SessionEndedEventSchema = BaseEventSchema.extend({
1566
- type: z3.literal("session_ended"),
1567
- exit_code: z3.number().int().optional()
1568
- });
1569
- var SessionStatusChangedEventSchema = BaseEventSchema.extend({
1570
- type: z3.literal("session_status_changed"),
1571
- from: z3.string(),
1572
- to: z3.string()
1573
- });
1574
- var ApprovalRequestedEventSchema = BaseEventSchema.extend({
1575
- type: z3.literal("approval_requested"),
1576
- approval_id: ApprovalIdSchema,
1577
- expires_at: IsoTimestampSchema.nullable().default(null),
1578
- risk_level: RiskLevelSchema,
1579
- // `action.kind` is required; additional fields are allowed to support
1580
- // future action shapes (shell_command, external_send, ...).
1581
- action: z3.object({ kind: z3.string() }).passthrough(),
1582
- reason: z3.string(),
1583
- status: z3.literal("pending")
1584
- });
1585
- var ApprovalApprovedEventSchema = BaseEventSchema.extend({
1586
- type: z3.literal("approval_approved"),
1587
- approval_id: ApprovalIdSchema,
1588
- resolver: z3.string().optional(),
1589
- note: z3.string().nullable().optional()
1590
- });
1591
- var ApprovalRejectedEventSchema = BaseEventSchema.extend({
1592
- type: z3.literal("approval_rejected"),
1593
- approval_id: ApprovalIdSchema,
1594
- resolver: z3.string().optional(),
1595
- reason: z3.string()
1596
- });
1597
- var ApprovalExpiredEventSchema = BaseEventSchema.extend({
1598
- type: z3.literal("approval_expired"),
1599
- approval_id: ApprovalIdSchema
1600
- });
1601
- var CommandExecutedEventSchema = BaseEventSchema.extend({
1602
- type: z3.literal("command_executed"),
1603
- command: z3.string().nullable(),
1604
- args: z3.array(z3.string()),
1605
- cwd: z3.string().nullable(),
1606
- exit_code: z3.number().int().nullable(),
1607
- signal: z3.string().nullable().optional(),
1608
- received_signal: z3.string().nullable().optional(),
1609
- duration_ms: z3.number().int().nonnegative()
1610
- });
1611
- var GitSnapshotEventSchema = BaseEventSchema.extend({
1612
- type: z3.literal("git_snapshot"),
1613
- head: z3.string(),
1614
- branch: z3.string(),
1615
- dirty: z3.boolean(),
1616
- staged: z3.array(z3.string()),
1617
- unstaged: z3.array(z3.string()),
1618
- untracked: z3.array(z3.string()),
1619
- ahead: z3.number().int().nonnegative().optional(),
1620
- behind: z3.number().int().nonnegative().optional()
1621
- });
1622
- var FileChangedEventSchema = BaseEventSchema.extend({
1623
- type: z3.literal("file_changed"),
1624
- path: z3.string(),
1625
- change_type: z3.enum(["added", "modified", "deleted", "renamed"]),
1626
- // Renamed entries record the previous path here. Optional + nullable to
1627
- // keep the wire format stable for added / modified / deleted events.
1628
- old_path: z3.string().nullable().optional()
1629
- });
1630
- var DecisionRecordedEventSchema = BaseEventSchema.extend({
1631
- type: z3.literal("decision_recorded"),
1632
- decision_id: DecisionIdSchema,
1633
- title: z3.string(),
1634
- rationale: z3.string().nullable().optional(),
1635
- alternatives: z3.array(z3.string().min(1)).optional(),
1636
- rejected_reason: z3.string().nullable().optional(),
1637
- linked_events: z3.array(EventIdSchema).optional(),
1638
- linked_files: z3.array(z3.string().min(1).max(4096)).optional(),
1639
- // `track` promotes a decision to a strategic, unfinished DIRECTION ("the next
1640
- // essential thing to build, and why") that orientation/handoff resurface every
1641
- // time until it is explicitly closed with `decision void` / supersede — as
1642
- // opposed to a point-in-time `decision`, which is only ever surfaced as the
1643
- // single latest one. This is the intent-continuity layer: a direction agreed
1644
- // in conversation otherwise sinks into the flat decision list and never carries
1645
- // to the next session. Absent (the default) is a plain `decision`, so all
1646
- // pre-existing decision_recorded events round-trip unchanged (additive optional
1647
- // => no schema_version bump; mirrors `note_added.kind`).
1648
- kind: z3.enum(["decision", "track"]).optional()
1649
- });
1650
- var DecisionVoidedEventSchema = BaseEventSchema.extend({
1651
- type: z3.literal("decision_voided"),
1652
- decision_id: DecisionIdSchema,
1653
- reason: z3.string().nullable().optional(),
1654
- superseded_by: DecisionIdSchema.optional()
1655
- });
1656
- var TaskCreatedEventSchema = BaseEventSchema.extend({
1657
- type: z3.literal("task_created"),
1658
- task_id: TaskIdSchema,
1659
- title: z3.string()
1660
- });
1661
- var TaskStatusChangedEventSchema = BaseEventSchema.extend({
1662
- type: z3.literal("task_status_changed"),
1663
- task_id: TaskIdSchema,
1664
- from: z3.string(),
1665
- to: z3.string()
1666
- });
1667
- var TaskReconciledEventSchema = BaseEventSchema.extend({
1668
- type: z3.literal("task_reconciled"),
1669
- task_id: TaskIdSchema,
1670
- removed_created_in_session: SessionIdSchema.nullable().default(null),
1671
- created_in_session_replacement: SessionIdSchema.nullable().default(null),
1672
- removed_linked_sessions: z3.array(SessionIdSchema).default([])
1673
- }).strict();
1674
- var TaskLinkageRefreshedEventSchema = BaseEventSchema.extend({
1675
- type: z3.literal("task_linkage_refreshed"),
1676
- task_id: TaskIdSchema,
1677
- added_linked_sessions: z3.array(SessionIdSchema).default([]),
1678
- removed_linked_sessions: z3.array(SessionIdSchema).default([]),
1679
- final_count: z3.number().int().nonnegative().optional()
1680
- }).strict();
1681
- var TaskDeletedEventSchema = BaseEventSchema.extend({
1682
- type: z3.literal("task_deleted"),
1683
- task_id: TaskIdSchema,
1684
- title: z3.string().min(1)
1685
- }).strict();
1686
- var TaskArchivedEventSchema = BaseEventSchema.extend({
1687
- type: z3.literal("task_archived"),
1688
- task_id: TaskIdSchema,
1689
- title: z3.string().min(1)
1690
- }).strict();
1691
- var NoteAddedEventSchema = BaseEventSchema.extend({
1692
- type: z3.literal("note_added"),
1693
- body: z3.string(),
1694
- // `next_step` marks a note authored by `basou note` as the operator's resume
1695
- // hint, which orientation surfaces as the next starting point. Absent (the
1696
- // `basou session note` default) is a plain annotation orientation does not
1697
- // surface. Optional so pre-existing note_added events remain valid.
1698
- kind: z3.enum(["note", "next_step"]).optional()
1699
- });
1700
- var ReviewFindingSchema = z3.object({
1701
- title: z3.string().min(1),
1702
- severity: z3.enum(["high", "medium", "low"]).optional(),
1703
- location: z3.string().min(1).optional(),
1704
- summary: z3.string().min(1).optional()
1705
- });
1706
- var ReviewBlockedSchema = z3.object({
1707
- title: z3.string().min(1),
1708
- reason: z3.enum(["spec-deviation", "design-reversal"]),
1709
- why: z3.string().min(1).optional()
1710
- });
1711
- var ReviewRecordedEventSchema = BaseEventSchema.extend({
1712
- type: z3.literal("review_recorded"),
1713
- reviewer: z3.string().min(1),
1714
- target: z3.string().min(1),
1715
- repos: z3.array(z3.string().min(1)).optional(),
1716
- repos_resolved: z3.array(z3.string().min(1)).optional(),
1717
- commits: z3.array(z3.string().min(1)).optional(),
1718
- verdict: z3.enum(["pass", "needs-attention", "fail"]).optional(),
1719
- findings: z3.array(ReviewFindingSchema).optional(),
1720
- blocked: z3.array(ReviewBlockedSchema).optional()
1721
- });
1722
- var AdapterOutputEventSchema = BaseEventSchema.extend({
1723
- type: z3.literal("adapter_output"),
1724
- stream: z3.enum(["stdout", "stderr"]),
1725
- summary: z3.string(),
1726
- raw_ref: z3.string(),
1727
- redacted: z3.boolean().optional()
1728
- }).strict();
1729
- var EventSchema = z3.discriminatedUnion("type", [
1730
- SessionStartedEventSchema,
1731
- SessionEndedEventSchema,
1732
- SessionStatusChangedEventSchema,
1733
- ApprovalRequestedEventSchema,
1734
- ApprovalApprovedEventSchema,
1735
- ApprovalRejectedEventSchema,
1736
- ApprovalExpiredEventSchema,
1737
- CommandExecutedEventSchema,
1738
- GitSnapshotEventSchema,
1739
- FileChangedEventSchema,
1740
- DecisionRecordedEventSchema,
1741
- DecisionVoidedEventSchema,
1742
- TaskCreatedEventSchema,
1743
- TaskStatusChangedEventSchema,
1744
- TaskReconciledEventSchema,
1745
- TaskLinkageRefreshedEventSchema,
1746
- TaskDeletedEventSchema,
1747
- TaskArchivedEventSchema,
1748
- NoteAddedEventSchema,
1749
- ReviewRecordedEventSchema,
1750
- AdapterOutputEventSchema
1751
- ]);
1752
-
1753
- // src/events/event-replay.ts
1754
1812
  async function* replayEvents(sessionDir, options = {}) {
1755
1813
  const filePath = join2(sessionDir, "events.jsonl");
1756
1814
  try {
@@ -1815,6 +1873,9 @@ function processLine(rawLine, lineNo, options) {
1815
1873
  options.onWarning?.({ kind: "schema_violation", line: lineNo, cause: result.error });
1816
1874
  return null;
1817
1875
  }
1876
+ if (hasRetiredZeroDuration(result.data)) {
1877
+ options.onWarning?.({ kind: "retired_zero_duration", line: lineNo });
1878
+ }
1818
1879
  return result.data;
1819
1880
  }
1820
1881
  async function readAllEvents(sessionDir, options = {}) {
@@ -2089,6 +2150,7 @@ var EN = {
2089
2150
  headingForward: "## Where you are heading",
2090
2151
  headingCurrency: "## Is this current",
2091
2152
  inFlightTasksHeading: (n) => `### In-flight tasks (${n})`,
2153
+ noTasksRecorded: "(no tasks recorded)",
2092
2154
  pendingApprovalsHeading: (n) => `### Pending approvals (${n})`,
2093
2155
  suspectSessionsHeading: (n) => `### Suspect sessions (${n})`,
2094
2156
  openTracksHeading: (n) => `### Open tracks (shown until closed) (${n})`,
@@ -2150,6 +2212,8 @@ var EN = {
2150
2212
  headingNextWork: "## Work to do next",
2151
2213
  headingSessions: "## Sessions",
2152
2214
  lastTaskLabel: "Last task",
2215
+ noPendingTasks: "(no pending tasks)",
2216
+ noTasksRecorded: "(no tasks recorded)",
2153
2217
  decisionStaleNote: "Note: the latest activity postdates this decision. It may already be resolved in conversation \u2014 confirm the continuation point before resuming (conversational decisions are not captured automatically; record them with `basou decision capture`).",
2154
2218
  trackCloseInstruction: "When finished, close it with `basou decision void <decision_id>`."
2155
2219
  },
@@ -2185,6 +2249,7 @@ var JA = {
2185
2249
  headingForward: "## \u3069\u3053\u3078\u5411\u304B\u3046",
2186
2250
  headingCurrency: "## \u3053\u308C\u306F\u6700\u65B0\u304B",
2187
2251
  inFlightTasksHeading: (n) => `### \u9032\u884C\u4E2D task (${n})`,
2252
+ noTasksRecorded: "(task \u304C 1 \u4EF6\u3082\u8A18\u9332\u3055\u308C\u3066\u3044\u307E\u305B\u3093)",
2188
2253
  pendingApprovalsHeading: (n) => `### \u627F\u8A8D\u5F85\u3061 (${n})`,
2189
2254
  suspectSessionsHeading: (n) => `### \u8981\u6CE8\u610F session (${n})`,
2190
2255
  openTracksHeading: (n) => `### \u672A\u5B8C\u30C8\u30E9\u30C3\u30AF (close \u307E\u3067\u7D99\u7D9A\u8868\u793A) (${n})`,
@@ -2246,6 +2311,8 @@ var JA = {
2246
2311
  headingNextWork: "## \u6B21\u306B\u5B9F\u884C\u3059\u3079\u304D\u4F5C\u696D",
2247
2312
  headingSessions: "## \u30BB\u30C3\u30B7\u30E7\u30F3\u4E00\u89A7",
2248
2313
  lastTaskLabel: "\u6700\u7D42 task",
2314
+ noPendingTasks: "(\u672A\u5B8C\u4E86\u306E task \u306F\u3042\u308A\u307E\u305B\u3093)",
2315
+ noTasksRecorded: "(task \u304C 1 \u4EF6\u3082\u8A18\u9332\u3055\u308C\u3066\u3044\u307E\u305B\u3093)",
2249
2316
  decisionStaleNote: "\u6CE8: \u6700\u7D42\u6D3B\u52D5\u306F\u3053\u306E\u5224\u65AD\u3088\u308A\u5F8C\u3067\u3059\u3002\u4F1A\u8A71\u3067\u65E2\u306B\u89E3\u6C7A\u6E08\u307F\u306E\u53EF\u80FD\u6027\u304C\u3042\u308B\u305F\u3081\u3001\u518D\u958B\u524D\u306B\u7D99\u7D9A\u70B9\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044(\u4F1A\u8A71\u3067\u306E\u610F\u601D\u6C7A\u5B9A\u306F\u81EA\u52D5\u8A18\u9332\u3055\u308C\u307E\u305B\u3093\u3002`basou decision capture` \u3067\u8A18\u9332\u3067\u304D\u307E\u3059)\u3002",
2250
2317
  trackCloseInstruction: "\u5B8C\u4E86\u3057\u305F\u3089 `basou decision void <decision_id>` \u3067\u9589\u3058\u3066\u304F\u3060\u3055\u3044\u3002"
2251
2318
  },
@@ -2455,11 +2522,11 @@ var PRESET_JA = {
2455
2522
 
2456
2523
  // src/storage/sessions.ts
2457
2524
  import { readdir as readdir2 } from "fs/promises";
2458
- import { join as join5 } from "path";
2525
+ import { join as join6 } from "path";
2459
2526
 
2460
2527
  // src/events/chained-append.ts
2461
- import { appendFile, readFile as readFile3 } from "fs/promises";
2462
- import { join as join4 } from "path";
2528
+ import { appendFile as appendFile2, readFile as readFile3 } from "fs/promises";
2529
+ import { join as join5 } from "path";
2463
2530
 
2464
2531
  // src/storage/lockfile.ts
2465
2532
  import { mkdir, readFile as readFile2, unlink as unlink2 } from "fs/promises";
@@ -2581,6 +2648,61 @@ function chainRawJsonLines(rawLines, sessionId) {
2581
2648
  return { lines, headHash: prev, count: lines.length };
2582
2649
  }
2583
2650
 
2651
+ // src/events/event-writer.ts
2652
+ import { appendFile } from "fs/promises";
2653
+ import { basename, join as join4 } from "path";
2654
+ async function appendEvent(sessionDir, event) {
2655
+ let validated;
2656
+ try {
2657
+ validated = EventSchema.parse(event);
2658
+ } catch (error) {
2659
+ throw new Error("Invalid Basou event payload", { cause: error });
2660
+ }
2661
+ assertWritableEvent(validated);
2662
+ const line = `${serializeEventLine(validated)}
2663
+ `;
2664
+ try {
2665
+ await appendFile(join4(sessionDir, "events.jsonl"), line, "utf8");
2666
+ } catch (error) {
2667
+ throw new Error("Failed to append event to events.jsonl", { cause: error });
2668
+ }
2669
+ }
2670
+ async function writeEventsBulk(sessionDir, events, options = {}) {
2671
+ const validated = [];
2672
+ try {
2673
+ for (const event of events) {
2674
+ validated.push(EventSchema.parse(event));
2675
+ }
2676
+ } catch (error) {
2677
+ throw new Error("Invalid Basou event payload", { cause: error });
2678
+ }
2679
+ for (const event of validated) assertWritableEvent(event);
2680
+ const filePath = join4(sessionDir, "events.jsonl");
2681
+ let body;
2682
+ let result = null;
2683
+ if (options.chain === true) {
2684
+ const { lines, headHash, count } = chainEvents(validated, basename(sessionDir));
2685
+ body = lines.length > 0 ? `${lines.join("\n")}
2686
+ ` : "";
2687
+ result = count > 0 ? { headHash, count } : null;
2688
+ } else {
2689
+ body = validated.length > 0 ? `${validated.map(serializeEventLine).join("\n")}
2690
+ ` : "";
2691
+ }
2692
+ try {
2693
+ await atomicReplace(filePath, body);
2694
+ } catch (error) {
2695
+ throw new Error("Failed to write events.jsonl", { cause: error });
2696
+ }
2697
+ return result;
2698
+ }
2699
+ function assertWritableEvent(event) {
2700
+ if (!hasRetiredZeroDuration(event)) return;
2701
+ throw new Error(
2702
+ `Refusing to write command_executed with duration_ms: 0 at schema_version ${event.schema_version}: 0 is not a duration a command can have had, and writers at ${ZERO_DURATION_RETIRED_SINCE} and above record null`
2703
+ );
2704
+ }
2705
+
2584
2706
  // src/events/chained-append.ts
2585
2707
  function splitLinesBytes(buf) {
2586
2708
  const out = [];
@@ -2603,7 +2725,7 @@ function carriesPrevHash(line) {
2603
2725
  }
2604
2726
  }
2605
2727
  async function inspectChainTail(paths, sessionId) {
2606
- const filePath = join4(paths.sessions, sessionId, "events.jsonl");
2728
+ const filePath = join5(paths.sessions, sessionId, "events.jsonl");
2607
2729
  let raw;
2608
2730
  try {
2609
2731
  raw = await readFile3(filePath);
@@ -2639,10 +2761,11 @@ async function appendChainedEventLocked(paths, sessionId, event) {
2639
2761
  } catch (error) {
2640
2762
  throw new Error("Invalid Basou event payload", { cause: error });
2641
2763
  }
2764
+ assertWritableEvent(validated);
2642
2765
  const tail = await inspectChainTail(paths, sessionId);
2643
2766
  const line = tail.chained ? serializeEventLine({ ...validated, prev_hash: tail.head }) : serializeEventLine(validated);
2644
2767
  try {
2645
- await appendFile(join4(paths.sessions, sessionId, "events.jsonl"), `${line}
2768
+ await appendFile2(join5(paths.sessions, sessionId, "events.jsonl"), `${line}
2646
2769
  `, "utf8");
2647
2770
  } catch (error) {
2648
2771
  throw new Error("Failed to append event to events.jsonl", { cause: error });
@@ -2754,7 +2877,7 @@ async function enumerateSessionDirs(paths) {
2754
2877
  }
2755
2878
  }
2756
2879
  async function readSessionYaml(paths, sessionId) {
2757
- const filePath = join5(paths.sessions, sessionId, "session.yaml");
2880
+ const filePath = join6(paths.sessions, sessionId, "session.yaml");
2758
2881
  let raw;
2759
2882
  try {
2760
2883
  raw = await readYamlFile(filePath);
@@ -2778,7 +2901,7 @@ async function finalizeSessionYaml(paths, sessionId, mutate) {
2778
2901
  session.session.integrity = { head_hash: tail.head, event_count: tail.count };
2779
2902
  }
2780
2903
  const validated = SessionSchema.parse(session);
2781
- await overwriteYamlFile(join5(paths.sessions, sessionId, "session.yaml"), validated);
2904
+ await overwriteYamlFile(join6(paths.sessions, sessionId, "session.yaml"), validated);
2782
2905
  } finally {
2783
2906
  await lock.release();
2784
2907
  }
@@ -2787,7 +2910,7 @@ async function classifySuspect(paths, sessionId, session, now, onWarning) {
2787
2910
  if (session.session.status !== "running") {
2788
2911
  return { suspect: false, suspectReason: null };
2789
2912
  }
2790
- const sessionDir = join5(paths.sessions, sessionId);
2913
+ const sessionDir = join6(paths.sessions, sessionId);
2791
2914
  let endedFound = false;
2792
2915
  let lastEventOccurredAt = null;
2793
2916
  const replayOpts = onWarning !== void 0 ? { onWarning } : {};
@@ -2897,7 +3020,7 @@ async function renderDecisions(input) {
2897
3020
  const voids = /* @__PURE__ */ new Map();
2898
3021
  const knownEventIds = /* @__PURE__ */ new Set();
2899
3022
  for (const entry of entries) {
2900
- const sessionDir = join6(input.paths.sessions, entry.sessionId);
3023
+ const sessionDir = join7(input.paths.sessions, entry.sessionId);
2901
3024
  try {
2902
3025
  for await (const ev of replayEvents(sessionDir, {
2903
3026
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
@@ -3024,53 +3147,6 @@ function shortDecisionSessionId(sessionId) {
3024
3147
  return sessionId.slice(0, 10);
3025
3148
  }
3026
3149
 
3027
- // src/events/event-writer.ts
3028
- import { appendFile as appendFile2 } from "fs/promises";
3029
- import { basename, join as join7 } from "path";
3030
- async function appendEvent(sessionDir, event) {
3031
- let validated;
3032
- try {
3033
- validated = EventSchema.parse(event);
3034
- } catch (error) {
3035
- throw new Error("Invalid Basou event payload", { cause: error });
3036
- }
3037
- const line = `${serializeEventLine(validated)}
3038
- `;
3039
- try {
3040
- await appendFile2(join7(sessionDir, "events.jsonl"), line, "utf8");
3041
- } catch (error) {
3042
- throw new Error("Failed to append event to events.jsonl", { cause: error });
3043
- }
3044
- }
3045
- async function writeEventsBulk(sessionDir, events, options = {}) {
3046
- const validated = [];
3047
- try {
3048
- for (const event of events) {
3049
- validated.push(EventSchema.parse(event));
3050
- }
3051
- } catch (error) {
3052
- throw new Error("Invalid Basou event payload", { cause: error });
3053
- }
3054
- const filePath = join7(sessionDir, "events.jsonl");
3055
- let body;
3056
- let result = null;
3057
- if (options.chain === true) {
3058
- const { lines, headHash, count } = chainEvents(validated, basename(sessionDir));
3059
- body = lines.length > 0 ? `${lines.join("\n")}
3060
- ` : "";
3061
- result = count > 0 ? { headHash, count } : null;
3062
- } else {
3063
- body = validated.length > 0 ? `${validated.map(serializeEventLine).join("\n")}
3064
- ` : "";
3065
- }
3066
- try {
3067
- await atomicReplace(filePath, body);
3068
- } catch (error) {
3069
- throw new Error("Failed to write events.jsonl", { cause: error });
3070
- }
3071
- return result;
3072
- }
3073
-
3074
3150
  // src/events/verify.ts
3075
3151
  import { readFile as readFile4 } from "fs/promises";
3076
3152
  import { join as join8 } from "path";
@@ -3777,7 +3853,7 @@ async function createAdHocSessionWithEvent(input) {
3777
3853
  });
3778
3854
  const events = [
3779
3855
  {
3780
- schema_version: "0.1.0",
3856
+ schema_version: EVENT_SCHEMA_VERSION,
3781
3857
  id: startedEventId,
3782
3858
  session_id: sessionId,
3783
3859
  occurred_at: input.occurredAt,
@@ -3785,7 +3861,7 @@ async function createAdHocSessionWithEvent(input) {
3785
3861
  type: "session_started"
3786
3862
  },
3787
3863
  {
3788
- schema_version: "0.1.0",
3864
+ schema_version: EVENT_SCHEMA_VERSION,
3789
3865
  id: statusToRunningEventId,
3790
3866
  session_id: sessionId,
3791
3867
  occurred_at: input.occurredAt,
@@ -3796,7 +3872,7 @@ async function createAdHocSessionWithEvent(input) {
3796
3872
  },
3797
3873
  ...targetEvents,
3798
3874
  {
3799
- schema_version: "0.1.0",
3875
+ schema_version: EVENT_SCHEMA_VERSION,
3800
3876
  id: statusToCompletedEventId,
3801
3877
  session_id: sessionId,
3802
3878
  occurred_at: input.occurredAt,
@@ -3806,7 +3882,7 @@ async function createAdHocSessionWithEvent(input) {
3806
3882
  to: "completed"
3807
3883
  },
3808
3884
  {
3809
- schema_version: "0.1.0",
3885
+ schema_version: EVENT_SCHEMA_VERSION,
3810
3886
  id: endedEventId,
3811
3887
  session_id: sessionId,
3812
3888
  occurred_at: input.occurredAt,
@@ -4268,7 +4344,7 @@ var TaskWriteAfterEventError = class extends Error {
4268
4344
  };
4269
4345
  function buildTaskCreatedEvent(input) {
4270
4346
  return {
4271
- schema_version: "0.1.0",
4347
+ schema_version: EVENT_SCHEMA_VERSION,
4272
4348
  id: input.eventId,
4273
4349
  session_id: input.sessionId,
4274
4350
  occurred_at: input.occurredAt,
@@ -4280,7 +4356,7 @@ function buildTaskCreatedEvent(input) {
4280
4356
  }
4281
4357
  function buildTaskStatusChangedEvent(input) {
4282
4358
  return {
4283
- schema_version: "0.1.0",
4359
+ schema_version: EVENT_SCHEMA_VERSION,
4284
4360
  id: input.eventId,
4285
4361
  session_id: input.sessionId,
4286
4362
  occurred_at: input.occurredAt,
@@ -4313,7 +4389,7 @@ function buildAdHocArchiveLabel(title) {
4313
4389
  }
4314
4390
  function buildTaskReconciledEvent(input) {
4315
4391
  return {
4316
- schema_version: "0.1.0",
4392
+ schema_version: EVENT_SCHEMA_VERSION,
4317
4393
  id: input.eventId,
4318
4394
  session_id: input.sessionId,
4319
4395
  occurred_at: input.occurredAt,
@@ -4327,7 +4403,7 @@ function buildTaskReconciledEvent(input) {
4327
4403
  }
4328
4404
  function buildTaskDeletedEvent(input) {
4329
4405
  return {
4330
- schema_version: "0.1.0",
4406
+ schema_version: EVENT_SCHEMA_VERSION,
4331
4407
  id: input.eventId,
4332
4408
  session_id: input.sessionId,
4333
4409
  occurred_at: input.occurredAt,
@@ -4339,7 +4415,7 @@ function buildTaskDeletedEvent(input) {
4339
4415
  }
4340
4416
  function buildTaskArchivedEvent(input) {
4341
4417
  return {
4342
- schema_version: "0.1.0",
4418
+ schema_version: EVENT_SCHEMA_VERSION,
4343
4419
  id: input.eventId,
4344
4420
  session_id: input.sessionId,
4345
4421
  occurred_at: input.occurredAt,
@@ -4351,7 +4427,7 @@ function buildTaskArchivedEvent(input) {
4351
4427
  }
4352
4428
  function buildTaskLinkageRefreshedEvent(input) {
4353
4429
  return {
4354
- schema_version: "0.1.0",
4430
+ schema_version: EVENT_SCHEMA_VERSION,
4355
4431
  id: input.eventId,
4356
4432
  session_id: input.sessionId,
4357
4433
  occurred_at: input.occurredAt,
@@ -5325,6 +5401,14 @@ async function archiveTaskLocked(input) {
5325
5401
  eventId
5326
5402
  };
5327
5403
  }
5404
+ async function anyTaskEverRecorded(input) {
5405
+ if (input.liveCount > 0 || input.taskCreatedSeen || input.skippedCount > 0) return true;
5406
+ try {
5407
+ return (await enumerateArchivedTaskIds(input.paths)).length > 0;
5408
+ } catch {
5409
+ return true;
5410
+ }
5411
+ }
5328
5412
 
5329
5413
  // src/handoff/handoff-renderer.ts
5330
5414
  async function renderHandoff(input) {
@@ -5421,8 +5505,13 @@ async function renderHandoff(input) {
5421
5505
  const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
5422
5506
  return c !== 0 ? c : a.taskId.localeCompare(b.taskId);
5423
5507
  });
5424
- const taskLoadOpts = {};
5425
- if (input.onTaskSkip !== void 0) taskLoadOpts.onSkip = input.onTaskSkip;
5508
+ let skippedTaskCount = 0;
5509
+ const taskLoadOpts = {
5510
+ onSkip: (taskId, reason) => {
5511
+ skippedTaskCount += 1;
5512
+ input.onTaskSkip?.(taskId, reason);
5513
+ }
5514
+ };
5426
5515
  const taskEntries = await loadTaskEntries(input.paths, taskLoadOpts);
5427
5516
  const taskById = /* @__PURE__ */ new Map();
5428
5517
  for (const t of taskEntries) taskById.set(t.task.task.id, t);
@@ -5470,7 +5559,13 @@ async function renderHandoff(input) {
5470
5559
  latestActivityRecord,
5471
5560
  latestTaskDoc,
5472
5561
  pendingTasks,
5473
- totalTaskCount: taskEntries.length
5562
+ totalTaskCount: taskEntries.length,
5563
+ anyTaskEverRecorded: await anyTaskEverRecorded({
5564
+ paths: input.paths,
5565
+ liveCount: taskEntries.length,
5566
+ taskCreatedSeen: tasksCreated.length > 0,
5567
+ skippedCount: skippedTaskCount
5568
+ })
5474
5569
  });
5475
5570
  return {
5476
5571
  body,
@@ -5581,7 +5676,7 @@ function formatHandoffBody(args) {
5581
5676
  lines.push(t.handoff.headingNextWork);
5582
5677
  lines.push("");
5583
5678
  if (args.pendingTasks.length === 0) {
5584
- lines.push("(no pending tasks)");
5679
+ lines.push(args.anyTaskEverRecorded ? t.handoff.noPendingTasks : t.handoff.noTasksRecorded);
5585
5680
  } else {
5586
5681
  for (const t2 of args.pendingTasks) {
5587
5682
  lines.push(
@@ -5870,6 +5965,7 @@ async function summarizeOrientation(input) {
5870
5965
  else if (kind === "note" && typeof value === "string") bucket.notes.push(value);
5871
5966
  };
5872
5967
  let latestActivityAt = null;
5968
+ let taskCreatedSeen = false;
5873
5969
  let latestNote = null;
5874
5970
  const noteActivity = (iso) => {
5875
5971
  if (latestActivityAt === null || Date.parse(iso) > Date.parse(latestActivityAt)) {
@@ -5908,6 +6004,8 @@ async function summarizeOrientation(input) {
5908
6004
  host: entry.host
5909
6005
  });
5910
6006
  }
6007
+ } else if (ev.type === "task_created") {
6008
+ taskCreatedSeen = true;
5911
6009
  } else if (ev.type === "decision_voided") {
5912
6010
  voidedDecisionIds.add(ev.decision_id);
5913
6011
  }
@@ -5973,8 +6071,13 @@ async function summarizeOrientation(input) {
5973
6071
  files
5974
6072
  };
5975
6073
  });
5976
- const taskLoadOpts = {};
5977
- if (input.onTaskSkip !== void 0) taskLoadOpts.onSkip = input.onTaskSkip;
6074
+ let skippedTaskCount = 0;
6075
+ const taskLoadOpts = {
6076
+ onSkip: (taskId, reason) => {
6077
+ skippedTaskCount += 1;
6078
+ input.onTaskSkip?.(taskId, reason);
6079
+ }
6080
+ };
5978
6081
  const taskEntries = await loadTaskEntries(input.paths, taskLoadOpts);
5979
6082
  const inFlightTasks = taskEntries.filter((t) => t.task.task.status === "in_progress" || t.task.task.status === "planned").map((t) => ({
5980
6083
  id: t.task.task.id,
@@ -6068,6 +6171,12 @@ async function summarizeOrientation(input) {
6068
6171
  recentDirection,
6069
6172
  relatedFiles: { displayed, overflow, outOfRoot, omitted: omittedFiles },
6070
6173
  inFlightTasks,
6174
+ anyTaskEverRecorded: await anyTaskEverRecorded({
6175
+ paths: input.paths,
6176
+ liveCount: taskEntries.length,
6177
+ taskCreatedSeen,
6178
+ skippedCount: skippedTaskCount
6179
+ }),
6071
6180
  plannedTasks,
6072
6181
  pendingApprovals,
6073
6182
  suspects,
@@ -6204,7 +6313,7 @@ function formatOrientationBody(summary, opts) {
6204
6313
  lines.push("");
6205
6314
  lines.push(t.orientation.inFlightTasksHeading(summary.inFlightTasks.length));
6206
6315
  if (summary.inFlightTasks.length === 0) {
6207
- lines.push("- (none)");
6316
+ lines.push(summary.anyTaskEverRecorded ? "- (none)" : `- ${t.orientation.noTasksRecorded}`);
6208
6317
  } else {
6209
6318
  for (const t2 of summary.inFlightTasks) {
6210
6319
  const linkedSuffix = t2.linkedSessions > 1 ? ` \u2014 linked_sessions: ${t2.linkedSessions}` : "";
@@ -7169,9 +7278,13 @@ async function computeWorkStats(input) {
7169
7278
  for (const entry of entries) {
7170
7279
  const events = [];
7171
7280
  let eventsUnreadable = false;
7281
+ let eventsLostLines = 0;
7172
7282
  try {
7173
7283
  for await (const ev of replayEvents(join16(input.paths.sessions, entry.sessionId), {
7174
- onWarning: (w) => input.onWarning?.(w, entry.sessionId)
7284
+ onWarning: (w) => {
7285
+ if (w.kind === "malformed_json" || w.kind === "schema_violation") eventsLostLines++;
7286
+ input.onWarning?.(w, entry.sessionId);
7287
+ }
7175
7288
  })) {
7176
7289
  events.push(ev);
7177
7290
  }
@@ -7187,7 +7300,8 @@ async function computeWorkStats(input) {
7187
7300
  entry.session.session,
7188
7301
  events,
7189
7302
  now,
7190
- eventsUnreadable
7303
+ eventsUnreadable,
7304
+ eventsLostLines
7191
7305
  )
7192
7306
  );
7193
7307
  }
@@ -7205,8 +7319,9 @@ async function computeWorkStats(input) {
7205
7319
  byDay: computeByDay(sessions, union.merged, timeZone)
7206
7320
  };
7207
7321
  }
7208
- function sessionWorkStatsFromEvents(sessionId, inner, events, now, eventsUnreadable = false) {
7322
+ function sessionWorkStatsFromEvents(sessionId, inner, events, now, eventsUnreadable = false, eventsLostLines = 0) {
7209
7323
  let commandCount = 0;
7324
+ let timedCommandCount = 0;
7210
7325
  let fileChangedCount = 0;
7211
7326
  let decisionCount = 0;
7212
7327
  let commandTimeMs = 0;
@@ -7216,7 +7331,11 @@ function sessionWorkStatsFromEvents(sessionId, inner, events, now, eventsUnreada
7216
7331
  if (Number.isFinite(t)) timestamps.push(t);
7217
7332
  if (ev.type === "command_executed") {
7218
7333
  commandCount++;
7219
- commandTimeMs += ev.duration_ms;
7334
+ const observed = readObservedDuration(ev);
7335
+ if (observed !== null) {
7336
+ timedCommandCount++;
7337
+ commandTimeMs += observed;
7338
+ }
7220
7339
  } else if (ev.type === "file_changed") {
7221
7340
  fileChangedCount++;
7222
7341
  } else if (ev.type === "decision_recorded") {
@@ -7249,7 +7368,26 @@ function sessionWorkStatsFromEvents(sessionId, inner, events, now, eventsUnreada
7249
7368
  tokens,
7250
7369
  availability: {
7251
7370
  span: true,
7252
- commandTime: inner.source.kind !== "claude-code-import",
7371
+ // Derived from what this session actually recorded, like its three
7372
+ // siblings below. A source kind cannot answer this: the share of codex
7373
+ // commands carrying an observed duration went from 1.5% (2026-05) to
7374
+ // 56.0% (2026-08) as the vendor's log format changed, so the same kind
7375
+ // is sometimes timed and sometimes not.
7376
+ //
7377
+ // Not `commandTimeMs > 0`: a sum cannot tell "no command was timed" from
7378
+ // "no command ran", and the second case is the common one — 466 of one
7379
+ // store's 862 sessions run no command at all (`basou note`,
7380
+ // `decision capture`), and calling those unmeasured would poison the
7381
+ // AND-aggregated workspace total forever.
7382
+ //
7383
+ // The two disjuncts need different backing. An observed duration is a
7384
+ // fact about a command basou did see, and a line lost elsewhere in the
7385
+ // stream does not take it away. "Ran no commands", by contrast, is a
7386
+ // claim about the WHOLE stream, so it holds only if the whole stream was
7387
+ // read: an unreadable events.jsonl, or one whose lines were dropped as
7388
+ // malformed / schema-invalid, saw no commands because the log was lost,
7389
+ // and 0ms then measures nothing.
7390
+ commandTime: timedCommandCount > 0 || commandCount === 0 && !eventsUnreadable && eventsLostLines === 0,
7253
7391
  activeTime: active.intervals.length > 0,
7254
7392
  tokens: hasTokens(tokens),
7255
7393
  machineActive: machineActiveTimeMs > 0
@@ -8651,7 +8789,7 @@ function requireNonEmptyString(value, field) {
8651
8789
  function buildReviewRecordedEvent(input) {
8652
8790
  const { review } = input;
8653
8791
  return {
8654
- schema_version: "0.1.0",
8792
+ schema_version: EVENT_SCHEMA_VERSION,
8655
8793
  id: input.eventId,
8656
8794
  session_id: input.sessionId,
8657
8795
  occurred_at: input.occurredAt,
@@ -8677,6 +8815,7 @@ function truncate(value) {
8677
8815
 
8678
8816
  // src/runtime/child-process-runner.ts
8679
8817
  import { spawn as spawn2 } from "child_process";
8818
+ import { performance } from "perf_hooks";
8680
8819
  var DEFAULT_KILL_GRACE_MS = 5e3;
8681
8820
  var ChildProcessRunner = class {
8682
8821
  async run(command, args, options) {
@@ -8691,6 +8830,7 @@ var ChildProcessRunner = class {
8691
8830
  const snapshotCwd = options.cwd;
8692
8831
  const captureMode = options.capture ?? "buffer";
8693
8832
  const started_at = /* @__PURE__ */ new Date();
8833
+ const startedHrMs = performance.now();
8694
8834
  let child;
8695
8835
  try {
8696
8836
  child = spawn2(snapshotCommand, [...snapshotArgs], {
@@ -8777,7 +8917,7 @@ var ChildProcessRunner = class {
8777
8917
  stderr,
8778
8918
  started_at: started_at.toISOString(),
8779
8919
  ended_at: ended_at.toISOString(),
8780
- duration_ms: ended_at.getTime() - started_at.getTime(),
8920
+ duration_ms: Math.round(performance.now() - startedHrMs),
8781
8921
  pid: child.pid ?? null
8782
8922
  });
8783
8923
  });
@@ -8841,15 +8981,28 @@ var SessionInnerImportSchema = z10.object({
8841
8981
  // imported. Mirrors the accept-and-discard of `prev_hash` on events.
8842
8982
  integrity: SessionIntegritySchema.optional()
8843
8983
  }).strict();
8984
+ var SESSION_IMPORT_SCHEMA_VERSION = "0.1.0";
8844
8985
  var SessionImportPayloadSchema = z10.object({
8845
- schema_version: z10.string(),
8986
+ schema_version: z10.string().meta({
8987
+ const: SESSION_IMPORT_SCHEMA_VERSION,
8988
+ description: "Import envelope version. Must be exactly 0.1.0; any other value is rejected by the importer. This is the envelope's own version, not the version of the events it carries."
8989
+ }),
8846
8990
  session: SessionInnerImportSchema,
8847
8991
  events: z10.array(EventSchema)
8848
8992
  }).strict();
8849
8993
 
8850
8994
  // src/schemas/json-schema.ts
8851
- var JSON_SCHEMA_VERSION = "0.1.0";
8852
- var ID_BASE = `https://basou.dev/schemas/${JSON_SCHEMA_VERSION}`;
8995
+ var JSON_SCHEMA_VERSIONS = {
8996
+ manifest: "0.1.0",
8997
+ session: "0.1.0",
8998
+ event: EVENT_SCHEMA_VERSION,
8999
+ task: "0.1.0",
9000
+ approval: "0.1.0",
9001
+ status: "0.1.0",
9002
+ "task-index": "0.1.0",
9003
+ "session-import": SESSION_IMPORT_SCHEMA_VERSION
9004
+ };
9005
+ var ID_BASE = "https://basou.dev/schemas";
8853
9006
  var JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
8854
9007
  var DOCUMENTS = [
8855
9008
  {
@@ -8907,7 +9060,7 @@ function buildJsonSchemas() {
8907
9060
  const { $schema, ...rest } = generated;
8908
9061
  const schema = {
8909
9062
  $schema: typeof $schema === "string" ? $schema : JSON_SCHEMA_DIALECT,
8910
- $id: `${ID_BASE}/${doc.name}.schema.json`,
9063
+ $id: `${ID_BASE}/${JSON_SCHEMA_VERSIONS[doc.name]}/${doc.name}.schema.json`,
8911
9064
  title: doc.title,
8912
9065
  description: doc.description,
8913
9066
  ...rest
@@ -9600,6 +9753,7 @@ export {
9600
9753
  ChildProcessRunner,
9601
9754
  DEFAULT_STOP_HOOK_MIN_EDITS,
9602
9755
  DecisionIdSchema,
9756
+ EVENT_SCHEMA_VERSION,
9603
9757
  EventIdSchema,
9604
9758
  EventSchema,
9605
9759
  EventSourceSchema,
@@ -9608,7 +9762,7 @@ export {
9608
9762
  GENERATED_START,
9609
9763
  ID_PREFIXES,
9610
9764
  IsoTimestampSchema,
9611
- JSON_SCHEMA_VERSION,
9765
+ JSON_SCHEMA_VERSIONS,
9612
9766
  ManifestSchema,
9613
9767
  ORIENTATION_END,
9614
9768
  ORIENTATION_START,
@@ -9616,6 +9770,7 @@ export {
9616
9770
  PROTOCOL_START,
9617
9771
  REVIEW_RECORD_NO_INPUT_HINT,
9618
9772
  RiskLevelSchema,
9773
+ SESSION_IMPORT_SCHEMA_VERSION,
9619
9774
  SESSION_START_HOOK_CONTEXT_LIMIT,
9620
9775
  SESSION_START_HOOK_MATCHER,
9621
9776
  SESSION_START_HOOK_STATUS_MESSAGE,
@@ -9637,6 +9792,7 @@ export {
9637
9792
  TaskStatusSchema,
9638
9793
  TaskWriteAfterEventError,
9639
9794
  WorkspaceIdSchema,
9795
+ ZERO_DURATION_RETIRED_SINCE,
9640
9796
  acquireLock,
9641
9797
  appendBasouGitignore,
9642
9798
  appendChainedEvent,
@@ -9683,6 +9839,7 @@ export {
9683
9839
  genesisHash,
9684
9840
  getDiff,
9685
9841
  getSnapshot,
9842
+ hasRetiredZeroDuration,
9686
9843
  importSessionFromJson,
9687
9844
  inspectChainTail,
9688
9845
  instructionMode,
@@ -9716,6 +9873,7 @@ export {
9716
9873
  readAllEvents,
9717
9874
  readManifest,
9718
9875
  readMarkdownFile,
9876
+ readObservedDuration,
9719
9877
  readSessionYaml,
9720
9878
  readStatus,
9721
9879
  readTaskFile,
@@ -9776,6 +9934,7 @@ export {
9776
9934
  writeEventsBulk,
9777
9935
  writeManifest,
9778
9936
  writeMarkdownFile,
9937
+ writeObservedDuration,
9779
9938
  writeStatus,
9780
9939
  writeTaskFile,
9781
9940
  writeYamlFile