runcastle 1.2.6 → 1.2.7

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/index.js CHANGED
@@ -408,7 +408,9 @@ var init_schemas = __esm(() => {
408
408
  transcriptPath: z2.string().optional(),
409
409
  status: SessionStatus,
410
410
  awaitingInput: z2.boolean(),
411
- worktreePath: z2.string()
411
+ worktreePath: z2.string(),
412
+ title: z2.string().optional(),
413
+ createdAt: z2.number().optional()
412
414
  });
413
415
  Run = z2.object({
414
416
  id: z2.string(),
@@ -649,7 +651,9 @@ var init_db_schema = __esm(() => {
649
651
  transcriptPath: text("transcript_path"),
650
652
  status: text("status").notNull().$type(),
651
653
  awaitingInput: integer("awaiting_input", { mode: "boolean" }).notNull().default(false),
652
- worktreePath: text("worktree_path").notNull()
654
+ worktreePath: text("worktree_path").notNull(),
655
+ title: text("title"),
656
+ createdAt: integer("created_at")
653
657
  });
654
658
  tickets = sqliteTable("tickets", {
655
659
  id: text("id").primaryKey(),
@@ -947,24 +951,49 @@ var init_resolve_executable = __esm(() => {
947
951
  });
948
952
 
949
953
  // src/pty/kill-tree.ts
950
- import { execFile } from "child_process";
951
- import { promisify } from "util";
954
+ import { spawn } from "child_process";
955
+ function taskkillTree(pid) {
956
+ return new Promise((resolve3) => {
957
+ let settled = false;
958
+ const settle = () => {
959
+ if (settled)
960
+ return;
961
+ settled = true;
962
+ clearTimeout(timer);
963
+ resolve3();
964
+ };
965
+ const timer = setTimeout(settle, TASKKILL_TIMEOUT_MS);
966
+ timer.unref?.();
967
+ try {
968
+ const child = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
969
+ windowsHide: true,
970
+ stdio: "ignore"
971
+ });
972
+ child.on("exit", settle);
973
+ child.on("close", settle);
974
+ child.on("error", settle);
975
+ } catch {
976
+ settle();
977
+ }
978
+ });
979
+ }
952
980
  async function killProcessTree(pid) {
953
- try {
954
- if (process.platform === "win32") {
955
- await execFileAsync("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true });
956
- } else {
981
+ const started = Date.now();
982
+ const isWin32 = process.platform === "win32";
983
+ if (isWin32) {
984
+ await taskkillTree(pid);
985
+ } else {
986
+ try {
957
987
  process.kill(-pid, "SIGTERM");
958
- }
959
- } catch {}
988
+ } catch {}
989
+ }
990
+ console.error(`[pty-teardown] killProcessTree: pid=${pid} branch=${isWin32 ? "taskkill" : "pgroup"} settled after ${Date.now() - started}ms`);
960
991
  }
961
- var execFileAsync;
962
- var init_kill_tree = __esm(() => {
963
- execFileAsync = promisify(execFile);
964
- });
992
+ var TASKKILL_TIMEOUT_MS = 3000;
993
+ var init_kill_tree = () => {};
965
994
 
966
995
  // src/pty/pty-sidecar.ts
967
- import { spawn } from "child_process";
996
+ import { spawn as spawn2 } from "child_process";
968
997
  import { createRequire } from "module";
969
998
  import { existsSync as existsSync4 } from "fs";
970
999
  import { fileURLToPath } from "url";
@@ -990,7 +1019,7 @@ function createSidecarPtySession(cmd, args, opts) {
990
1019
  const nodeExe = resolveNodeExecutable();
991
1020
  const nodePtyEntry = resolveNodePtyEntry();
992
1021
  const hostArgs = nodePtyEntry ? [HOST_PATH, nodePtyEntry] : [HOST_PATH];
993
- const child = spawn(nodeExe, hostArgs, {
1022
+ const child = spawn2(nodeExe, hostArgs, {
994
1023
  stdio: ["pipe", "pipe", "pipe"],
995
1024
  windowsHide: true
996
1025
  });
@@ -1299,15 +1328,36 @@ var init_ring_buffer = __esm(() => {
1299
1328
  });
1300
1329
 
1301
1330
  // src/pty/registry.ts
1302
- function killTreeBounded(pty) {
1331
+ function log(msg) {
1332
+ console.error(`[pty-teardown] ${msg}`);
1333
+ }
1334
+ function withDeadline(body, ms) {
1303
1335
  return new Promise((resolve3) => {
1304
- const timer = setTimeout(resolve3, KILL_TREE_TIMEOUT_MS);
1305
- pty.killTree().catch(() => {}).then(() => {
1336
+ const timer = setTimeout(() => resolve3(false), ms);
1337
+ body().catch(() => {}).then(() => {
1306
1338
  clearTimeout(timer);
1307
- resolve3();
1339
+ resolve3(true);
1308
1340
  });
1309
1341
  });
1310
1342
  }
1343
+ async function tearDownEntry(entry) {
1344
+ const id = entry.sessionId;
1345
+ if (entry.exited) {
1346
+ log(`${id}: skip (exited=true)`);
1347
+ return;
1348
+ }
1349
+ const ptyPid = entry.pty.pid;
1350
+ const started = Date.now();
1351
+ log(`${id}: start exited=false ptyPid=${ptyPid} t=${started}`);
1352
+ const finished = await withDeadline(async () => {
1353
+ await entry.pty.killTree();
1354
+ log(`${id}: killTree settled after ${Date.now() - started}ms`);
1355
+ try {
1356
+ entry.pty.kill();
1357
+ } catch {}
1358
+ }, TEARDOWN_TIMEOUT_MS);
1359
+ log(`${id}: done after ${Date.now() - started}ms deadline=${finished ? "no" : "FIRED"}`);
1360
+ }
1311
1361
 
1312
1362
  class PtyRegistry {
1313
1363
  entries = new Map;
@@ -1364,16 +1414,20 @@ class PtyRegistry {
1364
1414
  }
1365
1415
  kill(sessionId) {
1366
1416
  const entry = this.entries.get(sessionId);
1367
- if (!entry)
1417
+ if (!entry) {
1418
+ log(`${sessionId}: registry miss (kill)`);
1368
1419
  return false;
1369
- this.tearDown(entry);
1420
+ }
1421
+ tearDownEntry(entry);
1370
1422
  return true;
1371
1423
  }
1372
1424
  async killTree(sessionId) {
1373
1425
  const entry = this.entries.get(sessionId);
1374
- if (!entry)
1426
+ if (!entry) {
1427
+ log(`${sessionId}: registry miss (killTree)`);
1375
1428
  return false;
1376
- await this.tearDown(entry);
1429
+ }
1430
+ await tearDownEntry(entry);
1377
1431
  return true;
1378
1432
  }
1379
1433
  remove(sessionId) {
@@ -1382,15 +1436,7 @@ class PtyRegistry {
1382
1436
  async killAllTrees() {
1383
1437
  const all = [...this.entries.values()];
1384
1438
  this.entries.clear();
1385
- await Promise.allSettled(all.map((entry) => this.tearDown(entry)));
1386
- }
1387
- async tearDown(entry) {
1388
- if (entry.exited)
1389
- return;
1390
- await killTreeBounded(entry.pty);
1391
- try {
1392
- entry.pty.kill();
1393
- } catch {}
1439
+ await Promise.allSettled(all.map((entry) => tearDownEntry(entry)));
1394
1440
  }
1395
1441
  ids() {
1396
1442
  return [...this.entries.keys()];
@@ -1402,7 +1448,7 @@ function ptyRegistry() {
1402
1448
  g[REGISTRY_KEY] = new PtyRegistry;
1403
1449
  return g[REGISTRY_KEY];
1404
1450
  }
1405
- var KILL_TREE_TIMEOUT_MS = 5000, REGISTRY_KEY;
1451
+ var TEARDOWN_TIMEOUT_MS = 5000, REGISTRY_KEY;
1406
1452
  var init_registry = __esm(() => {
1407
1453
  init_pty();
1408
1454
  init_ring_buffer();
@@ -1630,7 +1676,9 @@ function rowToSession(row) {
1630
1676
  transcriptPath: row.transcriptPath ?? undefined,
1631
1677
  status: row.status,
1632
1678
  awaitingInput: row.awaitingInput,
1633
- worktreePath: row.worktreePath
1679
+ worktreePath: row.worktreePath,
1680
+ title: row.title ?? undefined,
1681
+ createdAt: row.createdAt ?? undefined
1634
1682
  });
1635
1683
  }
1636
1684
  function tryGetFeature(ctx, id) {
@@ -2094,7 +2142,7 @@ function sleep(ms, signal) {
2094
2142
  var APP_READY_BUDGET_MS = 120000, APP_READY_INTERVAL_MS = 1000, APP_READY_ATTEMPT_TIMEOUT_MS = 5000;
2095
2143
 
2096
2144
  // src/services/drive-hooks.ts
2097
- import { spawn as spawn2 } from "child_process";
2145
+ import { spawn as spawn3 } from "child_process";
2098
2146
  function tailLines(text2, max2 = OUTPUT_TAIL_LINES) {
2099
2147
  const lines = text2.replace(/\r\n/g, `
2100
2148
  `).split(`
@@ -2115,7 +2163,7 @@ function hookSpawnTarget(command) {
2115
2163
  return { file: "/bin/sh", args: ["-c", command], verbatim: false };
2116
2164
  }
2117
2165
  function runDriveHook(command, opts) {
2118
- const spawnFn = opts.spawnFn ?? spawn2;
2166
+ const spawnFn = opts.spawnFn ?? spawn3;
2119
2167
  const now = opts.now ?? Date.now;
2120
2168
  const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
2121
2169
  const startedAt = now();
@@ -3508,7 +3556,9 @@ function createSessionRow(ctx, input) {
3508
3556
  ccSessionId: null,
3509
3557
  transcriptPath: null,
3510
3558
  status: "launching",
3511
- worktreePath: input.worktreePath
3559
+ worktreePath: input.worktreePath,
3560
+ title: null,
3561
+ createdAt: Date.now()
3512
3562
  }).returning().get();
3513
3563
  return rowToSession(row);
3514
3564
  }
@@ -3795,6 +3845,12 @@ function activeProjectSession(ctx, projectId, kind) {
3795
3845
  const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3796
3846
  return row ? rowToSession(row) : null;
3797
3847
  }
3848
+ function projectSessions(ctx, projectId, kind) {
3849
+ return ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind))).orderBy(desc3(sql2`rowid`)).all().map(rowToSession);
3850
+ }
3851
+ function setSessionTitle(ctx, id, title) {
3852
+ ctx.db.update(sessions).set({ title }).where(eq5(sessions.id, id)).run();
3853
+ }
3798
3854
  function hasCompletedProjectSession(ctx, projectId, kind) {
3799
3855
  return ctx.db.select({ id: sessions.id }).from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), eq5(sessions.status, "ended"))).limit(1).get() !== undefined;
3800
3856
  }
@@ -4512,7 +4568,7 @@ function renderProjectPrompt(brief) {
4512
4568
  "## runcastle MCP tools",
4513
4569
  "Four, and none of the feature pipeline's \u2014 a session with no feature has no business",
4514
4570
  "advancing one through a gate:",
4515
- "- `create_feature({ title, oneLiner, baseBranch?, brief?, ticket? })` \u2014 the point of",
4571
+ "- `create_feature({ title, oneLiner, baseBranch?, brief?, tickets? })` \u2014 the point of",
4516
4572
  " this session. It does NOT open a terminal on what it creates; the new card appearing",
4517
4573
  " in the rail is the feedback, and the human decides what to work on next.",
4518
4574
  "- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
@@ -7001,13 +7057,47 @@ async function startDraft(ctx, featureId, opts = {}) {
7001
7057
  }
7002
7058
  return started;
7003
7059
  }
7060
+ function quickTicketTitle(prose) {
7061
+ const line = prose.split(`
7062
+ `)[0].trim();
7063
+ if (line.length <= TICKET_TITLE_MAX)
7064
+ return line;
7065
+ const cut = line.slice(0, TICKET_TITLE_MAX);
7066
+ const space = cut.lastIndexOf(" ");
7067
+ return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}\u2026`;
7068
+ }
7069
+ function quickBrief(title, proses) {
7070
+ const body = proses.length === 1 ? proses[0] : proses.map((p, i) => `## Ticket ${i + 1}
7071
+
7072
+ ${p}`).join(`
7073
+
7074
+ `);
7075
+ return `# ${title}
7076
+
7077
+ ${body}
7078
+ `;
7079
+ }
7080
+ function quickReviewTicket(proses) {
7081
+ return {
7082
+ title: "Review the integrated change",
7083
+ goal: "Review the integrated feature branch and report what you find. Code-review the branch's " + "diff against its base \u2014 always, whatever this change turned out to be \u2014 and additionally " + "drive the app when the diff touches something a human can operate. Write one note per " + "finding; finding bugs is a successful review, and the notes are the deliverable.",
7084
+ context: "This feature came through the quick-change door, so there was no grill session and there " + "is no spec.md or decisions.md to review it against: the whole statement of intent is the " + `${proses.length === 1 ? "sentence" : `${proses.length} sentences`} the human typed, which ` + "are the other tickets in this batch and are reproduced verbatim in brief.md. Nobody " + "prescribed a walkthrough for you either \u2014 judge from the diff whether there is anything " + "drivable, and if there is not, say so and let the code review stand alone. Your digest is " + "the lap's prose summary of what landed; the review page leads with it.",
7085
+ acceptanceCriteria: [
7086
+ "The branch's diff against its base is code-reviewed on both axes \u2014 the repo's own " + "standards, and the change against what was asked for.",
7087
+ ...proses.map((prose) => `Landed and does what it says: ${prose}`)
7088
+ ],
7089
+ seams: [],
7090
+ blockedBy: proses.map((_, i) => i + 1),
7091
+ kind: "review"
7092
+ };
7093
+ }
7004
7094
  async function quickChange(ctx, input) {
7005
7095
  const project = requireProjectById(ctx, input.projectId);
7006
7096
  const title = input.title.trim();
7007
- const prose = input.prose.trim();
7097
+ const proses = input.tickets.map((t) => t.trim()).filter((t) => t !== "");
7008
7098
  if (!title)
7009
7099
  throw new InvalidInputError("a quick change needs a title");
7010
- if (!prose)
7100
+ if (proses.length === 0)
7011
7101
  throw new InvalidInputError("a quick change needs a sentence describing the change");
7012
7102
  const slug = uniqueSlug(ctx, project.id, title);
7013
7103
  const branch = `feature/${slug}`;
@@ -7018,7 +7108,7 @@ async function quickChange(ctx, input) {
7018
7108
  projectId: project.id,
7019
7109
  slug,
7020
7110
  title,
7021
- oneLiner: prose.split(`
7111
+ oneLiner: proses[0].split(`
7022
7112
  `)[0].trim(),
7023
7113
  mapped: false,
7024
7114
  lap: 1,
@@ -7034,25 +7124,25 @@ async function quickChange(ctx, input) {
7034
7124
  message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
7035
7125
  data: { slug, branch, baseBranch, branchReady }
7036
7126
  });
7037
- scaffoldDocs(ctx, feature, { brief: `# ${title}
7038
-
7039
- ${prose}
7040
- ` });
7041
- const [ticket] = storeTickets(ctx, feature.id, [
7042
- {
7043
- title,
7127
+ scaffoldDocs(ctx, feature, { brief: quickBrief(title, proses) });
7128
+ const stored = storeTickets(ctx, feature.id, [
7129
+ ...proses.map((prose) => ({
7130
+ title: quickTicketTitle(prose),
7044
7131
  goal: prose,
7045
7132
  context: prose,
7046
7133
  acceptanceCriteria: [prose],
7047
7134
  seams: [],
7048
7135
  blockedBy: []
7049
- }
7136
+ })),
7137
+ quickReviewTicket(proses)
7050
7138
  ]);
7139
+ const typed = stored.slice(0, proses.length);
7140
+ const review = stored[stored.length - 1];
7141
+ const tally = typed.length === 1 ? "one ticket" : `${typed.length} tickets`;
7051
7142
  emit(ctx, feature.id, {
7052
7143
  type: "feature.quick_change",
7053
- message: `quick change \u2014 born at implementation on lap 1 with one ticket (#${ticket.seq}); no grill session, no spec.md`,
7054
- ticketId: ticket.id,
7055
- data: { slug, ticketSeq: ticket.seq, phase: "implementation" }
7144
+ message: `quick change \u2014 born at implementation on lap 1 with ${tally} (${typed.map((t) => `#${t.seq}`).join(", ")}) plus a review ticket (#${review.seq}); no grill session, no spec.md`,
7145
+ data: { slug, ticketSeqs: stored.map((t) => t.seq), phase: "implementation" }
7056
7146
  });
7057
7147
  if (branchReady) {
7058
7148
  try {
@@ -7392,6 +7482,7 @@ function slugify(title) {
7392
7482
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7393
7483
  return slug || "feature";
7394
7484
  }
7485
+ var TICKET_TITLE_MAX = 72;
7395
7486
  var init_features = __esm(() => {
7396
7487
  init_src();
7397
7488
  init_paths();
@@ -7527,20 +7618,47 @@ function promotionTicket(feature, note) {
7527
7618
  blockedBy: []
7528
7619
  };
7529
7620
  }
7621
+ function freezeAsTickets(ctx, noteIds) {
7622
+ if (noteIds.length === 0)
7623
+ throw new InvalidInputError("no notes selected to promote");
7624
+ if (new Set(noteIds).size !== noteIds.length)
7625
+ throw new InvalidInputError("the same note appears twice in the selection");
7626
+ const selected = noteIds.map((id) => getNote(ctx, id));
7627
+ for (const note of selected)
7628
+ assertOpen(note, "promote");
7629
+ const featureId = selected[0].featureId;
7630
+ if (selected.some((n) => n.featureId !== featureId))
7631
+ throw new InvalidInputError("every note in a promotion must belong to the same feature");
7632
+ const feature = getFeatureRow(ctx, featureId);
7633
+ const tickets2 = storeTickets(ctx, featureId, selected.map((note) => promotionTicket(feature, note)));
7634
+ const now = Date.now();
7635
+ selected.forEach((note, i) => {
7636
+ ctx.db.update(testNotes).set({ status: "promoted", ticketId: tickets2[i].id, updatedAt: now }).where(eq10(testNotes.id, note.id)).run();
7637
+ });
7638
+ return { feature, notes: selected.map((n) => getNote(ctx, n.id)), tickets: tickets2 };
7639
+ }
7530
7640
  function promoteNote(ctx, noteId) {
7531
- const current2 = getNote(ctx, noteId);
7532
- assertOpen(current2, "promote");
7533
- const feature = getFeatureRow(ctx, current2.featureId);
7534
- const [ticket] = storeTickets(ctx, current2.featureId, [promotionTicket(feature, current2)]);
7535
- ctx.db.update(testNotes).set({ status: "promoted", ticketId: ticket.id, updatedAt: Date.now() }).where(eq10(testNotes.id, noteId)).run();
7536
- emit(ctx, current2.featureId, {
7641
+ const { feature, notes, tickets: tickets2 } = freezeAsTickets(ctx, [noteId]);
7642
+ const [note] = notes;
7643
+ const [ticket] = tickets2;
7644
+ emit(ctx, feature.id, {
7537
7645
  type: "note.promoted",
7538
7646
  message: `note promoted to ticket ${ticket.seq}`,
7539
7647
  ticketId: ticket.id,
7540
7648
  data: { noteId, seq: ticket.seq }
7541
7649
  });
7542
7650
  renderTestNotes(ctx, feature);
7543
- return { note: getNote(ctx, noteId), ticket };
7651
+ return { note, ticket };
7652
+ }
7653
+ function promoteMany(ctx, noteIds) {
7654
+ const { feature, notes, tickets: tickets2 } = freezeAsTickets(ctx, noteIds);
7655
+ emit(ctx, feature.id, {
7656
+ type: "notes.promoted",
7657
+ message: `${notes.length} note${notes.length === 1 ? "" : "s"} promoted to ticket${tickets2.length === 1 ? ` ${tickets2[0].seq}` : `s ${tickets2.map((t) => t.seq).join(", ")}`}`,
7658
+ data: { noteIds, seqs: tickets2.map((t) => t.seq) }
7659
+ });
7660
+ renderTestNotes(ctx, feature);
7661
+ return { notes, tickets: tickets2 };
7544
7662
  }
7545
7663
  function noteLine(ctx, note) {
7546
7664
  if (note.status === "open")
@@ -7807,17 +7925,17 @@ function createFeatureProject(ctx, session, input) {
7807
7925
  if (!DRAFTING_KINDS.includes(session.kind)) {
7808
7926
  throw new GateError(`a ${session.kind} session is read-only and may not create features, drafts included. ` + "Tell the human what is worth capturing; the project session is where features are made.");
7809
7927
  }
7810
- if (!input.draft || input.ticket) {
7811
- throw new GateError(`a ${session.kind} session may only PARK a feature here: call create_feature with ` + "`draft: true` and a `brief` carrying why you deferred it. Cutting a branch \u2014 a full " + "create or the quick-change `ticket` shape \u2014 belongs to the project session; tell the " + "human to open it.");
7928
+ if (!input.draft || input.tickets) {
7929
+ throw new GateError(`a ${session.kind} session may only PARK a feature here: call create_feature with ` + "`draft: true` and a `brief` carrying why you deferred it. Cutting a branch \u2014 a full " + "create or the quick-change `tickets` shape \u2014 belongs to the project session; tell the " + "human to open it.");
7812
7930
  }
7813
7931
  return projectForFeature(ctx, getFeatureRow(ctx, requireFeatureId(session)));
7814
7932
  }
7815
7933
  async function toolCreateFeature(ctx, session, input) {
7816
7934
  const project = createFeatureProject(ctx, session, input);
7817
- const feature = input.ticket ? await quickChange(ctx, {
7935
+ const feature = input.tickets ? await quickChange(ctx, {
7818
7936
  projectId: project.id,
7819
7937
  title: input.title,
7820
- prose: input.ticket.prose,
7938
+ tickets: input.tickets,
7821
7939
  baseBranch: input.baseBranch
7822
7940
  }) : await createFeature(ctx, {
7823
7941
  projectId: project.id,
@@ -7957,7 +8075,7 @@ function buildMcpServer() {
7957
8075
  });
7958
8076
  server.registerTool("add_test_note", {
7959
8077
  title: "Add a review note",
7960
- description: "Record one review finding on the feature you are reviewing. It lands in the same notes " + "the human writes while test-driving \u2014 attributed to you \u2014 so it shows up in the review " + "panel and can be promoted to a fix ticket in one click. One note per finding, written " + "as an observation the human can reproduce (what you did, what happened, what you " + "expected), plus a closing note summarising the pass. Findings are not failure: report " + "what you saw and let the human decide.",
8078
+ description: "Record one review finding on the feature you are reviewing. It lands in the same notes " + "the human writes while test-driving \u2014 attributed to you \u2014 so it shows up in the review " + "panel, where the human triages every open note at once into fix tickets or the next " + "lap\u2019s session. One note per finding, written " + "as an observation the human can reproduce (what you did, what happened, what you " + "expected), plus a closing note summarising the pass. Findings are not failure: report " + "what you saw and let the human decide.",
7961
8079
  inputSchema: { text: z3.string().min(1) }
7962
8080
  }, async (args, extra) => {
7963
8081
  const rc = await resolveRunCaller(extra);
@@ -7975,14 +8093,14 @@ function buildMcpServer() {
7975
8093
  });
7976
8094
  server.registerTool("create_feature", {
7977
8095
  title: "Create a feature",
7978
- description: "Create a feature from the project session \u2014 the end of intake. Pass `brief` with the " + "reasoning you just worked out with the human (why this feature exists, what it must NOT " + "swallow): it becomes brief.md verbatim, and without it that reasoning is lost. Pass " + "`draft: true` to PARK it instead of starting it \u2014 a row and its brief, no branch and no " + "files, until the human clicks Start; ask them per feature whether to start it now or " + "park it. Pass " + "`ticket: { prose }` for a quick change \u2014 work too small to deserve a grill \u2014 which " + "creates the feature at the implementation phase with that one ticket, and whose brief.md " + "is the prose itself (so `brief` is unused there). From a feature session (ideation, " + "revisit, waypoint, converge) this tool parks drafts and nothing else: `draft: true` is " + "required and the `ticket` shape is refused, so deflect scope creep here and leave full " + "creation to the project session. This does NOT open " + "a terminal on what it creates; the new card in the rail is the feedback, and the human " + "decides what to work on next.",
8096
+ description: "Create a feature from the project session \u2014 the end of intake. Pass `brief` with the " + "reasoning you just worked out with the human (why this feature exists, what it must NOT " + "swallow): it becomes brief.md verbatim, and without it that reasoning is lost. Pass " + "`draft: true` to PARK it instead of starting it \u2014 a row and its brief, no branch and no " + "files, until the human clicks Start; ask them per feature whether to start it now or " + "park it. Pass " + "`tickets: [prose, ...]` for a quick change \u2014 work too small to deserve a grill \u2014 which " + "creates ONE feature at the implementation phase carrying a ticket per prose (plus the " + "review ticket every batch closes with), and whose brief.md is those proses themselves " + "(so `brief` is unused there). Send every sentence of one quick change in a single call: " + "calling this " + "once per ticket would make one feature per ticket. From a feature session (ideation, " + "revisit, waypoint, converge) this tool parks drafts and nothing else: `draft: true` is " + "required and the `tickets` shape is refused, so deflect scope creep here and leave full " + "creation to the project session. This does NOT open " + "a terminal on what it creates; the new card in the rail is the feedback, and the human " + "decides what to work on next.",
7979
8097
  inputSchema: {
7980
8098
  title: z3.string().min(1),
7981
8099
  oneLiner: z3.string(),
7982
8100
  baseBranch: z3.string().optional(),
7983
8101
  brief: z3.string().optional(),
7984
8102
  draft: z3.boolean().optional(),
7985
- ticket: z3.object({ prose: z3.string().min(1) }).optional()
8103
+ tickets: z3.array(z3.string()).min(1).optional()
7986
8104
  }
7987
8105
  }, async (args, extra) => {
7988
8106
  const rs = await resolveCtxSession(extra);
@@ -9328,17 +9446,30 @@ async function launchProjectSession(ctx, input, opts = {}) {
9328
9446
  kind: "project",
9329
9447
  worktreePath
9330
9448
  });
9331
- const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "project")?.ccSessionId;
9449
+ const resumeRowId = input.fresh ? undefined : input.resumeSessionId;
9450
+ const resumeSessionId = resumeRowId ? getSessionRow(ctx, resumeRowId)?.ccSessionId ?? undefined : undefined;
9332
9451
  emitProject(ctx, project.id, {
9333
9452
  type: "session.launching",
9334
- message: "launching project session",
9335
- data: { sessionId: session.id, kind: "project", worktreePath, branch: PROJECT_BRANCH }
9453
+ message: resumeSessionId ? "resuming a project conversation" : "launching a new project chat",
9454
+ data: {
9455
+ sessionId: session.id,
9456
+ kind: "project",
9457
+ worktreePath,
9458
+ branch: PROJECT_BRANCH,
9459
+ ...resumeSessionId ? {} : { fresh: true }
9460
+ }
9336
9461
  });
9337
9462
  if (resumeSessionId) {
9338
9463
  emitProject(ctx, project.id, {
9339
9464
  type: "session.resumed",
9340
9465
  message: "resuming the previous project conversation",
9341
- data: { sessionId: session.id, resumeSessionId }
9466
+ data: { sessionId: session.id, resumeSessionId, resumedFrom: resumeRowId }
9467
+ });
9468
+ } else if (resumeRowId) {
9469
+ emitProject(ctx, project.id, {
9470
+ type: "session.resume_unavailable",
9471
+ message: "that conversation was never picked up by Claude Code \u2014 starting a new chat instead",
9472
+ data: { sessionId: session.id, resumedFrom: resumeRowId }
9342
9473
  });
9343
9474
  }
9344
9475
  const artifacts = await writeSessionArtifacts({
@@ -9663,7 +9794,7 @@ var init_feature = __esm(() => {
9663
9794
  quickChange: publicProcedure.input(z6.object({
9664
9795
  projectId: z6.string(),
9665
9796
  title: z6.string().min(1),
9666
- prose: z6.string().min(1),
9797
+ tickets: z6.array(z6.string()).min(1),
9667
9798
  baseBranch: z6.string().optional()
9668
9799
  })).mutation(({ ctx, input }) => quickChange(ctx, input)),
9669
9800
  list: publicProcedure.input(z6.object({ projectId: z6.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
@@ -9863,6 +9994,98 @@ var init_fsbrowse = __esm(() => {
9863
9994
  COMMON_DIRS = ["Projects", "projects", "code", "Code", "src", "dev", "repos", "git"];
9864
9995
  });
9865
9996
 
9997
+ // src/services/transcripts.ts
9998
+ import { readFileSync as readFileSync10 } from "fs";
9999
+ function contentText(content) {
10000
+ if (typeof content === "string")
10001
+ return content.trim();
10002
+ if (!Array.isArray(content))
10003
+ return "";
10004
+ return content.map((block) => block && typeof block === "object" && block.type === "text" ? String(block.text ?? "") : "").join("").trim();
10005
+ }
10006
+ function parseTranscript(jsonl) {
10007
+ const turns = [];
10008
+ for (const line of jsonl.split(`
10009
+ `)) {
10010
+ const trimmed = line.trim();
10011
+ if (!trimmed)
10012
+ continue;
10013
+ let entry;
10014
+ try {
10015
+ entry = JSON.parse(trimmed);
10016
+ } catch {
10017
+ continue;
10018
+ }
10019
+ if (entry.type !== "user" && entry.type !== "assistant")
10020
+ continue;
10021
+ if (entry.isMeta)
10022
+ continue;
10023
+ const text2 = contentText(entry.message?.content);
10024
+ if (!text2)
10025
+ continue;
10026
+ turns.push({ role: entry.type, text: text2 });
10027
+ }
10028
+ return turns;
10029
+ }
10030
+ function readTranscript2(path) {
10031
+ if (!path)
10032
+ return [];
10033
+ try {
10034
+ return parseTranscript(readFileSync10(path, "utf8"));
10035
+ } catch {
10036
+ return [];
10037
+ }
10038
+ }
10039
+ var init_transcripts = () => {};
10040
+
10041
+ // src/services/conversations.ts
10042
+ function withoutKickoff(turns, kind) {
10043
+ const kickoff = kickoffLineFor(kind);
10044
+ return turns.filter((turn) => !(turn.role === "user" && promptMatchesKickoff(kickoff, turn.text)));
10045
+ }
10046
+ function deriveTitle(turns) {
10047
+ const first = withoutKickoff(turns, "project").find((turn) => turn.role === "user");
10048
+ return first ? elide(first.text) : null;
10049
+ }
10050
+ function elide(text2) {
10051
+ const line = text2.replace(/\s+/g, " ").trim();
10052
+ if (line.length <= TITLE_MAX2)
10053
+ return line;
10054
+ return `${line.slice(0, TITLE_MAX2).trimEnd()}\u2026`;
10055
+ }
10056
+ function untitled(createdAt) {
10057
+ return createdAt ? `Chat from ${new Date(createdAt).toISOString().slice(0, 10)}` : "Untitled chat";
10058
+ }
10059
+ function listProjectConversations(ctx, projectId) {
10060
+ return projectSessions(ctx, projectId, "project").map((session) => {
10061
+ const createdAt = session.createdAt ?? null;
10062
+ let title = session.title ?? null;
10063
+ if (!title) {
10064
+ title = deriveTitle(readTranscript2(session.transcriptPath));
10065
+ if (title)
10066
+ setSessionTitle(ctx, session.id, title);
10067
+ }
10068
+ return {
10069
+ id: session.id,
10070
+ title: title ?? untitled(createdAt),
10071
+ createdAt,
10072
+ status: session.status,
10073
+ resumable: !!session.ccSessionId
10074
+ };
10075
+ });
10076
+ }
10077
+ function conversationTranscript(ctx, sessionId) {
10078
+ const session = getSessionRow(ctx, sessionId);
10079
+ if (!session)
10080
+ return [];
10081
+ return withoutKickoff(readTranscript2(session.transcriptPath), session.kind);
10082
+ }
10083
+ var TITLE_MAX2 = 60;
10084
+ var init_conversations = __esm(() => {
10085
+ init_sessions();
10086
+ init_transcripts();
10087
+ });
10088
+
9866
10089
  // src/services/projects.ts
9867
10090
  import { existsSync as existsSync17 } from "fs";
9868
10091
  import { basename as basename2, join as join18 } from "path";
@@ -9980,6 +10203,7 @@ var init_project = __esm(() => {
9980
10203
  init_prep();
9981
10204
  init_launcher();
9982
10205
  init_sessions();
10206
+ init_conversations();
9983
10207
  init_projects();
9984
10208
  init_repo();
9985
10209
  init_context();
@@ -9993,8 +10217,14 @@ var init_project = __esm(() => {
9993
10217
  rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: ProjectName })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
9994
10218
  talkToPrep: publicProcedure.input(z7.object({ projectId: z7.string(), fresh: z7.boolean().optional() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId, fresh: input.fresh })),
9995
10219
  prepSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
9996
- talkToProject: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => launchProjectSession(ctx, { projectId: input.projectId })),
10220
+ talkToProject: publicProcedure.input(z7.object({
10221
+ projectId: z7.string(),
10222
+ fresh: z7.boolean().optional(),
10223
+ resumeSessionId: z7.string().optional()
10224
+ })).mutation(({ ctx, input }) => launchProjectSession(ctx, input)),
9997
10225
  projectSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
10226
+ conversations: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listProjectConversations(ctx, input.projectId)),
10227
+ conversationTranscript: publicProcedure.input(z7.object({ sessionId: z7.string() })).query(({ ctx, input }) => conversationTranscript(ctx, input.sessionId)),
9998
10228
  prep: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => prepView(ctx, requireProjectById(ctx, input.projectId))),
9999
10229
  dryRunStop: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => dryRunDrive(ctx, requireProjectById(ctx, input.projectId), "stop"))
10000
10230
  });
@@ -10020,7 +10250,7 @@ var init_run = __esm(() => {
10020
10250
  });
10021
10251
 
10022
10252
  // src/services/settings.ts
10023
- import { existsSync as existsSync18, mkdirSync as mkdirSync11, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
10253
+ import { existsSync as existsSync18, mkdirSync as mkdirSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
10024
10254
  import { dirname as dirname10 } from "path";
10025
10255
  import * as z9 from "zod";
10026
10256
  import { eq as eq14 } from "drizzle-orm";
@@ -10028,7 +10258,7 @@ function readRawConfig(configFile) {
10028
10258
  if (!existsSync18(configFile))
10029
10259
  return {};
10030
10260
  try {
10031
- const parsed = JSON.parse(readFileSync10(configFile, "utf8"));
10261
+ const parsed = JSON.parse(readFileSync11(configFile, "utf8"));
10032
10262
  if (typeof parsed !== "object" || parsed === null)
10033
10263
  return {};
10034
10264
  return foldLegacyModelConfig(parsed);
@@ -10549,13 +10779,13 @@ var init_doctor = __esm(() => {
10549
10779
  });
10550
10780
 
10551
10781
  // src/doctor/system-exec.ts
10552
- import { spawn as spawn3 } from "child_process";
10782
+ import { spawn as spawn4 } from "child_process";
10553
10783
  function createSystemExec(opts = {}) {
10554
10784
  return (command, args) => new Promise((resolve11) => {
10555
10785
  const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
10556
10786
  let stdout = "";
10557
10787
  let stderr = "";
10558
- const child = spawn3(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
10788
+ const child = spawn4(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
10559
10789
  child.stdout?.on("data", (d) => {
10560
10790
  stdout += d.toString();
10561
10791
  });
@@ -10575,7 +10805,7 @@ var init_system_exec = __esm(() => {
10575
10805
  });
10576
10806
 
10577
10807
  // src/services/setup.ts
10578
- import { copyFileSync, existsSync as existsSync19, mkdirSync as mkdirSync12, readFileSync as readFileSync11, readdirSync as readdirSync6, writeFileSync as writeFileSync8 } from "fs";
10808
+ import { copyFileSync, existsSync as existsSync19, mkdirSync as mkdirSync12, readFileSync as readFileSync12, readdirSync as readdirSync6, writeFileSync as writeFileSync8 } from "fs";
10579
10809
  import { dirname as dirname11, join as join19 } from "path";
10580
10810
  import { fileURLToPath as fileURLToPath7 } from "url";
10581
10811
  async function writeGitIdentity(exec, identity) {
@@ -10656,7 +10886,7 @@ function resolveSandcastleBin() {
10656
10886
  for (let hops = 0;hops < 6; hops++) {
10657
10887
  const manifestPath = join19(dir, "package.json");
10658
10888
  if (existsSync19(manifestPath)) {
10659
- const manifest = JSON.parse(readFileSync11(manifestPath, "utf8"));
10889
+ const manifest = JSON.parse(readFileSync12(manifestPath, "utf8"));
10660
10890
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
10661
10891
  return join19(dir, manifest.bin.sandcastle);
10662
10892
  }
@@ -10733,7 +10963,7 @@ function createTokenVerifier(exec) {
10733
10963
  function fileAfkTokenIo(exec) {
10734
10964
  const path = envPath();
10735
10965
  return {
10736
- read: () => existsSync19(path) ? readFileSync11(path, "utf8") : "",
10966
+ read: () => existsSync19(path) ? readFileSync12(path, "utf8") : "",
10737
10967
  write: (content) => {
10738
10968
  mkdirSync12(dirname11(path), { recursive: true });
10739
10969
  writeFileSync8(path, content, "utf8");
@@ -10813,7 +11043,8 @@ var init_test_notes2 = __esm(() => {
10813
11043
  edit: publicProcedure.input(z12.object({ noteId: z12.string(), text: z12.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
10814
11044
  remove: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
10815
11045
  toggle: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
10816
- promote: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId))
11046
+ promote: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId)),
11047
+ promoteMany: publicProcedure.input(z12.object({ noteIds: z12.array(z12.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds))
10817
11048
  });
10818
11049
  });
10819
11050