bertrand 0.22.2 → 0.24.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/bertrand.js CHANGED
@@ -504,6 +504,48 @@ var init_trigger = __esm(() => {
504
504
  init_config2();
505
505
  });
506
506
 
507
+ // src/cli/help.ts
508
+ function helpText(opts = {}) {
509
+ const header = opts.agent ? AGENT_HEADER : HUMAN_HEADER;
510
+ return `${header}
511
+
512
+ ${COMMAND_REFERENCE}`;
513
+ }
514
+ var COMMAND_REFERENCE = `Usage:
515
+ bertrand Launch the interactive TUI; start or resume a session.
516
+ bertrand init First-time setup: install hooks, settings, completions.
517
+
518
+ Inspect sessions (read-only):
519
+ bertrand list [--json] List sessions in the active project with status + activity.
520
+ bertrand log <session> [--json]
521
+ Full record for one session: metadata, stats,
522
+ conversations, and the complete event timeline.
523
+ <session> is "<category>/<slug>" (see \`list\`). Use this
524
+ to see what was decided or tried in another session.
525
+ bertrand stats <session> [--json]
526
+ Aggregate statistics (durations, interactions, diff metrics).
527
+
528
+ Manage sessions & projects:
529
+ bertrand archive <session> Archive or unarchive a session.
530
+ bertrand project <op> list | create | switch | current | rename | remove | import
531
+ (bertrand project --help)
532
+ bertrand sync <op> onboard | push | pull | status | invite | enable | disable
533
+ (bertrand sync --help)
534
+ bertrand serve Start the local dashboard HTTP server.
535
+
536
+ Add --json to list/log/stats for machine-readable output. Most commands accept
537
+ --project <slug> to target a project other than the active one.`, HUMAN_HEADER = `bertrand \u2014 multi-session workflow manager for Claude Code
538
+
539
+ bertrand wraps each Claude Code conversation in a tracked "session": it records the
540
+ full event timeline (prompts, answers, tool use, PRs, deploys), groups sessions by
541
+ project, and can replicate that history across machines.`, AGENT_HEADER = `## bertrand CLI
542
+
543
+ You are running inside a bertrand session. bertrand wraps each Claude Code
544
+ conversation in a tracked "session" and records the full event timeline (prompts,
545
+ answers, tool use, PRs, deploys), grouped by project and replicable across machines.
546
+ The subcommands below inspect and manage that data \u2014 reach for them (e.g.
547
+ \`bertrand log <session>\`) instead of assuming sessions are isolated.`;
548
+
507
549
  // src/cli/router.ts
508
550
  import { existsSync as existsSync4 } from "fs";
509
551
  function register(name, handler) {
@@ -544,6 +586,10 @@ async function autoInitIfFirstRun() {
544
586
  async function route(argv) {
545
587
  const args = argv.slice(2);
546
588
  const command = args[0];
589
+ if (command === "--help" || command === "-h" || command === "help") {
590
+ console.log(helpText({ agent: args.includes("--agent") }));
591
+ return;
592
+ }
547
593
  if (!command || !HOOK_COMMANDS.has(command)) {
548
594
  migrateOrAbort();
549
595
  }
@@ -573,21 +619,7 @@ function resolveCommand(name) {
573
619
  return;
574
620
  }
575
621
  function printUsage() {
576
- console.log(`
577
- bertrand \u2014 multi-session workflow manager for Claude Code
578
-
579
- Usage:
580
- bertrand Launch TUI (or resume named session)
581
- bertrand init Setup wizard
582
- bertrand list Session picker
583
- bertrand log <session> View session log
584
- bertrand stats <session> Session statistics
585
- bertrand archive <name> Archive/unarchive a session
586
- bertrand project <op> list|create|switch|current|rename|remove (see: bertrand project --help)
587
- bertrand update Hook-facing state writer (internal)
588
- bertrand serve Start dashboard HTTP server
589
- bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
590
- `.trim());
622
+ console.log(helpText());
591
623
  }
592
624
  var commands, aliases, HOOK_COMMANDS;
593
625
  var init_router = __esm(() => {
@@ -600,8 +632,10 @@ var init_router = __esm(() => {
600
632
  HOOK_COMMANDS = new Set([
601
633
  "update",
602
634
  "assistant-message",
635
+ "contract",
603
636
  "notify",
604
- "badge"
637
+ "badge",
638
+ "ensure-server"
605
639
  ]);
606
640
  });
607
641
 
@@ -654,6 +688,8 @@ var init_schema = __esm(() => {
654
688
  pid: integer("pid"),
655
689
  startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
656
690
  endedAt: text("ended_at"),
691
+ worktreePath: text("worktree_path"),
692
+ worktreeBranch: text("worktree_branch"),
657
693
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
658
694
  updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
659
695
  }, (t) => [
@@ -782,28 +818,122 @@ function createId(size = 12) {
782
818
  }
783
819
  var init_id = () => {};
784
820
 
821
+ // src/db/queries/categories.ts
822
+ import { eq, like, or, isNull } from "drizzle-orm";
823
+ function createCategory(opts) {
824
+ const db = getDb();
825
+ const id = createId();
826
+ let path = opts.slug;
827
+ let depth = 0;
828
+ if (opts.parentId) {
829
+ const parent = db.select().from(categories).where(eq(categories.id, opts.parentId)).get();
830
+ if (!parent)
831
+ throw new Error(`Parent category ${opts.parentId} not found`);
832
+ path = `${parent.path}/${opts.slug}`;
833
+ depth = parent.depth + 1;
834
+ }
835
+ return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
836
+ }
837
+ function getCategory(id) {
838
+ return getDb().select().from(categories).where(eq(categories.id, id)).get();
839
+ }
840
+ function getCategoryByPath(path) {
841
+ return getDb().select().from(categories).where(eq(categories.path, path)).get();
842
+ }
843
+ function getOrCreateCategoryPath(path) {
844
+ const existing = getCategoryByPath(path);
845
+ if (existing)
846
+ return existing.id;
847
+ const segments = path.split("/");
848
+ let parentId;
849
+ for (let i = 0;i < segments.length; i++) {
850
+ const partialPath = segments.slice(0, i + 1).join("/");
851
+ const category = getCategoryByPath(partialPath);
852
+ if (category) {
853
+ parentId = category.id;
854
+ } else {
855
+ const slug = segments[i];
856
+ const created = createCategory({ slug, name: slug, parentId });
857
+ parentId = created.id;
858
+ }
859
+ }
860
+ return parentId;
861
+ }
862
+ var init_categories = __esm(() => {
863
+ init_client();
864
+ init_schema();
865
+ init_id();
866
+ });
867
+
868
+ // src/lib/parse-session-name.ts
869
+ function parseSessionName(input) {
870
+ const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
871
+ if (!trimmed) {
872
+ throw new Error("Session name cannot be empty");
873
+ }
874
+ const segments = trimmed.split("/").filter(Boolean);
875
+ if (segments.length < 2) {
876
+ throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
877
+ }
878
+ for (const segment of segments) {
879
+ if (!SEGMENT_PATTERN.test(segment)) {
880
+ throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
881
+ }
882
+ }
883
+ const categoryPath = segments[0];
884
+ const slug = segments.slice(1).join("/");
885
+ return { categoryPath, slug };
886
+ }
887
+ var SEGMENT_PATTERN;
888
+ var init_parse_session_name = __esm(() => {
889
+ SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
890
+ });
891
+
785
892
  // src/db/queries/sessions.ts
786
- import { eq, and, inArray, sql as sql2 } from "drizzle-orm";
893
+ import { eq as eq2, and, inArray, sql as sql2 } from "drizzle-orm";
894
+ function resolveSessionByName(name) {
895
+ const flat = parseSessionName(name);
896
+ const flatCategory = getCategoryByPath(flat.categoryPath);
897
+ if (flatCategory) {
898
+ const session = getSessionByCategorySlug(flatCategory.id, flat.slug);
899
+ if (session) {
900
+ return { session, categoryPath: flat.categoryPath, slug: flat.slug };
901
+ }
902
+ }
903
+ const segments = name.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
904
+ if (segments.length >= 3) {
905
+ const legacyCategoryPath = segments.slice(0, -1).join("/");
906
+ const legacySlug = segments[segments.length - 1];
907
+ const legacyCategory = getCategoryByPath(legacyCategoryPath);
908
+ if (legacyCategory) {
909
+ const session = getSessionByCategorySlug(legacyCategory.id, legacySlug);
910
+ if (session) {
911
+ return { session, categoryPath: legacyCategoryPath, slug: legacySlug };
912
+ }
913
+ }
914
+ }
915
+ return;
916
+ }
787
917
  function createSession(opts) {
788
918
  const db = getDb();
789
919
  const id = createId();
790
920
  return db.insert(sessions).values({ id, ...opts }).returning().get();
791
921
  }
792
922
  function getSession(id) {
793
- return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
923
+ return getDb().select().from(sessions).where(eq2(sessions.id, id)).get();
794
924
  }
795
925
  function getSessionByCategorySlug(categoryId, slug) {
796
- return getDb().select().from(sessions).where(and(eq(sessions.categoryId, categoryId), eq(sessions.slug, slug))).get();
926
+ return getDb().select().from(sessions).where(and(eq2(sessions.categoryId, categoryId), eq2(sessions.slug, slug))).get();
797
927
  }
798
928
  function getSessionsByCategory(categoryId) {
799
- return getDb().select().from(sessions).where(eq(sessions.categoryId, categoryId)).all();
929
+ return getDb().select().from(sessions).where(eq2(sessions.categoryId, categoryId)).all();
800
930
  }
801
931
  function getActiveSessions() {
802
- return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
932
+ return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
803
933
  }
804
934
  function getAllSessions(opts) {
805
935
  const db = getDb();
806
- const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id));
936
+ const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id));
807
937
  if (opts?.excludeArchived) {
808
938
  return query.where(inArray(sessions.status, [
809
939
  "active",
@@ -814,33 +944,35 @@ function getAllSessions(opts) {
814
944
  return query.all();
815
945
  }
816
946
  function updateSessionStatus(id, status) {
817
- return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq(sessions.id, id)).returning().get();
947
+ return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
818
948
  }
819
949
  function updateSession(id, data) {
820
- return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(eq(sessions.id, id)).returning().get();
950
+ return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
821
951
  }
822
952
  function deleteSession(id) {
823
- return getDb().delete(sessions).where(eq(sessions.id, id)).run();
953
+ return getDb().delete(sessions).where(eq2(sessions.id, id)).run();
824
954
  }
825
955
  var init_sessions = __esm(() => {
826
956
  init_client();
827
957
  init_schema();
828
958
  init_id();
959
+ init_categories();
960
+ init_parse_session_name();
829
961
  });
830
962
 
831
963
  // src/db/queries/conversations.ts
832
- import { eq as eq2, and as and2, desc, sql as sql3 } from "drizzle-orm";
964
+ import { eq as eq3, and as and2, desc, sql as sql3 } from "drizzle-orm";
833
965
  function createConversation(opts) {
834
966
  return getDb().insert(conversations).values(opts).returning().get();
835
967
  }
836
968
  function getConversation(id) {
837
- return getDb().select().from(conversations).where(eq2(conversations.id, id)).get();
969
+ return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
838
970
  }
839
971
  function getConversationsBySession(sessionId) {
840
- return getDb().select().from(conversations).where(and2(eq2(conversations.sessionId, sessionId), eq2(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
972
+ return getDb().select().from(conversations).where(and2(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
841
973
  }
842
974
  function endConversation(id) {
843
- return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq2(conversations.id, id)).returning().get();
975
+ return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
844
976
  }
845
977
  var init_conversations = __esm(() => {
846
978
  init_client();
@@ -856,8 +988,6 @@ function normalizeEventMeta(eventName, meta) {
856
988
  if (!meta)
857
989
  return meta;
858
990
  switch (eventName) {
859
- case "session.recap":
860
- return mapStringField(meta, "recap", normalizeMarkdown);
861
991
  case "assistant.message":
862
992
  return mapStringField(meta, "text", normalizeMarkdown);
863
993
  case "session.answered":
@@ -896,7 +1026,7 @@ function normalizeAnsweredMeta(meta) {
896
1026
  }
897
1027
 
898
1028
  // src/db/queries/events.ts
899
- import { eq as eq3, and as and3, desc as desc2 } from "drizzle-orm";
1029
+ import { eq as eq4, and as and3, desc as desc2 } from "drizzle-orm";
900
1030
  function insertEvent(opts) {
901
1031
  return getDb().insert(events).values({
902
1032
  sessionId: opts.sessionId,
@@ -907,39 +1037,21 @@ function insertEvent(opts) {
907
1037
  }).returning().get();
908
1038
  }
909
1039
  function getEventsBySession(sessionId) {
910
- return getDb().select().from(events).where(eq3(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
1040
+ return getDb().select().from(events).where(eq4(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
911
1041
  }
912
1042
  function getEventsByType(sessionId, eventType) {
913
- return getDb().select().from(events).where(and3(eq3(events.sessionId, sessionId), eq3(events.event, eventType))).orderBy(events.createdAt).all();
1043
+ return getDb().select().from(events).where(and3(eq4(events.sessionId, sessionId), eq4(events.event, eventType))).orderBy(events.createdAt).all();
914
1044
  }
915
1045
  function getLatestEventOfType(sessionId, eventType, conversationId) {
916
1046
  const conditions = [
917
- eq3(events.sessionId, sessionId),
918
- eq3(events.event, eventType)
1047
+ eq4(events.sessionId, sessionId),
1048
+ eq4(events.event, eventType)
919
1049
  ];
920
1050
  if (conversationId) {
921
- conditions.push(eq3(events.conversationId, conversationId));
1051
+ conditions.push(eq4(events.conversationId, conversationId));
922
1052
  }
923
1053
  return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
924
1054
  }
925
- function getLatestRecaps() {
926
- const rows = getDb().select({
927
- sessionId: events.sessionId,
928
- meta: events.meta,
929
- createdAt: events.createdAt
930
- }).from(events).where(eq3(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
931
- const result = {};
932
- for (const row of rows) {
933
- if (result[row.sessionId])
934
- continue;
935
- const meta = row.meta;
936
- const recap = typeof meta?.recap === "string" ? meta.recap : null;
937
- if (!recap)
938
- continue;
939
- result[row.sessionId] = { recap, createdAt: row.createdAt };
940
- }
941
- return result;
942
- }
943
1055
  var init_events = __esm(() => {
944
1056
  init_client();
945
1057
  init_schema();
@@ -997,15 +1109,6 @@ function emitSessionAnswered(args) {
997
1109
  }
998
1110
  });
999
1111
  }
1000
- function emitSessionRecap(args) {
1001
- return insertEvent({
1002
- sessionId: args.sessionId,
1003
- conversationId: args.conversationId,
1004
- event: "session.recap",
1005
- summary: args.recap.slice(0, 200),
1006
- meta: { recap: args.recap, claude_id: args.conversationId }
1007
- });
1008
- }
1009
1112
  function emitToolUsed(args) {
1010
1113
  const summary = formatToolSummary(args.tool, args.detail);
1011
1114
  return insertEvent({
@@ -1070,13 +1173,22 @@ function emitAssistantMessage(args) {
1070
1173
  }
1071
1174
  });
1072
1175
  }
1073
- function emitAssistantRecap(args) {
1176
+ function emitWorktreeEntered(args) {
1177
+ return insertEvent({
1178
+ sessionId: args.sessionId,
1179
+ conversationId: args.conversationId,
1180
+ event: "worktree.entered",
1181
+ summary: args.branch ? `entered worktree ${args.branch}` : "entered worktree",
1182
+ meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
1183
+ });
1184
+ }
1185
+ function emitWorktreeExited(args) {
1074
1186
  return insertEvent({
1075
1187
  sessionId: args.sessionId,
1076
1188
  conversationId: args.conversationId,
1077
- event: "assistant.recap",
1078
- summary: args.recap.length > 80 ? `${args.recap.slice(0, 77)}...` : args.recap,
1079
- meta: { recap: args.recap, claude_id: args.conversationId }
1189
+ event: "worktree.exited",
1190
+ summary: args.branch ? `exited worktree ${args.branch}` : "exited worktree",
1191
+ meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
1080
1192
  });
1081
1193
  }
1082
1194
  var init_emit = __esm(() => {
@@ -1086,7 +1198,8 @@ var init_emit = __esm(() => {
1086
1198
  // src/cli/commands/update.ts
1087
1199
  var exports_update = {};
1088
1200
  __export(exports_update, {
1089
- shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
1201
+ shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip,
1202
+ dispatchHookEvent: () => dispatchHookEvent
1090
1203
  });
1091
1204
  function shouldIgnoreStatusFlip(newStatus, sessionPid) {
1092
1205
  if (!newStatus)
@@ -1121,13 +1234,6 @@ function dispatchHookEvent(event, ctx) {
1121
1234
  questions: meta.questions ?? []
1122
1235
  });
1123
1236
  return true;
1124
- case "session.recap":
1125
- emitSessionRecap({
1126
- sessionId,
1127
- conversationId,
1128
- recap: String(meta.recap ?? "")
1129
- });
1130
- return true;
1131
1237
  case "tool.applied":
1132
1238
  emitToolApplied({
1133
1239
  sessionId,
@@ -1145,6 +1251,22 @@ function dispatchHookEvent(event, ctx) {
1145
1251
  outcome: meta.outcome === "approved" ? "approved" : "auto"
1146
1252
  });
1147
1253
  return true;
1254
+ case "worktree.entered": {
1255
+ const path = String(meta.path ?? "");
1256
+ const branch = meta.branch ? String(meta.branch) : undefined;
1257
+ emitWorktreeEntered({ sessionId, conversationId, path, branch });
1258
+ updateSession(sessionId, {
1259
+ worktreePath: path || null,
1260
+ worktreeBranch: branch ?? null
1261
+ });
1262
+ return true;
1263
+ }
1264
+ case "worktree.exited": {
1265
+ const path = meta.path ? String(meta.path) : undefined;
1266
+ emitWorktreeExited({ sessionId, conversationId, path });
1267
+ updateSession(sessionId, { worktreePath: null, worktreeBranch: null });
1268
+ return true;
1269
+ }
1148
1270
  default:
1149
1271
  return false;
1150
1272
  }
@@ -1300,7 +1422,6 @@ function summarize(text2) {
1300
1422
  const trimmed = firstLine.trim();
1301
1423
  return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
1302
1424
  }
1303
- var RECAP_RE, RECAP_TAG_GLOBAL;
1304
1425
  var init_assistant_message = __esm(() => {
1305
1426
  init_router();
1306
1427
  init_sessions();
@@ -1308,8 +1429,6 @@ var init_assistant_message = __esm(() => {
1308
1429
  init_events();
1309
1430
  init_emit();
1310
1431
  init_transcript();
1311
- RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
1312
- RECAP_TAG_GLOBAL = /<recap>[\s\S]*?<\/recap>/gi;
1313
1432
  register("assistant-message", async (args) => {
1314
1433
  let sessionId = "";
1315
1434
  let transcriptPath = "";
@@ -1340,37 +1459,171 @@ var init_assistant_message = __esm(() => {
1340
1459
  const turn = getLatestAssistantTurn(transcriptPath);
1341
1460
  if (!turn)
1342
1461
  return;
1343
- const fullText = turn.text;
1344
- const textSansRecap = fullText.replace(RECAP_TAG_GLOBAL, "").trim();
1462
+ const text2 = turn.text.trim();
1345
1463
  const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
1346
- if (textSansRecap || turn.thinkingBlocks > 0) {
1464
+ if (text2 || turn.thinkingBlocks > 0) {
1347
1465
  const latestMsg = getLatestEventOfType(sessionId, "assistant.message", convoId);
1348
1466
  const latestText = latestMsg?.meta?.text;
1349
- if (latestText !== textSansRecap) {
1467
+ if (latestText !== text2) {
1350
1468
  emitAssistantMessage({
1351
1469
  sessionId,
1352
1470
  conversationId: convoId,
1353
- text: textSansRecap,
1471
+ text: text2,
1354
1472
  model: turn.model,
1355
1473
  thinkingBlocks: turn.thinkingBlocks,
1356
1474
  thinkingBytes: turn.thinkingBytes,
1357
- summary: textSansRecap ? summarize(textSansRecap) : "thinking only"
1475
+ summary: text2 ? summarize(text2) : "thinking only"
1358
1476
  });
1359
1477
  }
1360
1478
  }
1361
- const recapMatch = fullText.match(RECAP_RE);
1362
- const recap = recapMatch?.[1]?.trim();
1363
- if (recap) {
1364
- const latestRecap = getLatestEventOfType(sessionId, "assistant.recap", convoId);
1365
- const latestRecapText = latestRecap?.meta?.recap;
1366
- if (latestRecapText !== recap) {
1367
- emitAssistantRecap({
1368
- sessionId,
1369
- conversationId: convoId,
1370
- recap
1371
- });
1479
+ });
1480
+ });
1481
+
1482
+ // src/contract/template.md
1483
+ var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
1484
+
1485
+ After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it).
1486
+
1487
+ If the user's most recent answer to AskUserQuestion was "Done for now" (or contains it), this turn is the FINAL turn. Respond briefly to acknowledge and do NOT call AskUserQuestion again \u2014 the loop is over.
1488
+ `;
1489
+ var init_template = () => {};
1490
+
1491
+ // src/contract/template.ts
1492
+ function buildContract(sessionName, ...contextLayers) {
1493
+ const base = template_default.replace("{sessionName}", sessionName);
1494
+ const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
1495
+ if (layers.length === 0)
1496
+ return base;
1497
+ return base + `
1498
+
1499
+ ` + layers.join(`
1500
+
1501
+ `);
1502
+ }
1503
+ var init_template2 = __esm(() => {
1504
+ init_template();
1505
+ });
1506
+
1507
+ // src/lib/format.ts
1508
+ function formatDuration(ms) {
1509
+ if (ms < MINUTE)
1510
+ return `${Math.round(ms / SECOND)}s`;
1511
+ const days = Math.floor(ms / DAY);
1512
+ const hours = Math.floor(ms % DAY / HOUR);
1513
+ const minutes = Math.floor(ms % HOUR / MINUTE);
1514
+ if (days > 0)
1515
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
1516
+ if (hours > 0)
1517
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1518
+ return `${minutes}m`;
1519
+ }
1520
+ function formatAgo(isoOrDate) {
1521
+ const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
1522
+ const ms = Date.now() - date.getTime();
1523
+ if (ms < MINUTE)
1524
+ return "just now";
1525
+ if (ms < HOUR)
1526
+ return `${Math.floor(ms / MINUTE)}m ago`;
1527
+ if (ms < DAY)
1528
+ return `${Math.floor(ms / HOUR)}h ago`;
1529
+ if (ms < 2 * DAY)
1530
+ return "yesterday";
1531
+ if (ms < 7 * DAY)
1532
+ return `${Math.floor(ms / DAY)}d ago`;
1533
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1534
+ }
1535
+ function truncate(text2, maxLen) {
1536
+ if (text2.length <= maxLen)
1537
+ return text2;
1538
+ return text2.slice(0, maxLen - 1) + "\u2026";
1539
+ }
1540
+ function formatTime(iso, includeDate = false) {
1541
+ const date = new Date(iso);
1542
+ const time = date.toLocaleTimeString("en-US", {
1543
+ hour: "numeric",
1544
+ minute: "2-digit"
1545
+ });
1546
+ if (!includeDate)
1547
+ return time;
1548
+ const day = date.toLocaleDateString("en-US", {
1549
+ month: "short",
1550
+ day: "numeric"
1551
+ });
1552
+ return `${day} ${time}`;
1553
+ }
1554
+ var SECOND = 1000, MINUTE, HOUR, DAY;
1555
+ var init_format = __esm(() => {
1556
+ MINUTE = 60 * SECOND;
1557
+ HOUR = 60 * MINUTE;
1558
+ DAY = 24 * HOUR;
1559
+ });
1560
+
1561
+ // src/contract/context.ts
1562
+ function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
1563
+ const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
1564
+ if (siblings.length === 0)
1565
+ return "";
1566
+ const lines = siblings.map((s) => {
1567
+ const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
1568
+ const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
1569
+ const worktree = s.worktreeBranch ? ` [worktree: ${s.worktreeBranch}]` : "";
1570
+ return `- ${categoryPath}/${s.slug}: ${s.status}${worktree}${summary} (${ago})`;
1571
+ });
1572
+ const guidance = [
1573
+ "",
1574
+ "To inspect any sibling session's full record, run:",
1575
+ " bertrand log <category>/<slug> --json",
1576
+ "Returns session metadata, stats, conversations, and the full event timeline.",
1577
+ "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
1578
+ ].join(`
1579
+ `);
1580
+ return `## Sibling Sessions
1581
+ ${lines.join(`
1582
+ `)}
1583
+ ${guidance}`;
1584
+ }
1585
+ var init_context = __esm(() => {
1586
+ init_sessions();
1587
+ init_format();
1588
+ });
1589
+
1590
+ // src/cli/commands/contract.ts
1591
+ var exports_contract = {};
1592
+ var init_contract = __esm(() => {
1593
+ init_router();
1594
+ init_sessions();
1595
+ init_categories();
1596
+ init_template2();
1597
+ init_context();
1598
+ register("contract", async (args) => {
1599
+ let sessionId = "";
1600
+ let short = false;
1601
+ for (let i = 0;i < args.length; i++) {
1602
+ const arg = args[i];
1603
+ const next = args[i + 1];
1604
+ if (arg === "--session-id" && next) {
1605
+ sessionId = next;
1606
+ i++;
1607
+ } else if (arg === "--short") {
1608
+ short = true;
1372
1609
  }
1373
1610
  }
1611
+ if (!sessionId) {
1612
+ console.error("Usage: bertrand contract --session-id <id> [--short]");
1613
+ process.exit(1);
1614
+ }
1615
+ const session = getSession(sessionId);
1616
+ if (!session)
1617
+ return;
1618
+ const category = getCategory(session.categoryId);
1619
+ const categoryPath = category?.path ?? "";
1620
+ const sessionName = categoryPath ? `${categoryPath}/${session.slug}` : session.slug;
1621
+ if (short) {
1622
+ process.stdout.write(`Reminder \u2014 you are in bertrand session ${sessionName}: end this turn with an AskUserQuestion call (multiSelect:true on every question, plus a "Done for now" option).`);
1623
+ return;
1624
+ }
1625
+ const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
1626
+ process.stdout.write(buildContract(sessionName, helpText({ agent: true }), siblingContext));
1374
1627
  });
1375
1628
  });
1376
1629
 
@@ -1491,9 +1744,9 @@ var init_notify = __esm(() => {
1491
1744
  });
1492
1745
 
1493
1746
  // src/db/queries/stats.ts
1494
- import { eq as eq4, sql as sql4 } from "drizzle-orm";
1747
+ import { eq as eq5, sql as sql4 } from "drizzle-orm";
1495
1748
  function getSessionStats(sessionId) {
1496
- return getDb().select().from(sessionStats).where(eq4(sessionStats.sessionId, sessionId)).get();
1749
+ return getDb().select().from(sessionStats).where(eq5(sessionStats.sessionId, sessionId)).get();
1497
1750
  }
1498
1751
  function upsertSessionStats(sessionId, data) {
1499
1752
  return getDb().insert(sessionStats).values({
@@ -1666,7 +1919,7 @@ var init_timing = __esm(() => {
1666
1919
  });
1667
1920
 
1668
1921
  // src/lib/engagement_stats.ts
1669
- import { eq as eq5, sql as sql5 } from "drizzle-orm";
1922
+ import { eq as eq6, sql as sql5 } from "drizzle-orm";
1670
1923
  function aggregateToolUsage(sessionId) {
1671
1924
  const counts = {};
1672
1925
  const applied = getEventsByType(sessionId, "tool.applied");
@@ -1693,7 +1946,7 @@ function discardRate(sessionId) {
1693
1946
  const row = getDb().select({
1694
1947
  total: sql5`count(*)`,
1695
1948
  discarded: sql5`sum(case when ${conversations.discarded} then 1 else 0 end)`
1696
- }).from(conversations).where(eq5(conversations.sessionId, sessionId)).get();
1949
+ }).from(conversations).where(eq6(conversations.sessionId, sessionId)).get();
1697
1950
  return {
1698
1951
  total: row?.total ?? 0,
1699
1952
  discarded: row?.discarded ?? 0
@@ -1934,7 +2187,7 @@ var PORT, listSessions = (_params, url) => {
1934
2187
  return getSessionStats(sessionId) ?? liveStats(sessionId);
1935
2188
  }, getEngagement = ({
1936
2189
  sessionId
1937
- }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), listAllProjects = () => {
2190
+ }) => computeEngagementStats(sessionId), listAllProjects = () => {
1938
2191
  const active = resolveActiveProject();
1939
2192
  return listProjects().map((p) => ({
1940
2193
  slug: p.slug,
@@ -1945,7 +2198,7 @@ var PORT, listSessions = (_params, url) => {
1945
2198
  }, getActiveProjectMeta = () => {
1946
2199
  const active = resolveActiveProject();
1947
2200
  return { slug: active.slug, name: active.name };
1948
- }, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
2201
+ }, listWorktreeSessions = () => getAllSessions({ excludeArchived: true }).filter(({ session }) => session.worktreePath != null), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1949
2202
  var init_server = __esm(() => {
1950
2203
  init_sessions();
1951
2204
  init_events();
@@ -1960,11 +2213,11 @@ var init_server = __esm(() => {
1960
2213
  routes = [
1961
2214
  [/^\/api\/sessions$/, listSessions],
1962
2215
  [/^\/api\/sessions\/(?<id>[^/]+)$/, getSessionById],
2216
+ [/^\/api\/worktrees$/, listWorktreeSessions],
1963
2217
  [/^\/api\/events\/(?<sessionId>[^/]+)$/, listEvents],
1964
2218
  [/^\/api\/stats$/, listAllStats],
1965
2219
  [/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
1966
2220
  [/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
1967
- [/^\/api\/recaps$/, listRecaps],
1968
2221
  [/^\/api\/projects$/, listAllProjects],
1969
2222
  [/^\/api\/active-project$/, getActiveProjectMeta]
1970
2223
  ];
@@ -1988,9 +2241,110 @@ var init_serve = __esm(() => {
1988
2241
  });
1989
2242
  });
1990
2243
 
2244
+ // src/lib/server-lifecycle.ts
2245
+ import { spawn as spawn2 } from "child_process";
2246
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, unlinkSync } from "fs";
2247
+ import { join as join9 } from "path";
2248
+ function readPidFile() {
2249
+ try {
2250
+ const pid = Number(readFileSync6(deps.pidFile, "utf-8").trim());
2251
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
2252
+ } catch {
2253
+ return null;
2254
+ }
2255
+ }
2256
+ function isProcessAlive(pid) {
2257
+ try {
2258
+ process.kill(pid, 0);
2259
+ return true;
2260
+ } catch {
2261
+ return false;
2262
+ }
2263
+ }
2264
+ async function isPortListening(port) {
2265
+ try {
2266
+ await fetch(`http://localhost:${port}/api/sessions`, {
2267
+ signal: AbortSignal.timeout(500)
2268
+ });
2269
+ return true;
2270
+ } catch {
2271
+ return false;
2272
+ }
2273
+ }
2274
+ function removePidFile() {
2275
+ try {
2276
+ unlinkSync(deps.pidFile);
2277
+ } catch {}
2278
+ }
2279
+ async function ensureServerStarted() {
2280
+ const existingPid = readPidFile();
2281
+ if (existingPid && isProcessAlive(existingPid))
2282
+ return;
2283
+ if (existingPid)
2284
+ removePidFile();
2285
+ if (await isPortListening(deps.port))
2286
+ return;
2287
+ const bin = deps.resolveBin();
2288
+ if (!bin)
2289
+ return;
2290
+ const child = spawn2(bin, ["serve"], {
2291
+ detached: true,
2292
+ stdio: "ignore",
2293
+ env: { ...process.env, BERTRAND_PORT: String(deps.port) }
2294
+ });
2295
+ child.unref();
2296
+ if (child.pid)
2297
+ writeFileSync4(deps.pidFile, String(child.pid));
2298
+ }
2299
+ async function ensureServerForActiveSessions() {
2300
+ if (deps.getActiveCount() === 0)
2301
+ return;
2302
+ await ensureServerStarted();
2303
+ }
2304
+ function stopServerIfIdle() {
2305
+ if (deps.getActiveCount() > 0)
2306
+ return;
2307
+ const pid = readPidFile();
2308
+ if (!pid)
2309
+ return;
2310
+ try {
2311
+ process.kill(pid, "SIGTERM");
2312
+ } catch {}
2313
+ removePidFile();
2314
+ }
2315
+ var defaultDeps, deps;
2316
+ var init_server_lifecycle = __esm(() => {
2317
+ init_paths();
2318
+ init_sessions();
2319
+ defaultDeps = {
2320
+ pidFile: join9(paths.root, "server.pid"),
2321
+ port: Number(process.env.BERTRAND_PORT ?? 5200),
2322
+ resolveBin() {
2323
+ try {
2324
+ const config = JSON.parse(readFileSync6(join9(paths.root, "config.json"), "utf-8"));
2325
+ return typeof config?.bin === "string" ? config.bin : null;
2326
+ } catch {
2327
+ return null;
2328
+ }
2329
+ },
2330
+ getActiveCount: () => getActiveSessions().length
2331
+ };
2332
+ deps = defaultDeps;
2333
+ });
2334
+
2335
+ // src/cli/commands/ensure-server.ts
2336
+ var exports_ensure_server = {};
2337
+ var init_ensure_server = __esm(() => {
2338
+ init_router();
2339
+ init_server_lifecycle();
2340
+ register("ensure-server", async () => {
2341
+ await ensureServerForActiveSessions();
2342
+ });
2343
+ });
2344
+
1991
2345
  // src/sync/snapshot.ts
1992
2346
  import { Database as Database2 } from "bun:sqlite";
1993
- import { existsSync as existsSync7, unlinkSync } from "fs";
2347
+ import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "fs";
1994
2348
  function snapshotPathFor(dbPath) {
1995
2349
  return `${dbPath}.sync-snapshot`;
1996
2350
  }
@@ -2012,7 +2366,7 @@ function cleanupSnapshot() {
2012
2366
  const p = base + suffix;
2013
2367
  if (existsSync7(p)) {
2014
2368
  try {
2015
- unlinkSync(p);
2369
+ unlinkSync2(p);
2016
2370
  } catch {}
2017
2371
  }
2018
2372
  }
@@ -2065,7 +2419,7 @@ var init_crypto = __esm(() => {
2065
2419
 
2066
2420
  // src/sync/engine.ts
2067
2421
  import { createClient } from "@supabase/supabase-js";
2068
- import { readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
2422
+ import { readFileSync as readFileSync7, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
2069
2423
  import { dirname as dirname2 } from "path";
2070
2424
  function client(cfg) {
2071
2425
  if (!cfg)
@@ -2095,7 +2449,7 @@ async function push() {
2095
2449
  error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
2096
2450
  };
2097
2451
  }
2098
- const plaintext = readFileSync6(snapshotPath);
2452
+ const plaintext = readFileSync7(snapshotPath);
2099
2453
  const ciphertext = encrypt(plaintext, cfg.encryptionKey);
2100
2454
  const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
2101
2455
  contentType: "application/octet-stream",
@@ -2375,60 +2729,6 @@ var init_bootstrap = __esm(() => {
2375
2729
  init_config2();
2376
2730
  });
2377
2731
 
2378
- // src/lib/format.ts
2379
- function formatDuration(ms) {
2380
- if (ms < MINUTE)
2381
- return `${Math.round(ms / SECOND)}s`;
2382
- const days = Math.floor(ms / DAY);
2383
- const hours = Math.floor(ms % DAY / HOUR);
2384
- const minutes = Math.floor(ms % HOUR / MINUTE);
2385
- if (days > 0)
2386
- return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
2387
- if (hours > 0)
2388
- return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
2389
- return `${minutes}m`;
2390
- }
2391
- function formatAgo(isoOrDate) {
2392
- const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
2393
- const ms = Date.now() - date.getTime();
2394
- if (ms < MINUTE)
2395
- return "just now";
2396
- if (ms < HOUR)
2397
- return `${Math.floor(ms / MINUTE)}m ago`;
2398
- if (ms < DAY)
2399
- return `${Math.floor(ms / HOUR)}h ago`;
2400
- if (ms < 2 * DAY)
2401
- return "yesterday";
2402
- if (ms < 7 * DAY)
2403
- return `${Math.floor(ms / DAY)}d ago`;
2404
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2405
- }
2406
- function truncate(text2, maxLen) {
2407
- if (text2.length <= maxLen)
2408
- return text2;
2409
- return text2.slice(0, maxLen - 1) + "\u2026";
2410
- }
2411
- function formatTime(iso, includeDate = false) {
2412
- const date = new Date(iso);
2413
- const time = date.toLocaleTimeString("en-US", {
2414
- hour: "numeric",
2415
- minute: "2-digit"
2416
- });
2417
- if (!includeDate)
2418
- return time;
2419
- const day = date.toLocaleDateString("en-US", {
2420
- month: "short",
2421
- day: "numeric"
2422
- });
2423
- return `${day} ${time}`;
2424
- }
2425
- var SECOND = 1000, MINUTE, HOUR, DAY;
2426
- var init_format = __esm(() => {
2427
- MINUTE = 60 * SECOND;
2428
- HOUR = 60 * MINUTE;
2429
- DAY = 24 * HOUR;
2430
- });
2431
-
2432
2732
  // src/cli/commands/sync.ts
2433
2733
  var exports_sync = {};
2434
2734
  import { hostname as hostname2 } from "os";
@@ -2723,81 +3023,34 @@ var init_sync = __esm(() => {
2723
3023
  await runPush();
2724
3024
  return;
2725
3025
  case "pull":
2726
- await runPull(rest);
2727
- return;
2728
- case "status":
2729
- await runStatus();
2730
- return;
2731
- case "onboard":
2732
- await runOnboard();
2733
- return;
2734
- case "invite":
2735
- runInvite();
2736
- return;
2737
- case "enable":
2738
- runEnable();
2739
- return;
2740
- case "disable":
2741
- runDisable();
2742
- return;
2743
- case undefined:
2744
- case "--help":
2745
- case "-h":
2746
- printUsage2();
2747
- return;
2748
- default:
2749
- console.error(`Unknown subcommand: ${sub}`);
2750
- printUsage2();
2751
- process.exit(1);
2752
- }
2753
- });
2754
- });
2755
-
2756
- // src/db/queries/categories.ts
2757
- import { eq as eq6, like, or, isNull } from "drizzle-orm";
2758
- function createCategory(opts) {
2759
- const db = getDb();
2760
- const id = createId();
2761
- let path = opts.slug;
2762
- let depth = 0;
2763
- if (opts.parentId) {
2764
- const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
2765
- if (!parent)
2766
- throw new Error(`Parent category ${opts.parentId} not found`);
2767
- path = `${parent.path}/${opts.slug}`;
2768
- depth = parent.depth + 1;
2769
- }
2770
- return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
2771
- }
2772
- function getCategory(id) {
2773
- return getDb().select().from(categories).where(eq6(categories.id, id)).get();
2774
- }
2775
- function getCategoryByPath(path) {
2776
- return getDb().select().from(categories).where(eq6(categories.path, path)).get();
2777
- }
2778
- function getOrCreateCategoryPath(path) {
2779
- const existing = getCategoryByPath(path);
2780
- if (existing)
2781
- return existing.id;
2782
- const segments = path.split("/");
2783
- let parentId;
2784
- for (let i = 0;i < segments.length; i++) {
2785
- const partialPath = segments.slice(0, i + 1).join("/");
2786
- const category = getCategoryByPath(partialPath);
2787
- if (category) {
2788
- parentId = category.id;
2789
- } else {
2790
- const slug = segments[i];
2791
- const created = createCategory({ slug, name: slug, parentId });
2792
- parentId = created.id;
3026
+ await runPull(rest);
3027
+ return;
3028
+ case "status":
3029
+ await runStatus();
3030
+ return;
3031
+ case "onboard":
3032
+ await runOnboard();
3033
+ return;
3034
+ case "invite":
3035
+ runInvite();
3036
+ return;
3037
+ case "enable":
3038
+ runEnable();
3039
+ return;
3040
+ case "disable":
3041
+ runDisable();
3042
+ return;
3043
+ case undefined:
3044
+ case "--help":
3045
+ case "-h":
3046
+ printUsage2();
3047
+ return;
3048
+ default:
3049
+ console.error(`Unknown subcommand: ${sub}`);
3050
+ printUsage2();
3051
+ process.exit(1);
2793
3052
  }
2794
- }
2795
- return parentId;
2796
- }
2797
- var init_categories = __esm(() => {
2798
- init_client();
2799
- init_schema();
2800
- init_id();
3053
+ });
2801
3054
  });
2802
3055
 
2803
3056
  // src/db/queries/labels.ts
@@ -2823,63 +3076,8 @@ var init_labels = __esm(() => {
2823
3076
  init_id();
2824
3077
  });
2825
3078
 
2826
- // src/contract/template.md
2827
- var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
2828
-
2829
- After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it). The description for "Done for now" must be a 1-2 sentence summary of what was accomplished so far. Describe outcomes (what was built, fixed, decided), not process.
2830
-
2831
- If the user's most recent answer to AskUserQuestion was "Done for now" (or contains it), this turn is the FINAL turn. Respond briefly to acknowledge and do NOT call AskUserQuestion again \u2014 the loop is over.
2832
-
2833
- Before each AskUserQuestion call, emit a \`<recap>...</recap>\` block in your text output. Use markdown \u2014 a short bullet list is usually the most scannable shape; a single short paragraph is fine when the turn was one cohesive thing. Keep it concise. The recap covers what happened since the previous AskUserQuestion (or session start) \u2014 what you found, decided, or did. Write the gist for someone reading the session timeline, not a process log. The dashboard renders these between AskUserQuestion events; do not use this tag for any other purpose.
2834
- `;
2835
- var init_template = () => {};
2836
-
2837
- // src/contract/template.ts
2838
- function buildContract(sessionName, ...contextLayers) {
2839
- const base = template_default.replace("{sessionName}", sessionName);
2840
- const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
2841
- if (layers.length === 0)
2842
- return base;
2843
- return base + `
2844
-
2845
- ` + layers.join(`
2846
-
2847
- `);
2848
- }
2849
- var init_template2 = __esm(() => {
2850
- init_template();
2851
- });
2852
-
2853
- // src/contract/context.ts
2854
- function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
2855
- const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
2856
- if (siblings.length === 0)
2857
- return "";
2858
- const lines = siblings.map((s) => {
2859
- const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
2860
- const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
2861
- return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
2862
- });
2863
- const guidance = [
2864
- "",
2865
- "To inspect any sibling session's full record, run:",
2866
- " bertrand log <category>/<slug> --json",
2867
- "Returns session metadata, stats, conversations, and the full event timeline.",
2868
- "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
2869
- ].join(`
2870
- `);
2871
- return `## Sibling Sessions
2872
- ${lines.join(`
2873
- `)}
2874
- ${guidance}`;
2875
- }
2876
- var init_context = __esm(() => {
2877
- init_sessions();
2878
- init_format();
2879
- });
2880
-
2881
3079
  // src/engine/process.ts
2882
- import { spawn as spawn2 } from "child_process";
3080
+ import { spawn as spawn3 } from "child_process";
2883
3081
  function launchClaude(opts) {
2884
3082
  const args = [];
2885
3083
  if (opts.resume) {
@@ -2900,7 +3098,7 @@ function launchClaude(opts) {
2900
3098
  BERTRAND_PROJECT_DB: active.db
2901
3099
  };
2902
3100
  return new Promise((resolve, reject) => {
2903
- const child = spawn2("claude", args, {
3101
+ const child = spawn3("claude", args, {
2904
3102
  env,
2905
3103
  stdio: "inherit",
2906
3104
  shell: false
@@ -2935,90 +3133,42 @@ var init_process = __esm(() => {
2935
3133
  init_resolve();
2936
3134
  });
2937
3135
 
2938
- // src/lib/server-lifecycle.ts
2939
- import { spawn as spawn3 } from "child_process";
2940
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
2941
- import { join as join9 } from "path";
2942
- function readPidFile() {
2943
- try {
2944
- const pid = Number(readFileSync7(deps.pidFile, "utf-8").trim());
2945
- return Number.isFinite(pid) && pid > 0 ? pid : null;
2946
- } catch {
2947
- return null;
2948
- }
2949
- }
2950
- function isProcessAlive(pid) {
3136
+ // src/hooks/runtime.ts
3137
+ import { readdirSync as readdirSync2, rmSync, statSync as statSync4 } from "fs";
3138
+ import { join as join10 } from "path";
3139
+ function rmMarker(name) {
3140
+ rmSync(join10(runtimeDir, name), { force: true });
3141
+ }
3142
+ function pruneSessionMarkers(sessionId, conversationId) {
3143
+ rmMarker(`done-${sessionId}`);
3144
+ rmMarker(`auq-nudge-${sessionId}`);
3145
+ rmMarker(`working-${sessionId}`);
3146
+ rmMarker(`worktree-${sessionId}`);
3147
+ if (conversationId)
3148
+ rmMarker(`${CONTRACT_MARKER_PREFIX}${conversationId}`);
3149
+ }
3150
+ function pruneStaleContractMarkers(maxAgeMs = STALE_MS) {
3151
+ let entries;
2951
3152
  try {
2952
- process.kill(pid, 0);
2953
- return true;
3153
+ entries = readdirSync2(runtimeDir);
2954
3154
  } catch {
2955
- return false;
3155
+ return;
2956
3156
  }
2957
- }
2958
- async function isPortListening(port) {
2959
- try {
2960
- await fetch(`http://localhost:${port}/api/sessions`, {
2961
- signal: AbortSignal.timeout(500)
2962
- });
2963
- return true;
2964
- } catch {
2965
- return false;
3157
+ const cutoff = Date.now() - maxAgeMs;
3158
+ for (const name of entries) {
3159
+ if (!name.startsWith(CONTRACT_MARKER_PREFIX))
3160
+ continue;
3161
+ try {
3162
+ if (statSync4(join10(runtimeDir, name)).mtimeMs < cutoff)
3163
+ rmMarker(name);
3164
+ } catch {}
2966
3165
  }
2967
3166
  }
2968
- function removePidFile() {
2969
- try {
2970
- unlinkSync2(deps.pidFile);
2971
- } catch {}
2972
- }
2973
- async function ensureServerStarted() {
2974
- const existingPid = readPidFile();
2975
- if (existingPid && isProcessAlive(existingPid))
2976
- return;
2977
- if (existingPid)
2978
- removePidFile();
2979
- if (await isPortListening(deps.port))
2980
- return;
2981
- const bin = deps.resolveBin();
2982
- if (!bin)
2983
- return;
2984
- const child = spawn3(bin, ["serve"], {
2985
- detached: true,
2986
- stdio: "ignore",
2987
- env: { ...process.env, BERTRAND_PORT: String(deps.port) }
2988
- });
2989
- child.unref();
2990
- if (child.pid)
2991
- writeFileSync4(deps.pidFile, String(child.pid));
2992
- }
2993
- function stopServerIfIdle() {
2994
- if (deps.getActiveCount() > 0)
2995
- return;
2996
- const pid = readPidFile();
2997
- if (!pid)
2998
- return;
2999
- try {
3000
- process.kill(pid, "SIGTERM");
3001
- } catch {}
3002
- removePidFile();
3003
- }
3004
- var defaultDeps, deps;
3005
- var init_server_lifecycle = __esm(() => {
3167
+ var CONTRACT_MARKER_PREFIX = "contract-sent-", STALE_MS, runtimeDir;
3168
+ var init_runtime = __esm(() => {
3006
3169
  init_paths();
3007
- init_sessions();
3008
- defaultDeps = {
3009
- pidFile: join9(paths.root, "server.pid"),
3010
- port: Number(process.env.BERTRAND_PORT ?? 5200),
3011
- resolveBin() {
3012
- try {
3013
- const config = JSON.parse(readFileSync7(join9(paths.root, "config.json"), "utf-8"));
3014
- return typeof config?.bin === "string" ? config.bin : null;
3015
- } catch {
3016
- return null;
3017
- }
3018
- },
3019
- getActiveCount: () => getActiveSessions().length
3020
- };
3021
- deps = defaultDeps;
3170
+ STALE_MS = 24 * 60 * 60 * 1000;
3171
+ runtimeDir = paths.runtime;
3022
3172
  });
3023
3173
 
3024
3174
  // src/engine/session.ts
@@ -3057,6 +3207,7 @@ function installExitHandlers() {
3057
3207
  process.on("SIGHUP", onSignal);
3058
3208
  }
3059
3209
  async function launch(opts) {
3210
+ pruneStaleContractMarkers();
3060
3211
  const existingCategory = getCategoryByPath(opts.categoryPath);
3061
3212
  if (existingCategory) {
3062
3213
  const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
@@ -3090,7 +3241,7 @@ async function launch(opts) {
3090
3241
  cwd: process.cwd()
3091
3242
  });
3092
3243
  const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
3093
- const contract = buildContract(sessionName, siblingContext);
3244
+ const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
3094
3245
  const exitCode = await launchClaude({
3095
3246
  sessionId: session.id,
3096
3247
  claudeId,
@@ -3102,6 +3253,7 @@ async function launch(opts) {
3102
3253
  return session.id;
3103
3254
  }
3104
3255
  async function resume(opts) {
3256
+ pruneStaleContractMarkers();
3105
3257
  const session = getSession(opts.sessionId);
3106
3258
  if (!session)
3107
3259
  throw new Error(`Session not found: ${opts.sessionId}`);
@@ -3121,7 +3273,7 @@ async function resume(opts) {
3121
3273
  }
3122
3274
  const categoryPath = category?.path ?? "";
3123
3275
  const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
3124
- const contract = buildContract(sessionName, siblingContext);
3276
+ const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
3125
3277
  const exitCode = await launchClaude({
3126
3278
  sessionId: session.id,
3127
3279
  claudeId: opts.conversationId,
@@ -3153,6 +3305,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
3153
3305
  });
3154
3306
  if (liveSession?.sessionId === sessionId)
3155
3307
  liveSession = null;
3308
+ pruneSessionMarkers(sessionId, safeConversationId);
3156
3309
  computeAndPersist(sessionId);
3157
3310
  stopServerIfIdle();
3158
3311
  triggerBackgroundPush();
@@ -3171,16 +3324,17 @@ var init_session = __esm(() => {
3171
3324
  init_server_lifecycle();
3172
3325
  init_trigger();
3173
3326
  init_transcript();
3327
+ init_runtime();
3174
3328
  });
3175
3329
 
3176
3330
  // src/tui/app.tsx
3177
3331
  import { spawn as spawn4 } from "child_process";
3178
3332
  import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
3179
3333
  import { tmpdir } from "os";
3180
- import { join as join10 } from "path";
3334
+ import { join as join11 } from "path";
3181
3335
  import { randomUUID as randomUUID2 } from "crypto";
3182
3336
  async function runScreen(screen, ...args) {
3183
- const tmpFile = join10(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
3337
+ const tmpFile = join11(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
3184
3338
  if (process.env.BERTRAND_DEBUG_TUI) {
3185
3339
  try {
3186
3340
  const { appendFileSync } = await import("fs");
@@ -3190,7 +3344,7 @@ async function runScreen(screen, ...args) {
3190
3344
  }
3191
3345
  const child = spawn4("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
3192
3346
  stdio: "inherit",
3193
- env: process.env
3347
+ env: { ...process.env, BERTRAND_PROJECT: resolveActiveProject().slug }
3194
3348
  });
3195
3349
  const noopSignal = () => {};
3196
3350
  process.on("SIGINT", noopSignal);
@@ -3337,35 +3491,11 @@ var init_app = __esm(() => {
3337
3491
  init_create();
3338
3492
  init_resolve();
3339
3493
  SCREEN_ENTRY = (() => {
3340
- const built = join10(import.meta.dir, "run-screen.js");
3341
- return existsSync8(built) ? built : join10(import.meta.dir, "run-screen.tsx");
3494
+ const built = join11(import.meta.dir, "run-screen.js");
3495
+ return existsSync8(built) ? built : join11(import.meta.dir, "run-screen.tsx");
3342
3496
  })();
3343
3497
  });
3344
3498
 
3345
- // src/lib/parse-session-name.ts
3346
- function parseSessionName(input) {
3347
- const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
3348
- if (!trimmed) {
3349
- throw new Error("Session name cannot be empty");
3350
- }
3351
- const segments = trimmed.split("/").filter(Boolean);
3352
- if (segments.length < 2) {
3353
- throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
3354
- }
3355
- for (const segment of segments) {
3356
- if (!SEGMENT_PATTERN.test(segment)) {
3357
- throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
3358
- }
3359
- }
3360
- const categoryPath = segments[0];
3361
- const slug = segments.slice(1).join("/");
3362
- return { categoryPath, slug };
3363
- }
3364
- var SEGMENT_PATTERN;
3365
- var init_parse_session_name = __esm(() => {
3366
- SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
3367
- });
3368
-
3369
3499
  // src/engine/recovery.ts
3370
3500
  function isProcessAlive2(pid) {
3371
3501
  try {
@@ -3430,7 +3560,7 @@ var init_launch = __esm(() => {
3430
3560
  function quietHelper(bin) {
3431
3561
  return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
3432
3562
  }
3433
- function waitingScript(bin, runtimeDir) {
3563
+ function waitingScript(bin, runtimeDir2) {
3434
3564
  return `#!/usr/bin/env bash
3435
3565
  # Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
3436
3566
  ${quietHelper(bin)}
@@ -3455,11 +3585,11 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
3455
3585
  [ -z "$question" ] && question="Waiting for input"
3456
3586
 
3457
3587
  # Clear working debounce marker so next resume\u2192working transition fires
3458
- rm -f "${runtimeDir}/working-$sid"
3588
+ rm -f "${runtimeDir2}/working-$sid"
3459
3589
 
3460
3590
  bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3461
3591
 
3462
- # Capture the latest assistant turn's text + recap tag. Dedup inside the
3592
+ # Capture the latest assistant turn's text. Dedup inside the
3463
3593
  # command makes it idempotent vs the matching Stop-time capture so the same
3464
3594
  # turn never lands twice.
3465
3595
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
@@ -3473,7 +3603,7 @@ bq notify bertrand "$question" &
3473
3603
  wait
3474
3604
  `;
3475
3605
  }
3476
- function answeredScript(bin, _runtimeDir) {
3606
+ function answeredScript(bin, runtimeDir2) {
3477
3607
  return `#!/usr/bin/env bash
3478
3608
  # Hook: PostToolUse AskUserQuestion \u2192 mark session as active
3479
3609
  #
@@ -3507,19 +3637,17 @@ bq update --session-id "$sid" --event session.answered --meta "$meta"
3507
3637
 
3508
3638
  bq badge --clear &
3509
3639
 
3640
+ # The loop is healthy \u2014 the agent ended its turn on AskUserQuestion and the
3641
+ # user answered. Reset the Stop-hook nudge counter so its cap applies per
3642
+ # run of consecutive contract violations, not cumulatively across the session.
3643
+ rm -f "${runtimeDir2}/auq-nudge-$sid"
3644
+
3510
3645
  # Halt the agent loop if the user signaled Done for now. The Stop hook
3511
3646
  # (on-done.sh) will fire afterwards and mark the session as paused.
3512
3647
  if printf '%s' "$done_check" | grep -q "Done for now"; then
3513
- # Promote the picked Done-for-now option's description into a session.recap
3514
- # event so the timeline has a dedicated end-of-session summary row. Bertrand
3515
- # forces session exit before Claude can write a closing message, so this
3516
- # reuses the agent-authored recap that already lives on the option.
3517
- recap="$(printf '%s' "$meta" | jq -r '
3518
- [.questions[]?.options[]? | select(.label == "Done for now") | .description] | first // empty
3519
- ' 2>/dev/null)"
3520
- if [ -n "$recap" ]; then
3521
- bq update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
3522
- fi
3648
+ # Tell on-done.sh this Stop is a legitimate exit, not a dropped AUQ call \u2014
3649
+ # so it pauses normally instead of forcing the loop to continue.
3650
+ touch "${runtimeDir2}/done-$sid"
3523
3651
 
3524
3652
  printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
3525
3653
  fi
@@ -3527,7 +3655,7 @@ fi
3527
3655
  wait
3528
3656
  `;
3529
3657
  }
3530
- function permissionWaitScript(bin, runtimeDir) {
3658
+ function permissionWaitScript(bin, runtimeDir2) {
3531
3659
  return `#!/usr/bin/env bash
3532
3660
  # Hook: PermissionRequest \u2192 mark pending, badge + notify
3533
3661
  ${quietHelper(bin)}
@@ -3541,7 +3669,7 @@ ${EXTRACT_TOOL}
3541
3669
  # Marker tells the PostToolUse hook to emit tool.used with outcome:approved
3542
3670
  # instead of outcome:auto. Without it, every prompted-then-approved tool call
3543
3671
  # would look identical to an auto-approved one.
3544
- touch "${runtimeDir}/perm-pending-$sid"
3672
+ touch "${runtimeDir2}/perm-pending-$sid"
3545
3673
 
3546
3674
  # Badge + notify in background
3547
3675
  bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
@@ -3549,7 +3677,7 @@ bq notify bertrand "Needs permission: $tool" &
3549
3677
  wait
3550
3678
  `;
3551
3679
  }
3552
- function permissionDoneScript(bin, runtimeDir) {
3680
+ function permissionDoneScript(bin, runtimeDir2) {
3553
3681
  return `#!/usr/bin/env bash
3554
3682
  # Hook: PostToolUse (catch-all)
3555
3683
  #
@@ -3569,10 +3697,11 @@ sid="\${BERTRAND_SESSION:-}"
3569
3697
  input="$(cat)"
3570
3698
  ${EXTRACT_TOOL}
3571
3699
 
3572
- # Don't double-log: AskUserQuestion has its own waiting/answered events
3573
- [ "$tool" = "AskUserQuestion" ] && exit 0
3700
+ # Don't double-log: AskUserQuestion has its own waiting/answered events, and
3701
+ # EnterWorktree/ExitWorktree have their own worktree.entered/exited hooks.
3702
+ case "$tool" in AskUserQuestion|EnterWorktree|ExitWorktree) exit 0 ;; esac
3574
3703
 
3575
- marker="${runtimeDir}/perm-pending-$sid"
3704
+ marker="${runtimeDir2}/perm-pending-$sid"
3576
3705
  had_marker=0
3577
3706
  if [ -f "$marker" ]; then
3578
3707
  had_marker=1
@@ -3626,9 +3755,17 @@ bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool"
3626
3755
  wait
3627
3756
  `;
3628
3757
  }
3629
- function userPromptScript(bin, _runtimeDir) {
3758
+ function userPromptScript(bin, runtimeDir2) {
3630
3759
  return `#!/usr/bin/env bash
3631
- # Hook: UserPromptSubmit \u2192 record user free-text prompt
3760
+ # Hook: UserPromptSubmit \u2192 record user prompt + re-inject the session contract.
3761
+ #
3762
+ # The contract normally arrives via --append-system-prompt on bertrand's own
3763
+ # claude spawn, which reaches only that one process. Sessions that inherit the
3764
+ # BERTRAND_* env vars without going through launchClaude (background jobs,
3765
+ # nested \`claude\`, the Warp plugin's own launcher) never receive it. Re-
3766
+ # injecting here \u2014 through the durable env/hook channel \u2014 closes that gap.
3767
+ # Full contract on the first prompt of each conversation, a one-line reminder
3768
+ # thereafter, to keep the per-turn token cost low.
3632
3769
  ${quietHelper(bin)}
3633
3770
  sid="\${BERTRAND_SESSION:-}"
3634
3771
  [ -z "$sid" ] && exit 0
@@ -3636,34 +3773,102 @@ sid="\${BERTRAND_SESSION:-}"
3636
3773
  input="$(cat)"
3637
3774
  cid="\${BERTRAND_CLAUDE_ID:-}"
3638
3775
 
3776
+ # Record the prompt event. Stdout muted so only the context JSON below reaches
3777
+ # the hook's stdout (UserPromptSubmit parses stdout as a hook decision).
3639
3778
  meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
3640
- [ -z "$meta" ] && exit 0
3779
+ [ -n "$meta" ] && bq update --session-id "$sid" --event user.prompt --meta "$meta" >/dev/null
3780
+
3781
+ # Re-deliver the contract as additional context.
3782
+ marker="${runtimeDir2}/contract-sent-\${cid:-$sid}"
3783
+ if [ -f "$marker" ]; then
3784
+ contract="$(bq contract --session-id "$sid" --short)"
3785
+ else
3786
+ contract="$(bq contract --session-id "$sid")"
3787
+ : > "$marker"
3788
+ fi
3641
3789
 
3642
- bq update --session-id "$sid" --event user.prompt --meta "$meta"
3790
+ [ -n "$contract" ] && jq -n --arg c "$contract" '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
3643
3791
  `;
3644
3792
  }
3645
- function doneScript(bin, _runtimeDir) {
3793
+ function doneScript(bin, runtimeDir2) {
3646
3794
  return `#!/usr/bin/env bash
3647
- # Hook: Stop \u2192 flip session status to paused; no event row written.
3648
- # The status flip is driven by EVENT_STATUS_MAP[session.paused]; dispatchHookEvent
3649
- # no longer has a session.paused case, so update.ts flips status without inserting.
3795
+ # Hook: Stop \u2192 enforce AUQ loop, else flip session status to paused.
3650
3796
  ${quietHelper(bin)}
3651
3797
  sid="\${BERTRAND_SESSION:-}"
3652
3798
  [ -z "$sid" ] && exit 0
3653
3799
 
3654
3800
  input="$(cat)"
3655
3801
  cid="\${BERTRAND_CLAUDE_ID:-}"
3656
- bq update --session-id "$sid" --event session.paused
3657
3802
 
3658
- # Final assistant-message read. Dedups against the most-recent AskUQ-time
3659
- # capture, so a Done-for-now exit (no work between AskUQ and Stop) lands zero
3660
- # new events here; intermediate Stops with fresh assistant output do.
3803
+ done_marker="${runtimeDir2}/done-$sid"
3804
+ nudge_marker="${runtimeDir2}/auq-nudge-$sid"
3805
+
3806
+ # Capture the assistant turn either way. Stdout is muted so it can never corrupt
3807
+ # a decision-JSON payload. Dedups against the most-recent AskUQ-time capture, so
3808
+ # a Done-for-now exit lands zero new events; a dropped-AUQ Stop records the
3809
+ # stray turn; intermediate Stops with fresh output land normally.
3661
3810
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3662
3811
  if [ -n "$tpath" ]; then
3663
- bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3812
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" >/dev/null &
3813
+ fi
3814
+
3815
+ if [ ! -f "$done_marker" ]; then
3816
+ # Not a Done-for-now exit \u2192 the turn ended without AskUserQuestion. Force the
3817
+ # loop to continue, up to a small cap so a context where AUQ is genuinely
3818
+ # unavailable can't wedge the session in an endless block/stop cycle. The
3819
+ # counter is reset on every answered AUQ (on-answered.sh), so the cap bounds
3820
+ # consecutive violations, not the whole session.
3821
+ count="$(cat "$nudge_marker" 2>/dev/null)"
3822
+ case "$count" in ''|*[!0-9]*) count=0 ;; esac
3823
+ if [ "$count" -lt 3 ]; then
3824
+ printf '%s' "$((count + 1))" > "$nudge_marker"
3825
+ reason='This is a bertrand session: every turn must end with an AskUserQuestion call (multiSelect:true on every question) that includes a "Done for now" option. You ended a turn without calling AskUserQuestion. Call it now to continue the loop, or \u2014 if the work is finished \u2014 present it so the user can pick "Done for now" to end the session.'
3826
+ wait
3827
+ jq -n --arg r "$reason" '{decision:"block", reason:$r}'
3828
+ exit 0
3829
+ fi
3830
+ # Cap reached \u2014 stop nudging and let the session pause normally.
3664
3831
  fi
3665
3832
 
3833
+ # Terminal path: legitimate Done-for-now exit, or nudge cap exhausted.
3834
+ rm -f "$done_marker" "$nudge_marker"
3835
+ bq update --session-id "$sid" --event session.paused
3666
3836
  bq badge check --color '#58c142' --priority 10
3837
+ wait
3838
+ `;
3839
+ }
3840
+ function enterWorktreeScript(bin, runtimeDir2) {
3841
+ return `#!/usr/bin/env bash
3842
+ # Hook: PostToolUse EnterWorktree \u2192 record worktree path + branch on the session.
3843
+ ${quietHelper(bin)}
3844
+ sid="\${BERTRAND_SESSION:-}"
3845
+ [ -z "$sid" ] && exit 0
3846
+
3847
+ input="$(cat)"
3848
+ cid="\${BERTRAND_CLAUDE_ID:-}"
3849
+
3850
+ path="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
3851
+ [ -z "$path" ] && exit 0
3852
+ branch="$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null)"
3853
+
3854
+ printf '%s' "$path" > "${runtimeDir2}/worktree-$sid"
3855
+
3856
+ bq update --session-id "$sid" --event worktree.entered --meta "$(jq -n --arg p "$path" --arg b "$branch" --arg cid "$cid" '{path:$p, branch:$b, claude_id:$cid}')"
3857
+ `;
3858
+ }
3859
+ function exitWorktreeScript(bin, runtimeDir2) {
3860
+ return `#!/usr/bin/env bash
3861
+ # Hook: PostToolUse ExitWorktree \u2192 clear the session's worktree state.
3862
+ ${quietHelper(bin)}
3863
+ sid="\${BERTRAND_SESSION:-}"
3864
+ [ -z "$sid" ] && exit 0
3865
+
3866
+ cid="\${BERTRAND_CLAUDE_ID:-}"
3867
+ marker="${runtimeDir2}/worktree-$sid"
3868
+ path="$(cat "$marker" 2>/dev/null)"
3869
+ rm -f "$marker"
3870
+
3871
+ bq update --session-id "$sid" --event worktree.exited --meta "$(jq -n --arg p "$path" --arg cid "$cid" '{path:$p, claude_id:$cid}')"
3667
3872
  `;
3668
3873
  }
3669
3874
  var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
@@ -3674,18 +3879,20 @@ var init_scripts = __esm(() => {
3674
3879
  "on-permission-wait.sh": permissionWaitScript,
3675
3880
  "on-permission-done.sh": permissionDoneScript,
3676
3881
  "on-user-prompt.sh": userPromptScript,
3677
- "on-done.sh": doneScript
3882
+ "on-done.sh": doneScript,
3883
+ "on-enter-worktree.sh": enterWorktreeScript,
3884
+ "on-exit-worktree.sh": exitWorktreeScript
3678
3885
  };
3679
3886
  });
3680
3887
 
3681
3888
  // src/hooks/install.ts
3682
3889
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
3683
- import { join as join11 } from "path";
3890
+ import { join as join12 } from "path";
3684
3891
  function installHookScripts(bin) {
3685
3892
  mkdirSync7(paths.hooks, { recursive: true });
3686
3893
  mkdirSync7(paths.runtime, { recursive: true });
3687
3894
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
3688
- const filePath = join11(paths.hooks, filename);
3895
+ const filePath = join12(paths.hooks, filename);
3689
3896
  writeFileSync5(filePath, scriptFn(bin, paths.runtime));
3690
3897
  chmodSync2(filePath, 493);
3691
3898
  }
@@ -3698,7 +3905,7 @@ var init_install = __esm(() => {
3698
3905
 
3699
3906
  // src/hooks/settings.ts
3700
3907
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
3701
- import { join as join12, dirname as dirname4 } from "path";
3908
+ import { join as join13, dirname as dirname4 } from "path";
3702
3909
  import { homedir as homedir3 } from "os";
3703
3910
  function isBertrandGroup(group) {
3704
3911
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -3723,7 +3930,7 @@ function installHookSettings() {
3723
3930
  var SETTINGS_PATH, BERTRAND_HOOKS;
3724
3931
  var init_settings = __esm(() => {
3725
3932
  init_paths();
3726
- SETTINGS_PATH = join12(homedir3(), ".claude", "settings.json");
3933
+ SETTINGS_PATH = join13(homedir3(), ".claude", "settings.json");
3727
3934
  BERTRAND_HOOKS = {
3728
3935
  PreToolUse: [
3729
3936
  {
@@ -3736,6 +3943,14 @@ var init_settings = __esm(() => {
3736
3943
  matcher: "AskUserQuestion",
3737
3944
  hooks: [{ type: "command", command: `${paths.hooks}/on-answered.sh` }]
3738
3945
  },
3946
+ {
3947
+ matcher: "EnterWorktree",
3948
+ hooks: [{ type: "command", command: `${paths.hooks}/on-enter-worktree.sh` }]
3949
+ },
3950
+ {
3951
+ matcher: "ExitWorktree",
3952
+ hooks: [{ type: "command", command: `${paths.hooks}/on-exit-worktree.sh` }]
3953
+ },
3739
3954
  {
3740
3955
  matcher: "",
3741
3956
  hooks: [{ type: "command", command: `${paths.hooks}/on-permission-done.sh` }]
@@ -3764,7 +3979,7 @@ var init_settings = __esm(() => {
3764
3979
 
3765
3980
  // src/lib/completions.ts
3766
3981
  import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
3767
- import { join as join13 } from "path";
3982
+ import { join as join14 } from "path";
3768
3983
  function bashCompletion() {
3769
3984
  return `# bertrand bash completion
3770
3985
  _bertrand() {
@@ -3794,11 +4009,11 @@ function fishCompletion() {
3794
4009
  `;
3795
4010
  }
3796
4011
  function generateCompletions() {
3797
- const dir = join13(paths.root, "completions");
4012
+ const dir = join14(paths.root, "completions");
3798
4013
  mkdirSync9(dir, { recursive: true });
3799
- writeFileSync7(join13(dir, "bertrand.bash"), bashCompletion());
3800
- writeFileSync7(join13(dir, "_bertrand"), zshCompletion());
3801
- writeFileSync7(join13(dir, "bertrand.fish"), fishCompletion());
4014
+ writeFileSync7(join14(dir, "bertrand.bash"), bashCompletion());
4015
+ writeFileSync7(join14(dir, "_bertrand"), zshCompletion());
4016
+ writeFileSync7(join14(dir, "bertrand.fish"), fishCompletion());
3802
4017
  console.log(`Shell completions written to ${dir}`);
3803
4018
  console.log(" Add to your shell config:");
3804
4019
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -3829,7 +4044,7 @@ var init_completions = __esm(() => {
3829
4044
  var exports_init = {};
3830
4045
  import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
3831
4046
  import { execSync as execSync2 } from "child_process";
3832
- import { join as join14 } from "path";
4047
+ import { join as join15 } from "path";
3833
4048
  function detectTerminal() {
3834
4049
  try {
3835
4050
  execSync2("which wsh", { stdio: "ignore" });
@@ -3844,8 +4059,8 @@ function resolveBin() {
3844
4059
  return onPath;
3845
4060
  const entry = process.argv[1];
3846
4061
  if (entry && SOURCE_ENTRY.test(entry)) {
3847
- const launcherDir = join14(paths.root, "bin");
3848
- const launcher = join14(launcherDir, "bertrand-dev");
4062
+ const launcherDir = join15(paths.root, "bin");
4063
+ const launcher = join15(launcherDir, "bertrand-dev");
3849
4064
  mkdirSync10(launcherDir, { recursive: true });
3850
4065
  writeFileSync8(launcher, `#!/usr/bin/env bash
3851
4066
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
@@ -4054,8 +4269,9 @@ function extractSummary(row) {
4054
4269
  }
4055
4270
  case "user.prompt":
4056
4271
  return meta.prompt ?? "";
4057
- case "session.recap":
4058
- return meta.recap ?? "";
4272
+ case "worktree.entered":
4273
+ case "worktree.exited":
4274
+ return meta.branch ?? meta.path ?? "";
4059
4275
  default:
4060
4276
  return "";
4061
4277
  }
@@ -4091,9 +4307,9 @@ var init_catalog = __esm(() => {
4091
4307
  "user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
4092
4308
  "tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: false },
4093
4309
  "tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
4094
- "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
4095
4310
  "assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
4096
- "assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false }
4311
+ "worktree.entered": { label: "worktree entered", category: "lifecycle", color: 78, detailColor: 245, skip: false },
4312
+ "worktree.exited": { label: "worktree exited", category: "lifecycle", color: 245, detailColor: 245, skip: false }
4097
4313
  };
4098
4314
  DEFAULT_INFO = {
4099
4315
  label: "unknown",
@@ -4400,13 +4616,11 @@ var init_log = __esm(() => {
4400
4616
  init_router();
4401
4617
  init_sessions();
4402
4618
  init_events();
4403
- init_categories();
4404
4619
  init_stats();
4405
4620
  init_conversations();
4406
4621
  init_catalog();
4407
4622
  init_compact();
4408
4623
  init_timing();
4409
- init_parse_session_name();
4410
4624
  init_format();
4411
4625
  init_resolve();
4412
4626
  init_cli_flag();
@@ -4426,18 +4640,12 @@ var init_log = __esm(() => {
4426
4640
  showAllSessions();
4427
4641
  return;
4428
4642
  }
4429
- const { categoryPath, slug } = parseSessionName(target);
4430
- const category = getCategoryByPath(categoryPath);
4431
- if (!category) {
4432
- console.error(`Category not found: ${categoryPath}`);
4433
- process.exit(1);
4434
- }
4435
- const session = getSessionByCategorySlug(category.id, slug);
4436
- if (!session) {
4643
+ const resolved = resolveSessionByName(target);
4644
+ if (!resolved) {
4437
4645
  console.error(`Session not found: ${target}`);
4438
4646
  process.exit(1);
4439
4647
  }
4440
- showSessionLog(session, `${categoryPath}/${slug}`, isJson);
4648
+ showSessionLog(resolved.session, `${resolved.categoryPath}/${resolved.slug}`, isJson);
4441
4649
  });
4442
4650
  });
4443
4651
 
@@ -4557,7 +4765,6 @@ var init_stats2 = __esm(() => {
4557
4765
  init_events();
4558
4766
  init_timing();
4559
4767
  init_format();
4560
- init_parse_session_name();
4561
4768
  ACTIVE_STATUSES2 = ["active", "waiting"];
4562
4769
  register("stats", async (args) => {
4563
4770
  const isJson = args.includes("--json");
@@ -4570,29 +4777,23 @@ var init_stats2 = __esm(() => {
4570
4777
  return;
4571
4778
  }
4572
4779
  if (target.endsWith("/")) {
4573
- const categoryPath2 = target.replace(/\/+$/, "");
4574
- const category2 = getCategoryByPath(categoryPath2);
4575
- if (!category2) {
4576
- console.error(`Category not found: ${categoryPath2}`);
4780
+ const categoryPath = target.replace(/\/+$/, "");
4781
+ const category = getCategoryByPath(categoryPath);
4782
+ if (!category) {
4783
+ console.error(`Category not found: ${categoryPath}`);
4577
4784
  process.exit(1);
4578
4785
  }
4579
- const categorySessions = getSessionsByCategory(category2.id);
4786
+ const categorySessions = getSessionsByCategory(category.id);
4580
4787
  const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
4581
- renderCategory(metrics, categoryPath2, isJson);
4788
+ renderCategory(metrics, categoryPath, isJson);
4582
4789
  return;
4583
4790
  }
4584
- const { categoryPath, slug } = parseSessionName(target);
4585
- const category = getCategoryByPath(categoryPath);
4586
- if (!category) {
4587
- console.error(`Category not found: ${categoryPath}`);
4588
- process.exit(1);
4589
- }
4590
- const session = getSessionByCategorySlug(category.id, slug);
4591
- if (!session) {
4791
+ const resolved = resolveSessionByName(target);
4792
+ if (!resolved) {
4592
4793
  console.error(`Session not found: ${target}`);
4593
4794
  process.exit(1);
4594
4795
  }
4595
- const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
4796
+ const m = getMetrics(resolved.session.id, `${resolved.categoryPath}/${resolved.slug}`, resolved.session.status);
4596
4797
  renderSession(m, isJson);
4597
4798
  });
4598
4799
  });
@@ -4618,24 +4819,16 @@ var init_backfill_stats = __esm(() => {
4618
4819
  // src/cli/commands/archive.ts
4619
4820
  var exports_archive = {};
4620
4821
  function resolveSession(name) {
4621
- const { categoryPath, slug } = parseSessionName(name);
4622
- const category = getCategoryByPath(categoryPath);
4623
- if (!category) {
4624
- console.error(`Category not found: ${categoryPath}`);
4625
- process.exit(1);
4626
- }
4627
- const session = getSessionByCategorySlug(category.id, slug);
4628
- if (!session) {
4822
+ const resolved = resolveSessionByName(name);
4823
+ if (!resolved) {
4629
4824
  console.error(`Session not found: ${name}`);
4630
4825
  process.exit(1);
4631
4826
  }
4632
- return { session, categoryPath };
4827
+ return { session: resolved.session, categoryPath: resolved.categoryPath };
4633
4828
  }
4634
4829
  var init_archive = __esm(() => {
4635
4830
  init_router();
4636
4831
  init_sessions();
4637
- init_categories();
4638
- init_parse_session_name();
4639
4832
  init_session_archive();
4640
4833
  register("archive", async (args) => {
4641
4834
  const isUndo = args.includes("--undo");
@@ -4707,7 +4900,7 @@ __export(exports_project, {
4707
4900
  createSubcommand: () => createSubcommand,
4708
4901
  _UsageError: () => _UsageError
4709
4902
  });
4710
- import { existsSync as existsSync9, rmSync } from "fs";
4903
+ import { existsSync as existsSync9, rmSync as rmSync2 } from "fs";
4711
4904
  function countSessions(slug) {
4712
4905
  const dbFile = projectPaths(slug).db;
4713
4906
  if (!existsSync9(dbFile))
@@ -4887,7 +5080,7 @@ function removeSubcommand(args) {
4887
5080
  removeProject(slug);
4888
5081
  invalidateDbCache(slug);
4889
5082
  if (purge) {
4890
- rmSync(projectPaths(slug).root, { recursive: true, force: true });
5083
+ rmSync2(projectPaths(slug).root, { recursive: true, force: true });
4891
5084
  }
4892
5085
  console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
4893
5086
  }
@@ -4997,9 +5190,11 @@ var command = process.argv[2];
4997
5190
  var hotPath = {
4998
5191
  update: () => Promise.resolve().then(() => (init_update(), exports_update)),
4999
5192
  "assistant-message": () => Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
5193
+ contract: () => Promise.resolve().then(() => (init_contract(), exports_contract)),
5000
5194
  badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
5001
5195
  notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
5002
5196
  serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
5197
+ "ensure-server": () => Promise.resolve().then(() => (init_ensure_server(), exports_ensure_server)),
5003
5198
  sync: () => Promise.resolve().then(() => (init_sync(), exports_sync))
5004
5199
  };
5005
5200
  if (command && command in hotPath) {
@@ -5015,6 +5210,7 @@ if (command && command in hotPath) {
5015
5210
  Promise.resolve().then(() => (init_archive(), exports_archive)),
5016
5211
  Promise.resolve().then(() => (init_update(), exports_update)),
5017
5212
  Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
5213
+ Promise.resolve().then(() => (init_contract(), exports_contract)),
5018
5214
  Promise.resolve().then(() => (init_serve(), exports_serve)),
5019
5215
  Promise.resolve().then(() => (init_badge(), exports_badge)),
5020
5216
  Promise.resolve().then(() => (init_notify(), exports_notify)),