bertrand 0.22.2 → 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
@@ -600,6 +600,7 @@ var init_router = __esm(() => {
600
600
  HOOK_COMMANDS = new Set([
601
601
  "update",
602
602
  "assistant-message",
603
+ "contract",
603
604
  "notify",
604
605
  "badge"
605
606
  ]);
@@ -654,6 +655,8 @@ var init_schema = __esm(() => {
654
655
  pid: integer("pid"),
655
656
  startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
656
657
  endedAt: text("ended_at"),
658
+ worktreePath: text("worktree_path"),
659
+ worktreeBranch: text("worktree_branch"),
657
660
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
658
661
  updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
659
662
  }, (t) => [
@@ -782,28 +785,122 @@ function createId(size = 12) {
782
785
  }
783
786
  var init_id = () => {};
784
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
+
785
859
  // src/db/queries/sessions.ts
786
- 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
+ }
787
884
  function createSession(opts) {
788
885
  const db = getDb();
789
886
  const id = createId();
790
887
  return db.insert(sessions).values({ id, ...opts }).returning().get();
791
888
  }
792
889
  function getSession(id) {
793
- return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
890
+ return getDb().select().from(sessions).where(eq2(sessions.id, id)).get();
794
891
  }
795
892
  function getSessionByCategorySlug(categoryId, slug) {
796
- 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();
797
894
  }
798
895
  function getSessionsByCategory(categoryId) {
799
- return getDb().select().from(sessions).where(eq(sessions.categoryId, categoryId)).all();
896
+ return getDb().select().from(sessions).where(eq2(sessions.categoryId, categoryId)).all();
800
897
  }
801
898
  function getActiveSessions() {
802
- return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
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();
803
900
  }
804
901
  function getAllSessions(opts) {
805
902
  const db = getDb();
806
- 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));
807
904
  if (opts?.excludeArchived) {
808
905
  return query.where(inArray(sessions.status, [
809
906
  "active",
@@ -814,33 +911,35 @@ function getAllSessions(opts) {
814
911
  return query.all();
815
912
  }
816
913
  function updateSessionStatus(id, status) {
817
- 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();
818
915
  }
819
916
  function updateSession(id, data) {
820
- 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();
821
918
  }
822
919
  function deleteSession(id) {
823
- return getDb().delete(sessions).where(eq(sessions.id, id)).run();
920
+ return getDb().delete(sessions).where(eq2(sessions.id, id)).run();
824
921
  }
825
922
  var init_sessions = __esm(() => {
826
923
  init_client();
827
924
  init_schema();
828
925
  init_id();
926
+ init_categories();
927
+ init_parse_session_name();
829
928
  });
830
929
 
831
930
  // src/db/queries/conversations.ts
832
- 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";
833
932
  function createConversation(opts) {
834
933
  return getDb().insert(conversations).values(opts).returning().get();
835
934
  }
836
935
  function getConversation(id) {
837
- return getDb().select().from(conversations).where(eq2(conversations.id, id)).get();
936
+ return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
838
937
  }
839
938
  function getConversationsBySession(sessionId) {
840
- 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();
841
940
  }
842
941
  function endConversation(id) {
843
- return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq2(conversations.id, id)).returning().get();
942
+ return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
844
943
  }
845
944
  var init_conversations = __esm(() => {
846
945
  init_client();
@@ -896,7 +995,7 @@ function normalizeAnsweredMeta(meta) {
896
995
  }
897
996
 
898
997
  // src/db/queries/events.ts
899
- 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";
900
999
  function insertEvent(opts) {
901
1000
  return getDb().insert(events).values({
902
1001
  sessionId: opts.sessionId,
@@ -907,18 +1006,18 @@ function insertEvent(opts) {
907
1006
  }).returning().get();
908
1007
  }
909
1008
  function getEventsBySession(sessionId) {
910
- 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();
911
1010
  }
912
1011
  function getEventsByType(sessionId, eventType) {
913
- 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();
914
1013
  }
915
1014
  function getLatestEventOfType(sessionId, eventType, conversationId) {
916
1015
  const conditions = [
917
- eq3(events.sessionId, sessionId),
918
- eq3(events.event, eventType)
1016
+ eq4(events.sessionId, sessionId),
1017
+ eq4(events.event, eventType)
919
1018
  ];
920
1019
  if (conversationId) {
921
- conditions.push(eq3(events.conversationId, conversationId));
1020
+ conditions.push(eq4(events.conversationId, conversationId));
922
1021
  }
923
1022
  return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
924
1023
  }
@@ -927,7 +1026,7 @@ function getLatestRecaps() {
927
1026
  sessionId: events.sessionId,
928
1027
  meta: events.meta,
929
1028
  createdAt: events.createdAt
930
- }).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();
931
1030
  const result = {};
932
1031
  for (const row of rows) {
933
1032
  if (result[row.sessionId])
@@ -1079,6 +1178,24 @@ function emitAssistantRecap(args) {
1079
1178
  meta: { recap: args.recap, claude_id: args.conversationId }
1080
1179
  });
1081
1180
  }
1181
+ function emitWorktreeEntered(args) {
1182
+ return insertEvent({
1183
+ sessionId: args.sessionId,
1184
+ conversationId: args.conversationId,
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 }
1197
+ });
1198
+ }
1082
1199
  var init_emit = __esm(() => {
1083
1200
  init_events();
1084
1201
  });
@@ -1086,7 +1203,8 @@ var init_emit = __esm(() => {
1086
1203
  // src/cli/commands/update.ts
1087
1204
  var exports_update = {};
1088
1205
  __export(exports_update, {
1089
- shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
1206
+ shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip,
1207
+ dispatchHookEvent: () => dispatchHookEvent
1090
1208
  });
1091
1209
  function shouldIgnoreStatusFlip(newStatus, sessionPid) {
1092
1210
  if (!newStatus)
@@ -1145,6 +1263,22 @@ function dispatchHookEvent(event, ctx) {
1145
1263
  outcome: meta.outcome === "approved" ? "approved" : "auto"
1146
1264
  });
1147
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
+ }
1148
1282
  default:
1149
1283
  return false;
1150
1284
  }
@@ -1374,6 +1508,156 @@ var init_assistant_message = __esm(() => {
1374
1508
  });
1375
1509
  });
1376
1510
 
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(() => {
1624
+ init_router();
1625
+ init_sessions();
1626
+ init_categories();
1627
+ init_template2();
1628
+ init_context();
1629
+ register("contract", async (args) => {
1630
+ let sessionId = "";
1631
+ let short = false;
1632
+ for (let i = 0;i < args.length; i++) {
1633
+ const arg = args[i];
1634
+ const next = args[i + 1];
1635
+ if (arg === "--session-id" && next) {
1636
+ sessionId = next;
1637
+ i++;
1638
+ } else if (arg === "--short") {
1639
+ short = true;
1640
+ }
1641
+ }
1642
+ if (!sessionId) {
1643
+ console.error("Usage: bertrand contract --session-id <id> [--short]");
1644
+ process.exit(1);
1645
+ }
1646
+ const session = getSession(sessionId);
1647
+ if (!session)
1648
+ return;
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.`);
1654
+ return;
1655
+ }
1656
+ const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
1657
+ process.stdout.write(buildContract(sessionName, siblingContext));
1658
+ });
1659
+ });
1660
+
1377
1661
  // src/terminal/wave.ts
1378
1662
  import { execSync } from "child_process";
1379
1663
 
@@ -1491,9 +1775,9 @@ var init_notify = __esm(() => {
1491
1775
  });
1492
1776
 
1493
1777
  // src/db/queries/stats.ts
1494
- import { eq as eq4, sql as sql4 } from "drizzle-orm";
1778
+ import { eq as eq5, sql as sql4 } from "drizzle-orm";
1495
1779
  function getSessionStats(sessionId) {
1496
- return getDb().select().from(sessionStats).where(eq4(sessionStats.sessionId, sessionId)).get();
1780
+ return getDb().select().from(sessionStats).where(eq5(sessionStats.sessionId, sessionId)).get();
1497
1781
  }
1498
1782
  function upsertSessionStats(sessionId, data) {
1499
1783
  return getDb().insert(sessionStats).values({
@@ -1666,7 +1950,7 @@ var init_timing = __esm(() => {
1666
1950
  });
1667
1951
 
1668
1952
  // src/lib/engagement_stats.ts
1669
- import { eq as eq5, sql as sql5 } from "drizzle-orm";
1953
+ import { eq as eq6, sql as sql5 } from "drizzle-orm";
1670
1954
  function aggregateToolUsage(sessionId) {
1671
1955
  const counts = {};
1672
1956
  const applied = getEventsByType(sessionId, "tool.applied");
@@ -1693,7 +1977,7 @@ function discardRate(sessionId) {
1693
1977
  const row = getDb().select({
1694
1978
  total: sql5`count(*)`,
1695
1979
  discarded: sql5`sum(case when ${conversations.discarded} then 1 else 0 end)`
1696
- }).from(conversations).where(eq5(conversations.sessionId, sessionId)).get();
1980
+ }).from(conversations).where(eq6(conversations.sessionId, sessionId)).get();
1697
1981
  return {
1698
1982
  total: row?.total ?? 0,
1699
1983
  discarded: row?.discarded ?? 0
@@ -1945,7 +2229,7 @@ var PORT, listSessions = (_params, url) => {
1945
2229
  }, getActiveProjectMeta = () => {
1946
2230
  const active = resolveActiveProject();
1947
2231
  return { slug: active.slug, name: active.name };
1948
- }, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
2232
+ }, listWorktreeSessions = () => getAllSessions({ excludeArchived: true }).filter(({ session }) => session.worktreePath != null), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1949
2233
  var init_server = __esm(() => {
1950
2234
  init_sessions();
1951
2235
  init_events();
@@ -1960,6 +2244,7 @@ var init_server = __esm(() => {
1960
2244
  routes = [
1961
2245
  [/^\/api\/sessions$/, listSessions],
1962
2246
  [/^\/api\/sessions\/(?<id>[^/]+)$/, getSessionById],
2247
+ [/^\/api\/worktrees$/, listWorktreeSessions],
1963
2248
  [/^\/api\/events\/(?<sessionId>[^/]+)$/, listEvents],
1964
2249
  [/^\/api\/stats$/, listAllStats],
1965
2250
  [/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
@@ -2375,60 +2660,6 @@ var init_bootstrap = __esm(() => {
2375
2660
  init_config2();
2376
2661
  });
2377
2662
 
2378
- // src/lib/format.ts
2379
- function formatDuration(ms) {
2380
- if (ms < MINUTE)
2381
- return `${Math.round(ms / SECOND)}s`;
2382
- const days = Math.floor(ms / DAY);
2383
- const hours = Math.floor(ms % DAY / HOUR);
2384
- const minutes = Math.floor(ms % HOUR / MINUTE);
2385
- if (days > 0)
2386
- return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
2387
- if (hours > 0)
2388
- return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
2389
- return `${minutes}m`;
2390
- }
2391
- function formatAgo(isoOrDate) {
2392
- const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
2393
- const ms = Date.now() - date.getTime();
2394
- if (ms < MINUTE)
2395
- return "just now";
2396
- if (ms < HOUR)
2397
- return `${Math.floor(ms / MINUTE)}m ago`;
2398
- if (ms < DAY)
2399
- return `${Math.floor(ms / HOUR)}h ago`;
2400
- if (ms < 2 * DAY)
2401
- return "yesterday";
2402
- if (ms < 7 * DAY)
2403
- return `${Math.floor(ms / DAY)}d ago`;
2404
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2405
- }
2406
- function truncate(text2, maxLen) {
2407
- if (text2.length <= maxLen)
2408
- return text2;
2409
- return text2.slice(0, maxLen - 1) + "\u2026";
2410
- }
2411
- function formatTime(iso, includeDate = false) {
2412
- const date = new Date(iso);
2413
- const time = date.toLocaleTimeString("en-US", {
2414
- hour: "numeric",
2415
- minute: "2-digit"
2416
- });
2417
- if (!includeDate)
2418
- return time;
2419
- const day = date.toLocaleDateString("en-US", {
2420
- month: "short",
2421
- day: "numeric"
2422
- });
2423
- return `${day} ${time}`;
2424
- }
2425
- var SECOND = 1000, MINUTE, HOUR, DAY;
2426
- var init_format = __esm(() => {
2427
- MINUTE = 60 * SECOND;
2428
- HOUR = 60 * MINUTE;
2429
- DAY = 24 * HOUR;
2430
- });
2431
-
2432
2663
  // src/cli/commands/sync.ts
2433
2664
  var exports_sync = {};
2434
2665
  import { hostname as hostname2 } from "os";
@@ -2753,53 +2984,6 @@ var init_sync = __esm(() => {
2753
2984
  });
2754
2985
  });
2755
2986
 
2756
- // src/db/queries/categories.ts
2757
- import { eq as eq6, like, or, isNull } from "drizzle-orm";
2758
- function createCategory(opts) {
2759
- const db = getDb();
2760
- const id = createId();
2761
- let path = opts.slug;
2762
- let depth = 0;
2763
- if (opts.parentId) {
2764
- const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
2765
- if (!parent)
2766
- throw new Error(`Parent category ${opts.parentId} not found`);
2767
- path = `${parent.path}/${opts.slug}`;
2768
- depth = parent.depth + 1;
2769
- }
2770
- return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
2771
- }
2772
- function getCategory(id) {
2773
- return getDb().select().from(categories).where(eq6(categories.id, id)).get();
2774
- }
2775
- function getCategoryByPath(path) {
2776
- return getDb().select().from(categories).where(eq6(categories.path, path)).get();
2777
- }
2778
- function getOrCreateCategoryPath(path) {
2779
- const existing = getCategoryByPath(path);
2780
- if (existing)
2781
- return existing.id;
2782
- const segments = path.split("/");
2783
- let parentId;
2784
- for (let i = 0;i < segments.length; i++) {
2785
- const partialPath = segments.slice(0, i + 1).join("/");
2786
- const category = getCategoryByPath(partialPath);
2787
- if (category) {
2788
- parentId = category.id;
2789
- } else {
2790
- const slug = segments[i];
2791
- const created = createCategory({ slug, name: slug, parentId });
2792
- parentId = created.id;
2793
- }
2794
- }
2795
- return parentId;
2796
- }
2797
- var init_categories = __esm(() => {
2798
- init_client();
2799
- init_schema();
2800
- init_id();
2801
- });
2802
-
2803
2987
  // src/db/queries/labels.ts
2804
2988
  import { eq as eq7, and as and4 } from "drizzle-orm";
2805
2989
  function createLabel(opts) {
@@ -2823,61 +3007,6 @@ var init_labels = __esm(() => {
2823
3007
  init_id();
2824
3008
  });
2825
3009
 
2826
- // src/contract/template.md
2827
- var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
2828
-
2829
- After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it). The description for "Done for now" must be a 1-2 sentence summary of what was accomplished so far. Describe outcomes (what was built, fixed, decided), not process.
2830
-
2831
- If the user's most recent answer to AskUserQuestion was "Done for now" (or contains it), this turn is the FINAL turn. Respond briefly to acknowledge and do NOT call AskUserQuestion again \u2014 the loop is over.
2832
-
2833
- Before each AskUserQuestion call, emit a \`<recap>...</recap>\` block in your text output. Use markdown \u2014 a short bullet list is usually the most scannable shape; a single short paragraph is fine when the turn was one cohesive thing. Keep it concise. The recap covers what happened since the previous AskUserQuestion (or session start) \u2014 what you found, decided, or did. Write the gist for someone reading the session timeline, not a process log. The dashboard renders these between AskUserQuestion events; do not use this tag for any other purpose.
2834
- `;
2835
- var init_template = () => {};
2836
-
2837
- // src/contract/template.ts
2838
- function buildContract(sessionName, ...contextLayers) {
2839
- const base = template_default.replace("{sessionName}", sessionName);
2840
- const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
2841
- if (layers.length === 0)
2842
- return base;
2843
- return base + `
2844
-
2845
- ` + layers.join(`
2846
-
2847
- `);
2848
- }
2849
- var init_template2 = __esm(() => {
2850
- init_template();
2851
- });
2852
-
2853
- // src/contract/context.ts
2854
- function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
2855
- const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
2856
- if (siblings.length === 0)
2857
- return "";
2858
- const lines = siblings.map((s) => {
2859
- const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
2860
- const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
2861
- return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
2862
- });
2863
- const guidance = [
2864
- "",
2865
- "To inspect any sibling session's full record, run:",
2866
- " bertrand log <category>/<slug> --json",
2867
- "Returns session metadata, stats, conversations, and the full event timeline.",
2868
- "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
2869
- ].join(`
2870
- `);
2871
- return `## Sibling Sessions
2872
- ${lines.join(`
2873
- `)}
2874
- ${guidance}`;
2875
- }
2876
- var init_context = __esm(() => {
2877
- init_sessions();
2878
- init_format();
2879
- });
2880
-
2881
3010
  // src/engine/process.ts
2882
3011
  import { spawn as spawn2 } from "child_process";
2883
3012
  function launchClaude(opts) {
@@ -3021,6 +3150,44 @@ var init_server_lifecycle = __esm(() => {
3021
3150
  deps = defaultDeps;
3022
3151
  });
3023
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
+
3024
3191
  // src/engine/session.ts
3025
3192
  import { randomUUID } from "crypto";
3026
3193
  function forceFinalizeLive() {
@@ -3057,6 +3224,7 @@ function installExitHandlers() {
3057
3224
  process.on("SIGHUP", onSignal);
3058
3225
  }
3059
3226
  async function launch(opts) {
3227
+ pruneStaleContractMarkers();
3060
3228
  const existingCategory = getCategoryByPath(opts.categoryPath);
3061
3229
  if (existingCategory) {
3062
3230
  const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
@@ -3102,6 +3270,7 @@ async function launch(opts) {
3102
3270
  return session.id;
3103
3271
  }
3104
3272
  async function resume(opts) {
3273
+ pruneStaleContractMarkers();
3105
3274
  const session = getSession(opts.sessionId);
3106
3275
  if (!session)
3107
3276
  throw new Error(`Session not found: ${opts.sessionId}`);
@@ -3153,6 +3322,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
3153
3322
  });
3154
3323
  if (liveSession?.sessionId === sessionId)
3155
3324
  liveSession = null;
3325
+ pruneSessionMarkers(sessionId, safeConversationId);
3156
3326
  computeAndPersist(sessionId);
3157
3327
  stopServerIfIdle();
3158
3328
  triggerBackgroundPush();
@@ -3171,16 +3341,17 @@ var init_session = __esm(() => {
3171
3341
  init_server_lifecycle();
3172
3342
  init_trigger();
3173
3343
  init_transcript();
3344
+ init_runtime();
3174
3345
  });
3175
3346
 
3176
3347
  // src/tui/app.tsx
3177
3348
  import { spawn as spawn4 } from "child_process";
3178
3349
  import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
3179
3350
  import { tmpdir } from "os";
3180
- import { join as join10 } from "path";
3351
+ import { join as join11 } from "path";
3181
3352
  import { randomUUID as randomUUID2 } from "crypto";
3182
3353
  async function runScreen(screen, ...args) {
3183
- const tmpFile = join10(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
3354
+ const tmpFile = join11(tmpdir(), `bertrand-tui-${randomUUID2()}.json`);
3184
3355
  if (process.env.BERTRAND_DEBUG_TUI) {
3185
3356
  try {
3186
3357
  const { appendFileSync } = await import("fs");
@@ -3190,7 +3361,7 @@ async function runScreen(screen, ...args) {
3190
3361
  }
3191
3362
  const child = spawn4("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
3192
3363
  stdio: "inherit",
3193
- env: process.env
3364
+ env: { ...process.env, BERTRAND_PROJECT: resolveActiveProject().slug }
3194
3365
  });
3195
3366
  const noopSignal = () => {};
3196
3367
  process.on("SIGINT", noopSignal);
@@ -3337,35 +3508,11 @@ var init_app = __esm(() => {
3337
3508
  init_create();
3338
3509
  init_resolve();
3339
3510
  SCREEN_ENTRY = (() => {
3340
- const built = join10(import.meta.dir, "run-screen.js");
3341
- 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");
3342
3513
  })();
3343
3514
  });
3344
3515
 
3345
- // src/lib/parse-session-name.ts
3346
- function parseSessionName(input) {
3347
- const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
3348
- if (!trimmed) {
3349
- throw new Error("Session name cannot be empty");
3350
- }
3351
- const segments = trimmed.split("/").filter(Boolean);
3352
- if (segments.length < 2) {
3353
- throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
3354
- }
3355
- for (const segment of segments) {
3356
- if (!SEGMENT_PATTERN.test(segment)) {
3357
- throw new Error(`Invalid segment "${segment}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes`);
3358
- }
3359
- }
3360
- const categoryPath = segments[0];
3361
- const slug = segments.slice(1).join("/");
3362
- return { categoryPath, slug };
3363
- }
3364
- var SEGMENT_PATTERN;
3365
- var init_parse_session_name = __esm(() => {
3366
- SEGMENT_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
3367
- });
3368
-
3369
3516
  // src/engine/recovery.ts
3370
3517
  function isProcessAlive2(pid) {
3371
3518
  try {
@@ -3430,7 +3577,7 @@ var init_launch = __esm(() => {
3430
3577
  function quietHelper(bin) {
3431
3578
  return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
3432
3579
  }
3433
- function waitingScript(bin, runtimeDir) {
3580
+ function waitingScript(bin, runtimeDir2) {
3434
3581
  return `#!/usr/bin/env bash
3435
3582
  # Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
3436
3583
  ${quietHelper(bin)}
@@ -3455,7 +3602,7 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
3455
3602
  [ -z "$question" ] && question="Waiting for input"
3456
3603
 
3457
3604
  # Clear working debounce marker so next resume\u2192working transition fires
3458
- rm -f "${runtimeDir}/working-$sid"
3605
+ rm -f "${runtimeDir2}/working-$sid"
3459
3606
 
3460
3607
  bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3461
3608
 
@@ -3473,7 +3620,7 @@ bq notify bertrand "$question" &
3473
3620
  wait
3474
3621
  `;
3475
3622
  }
3476
- function answeredScript(bin, _runtimeDir) {
3623
+ function answeredScript(bin, runtimeDir2) {
3477
3624
  return `#!/usr/bin/env bash
3478
3625
  # Hook: PostToolUse AskUserQuestion \u2192 mark session as active
3479
3626
  #
@@ -3507,9 +3654,18 @@ bq update --session-id "$sid" --event session.answered --meta "$meta"
3507
3654
 
3508
3655
  bq badge --clear &
3509
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
+
3510
3662
  # Halt the agent loop if the user signaled Done for now. The Stop hook
3511
3663
  # (on-done.sh) will fire afterwards and mark the session as paused.
3512
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
+
3513
3669
  # Promote the picked Done-for-now option's description into a session.recap
3514
3670
  # event so the timeline has a dedicated end-of-session summary row. Bertrand
3515
3671
  # forces session exit before Claude can write a closing message, so this
@@ -3527,7 +3683,7 @@ fi
3527
3683
  wait
3528
3684
  `;
3529
3685
  }
3530
- function permissionWaitScript(bin, runtimeDir) {
3686
+ function permissionWaitScript(bin, runtimeDir2) {
3531
3687
  return `#!/usr/bin/env bash
3532
3688
  # Hook: PermissionRequest \u2192 mark pending, badge + notify
3533
3689
  ${quietHelper(bin)}
@@ -3541,7 +3697,7 @@ ${EXTRACT_TOOL}
3541
3697
  # Marker tells the PostToolUse hook to emit tool.used with outcome:approved
3542
3698
  # instead of outcome:auto. Without it, every prompted-then-approved tool call
3543
3699
  # would look identical to an auto-approved one.
3544
- touch "${runtimeDir}/perm-pending-$sid"
3700
+ touch "${runtimeDir2}/perm-pending-$sid"
3545
3701
 
3546
3702
  # Badge + notify in background
3547
3703
  bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
@@ -3549,7 +3705,7 @@ bq notify bertrand "Needs permission: $tool" &
3549
3705
  wait
3550
3706
  `;
3551
3707
  }
3552
- function permissionDoneScript(bin, runtimeDir) {
3708
+ function permissionDoneScript(bin, runtimeDir2) {
3553
3709
  return `#!/usr/bin/env bash
3554
3710
  # Hook: PostToolUse (catch-all)
3555
3711
  #
@@ -3569,10 +3725,11 @@ sid="\${BERTRAND_SESSION:-}"
3569
3725
  input="$(cat)"
3570
3726
  ${EXTRACT_TOOL}
3571
3727
 
3572
- # Don't double-log: AskUserQuestion has its own waiting/answered events
3573
- [ "$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
3574
3731
 
3575
- marker="${runtimeDir}/perm-pending-$sid"
3732
+ marker="${runtimeDir2}/perm-pending-$sid"
3576
3733
  had_marker=0
3577
3734
  if [ -f "$marker" ]; then
3578
3735
  had_marker=1
@@ -3626,9 +3783,17 @@ bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool"
3626
3783
  wait
3627
3784
  `;
3628
3785
  }
3629
- function userPromptScript(bin, _runtimeDir) {
3786
+ function userPromptScript(bin, runtimeDir2) {
3630
3787
  return `#!/usr/bin/env bash
3631
- # 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.
3632
3797
  ${quietHelper(bin)}
3633
3798
  sid="\${BERTRAND_SESSION:-}"
3634
3799
  [ -z "$sid" ] && exit 0
@@ -3636,34 +3801,102 @@ sid="\${BERTRAND_SESSION:-}"
3636
3801
  input="$(cat)"
3637
3802
  cid="\${BERTRAND_CLAUDE_ID:-}"
3638
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).
3639
3806
  meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
3640
- [ -z "$meta" ] && exit 0
3807
+ [ -n "$meta" ] && bq update --session-id "$sid" --event user.prompt --meta "$meta" >/dev/null
3641
3808
 
3642
- bq update --session-id "$sid" --event user.prompt --meta "$meta"
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
3817
+
3818
+ [ -n "$contract" ] && jq -n --arg c "$contract" '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
3643
3819
  `;
3644
3820
  }
3645
- function doneScript(bin, _runtimeDir) {
3821
+ function doneScript(bin, runtimeDir2) {
3646
3822
  return `#!/usr/bin/env bash
3647
- # Hook: Stop \u2192 flip session status to paused; no event row written.
3648
- # The status flip is driven by EVENT_STATUS_MAP[session.paused]; dispatchHookEvent
3649
- # no longer has a session.paused case, so update.ts flips status without inserting.
3823
+ # Hook: Stop \u2192 enforce AUQ loop, else flip session status to paused.
3650
3824
  ${quietHelper(bin)}
3651
3825
  sid="\${BERTRAND_SESSION:-}"
3652
3826
  [ -z "$sid" ] && exit 0
3653
3827
 
3654
3828
  input="$(cat)"
3655
3829
  cid="\${BERTRAND_CLAUDE_ID:-}"
3656
- bq update --session-id "$sid" --event session.paused
3657
3830
 
3658
- # Final assistant-message read. Dedups against the most-recent AskUQ-time
3659
- # capture, so a Done-for-now exit (no work between AskUQ and Stop) lands zero
3660
- # new events here; intermediate Stops with fresh assistant output do.
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.
3661
3838
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3662
3839
  if [ -n "$tpath" ]; then
3663
- 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 &
3664
3841
  fi
3665
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.
3859
+ fi
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
3666
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}')"
3667
3900
  `;
3668
3901
  }
3669
3902
  var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
@@ -3674,18 +3907,20 @@ var init_scripts = __esm(() => {
3674
3907
  "on-permission-wait.sh": permissionWaitScript,
3675
3908
  "on-permission-done.sh": permissionDoneScript,
3676
3909
  "on-user-prompt.sh": userPromptScript,
3677
- "on-done.sh": doneScript
3910
+ "on-done.sh": doneScript,
3911
+ "on-enter-worktree.sh": enterWorktreeScript,
3912
+ "on-exit-worktree.sh": exitWorktreeScript
3678
3913
  };
3679
3914
  });
3680
3915
 
3681
3916
  // src/hooks/install.ts
3682
3917
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
3683
- import { join as join11 } from "path";
3918
+ import { join as join12 } from "path";
3684
3919
  function installHookScripts(bin) {
3685
3920
  mkdirSync7(paths.hooks, { recursive: true });
3686
3921
  mkdirSync7(paths.runtime, { recursive: true });
3687
3922
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
3688
- const filePath = join11(paths.hooks, filename);
3923
+ const filePath = join12(paths.hooks, filename);
3689
3924
  writeFileSync5(filePath, scriptFn(bin, paths.runtime));
3690
3925
  chmodSync2(filePath, 493);
3691
3926
  }
@@ -3698,7 +3933,7 @@ var init_install = __esm(() => {
3698
3933
 
3699
3934
  // src/hooks/settings.ts
3700
3935
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
3701
- import { join as join12, dirname as dirname4 } from "path";
3936
+ import { join as join13, dirname as dirname4 } from "path";
3702
3937
  import { homedir as homedir3 } from "os";
3703
3938
  function isBertrandGroup(group) {
3704
3939
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -3723,7 +3958,7 @@ function installHookSettings() {
3723
3958
  var SETTINGS_PATH, BERTRAND_HOOKS;
3724
3959
  var init_settings = __esm(() => {
3725
3960
  init_paths();
3726
- SETTINGS_PATH = join12(homedir3(), ".claude", "settings.json");
3961
+ SETTINGS_PATH = join13(homedir3(), ".claude", "settings.json");
3727
3962
  BERTRAND_HOOKS = {
3728
3963
  PreToolUse: [
3729
3964
  {
@@ -3736,6 +3971,14 @@ var init_settings = __esm(() => {
3736
3971
  matcher: "AskUserQuestion",
3737
3972
  hooks: [{ type: "command", command: `${paths.hooks}/on-answered.sh` }]
3738
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
+ },
3739
3982
  {
3740
3983
  matcher: "",
3741
3984
  hooks: [{ type: "command", command: `${paths.hooks}/on-permission-done.sh` }]
@@ -3764,7 +4007,7 @@ var init_settings = __esm(() => {
3764
4007
 
3765
4008
  // src/lib/completions.ts
3766
4009
  import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
3767
- import { join as join13 } from "path";
4010
+ import { join as join14 } from "path";
3768
4011
  function bashCompletion() {
3769
4012
  return `# bertrand bash completion
3770
4013
  _bertrand() {
@@ -3794,11 +4037,11 @@ function fishCompletion() {
3794
4037
  `;
3795
4038
  }
3796
4039
  function generateCompletions() {
3797
- const dir = join13(paths.root, "completions");
4040
+ const dir = join14(paths.root, "completions");
3798
4041
  mkdirSync9(dir, { recursive: true });
3799
- writeFileSync7(join13(dir, "bertrand.bash"), bashCompletion());
3800
- writeFileSync7(join13(dir, "_bertrand"), zshCompletion());
3801
- 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());
3802
4045
  console.log(`Shell completions written to ${dir}`);
3803
4046
  console.log(" Add to your shell config:");
3804
4047
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -3829,7 +4072,7 @@ var init_completions = __esm(() => {
3829
4072
  var exports_init = {};
3830
4073
  import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
3831
4074
  import { execSync as execSync2 } from "child_process";
3832
- import { join as join14 } from "path";
4075
+ import { join as join15 } from "path";
3833
4076
  function detectTerminal() {
3834
4077
  try {
3835
4078
  execSync2("which wsh", { stdio: "ignore" });
@@ -3844,8 +4087,8 @@ function resolveBin() {
3844
4087
  return onPath;
3845
4088
  const entry = process.argv[1];
3846
4089
  if (entry && SOURCE_ENTRY.test(entry)) {
3847
- const launcherDir = join14(paths.root, "bin");
3848
- const launcher = join14(launcherDir, "bertrand-dev");
4090
+ const launcherDir = join15(paths.root, "bin");
4091
+ const launcher = join15(launcherDir, "bertrand-dev");
3849
4092
  mkdirSync10(launcherDir, { recursive: true });
3850
4093
  writeFileSync8(launcher, `#!/usr/bin/env bash
3851
4094
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
@@ -4056,6 +4299,9 @@ function extractSummary(row) {
4056
4299
  return meta.prompt ?? "";
4057
4300
  case "session.recap":
4058
4301
  return meta.recap ?? "";
4302
+ case "worktree.entered":
4303
+ case "worktree.exited":
4304
+ return meta.branch ?? meta.path ?? "";
4059
4305
  default:
4060
4306
  return "";
4061
4307
  }
@@ -4093,7 +4339,9 @@ var init_catalog = __esm(() => {
4093
4339
  "tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
4094
4340
  "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
4095
4341
  "assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
4096
- "assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false }
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 }
4097
4345
  };
4098
4346
  DEFAULT_INFO = {
4099
4347
  label: "unknown",
@@ -4400,13 +4648,11 @@ var init_log = __esm(() => {
4400
4648
  init_router();
4401
4649
  init_sessions();
4402
4650
  init_events();
4403
- init_categories();
4404
4651
  init_stats();
4405
4652
  init_conversations();
4406
4653
  init_catalog();
4407
4654
  init_compact();
4408
4655
  init_timing();
4409
- init_parse_session_name();
4410
4656
  init_format();
4411
4657
  init_resolve();
4412
4658
  init_cli_flag();
@@ -4426,18 +4672,12 @@ var init_log = __esm(() => {
4426
4672
  showAllSessions();
4427
4673
  return;
4428
4674
  }
4429
- const { categoryPath, slug } = parseSessionName(target);
4430
- const category = getCategoryByPath(categoryPath);
4431
- if (!category) {
4432
- console.error(`Category not found: ${categoryPath}`);
4433
- process.exit(1);
4434
- }
4435
- const session = getSessionByCategorySlug(category.id, slug);
4436
- if (!session) {
4675
+ const resolved = resolveSessionByName(target);
4676
+ if (!resolved) {
4437
4677
  console.error(`Session not found: ${target}`);
4438
4678
  process.exit(1);
4439
4679
  }
4440
- showSessionLog(session, `${categoryPath}/${slug}`, isJson);
4680
+ showSessionLog(resolved.session, `${resolved.categoryPath}/${resolved.slug}`, isJson);
4441
4681
  });
4442
4682
  });
4443
4683
 
@@ -4557,7 +4797,6 @@ var init_stats2 = __esm(() => {
4557
4797
  init_events();
4558
4798
  init_timing();
4559
4799
  init_format();
4560
- init_parse_session_name();
4561
4800
  ACTIVE_STATUSES2 = ["active", "waiting"];
4562
4801
  register("stats", async (args) => {
4563
4802
  const isJson = args.includes("--json");
@@ -4570,29 +4809,23 @@ var init_stats2 = __esm(() => {
4570
4809
  return;
4571
4810
  }
4572
4811
  if (target.endsWith("/")) {
4573
- const categoryPath2 = target.replace(/\/+$/, "");
4574
- const category2 = getCategoryByPath(categoryPath2);
4575
- if (!category2) {
4576
- 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}`);
4577
4816
  process.exit(1);
4578
4817
  }
4579
- const categorySessions = getSessionsByCategory(category2.id);
4818
+ const categorySessions = getSessionsByCategory(category.id);
4580
4819
  const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
4581
- renderCategory(metrics, categoryPath2, isJson);
4820
+ renderCategory(metrics, categoryPath, isJson);
4582
4821
  return;
4583
4822
  }
4584
- const { categoryPath, slug } = parseSessionName(target);
4585
- const category = getCategoryByPath(categoryPath);
4586
- if (!category) {
4587
- console.error(`Category not found: ${categoryPath}`);
4588
- process.exit(1);
4589
- }
4590
- const session = getSessionByCategorySlug(category.id, slug);
4591
- if (!session) {
4823
+ const resolved = resolveSessionByName(target);
4824
+ if (!resolved) {
4592
4825
  console.error(`Session not found: ${target}`);
4593
4826
  process.exit(1);
4594
4827
  }
4595
- const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
4828
+ const m = getMetrics(resolved.session.id, `${resolved.categoryPath}/${resolved.slug}`, resolved.session.status);
4596
4829
  renderSession(m, isJson);
4597
4830
  });
4598
4831
  });
@@ -4618,24 +4851,16 @@ var init_backfill_stats = __esm(() => {
4618
4851
  // src/cli/commands/archive.ts
4619
4852
  var exports_archive = {};
4620
4853
  function resolveSession(name) {
4621
- const { categoryPath, slug } = parseSessionName(name);
4622
- const category = getCategoryByPath(categoryPath);
4623
- if (!category) {
4624
- console.error(`Category not found: ${categoryPath}`);
4625
- process.exit(1);
4626
- }
4627
- const session = getSessionByCategorySlug(category.id, slug);
4628
- if (!session) {
4854
+ const resolved = resolveSessionByName(name);
4855
+ if (!resolved) {
4629
4856
  console.error(`Session not found: ${name}`);
4630
4857
  process.exit(1);
4631
4858
  }
4632
- return { session, categoryPath };
4859
+ return { session: resolved.session, categoryPath: resolved.categoryPath };
4633
4860
  }
4634
4861
  var init_archive = __esm(() => {
4635
4862
  init_router();
4636
4863
  init_sessions();
4637
- init_categories();
4638
- init_parse_session_name();
4639
4864
  init_session_archive();
4640
4865
  register("archive", async (args) => {
4641
4866
  const isUndo = args.includes("--undo");
@@ -4707,7 +4932,7 @@ __export(exports_project, {
4707
4932
  createSubcommand: () => createSubcommand,
4708
4933
  _UsageError: () => _UsageError
4709
4934
  });
4710
- import { existsSync as existsSync9, rmSync } from "fs";
4935
+ import { existsSync as existsSync9, rmSync as rmSync2 } from "fs";
4711
4936
  function countSessions(slug) {
4712
4937
  const dbFile = projectPaths(slug).db;
4713
4938
  if (!existsSync9(dbFile))
@@ -4887,7 +5112,7 @@ function removeSubcommand(args) {
4887
5112
  removeProject(slug);
4888
5113
  invalidateDbCache(slug);
4889
5114
  if (purge) {
4890
- rmSync(projectPaths(slug).root, { recursive: true, force: true });
5115
+ rmSync2(projectPaths(slug).root, { recursive: true, force: true });
4891
5116
  }
4892
5117
  console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
4893
5118
  }
@@ -4997,6 +5222,7 @@ var command = process.argv[2];
4997
5222
  var hotPath = {
4998
5223
  update: () => Promise.resolve().then(() => (init_update(), exports_update)),
4999
5224
  "assistant-message": () => Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
5225
+ contract: () => Promise.resolve().then(() => (init_contract(), exports_contract)),
5000
5226
  badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
5001
5227
  notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
5002
5228
  serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
@@ -5015,6 +5241,7 @@ if (command && command in hotPath) {
5015
5241
  Promise.resolve().then(() => (init_archive(), exports_archive)),
5016
5242
  Promise.resolve().then(() => (init_update(), exports_update)),
5017
5243
  Promise.resolve().then(() => (init_assistant_message(), exports_assistant_message)),
5244
+ Promise.resolve().then(() => (init_contract(), exports_contract)),
5018
5245
  Promise.resolve().then(() => (init_serve(), exports_serve)),
5019
5246
  Promise.resolve().then(() => (init_badge(), exports_badge)),
5020
5247
  Promise.resolve().then(() => (init_notify(), exports_notify)),