bertrand 0.22.1 → 0.23.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
@@ -599,9 +599,8 @@ var init_router = __esm(() => {
599
599
  aliases = new Map;
600
600
  HOOK_COMMANDS = new Set([
601
601
  "update",
602
- "snapshot",
603
- "recap-thinking",
604
602
  "assistant-message",
603
+ "contract",
605
604
  "notify",
606
605
  "badge"
607
606
  ]);
@@ -610,7 +609,6 @@ var init_router = __esm(() => {
610
609
  // src/db/schema.ts
611
610
  var exports_schema = {};
612
611
  __export(exports_schema, {
613
- worktreeAssociations: () => worktreeAssociations,
614
612
  sessions: () => sessions,
615
613
  sessionStats: () => sessionStats,
616
614
  sessionLabels: () => sessionLabels,
@@ -621,7 +619,7 @@ __export(exports_schema, {
621
619
  });
622
620
  import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
623
621
  import { sql } from "drizzle-orm";
624
- var categories, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
622
+ var categories, labels, sessions, sessionLabels, conversations, events, sessionStats;
625
623
  var init_schema = __esm(() => {
626
624
  categories = sqliteTable("categories", {
627
625
  id: text("id").primaryKey(),
@@ -657,6 +655,8 @@ var init_schema = __esm(() => {
657
655
  pid: integer("pid"),
658
656
  startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
659
657
  endedAt: text("ended_at"),
658
+ worktreePath: text("worktree_path"),
659
+ worktreeBranch: text("worktree_branch"),
660
660
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
661
661
  updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
662
662
  }, (t) => [
@@ -677,7 +677,6 @@ var init_schema = __esm(() => {
677
677
  startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
678
678
  endedAt: text("ended_at"),
679
679
  discarded: integer("discarded", { mode: "boolean" }).notNull().default(false),
680
- lastQuestion: text("last_question"),
681
680
  eventCount: integer("event_count").notNull().default(0)
682
681
  }, (t) => [index("conv_session").on(t.sessionId)]);
683
682
  events = sqliteTable("events", {
@@ -694,24 +693,11 @@ var init_schema = __esm(() => {
694
693
  index("ev_event_created").on(t.event, t.createdAt),
695
694
  index("ev_conversation").on(t.conversationId)
696
695
  ]);
697
- worktreeAssociations = sqliteTable("worktree_associations", {
698
- id: text("id").primaryKey(),
699
- sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
700
- branch: text("branch").notNull(),
701
- worktreePath: text("worktree_path"),
702
- active: integer("active", { mode: "boolean" }).notNull().default(true),
703
- enteredAt: text("entered_at").notNull().default(sql`(datetime('now'))`),
704
- exitedAt: text("exited_at")
705
- }, (t) => [
706
- index("wt_session").on(t.sessionId),
707
- index("wt_active").on(t.active)
708
- ]);
709
696
  sessionStats = sqliteTable("session_stats", {
710
697
  sessionId: text("session_id").primaryKey().references(() => sessions.id, { onDelete: "cascade" }),
711
698
  eventCount: integer("event_count").notNull().default(0),
712
699
  conversationCount: integer("conversation_count").notNull().default(0),
713
700
  interactionCount: integer("interaction_count").notNull().default(0),
714
- prCount: integer("pr_count").notNull().default(0),
715
701
  claudeWorkS: integer("claude_work_s").notNull().default(0),
716
702
  userWaitS: integer("user_wait_s").notNull().default(0),
717
703
  activePct: integer("active_pct").notNull().default(0),
@@ -799,28 +785,122 @@ function createId(size = 12) {
799
785
  }
800
786
  var init_id = () => {};
801
787
 
788
+ // src/db/queries/categories.ts
789
+ import { eq, like, or, isNull } from "drizzle-orm";
790
+ function createCategory(opts) {
791
+ const db = getDb();
792
+ const id = createId();
793
+ let path = opts.slug;
794
+ let depth = 0;
795
+ if (opts.parentId) {
796
+ const parent = db.select().from(categories).where(eq(categories.id, opts.parentId)).get();
797
+ if (!parent)
798
+ throw new Error(`Parent category ${opts.parentId} not found`);
799
+ path = `${parent.path}/${opts.slug}`;
800
+ depth = parent.depth + 1;
801
+ }
802
+ return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
803
+ }
804
+ function getCategory(id) {
805
+ return getDb().select().from(categories).where(eq(categories.id, id)).get();
806
+ }
807
+ function getCategoryByPath(path) {
808
+ return getDb().select().from(categories).where(eq(categories.path, path)).get();
809
+ }
810
+ function getOrCreateCategoryPath(path) {
811
+ const existing = getCategoryByPath(path);
812
+ if (existing)
813
+ return existing.id;
814
+ const segments = path.split("/");
815
+ let parentId;
816
+ for (let i = 0;i < segments.length; i++) {
817
+ const partialPath = segments.slice(0, i + 1).join("/");
818
+ const category = getCategoryByPath(partialPath);
819
+ if (category) {
820
+ parentId = category.id;
821
+ } else {
822
+ const slug = segments[i];
823
+ const created = createCategory({ slug, name: slug, parentId });
824
+ parentId = created.id;
825
+ }
826
+ }
827
+ return parentId;
828
+ }
829
+ var init_categories = __esm(() => {
830
+ init_client();
831
+ init_schema();
832
+ init_id();
833
+ });
834
+
835
+ // src/lib/parse-session-name.ts
836
+ function parseSessionName(input) {
837
+ const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
838
+ if (!trimmed) {
839
+ throw new Error("Session name cannot be empty");
840
+ }
841
+ const segments = trimmed.split("/").filter(Boolean);
842
+ if (segments.length < 2) {
843
+ throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
844
+ }
845
+ for (const segment of segments) {
846
+ if (!SEGMENT_PATTERN.test(segment)) {
847
+ throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
848
+ }
849
+ }
850
+ const categoryPath = segments[0];
851
+ const slug = segments.slice(1).join("/");
852
+ return { categoryPath, slug };
853
+ }
854
+ var SEGMENT_PATTERN;
855
+ var init_parse_session_name = __esm(() => {
856
+ SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
857
+ });
858
+
802
859
  // src/db/queries/sessions.ts
803
- import { eq, and, inArray, sql as sql2 } from "drizzle-orm";
860
+ import { eq as eq2, and, inArray, sql as sql2 } from "drizzle-orm";
861
+ function resolveSessionByName(name) {
862
+ const flat = parseSessionName(name);
863
+ const flatCategory = getCategoryByPath(flat.categoryPath);
864
+ if (flatCategory) {
865
+ const session = getSessionByCategorySlug(flatCategory.id, flat.slug);
866
+ if (session) {
867
+ return { session, categoryPath: flat.categoryPath, slug: flat.slug };
868
+ }
869
+ }
870
+ const segments = name.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
871
+ if (segments.length >= 3) {
872
+ const legacyCategoryPath = segments.slice(0, -1).join("/");
873
+ const legacySlug = segments[segments.length - 1];
874
+ const legacyCategory = getCategoryByPath(legacyCategoryPath);
875
+ if (legacyCategory) {
876
+ const session = getSessionByCategorySlug(legacyCategory.id, legacySlug);
877
+ if (session) {
878
+ return { session, categoryPath: legacyCategoryPath, slug: legacySlug };
879
+ }
880
+ }
881
+ }
882
+ return;
883
+ }
804
884
  function createSession(opts) {
805
885
  const db = getDb();
806
886
  const id = createId();
807
887
  return db.insert(sessions).values({ id, ...opts }).returning().get();
808
888
  }
809
889
  function getSession(id) {
810
- return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
890
+ return getDb().select().from(sessions).where(eq2(sessions.id, id)).get();
811
891
  }
812
892
  function getSessionByCategorySlug(categoryId, slug) {
813
- return getDb().select().from(sessions).where(and(eq(sessions.categoryId, categoryId), eq(sessions.slug, slug))).get();
893
+ return getDb().select().from(sessions).where(and(eq2(sessions.categoryId, categoryId), eq2(sessions.slug, slug))).get();
814
894
  }
815
895
  function getSessionsByCategory(categoryId) {
816
- return getDb().select().from(sessions).where(eq(sessions.categoryId, categoryId)).all();
896
+ return getDb().select().from(sessions).where(eq2(sessions.categoryId, categoryId)).all();
817
897
  }
818
898
  function getActiveSessions() {
819
- return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
899
+ return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
820
900
  }
821
901
  function getAllSessions(opts) {
822
902
  const db = getDb();
823
- const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id));
903
+ const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id));
824
904
  if (opts?.excludeArchived) {
825
905
  return query.where(inArray(sessions.status, [
826
906
  "active",
@@ -831,36 +911,35 @@ function getAllSessions(opts) {
831
911
  return query.all();
832
912
  }
833
913
  function updateSessionStatus(id, status) {
834
- return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq(sessions.id, id)).returning().get();
914
+ return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
835
915
  }
836
916
  function updateSession(id, data) {
837
- return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(eq(sessions.id, id)).returning().get();
917
+ return getDb().update(sessions).set({ ...data, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
838
918
  }
839
919
  function deleteSession(id) {
840
- return getDb().delete(sessions).where(eq(sessions.id, id)).run();
920
+ return getDb().delete(sessions).where(eq2(sessions.id, id)).run();
841
921
  }
842
922
  var init_sessions = __esm(() => {
843
923
  init_client();
844
924
  init_schema();
845
925
  init_id();
926
+ init_categories();
927
+ init_parse_session_name();
846
928
  });
847
929
 
848
930
  // src/db/queries/conversations.ts
849
- import { eq as eq2, and as and2, desc, sql as sql3 } from "drizzle-orm";
931
+ import { eq as eq3, and as and2, desc, sql as sql3 } from "drizzle-orm";
850
932
  function createConversation(opts) {
851
933
  return getDb().insert(conversations).values(opts).returning().get();
852
934
  }
853
935
  function getConversation(id) {
854
- return getDb().select().from(conversations).where(eq2(conversations.id, id)).get();
936
+ return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
855
937
  }
856
938
  function getConversationsBySession(sessionId) {
857
- return getDb().select().from(conversations).where(and2(eq2(conversations.sessionId, sessionId), eq2(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
939
+ return getDb().select().from(conversations).where(and2(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
858
940
  }
859
941
  function endConversation(id) {
860
- return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq2(conversations.id, id)).returning().get();
861
- }
862
- function updateLastQuestion(id, question) {
863
- return getDb().update(conversations).set({ lastQuestion: question }).where(eq2(conversations.id, id)).returning().get();
942
+ return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
864
943
  }
865
944
  var init_conversations = __esm(() => {
866
945
  init_client();
@@ -916,7 +995,7 @@ function normalizeAnsweredMeta(meta) {
916
995
  }
917
996
 
918
997
  // src/db/queries/events.ts
919
- import { eq as eq3, and as and3, desc as desc2 } from "drizzle-orm";
998
+ import { eq as eq4, and as and3, desc as desc2 } from "drizzle-orm";
920
999
  function insertEvent(opts) {
921
1000
  return getDb().insert(events).values({
922
1001
  sessionId: opts.sessionId,
@@ -927,18 +1006,18 @@ function insertEvent(opts) {
927
1006
  }).returning().get();
928
1007
  }
929
1008
  function getEventsBySession(sessionId) {
930
- return getDb().select().from(events).where(eq3(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
1009
+ return getDb().select().from(events).where(eq4(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
931
1010
  }
932
1011
  function getEventsByType(sessionId, eventType) {
933
- return getDb().select().from(events).where(and3(eq3(events.sessionId, sessionId), eq3(events.event, eventType))).orderBy(events.createdAt).all();
1012
+ return getDb().select().from(events).where(and3(eq4(events.sessionId, sessionId), eq4(events.event, eventType))).orderBy(events.createdAt).all();
934
1013
  }
935
1014
  function getLatestEventOfType(sessionId, eventType, conversationId) {
936
1015
  const conditions = [
937
- eq3(events.sessionId, sessionId),
938
- eq3(events.event, eventType)
1016
+ eq4(events.sessionId, sessionId),
1017
+ eq4(events.event, eventType)
939
1018
  ];
940
1019
  if (conversationId) {
941
- conditions.push(eq3(events.conversationId, conversationId));
1020
+ conditions.push(eq4(events.conversationId, conversationId));
942
1021
  }
943
1022
  return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
944
1023
  }
@@ -947,7 +1026,7 @@ function getLatestRecaps() {
947
1026
  sessionId: events.sessionId,
948
1027
  meta: events.meta,
949
1028
  createdAt: events.createdAt
950
- }).from(events).where(eq3(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
1029
+ }).from(events).where(eq4(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
951
1030
  const result = {};
952
1031
  for (const row of rows) {
953
1032
  if (result[row.sessionId])
@@ -966,50 +1045,6 @@ var init_events = __esm(() => {
966
1045
  });
967
1046
 
968
1047
  // src/db/events/emit.ts
969
- function emitSessionStarted(args) {
970
- return insertEvent({
971
- sessionId: args.sessionId,
972
- conversationId: args.conversationId,
973
- event: "session.started",
974
- meta: {
975
- category_path: args.categoryPath,
976
- session_name: args.sessionName,
977
- session_slug: args.sessionSlug,
978
- labels: args.labels,
979
- summary: args.summary ?? null
980
- }
981
- });
982
- }
983
- function emitSessionResumed(args) {
984
- return insertEvent({
985
- sessionId: args.sessionId,
986
- conversationId: args.conversationId,
987
- event: "session.resumed",
988
- meta: { claude_id: args.conversationId }
989
- });
990
- }
991
- function emitSessionPaused(args) {
992
- return insertEvent({
993
- sessionId: args.sessionId,
994
- conversationId: args.conversationId,
995
- event: "session.paused",
996
- meta: { claude_id: args.conversationId }
997
- });
998
- }
999
- function emitSessionPausedByRecovery(args) {
1000
- return insertEvent({
1001
- sessionId: args.sessionId,
1002
- event: "session.paused",
1003
- summary: "Recovered from stale state (process not found)",
1004
- meta: { stale_pid: args.stalePid }
1005
- });
1006
- }
1007
- function emitSessionEnded(args) {
1008
- return insertEvent({
1009
- sessionId: args.sessionId,
1010
- event: "session.end"
1011
- });
1012
- }
1013
1048
  function emitClaudeStarted(args) {
1014
1049
  return insertEvent({
1015
1050
  sessionId: args.sessionId,
@@ -1017,9 +1052,6 @@ function emitClaudeStarted(args) {
1017
1052
  event: "claude.started",
1018
1053
  meta: {
1019
1054
  claude_id: args.conversationId,
1020
- model: args.model,
1021
- claude_version: args.claudeVersion,
1022
- git: args.git,
1023
1055
  cwd: args.cwd
1024
1056
  }
1025
1057
  });
@@ -1073,31 +1105,6 @@ function emitSessionRecap(args) {
1073
1105
  meta: { recap: args.recap, claude_id: args.conversationId }
1074
1106
  });
1075
1107
  }
1076
- function emitPermissionRequested(args) {
1077
- return insertEvent({
1078
- sessionId: args.sessionId,
1079
- conversationId: args.conversationId,
1080
- event: "permission.request",
1081
- meta: {
1082
- tool: args.tool,
1083
- detail: args.detail,
1084
- claude_id: args.conversationId
1085
- }
1086
- });
1087
- }
1088
- function emitPermissionResolved(args) {
1089
- return insertEvent({
1090
- sessionId: args.sessionId,
1091
- conversationId: args.conversationId,
1092
- event: "permission.resolve",
1093
- meta: {
1094
- tool: args.tool,
1095
- detail: args.detail,
1096
- outcome: args.outcome,
1097
- claude_id: args.conversationId
1098
- }
1099
- });
1100
- }
1101
1108
  function emitToolUsed(args) {
1102
1109
  const summary = formatToolSummary(args.tool, args.detail);
1103
1110
  return insertEvent({
@@ -1171,21 +1178,22 @@ function emitAssistantRecap(args) {
1171
1178
  meta: { recap: args.recap, claude_id: args.conversationId }
1172
1179
  });
1173
1180
  }
1174
- function emitContextSnapshot(args) {
1181
+ function emitWorktreeEntered(args) {
1175
1182
  return insertEvent({
1176
1183
  sessionId: args.sessionId,
1177
1184
  conversationId: args.conversationId,
1178
- event: "context.snapshot",
1179
- summary: `${args.remainingPct}% remaining`,
1180
- meta: {
1181
- model: args.model,
1182
- input_tokens: String(args.inputTokens),
1183
- cache_creation_tokens: String(args.cacheCreationTokens),
1184
- cache_read_tokens: String(args.cacheReadTokens),
1185
- context_window_tokens: String(args.totalContextTokens),
1186
- remaining_pct: String(args.remainingPct),
1187
- claude_id: args.conversationId
1188
- }
1185
+ event: "worktree.entered",
1186
+ summary: args.branch ? `entered worktree ${args.branch}` : "entered worktree",
1187
+ meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
1188
+ });
1189
+ }
1190
+ function emitWorktreeExited(args) {
1191
+ return insertEvent({
1192
+ sessionId: args.sessionId,
1193
+ conversationId: args.conversationId,
1194
+ event: "worktree.exited",
1195
+ summary: args.branch ? `exited worktree ${args.branch}` : "exited worktree",
1196
+ meta: { path: args.path, branch: args.branch, claude_id: args.conversationId }
1189
1197
  });
1190
1198
  }
1191
1199
  var init_emit = __esm(() => {
@@ -1195,7 +1203,8 @@ var init_emit = __esm(() => {
1195
1203
  // src/cli/commands/update.ts
1196
1204
  var exports_update = {};
1197
1205
  __export(exports_update, {
1198
- shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
1206
+ shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip,
1207
+ dispatchHookEvent: () => dispatchHookEvent
1199
1208
  });
1200
1209
  function shouldIgnoreStatusFlip(newStatus, sessionPid) {
1201
1210
  if (!newStatus)
@@ -1237,26 +1246,6 @@ function dispatchHookEvent(event, ctx) {
1237
1246
  recap: String(meta.recap ?? "")
1238
1247
  });
1239
1248
  return true;
1240
- case "session.paused":
1241
- emitSessionPaused({ sessionId, conversationId });
1242
- return true;
1243
- case "permission.request":
1244
- emitPermissionRequested({
1245
- sessionId,
1246
- conversationId,
1247
- tool: String(meta.tool ?? ""),
1248
- detail: String(meta.detail ?? "")
1249
- });
1250
- return true;
1251
- case "permission.resolve":
1252
- emitPermissionResolved({
1253
- sessionId,
1254
- conversationId,
1255
- tool: String(meta.tool ?? ""),
1256
- detail: String(meta.detail ?? ""),
1257
- outcome: meta.outcome === "denied" ? "denied" : "approved"
1258
- });
1259
- return true;
1260
1249
  case "tool.applied":
1261
1250
  emitToolApplied({
1262
1251
  sessionId,
@@ -1274,6 +1263,22 @@ function dispatchHookEvent(event, ctx) {
1274
1263
  outcome: meta.outcome === "approved" ? "approved" : "auto"
1275
1264
  });
1276
1265
  return true;
1266
+ case "worktree.entered": {
1267
+ const path = String(meta.path ?? "");
1268
+ const branch = meta.branch ? String(meta.branch) : undefined;
1269
+ emitWorktreeEntered({ sessionId, conversationId, path, branch });
1270
+ updateSession(sessionId, {
1271
+ worktreePath: path || null,
1272
+ worktreeBranch: branch ?? null
1273
+ });
1274
+ return true;
1275
+ }
1276
+ case "worktree.exited": {
1277
+ const path = meta.path ? String(meta.path) : undefined;
1278
+ emitWorktreeExited({ sessionId, conversationId, path });
1279
+ updateSession(sessionId, { worktreePath: null, worktreeBranch: null });
1280
+ return true;
1281
+ }
1277
1282
  default:
1278
1283
  return false;
1279
1284
  }
@@ -1283,15 +1288,11 @@ var init_update = __esm(() => {
1283
1288
  init_router();
1284
1289
  init_sessions();
1285
1290
  init_conversations();
1286
- init_trigger();
1287
1291
  init_emit();
1288
1292
  EVENT_STATUS_MAP = {
1289
1293
  "session.waiting": "waiting",
1290
1294
  "session.answered": "active",
1291
- "session.active": "active",
1292
- "session.paused": "paused",
1293
- "session.started": "active",
1294
- "session.end": "paused"
1295
+ "session.paused": "paused"
1295
1296
  };
1296
1297
  register("update", async (args) => {
1297
1298
  let sessionId = "";
@@ -1340,24 +1341,15 @@ var init_update = __esm(() => {
1340
1341
  }
1341
1342
  const rawConvoId = meta?.claude_id || process.env.BERTRAND_CLAUDE_ID || undefined;
1342
1343
  const conversationId = rawConvoId && getConversation(rawConvoId) ? rawConvoId : undefined;
1343
- const dispatched = dispatchHookEvent(event, {
1344
+ dispatchHookEvent(event, {
1344
1345
  sessionId,
1345
1346
  conversationId,
1346
1347
  meta,
1347
1348
  summary: summaryArg
1348
1349
  });
1349
- if (!dispatched) {
1350
- return;
1351
- }
1352
1350
  if (newStatus && !ignoreStatusFlip) {
1353
1351
  updateSessionStatus(sessionId, newStatus);
1354
1352
  }
1355
- if (event === "session.waiting" && conversationId && meta?.question) {
1356
- updateLastQuestion(conversationId, meta.question);
1357
- }
1358
- if (event === "session.end") {
1359
- triggerBackgroundPush();
1360
- }
1361
1353
  });
1362
1354
  });
1363
1355
 
@@ -1365,13 +1357,6 @@ var init_update = __esm(() => {
1365
1357
  import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
1366
1358
  import { homedir as homedir2 } from "os";
1367
1359
  import { join as join6 } from "path";
1368
- function getContextWindowSize(model) {
1369
- for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
1370
- if (model.startsWith(prefix))
1371
- return size;
1372
- }
1373
- return 200000;
1374
- }
1375
1360
  function claudeTranscriptPath(sessionId, cwd) {
1376
1361
  const dir = (cwd ?? process.cwd()).replace(/\//g, "-");
1377
1362
  return join6(homedir2(), ".claude", "projects", dir, `${sessionId}.jsonl`);
@@ -1439,108 +1424,7 @@ function getLatestAssistantTurn(filePath) {
1439
1424
  thinkingBytes
1440
1425
  };
1441
1426
  }
1442
- function getContextSnapshot(filePath) {
1443
- if (!existsSync5(filePath))
1444
- return null;
1445
- const text2 = readFileSync4(filePath, "utf-8");
1446
- const lines = text2.split(`
1447
- `);
1448
- for (let i = lines.length - 1;i >= 0; i--) {
1449
- const line = lines[i];
1450
- if (!line)
1451
- continue;
1452
- let entry;
1453
- try {
1454
- entry = JSON.parse(line);
1455
- } catch {
1456
- continue;
1457
- }
1458
- if (entry.type !== "assistant")
1459
- continue;
1460
- const message = entry.message;
1461
- const usage = message?.usage;
1462
- if (!usage)
1463
- continue;
1464
- const model = message?.model ?? "";
1465
- const inputTokens = usage.input_tokens ?? 0;
1466
- const outputTokens = usage.output_tokens ?? 0;
1467
- const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
1468
- const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
1469
- const totalContextTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
1470
- const windowSize = getContextWindowSize(model);
1471
- const remainingPct = Math.max(0, Math.min(100, Math.round(100 - totalContextTokens * 100 / windowSize)));
1472
- return {
1473
- model,
1474
- inputTokens,
1475
- outputTokens,
1476
- cacheCreationTokens,
1477
- cacheReadTokens,
1478
- totalContextTokens,
1479
- remainingPct
1480
- };
1481
- }
1482
- return null;
1483
- }
1484
- var CONTEXT_WINDOW_SIZES;
1485
- var init_transcript = __esm(() => {
1486
- CONTEXT_WINDOW_SIZES = {
1487
- "claude-opus-4": 1e6,
1488
- "claude-sonnet-4": 200000,
1489
- "claude-haiku-4": 200000
1490
- };
1491
- });
1492
-
1493
- // src/cli/commands/snapshot.ts
1494
- var exports_snapshot = {};
1495
- var init_snapshot = __esm(() => {
1496
- init_router();
1497
- init_sessions();
1498
- init_conversations();
1499
- init_emit();
1500
- init_transcript();
1501
- register("snapshot", async (args) => {
1502
- let sessionId = "";
1503
- let transcriptPath = "";
1504
- let conversationId = "";
1505
- for (let i = 0;i < args.length; i++) {
1506
- const arg = args[i];
1507
- const next = args[i + 1];
1508
- if (arg === "--session-id" && next) {
1509
- sessionId = next;
1510
- i++;
1511
- } else if (arg === "--transcript-path" && next) {
1512
- transcriptPath = next;
1513
- i++;
1514
- } else if (arg === "--conversation-id" && next) {
1515
- conversationId = next;
1516
- i++;
1517
- }
1518
- }
1519
- if (!sessionId || !transcriptPath) {
1520
- console.error("Usage: bertrand snapshot --session-id <id> --transcript-path <path> [--conversation-id <id>]");
1521
- process.exit(1);
1522
- }
1523
- const session = getSession(sessionId);
1524
- if (!session) {
1525
- console.error(`Session not found: ${sessionId}`);
1526
- process.exit(1);
1527
- }
1528
- const snapshot = getContextSnapshot(transcriptPath);
1529
- if (!snapshot)
1530
- return;
1531
- const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
1532
- emitContextSnapshot({
1533
- sessionId,
1534
- conversationId: convoId,
1535
- model: snapshot.model,
1536
- inputTokens: snapshot.inputTokens,
1537
- cacheCreationTokens: snapshot.cacheCreationTokens,
1538
- cacheReadTokens: snapshot.cacheReadTokens,
1539
- totalContextTokens: snapshot.totalContextTokens,
1540
- remainingPct: snapshot.remainingPct
1541
- });
1542
- });
1543
- });
1427
+ var init_transcript = () => {};
1544
1428
 
1545
1429
  // src/cli/commands/assistant-message.ts
1546
1430
  var exports_assistant_message = {};
@@ -1624,53 +1508,153 @@ var init_assistant_message = __esm(() => {
1624
1508
  });
1625
1509
  });
1626
1510
 
1627
- // src/cli/commands/recap-thinking.ts
1628
- var exports_recap_thinking = {};
1629
- var RECAP_RE2;
1630
- var init_recap_thinking = __esm(() => {
1511
+ // src/contract/template.md
1512
+ var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
1513
+
1514
+ 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.
1515
+
1516
+ 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.
1517
+
1518
+ 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.
1519
+ `;
1520
+ var init_template = () => {};
1521
+
1522
+ // src/contract/template.ts
1523
+ function buildContract(sessionName, ...contextLayers) {
1524
+ const base = template_default.replace("{sessionName}", sessionName);
1525
+ const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
1526
+ if (layers.length === 0)
1527
+ return base;
1528
+ return base + `
1529
+
1530
+ ` + layers.join(`
1531
+
1532
+ `);
1533
+ }
1534
+ var init_template2 = __esm(() => {
1535
+ init_template();
1536
+ });
1537
+
1538
+ // src/lib/format.ts
1539
+ function formatDuration(ms) {
1540
+ if (ms < MINUTE)
1541
+ return `${Math.round(ms / SECOND)}s`;
1542
+ const days = Math.floor(ms / DAY);
1543
+ const hours = Math.floor(ms % DAY / HOUR);
1544
+ const minutes = Math.floor(ms % HOUR / MINUTE);
1545
+ if (days > 0)
1546
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
1547
+ if (hours > 0)
1548
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1549
+ return `${minutes}m`;
1550
+ }
1551
+ function formatAgo(isoOrDate) {
1552
+ const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
1553
+ const ms = Date.now() - date.getTime();
1554
+ if (ms < MINUTE)
1555
+ return "just now";
1556
+ if (ms < HOUR)
1557
+ return `${Math.floor(ms / MINUTE)}m ago`;
1558
+ if (ms < DAY)
1559
+ return `${Math.floor(ms / HOUR)}h ago`;
1560
+ if (ms < 2 * DAY)
1561
+ return "yesterday";
1562
+ if (ms < 7 * DAY)
1563
+ return `${Math.floor(ms / DAY)}d ago`;
1564
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1565
+ }
1566
+ function truncate(text2, maxLen) {
1567
+ if (text2.length <= maxLen)
1568
+ return text2;
1569
+ return text2.slice(0, maxLen - 1) + "\u2026";
1570
+ }
1571
+ function formatTime(iso, includeDate = false) {
1572
+ const date = new Date(iso);
1573
+ const time = date.toLocaleTimeString("en-US", {
1574
+ hour: "numeric",
1575
+ minute: "2-digit"
1576
+ });
1577
+ if (!includeDate)
1578
+ return time;
1579
+ const day = date.toLocaleDateString("en-US", {
1580
+ month: "short",
1581
+ day: "numeric"
1582
+ });
1583
+ return `${day} ${time}`;
1584
+ }
1585
+ var SECOND = 1000, MINUTE, HOUR, DAY;
1586
+ var init_format = __esm(() => {
1587
+ MINUTE = 60 * SECOND;
1588
+ HOUR = 60 * MINUTE;
1589
+ DAY = 24 * HOUR;
1590
+ });
1591
+
1592
+ // src/contract/context.ts
1593
+ function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
1594
+ const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
1595
+ if (siblings.length === 0)
1596
+ return "";
1597
+ const lines = siblings.map((s) => {
1598
+ const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
1599
+ const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
1600
+ const worktree = s.worktreeBranch ? ` [worktree: ${s.worktreeBranch}]` : "";
1601
+ return `- ${categoryPath}/${s.slug}: ${s.status}${worktree}${summary} (${ago})`;
1602
+ });
1603
+ const guidance = [
1604
+ "",
1605
+ "To inspect any sibling session's full record, run:",
1606
+ " bertrand log <category>/<slug> --json",
1607
+ "Returns session metadata, stats, conversations, and the full event timeline.",
1608
+ "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
1609
+ ].join(`
1610
+ `);
1611
+ return `## Sibling Sessions
1612
+ ${lines.join(`
1613
+ `)}
1614
+ ${guidance}`;
1615
+ }
1616
+ var init_context = __esm(() => {
1617
+ init_sessions();
1618
+ init_format();
1619
+ });
1620
+
1621
+ // src/cli/commands/contract.ts
1622
+ var exports_contract = {};
1623
+ var init_contract = __esm(() => {
1631
1624
  init_router();
1632
1625
  init_sessions();
1633
- init_conversations();
1634
- init_emit();
1635
- init_transcript();
1636
- RECAP_RE2 = /<recap>([\s\S]*?)<\/recap>/i;
1637
- register("recap-thinking", async (args) => {
1626
+ init_categories();
1627
+ init_template2();
1628
+ init_context();
1629
+ register("contract", async (args) => {
1638
1630
  let sessionId = "";
1639
- let transcriptPath = "";
1640
- let conversationId = "";
1631
+ let short = false;
1641
1632
  for (let i = 0;i < args.length; i++) {
1642
1633
  const arg = args[i];
1643
1634
  const next = args[i + 1];
1644
1635
  if (arg === "--session-id" && next) {
1645
1636
  sessionId = next;
1646
1637
  i++;
1647
- } else if (arg === "--transcript-path" && next) {
1648
- transcriptPath = next;
1649
- i++;
1650
- } else if (arg === "--conversation-id" && next) {
1651
- conversationId = next;
1652
- i++;
1638
+ } else if (arg === "--short") {
1639
+ short = true;
1653
1640
  }
1654
1641
  }
1655
- if (!sessionId || !transcriptPath) {
1656
- console.error("Usage: bertrand recap-thinking --session-id <id> --transcript-path <path> [--conversation-id <id>]");
1642
+ if (!sessionId) {
1643
+ console.error("Usage: bertrand contract --session-id <id> [--short]");
1657
1644
  process.exit(1);
1658
1645
  }
1659
- if (!getSession(sessionId))
1660
- return;
1661
- const turn = getLatestAssistantTurn(transcriptPath);
1662
- if (!turn?.text)
1646
+ const session = getSession(sessionId);
1647
+ if (!session)
1663
1648
  return;
1664
- const match = turn.text.match(RECAP_RE2);
1665
- const recap = match?.[1]?.trim();
1666
- if (!recap)
1649
+ const category = getCategory(session.categoryId);
1650
+ const categoryPath = category?.path ?? "";
1651
+ const sessionName = categoryPath ? `${categoryPath}/${session.slug}` : session.slug;
1652
+ if (short) {
1653
+ 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) preceded by a <recap> block.`);
1667
1654
  return;
1668
- const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
1669
- emitAssistantRecap({
1670
- sessionId,
1671
- conversationId: convoId,
1672
- recap
1673
- });
1655
+ }
1656
+ const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
1657
+ process.stdout.write(buildContract(sessionName, siblingContext));
1674
1658
  });
1675
1659
  });
1676
1660
 
@@ -1791,9 +1775,9 @@ var init_notify = __esm(() => {
1791
1775
  });
1792
1776
 
1793
1777
  // src/db/queries/stats.ts
1794
- import { eq as eq4, sql as sql4 } from "drizzle-orm";
1778
+ import { eq as eq5, sql as sql4 } from "drizzle-orm";
1795
1779
  function getSessionStats(sessionId) {
1796
- return getDb().select().from(sessionStats).where(eq4(sessionStats.sessionId, sessionId)).get();
1780
+ return getDb().select().from(sessionStats).where(eq5(sessionStats.sessionId, sessionId)).get();
1797
1781
  }
1798
1782
  function upsertSessionStats(sessionId, data) {
1799
1783
  return getDb().insert(sessionStats).values({
@@ -1935,7 +1919,6 @@ function computeTimings(events2) {
1935
1919
  function computeSessionStats(sessionId) {
1936
1920
  const events2 = getEventsBySession(sessionId);
1937
1921
  const summary = computeTimings(events2);
1938
- const prEvents = getEventsByType(sessionId, "gh.pr.created");
1939
1922
  const conversationIds = new Set(events2.filter((e) => e.conversationId).map((e) => e.conversationId));
1940
1923
  const interactionCount = events2.filter((e) => e.event === "session.waiting" || e.event === "session.answered").length;
1941
1924
  const diff = computeDiffStats(sessionId);
@@ -1943,7 +1926,6 @@ function computeSessionStats(sessionId) {
1943
1926
  eventCount: events2.length,
1944
1927
  conversationCount: conversationIds.size,
1945
1928
  interactionCount,
1946
- prCount: prEvents.length,
1947
1929
  claudeWorkS: Math.round(summary.totalClaudeWorkMs / 1000),
1948
1930
  userWaitS: Math.round(summary.totalUserWaitMs / 1000),
1949
1931
  activePct: summary.activePct,
@@ -1968,7 +1950,7 @@ var init_timing = __esm(() => {
1968
1950
  });
1969
1951
 
1970
1952
  // src/lib/engagement_stats.ts
1971
- import { eq as eq5, sql as sql5 } from "drizzle-orm";
1953
+ import { eq as eq6, sql as sql5 } from "drizzle-orm";
1972
1954
  function aggregateToolUsage(sessionId) {
1973
1955
  const counts = {};
1974
1956
  const applied = getEventsByType(sessionId, "tool.applied");
@@ -1981,14 +1963,6 @@ function aggregateToolUsage(sessionId) {
1981
1963
  counts[p.tool] = (counts[p.tool] ?? 0) + (p.count ?? 1);
1982
1964
  }
1983
1965
  }
1984
- const resolves = getEventsByType(sessionId, "permission.resolve");
1985
- for (const ev of resolves) {
1986
- const meta = ev.meta;
1987
- const tool = meta?.tool;
1988
- if (!tool)
1989
- continue;
1990
- counts[tool] = (counts[tool] ?? 0) + 1;
1991
- }
1992
1966
  const used = getEventsByType(sessionId, "tool.used");
1993
1967
  for (const ev of used) {
1994
1968
  const meta = ev.meta;
@@ -1999,45 +1973,11 @@ function aggregateToolUsage(sessionId) {
1999
1973
  }
2000
1974
  return counts;
2001
1975
  }
2002
- function contextTokenStats(sessionId) {
2003
- const snapshots = getEventsByType(sessionId, "context.snapshot");
2004
- const samples = [];
2005
- for (const ev of snapshots) {
2006
- const meta = ev.meta;
2007
- const total = parseInt(meta?.context_window_tokens ?? "0", 10);
2008
- if (Number.isFinite(total) && total > 0)
2009
- samples.push(total);
2010
- }
2011
- if (samples.length === 0)
2012
- return { avg: 0, max: 0, latest: 0 };
2013
- let sum = 0;
2014
- let max = 0;
2015
- for (const n of samples) {
2016
- sum += n;
2017
- if (n > max)
2018
- max = n;
2019
- }
2020
- const avg = Math.round(sum / samples.length);
2021
- const latest = samples[samples.length - 1] ?? 0;
2022
- return { avg, max, latest };
2023
- }
2024
- function permissionDenialCount(sessionId) {
2025
- const all = getEventsBySession(sessionId);
2026
- let requests = 0;
2027
- let resolves = 0;
2028
- for (const ev of all) {
2029
- if (ev.event === "permission.request")
2030
- requests++;
2031
- else if (ev.event === "permission.resolve")
2032
- resolves++;
2033
- }
2034
- return Math.max(0, requests - resolves);
2035
- }
2036
1976
  function discardRate(sessionId) {
2037
1977
  const row = getDb().select({
2038
1978
  total: sql5`count(*)`,
2039
1979
  discarded: sql5`sum(case when ${conversations.discarded} then 1 else 0 end)`
2040
- }).from(conversations).where(eq5(conversations.sessionId, sessionId)).get();
1980
+ }).from(conversations).where(eq6(conversations.sessionId, sessionId)).get();
2041
1981
  return {
2042
1982
  total: row?.total ?? 0,
2043
1983
  discarded: row?.discarded ?? 0
@@ -2046,8 +1986,6 @@ function discardRate(sessionId) {
2046
1986
  function computeEngagementStats(sessionId) {
2047
1987
  return {
2048
1988
  toolUsage: aggregateToolUsage(sessionId),
2049
- contextTokens: contextTokenStats(sessionId),
2050
- permissionDenials: permissionDenialCount(sessionId),
2051
1989
  discardRate: discardRate(sessionId)
2052
1990
  };
2053
1991
  }
@@ -2291,7 +2229,7 @@ var PORT, listSessions = (_params, url) => {
2291
2229
  }, getActiveProjectMeta = () => {
2292
2230
  const active = resolveActiveProject();
2293
2231
  return { slug: active.slug, name: active.name };
2294
- }, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
2232
+ }, listWorktreeSessions = () => getAllSessions({ excludeArchived: true }).filter(({ session }) => session.worktreePath != null), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
2295
2233
  var init_server = __esm(() => {
2296
2234
  init_sessions();
2297
2235
  init_events();
@@ -2306,6 +2244,7 @@ var init_server = __esm(() => {
2306
2244
  routes = [
2307
2245
  [/^\/api\/sessions$/, listSessions],
2308
2246
  [/^\/api\/sessions\/(?<id>[^/]+)$/, getSessionById],
2247
+ [/^\/api\/worktrees$/, listWorktreeSessions],
2309
2248
  [/^\/api\/events\/(?<sessionId>[^/]+)$/, listEvents],
2310
2249
  [/^\/api\/stats$/, listAllStats],
2311
2250
  [/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
@@ -2364,7 +2303,7 @@ function cleanupSnapshot() {
2364
2303
  }
2365
2304
  }
2366
2305
  var SIDECAR_SUFFIXES;
2367
- var init_snapshot2 = __esm(() => {
2306
+ var init_snapshot = __esm(() => {
2368
2307
  init_resolve();
2369
2308
  SIDECAR_SUFFIXES = ["", "-wal", "-shm"];
2370
2309
  });
@@ -2563,7 +2502,7 @@ var init_engine = __esm(() => {
2563
2502
  init_resolve();
2564
2503
  init_lsof();
2565
2504
  init_config();
2566
- init_snapshot2();
2505
+ init_snapshot();
2567
2506
  init_crypto();
2568
2507
  });
2569
2508
 
@@ -2721,60 +2660,6 @@ var init_bootstrap = __esm(() => {
2721
2660
  init_config2();
2722
2661
  });
2723
2662
 
2724
- // src/lib/format.ts
2725
- function formatDuration(ms) {
2726
- if (ms < MINUTE)
2727
- return `${Math.round(ms / SECOND)}s`;
2728
- const days = Math.floor(ms / DAY);
2729
- const hours = Math.floor(ms % DAY / HOUR);
2730
- const minutes = Math.floor(ms % HOUR / MINUTE);
2731
- if (days > 0)
2732
- return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
2733
- if (hours > 0)
2734
- return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
2735
- return `${minutes}m`;
2736
- }
2737
- function formatAgo(isoOrDate) {
2738
- const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
2739
- const ms = Date.now() - date.getTime();
2740
- if (ms < MINUTE)
2741
- return "just now";
2742
- if (ms < HOUR)
2743
- return `${Math.floor(ms / MINUTE)}m ago`;
2744
- if (ms < DAY)
2745
- return `${Math.floor(ms / HOUR)}h ago`;
2746
- if (ms < 2 * DAY)
2747
- return "yesterday";
2748
- if (ms < 7 * DAY)
2749
- return `${Math.floor(ms / DAY)}d ago`;
2750
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2751
- }
2752
- function truncate(text2, maxLen) {
2753
- if (text2.length <= maxLen)
2754
- return text2;
2755
- return text2.slice(0, maxLen - 1) + "\u2026";
2756
- }
2757
- function formatTime(iso, includeDate = false) {
2758
- const date = new Date(iso);
2759
- const time = date.toLocaleTimeString("en-US", {
2760
- hour: "numeric",
2761
- minute: "2-digit"
2762
- });
2763
- if (!includeDate)
2764
- return time;
2765
- const day = date.toLocaleDateString("en-US", {
2766
- month: "short",
2767
- day: "numeric"
2768
- });
2769
- return `${day} ${time}`;
2770
- }
2771
- var SECOND = 1000, MINUTE, HOUR, DAY;
2772
- var init_format = __esm(() => {
2773
- MINUTE = 60 * SECOND;
2774
- HOUR = 60 * MINUTE;
2775
- DAY = 24 * HOUR;
2776
- });
2777
-
2778
2663
  // src/cli/commands/sync.ts
2779
2664
  var exports_sync = {};
2780
2665
  import { hostname as hostname2 } from "os";
@@ -3070,80 +2955,33 @@ var init_sync = __esm(() => {
3070
2955
  return;
3071
2956
  case "pull":
3072
2957
  await runPull(rest);
3073
- return;
3074
- case "status":
3075
- await runStatus();
3076
- return;
3077
- case "onboard":
3078
- await runOnboard();
3079
- return;
3080
- case "invite":
3081
- runInvite();
3082
- return;
3083
- case "enable":
3084
- runEnable();
3085
- return;
3086
- case "disable":
3087
- runDisable();
3088
- return;
3089
- case undefined:
3090
- case "--help":
3091
- case "-h":
3092
- printUsage2();
3093
- return;
3094
- default:
3095
- console.error(`Unknown subcommand: ${sub}`);
3096
- printUsage2();
3097
- process.exit(1);
3098
- }
3099
- });
3100
- });
3101
-
3102
- // src/db/queries/categories.ts
3103
- import { eq as eq6, like, or, isNull } from "drizzle-orm";
3104
- function createCategory(opts) {
3105
- const db = getDb();
3106
- const id = createId();
3107
- let path = opts.slug;
3108
- let depth = 0;
3109
- if (opts.parentId) {
3110
- const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
3111
- if (!parent)
3112
- throw new Error(`Parent category ${opts.parentId} not found`);
3113
- path = `${parent.path}/${opts.slug}`;
3114
- depth = parent.depth + 1;
3115
- }
3116
- return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
3117
- }
3118
- function getCategory(id) {
3119
- return getDb().select().from(categories).where(eq6(categories.id, id)).get();
3120
- }
3121
- function getCategoryByPath(path) {
3122
- return getDb().select().from(categories).where(eq6(categories.path, path)).get();
3123
- }
3124
- function getOrCreateCategoryPath(path) {
3125
- const existing = getCategoryByPath(path);
3126
- if (existing)
3127
- return existing.id;
3128
- const segments = path.split("/");
3129
- let parentId;
3130
- for (let i = 0;i < segments.length; i++) {
3131
- const partialPath = segments.slice(0, i + 1).join("/");
3132
- const category = getCategoryByPath(partialPath);
3133
- if (category) {
3134
- parentId = category.id;
3135
- } else {
3136
- const slug = segments[i];
3137
- const created = createCategory({ slug, name: slug, parentId });
3138
- parentId = created.id;
2958
+ return;
2959
+ case "status":
2960
+ await runStatus();
2961
+ return;
2962
+ case "onboard":
2963
+ await runOnboard();
2964
+ return;
2965
+ case "invite":
2966
+ runInvite();
2967
+ return;
2968
+ case "enable":
2969
+ runEnable();
2970
+ return;
2971
+ case "disable":
2972
+ runDisable();
2973
+ return;
2974
+ case undefined:
2975
+ case "--help":
2976
+ case "-h":
2977
+ printUsage2();
2978
+ return;
2979
+ default:
2980
+ console.error(`Unknown subcommand: ${sub}`);
2981
+ printUsage2();
2982
+ process.exit(1);
3139
2983
  }
3140
- }
3141
- return parentId;
3142
- }
3143
- var init_categories = __esm(() => {
3144
- init_client();
3145
- init_schema();
3146
- init_id();
2984
+ });
3147
2985
  });
3148
2986
 
3149
2987
  // src/db/queries/labels.ts
@@ -3169,61 +3007,6 @@ var init_labels = __esm(() => {
3169
3007
  init_id();
3170
3008
  });
3171
3009
 
3172
- // src/contract/template.md
3173
- var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
3174
-
3175
- 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.
3176
-
3177
- 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.
3178
-
3179
- 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.
3180
- `;
3181
- var init_template = () => {};
3182
-
3183
- // src/contract/template.ts
3184
- function buildContract(sessionName, ...contextLayers) {
3185
- const base = template_default.replace("{sessionName}", sessionName);
3186
- const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
3187
- if (layers.length === 0)
3188
- return base;
3189
- return base + `
3190
-
3191
- ` + layers.join(`
3192
-
3193
- `);
3194
- }
3195
- var init_template2 = __esm(() => {
3196
- init_template();
3197
- });
3198
-
3199
- // src/contract/context.ts
3200
- function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
3201
- const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
3202
- if (siblings.length === 0)
3203
- return "";
3204
- const lines = siblings.map((s) => {
3205
- const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
3206
- const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
3207
- return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
3208
- });
3209
- const guidance = [
3210
- "",
3211
- "To inspect any sibling session's full record, run:",
3212
- " bertrand log <category>/<slug> --json",
3213
- "Returns session metadata, stats, conversations, and the full event timeline.",
3214
- "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
3215
- ].join(`
3216
- `);
3217
- return `## Sibling Sessions
3218
- ${lines.join(`
3219
- `)}
3220
- ${guidance}`;
3221
- }
3222
- var init_context = __esm(() => {
3223
- init_sessions();
3224
- init_format();
3225
- });
3226
-
3227
3010
  // src/engine/process.ts
3228
3011
  import { spawn as spawn2 } from "child_process";
3229
3012
  function launchClaude(opts) {
@@ -3281,73 +3064,6 @@ var init_process = __esm(() => {
3281
3064
  init_resolve();
3282
3065
  });
3283
3066
 
3284
- // src/engine/spawn-context.ts
3285
- import { execFile as execFile2 } from "child_process";
3286
- import { promisify } from "util";
3287
- function captureModel() {
3288
- return process.env.BERTRAND_MODEL || process.env.CLAUDE_MODEL || undefined;
3289
- }
3290
- async function captureClaudeVersion() {
3291
- if (cachedClaudeVersion !== null)
3292
- return cachedClaudeVersion;
3293
- try {
3294
- const { stdout } = await execFileAsync("claude", ["--version"], {
3295
- timeout: 5000
3296
- });
3297
- const match2 = stdout.trim().match(/(\d+\.\d+\.\d+(?:\.\d+)?)/);
3298
- cachedClaudeVersion = match2 ? match2[1] : stdout.trim() || undefined;
3299
- return cachedClaudeVersion;
3300
- } catch {
3301
- cachedClaudeVersion = undefined;
3302
- return;
3303
- }
3304
- }
3305
- async function captureGit() {
3306
- try {
3307
- const [statusRes, shaRes] = await Promise.all([
3308
- execFileAsync("git", ["status", "--porcelain=v2", "--branch"], {
3309
- timeout: 5000
3310
- }),
3311
- execFileAsync("git", ["rev-parse", "HEAD"], { timeout: 5000 })
3312
- ]);
3313
- let branch;
3314
- let dirty = false;
3315
- for (const line of statusRes.stdout.split(`
3316
- `)) {
3317
- if (line.startsWith("# branch.head ")) {
3318
- branch = line.slice("# branch.head ".length).trim();
3319
- } else if (line && !line.startsWith("#")) {
3320
- dirty = true;
3321
- }
3322
- }
3323
- if (!branch)
3324
- return;
3325
- return {
3326
- branch,
3327
- sha: shaRes.stdout.trim(),
3328
- dirty
3329
- };
3330
- } catch {
3331
- return;
3332
- }
3333
- }
3334
- async function captureSpawnContext() {
3335
- const [claudeVersion, git] = await Promise.all([
3336
- captureClaudeVersion(),
3337
- captureGit()
3338
- ]);
3339
- return {
3340
- model: captureModel(),
3341
- claudeVersion,
3342
- git,
3343
- cwd: process.cwd()
3344
- };
3345
- }
3346
- var execFileAsync, cachedClaudeVersion = null;
3347
- var init_spawn_context = __esm(() => {
3348
- execFileAsync = promisify(execFile2);
3349
- });
3350
-
3351
3067
  // src/lib/server-lifecycle.ts
3352
3068
  import { spawn as spawn3 } from "child_process";
3353
3069
  import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
@@ -3434,6 +3150,44 @@ var init_server_lifecycle = __esm(() => {
3434
3150
  deps = defaultDeps;
3435
3151
  });
3436
3152
 
3153
+ // src/hooks/runtime.ts
3154
+ import { readdirSync as readdirSync2, rmSync, statSync as statSync4 } from "fs";
3155
+ import { join as join10 } from "path";
3156
+ function rmMarker(name) {
3157
+ rmSync(join10(runtimeDir, name), { force: true });
3158
+ }
3159
+ function pruneSessionMarkers(sessionId, conversationId) {
3160
+ rmMarker(`done-${sessionId}`);
3161
+ rmMarker(`auq-nudge-${sessionId}`);
3162
+ rmMarker(`working-${sessionId}`);
3163
+ rmMarker(`worktree-${sessionId}`);
3164
+ if (conversationId)
3165
+ rmMarker(`${CONTRACT_MARKER_PREFIX}${conversationId}`);
3166
+ }
3167
+ function pruneStaleContractMarkers(maxAgeMs = STALE_MS) {
3168
+ let entries;
3169
+ try {
3170
+ entries = readdirSync2(runtimeDir);
3171
+ } catch {
3172
+ return;
3173
+ }
3174
+ const cutoff = Date.now() - maxAgeMs;
3175
+ for (const name of entries) {
3176
+ if (!name.startsWith(CONTRACT_MARKER_PREFIX))
3177
+ continue;
3178
+ try {
3179
+ if (statSync4(join10(runtimeDir, name)).mtimeMs < cutoff)
3180
+ rmMarker(name);
3181
+ } catch {}
3182
+ }
3183
+ }
3184
+ var CONTRACT_MARKER_PREFIX = "contract-sent-", STALE_MS, runtimeDir;
3185
+ var init_runtime = __esm(() => {
3186
+ init_paths();
3187
+ STALE_MS = 24 * 60 * 60 * 1000;
3188
+ runtimeDir = paths.runtime;
3189
+ });
3190
+
3437
3191
  // src/engine/session.ts
3438
3192
  import { randomUUID } from "crypto";
3439
3193
  function forceFinalizeLive() {
@@ -3470,6 +3224,7 @@ function installExitHandlers() {
3470
3224
  process.on("SIGHUP", onSignal);
3471
3225
  }
3472
3226
  async function launch(opts) {
3227
+ pruneStaleContractMarkers();
3473
3228
  const existingCategory = getCategoryByPath(opts.categoryPath);
3474
3229
  if (existingCategory) {
3475
3230
  const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
@@ -3496,24 +3251,11 @@ async function launch(opts) {
3496
3251
  liveSession = { sessionId: session.id, claudeId };
3497
3252
  installExitHandlers();
3498
3253
  await ensureServerStarted();
3499
- const spawnContext = await captureSpawnContext();
3500
3254
  const sessionName = `${opts.categoryPath}/${opts.slug}`;
3501
- emitSessionStarted({
3502
- sessionId: session.id,
3503
- conversationId: claudeId,
3504
- categoryPath: opts.categoryPath,
3505
- sessionName: opts.name ?? opts.slug,
3506
- sessionSlug: opts.slug,
3507
- labels: opts.labelNames ?? [],
3508
- summary: session.summary ?? null
3509
- });
3510
3255
  emitClaudeStarted({
3511
3256
  sessionId: session.id,
3512
3257
  conversationId: claudeId,
3513
- model: spawnContext.model,
3514
- claudeVersion: spawnContext.claudeVersion,
3515
- git: spawnContext.git,
3516
- cwd: spawnContext.cwd
3258
+ cwd: process.cwd()
3517
3259
  });
3518
3260
  const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
3519
3261
  const contract = buildContract(sessionName, siblingContext);
@@ -3528,6 +3270,7 @@ async function launch(opts) {
3528
3270
  return session.id;
3529
3271
  }
3530
3272
  async function resume(opts) {
3273
+ pruneStaleContractMarkers();
3531
3274
  const session = getSession(opts.sessionId);
3532
3275
  if (!session)
3533
3276
  throw new Error(`Session not found: ${opts.sessionId}`);
@@ -3538,19 +3281,11 @@ async function resume(opts) {
3538
3281
  liveSession = { sessionId: session.id, claudeId: opts.conversationId };
3539
3282
  installExitHandlers();
3540
3283
  await ensureServerStarted();
3541
- emitSessionResumed({
3542
- sessionId: session.id,
3543
- conversationId: opts.conversationId
3544
- });
3545
3284
  if (isFreshClaudeSession) {
3546
- const spawnContext = await captureSpawnContext();
3547
3285
  emitClaudeStarted({
3548
3286
  sessionId: session.id,
3549
3287
  conversationId: opts.conversationId,
3550
- model: spawnContext.model,
3551
- claudeVersion: spawnContext.claudeVersion,
3552
- git: spawnContext.git,
3553
- cwd: spawnContext.cwd
3288
+ cwd: process.cwd()
3554
3289
  });
3555
3290
  }
3556
3291
  const categoryPath = category?.path ?? "";
@@ -3587,7 +3322,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
3587
3322
  });
3588
3323
  if (liveSession?.sessionId === sessionId)
3589
3324
  liveSession = null;
3590
- emitSessionEnded({ sessionId });
3325
+ pruneSessionMarkers(sessionId, safeConversationId);
3591
3326
  computeAndPersist(sessionId);
3592
3327
  stopServerIfIdle();
3593
3328
  triggerBackgroundPush();
@@ -3602,21 +3337,21 @@ var init_session = __esm(() => {
3602
3337
  init_template2();
3603
3338
  init_context();
3604
3339
  init_process();
3605
- init_spawn_context();
3606
3340
  init_timing();
3607
3341
  init_server_lifecycle();
3608
3342
  init_trigger();
3609
3343
  init_transcript();
3344
+ init_runtime();
3610
3345
  });
3611
3346
 
3612
3347
  // src/tui/app.tsx
3613
3348
  import { spawn as spawn4 } from "child_process";
3614
3349
  import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
3615
3350
  import { tmpdir } from "os";
3616
- import { join as join10 } from "path";
3351
+ import { join as join11 } from "path";
3617
3352
  import { randomUUID as randomUUID2 } from "crypto";
3618
3353
  async function runScreen(screen, ...args) {
3619
- const tmpFile = join10(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
3354
+ const tmpFile = join11(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
3620
3355
  if (process.env.BERTRAND_DEBUG_TUI) {
3621
3356
  try {
3622
3357
  const { appendFileSync } = await import("fs");
@@ -3626,7 +3361,7 @@ async function runScreen(screen, ...args) {
3626
3361
  }
3627
3362
  const child = spawn4("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
3628
3363
  stdio: "inherit",
3629
- env: process.env
3364
+ env: { ...process.env, BERTRAND_PROJECT: resolveActiveProject().slug }
3630
3365
  });
3631
3366
  const noopSignal = () => {};
3632
3367
  process.on("SIGINT", noopSignal);
@@ -3773,35 +3508,11 @@ var init_app = __esm(() => {
3773
3508
  init_create();
3774
3509
  init_resolve();
3775
3510
  SCREEN_ENTRY = (() => {
3776
- const built = join10(import.meta.dir, "run-screen.js");
3777
- return existsSync8(built) ? built : join10(import.meta.dir, "run-screen.tsx");
3511
+ const built = join11(import.meta.dir, "run-screen.js");
3512
+ return existsSync8(built) ? built : join11(import.meta.dir, "run-screen.tsx");
3778
3513
  })();
3779
3514
  });
3780
3515
 
3781
- // src/lib/parse-session-name.ts
3782
- function parseSessionName(input) {
3783
- const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
3784
- if (!trimmed) {
3785
- throw new Error("Session name cannot be empty");
3786
- }
3787
- const segments = trimmed.split("/").filter(Boolean);
3788
- if (segments.length < 2) {
3789
- throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
3790
- }
3791
- for (const segment of segments) {
3792
- if (!SEGMENT_PATTERN.test(segment)) {
3793
- throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
3794
- }
3795
- }
3796
- const categoryPath = segments[0];
3797
- const slug = segments.slice(1).join("/");
3798
- return { categoryPath, slug };
3799
- }
3800
- var SEGMENT_PATTERN;
3801
- var init_parse_session_name = __esm(() => {
3802
- SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
3803
- });
3804
-
3805
3516
  // src/engine/recovery.ts
3806
3517
  function isProcessAlive2(pid) {
3807
3518
  try {
@@ -3820,10 +3531,6 @@ function recoverStaleSessions() {
3820
3531
  status: "paused",
3821
3532
  pid: null
3822
3533
  });
3823
- emitSessionPausedByRecovery({
3824
- sessionId: session.id,
3825
- stalePid: session.pid
3826
- });
3827
3534
  recovered++;
3828
3535
  }
3829
3536
  }
@@ -3831,7 +3538,6 @@ function recoverStaleSessions() {
3831
3538
  }
3832
3539
  var init_recovery = __esm(() => {
3833
3540
  init_sessions();
3834
- init_emit();
3835
3541
  });
3836
3542
 
3837
3543
  // src/cli/commands/launch.ts
@@ -3871,7 +3577,7 @@ var init_launch = __esm(() => {
3871
3577
  function quietHelper(bin) {
3872
3578
  return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
3873
3579
  }
3874
- function waitingScript(bin, runtimeDir) {
3580
+ function waitingScript(bin, runtimeDir2) {
3875
3581
  return `#!/usr/bin/env bash
3876
3582
  # Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
3877
3583
  ${quietHelper(bin)}
@@ -3896,18 +3602,15 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
3896
3602
  [ -z "$question" ] && question="Waiting for input"
3897
3603
 
3898
3604
  # Clear working debounce marker so next resume\u2192working transition fires
3899
- rm -f "${runtimeDir}/working-$sid"
3605
+ rm -f "${runtimeDir2}/working-$sid"
3900
3606
 
3901
3607
  bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3902
3608
 
3903
- # Context snapshot \u2014 extract transcript path and capture token usage.
3904
- # assistant-message captures the latest turn's text + recap tag in one pass;
3905
- # replaces the older recap-thinking call which only grabbed the recap. Dedup
3906
- # inside the command makes it idempotent vs the matching Stop-time capture,
3907
- # so the same turn never lands twice.
3609
+ # Capture the latest assistant turn's text + recap tag. Dedup inside the
3610
+ # command makes it idempotent vs the matching Stop-time capture so the same
3611
+ # turn never lands twice.
3908
3612
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3909
3613
  if [ -n "$tpath" ]; then
3910
- bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3911
3614
  bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3912
3615
  fi
3913
3616
 
@@ -3917,7 +3620,7 @@ bq notify bertrand "$question" &
3917
3620
  wait
3918
3621
  `;
3919
3622
  }
3920
- function answeredScript(bin, _runtimeDir) {
3623
+ function answeredScript(bin, runtimeDir2) {
3921
3624
  return `#!/usr/bin/env bash
3922
3625
  # Hook: PostToolUse AskUserQuestion \u2192 mark session as active
3923
3626
  #
@@ -3951,9 +3654,18 @@ bq update --session-id "$sid" --event session.answered --meta "$meta"
3951
3654
 
3952
3655
  bq badge --clear &
3953
3656
 
3657
+ # The loop is healthy \u2014 the agent ended its turn on AskUserQuestion and the
3658
+ # user answered. Reset the Stop-hook nudge counter so its cap applies per
3659
+ # run of consecutive contract violations, not cumulatively across the session.
3660
+ rm -f "${runtimeDir2}/auq-nudge-$sid"
3661
+
3954
3662
  # Halt the agent loop if the user signaled Done for now. The Stop hook
3955
3663
  # (on-done.sh) will fire afterwards and mark the session as paused.
3956
3664
  if printf '%s' "$done_check" | grep -q "Done for now"; then
3665
+ # Tell on-done.sh this Stop is a legitimate exit, not a dropped AUQ call \u2014
3666
+ # so it pauses normally instead of forcing the loop to continue.
3667
+ touch "${runtimeDir2}/done-$sid"
3668
+
3957
3669
  # Promote the picked Done-for-now option's description into a session.recap
3958
3670
  # event so the timeline has a dedicated end-of-session summary row. Bertrand
3959
3671
  # forces session exit before Claude can write a closing message, so this
@@ -3971,35 +3683,9 @@ fi
3971
3683
  wait
3972
3684
  `;
3973
3685
  }
3974
- function activeScript(bin, runtimeDir) {
3975
- return `#!/usr/bin/env bash
3976
- # Hook: PreToolUse (catch-all) \u2192 flip waiting to active
3977
- ${quietHelper(bin)}
3978
- sid="\${BERTRAND_SESSION:-}"
3979
- [ -z "$sid" ] && exit 0
3980
-
3981
- # Debounce: skip if we already sent session.active within the last 5 seconds.
3982
- # This avoids spawning bertrand (~31ms) on every tool call during rapid sequences.
3983
- marker="${runtimeDir}/working-$sid"
3984
- if [ -f "$marker" ]; then
3985
- age=$(( $(date +%s) - $(stat -f%m "$marker" 2>/dev/null || echo 0) ))
3986
- [ "$age" -lt 5 ] && exit 0
3987
- fi
3988
-
3989
- input="$(cat)"
3990
- ${EXTRACT_TOOL}
3991
-
3992
- # AskUserQuestion has its own PreToolUse hook
3993
- [ "$tool" = "AskUserQuestion" ] && exit 0
3994
-
3995
- touch "$marker"
3996
- cid="\${BERTRAND_CLAUDE_ID:-}"
3997
- bq update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3998
- `;
3999
- }
4000
- function permissionWaitScript(bin, runtimeDir) {
3686
+ function permissionWaitScript(bin, runtimeDir2) {
4001
3687
  return `#!/usr/bin/env bash
4002
- # Hook: PermissionRequest \u2192 mark pending, emit permission.request
3688
+ # Hook: PermissionRequest \u2192 mark pending, badge + notify
4003
3689
  ${quietHelper(bin)}
4004
3690
  sid="\${BERTRAND_SESSION:-}"
4005
3691
  [ -z "$sid" ] && exit 0
@@ -4008,18 +3694,10 @@ input="$(cat)"
4008
3694
  ${EXTRACT_TOOL}
4009
3695
  [ "$tool" = "AskUserQuestion" ] && exit 0
4010
3696
 
4011
- # Write marker so PostToolUse knows this was a real permission prompt (not auto-approved)
4012
- touch "${runtimeDir}/perm-pending-$sid"
4013
-
4014
- # Extract detail from tool_input via grep (avoid jq for simple fields)
4015
- detail=""
4016
- case "$tool" in
4017
- Bash) detail="$(printf '%s' "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4018
- Edit|Write|Read) detail="$(printf '%s' "$input" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4019
- esac
4020
-
4021
- cid="\${BERTRAND_CLAUDE_ID:-}"
4022
- bq update --session-id "$sid" --event permission.request --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, claude_id:$cid}')"
3697
+ # Marker tells the PostToolUse hook to emit tool.used with outcome:approved
3698
+ # instead of outcome:auto. Without it, every prompted-then-approved tool call
3699
+ # would look identical to an auto-approved one.
3700
+ touch "${runtimeDir2}/perm-pending-$sid"
4023
3701
 
4024
3702
  # Badge + notify in background
4025
3703
  bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
@@ -4027,21 +3705,19 @@ bq notify bertrand "Needs permission: $tool" &
4027
3705
  wait
4028
3706
  `;
4029
3707
  }
4030
- function permissionDoneScript(bin, runtimeDir) {
3708
+ function permissionDoneScript(bin, runtimeDir2) {
4031
3709
  return `#!/usr/bin/env bash
4032
3710
  # Hook: PostToolUse (catch-all)
4033
3711
  #
4034
- # Captures every tool call Claude makes. Three event flows:
3712
+ # Captures every tool call Claude makes. Two event flows:
4035
3713
  # 1. Edit/Write/MultiEdit \u2192 tool.applied with diff payload. Keeps the
4036
3714
  # existing dashboard diff-renderer happy and is the only place we get
4037
3715
  # old_string/new_string on auto-approved edits.
4038
- # 2. Tools that went through a permission prompt \u2192 permission.resolve.
4039
- # The PermissionRequest hook set a marker; we clear it and log the
4040
- # approval. (Denials never reach PostToolUse, so absence-of-resolve
4041
- # means the user said no.)
4042
- # 3. Everything else (auto-approved Bash/Read/Grep/Glob/etc.) \u2192 tool.used
4043
- # with outcome:"auto". Previously this case dropped the call entirely;
4044
- # now Claude's read-only / shell activity shows up in the timeline.
3716
+ # 2. Everything else \u2192 tool.used. The PermissionRequest hook may have set
3717
+ # a marker; if so the call was prompted-then-approved (outcome:approved),
3718
+ # otherwise it was auto-approved (outcome:auto). Denials never reach
3719
+ # PostToolUse, so absence of a tool.used after a permission.request means
3720
+ # the user said no.
4045
3721
  ${quietHelper(bin)}
4046
3722
  sid="\${BERTRAND_SESSION:-}"
4047
3723
  [ -z "$sid" ] && exit 0
@@ -4049,10 +3725,11 @@ sid="\${BERTRAND_SESSION:-}"
4049
3725
  input="$(cat)"
4050
3726
  ${EXTRACT_TOOL}
4051
3727
 
4052
- # Don't double-log: AskUserQuestion has its own waiting/answered events
4053
- [ "$tool" = "AskUserQuestion" ] && exit 0
3728
+ # Don't double-log: AskUserQuestion has its own waiting/answered events, and
3729
+ # EnterWorktree/ExitWorktree have their own worktree.entered/exited hooks.
3730
+ case "$tool" in AskUserQuestion|EnterWorktree|ExitWorktree) exit 0 ;; esac
4054
3731
 
4055
- marker="${runtimeDir}/perm-pending-$sid"
3732
+ marker="${runtimeDir2}/perm-pending-$sid"
4056
3733
  had_marker=0
4057
3734
  if [ -f "$marker" ]; then
4058
3735
  had_marker=1
@@ -4100,20 +3777,23 @@ case "$tool" in
4100
3777
  WebSearch) detail="$(printf '%s' "$input" | grep -o '"query":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
4101
3778
  esac
4102
3779
 
4103
- if [ "$had_marker" = "1" ]; then
4104
- # Prompted-then-approved path. Keep permission.resolve for back-compat
4105
- # rendering; downstream consumers can migrate to tool.used at their pace.
4106
- bq update --session-id "$sid" --event permission.resolve --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"approved", claude_id:$cid}')"
4107
- else
4108
- # Auto-approved path. Without tool.used, these calls were invisible.
4109
- bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"auto", claude_id:$cid}')"
4110
- fi
3780
+ outcome="auto"
3781
+ [ "$had_marker" = "1" ] && outcome="approved"
3782
+ bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg o "$outcome" --arg cid "$cid" '{tool:$t, detail:$d, outcome:$o, claude_id:$cid}')"
4111
3783
  wait
4112
3784
  `;
4113
3785
  }
4114
- function userPromptScript(bin, _runtimeDir) {
3786
+ function userPromptScript(bin, runtimeDir2) {
4115
3787
  return `#!/usr/bin/env bash
4116
- # Hook: UserPromptSubmit \u2192 record user free-text prompt
3788
+ # Hook: UserPromptSubmit \u2192 record user prompt + re-inject the session contract.
3789
+ #
3790
+ # The contract normally arrives via --append-system-prompt on bertrand's own
3791
+ # claude spawn, which reaches only that one process. Sessions that inherit the
3792
+ # BERTRAND_* env vars without going through launchClaude (background jobs,
3793
+ # nested \`claude\`, the Warp plugin's own launcher) never receive it. Re-
3794
+ # injecting here \u2014 through the durable env/hook channel \u2014 closes that gap.
3795
+ # Full contract on the first prompt of each conversation, a one-line reminder
3796
+ # thereafter, to keep the per-turn token cost low.
4117
3797
  ${quietHelper(bin)}
4118
3798
  sid="\${BERTRAND_SESSION:-}"
4119
3799
  [ -z "$sid" ] && exit 0
@@ -4121,34 +3801,102 @@ sid="\${BERTRAND_SESSION:-}"
4121
3801
  input="$(cat)"
4122
3802
  cid="\${BERTRAND_CLAUDE_ID:-}"
4123
3803
 
3804
+ # Record the prompt event. Stdout muted so only the context JSON below reaches
3805
+ # the hook's stdout (UserPromptSubmit parses stdout as a hook decision).
4124
3806
  meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
4125
- [ -z "$meta" ] && exit 0
3807
+ [ -n "$meta" ] && bq update --session-id "$sid" --event user.prompt --meta "$meta" >/dev/null
3808
+
3809
+ # Re-deliver the contract as additional context.
3810
+ marker="${runtimeDir2}/contract-sent-\${cid:-$sid}"
3811
+ if [ -f "$marker" ]; then
3812
+ contract="$(bq contract --session-id "$sid" --short)"
3813
+ else
3814
+ contract="$(bq contract --session-id "$sid")"
3815
+ : > "$marker"
3816
+ fi
4126
3817
 
4127
- bq update --session-id "$sid" --event user.prompt --meta "$meta"
3818
+ [ -n "$contract" ] && jq -n --arg c "$contract" '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
4128
3819
  `;
4129
3820
  }
4130
- function doneScript(bin, _runtimeDir) {
3821
+ function doneScript(bin, runtimeDir2) {
4131
3822
  return `#!/usr/bin/env bash
4132
- # Hook: Stop \u2192 mark session as paused
3823
+ # Hook: Stop \u2192 enforce AUQ loop, else flip session status to paused.
4133
3824
  ${quietHelper(bin)}
4134
3825
  sid="\${BERTRAND_SESSION:-}"
4135
3826
  [ -z "$sid" ] && exit 0
4136
3827
 
4137
3828
  input="$(cat)"
4138
3829
  cid="\${BERTRAND_CLAUDE_ID:-}"
4139
- bq update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
4140
3830
 
4141
- # Final transcript reads. assistant-message dedups against the most-recent
4142
- # AskUQ-time capture, so a Done-for-now exit (no work between AskUQ and Stop)
4143
- # lands zero new events here; intermediate Stops with fresh assistant output
4144
- # do land new ones.
3831
+ done_marker="${runtimeDir2}/done-$sid"
3832
+ nudge_marker="${runtimeDir2}/auq-nudge-$sid"
3833
+
3834
+ # Capture the assistant turn either way. Stdout is muted so it can never corrupt
3835
+ # a decision-JSON payload. Dedups against the most-recent AskUQ-time capture, so
3836
+ # a Done-for-now exit lands zero new events; a dropped-AUQ Stop records the
3837
+ # stray turn; intermediate Stops with fresh output land normally.
4145
3838
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
4146
3839
  if [ -n "$tpath" ]; then
4147
- bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
4148
- bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3840
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" >/dev/null &
3841
+ fi
3842
+
3843
+ if [ ! -f "$done_marker" ]; then
3844
+ # Not a Done-for-now exit \u2192 the turn ended without AskUserQuestion. Force the
3845
+ # loop to continue, up to a small cap so a context where AUQ is genuinely
3846
+ # unavailable can't wedge the session in an endless block/stop cycle. The
3847
+ # counter is reset on every answered AUQ (on-answered.sh), so the cap bounds
3848
+ # consecutive violations, not the whole session.
3849
+ count="$(cat "$nudge_marker" 2>/dev/null)"
3850
+ case "$count" in ''|*[!0-9]*) count=0 ;; esac
3851
+ if [ "$count" -lt 3 ]; then
3852
+ printf '%s' "$((count + 1))" > "$nudge_marker"
3853
+ 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, preceded by a <recap> block. 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.'
3854
+ wait
3855
+ jq -n --arg r "$reason" '{decision:"block", reason:$r}'
3856
+ exit 0
3857
+ fi
3858
+ # Cap reached \u2014 stop nudging and let the session pause normally.
4149
3859
  fi
4150
3860
 
3861
+ # Terminal path: legitimate Done-for-now exit, or nudge cap exhausted.
3862
+ rm -f "$done_marker" "$nudge_marker"
3863
+ bq update --session-id "$sid" --event session.paused
4151
3864
  bq badge check --color '#58c142' --priority 10
3865
+ wait
3866
+ `;
3867
+ }
3868
+ function enterWorktreeScript(bin, runtimeDir2) {
3869
+ return `#!/usr/bin/env bash
3870
+ # Hook: PostToolUse EnterWorktree \u2192 record worktree path + branch on the session.
3871
+ ${quietHelper(bin)}
3872
+ sid="\${BERTRAND_SESSION:-}"
3873
+ [ -z "$sid" ] && exit 0
3874
+
3875
+ input="$(cat)"
3876
+ cid="\${BERTRAND_CLAUDE_ID:-}"
3877
+
3878
+ path="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
3879
+ [ -z "$path" ] && exit 0
3880
+ branch="$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null)"
3881
+
3882
+ printf '%s' "$path" > "${runtimeDir2}/worktree-$sid"
3883
+
3884
+ 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}')"
3885
+ `;
3886
+ }
3887
+ function exitWorktreeScript(bin, runtimeDir2) {
3888
+ return `#!/usr/bin/env bash
3889
+ # Hook: PostToolUse ExitWorktree \u2192 clear the session's worktree state.
3890
+ ${quietHelper(bin)}
3891
+ sid="\${BERTRAND_SESSION:-}"
3892
+ [ -z "$sid" ] && exit 0
3893
+
3894
+ cid="\${BERTRAND_CLAUDE_ID:-}"
3895
+ marker="${runtimeDir2}/worktree-$sid"
3896
+ path="$(cat "$marker" 2>/dev/null)"
3897
+ rm -f "$marker"
3898
+
3899
+ bq update --session-id "$sid" --event worktree.exited --meta "$(jq -n --arg p "$path" --arg cid "$cid" '{path:$p, claude_id:$cid}')"
4152
3900
  `;
4153
3901
  }
4154
3902
  var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
@@ -4156,22 +3904,23 @@ var init_scripts = __esm(() => {
4156
3904
  HOOK_SCRIPTS = {
4157
3905
  "on-waiting.sh": waitingScript,
4158
3906
  "on-answered.sh": answeredScript,
4159
- "on-active.sh": activeScript,
4160
3907
  "on-permission-wait.sh": permissionWaitScript,
4161
3908
  "on-permission-done.sh": permissionDoneScript,
4162
3909
  "on-user-prompt.sh": userPromptScript,
4163
- "on-done.sh": doneScript
3910
+ "on-done.sh": doneScript,
3911
+ "on-enter-worktree.sh": enterWorktreeScript,
3912
+ "on-exit-worktree.sh": exitWorktreeScript
4164
3913
  };
4165
3914
  });
4166
3915
 
4167
3916
  // src/hooks/install.ts
4168
3917
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
4169
- import { join as join11 } from "path";
3918
+ import { join as join12 } from "path";
4170
3919
  function installHookScripts(bin) {
4171
3920
  mkdirSync7(paths.hooks, { recursive: true });
4172
3921
  mkdirSync7(paths.runtime, { recursive: true });
4173
3922
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
4174
- const filePath = join11(paths.hooks, filename);
3923
+ const filePath = join12(paths.hooks, filename);
4175
3924
  writeFileSync5(filePath, scriptFn(bin, paths.runtime));
4176
3925
  chmodSync2(filePath, 493);
4177
3926
  }
@@ -4184,7 +3933,7 @@ var init_install = __esm(() => {
4184
3933
 
4185
3934
  // src/hooks/settings.ts
4186
3935
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
4187
- import { join as join12, dirname as dirname4 } from "path";
3936
+ import { join as join13, dirname as dirname4 } from "path";
4188
3937
  import { homedir as homedir3 } from "os";
4189
3938
  function isBertrandGroup(group) {
4190
3939
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -4209,16 +3958,12 @@ function installHookSettings() {
4209
3958
  var SETTINGS_PATH, BERTRAND_HOOKS;
4210
3959
  var init_settings = __esm(() => {
4211
3960
  init_paths();
4212
- SETTINGS_PATH = join12(homedir3(), ".claude", "settings.json");
3961
+ SETTINGS_PATH = join13(homedir3(), ".claude", "settings.json");
4213
3962
  BERTRAND_HOOKS = {
4214
3963
  PreToolUse: [
4215
3964
  {
4216
3965
  matcher: "AskUserQuestion",
4217
3966
  hooks: [{ type: "command", command: `${paths.hooks}/on-waiting.sh` }]
4218
- },
4219
- {
4220
- matcher: "",
4221
- hooks: [{ type: "command", command: `${paths.hooks}/on-active.sh` }]
4222
3967
  }
4223
3968
  ],
4224
3969
  PostToolUse: [
@@ -4226,6 +3971,14 @@ var init_settings = __esm(() => {
4226
3971
  matcher: "AskUserQuestion",
4227
3972
  hooks: [{ type: "command", command: `${paths.hooks}/on-answered.sh` }]
4228
3973
  },
3974
+ {
3975
+ matcher: "EnterWorktree",
3976
+ hooks: [{ type: "command", command: `${paths.hooks}/on-enter-worktree.sh` }]
3977
+ },
3978
+ {
3979
+ matcher: "ExitWorktree",
3980
+ hooks: [{ type: "command", command: `${paths.hooks}/on-exit-worktree.sh` }]
3981
+ },
4229
3982
  {
4230
3983
  matcher: "",
4231
3984
  hooks: [{ type: "command", command: `${paths.hooks}/on-permission-done.sh` }]
@@ -4254,7 +4007,7 @@ var init_settings = __esm(() => {
4254
4007
 
4255
4008
  // src/lib/completions.ts
4256
4009
  import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
4257
- import { join as join13 } from "path";
4010
+ import { join as join14 } from "path";
4258
4011
  function bashCompletion() {
4259
4012
  return `# bertrand bash completion
4260
4013
  _bertrand() {
@@ -4284,11 +4037,11 @@ function fishCompletion() {
4284
4037
  `;
4285
4038
  }
4286
4039
  function generateCompletions() {
4287
- const dir = join13(paths.root, "completions");
4040
+ const dir = join14(paths.root, "completions");
4288
4041
  mkdirSync9(dir, { recursive: true });
4289
- writeFileSync7(join13(dir, "bertrand.bash"), bashCompletion());
4290
- writeFileSync7(join13(dir, "_bertrand"), zshCompletion());
4291
- writeFileSync7(join13(dir, "bertrand.fish"), fishCompletion());
4042
+ writeFileSync7(join14(dir, "bertrand.bash"), bashCompletion());
4043
+ writeFileSync7(join14(dir, "_bertrand"), zshCompletion());
4044
+ writeFileSync7(join14(dir, "bertrand.fish"), fishCompletion());
4292
4045
  console.log(`Shell completions written to ${dir}`);
4293
4046
  console.log(" Add to your shell config:");
4294
4047
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -4319,7 +4072,7 @@ var init_completions = __esm(() => {
4319
4072
  var exports_init = {};
4320
4073
  import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
4321
4074
  import { execSync as execSync2 } from "child_process";
4322
- import { join as join14 } from "path";
4075
+ import { join as join15 } from "path";
4323
4076
  function detectTerminal() {
4324
4077
  try {
4325
4078
  execSync2("which wsh", { stdio: "ignore" });
@@ -4334,8 +4087,8 @@ function resolveBin() {
4334
4087
  return onPath;
4335
4088
  const entry = process.argv[1];
4336
4089
  if (entry && SOURCE_ENTRY.test(entry)) {
4337
- const launcherDir = join14(paths.root, "bin");
4338
- const launcher = join14(launcherDir, "bertrand-dev");
4090
+ const launcherDir = join15(paths.root, "bin");
4091
+ const launcher = join15(launcherDir, "bertrand-dev");
4339
4092
  mkdirSync10(launcherDir, { recursive: true });
4340
4093
  writeFileSync8(launcher, `#!/usr/bin/env bash
4341
4094
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
@@ -4542,33 +4295,13 @@ function extractSummary(row) {
4542
4295
  const answers = meta.answers;
4543
4296
  return answers ? Object.values(answers).join(", ") : "";
4544
4297
  }
4545
- case "permission.request":
4546
- case "permission.resolve": {
4547
- const tool = meta.tool ?? "";
4548
- const detail = meta.detail ?? "";
4549
- return detail ? `${tool}: ${detail}` : tool;
4550
- }
4551
- case "gh.pr.created": {
4552
- const title = meta.pr_title ?? "";
4553
- const url = meta.pr_url ?? "";
4554
- return title || url;
4555
- }
4556
- case "gh.pr.merged":
4557
- return meta.branch ?? "";
4558
- case "worktree.entered":
4559
- return meta.branch ?? "";
4560
- case "linear.issue.read":
4561
- return meta.issue_title ?? "";
4562
- case "notion.page.read":
4563
- return meta.page_title ?? "";
4564
- case "vercel.deploy":
4565
- return meta.project_name ?? "";
4566
4298
  case "user.prompt":
4567
4299
  return meta.prompt ?? "";
4568
- case "context.snapshot":
4569
- return meta.remaining_pct ? `${meta.remaining_pct}% remaining` : "";
4570
4300
  case "session.recap":
4571
4301
  return meta.recap ?? "";
4302
+ case "worktree.entered":
4303
+ case "worktree.exited":
4304
+ return meta.branch ?? meta.path ?? "";
4572
4305
  default:
4573
4306
  return "";
4574
4307
  }
@@ -4596,30 +4329,19 @@ function enrichAll(rows) {
4596
4329
  var catalog, DEFAULT_INFO;
4597
4330
  var init_catalog = __esm(() => {
4598
4331
  catalog = {
4599
- "session.started": { label: "started", category: "lifecycle", color: 34, detailColor: 245, skip: false },
4600
- "session.resumed": { label: "resumed", category: "lifecycle", color: 34, detailColor: 245, skip: false },
4601
- "session.end": { label: "ended", category: "lifecycle", color: 245, detailColor: 245, skip: false },
4602
4332
  "claude.started": { label: "claude started", category: "lifecycle", color: 35, detailColor: 245, skip: false },
4603
4333
  "claude.ended": { label: "claude ended", category: "lifecycle", color: 35, detailColor: 245, skip: false },
4604
4334
  "claude.discarded": { label: "discarded", category: "lifecycle", color: 245, detailColor: 245, skip: false },
4605
4335
  "session.waiting": { label: "waiting", category: "interaction", color: 33, detailColor: 245, skip: false },
4606
4336
  "session.answered": { label: "answered", category: "interaction", color: 36, detailColor: 245, skip: false },
4607
- "permission.request": { label: "permission", category: "work", color: 214, detailColor: 245, skip: false },
4608
- "permission.resolve": { label: "allowed", category: "work", color: 214, detailColor: 245, skip: false },
4609
- "worktree.entered": { label: "worktree", category: "lifecycle", color: 35, detailColor: 245, skip: false },
4610
- "worktree.exited": { label: "worktree exited", category: "lifecycle", color: 35, detailColor: 245, skip: false },
4611
- "gh.pr.created": { label: "PR created", category: "integration", color: 32, detailColor: 32, skip: false },
4612
- "gh.pr.merged": { label: "PR merged", category: "integration", color: 35, detailColor: 35, skip: false },
4613
- "linear.issue.read": { label: "Linear issue", category: "integration", color: 33, detailColor: 33, skip: false },
4614
- "notion.page.read": { label: "Notion page", category: "integration", color: 245, detailColor: 245, skip: false },
4615
- "vercel.deploy": { label: "deployed", category: "integration", color: 245, detailColor: 245, skip: false },
4616
4337
  "user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
4617
- "context.snapshot": { label: "context", category: "context", color: 245, detailColor: 245, skip: true },
4618
- "tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: true },
4338
+ "tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: false },
4619
4339
  "tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
4620
4340
  "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
4621
4341
  "assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
4622
- "assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false }
4342
+ "assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false },
4343
+ "worktree.entered": { label: "worktree entered", category: "lifecycle", color: 78, detailColor: 245, skip: false },
4344
+ "worktree.exited": { label: "worktree exited", category: "lifecycle", color: 245, detailColor: 245, skip: false }
4623
4345
  };
4624
4346
  DEFAULT_INFO = {
4625
4347
  label: "unknown",
@@ -4676,8 +4398,6 @@ function collapsePermissions(events2) {
4676
4398
  continue;
4677
4399
  const toolCounts = new Map;
4678
4400
  for (const pev of batch) {
4679
- if (pev.event !== "permission.request" && pev.event !== "tool.used")
4680
- continue;
4681
4401
  const meta = pev.meta;
4682
4402
  const tool = meta?.tool ?? "unknown";
4683
4403
  toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + 1);
@@ -4739,8 +4459,6 @@ var ROLLUP_EVENTS;
4739
4459
  var init_compact = __esm(() => {
4740
4460
  init_catalog();
4741
4461
  ROLLUP_EVENTS = new Set([
4742
- "permission.request",
4743
- "permission.resolve",
4744
4462
  "tool.used"
4745
4463
  ]);
4746
4464
  });
@@ -4930,13 +4648,11 @@ var init_log = __esm(() => {
4930
4648
  init_router();
4931
4649
  init_sessions();
4932
4650
  init_events();
4933
- init_categories();
4934
4651
  init_stats();
4935
4652
  init_conversations();
4936
4653
  init_catalog();
4937
4654
  init_compact();
4938
4655
  init_timing();
4939
- init_parse_session_name();
4940
4656
  init_format();
4941
4657
  init_resolve();
4942
4658
  init_cli_flag();
@@ -4956,18 +4672,12 @@ var init_log = __esm(() => {
4956
4672
  showAllSessions();
4957
4673
  return;
4958
4674
  }
4959
- const { categoryPath, slug } = parseSessionName(target);
4960
- const category = getCategoryByPath(categoryPath);
4961
- if (!category) {
4962
- console.error(`Category not found: ${categoryPath}`);
4963
- process.exit(1);
4964
- }
4965
- const session = getSessionByCategorySlug(category.id, slug);
4966
- if (!session) {
4675
+ const resolved = resolveSessionByName(target);
4676
+ if (!resolved) {
4967
4677
  console.error(`Session not found: ${target}`);
4968
4678
  process.exit(1);
4969
4679
  }
4970
- showSessionLog(session, `${categoryPath}/${slug}`, isJson);
4680
+ showSessionLog(resolved.session, `${resolved.categoryPath}/${resolved.slug}`, isJson);
4971
4681
  });
4972
4682
  });
4973
4683
 
@@ -4982,14 +4692,11 @@ function getMetrics(sessionId, name, status2) {
4982
4692
  const allEvents = getEventsBySession(sessionId);
4983
4693
  const conversations2 = new Set;
4984
4694
  let interactionCount = 0;
4985
- let prCount = 0;
4986
4695
  for (const ev of allEvents) {
4987
4696
  if (ev.conversationId)
4988
4697
  conversations2.add(ev.conversationId);
4989
4698
  if (ev.event === "session.waiting" || ev.event === "session.answered")
4990
4699
  interactionCount++;
4991
- if (ev.event === "gh.pr.created")
4992
- prCount++;
4993
4700
  }
4994
4701
  return {
4995
4702
  name,
@@ -4997,7 +4704,6 @@ function getMetrics(sessionId, name, status2) {
4997
4704
  eventCount: allEvents.length,
4998
4705
  conversationCount: conversations2.size,
4999
4706
  interactionCount,
5000
- prCount,
5001
4707
  claudeWorkS: Math.round(timing.totalClaudeWorkMs / 1000),
5002
4708
  userWaitS: Math.round(timing.totalUserWaitMs / 1000),
5003
4709
  activePct: timing.activePct,
@@ -5018,9 +4724,8 @@ function renderGlobal(metrics, isJson) {
5018
4724
  claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
5019
4725
  userWaitS: acc.userWaitS + m.userWaitS,
5020
4726
  conversations: acc.conversations + m.conversationCount,
5021
- prs: acc.prs + m.prCount,
5022
4727
  interactions: acc.interactions + m.interactionCount
5023
- }), { sessions: 0, active: 0, durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0, prs: 0, interactions: 0 });
4728
+ }), { sessions: 0, active: 0, durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0, interactions: 0 });
5024
4729
  const totalTracked = totals.claudeWorkS + totals.userWaitS;
5025
4730
  const globalActivePct = totalTracked > 0 ? Math.round(totals.claudeWorkS / totalTracked * 100) : 0;
5026
4731
  if (isJson) {
@@ -5037,7 +4742,6 @@ function renderGlobal(metrics, isJson) {
5037
4742
  console.log(` Claude work: ${dur(totals.claudeWorkS)} ${dim}(${pct(globalActivePct)})${reset}`);
5038
4743
  console.log(` User wait: ${dur(totals.userWaitS)} ${dim}(${pct(100 - globalActivePct)})${reset}`);
5039
4744
  console.log(` Conversations: ${totals.conversations}`);
5040
- console.log(` PRs: ${totals.prs}`);
5041
4745
  console.log(` Interactions: ${totals.interactions}`);
5042
4746
  }
5043
4747
  function renderCategory(metrics, categoryPath, isJson) {
@@ -5052,21 +4756,20 @@ function renderCategory(metrics, categoryPath, isJson) {
5052
4756
  const maxName = Math.max(...sorted.map((m) => m.name.length), 4);
5053
4757
  console.log(`${bold}${categoryPath}${reset}
5054
4758
  `);
5055
- console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} ${"CONVOS".padEnd(6)} PRS${reset}`);
4759
+ console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} CONVOS${reset}`);
5056
4760
  for (const m of sorted) {
5057
- console.log(`${m.name.padEnd(maxName)} ${dur(m.durationS).padEnd(8)} ${dur(m.claudeWorkS).padEnd(8)} ${dur(m.userWaitS).padEnd(8)} ${pct(m.activePct).padEnd(5)} ${String(m.conversationCount).padEnd(6)} ${m.prCount}`);
4761
+ console.log(`${m.name.padEnd(maxName)} ${dur(m.durationS).padEnd(8)} ${dur(m.claudeWorkS).padEnd(8)} ${dur(m.userWaitS).padEnd(8)} ${pct(m.activePct).padEnd(5)} ${m.conversationCount}`);
5058
4762
  }
5059
4763
  const totals = sorted.reduce((acc, m) => ({
5060
4764
  durationS: acc.durationS + m.durationS,
5061
4765
  claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
5062
4766
  userWaitS: acc.userWaitS + m.userWaitS,
5063
- conversations: acc.conversations + m.conversationCount,
5064
- prs: acc.prs + m.prCount
5065
- }), { durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0, prs: 0 });
4767
+ conversations: acc.conversations + m.conversationCount
4768
+ }), { durationS: 0, claudeWorkS: 0, userWaitS: 0, conversations: 0 });
5066
4769
  const totalTracked = totals.claudeWorkS + totals.userWaitS;
5067
4770
  const totalPct = totalTracked > 0 ? Math.round(totals.claudeWorkS / totalTracked * 100) : 0;
5068
4771
  console.log(`${dim}${"\u2500".repeat(maxName + 50)}${reset}`);
5069
- console.log(`${"TOTAL".padEnd(maxName)} ${dur(totals.durationS).padEnd(8)} ${dur(totals.claudeWorkS).padEnd(8)} ${dur(totals.userWaitS).padEnd(8)} ${pct(totalPct).padEnd(5)} ${String(totals.conversations).padEnd(6)} ${totals.prs}`);
4772
+ console.log(`${"TOTAL".padEnd(maxName)} ${dur(totals.durationS).padEnd(8)} ${dur(totals.claudeWorkS).padEnd(8)} ${dur(totals.userWaitS).padEnd(8)} ${pct(totalPct).padEnd(5)} ${totals.conversations}`);
5070
4773
  }
5071
4774
  function renderSession(m, isJson) {
5072
4775
  if (isJson) {
@@ -5084,7 +4787,6 @@ function renderSession(m, isJson) {
5084
4787
  console.log(` Events: ${m.eventCount}`);
5085
4788
  console.log(` Conversations: ${m.conversationCount}`);
5086
4789
  console.log(` Interactions: ${m.interactionCount}`);
5087
- console.log(` PRs: ${m.prCount}`);
5088
4790
  }
5089
4791
  var ACTIVE_STATUSES2;
5090
4792
  var init_stats2 = __esm(() => {
@@ -5095,7 +4797,6 @@ var init_stats2 = __esm(() => {
5095
4797
  init_events();
5096
4798
  init_timing();
5097
4799
  init_format();
5098
- init_parse_session_name();
5099
4800
  ACTIVE_STATUSES2 = ["active", "waiting"];
5100
4801
  register("stats", async (args) => {
5101
4802
  const isJson = args.includes("--json");
@@ -5108,29 +4809,23 @@ var init_stats2 = __esm(() => {
5108
4809
  return;
5109
4810
  }
5110
4811
  if (target.endsWith("/")) {
5111
- const categoryPath2 = target.replace(/\/+$/, "");
5112
- const category2 = getCategoryByPath(categoryPath2);
5113
- if (!category2) {
5114
- console.error(`Category not found: ${categoryPath2}`);
4812
+ const categoryPath = target.replace(/\/+$/, "");
4813
+ const category = getCategoryByPath(categoryPath);
4814
+ if (!category) {
4815
+ console.error(`Category not found: ${categoryPath}`);
5115
4816
  process.exit(1);
5116
4817
  }
5117
- const categorySessions = getSessionsByCategory(category2.id);
4818
+ const categorySessions = getSessionsByCategory(category.id);
5118
4819
  const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
5119
- renderCategory(metrics, categoryPath2, isJson);
4820
+ renderCategory(metrics, categoryPath, isJson);
5120
4821
  return;
5121
4822
  }
5122
- const { categoryPath, slug } = parseSessionName(target);
5123
- const category = getCategoryByPath(categoryPath);
5124
- if (!category) {
5125
- console.error(`Category not found: ${categoryPath}`);
5126
- process.exit(1);
5127
- }
5128
- const session = getSessionByCategorySlug(category.id, slug);
5129
- if (!session) {
4823
+ const resolved = resolveSessionByName(target);
4824
+ if (!resolved) {
5130
4825
  console.error(`Session not found: ${target}`);
5131
4826
  process.exit(1);
5132
4827
  }
5133
- const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
4828
+ const m = getMetrics(resolved.session.id, `${resolved.categoryPath}/${resolved.slug}`, resolved.session.status);
5134
4829
  renderSession(m, isJson);
5135
4830
  });
5136
4831
  });
@@ -5156,24 +4851,16 @@ var init_backfill_stats = __esm(() => {
5156
4851
  // src/cli/commands/archive.ts
5157
4852
  var exports_archive = {};
5158
4853
  function resolveSession(name) {
5159
- const { categoryPath, slug } = parseSessionName(name);
5160
- const category = getCategoryByPath(categoryPath);
5161
- if (!category) {
5162
- console.error(`Category not found: ${categoryPath}`);
5163
- process.exit(1);
5164
- }
5165
- const session = getSessionByCategorySlug(category.id, slug);
5166
- if (!session) {
4854
+ const resolved = resolveSessionByName(name);
4855
+ if (!resolved) {
5167
4856
  console.error(`Session not found: ${name}`);
5168
4857
  process.exit(1);
5169
4858
  }
5170
- return { session, categoryPath };
4859
+ return { session: resolved.session, categoryPath: resolved.categoryPath };
5171
4860
  }
5172
4861
  var init_archive = __esm(() => {
5173
4862
  init_router();
5174
4863
  init_sessions();
5175
- init_categories();
5176
- init_parse_session_name();
5177
4864
  init_session_archive();
5178
4865
  register("archive", async (args) => {
5179
4866
  const isUndo = args.includes("--undo");
@@ -5245,7 +4932,7 @@ __export(exports_project, {
5245
4932
  createSubcommand: () => createSubcommand,
5246
4933
  _UsageError: () => _UsageError
5247
4934
  });
5248
- import { existsSync as existsSync9, rmSync } from "fs";
4935
+ import { existsSync as existsSync9, rmSync as rmSync2 } from "fs";
5249
4936
  function countSessions(slug) {
5250
4937
  const dbFile = projectPaths(slug).db;
5251
4938
  if (!existsSync9(dbFile))
@@ -5425,7 +5112,7 @@ function removeSubcommand(args) {
5425
5112
  removeProject(slug);
5426
5113
  invalidateDbCache(slug);
5427
5114
  if (purge) {
5428
- rmSync(projectPaths(slug).root, { recursive: true, force: true });
5115
+ rmSync2(projectPaths(slug).root, { recursive: true, force: true });
5429
5116
  }
5430
5117
  console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
5431
5118
  }
@@ -5534,9 +5221,8 @@ init_router();
5534
5221
  var command = process.argv[2];
5535
5222
  var hotPath = {
5536
5223
  update: () => Promise.resolve().then(() => (init_update(), exports_update)),
5537
- snapshot: () => Promise.resolve().then(() => (init_snapshot(), exports_snapshot)),
5538
5224
  "assistant-message": () => Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
5539
- "recap-thinking": () => Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
5225
+ contract: () => Promise.resolve().then(() => (init_contract(), exports_contract)),
5540
5226
  badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
5541
5227
  notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
5542
5228
  serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
@@ -5554,9 +5240,8 @@ if (command && command in hotPath) {
5554
5240
  Promise.resolve().then(() => (init_backfill_stats(), exports_backfill_stats)),
5555
5241
  Promise.resolve().then(() => (init_archive(), exports_archive)),
5556
5242
  Promise.resolve().then(() => (init_update(), exports_update)),
5557
- Promise.resolve().then(() => (init_snapshot(), exports_snapshot)),
5558
5243
  Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
5559
- Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
5244
+ Promise.resolve().then(() => (init_contract(), exports_contract)),
5560
5245
  Promise.resolve().then(() => (init_serve(), exports_serve)),
5561
5246
  Promise.resolve().then(() => (init_badge(), exports_badge)),
5562
5247
  Promise.resolve().then(() => (init_notify(), exports_notify)),