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