runcastle 1.2.12 → 1.3.1

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
@@ -176,7 +176,23 @@ import * as z2 from "zod";
176
176
  function isProjectSessionKind(kind) {
177
177
  return PROJECT_SESSION_KINDS.includes(kind);
178
178
  }
179
- var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, RunStatus, FeatureStatus, SessionStatus, TicketKind, FindingKind, FindingSeverity, FindingStatus, FindingOpenReason, ReviewFindingInput, ReviewFinding, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNoteAuthor, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
179
+ function unresolvedMergeConflict(events) {
180
+ let conflict = null;
181
+ for (const event of events) {
182
+ if (event.type === "merge.conflict") {
183
+ const data = event.data ?? {};
184
+ conflict = {
185
+ base: typeof data.base === "string" ? data.base : "",
186
+ files: Array.isArray(data.files) ? data.files.filter((file) => typeof file === "string") : [],
187
+ at: event.ts
188
+ };
189
+ } else if (["burn.started", "merge.resolved", "feature.shipped"].includes(event.type)) {
190
+ conflict = null;
191
+ }
192
+ }
193
+ return conflict;
194
+ }
195
+ var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, RunStatus, FeatureStatus, SessionStatus, TicketKind, FindingKind, FindingSeverity, FindingStatus, FindingOpenReason, ReviewFindingInput, ReviewFinding, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNoteAuthor, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow, DriveState;
180
196
  var init_schemas = __esm(() => {
181
197
  init_config();
182
198
  Phase = z2.enum([
@@ -211,7 +227,7 @@ var init_schemas = __esm(() => {
211
227
  FindingKind = z2.enum(["defect", "observation"]);
212
228
  FindingSeverity = z2.enum(["high", "medium", "low"]);
213
229
  FindingStatus = z2.enum(["open", "fixing", "fixed", "failed", "dismissed"]);
214
- FindingOpenReason = z2.enum(["over-cap", "fix-failed"]);
230
+ FindingOpenReason = z2.enum(["over-cap", "fix-failed", "verification"]);
215
231
  ReviewFindingInput = z2.object({
216
232
  kind: FindingKind,
217
233
  severity: FindingSeverity,
@@ -245,6 +261,7 @@ var init_schemas = __esm(() => {
245
261
  seams: z2.array(z2.string()),
246
262
  blockedBy: z2.array(z2.number()),
247
263
  kind: TicketKind.default("implementation"),
264
+ passKind: z2.enum(["review", "verification"]).default("review"),
248
265
  model: z2.string().optional(),
249
266
  originFindingId: z2.string().optional()
250
267
  });
@@ -255,6 +272,8 @@ var init_schemas = __esm(() => {
255
272
  status: TicketStatus,
256
273
  lap: z2.number(),
257
274
  commits: z2.array(z2.string()),
275
+ reviewedCommit: z2.string().nullable().default(null),
276
+ completedAt: z2.number().nullable().default(null),
258
277
  error: z2.string().optional(),
259
278
  attemptBranch: z2.string().optional(),
260
279
  conflictFiles: z2.array(z2.string()).optional(),
@@ -280,7 +299,7 @@ var init_schemas = __esm(() => {
280
299
  lastSessionId: z2.string().optional(),
281
300
  summary: z2.string().optional()
282
301
  });
283
- TestNoteStatus = z2.enum(["open", "done", "promoted"]);
302
+ TestNoteStatus = z2.enum(["open", "done", "promoted", "carried"]);
284
303
  TestNoteAuthor = z2.enum(["human", "agent"]);
285
304
  TestNote = z2.object({
286
305
  id: z2.string(),
@@ -290,6 +309,8 @@ var init_schemas = __esm(() => {
290
309
  status: TestNoteStatus,
291
310
  author: TestNoteAuthor.default("human"),
292
311
  ticketId: z2.string().optional(),
312
+ reviewTicketId: z2.string().optional(),
313
+ carriedLap: z2.number().optional(),
293
314
  videoTimestamp: z2.number().optional(),
294
315
  screenshotUrl: z2.string().optional(),
295
316
  createdAt: z2.number(),
@@ -356,6 +377,7 @@ var init_schemas = __esm(() => {
356
377
  id: z2.string(),
357
378
  featureId: z2.string().optional(),
358
379
  projectId: z2.string().optional(),
380
+ lap: z2.number(),
359
381
  kind: SessionKind,
360
382
  purpose: SessionPurpose.optional(),
361
383
  purposeData: MergeBranchPair.optional(),
@@ -366,8 +388,10 @@ var init_schemas = __esm(() => {
366
388
  worktreePath: z2.string(),
367
389
  model: z2.string().optional(),
368
390
  runtime: AgentRuntime.optional(),
369
- title: z2.string().optional(),
370
- createdAt: z2.number().optional()
391
+ title: z2.string().nullable().optional(),
392
+ transcriptMissing: z2.boolean().optional(),
393
+ createdAt: z2.number().optional(),
394
+ endedAt: z2.number().optional()
371
395
  });
372
396
  Run = z2.object({
373
397
  id: z2.string(),
@@ -409,16 +433,27 @@ var init_schemas = __esm(() => {
409
433
  message: z2.string(),
410
434
  data: z2.unknown().optional()
411
435
  });
436
+ DriveState = z2.enum([
437
+ "idle",
438
+ "starting",
439
+ "serving",
440
+ "bare-checkout",
441
+ "setup-failed",
442
+ "review-agent-driving"
443
+ ]);
412
444
  });
413
445
 
414
446
  // ../core/src/routes.ts
447
+ function reviewWalkthroughUrl(ticketId) {
448
+ return `${REVIEWS_BASE}${REVIEW_WALKTHROUGH_ROUTE.replace(":ticketId", encodeURIComponent(ticketId))}`;
449
+ }
415
450
  function fillNoteId(route, noteId) {
416
451
  return `${REVIEWS_BASE}${route.replace(":noteId", encodeURIComponent(noteId))}`;
417
452
  }
418
453
  function noteScreenshotUrl(noteId) {
419
454
  return fillNoteId(NOTE_SCREENSHOT_ROUTE, noteId);
420
455
  }
421
- var REVIEWS_BASE = "/api/reviews", NOTE_SCREENSHOT_UPLOAD_ROUTE = "/note/:noteId/screenshot", NOTE_SCREENSHOT_ROUTE;
456
+ var REVIEWS_BASE = "/api/reviews", REVIEW_ARTIFACTS_ROUTE = "/:featureId", REVIEW_WALKTHROUGH_ROUTE = "/ticket/:ticketId/walkthrough.webm", NOTE_SCREENSHOT_UPLOAD_ROUTE = "/note/:noteId/screenshot", NOTE_SCREENSHOT_ROUTE;
422
457
  var init_routes = __esm(() => {
423
458
  NOTE_SCREENSHOT_ROUTE = `${NOTE_SCREENSHOT_UPLOAD_ROUTE}.png`;
424
459
  });
@@ -434,6 +469,17 @@ function fmtClock(seconds) {
434
469
  const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
435
470
  return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
436
471
  }
472
+ function ticketTitleFromNote(text) {
473
+ const clean = text.trim();
474
+ const boundary = clean.search(/(?:[.!?](?:\s|$)|\n)/);
475
+ const sentence = (boundary < 0 ? clean : clean.slice(0, boundary + (clean[boundary] === `
476
+ ` ? 0 : 1))).trim();
477
+ if (sentence.length <= 80)
478
+ return sentence;
479
+ const cut = sentence.slice(0, 80);
480
+ const wordBoundary = cut.lastIndexOf(" ");
481
+ return (wordBoundary > 0 ? cut.slice(0, wordBoundary) : cut).trimEnd();
482
+ }
437
483
 
438
484
  // ../core/src/blocking.ts
439
485
  function resolveBatchBlocking(nodes, { startSeq, label = "ticket" }) {
@@ -636,7 +682,8 @@ var init_db_schema = __esm(() => {
636
682
  model: text("model"),
637
683
  runtime: text("runtime").$type(),
638
684
  title: text("title"),
639
- createdAt: integer("created_at")
685
+ createdAt: integer("created_at"),
686
+ endedAt: integer("ended_at")
640
687
  });
641
688
  tickets = sqliteTable("tickets", {
642
689
  id: text("id").primaryKey(),
@@ -649,6 +696,9 @@ var init_db_schema = __esm(() => {
649
696
  seams: text("seams", { mode: "json" }).notNull().$type(),
650
697
  blockedBy: text("blocked_by", { mode: "json" }).notNull().$type(),
651
698
  kind: text("kind").notNull().$type().default("implementation"),
699
+ passKind: text("pass_kind").notNull().$type().default("review"),
700
+ reviewedCommit: text("reviewed_commit"),
701
+ completedAt: integer("completed_at"),
652
702
  model: text("model"),
653
703
  lap: integer("lap").notNull().default(1),
654
704
  status: text("status").notNull().$type(),
@@ -685,6 +735,8 @@ var init_db_schema = __esm(() => {
685
735
  status: text("status").notNull().$type(),
686
736
  author: text("author").notNull().$type().default("human"),
687
737
  ticketId: text("ticket_id"),
738
+ reviewTicketId: text("review_ticket_id"),
739
+ carriedLap: integer("carried_lap"),
688
740
  videoTimestamp: real("video_timestamp"),
689
741
  createdAt: integer("created_at").notNull(),
690
742
  updatedAt: integer("updated_at").notNull()
@@ -2077,6 +2129,7 @@ function rowToSession(row) {
2077
2129
  id: row.id,
2078
2130
  featureId: row.featureId ?? undefined,
2079
2131
  projectId: row.projectId ?? undefined,
2132
+ lap: row.lap,
2080
2133
  kind: row.kind,
2081
2134
  purpose: row.purpose ?? undefined,
2082
2135
  purposeData: row.purposeData ?? undefined,
@@ -2088,7 +2141,8 @@ function rowToSession(row) {
2088
2141
  model: row.model ?? undefined,
2089
2142
  runtime: row.runtime ?? undefined,
2090
2143
  title: row.title ?? undefined,
2091
- createdAt: row.createdAt ?? undefined
2144
+ createdAt: row.createdAt ?? undefined,
2145
+ endedAt: row.endedAt ?? undefined
2092
2146
  });
2093
2147
  }
2094
2148
  function tryGetFeature(ctx, id) {
@@ -2854,6 +2908,19 @@ import {
2854
2908
  import { tmpdir } from "os";
2855
2909
  import { dirname, join as join6, resolve as resolve4 } from "path";
2856
2910
  import { simpleGit } from "simple-git";
2911
+ function deriveDriveState(info) {
2912
+ if (!info)
2913
+ return "idle";
2914
+ if (info.hookFailure)
2915
+ return "setup-failed";
2916
+ if (info.purpose === "review")
2917
+ return "review-agent-driving";
2918
+ if (!info.devConfigured)
2919
+ return "bare-checkout";
2920
+ if (info.devReady)
2921
+ return "serving";
2922
+ return "starting";
2923
+ }
2857
2924
  function migrationPaths(paths) {
2858
2925
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2859
2926
  }
@@ -2949,12 +3016,12 @@ async function createFeatureBranch(project, slug, base) {
2949
3016
  await g.raw(["branch", branch, from]);
2950
3017
  return branch;
2951
3018
  }
2952
- async function listBranches(project) {
3019
+ async function listBranches(project, options = {}) {
2953
3020
  const g = git(project.repoPath);
2954
3021
  const local = await g.branchLocal();
2955
3022
  const detachedRef = local.detached ? local.current : null;
2956
3023
  const current = local.detached ? "" : local.current;
2957
- const branches = local.all.filter((name) => !name.startsWith("feature/") && name !== detachedRef);
3024
+ const branches = local.all.filter((name) => (options.includeFeatureBranches || !name.startsWith("feature/")) && name !== detachedRef);
2958
3025
  const localSet = new Set(local.all);
2959
3026
  let remoteBranches = [];
2960
3027
  try {
@@ -2964,7 +3031,8 @@ async function listBranches(project) {
2964
3031
  return !short.startsWith("feature/") && !localSet.has(short);
2965
3032
  });
2966
3033
  } catch {}
2967
- return { current, branches, remoteBranches };
3034
+ const detected = await detectMainBranch(project.repoPath);
3035
+ return { current, detected, branches, remoteBranches };
2968
3036
  }
2969
3037
  async function resolveBaseBranch(project, base) {
2970
3038
  const g = git(project.repoPath);
@@ -3262,6 +3330,17 @@ async function reviewCommitCount(project, feature) {
3262
3330
  return { base };
3263
3331
  }
3264
3332
  }
3333
+ async function mergeDelta(project, feature) {
3334
+ const counted = await reviewCommitCount(project, feature);
3335
+ const branch = featureBranch(feature.slug);
3336
+ try {
3337
+ const files = (await git(project.repoPath).raw(["diff", "--name-only", `${counted.base}...${branch}`])).split(`
3338
+ `).map((path) => path.trim()).filter(Boolean).length;
3339
+ return { base: counted.base, commits: counted.count, files };
3340
+ } catch {
3341
+ return { base: counted.base, commits: counted.count };
3342
+ }
3343
+ }
3265
3344
  async function featureBranchDelta(project, feature) {
3266
3345
  const base = featureBase(feature);
3267
3346
  const branch = featureBranch(feature.slug);
@@ -3545,6 +3624,18 @@ async function commitDocs(worktreePath, message) {
3545
3624
  return;
3546
3625
  await g.commit(message, [DOCS_PATHSPEC]);
3547
3626
  }
3627
+ async function onBranch(repoPath, branch, action) {
3628
+ const g = git(repoPath);
3629
+ const previous = (await g.revparse(["--abbrev-ref", "HEAD"])).trim();
3630
+ if (previous !== branch)
3631
+ await g.checkout(branch);
3632
+ try {
3633
+ return await action();
3634
+ } finally {
3635
+ if (previous !== branch && previous !== "HEAD")
3636
+ await g.checkout(previous);
3637
+ }
3638
+ }
3548
3639
  async function commitPipelineDocs(repoPath) {
3549
3640
  try {
3550
3641
  await commitDocs(repoPath, "runcastle: docs the pipeline left uncommitted");
@@ -3557,7 +3648,7 @@ function activeDriveInfo() {
3557
3648
  const state = testDriveState;
3558
3649
  if (!state)
3559
3650
  return null;
3560
- return {
3651
+ const info = {
3561
3652
  ...state.kind === "feature" ? {
3562
3653
  featureId: state.featureId,
3563
3654
  purpose: state.purpose,
@@ -3571,6 +3662,7 @@ function activeDriveInfo() {
3571
3662
  ...state.devReadiness === "timedOut" ? { devReadyTimedOut: true } : {},
3572
3663
  devConfigured: state.devConfigured
3573
3664
  };
3665
+ return { ...info, state: deriveDriveState(info) };
3574
3666
  }
3575
3667
  function recordDriveUrl(ctx, featureId, url) {
3576
3668
  if (testDriveState?.kind !== "feature")
@@ -5155,7 +5247,7 @@ async function sandcastleImageProbe(exec, imageName, burnerDockerfile, fileMtime
5155
5247
  status: "stale",
5156
5248
  severity: "error",
5157
5249
  detail: `${imageName} built ${date(created)}, burner Dockerfile changed ${date(dockerfileChanged)} \u2014 rebuild`,
5158
- fix: 'Open Settings \u2192 AFK burns and click "Rebuild image".'
5250
+ fix: "Open Settings \u2192 Burns (Rebuild image)."
5159
5251
  };
5160
5252
  }
5161
5253
  return { id, label, tier: 2, status: "ok", severity: "error", detail: `${imageName} present` };
@@ -5785,7 +5877,7 @@ function markSessionEnded(ctx, id) {
5785
5877
  forgetKickoff(id);
5786
5878
  if (existing.featureId)
5787
5879
  stopDocsWatch(existing.featureId);
5788
- ctx.db.update(sessions).set({ status: "ended" }).where(eq5(sessions.id, id)).run();
5880
+ ctx.db.update(sessions).set({ status: "ended", ...existing.endedAt === undefined ? { endedAt: Date.now() } : {} }).where(eq5(sessions.id, id)).run();
5789
5881
  return getSessionRow(ctx, id);
5790
5882
  }
5791
5883
  function mostRecentResumableSession(ctx, featureId, kind) {
@@ -5802,6 +5894,9 @@ function projectSessions(ctx, projectId, kind) {
5802
5894
  function setSessionTitle(ctx, id, title) {
5803
5895
  ctx.db.update(sessions).set({ title }).where(eq5(sessions.id, id)).run();
5804
5896
  }
5897
+ function clearSessionTitle(ctx, id) {
5898
+ ctx.db.update(sessions).set({ title: null }).where(eq5(sessions.id, id)).run();
5899
+ }
5805
5900
  function hasCompletedProjectSession(ctx, projectId, kind) {
5806
5901
  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;
5807
5902
  }
@@ -5933,8 +6028,198 @@ async function getRuntimeCtx() {
5933
6028
  }
5934
6029
  var current = null;
5935
6030
 
6031
+ // src/services/review-findings.ts
6032
+ import { and as and6, asc as asc3, eq as eq6, isNotNull as isNotNull2 } from "drizzle-orm";
6033
+ function rowToFinding(row) {
6034
+ return ReviewFinding.parse(row);
6035
+ }
6036
+ function getFinding(ctx, id) {
6037
+ const row = ctx.db.select().from(reviewFindings).where(eq6(reviewFindings.id, id)).get();
6038
+ if (!row)
6039
+ throw new NotFoundError(`review finding ${id} not found`);
6040
+ return rowToFinding(row);
6041
+ }
6042
+ function listByFeature3(ctx, featureId) {
6043
+ return ctx.db.select().from(reviewFindings).where(eq6(reviewFindings.featureId, featureId)).orderBy(asc3(reviewFindings.createdAt), asc3(reviewFindings.id)).all().map(rowToFinding);
6044
+ }
6045
+ function buildFixTicket(finding) {
6046
+ return {
6047
+ title: finding.title,
6048
+ goal: `Fix: ${finding.title}`,
6049
+ context: [
6050
+ `Location: ${finding.location}`,
6051
+ `Citation: ${finding.citation}`,
6052
+ `Detail: ${finding.detail}`,
6053
+ `Repro step: ${finding.reproStep}`
6054
+ ].join(`
6055
+
6056
+ `),
6057
+ acceptanceCriteria: [
6058
+ `The repro step no longer reproduces / the cited criterion holds: ${finding.reproStep}`
6059
+ ],
6060
+ seams: [],
6061
+ blockedBy: [],
6062
+ kind: "implementation",
6063
+ originFindingId: finding.id
6064
+ };
6065
+ }
6066
+ function reportFinding(ctx, args) {
6067
+ const { featureId, reviewTicket, input } = args;
6068
+ const feature = getFeatureRow(ctx, featureId);
6069
+ const id = newId("finding");
6070
+ const ticketCount = ctx.db.select({ id: reviewFindings.id }).from(reviewFindings).where(and6(eq6(reviewFindings.reviewTicketId, reviewTicket.id), isNotNull2(reviewFindings.fixTicketId))).all().length;
6071
+ const verification = reviewTicket.passKind === "verification";
6072
+ const overCap = input.kind === "defect" && !verification && ticketCount >= AUTO_FIX_CAP;
6073
+ ctx.db.insert(reviewFindings).values({
6074
+ id,
6075
+ featureId,
6076
+ lap: feature.lap,
6077
+ reviewTicketId: reviewTicket.id,
6078
+ ...input,
6079
+ reproStep: input.reproStep ?? "",
6080
+ status: "open",
6081
+ openReason: verification && input.kind === "defect" ? "verification" : overCap ? "over-cap" : null,
6082
+ failureReason: null,
6083
+ fixTicketId: null,
6084
+ createdAt: Date.now()
6085
+ }).run();
6086
+ let fixTicket = null;
6087
+ if (input.kind === "defect" && !overCap && !verification) {
6088
+ const finding2 = getFinding(ctx, id);
6089
+ fixTicket = storeTickets(ctx, featureId, [
6090
+ { ...buildFixTicket(finding2), blockedBy: [reviewTicket.seq] }
6091
+ ], { blockedByAreGlobal: true })[0];
6092
+ ctx.db.update(reviewFindings).set({ fixTicketId: fixTicket.id }).where(eq6(reviewFindings.id, id)).run();
6093
+ }
6094
+ const finding = getFinding(ctx, id);
6095
+ emit(ctx, featureId, {
6096
+ type: "finding.reported",
6097
+ message: `${input.kind} reported: ${input.title}`,
6098
+ ticketId: fixTicket?.id,
6099
+ data: { findingId: id, fixTicketId: fixTicket?.id, overCap }
6100
+ });
6101
+ return { finding, fixTicket, overCap };
6102
+ }
6103
+ function updateStatus(ctx, findingId, patch) {
6104
+ const current2 = getFinding(ctx, findingId);
6105
+ ctx.db.update(reviewFindings).set(patch).where(eq6(reviewFindings.id, findingId)).run();
6106
+ emit(ctx, current2.featureId, {
6107
+ type: "finding.updated",
6108
+ message: `finding ${findingId} marked ${patch.status}`,
6109
+ data: { findingId, status: patch.status, reason: patch.failureReason }
6110
+ });
6111
+ return getFinding(ctx, findingId);
6112
+ }
6113
+ function markFixing(ctx, findingId) {
6114
+ return updateStatus(ctx, findingId, {
6115
+ status: "fixing",
6116
+ openReason: null,
6117
+ failureReason: null
6118
+ });
6119
+ }
6120
+ function markFixed(ctx, findingId) {
6121
+ return updateStatus(ctx, findingId, {
6122
+ status: "fixed",
6123
+ openReason: null,
6124
+ failureReason: null
6125
+ });
6126
+ }
6127
+ function markFailed(ctx, findingId, reason) {
6128
+ return updateStatus(ctx, findingId, {
6129
+ status: "failed",
6130
+ openReason: "fix-failed",
6131
+ failureReason: reason ?? null
6132
+ });
6133
+ }
6134
+ function markFixProgress(ctx, findingId, progress, reason) {
6135
+ if (progress === "fixing")
6136
+ return markFixing(ctx, findingId);
6137
+ if (progress === "fixed")
6138
+ return markFixed(ctx, findingId);
6139
+ return markFailed(ctx, findingId, reason);
6140
+ }
6141
+ function dismiss(ctx, findingId) {
6142
+ return updateStatus(ctx, findingId, {
6143
+ status: "dismissed",
6144
+ openReason: null,
6145
+ failureReason: null
6146
+ });
6147
+ }
6148
+ function defectState(finding, fixTicket) {
6149
+ if (finding.status === "dismissed")
6150
+ return "dismissed";
6151
+ if (finding.status === "fixed" || fixTicket?.status === "done")
6152
+ return "fixed";
6153
+ if (fixTicket && fixTicket.status !== "failed" && fixTicket.status !== "cancelled") {
6154
+ return "fixing";
6155
+ }
6156
+ return finding.status === "open" || finding.status === "failed" || fixTicket?.status === "failed" || fixTicket?.status === "cancelled" ? "open" : "fixing";
6157
+ }
6158
+ function viewByFeature(ctx, featureId) {
6159
+ const findings = listByFeature3(ctx, featureId);
6160
+ const tickets2 = listByFeature2(ctx, featureId);
6161
+ const summary = { found: 0, fixed: 0, open: 0, observations: 0 };
6162
+ const openDefects = [];
6163
+ for (const finding of findings) {
6164
+ if (finding.kind === "observation") {
6165
+ summary.observations += 1;
6166
+ continue;
6167
+ }
6168
+ summary.found += 1;
6169
+ const state = defectState(finding, fixTicketOf(finding, tickets2));
6170
+ if (state === "fixed")
6171
+ summary.fixed += 1;
6172
+ else if (state === "open") {
6173
+ summary.open += 1;
6174
+ openDefects.push(finding);
6175
+ }
6176
+ }
6177
+ return { findings, openDefects, summary };
6178
+ }
6179
+ function fixTicketOf(finding, tickets2) {
6180
+ return finding.fixTicketId ? tickets2.find((t) => t.id === finding.fixTicketId) : undefined;
6181
+ }
6182
+ function promoteOpenDefects(ctx, featureId, findingIds) {
6183
+ const open = viewByFeature(ctx, featureId).openDefects;
6184
+ const defects = findingIds ? findingIds.map((id) => {
6185
+ const finding = open.find((candidate) => candidate.id === id);
6186
+ if (!finding)
6187
+ throw new InvalidInputError(`finding ${id} is not an open defect of this feature`);
6188
+ return finding;
6189
+ }) : open;
6190
+ if (findingIds && new Set(findingIds).size !== findingIds.length)
6191
+ throw new InvalidInputError("duplicate finding selected");
6192
+ if (defects.length === 0)
6193
+ throw new InvalidInputError("no open defects to fix");
6194
+ const tickets2 = storeTickets(ctx, featureId, defects.map(buildFixTicket));
6195
+ const findings = defects.map((defect, index2) => {
6196
+ ctx.db.update(reviewFindings).set({
6197
+ status: "fixing",
6198
+ openReason: null,
6199
+ failureReason: null,
6200
+ fixTicketId: tickets2[index2].id
6201
+ }).where(eq6(reviewFindings.id, defect.id)).run();
6202
+ return getFinding(ctx, defect.id);
6203
+ });
6204
+ emit(ctx, featureId, {
6205
+ type: "finding.fixing",
6206
+ message: `${findings.length} open defect${findings.length === 1 ? "" : "s"} promoted to fix ticket${tickets2.length === 1 ? ` ${tickets2[0].seq}` : `s ${tickets2.map((t) => t.seq).join(", ")}`}`,
6207
+ data: { findingIds: findings.map((f) => f.id), seqs: tickets2.map((t) => t.seq) }
6208
+ });
6209
+ return { findings, tickets: tickets2 };
6210
+ }
6211
+ var AUTO_FIX_CAP = 8;
6212
+ var init_review_findings = __esm(() => {
6213
+ init_src();
6214
+ init_schema();
6215
+ init_errors();
6216
+ init_events();
6217
+ init_repo();
6218
+ init_tickets();
6219
+ });
6220
+
5936
6221
  // src/services/tickets.ts
5937
- import { and as and6, asc as asc3, eq as eq6 } from "drizzle-orm";
6222
+ import { and as and7, asc as asc4, eq as eq7, inArray as inArray3 } from "drizzle-orm";
5938
6223
  function rowToTicket(row) {
5939
6224
  return Ticket.parse({
5940
6225
  id: row.id,
@@ -5947,6 +6232,9 @@ function rowToTicket(row) {
5947
6232
  seams: row.seams,
5948
6233
  blockedBy: row.blockedBy,
5949
6234
  kind: row.kind,
6235
+ passKind: row.passKind,
6236
+ reviewedCommit: row.reviewedCommit,
6237
+ completedAt: row.completedAt,
5950
6238
  model: row.model ?? undefined,
5951
6239
  lap: row.lap,
5952
6240
  status: row.status,
@@ -5978,13 +6266,13 @@ function normalizeModel(ctx, value) {
5978
6266
  return id;
5979
6267
  }
5980
6268
  function listByFeature2(ctx, featureId) {
5981
- return ctx.db.select().from(tickets).where(eq6(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
6269
+ return ctx.db.select().from(tickets).where(eq7(tickets.featureId, featureId)).orderBy(asc4(tickets.seq)).all().map(rowToTicket);
5982
6270
  }
5983
6271
  function storeTickets(ctx, featureId, inputs, options = {}) {
5984
6272
  if (inputs.length === 0)
5985
6273
  return [];
5986
6274
  const { lap } = getFeatureRow(ctx, featureId);
5987
- const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq6(tickets.featureId, featureId)).all();
6275
+ const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
5988
6276
  const startSeq = existing.reduce((max2, r) => Math.max(max2, r.seq), 0) + 1;
5989
6277
  const resolved = options.blockedByAreGlobal ? inputs.map((input, index2) => ({ seq: startSeq + index2, blockedBy: input.blockedBy })) : resolveBlocking(inputs, startSeq);
5990
6278
  const rows = inputs.map((t, i) => ({
@@ -5998,6 +6286,9 @@ function storeTickets(ctx, featureId, inputs, options = {}) {
5998
6286
  seams: t.seams,
5999
6287
  blockedBy: resolved[i].blockedBy,
6000
6288
  kind: t.kind ?? "implementation",
6289
+ passKind: t.passKind ?? "review",
6290
+ reviewedCommit: null,
6291
+ completedAt: null,
6001
6292
  model: normalizeModel(ctx, t.model),
6002
6293
  lap,
6003
6294
  status: "pending",
@@ -6017,7 +6308,7 @@ function storeTickets(ctx, featureId, inputs, options = {}) {
6017
6308
  return rows.map(rowToTicket);
6018
6309
  }
6019
6310
  function getTicket(ctx, id) {
6020
- const row = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
6311
+ const row = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
6021
6312
  if (!row)
6022
6313
  throw new NotFoundError(`ticket ${id} not found`);
6023
6314
  return rowToTicket(row);
@@ -6046,7 +6337,7 @@ function editTicket(ctx, id, patch) {
6046
6337
  if (Object.keys(set).length === 0) {
6047
6338
  throw new InvalidInputError("nothing to edit \u2014 pass at least one field");
6048
6339
  }
6049
- ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
6340
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
6050
6341
  emit(ctx, current2.featureId, {
6051
6342
  type: "ticket.edited",
6052
6343
  message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
@@ -6058,7 +6349,11 @@ function editTicket(ctx, id, patch) {
6058
6349
  function cancelTicket(ctx, id, reason) {
6059
6350
  const current2 = getTicket(ctx, id);
6060
6351
  assertMutable(current2, "cancel");
6061
- ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq6(tickets.id, id)).run();
6352
+ ctx.db.update(tickets).set({
6353
+ status: "cancelled",
6354
+ error: reason?.trim() ? reason.trim() : null,
6355
+ completedAt: Date.now()
6356
+ }).where(eq7(tickets.id, id)).run();
6062
6357
  emit(ctx, current2.featureId, {
6063
6358
  type: "ticket.cancelled",
6064
6359
  message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
@@ -6068,9 +6363,12 @@ function cancelTicket(ctx, id, reason) {
6068
6363
  return getTicket(ctx, id);
6069
6364
  }
6070
6365
  function sweepOrphanedBurning(ctx, featureId, reason) {
6071
- const orphaned = ctx.db.select().from(tickets).where(and6(eq6(tickets.featureId, featureId), eq6(tickets.status, "burning"))).all().map(rowToTicket);
6366
+ const orphaned = ctx.db.select().from(tickets).where(and7(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
6367
+ const swept = [];
6072
6368
  for (const t of orphaned) {
6073
- updateTicket(ctx, t.id, { status: "failed", error: reason });
6369
+ swept.push(updateTicket(ctx, t.id, { status: "failed", error: reason }));
6370
+ if (t.originFindingId)
6371
+ markFailed(ctx, t.originFindingId, reason);
6074
6372
  emit(ctx, featureId, {
6075
6373
  type: "ticket.failed",
6076
6374
  message: `ticket ${t.seq} failed: ${reason}`,
@@ -6078,15 +6376,18 @@ function sweepOrphanedBurning(ctx, featureId, reason) {
6078
6376
  data: { error: reason, orphaned: true }
6079
6377
  });
6080
6378
  }
6081
- return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
6379
+ return swept;
6082
6380
  }
6083
6381
  function updateTicket(ctx, id, patch) {
6084
- const current2 = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
6382
+ const current2 = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
6085
6383
  if (!current2)
6086
6384
  throw new NotFoundError(`ticket ${id} not found`);
6087
6385
  const set = {};
6088
6386
  if (patch.status !== undefined)
6089
6387
  set.status = patch.status;
6388
+ if (patch.status !== undefined) {
6389
+ set.completedAt = ["done", "failed", "cancelled"].includes(patch.status) ? Date.now() : null;
6390
+ }
6090
6391
  if (patch.commits !== undefined)
6091
6392
  set.commits = patch.commits;
6092
6393
  if (patch.error !== undefined)
@@ -6097,16 +6398,44 @@ function updateTicket(ctx, id, patch) {
6097
6398
  set.conflictFiles = patch.conflictFiles;
6098
6399
  if (patch.digest !== undefined)
6099
6400
  set.digest = patch.digest;
6100
- ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
6401
+ if (patch.reviewedCommit !== undefined)
6402
+ set.reviewedCommit = patch.reviewedCommit;
6403
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
6101
6404
  emit(ctx, current2.featureId, {
6102
6405
  type: "ticket.updated",
6103
6406
  message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
6104
6407
  ticketId: id,
6105
6408
  data: patch
6106
6409
  });
6107
- const updated = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
6410
+ const updated = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
6108
6411
  return rowToTicket(updated);
6109
6412
  }
6413
+ function listByIds(ctx, ids2) {
6414
+ if (ids2.length === 0)
6415
+ return [];
6416
+ return ctx.db.select().from(tickets).where(inArray3(tickets.id, [...ids2])).orderBy(asc4(tickets.seq)).all().map(rowToTicket);
6417
+ }
6418
+ function timingWallMs(data) {
6419
+ if (typeof data !== "object" || data === null)
6420
+ return;
6421
+ const ms = data.wallMs;
6422
+ return typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : undefined;
6423
+ }
6424
+ function ticketDurationStats(ctx, projectId) {
6425
+ const rows = ctx.db.select({ ticketId: events.ticketId, data: events.data }).from(events).innerJoin(tickets, eq7(events.ticketId, tickets.id)).where(and7(eq7(events.projectId, projectId), eq7(events.type, "ticket.timing"), eq7(tickets.status, "done"), eq7(tickets.kind, "implementation"))).orderBy(asc4(events.id)).all();
6426
+ const perTicket = new Map;
6427
+ for (const row of rows) {
6428
+ const ms = timingWallMs(row.data);
6429
+ if (row.ticketId && ms !== undefined)
6430
+ perTicket.set(row.ticketId, ms);
6431
+ }
6432
+ const samples = [...perTicket.values()].sort((a, b) => a - b);
6433
+ if (samples.length === 0)
6434
+ return { medianMs: 0, sampleSize: 0 };
6435
+ const mid = samples.length >> 1;
6436
+ const medianMs = samples.length % 2 === 1 ? samples[mid] : Math.round((samples[mid - 1] + samples[mid]) / 2);
6437
+ return { medianMs, sampleSize: samples.length };
6438
+ }
6110
6439
  var MUTABLE_STATUSES;
6111
6440
  var init_tickets = __esm(() => {
6112
6441
  init_src();
@@ -6114,12 +6443,13 @@ var init_tickets = __esm(() => {
6114
6443
  init_errors();
6115
6444
  init_events();
6116
6445
  init_repo();
6446
+ init_review_findings();
6117
6447
  MUTABLE_STATUSES = new Set(["pending", "failed"]);
6118
6448
  });
6119
6449
 
6120
6450
  // src/services/gates.ts
6121
6451
  import { existsSync as existsSync11 } from "fs";
6122
- import { and as and7, desc as desc4, eq as eq7, inArray as inArray3 } from "drizzle-orm";
6452
+ import { and as and8, desc as desc4, eq as eq8, inArray as inArray4 } from "drizzle-orm";
6123
6453
  function checkGate(ctx, check, feature) {
6124
6454
  switch (check) {
6125
6455
  case "decisions-file-exists":
@@ -6130,10 +6460,17 @@ function checkGate(ctx, check, feature) {
6130
6460
  return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
6131
6461
  }
6132
6462
  const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
6133
- return open.length === 0 ? { satisfied: true } : {
6134
- satisfied: false,
6135
- reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
6136
- };
6463
+ if (open.length > 0)
6464
+ return {
6465
+ satisfied: false,
6466
+ reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
6467
+ };
6468
+ if (feature.lap > 1 && !lapWorked(ctx, feature))
6469
+ return {
6470
+ satisfied: false,
6471
+ reason: `the map is an earlier lap's \u2014 no lap ${feature.lap} session has worked this feature yet; open the lap's session before converging`
6472
+ };
6473
+ return { satisfied: true };
6137
6474
  }
6138
6475
  case "spec-file-exists":
6139
6476
  return docGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
@@ -6170,12 +6507,14 @@ function fileGate(ctx, feature, fileName, reason) {
6170
6507
  const project = projectForFeature(ctx, feature);
6171
6508
  return existsSync11(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
6172
6509
  }
6510
+ function lapWorked(ctx, feature) {
6511
+ return ctx.db.select({ id: sessions.id }).from(sessions).where(and8(eq8(sessions.featureId, feature.id), eq8(sessions.lap, feature.lap), inArray4(sessions.kind, [...DOC_WRITING_KINDS]))).all().length > 0;
6512
+ }
6173
6513
  function docGate(ctx, feature, fileName, reason) {
6174
6514
  const file = fileGate(ctx, feature, fileName, reason);
6175
6515
  if (!file.satisfied || feature.lap === 1)
6176
6516
  return file;
6177
- const worked = ctx.db.select({ id: sessions.id }).from(sessions).where(and7(eq7(sessions.featureId, feature.id), eq7(sessions.lap, feature.lap), inArray3(sessions.kind, [...DOC_WRITING_KINDS]))).all();
6178
- return worked.length > 0 ? { satisfied: true } : {
6517
+ return lapWorked(ctx, feature) ? { satisfied: true } : {
6179
6518
  satisfied: false,
6180
6519
  reason: `${fileName} is an earlier lap's \u2014 no lap ${feature.lap} session has worked this feature yet; open the lap's session before promoting`
6181
6520
  };
@@ -6201,10 +6540,10 @@ function overrideGate(ctx, featureId, gate, reason) {
6201
6540
  return feature;
6202
6541
  }
6203
6542
  function undoLastGateOverride(ctx, featureId, gate) {
6204
- const last = ctx.db.select({ id: gateOverrides.id }).from(gateOverrides).where(and7(eq7(gateOverrides.featureId, featureId), eq7(gateOverrides.gate, gate))).orderBy(desc4(gateOverrides.id)).get();
6543
+ const last = ctx.db.select({ id: gateOverrides.id }).from(gateOverrides).where(and8(eq8(gateOverrides.featureId, featureId), eq8(gateOverrides.gate, gate))).orderBy(desc4(gateOverrides.id)).get();
6205
6544
  if (!last)
6206
6545
  return false;
6207
- ctx.db.delete(gateOverrides).where(eq7(gateOverrides.id, last.id)).run();
6546
+ ctx.db.delete(gateOverrides).where(eq8(gateOverrides.id, last.id)).run();
6208
6547
  return true;
6209
6548
  }
6210
6549
  function undoGateOverride(ctx, featureId, gate) {
@@ -6232,7 +6571,7 @@ var init_gates = __esm(() => {
6232
6571
  });
6233
6572
 
6234
6573
  // src/services/knowledge.ts
6235
- import { existsSync as existsSync12, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync as writeFileSync4 } from "fs";
6574
+ import { existsSync as existsSync12, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "fs";
6236
6575
  import { join as join11, relative as relative2, resolve as resolve8, sep } from "path";
6237
6576
  function scaffoldDocs(ctx, feature, opts = {}) {
6238
6577
  const project = projectForFeature(ctx, feature);
@@ -6320,7 +6659,10 @@ function listDocs(ctx, feature) {
6320
6659
  const dir = featureDocsDir(project, feature);
6321
6660
  if (!existsSync12(dir))
6322
6661
  return [];
6323
- return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join11(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
6662
+ return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => {
6663
+ const path = join11(dir, e.name);
6664
+ return { relPath: e.name, title: readTitle(path, e.name), ...writtenAt(path) };
6665
+ }).sort((a, b) => a.relPath.localeCompare(b.relPath));
6324
6666
  }
6325
6667
  function readDoc(ctx, feature, relPath) {
6326
6668
  const project = projectForFeature(ctx, feature);
@@ -6335,6 +6677,13 @@ function readDoc(ctx, feature, relPath) {
6335
6677
  throw new NotFoundError(`doc not found: ${relPath}`);
6336
6678
  return { content: readFileSync5(target, "utf8") };
6337
6679
  }
6680
+ function writtenAt(path) {
6681
+ try {
6682
+ return { updatedAt: Math.floor(statSync4(path).mtimeMs) };
6683
+ } catch {
6684
+ return {};
6685
+ }
6686
+ }
6338
6687
  function readTitle(path, fallback) {
6339
6688
  try {
6340
6689
  const firstLine = readFileSync5(path, "utf8").split(`
@@ -6388,187 +6737,6 @@ var init_end_session = __esm(() => {
6388
6737
  init_registry();
6389
6738
  });
6390
6739
 
6391
- // src/services/review-findings.ts
6392
- import { and as and8, asc as asc4, eq as eq8, isNotNull as isNotNull2 } from "drizzle-orm";
6393
- function rowToFinding(row) {
6394
- return ReviewFinding.parse(row);
6395
- }
6396
- function getFinding(ctx, id) {
6397
- const row = ctx.db.select().from(reviewFindings).where(eq8(reviewFindings.id, id)).get();
6398
- if (!row)
6399
- throw new NotFoundError(`review finding ${id} not found`);
6400
- return rowToFinding(row);
6401
- }
6402
- function listByFeature3(ctx, featureId) {
6403
- return ctx.db.select().from(reviewFindings).where(eq8(reviewFindings.featureId, featureId)).orderBy(asc4(reviewFindings.createdAt), asc4(reviewFindings.id)).all().map(rowToFinding);
6404
- }
6405
- function buildFixTicket(finding) {
6406
- return {
6407
- title: finding.title,
6408
- goal: `Fix: ${finding.title}`,
6409
- context: [
6410
- `Location: ${finding.location}`,
6411
- `Citation: ${finding.citation}`,
6412
- `Detail: ${finding.detail}`,
6413
- `Repro step: ${finding.reproStep}`
6414
- ].join(`
6415
-
6416
- `),
6417
- acceptanceCriteria: [
6418
- `The repro step no longer reproduces / the cited criterion holds: ${finding.reproStep}`
6419
- ],
6420
- seams: [],
6421
- blockedBy: [],
6422
- kind: "implementation",
6423
- originFindingId: finding.id
6424
- };
6425
- }
6426
- function reportFinding(ctx, args) {
6427
- const { featureId, reviewTicket, input } = args;
6428
- const feature = getFeatureRow(ctx, featureId);
6429
- const id = newId("finding");
6430
- const ticketCount = ctx.db.select({ id: reviewFindings.id }).from(reviewFindings).where(and8(eq8(reviewFindings.reviewTicketId, reviewTicket.id), isNotNull2(reviewFindings.fixTicketId))).all().length;
6431
- const overCap = input.kind === "defect" && ticketCount >= AUTO_FIX_CAP;
6432
- ctx.db.insert(reviewFindings).values({
6433
- id,
6434
- featureId,
6435
- lap: feature.lap,
6436
- reviewTicketId: reviewTicket.id,
6437
- ...input,
6438
- reproStep: input.reproStep ?? "",
6439
- status: "open",
6440
- openReason: overCap ? "over-cap" : null,
6441
- failureReason: null,
6442
- fixTicketId: null,
6443
- createdAt: Date.now()
6444
- }).run();
6445
- let fixTicket = null;
6446
- if (input.kind === "defect" && !overCap) {
6447
- const finding2 = getFinding(ctx, id);
6448
- fixTicket = storeTickets(ctx, featureId, [
6449
- { ...buildFixTicket(finding2), blockedBy: [reviewTicket.seq] }
6450
- ], { blockedByAreGlobal: true })[0];
6451
- ctx.db.update(reviewFindings).set({ fixTicketId: fixTicket.id }).where(eq8(reviewFindings.id, id)).run();
6452
- }
6453
- const finding = getFinding(ctx, id);
6454
- emit(ctx, featureId, {
6455
- type: "finding.reported",
6456
- message: `${input.kind} reported: ${input.title}`,
6457
- ticketId: fixTicket?.id,
6458
- data: { findingId: id, fixTicketId: fixTicket?.id, overCap }
6459
- });
6460
- return { finding, fixTicket, overCap };
6461
- }
6462
- function updateStatus(ctx, findingId, patch) {
6463
- const current2 = getFinding(ctx, findingId);
6464
- ctx.db.update(reviewFindings).set(patch).where(eq8(reviewFindings.id, findingId)).run();
6465
- emit(ctx, current2.featureId, {
6466
- type: "finding.updated",
6467
- message: `finding ${findingId} marked ${patch.status}`,
6468
- data: { findingId, status: patch.status, reason: patch.failureReason }
6469
- });
6470
- return getFinding(ctx, findingId);
6471
- }
6472
- function markFixing(ctx, findingId) {
6473
- return updateStatus(ctx, findingId, {
6474
- status: "fixing",
6475
- openReason: null,
6476
- failureReason: null
6477
- });
6478
- }
6479
- function markFixed(ctx, findingId) {
6480
- return updateStatus(ctx, findingId, {
6481
- status: "fixed",
6482
- openReason: null,
6483
- failureReason: null
6484
- });
6485
- }
6486
- function markFailed(ctx, findingId, reason) {
6487
- return updateStatus(ctx, findingId, {
6488
- status: "failed",
6489
- openReason: "fix-failed",
6490
- failureReason: reason ?? null
6491
- });
6492
- }
6493
- function markFixProgress(ctx, findingId, progress, reason) {
6494
- if (progress === "fixing")
6495
- return markFixing(ctx, findingId);
6496
- if (progress === "fixed")
6497
- return markFixed(ctx, findingId);
6498
- return markFailed(ctx, findingId, reason);
6499
- }
6500
- function dismiss(ctx, findingId) {
6501
- return updateStatus(ctx, findingId, {
6502
- status: "dismissed",
6503
- openReason: null,
6504
- failureReason: null
6505
- });
6506
- }
6507
- function defectState(finding, fixTicket) {
6508
- if (finding.status === "dismissed")
6509
- return "dismissed";
6510
- if (finding.status === "fixed" || fixTicket?.status === "done")
6511
- return "fixed";
6512
- if (fixTicket && fixTicket.status !== "failed" && fixTicket.status !== "cancelled") {
6513
- return "fixing";
6514
- }
6515
- return finding.status === "open" || finding.status === "failed" ? "open" : "fixing";
6516
- }
6517
- function viewByFeature(ctx, featureId) {
6518
- const findings = listByFeature3(ctx, featureId);
6519
- const tickets2 = listByFeature2(ctx, featureId);
6520
- const summary = { found: 0, fixed: 0, open: 0, observations: 0 };
6521
- const openDefects = [];
6522
- for (const finding of findings) {
6523
- if (finding.kind === "observation") {
6524
- summary.observations += 1;
6525
- continue;
6526
- }
6527
- summary.found += 1;
6528
- const state = defectState(finding, fixTicketOf(finding, tickets2));
6529
- if (state === "fixed")
6530
- summary.fixed += 1;
6531
- else if (state === "open") {
6532
- summary.open += 1;
6533
- openDefects.push(finding);
6534
- }
6535
- }
6536
- return { findings, openDefects, summary };
6537
- }
6538
- function fixTicketOf(finding, tickets2) {
6539
- return finding.fixTicketId ? tickets2.find((t) => t.id === finding.fixTicketId) : undefined;
6540
- }
6541
- function promoteOpenDefects(ctx, featureId) {
6542
- const defects = viewByFeature(ctx, featureId).openDefects;
6543
- if (defects.length === 0)
6544
- throw new InvalidInputError("no open defects to fix");
6545
- const tickets2 = storeTickets(ctx, featureId, defects.map(buildFixTicket));
6546
- const findings = defects.map((defect, index2) => {
6547
- ctx.db.update(reviewFindings).set({
6548
- status: "fixing",
6549
- openReason: null,
6550
- failureReason: null,
6551
- fixTicketId: tickets2[index2].id
6552
- }).where(eq8(reviewFindings.id, defect.id)).run();
6553
- return getFinding(ctx, defect.id);
6554
- });
6555
- emit(ctx, featureId, {
6556
- type: "finding.fixing",
6557
- message: `${findings.length} open defect${findings.length === 1 ? "" : "s"} promoted to fix ticket${tickets2.length === 1 ? ` ${tickets2[0].seq}` : `s ${tickets2.map((t) => t.seq).join(", ")}`}`,
6558
- data: { findingIds: findings.map((f) => f.id), seqs: tickets2.map((t) => t.seq) }
6559
- });
6560
- return { findings, tickets: tickets2 };
6561
- }
6562
- var AUTO_FIX_CAP = 8;
6563
- var init_review_findings = __esm(() => {
6564
- init_src();
6565
- init_schema();
6566
- init_errors();
6567
- init_events();
6568
- init_repo();
6569
- init_tickets();
6570
- });
6571
-
6572
6740
  // ../../node_modules/.bun/@ai-hero+sandcastle@0.12.0/node_modules/@ai-hero/sandcastle/dist/chunk-NGBM7T3E.js
6573
6741
  import { createRequire as createRequire3 } from "module";
6574
6742
  var require$1, __create, __defProp2, __getOwnPropDesc, __getOwnPropNames, __getProtoOf, __hasOwnProp, __require2, __commonJS = (cb, mod) => function __require22() {
@@ -6628,7 +6796,7 @@ import { createRequire as createRequire4 } from "module";
6628
6796
  import * as clack from "@clack/prompts";
6629
6797
  import { join as join$1, basename as basename2, dirname as dirname4, resolve as resolve9, posix as posix2, normalize, isAbsolute as isAbsolute2 } from "path";
6630
6798
  import { styleText, promisify } from "util";
6631
- import { existsSync as existsSync13, statSync as statSync4 } from "fs";
6799
+ import { existsSync as existsSync13, statSync as statSync5 } from "fs";
6632
6800
  import { tmpdir as tmpdir2, homedir as homedir3 } from "os";
6633
6801
  import { mkdtemp, writeFile, mkdir, readFile, rm, readdir, stat } from "fs/promises";
6634
6802
  import { exec, execFile } from "child_process";
@@ -18440,7 +18608,7 @@ Worktree removed (no uncommitted changes)`);
18440
18608
  const selinux = mount.volume !== undefined ? undefined : selinuxLabel || undefined;
18441
18609
  const options = [mount.readonly ? "ro" : undefined, selinux].filter((option5) => option5 !== undefined).join(",");
18442
18610
  return options ? `${base}:${options}` : base;
18443
- }, processFileMountParents = (mounts, sandboxHomedir, statFn = statSync4) => {
18611
+ }, processFileMountParents = (mounts, sandboxHomedir, statFn = statSync5) => {
18444
18612
  const parentDirs = /* @__PURE__ */ new Set;
18445
18613
  for (const mount of mounts) {
18446
18614
  let isFile2;
@@ -55378,10 +55546,11 @@ var init_no_sandbox = __esm(() => {
55378
55546
  // src/workflows/review-ticket.ts
55379
55547
  import { existsSync as existsSync16, mkdirSync as mkdirSync9, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
55380
55548
  import { delimiter, join as join16 } from "path";
55381
- function reviewTemplatePath() {
55382
- return burnerAssetPath("review-ticket.md");
55549
+ function reviewTemplatePath(ticket) {
55550
+ return burnerAssetPath(ticket?.passKind === "verification" ? "verify-fixes.md" : "review-ticket.md");
55383
55551
  }
55384
- function renderReviewPrompt(template, values) {
55552
+ function renderReviewPrompt(templateOrTicket, values) {
55553
+ const template = typeof templateOrTicket === "string" ? templateOrTicket : readFileSync8(reviewTemplatePath(templateOrTicket), "utf8");
55385
55554
  return renderTemplate(template, values);
55386
55555
  }
55387
55556
  function findOnPath(bin, env = process.env, platform = process.platform) {
@@ -55396,7 +55565,10 @@ function findOnPath(bin, env = process.env, platform = process.platform) {
55396
55565
  }
55397
55566
  return;
55398
55567
  }
55399
- function buildDriveAvailability(browserPath, devCommand) {
55568
+ function buildDriveAvailability(browserPath, devCommand, inheritedMode) {
55569
+ if (inheritedMode) {
55570
+ return inheritedMode === "drive" ? "Inherited mode: **Drive**. The pass being verified left a recording, so run Drive mode and record the full tour; do not choose Gates mode." : "Inherited mode: **Gates**. The pass being verified left no recording, so run Gates mode; do not choose Drive mode or call `review_drive`.";
55571
+ }
55400
55572
  const missing = [];
55401
55573
  if (!browserPath) {
55402
55574
  missing.push(`\`${AGENT_BROWSER_BIN}\` is not on this machine's PATH, so there is no browser to walk the app with`);
@@ -55409,6 +55581,9 @@ function buildDriveAvailability(browserPath, devCommand) {
55409
55581
  }
55410
55582
  return `A drive is **not** available: ${missing.join(", and ")}. So the mode is already decided \u2014 ` + "run Gates mode, whatever this lap touched, and do not call `review_drive`. This is not a " + "degraded review; it is the whole review this lap gets.";
55411
55583
  }
55584
+ function inheritedReviewMode(verifiedTicketId, fileExists2 = existsSync16) {
55585
+ return verifiedTicketId && fileExists2(reviewWalkthroughPath(verifiedTicketId)) ? "drive" : "gates";
55586
+ }
55412
55587
  function buildGateNotes(config2) {
55413
55588
  const commands = config2.verifyCommands?.trim();
55414
55589
  const failures2 = config2.knownFailures?.trim();
@@ -55462,18 +55637,24 @@ async function reviewTicketOutcome(ctx, ticket, deps, timer) {
55462
55637
  return couldNotReview(ticket, `feature ${feature.slug} has no recorded base branch, so there is nothing to diff \`${feature.branch}\` against.`);
55463
55638
  }
55464
55639
  const artifacts = writeReviewArtifacts(ticket, ctx.runId, deps.config);
55465
- const prompt = renderReviewPrompt(readFileSync8(reviewTemplatePath(), "utf8"), {
55640
+ const allTickets = ctx.listTickets?.() ?? ctx.tickets;
55641
+ const verifies = allTickets.filter((candidate) => candidate.kind === "review" && candidate.id !== ticket.id && candidate.status === "done").sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0) || a.seq - b.seq).at(-1);
55642
+ const inheritedMode = ticket.passKind === "verification" ? inheritedReviewMode(verifies?.id) : undefined;
55643
+ const prompt = renderReviewPrompt(ticket, {
55466
55644
  TICKET_JSON: buildTicketJson(ticket),
55467
55645
  FEATURE_BRIEF: buildFeatureBrief(feature),
55468
55646
  DOCS_DIGEST: deps.docsDigest,
55469
55647
  LAP_DIGESTS: buildLapDigestsBlock(deps.lapDigests),
55470
55648
  FEATURE_BRANCH: feature.branch,
55471
55649
  BASE_BRANCH: feature.baseBranch,
55472
- DRIVE_AVAILABILITY: buildDriveAvailability(findOnPath(AGENT_BROWSER_BIN), project3.devCommand),
55650
+ DRIVE_AVAILABILITY: buildDriveAvailability(findOnPath(AGENT_BROWSER_BIN), project3.devCommand, inheritedMode),
55473
55651
  GATE_NOTES: buildGateNotes(deps.config),
55474
55652
  DIGEST_PATH: artifacts.digestPath,
55475
55653
  BLOCKED_PATH: artifacts.blockedPath,
55476
- WALKTHROUGH_PATH: artifacts.walkthroughPath
55654
+ WALKTHROUGH_PATH: artifacts.walkthroughPath,
55655
+ LANDED_FIXES: ticket.context,
55656
+ VERIFIES_PASS: verifies ? `#${verifies.seq} \xB7 ${inheritedMode === "drive" ? "Drive" : "Gates"} mode` : "no earlier review in this run \xB7 Gates mode",
55657
+ AUTO_FIX_CAP: String(AUTO_FIX_CAP)
55477
55658
  });
55478
55659
  mkdirSync9(logsDir(), { recursive: true });
55479
55660
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
@@ -55551,6 +55732,7 @@ var init_review_ticket = __esm(() => {
55551
55732
  init_artifacts();
55552
55733
  init_agent_stream();
55553
55734
  init_git();
55735
+ init_review_findings();
55554
55736
  init_ticket_burner();
55555
55737
  });
55556
55738
 
@@ -56111,7 +56293,7 @@ import { dirname as dirname9, join as join17, posix as posixPath, win32 as winPa
56111
56293
  import { fileURLToPath as fileURLToPath7 } from "url";
56112
56294
  function missingImageRuntimeMessage(runtime5, image) {
56113
56295
  const binary = RUNTIME_BINARY[runtime5];
56114
- return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 AFK burns (Rebuild image).`;
56296
+ return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 Burns (Rebuild image).`;
56115
56297
  }
56116
56298
  function burnAuthReady(runtime5, token, loggedIn = codexLoggedIn) {
56117
56299
  if (token !== undefined)
@@ -56121,6 +56303,17 @@ function burnAuthReady(runtime5, token, loggedIn = codexLoggedIn) {
56121
56303
  function isReviewTicket(ticket) {
56122
56304
  return ticket.kind === "review";
56123
56305
  }
56306
+ function verificationDue(tickets2, runTicketIds) {
56307
+ const inRun = tickets2.filter((ticket) => runTicketIds.has(ticket.id));
56308
+ const reviews = inRun.filter(isReviewTicket).filter((ticket) => ticket.completedAt !== null).sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0) || a.seq - b.seq);
56309
+ const lastReview = reviews.at(-1) ?? null;
56310
+ const verifies = [...reviews].reverse().find((ticket) => ticket.status === "done") ?? null;
56311
+ if (lastReview?.status === "failed")
56312
+ return { due: false, landed: [], verifies };
56313
+ const cutoff = verifies?.completedAt ?? Number.NEGATIVE_INFINITY;
56314
+ const landed = inRun.filter((ticket) => !isReviewTicket(ticket) && ticket.status === "done" && ticket.completedAt !== null && (ticket.completedAt > cutoff || ticket.completedAt === cutoff && verifies !== null && ticket.seq > verifies.seq));
56315
+ return { due: landed.length > 0, landed, verifies };
56316
+ }
56124
56317
  function indexBySeq(tickets2) {
56125
56318
  return new Map(tickets2.map((t) => [t.seq, t]));
56126
56319
  }
@@ -56487,7 +56680,7 @@ function buildRepoSetupSteps(repoPath, tempBranch, setupCommand, pm) {
56487
56680
  const repoAttachmentsDir = `${repoPath}/${ATTACHMENTS_DIR}`;
56488
56681
  const excludeFile = `${repoPath}/.git/info/exclude`;
56489
56682
  const pre = [
56490
- `if [ -d "${attachmentsDir}" ]; then mkdir -p "${repoAttachmentsDir}" && cp -r "${attachmentsDir}/." "${repoAttachmentsDir}/" && mkdir -p "${repoPath}/.git/info" && { grep -qxF '${ATTACHMENTS_DIR}/' "${excludeFile}" 2>/dev/null || printf '%s\\n' '${ATTACHMENTS_DIR}/' >> "${excludeFile}"; }; fi`,
56683
+ `if [ -d "${attachmentsDir}" ]; then mkdir -p "${repoAttachmentsDir}" && cp -r "${attachmentsDir}/." "${repoAttachmentsDir}/" && mkdir -p "${repoPath}/.git/info" && { grep -qxF '${ATTACHMENTS_PREFIX}' "${excludeFile}" 2>/dev/null || printf '%s\\n' '${ATTACHMENTS_PREFIX}' >> "${excludeFile}"; }; fi`,
56491
56684
  `printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exit 0\\nsleep 2\\ngit push --quiet origin HEAD:%s && exit 0\\necho "runcastle: commit sync failed (will retry on your next commit); do not re-commit" >&2\\nexit 0\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
56492
56685
  `chmod +x ${hookFile}`
56493
56686
  ];
@@ -56574,7 +56767,7 @@ function buildWorkspaceNotes(mode, repoPath = ISOLATED_REPO_PATH) {
56574
56767
  `);
56575
56768
  }
56576
56769
  function attachedNoteIds(context5) {
56577
- const marker = `${ATTACHMENTS_DIR}/`;
56770
+ const marker = ATTACHMENTS_PREFIX;
56578
56771
  const ids3 = [];
56579
56772
  for (let at = context5.indexOf(marker);at !== -1; at = context5.indexOf(marker, at + 1)) {
56580
56773
  const id3 = /^([\w-]+)\.png/.exec(context5.slice(at + marker.length))?.[1];
@@ -56605,7 +56798,7 @@ function buildAttachmentCopyCommands(sources, platform = process.platform) {
56605
56798
  }
56606
56799
  async function clearAttachments(workspacePath, repoPath) {
56607
56800
  rmSync3(join17(workspacePath, ATTACHMENTS_DIR), { recursive: true, force: true });
56608
- await unexcludePath(repoPath, `${ATTACHMENTS_DIR}/`);
56801
+ await unexcludePath(repoPath, ATTACHMENTS_PREFIX);
56609
56802
  }
56610
56803
  function buildVerifyNotes(config2) {
56611
56804
  const commands = config2.verifyCommands?.trim();
@@ -56785,7 +56978,7 @@ function missingAgentBinaryMessage(err, runtime5, image) {
56785
56978
  const missing = new RegExp(`\\b${binary}\\b[^\\n]*(?:exited with code 127|command not found|not found|no such file or directory)`, "i");
56786
56979
  if (!missing.test(msg))
56787
56980
  return;
56788
- return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 AFK burns (Rebuild image).`;
56981
+ return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 Burns (Rebuild image).`;
56789
56982
  }
56790
56983
  function classifyTicketRunError(err, runtime5) {
56791
56984
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
@@ -56913,6 +57106,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
56913
57106
  const inFlight = new Map;
56914
57107
  const digests = [];
56915
57108
  let admitted = 0;
57109
+ let verificationChecked = false;
56916
57110
  const satisfied = (t, s) => s === "done" || s === "cancelled" || isReviewTicket(t) && s === "failed";
56917
57111
  const implementationsSettled = () => scheduled.every((t) => isReviewTicket(t) || !pending3.has(t.seq) && !inFlight.has(t.seq));
56918
57112
  const admitNewTickets = () => {
@@ -56937,6 +57131,46 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
56937
57131
  data: { seqs: added }
56938
57132
  });
56939
57133
  };
57134
+ const appendVerificationIfDue = () => {
57135
+ verificationChecked = true;
57136
+ if (!ctx.listTickets || !ctx.storeTickets)
57137
+ return;
57138
+ const allTickets = ctx.listTickets();
57139
+ const runTicketIds = new Set(scheduled.map((ticket) => ticket.id));
57140
+ const decision = verificationDue(allTickets, runTicketIds);
57141
+ if (!decision.due)
57142
+ return;
57143
+ const findings = new Map((ctx.listFindings?.() ?? []).map((finding) => [finding.id, finding]));
57144
+ const landedLines = decision.landed.map((ticket) => {
57145
+ const finding = ticket.originFindingId ? findings.get(ticket.originFindingId) : undefined;
57146
+ const detail = finding ? `
57147
+ Finding: ${finding.title}
57148
+ Location: ${finding.location}
57149
+ Repro step: ${finding.reproStep}` : "";
57150
+ return `- #${ticket.seq} ${ticket.title}${detail}`;
57151
+ });
57152
+ ctx.storeTickets([{
57153
+ kind: "review",
57154
+ passKind: "verification",
57155
+ title: "Verify the fixes that landed",
57156
+ goal: "Tour the current build and verify the fixes that landed in this run.",
57157
+ context: [
57158
+ `Verifies pass: ${decision.verifies ? `#${decision.verifies.seq}` : "none in this run"}`,
57159
+ "Landed implementation tickets:",
57160
+ ...landedLines
57161
+ ].join(`
57162
+ `),
57163
+ acceptanceCriteria: ["Tour the current build and verify every landed fix listed in the context."],
57164
+ seams: ["review evidence and the landed fixes listed in the context"],
57165
+ blockedBy: []
57166
+ }]);
57167
+ ctx.emitEvent({
57168
+ type: "ticket.verification_minted",
57169
+ message: `verification pass appended \u2014 ${decision.landed.length} fixes landed after the review`,
57170
+ data: { landedSeqs: decision.landed.map((ticket) => ticket.seq) }
57171
+ });
57172
+ admitNewTickets();
57173
+ };
56940
57174
  const mirrorFinding = (t, progress, reason) => {
56941
57175
  if (!t.originFindingId)
56942
57176
  return;
@@ -56990,7 +57224,13 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
56990
57224
  const outcome = await execute(ctx, t, { digests: [...digests] });
56991
57225
  if (outcome.status === "done") {
56992
57226
  status3.set(seq2, "done");
56993
- ctx.updateTicket(t.id, { status: "done", commits: outcome.commits, digest: outcome.digest });
57227
+ const reviewedCommit = isReviewTicket(t) ? await headSha(ctx.project.repoPath, ctx.feature.branch) : undefined;
57228
+ ctx.updateTicket(t.id, {
57229
+ status: "done",
57230
+ commits: outcome.commits,
57231
+ digest: outcome.digest,
57232
+ ...reviewedCommit ? { reviewedCommit } : {}
57233
+ });
56994
57234
  mirrorFinding(t, "fixed");
56995
57235
  ctx.emitEvent({
56996
57236
  type: "ticket.done",
@@ -57018,8 +57258,13 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
57018
57258
  if (isReviewTicket(t))
57019
57259
  admitNewTickets();
57020
57260
  };
57021
- while (pending3.size > 0 || inFlight.size > 0) {
57261
+ while (pending3.size > 0 || inFlight.size > 0 || !verificationChecked) {
57022
57262
  ctx.signal.throwIfAborted();
57263
+ if (pending3.size === 0 && inFlight.size === 0) {
57264
+ appendVerificationIfDue();
57265
+ if (pending3.size === 0)
57266
+ break;
57267
+ }
57023
57268
  for (const seq2 of [...pending3]) {
57024
57269
  const st = readyState(seq2);
57025
57270
  if (typeof st === "object") {
@@ -57365,7 +57610,7 @@ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolat
57365
57610
  const attachmentCommands = buildAttachmentCopyCommands(attachments);
57366
57611
  const excludeAttachments = async () => {
57367
57612
  if (attachments.length > 0)
57368
- await excludePath(project3.repoPath, `${ATTACHMENTS_DIR}/`);
57613
+ await excludePath(project3.repoPath, ATTACHMENTS_PREFIX);
57369
57614
  };
57370
57615
  const clearAttachmentsFor = async (branch) => {
57371
57616
  if (attachments.length > 0) {
@@ -57878,7 +58123,7 @@ function readTokenFromEnvFile(path2, runtime5) {
57878
58123
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env[key];
57879
58124
  return token && token.length > 0 ? token : undefined;
57880
58125
  }
57881
- var AUTH_MISSING_EVENT = "auth.missing", IMAGE_RUNTIME_MISSING_EVENT = "burn.image_runtime_missing", RUNTIME_BINARY, DROPPED_MAP_SECTIONS, AGENT_CONVENTIONS_FILE = "CLAUDE.md", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, CODEX_HOST_MOUNT_PATH = "/mnt/host-codex", SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", SETUP_MARKER_FILE = ".runcastle-setup", SETUP_MARKER = "RUNCASTLE_SETUP", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RUNTIME_FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, RUNTIME_RETRYABLE_ERROR_PATTERNS, AGENT_BINARY, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
58126
+ var ATTACHMENTS_PREFIX, AUTH_MISSING_EVENT = "auth.missing", IMAGE_RUNTIME_MISSING_EVENT = "burn.image_runtime_missing", RUNTIME_BINARY, DROPPED_MAP_SECTIONS, AGENT_CONVENTIONS_FILE = "CLAUDE.md", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, CODEX_HOST_MOUNT_PATH = "/mnt/host-codex", SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", SETUP_MARKER_FILE = ".runcastle-setup", SETUP_MARKER = "RUNCASTLE_SETUP", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RUNTIME_FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, RUNTIME_RETRYABLE_ERROR_PATTERNS, AGENT_BINARY, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
57882
58127
  var init_ticket_burner = __esm(() => {
57883
58128
  init_src();
57884
58129
  init_config_load();
@@ -57897,6 +58142,7 @@ var init_ticket_burner = __esm(() => {
57897
58142
  init_docker();
57898
58143
  init_podman();
57899
58144
  init_no_sandbox();
58145
+ ATTACHMENTS_PREFIX = `${ATTACHMENTS_DIR}/`;
57900
58146
  RUNTIME_BINARY = {
57901
58147
  "claude-code": "claude",
57902
58148
  codex: "codex"
@@ -58226,6 +58472,8 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
58226
58472
  updateTicket(ctx, id3, patch9);
58227
58473
  },
58228
58474
  listTickets: () => listByFeature2(ctx, featureId),
58475
+ storeTickets: (inputs) => storeTickets(ctx, featureId, inputs),
58476
+ listFindings: () => listByFeature3(ctx, featureId),
58229
58477
  updateFinding: (findingId, progress, reason) => {
58230
58478
  markFixProgress(ctx, findingId, progress, reason);
58231
58479
  },
@@ -58508,7 +58756,7 @@ function getFeatureFull(ctx, id3) {
58508
58756
  return {
58509
58757
  feature,
58510
58758
  tickets: listByFeature2(ctx, id3),
58511
- sessions: listSessionsByFeature(ctx, id3),
58759
+ sessions: listSessionsByFeature(ctx, id3).map((session) => session.kind === "qa" && session.status === "ended" && !session.ccSessionId ? { ...session, title: null, transcriptMissing: true } : session),
58512
58760
  runs: listRunsByFeature(ctx, id3),
58513
58761
  docs: listDocs(ctx, feature),
58514
58762
  gate: gateState(ctx, feature),
@@ -58615,22 +58863,25 @@ async function burn(ctx, featureId, opts = {}) {
58615
58863
  throw e;
58616
58864
  }
58617
58865
  }
58866
+ function assertIterable(ctx, feature) {
58867
+ if (hasActiveRun(ctx, feature.id)) {
58868
+ throw new GateError("a run is burning this feature \u2014 cancel or wait for it before iterating");
58869
+ }
58870
+ if (activeTestDriveFeatureId() === feature.id) {
58871
+ throw new GateError(`${feature.slug} is being test-driven \u2014 stop the test drive first, it holds the feature branch`);
58872
+ }
58873
+ }
58618
58874
  function rethink(ctx, featureId) {
58619
58875
  const feature = getFeatureRow(ctx, featureId);
58620
58876
  requireNotDraft(feature);
58621
58877
  if (feature.phase !== RETHINK_LOOP_BACK.from) {
58622
58878
  throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
58623
58879
  }
58624
- if (hasActiveRun(ctx, featureId)) {
58625
- throw new GateError("a run is burning this feature \u2014 cancel or wait for it before rethinking");
58626
- }
58880
+ assertIterable(ctx, feature);
58627
58881
  const live = activeSessionsForFeature(ctx, featureId);
58628
58882
  if (live.length > 0) {
58629
58883
  throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
58630
58884
  }
58631
- if (activeTestDriveFeatureId() === featureId) {
58632
- throw new GateError(`${feature.slug} is being test-driven \u2014 stop the test drive first, it holds the feature branch`);
58633
- }
58634
58885
  const lap = feature.lap + 1;
58635
58886
  ctx.db.update(features).set({ lap }).where(eq11(features.id, featureId)).run();
58636
58887
  return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
@@ -58842,7 +59093,7 @@ var init_features = __esm(() => {
58842
59093
  // src/services/test-notes.ts
58843
59094
  import { existsSync as existsSync19, mkdirSync as mkdirSync12, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
58844
59095
  import { dirname as dirname11 } from "path";
58845
- import { asc as asc5, eq as eq12 } from "drizzle-orm";
59096
+ import { and as and9, asc as asc5, eq as eq12, lt } from "drizzle-orm";
58846
59097
  function rowToNote(row) {
58847
59098
  return TestNote.parse({
58848
59099
  id: row.id,
@@ -58852,6 +59103,8 @@ function rowToNote(row) {
58852
59103
  status: row.status,
58853
59104
  author: row.author,
58854
59105
  ticketId: row.ticketId ?? undefined,
59106
+ reviewTicketId: row.reviewTicketId ?? undefined,
59107
+ carriedLap: row.carriedLap ?? undefined,
58855
59108
  videoTimestamp: row.videoTimestamp ?? undefined,
58856
59109
  screenshotUrl: existsSync19(annotationPath(row.id)) ? noteScreenshotUrl(row.id) : undefined,
58857
59110
  createdAt: row.createdAt,
@@ -58878,9 +59131,14 @@ function assertOpen(note2, action) {
58878
59131
  function listByFeature4(ctx, featureId) {
58879
59132
  return ctx.db.select().from(testNotes).where(eq12(testNotes.featureId, featureId)).orderBy(asc5(testNotes.createdAt), asc5(testNotes.id)).all().map(rowToNote);
58880
59133
  }
58881
- function addNote(ctx, featureId, text3, author = "human", videoTimestamp) {
59134
+ function addNote(ctx, featureId, text3, author = "human", videoTimestamp, reviewTicketId) {
58882
59135
  const body = cleanText(text3);
58883
59136
  const feature = getFeatureRow(ctx, featureId);
59137
+ if (reviewTicketId) {
59138
+ const ticket = getTicket(ctx, reviewTicketId);
59139
+ if (ticket.featureId !== featureId || ticket.kind !== "review")
59140
+ throw new InvalidInputError("review ticket must be a review of the same feature");
59141
+ }
58884
59142
  const now = Date.now();
58885
59143
  const row = ctx.db.insert(testNotes).values({
58886
59144
  id: newId("note"),
@@ -58891,6 +59149,8 @@ function addNote(ctx, featureId, text3, author = "human", videoTimestamp) {
58891
59149
  author,
58892
59150
  ticketId: null,
58893
59151
  videoTimestamp: videoTimestamp ?? null,
59152
+ reviewTicketId: reviewTicketId ?? null,
59153
+ carriedLap: null,
58894
59154
  createdAt: now,
58895
59155
  updatedAt: now
58896
59156
  }).returning().get();
@@ -58942,7 +59202,7 @@ function attachScreenshot(ctx, noteId, png) {
58942
59202
  }
58943
59203
  function toggleNote(ctx, noteId) {
58944
59204
  const current2 = getNote(ctx, noteId);
58945
- if (current2.status === "promoted") {
59205
+ if (current2.status === "promoted" || current2.status === "carried") {
58946
59206
  throw new InvalidInputError(`cannot toggle note ${noteId} \u2014 it is promoted, and promoted notes are frozen`);
58947
59207
  }
58948
59208
  const status3 = current2.status === "open" ? "done" : "open";
@@ -58962,12 +59222,10 @@ function screenshotParagraph(note2) {
58962
59222
  return `An annotated screenshot of the problem is at ${attachmentRelPath(note2.id)} in your workspace${moment} \u2014 Read it before starting; the drawing marks the problem area.`;
58963
59223
  }
58964
59224
  function promotionTicket(feature, note2) {
58965
- const firstLine = note2.text.split(`
58966
- `)[0].trim();
58967
59225
  const docs2 = featureDocsRel(feature.slug);
58968
59226
  const screenshot = screenshotParagraph(note2);
58969
59227
  return {
58970
- title: firstLine.length <= TITLE_MAX ? firstLine : `${firstLine.slice(0, TITLE_MAX - 1).trimEnd()}\u2026`,
59228
+ title: ticketTitleFromNote(note2.text),
58971
59229
  goal: note2.text,
58972
59230
  context: [
58973
59231
  `Found during lap ${note2.lap} test drive of ${feature.slug}.`,
@@ -59030,9 +59288,56 @@ function noteLine(ctx, note2) {
59030
59288
  const shot = screenshotSuffix(note2);
59031
59289
  if (note2.status === "open")
59032
59290
  return `- [ ] ${note2.text}${shot}`;
59291
+ if (note2.status === "carried")
59292
+ return `- [\u2192] ${note2.text} (carried into lap ${note2.carriedLap})${shot}`;
59033
59293
  const ticket = note2.ticketId ? ` (\u2192 ticket ${getTicket(ctx, note2.ticketId).seq})` : "";
59034
59294
  return `- [x] ${note2.text}${ticket}${shot}`;
59035
59295
  }
59296
+ function carryNotes(ctx, featureId, noteIds, lap) {
59297
+ const selected = noteIds.map((id3) => getNote(ctx, id3));
59298
+ for (const note2 of selected) {
59299
+ if (note2.featureId !== featureId)
59300
+ throw new InvalidInputError("every carried note must belong to the feature");
59301
+ assertOpen(note2, "carry");
59302
+ ctx.db.update(testNotes).set({ status: "carried", carriedLap: lap, updatedAt: Date.now() }).where(eq12(testNotes.id, note2.id)).run();
59303
+ emit(ctx, featureId, { type: "note.carried", message: `note carried into lap ${lap}`, data: { noteId: note2.id, lap } });
59304
+ }
59305
+ renderTestNotes(ctx, getFeatureRow(ctx, featureId));
59306
+ return selected.map((note2) => getNote(ctx, note2.id));
59307
+ }
59308
+ function reopenNote(ctx, noteId) {
59309
+ const note2 = getNote(ctx, noteId);
59310
+ if (note2.status !== "carried")
59311
+ throw new InvalidInputError("only a carried note can be reopened");
59312
+ ctx.db.update(testNotes).set({ status: "open", carriedLap: null, updatedAt: Date.now() }).where(eq12(testNotes.id, noteId)).run();
59313
+ emit(ctx, note2.featureId, { type: "note.reopened", message: "note reopened", data: { noteId } });
59314
+ renderTestNotes(ctx, getFeatureRow(ctx, note2.featureId));
59315
+ return getNote(ctx, noteId);
59316
+ }
59317
+ function triageNotes(ctx, featureId, input) {
59318
+ const feature = getFeatureRow(ctx, featureId);
59319
+ assertIterable(ctx, feature);
59320
+ for (const id3 of input.dismissIds)
59321
+ deleteNote(ctx, id3);
59322
+ const noteTickets = input.quickFixIds.length ? freezeAsTickets(ctx, input.quickFixIds).tickets.length : 0;
59323
+ const findingTickets = input.quickFixFindingIds.length ? promoteOpenDefects(ctx, featureId, input.quickFixFindingIds).tickets.length : 0;
59324
+ const remaining = input.carry ? listByFeature4(ctx, featureId).filter((note2) => note2.status === "open") : [];
59325
+ if (remaining.length)
59326
+ carryNotes(ctx, featureId, remaining.map((note2) => note2.id), feature.lap + 1);
59327
+ return { minted: noteTickets + findingTickets, carried: remaining.length, dismissed: input.dismissIds.length };
59328
+ }
59329
+ function triagePreview(ctx, featureId) {
59330
+ const feature = getFeatureRow(ctx, featureId);
59331
+ const standing = ctx.db.select().from(tickets).where(and9(eq12(tickets.featureId, featureId), eq12(tickets.kind, "implementation"), eq12(tickets.status, "pending"), lt(tickets.lap, feature.lap))).all();
59332
+ const grouped = new Map;
59333
+ for (const ticket of standing)
59334
+ grouped.set(ticket.lap, (grouped.get(ticket.lap) ?? 0) + 1);
59335
+ return {
59336
+ openNotes: listByFeature4(ctx, featureId).filter((note2) => note2.status === "open").length,
59337
+ openDefects: viewByFeature(ctx, featureId).openDefects.length,
59338
+ standingFixTickets: [...grouped].sort(([a], [b]) => a - b).map(([lap, count3]) => ({ count: count3, lap }))
59339
+ };
59340
+ }
59036
59341
  function renderTestNotes(ctx, feature) {
59037
59342
  const notes = listByFeature4(ctx, feature.id);
59038
59343
  const laps = [...new Set(notes.map((n) => n.lap))].sort((a, b) => a - b);
@@ -59048,7 +59353,6 @@ function renderTestNotes(ctx, feature) {
59048
59353
  `)}
59049
59354
  `, "utf8");
59050
59355
  }
59051
- var TITLE_MAX = 60;
59052
59356
  var init_test_notes = __esm(() => {
59053
59357
  init_src();
59054
59358
  init_paths();
@@ -59058,6 +59362,8 @@ var init_test_notes = __esm(() => {
59058
59362
  init_feature_docs();
59059
59363
  init_repo();
59060
59364
  init_tickets();
59365
+ init_features();
59366
+ init_review_findings();
59061
59367
  });
59062
59368
 
59063
59369
  // src/mcp/server.ts
@@ -59302,7 +59608,7 @@ function requireProject(ctx, session) {
59302
59608
  throw new GateError(`project ${session.projectId} not found`);
59303
59609
  return project3;
59304
59610
  }
59305
- function toolRecordFinding(ctx, session, input) {
59611
+ async function toolRecordFinding(ctx, session, input) {
59306
59612
  const project3 = requireProject(ctx, session);
59307
59613
  const source = input.userSupplied ? "human" : "session";
59308
59614
  if (!isOverwritable(ctx, project3.id, input.key) && !input.userSupplied) {
@@ -59313,11 +59619,17 @@ function toolRecordFinding(ctx, session, input) {
59313
59619
  skipped: `${input.key} was set by a human and is not auto-overwritable \u2014 ask them to clear it first if it should change`
59314
59620
  };
59315
59621
  }
59622
+ let establishedSha;
59623
+ try {
59624
+ const mainLine = await detectMainBranch(project3.repoPath);
59625
+ establishedSha = await headSha(project3.repoPath, mainLine);
59626
+ } catch {}
59316
59627
  recordFinding(ctx, project3.id, {
59317
59628
  key: input.key,
59318
59629
  value: input.value,
59319
59630
  source,
59320
- ...input.evidence ? { evidence: input.evidence } : {}
59631
+ ...input.evidence ? { evidence: input.evidence } : {},
59632
+ ...establishedSha ? { establishedSha } : {}
59321
59633
  });
59322
59634
  emitProject(ctx, project3.id, {
59323
59635
  type: "prep.finding_recorded",
@@ -59594,7 +59906,7 @@ function buildMcpServer(audience) {
59594
59906
  const rs = await resolveCtxSession(extra);
59595
59907
  if (!rs)
59596
59908
  return noSession();
59597
- return ok(toolRecordFinding(rs.ctx, rs.session, args2));
59909
+ return ok(await toolRecordFinding(rs.ctx, rs.session, args2));
59598
59910
  });
59599
59911
  }
59600
59912
  if (wants("dry_run_drive")) {
@@ -60071,7 +60383,7 @@ var init_ws = __esm(() => {
60071
60383
  });
60072
60384
 
60073
60385
  // src/services/prep.ts
60074
- import { and as and9, desc as desc6, eq as eq13, inArray as inArray4 } from "drizzle-orm";
60386
+ import { and as and10, desc as desc6, eq as eq13, inArray as inArray5 } from "drizzle-orm";
60075
60387
  function keysToPrepare(ctx, project3) {
60076
60388
  return unsetPreparedKeys(project3).filter((key) => isOverwritable(ctx, project3.id, key));
60077
60389
  }
@@ -60081,10 +60393,10 @@ function isPrepared(ctx, project3) {
60081
60393
  return hasCompletedProjectSession(ctx, project3.id, "prepare");
60082
60394
  }
60083
60395
  function preparedAt(ctx, projectId) {
60084
- const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and9(eq13(sessions.projectId, projectId), eq13(sessions.kind, "prepare"))).all().map((r) => r.id));
60396
+ const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and10(eq13(sessions.projectId, projectId), eq13(sessions.kind, "prepare"))).all().map((r) => r.id));
60085
60397
  if (prepareSessions.size === 0)
60086
60398
  return null;
60087
- const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and9(eq13(events.projectId, projectId), inArray4(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
60399
+ const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and10(eq13(events.projectId, projectId), inArray5(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
60088
60400
  for (const end5 of ends) {
60089
60401
  const sessionId = end5.data?.sessionId;
60090
60402
  if (sessionId && prepareSessions.has(sessionId))
@@ -60339,15 +60651,12 @@ var init_hooks = __esm(() => {
60339
60651
  });
60340
60652
 
60341
60653
  // src/routes/reviews.ts
60342
- import { createReadStream, statSync as statSync5 } from "fs";
60654
+ import { createReadStream, statSync as statSync6 } from "fs";
60343
60655
  import { Readable } from "stream";
60344
60656
  import { Hono as Hono3 } from "hono";
60345
- function walkthroughUrl(ticketId) {
60346
- return `/api/reviews/ticket/${ticketId}/walkthrough.webm`;
60347
- }
60348
60657
  function fileSize(path2) {
60349
60658
  try {
60350
- const stat3 = statSync5(path2);
60659
+ const stat3 = statSync6(path2);
60351
60660
  return stat3.isFile() ? stat3.size : undefined;
60352
60661
  } catch {
60353
60662
  return;
@@ -60390,9 +60699,6 @@ function parseRange(header, size6) {
60390
60699
  function fileStream(path2, start3, end5) {
60391
60700
  return Readable.toWeb(createReadStream(path2, { start: start3, end: end5 }));
60392
60701
  }
60393
- function findNote(ctx, noteId) {
60394
- return lookupOrUndefined(() => getNote(ctx, noteId));
60395
- }
60396
60702
  function isPng(bytes) {
60397
60703
  return bytes.length > PNG_MAGIC.length && PNG_MAGIC.every((byte, i) => bytes[i] === byte);
60398
60704
  }
@@ -60404,20 +60710,32 @@ var init_reviews = __esm(() => {
60404
60710
  init_test_notes();
60405
60711
  init_tickets();
60406
60712
  reviews = new Hono3;
60407
- reviews.get("/:featureId", async (c) => {
60713
+ reviews.get(REVIEW_ARTIFACTS_ROUTE, async (c) => {
60408
60714
  const ctx = await getRuntimeCtx();
60409
- const artifacts = listByFeature2(ctx, c.req.param("featureId")).filter((t) => t.kind === "review").map((t) => {
60715
+ const allTickets = listByFeature2(ctx, c.req.param("featureId"));
60716
+ const artifacts = allTickets.filter((t) => t.kind === "review").map((t) => {
60410
60717
  const hasVideo = fileSize(reviewWalkthroughPath(t.id)) !== undefined;
60411
60718
  return {
60412
60719
  ticketId: t.id,
60413
60720
  seq: t.seq,
60721
+ lap: t.lap,
60722
+ passKind: t.passKind,
60723
+ reviewedCommit: t.reviewedCommit,
60724
+ completedAt: t.completedAt,
60725
+ landedSince: t.completedAt === null ? 0 : allTickets.filter((candidate) => candidate.kind === "implementation" && candidate.status === "done" && candidate.completedAt !== null && candidate.completedAt > t.completedAt).length,
60414
60726
  hasVideo,
60415
- videoUrl: hasVideo ? walkthroughUrl(t.id) : null
60416
- };
60727
+ videoUrl: hasVideo ? reviewWalkthroughUrl(t.id) : null
60728
+ };
60729
+ }).sort((a, b) => {
60730
+ if (a.completedAt === null)
60731
+ return b.completedAt === null ? a.seq - b.seq : 1;
60732
+ if (b.completedAt === null)
60733
+ return -1;
60734
+ return a.completedAt - b.completedAt || a.seq - b.seq;
60417
60735
  });
60418
60736
  return c.json(artifacts);
60419
60737
  });
60420
- reviews.get("/ticket/:ticketId/walkthrough.webm", async (c) => {
60738
+ reviews.get(REVIEW_WALKTHROUGH_ROUTE, async (c) => {
60421
60739
  const ctx = await getRuntimeCtx();
60422
60740
  const ticket = findReviewTicket(ctx, c.req.param("ticketId"));
60423
60741
  if (!ticket)
@@ -60448,7 +60766,7 @@ var init_reviews = __esm(() => {
60448
60766
  PNG_MAGIC = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
60449
60767
  reviews.post(NOTE_SCREENSHOT_UPLOAD_ROUTE, async (c) => {
60450
60768
  const ctx = await getRuntimeCtx();
60451
- const note2 = findNote(ctx, c.req.param("noteId"));
60769
+ const note2 = lookupOrUndefined(() => getNote(ctx, c.req.param("noteId")));
60452
60770
  if (!note2)
60453
60771
  return c.notFound();
60454
60772
  const png = new Uint8Array(await c.req.arrayBuffer());
@@ -60458,7 +60776,7 @@ var init_reviews = __esm(() => {
60458
60776
  });
60459
60777
  reviews.get(NOTE_SCREENSHOT_ROUTE, async (c) => {
60460
60778
  const ctx = await getRuntimeCtx();
60461
- const note2 = findNote(ctx, c.req.param("noteId"));
60779
+ const note2 = lookupOrUndefined(() => getNote(ctx, c.req.param("noteId")));
60462
60780
  if (!note2)
60463
60781
  return c.notFound();
60464
60782
  const path2 = annotationPath(note2.id);
@@ -60525,7 +60843,7 @@ var init_stream = __esm(() => {
60525
60843
  });
60526
60844
 
60527
60845
  // src/routes/web.ts
60528
- import { existsSync as existsSync21, statSync as statSync6 } from "fs";
60846
+ import { existsSync as existsSync21, statSync as statSync7 } from "fs";
60529
60847
  import { readFile as readFile4 } from "fs/promises";
60530
60848
  import { extname, join as join20, normalize as normalize2, resolve as resolve11, sep as sep3 } from "path";
60531
60849
  function contentType(path2) {
@@ -60533,7 +60851,7 @@ function contentType(path2) {
60533
60851
  }
60534
60852
  function isFile2(path2) {
60535
60853
  try {
60536
- return statSync6(path2).isFile();
60854
+ return statSync7(path2).isFile();
60537
60855
  } catch {
60538
60856
  return false;
60539
60857
  }
@@ -60836,7 +61154,7 @@ var init_events2 = __esm(() => {
60836
61154
  // src/launcher/launcher.ts
60837
61155
  import { existsSync as existsSync23 } from "fs";
60838
61156
  import { join as join23 } from "path";
60839
- import { and as and10, eq as eq14 } from "drizzle-orm";
61157
+ import { and as and11, eq as eq14 } from "drizzle-orm";
60840
61158
  function ptyExitMessage(exitCode) {
60841
61159
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
60842
61160
  return `terminal exited (code ${label})`;
@@ -60856,7 +61174,7 @@ function renderCommand(runtime5, spec) {
60856
61174
  return [...env, runtime5.binary, ...spec.argv].join(" ");
60857
61175
  }
60858
61176
  function activeRunFor(ctx, featureId) {
60859
- const row = ctx.db.select().from(runs).where(and10(eq14(runs.featureId, featureId), eq14(runs.status, "running"))).limit(1).get();
61177
+ const row = ctx.db.select().from(runs).where(and11(eq14(runs.featureId, featureId), eq14(runs.status, "running"))).limit(1).get();
60860
61178
  return row ? rowToRun(row) : null;
60861
61179
  }
60862
61180
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -61500,69 +61818,126 @@ var init_launcher = __esm(() => {
61500
61818
  // src/services/outcome.ts
61501
61819
  import { mkdirSync as mkdirSync14, writeFileSync as writeFileSync10 } from "fs";
61502
61820
  import { join as join25 } from "path";
61503
- function composeOutcomeDoc(feature, tickets2, shippedAt) {
61821
+ function digestHasSubstance(text3) {
61822
+ return text3.split(`
61823
+ `).some((line) => {
61824
+ const trimmed = line.trim();
61825
+ return /^[-*]\s+/.test(trimmed) && !EMPTY_VALUE.test(trimmed);
61826
+ });
61827
+ }
61828
+ function findingResolution(finding) {
61829
+ return finding.status === "fixed" ? "fixed" : finding.status === "dismissed" ? "dismissed" : "open";
61830
+ }
61831
+ function noteResolution(note2) {
61832
+ if (note2.status === "promoted")
61833
+ return `quick-fixed \u2192 ticket ${note2.ticketId ?? "unknown"}`;
61834
+ if (note2.status === "carried")
61835
+ return `carried \u2192 lap ${note2.carriedLap ?? "?"}`;
61836
+ if (note2.status === "done")
61837
+ return "dismissed/done";
61838
+ return "open";
61839
+ }
61840
+ function composeOutcomeDoc(input) {
61841
+ const { feature, tickets: tickets2, findings, notes, artifacts, shippedAt, delta } = input;
61842
+ const laps = Math.max(feature.lap, ...tickets2.map((ticket) => ticket.lap), 1);
61504
61843
  const blocks = [
61505
61844
  `# Outcome \u2014 ${feature.title}`,
61506
61845
  feature.oneLiner,
61507
- [`- Shipped: ${shippedDate(shippedAt)}`, `- Lap: ${feature.lap}`].join(`
61508
- `)
61846
+ `- Shipped: ${new Date(shippedAt).toISOString().slice(0, 10)}
61847
+ - Laps run: ${laps}`,
61848
+ `## What shipped
61849
+
61850
+ ${delta.commits} commits \xB7 ${delta.files} files`
61509
61851
  ];
61510
- let oneLiners = [];
61511
- const flush = () => {
61512
- if (oneLiners.length > 0)
61513
- blocks.push(oneLiners.join(`
61514
- `));
61515
- oneLiners = [];
61516
- };
61517
- for (const ticket of [...tickets2].sort((a, b) => a.seq - b.seq)) {
61518
- if (ticket.status === "done") {
61519
- flush();
61520
- blocks.push(`## ${ticket.seq}. ${ticket.title}`, ticket.digest?.trim() || NO_DIGEST);
61521
- } else {
61522
- oneLiners.push(oneLineEntry(ticket));
61852
+ for (let lap = 1;lap <= laps; lap += 1) {
61853
+ const implementation = tickets2.filter((ticket) => ticket.kind === "implementation" && ticket.lap === lap);
61854
+ const line = (status3, label) => {
61855
+ const matches = implementation.filter((ticket) => ticket.status === status3);
61856
+ return `- ${matches.length} ${label}${matches.length ? `: ${matches.map((t2) => `#${t2.seq} ${t2.title}`).join("; ")}` : ""}`;
61857
+ };
61858
+ blocks.push(`### Lap ${lap}
61859
+ ${line("done", "tickets landed")}
61860
+ ${line("cancelled", "waived")}
61861
+ ${line("failed", "failed")}`);
61862
+ }
61863
+ blocks.push("## Review record");
61864
+ const completed = artifacts.filter((artifact) => artifact.completedAt !== null);
61865
+ const latest = [...completed].sort((a, b) => a.completedAt - b.completedAt).at(-1);
61866
+ for (const artifact of completed) {
61867
+ const ticket = tickets2.find((candidate) => candidate.id === artifact.ticketId);
61868
+ blocks.push(`### Lap ${artifact.lap} \xB7 ${artifact.passKind}
61869
+
61870
+ - Reviewed commit: ${artifact.reviewedCommit ?? "unknown"}
61871
+ - Landed since: ${artifact.landedSince}
61872
+ - Outcome: ${ticket?.status ?? "unknown"}`);
61873
+ if (artifact.ticketId === latest?.ticketId) {
61874
+ const passFindings = findings.filter((finding) => finding.reviewTicketId === artifact.ticketId);
61875
+ blocks.push(passFindings.length ? passFindings.map((finding) => `- **${finding.title}** \u2014 ${findingResolution(finding)}`).join(`
61876
+ `) : "- No findings");
61877
+ }
61878
+ }
61879
+ if (!completed.length)
61880
+ blocks.push("- No completed review pass");
61881
+ blocks.push("## Notes record", notes.length ? notes.map((note2) => `- ${note2.text} \u2014 ${noteResolution(note2)}`).join(`
61882
+ `) : "- No human notes");
61883
+ const substantive = tickets2.filter((ticket) => ticket.digest && digestHasSubstance(ticket.digest));
61884
+ if (substantive.length) {
61885
+ blocks.push("## Per-ticket digests");
61886
+ for (let lap = 1;lap <= laps; lap += 1) {
61887
+ const inLap = substantive.filter((ticket) => ticket.lap === lap);
61888
+ if (inLap.length)
61889
+ blocks.push(`### Lap ${lap}`, ...inLap.map((ticket) => `#### ${ticket.seq}. ${ticket.title}
61890
+
61891
+ ${ticket.digest.trim()}`));
61523
61892
  }
61524
61893
  }
61525
- flush();
61526
61894
  return `${blocks.join(`
61527
61895
 
61528
61896
  `)}
61529
61897
  `;
61530
61898
  }
61531
- function shippedDate(shippedAt) {
61532
- return new Date(shippedAt).toISOString().slice(0, 10);
61533
- }
61534
- function oneLineEntry(ticket) {
61535
- const headline = errorHeadline3(ticket.error ?? "");
61536
- return `- **${ticket.seq}. ${ticket.title}** \u2014 ${ticket.status}${headline ? `: ${headline}` : ""}`;
61537
- }
61538
- function errorHeadline3(error) {
61539
- const lines = error.split(`
61540
- `).map((l) => l.trim()).filter(Boolean);
61541
- const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
61542
- const headline = causes.at(-1) ?? lines[0] ?? "";
61543
- return headline.length > HEADLINE_MAX ? `${headline.slice(0, HEADLINE_MAX)}\u2026` : headline;
61544
- }
61545
- async function promoteOutcomeDoc(ctx, project3, feature) {
61899
+ async function promoteOutcomeDoc(ctx, project3, feature, target, delta) {
61546
61900
  try {
61547
- const worktreePath = await ensureTalkWorktree(project3, feature);
61548
- const docsDir = join25(worktreePath, ...featureDocsRel(feature.slug).split("/"));
61549
- mkdirSync14(docsDir, { recursive: true });
61550
- const doc = composeOutcomeDoc(feature, listByFeature2(ctx, feature.id), Date.now());
61551
- writeFileSync10(join25(docsDir, "outcome.md"), doc, "utf8");
61552
- await commitDocs(worktreePath, `runcastle: outcome for ${feature.slug}`);
61553
- } catch (e) {
61901
+ const tickets2 = listByFeature2(ctx, feature.id);
61902
+ const artifacts = tickets2.filter((ticket) => ticket.kind === "review").map((ticket) => ({
61903
+ ticketId: ticket.id,
61904
+ lap: ticket.lap,
61905
+ passKind: ticket.passKind,
61906
+ reviewedCommit: ticket.reviewedCommit,
61907
+ completedAt: ticket.completedAt,
61908
+ landedSince: ticket.completedAt === null ? 0 : tickets2.filter((candidate) => candidate.kind === "implementation" && candidate.status === "done" && candidate.completedAt !== null && candidate.completedAt > ticket.completedAt).length
61909
+ }));
61910
+ const doc = composeOutcomeDoc({
61911
+ feature,
61912
+ tickets: tickets2,
61913
+ findings: listByFeature3(ctx, feature.id),
61914
+ notes: listByFeature4(ctx, feature.id),
61915
+ artifacts,
61916
+ shippedAt: Date.now(),
61917
+ delta: { commits: delta.commits ?? 0, files: delta.files ?? 0 }
61918
+ });
61919
+ await onBranch(project3.repoPath, target, async () => {
61920
+ const docsDir = join25(project3.repoPath, ...featureDocsRel(feature.slug).split("/"));
61921
+ mkdirSync14(docsDir, { recursive: true });
61922
+ writeFileSync10(join25(docsDir, "outcome.md"), doc, "utf8");
61923
+ await commitDocs(project3.repoPath, `runcastle: outcome for ${feature.slug}`);
61924
+ });
61925
+ } catch (error) {
61554
61926
  emit(ctx, feature.id, {
61555
61927
  type: "docs.outcome_failed",
61556
- message: `outcome.md not promoted: ${e instanceof Error ? e.message : String(e)}`
61928
+ message: `outcome.md not promoted on ${target}: ${error instanceof Error ? error.message : String(error)}`
61557
61929
  });
61558
61930
  }
61559
61931
  }
61560
- var NO_DIGEST = "_no digest captured_", HEADLINE_MAX = 160;
61932
+ var EMPTY_VALUE;
61561
61933
  var init_outcome = __esm(() => {
61562
61934
  init_paths();
61563
61935
  init_events();
61564
61936
  init_git();
61937
+ init_review_findings();
61938
+ init_test_notes();
61565
61939
  init_tickets();
61940
+ EMPTY_VALUE = /^(?:[-*]\s*)?(?:none|nothing|n\/?a)[.!]?$/i;
61566
61941
  });
61567
61942
 
61568
61943
  // src/trpc/routers/feature.ts
@@ -61585,6 +61960,7 @@ var init_feature = __esm(() => {
61585
61960
  projectId: z7.string(),
61586
61961
  title: z7.string().min(1),
61587
61962
  oneLiner: z7.string(),
61963
+ brief: z7.string().optional(),
61588
61964
  baseBranch: z7.string().optional(),
61589
61965
  draft: z7.boolean().optional()
61590
61966
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
@@ -61632,6 +62008,10 @@ var init_feature = __esm(() => {
61632
62008
  const feature = getFeatureRow(ctx, input.featureId);
61633
62009
  return reviewCommitCount(projectForFeature(ctx, feature), feature);
61634
62010
  }),
62011
+ mergeDelta: publicProcedure.input(z7.object({ featureId: z7.string() })).query(({ ctx, input }) => {
62012
+ const feature = getFeatureRow(ctx, input.featureId);
62013
+ return mergeDelta(projectForFeature(ctx, feature), feature);
62014
+ }),
61635
62015
  merge: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(async ({ ctx, input }) => {
61636
62016
  const feature = getFeatureRow(ctx, input.featureId);
61637
62017
  requireNotDraft(feature);
@@ -61639,9 +62019,17 @@ var init_feature = __esm(() => {
61639
62019
  if (activeTestDriveFeatureId() === feature.id) {
61640
62020
  await testDrive(ctx, project3, feature, "stop");
61641
62021
  }
61642
- await promoteOutcomeDoc(ctx, project3, feature);
62022
+ const delta = await mergeDelta(project3, feature);
62023
+ const standingConflict = unresolvedMergeConflict(listAfter(ctx, feature.id, 0));
61643
62024
  const res = await mergeFeature(project3, feature);
61644
62025
  if (res.ok) {
62026
+ if (standingConflict) {
62027
+ emit(ctx, input.featureId, {
62028
+ type: "merge.resolved",
62029
+ message: "conflict retired by the merge"
62030
+ });
62031
+ }
62032
+ await promoteOutcomeDoc(ctx, project3, feature, res.target, delta);
61645
62033
  setPhase(ctx, input.featureId, "shipped", "feature.shipped", `merged to ${res.target}`);
61646
62034
  setFeatureStatus(ctx, input.featureId, "shipped");
61647
62035
  } else {
@@ -61657,9 +62045,9 @@ var init_feature = __esm(() => {
61657
62045
  });
61658
62046
 
61659
62047
  // src/services/fsbrowse.ts
61660
- import { existsSync as existsSync24, readdirSync as readdirSync6, statSync as statSync7 } from "fs";
62048
+ import { existsSync as existsSync24, readdirSync as readdirSync6, statSync as statSync8 } from "fs";
61661
62049
  import { homedir as homedir4 } from "os";
61662
- import { basename as basename4, dirname as dirname14, isAbsolute as isAbsolute3, join as join26, resolve as resolve12, sep as sep4 } from "path";
62050
+ import { basename as basename3, dirname as dirname14, isAbsolute as isAbsolute3, join as join26, resolve as resolve12, sep as sep4 } from "path";
61663
62051
  function expandPath(input, home = homedir4()) {
61664
62052
  const trimmed = input.trim();
61665
62053
  if (trimmed === "")
@@ -61711,7 +62099,7 @@ function crumbsFor(dir) {
61711
62099
  const out = [];
61712
62100
  let cur = dir;
61713
62101
  for (;; ) {
61714
- out.unshift({ name: basename4(cur) || cur, path: cur });
62102
+ out.unshift({ name: basename3(cur) || cur, path: cur });
61715
62103
  const parent = dirname14(cur);
61716
62104
  if (parent === cur)
61717
62105
  break;
@@ -61733,7 +62121,7 @@ function browseDir(input, showHidden = false, home = homedir4()) {
61733
62121
  }
61734
62122
  let dir = target;
61735
62123
  try {
61736
- if (!statSync7(target).isDirectory())
62124
+ if (!statSync8(target).isDirectory())
61737
62125
  dir = dirname14(target);
61738
62126
  } catch (e) {
61739
62127
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
@@ -61763,7 +62151,7 @@ function browseDir(input, showHidden = false, home = homedir4()) {
61763
62151
  let isDirectory = dirent.isDirectory();
61764
62152
  if (isSymlink) {
61765
62153
  try {
61766
- isDirectory = statSync7(path2).isDirectory();
62154
+ isDirectory = statSync8(path2).isDirectory();
61767
62155
  } catch {
61768
62156
  continue;
61769
62157
  }
@@ -61901,38 +62289,72 @@ function withoutKickoff(turns, kind, runtime5) {
61901
62289
  const kickoff = kickoffLineFor(kind, undefined, runtime5);
61902
62290
  return turns.filter((turn) => !(turn.role === "user" && promptMatchesKickoff(kickoff, turn.text)));
61903
62291
  }
62292
+ function saidText(text3) {
62293
+ const trimmed = text3.trim();
62294
+ if (trimmed.startsWith("<command-name>"))
62295
+ return null;
62296
+ if (trimmed === INTERRUPTED)
62297
+ return null;
62298
+ return trimmed.replace(/\[Image #\d+\]/g, " ").trim() || null;
62299
+ }
61904
62300
  function deriveTitle(turns, runtime5) {
61905
- const first = withoutKickoff(turns, "project", runtime5).find((turn) => turn.role === "user");
61906
- return first ? elide(first.text) : null;
62301
+ for (const turn of withoutKickoff(turns, "project", runtime5)) {
62302
+ if (turn.role !== "user")
62303
+ continue;
62304
+ const said = saidText(turn.text);
62305
+ if (said)
62306
+ return elide(said);
62307
+ }
62308
+ return null;
61907
62309
  }
61908
62310
  function elide(text3) {
61909
62311
  const line = text3.replace(/\s+/g, " ").trim();
61910
- if (line.length <= TITLE_MAX2)
62312
+ if (line.length <= TITLE_MAX)
61911
62313
  return line;
61912
- return `${line.slice(0, TITLE_MAX2).trimEnd()}\u2026`;
62314
+ return `${line.slice(0, TITLE_MAX).trimEnd()}\u2026`;
61913
62315
  }
61914
- function untitled(createdAt) {
61915
- return createdAt ? `Chat from ${new Date(createdAt).toISOString().slice(0, 10)}` : "Untitled chat";
62316
+ function junkTitle(title) {
62317
+ return title.startsWith("<command-name>") || title.startsWith("[");
61916
62318
  }
61917
62319
  function listProjectConversations(ctx, projectId) {
61918
- return projectSessions(ctx, projectId, "project").map((session) => {
61919
- const createdAt = session.createdAt ?? null;
61920
- const runtime5 = session.runtime ?? DEFAULT_RUNTIME;
61921
- let title = session.title ?? null;
61922
- if (!title) {
61923
- title = deriveTitle(readTranscript2(session.transcriptPath, runtime5).turns, runtime5);
61924
- if (title)
61925
- setSessionTitle(ctx, session.id, title);
61926
- }
62320
+ return groupByConversation(projectSessions(ctx, projectId, "project")).map((group) => {
62321
+ const [latest] = group;
62322
+ const earliest = group[group.length - 1];
62323
+ const stamps = group.map((s) => s.createdAt).filter((at) => at !== undefined);
61927
62324
  return {
61928
- id: session.id,
61929
- title: title ?? untitled(createdAt),
61930
- createdAt,
61931
- status: session.status,
61932
- resumable: !!session.ccSessionId
62325
+ id: latest.id,
62326
+ title: conversationTitle(ctx, earliest),
62327
+ createdAt: stamps.length ? Math.min(...stamps) : null,
62328
+ status: latest.status,
62329
+ resumable: true
61933
62330
  };
61934
62331
  });
61935
62332
  }
62333
+ function groupByConversation(rows) {
62334
+ const groups = new Map;
62335
+ for (const row of rows) {
62336
+ if (!row.ccSessionId)
62337
+ continue;
62338
+ const group = groups.get(row.ccSessionId);
62339
+ if (group)
62340
+ group.push(row);
62341
+ else
62342
+ groups.set(row.ccSessionId, [row]);
62343
+ }
62344
+ return [...groups.values()];
62345
+ }
62346
+ function conversationTitle(ctx, earliest) {
62347
+ const cached5 = earliest.title;
62348
+ if (cached5 && !junkTitle(cached5))
62349
+ return cached5;
62350
+ const runtime5 = earliest.runtime ?? DEFAULT_RUNTIME;
62351
+ const derived = deriveTitle(readTranscript2(earliest.transcriptPath, runtime5).turns, runtime5);
62352
+ if (derived)
62353
+ setSessionTitle(ctx, earliest.id, derived);
62354
+ else if (cached5)
62355
+ clearSessionTitle(ctx, earliest.id);
62356
+ return derived ?? UNTITLED;
62357
+ }
61936
62358
  function conversationTranscript(ctx, sessionId) {
61937
62359
  const session = getSessionRow(ctx, sessionId);
61938
62360
  if (!session)
@@ -61945,7 +62367,7 @@ function conversationTranscript(ctx, sessionId) {
61945
62367
  runtime: runtime5
61946
62368
  };
61947
62369
  }
61948
- var TITLE_MAX2 = 60;
62370
+ var TITLE_MAX = 60, INTERRUPTED = "[Request interrupted by user]", UNTITLED = "Untitled";
61949
62371
  var init_conversations = __esm(() => {
61950
62372
  init_src();
61951
62373
  init_sessions();
@@ -61954,7 +62376,7 @@ var init_conversations = __esm(() => {
61954
62376
 
61955
62377
  // src/services/projects.ts
61956
62378
  import { existsSync as existsSync25 } from "fs";
61957
- import { basename as basename5, join as join27 } from "path";
62379
+ import { basename as basename4, join as join27 } from "path";
61958
62380
  import { eq as eq15, isNull } from "drizzle-orm";
61959
62381
  function listProjects(ctx) {
61960
62382
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
@@ -61973,7 +62395,7 @@ function warnIfTranslatedMount(ctx, projectId, repoPath) {
61973
62395
  async function openProject(ctx, rawPath) {
61974
62396
  const repoPath = expandPath(rawPath);
61975
62397
  await assertRepoTolerant(ctx, repoPath);
61976
- const name = basename5(repoPath) || repoPath;
62398
+ const name = basename4(repoPath) || repoPath;
61977
62399
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
61978
62400
  if (existing) {
61979
62401
  ctx.db.update(projects).set({ repoPath, closedAt: null }).where(eq15(projects.id, existing.id)).run();
@@ -62066,7 +62488,9 @@ var init_project = __esm(() => {
62066
62488
  list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
62067
62489
  roots: publicProcedure.query(() => listRoots()),
62068
62490
  browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
62069
- branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
62491
+ branches: publicProcedure.input(z8.object({ projectId: z8.string(), includeFeatureBranches: z8.boolean().optional() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId), {
62492
+ includeFeatureBranches: input.includeFeatureBranches
62493
+ })),
62070
62494
  sessionBranch: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => sessionBranchView(requireProjectById(ctx, input.projectId))),
62071
62495
  open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
62072
62496
  close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
@@ -62104,16 +62528,52 @@ var init_review_findings2 = __esm(() => {
62104
62528
  });
62105
62529
  });
62106
62530
 
62531
+ // src/services/runs.ts
62532
+ import { and as and12, asc as asc6, eq as eq16, isNotNull as isNotNull3 } from "drizzle-orm";
62533
+ function runTicketIds(ctx, runId) {
62534
+ const rows = ctx.db.select({ ticketId: events.ticketId }).from(events).where(and12(eq16(events.runId, runId), isNotNull3(events.ticketId))).orderBy(asc6(events.id)).all();
62535
+ const seen = new Set;
62536
+ for (const row of rows)
62537
+ if (row.ticketId)
62538
+ seen.add(row.ticketId);
62539
+ return [...seen];
62540
+ }
62541
+ function runLap(ctx, runId) {
62542
+ const row = ctx.db.select({ lap: events.lap }).from(events).where(eq16(events.runId, runId)).orderBy(asc6(events.id)).limit(1).get();
62543
+ return row?.lap ?? 1;
62544
+ }
62545
+ function listRunSummaries(ctx, featureId) {
62546
+ return listRunsByFeature(ctx, featureId).map((run7) => ({
62547
+ id: run7.id,
62548
+ status: run7.status,
62549
+ startedAt: run7.startedAt,
62550
+ ...run7.endedAt === undefined ? {} : { endedAt: run7.endedAt },
62551
+ lap: runLap(ctx, run7.id),
62552
+ ticketIds: runTicketIds(ctx, run7.id)
62553
+ }));
62554
+ }
62555
+ function getRunWithTickets(ctx, runId) {
62556
+ const run7 = getRunRow(ctx, runId);
62557
+ return { ...run7, tickets: listByIds(ctx, runTicketIds(ctx, runId)) };
62558
+ }
62559
+ var init_runs = __esm(() => {
62560
+ init_schema();
62561
+ init_repo();
62562
+ init_tickets();
62563
+ });
62564
+
62107
62565
  // src/trpc/routers/run.ts
62108
62566
  import * as z10 from "zod";
62109
62567
  var runRouter;
62110
62568
  var init_run = __esm(() => {
62111
62569
  init_agent_stream();
62112
62570
  init_repo();
62571
+ init_runs();
62113
62572
  init_runner();
62114
62573
  init_context();
62115
62574
  runRouter = router({
62116
- get: publicProcedure.input(z10.object({ runId: z10.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
62575
+ get: publicProcedure.input(z10.object({ runId: z10.string() })).query(({ ctx, input }) => getRunWithTickets(ctx, input.runId)),
62576
+ listByFeature: publicProcedure.input(z10.object({ featureId: z10.string() })).query(({ ctx, input }) => listRunSummaries(ctx, input.featureId)),
62117
62577
  agentTranscript: publicProcedure.input(z10.object({ ticketId: z10.string(), after: z10.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
62118
62578
  cancel: publicProcedure.input(z10.object({ runId: z10.string() })).mutation(({ ctx, input }) => {
62119
62579
  getRunRow(ctx, input.runId);
@@ -62137,7 +62597,7 @@ var init_settings2 = __esm(() => {
62137
62597
  });
62138
62598
 
62139
62599
  // src/trpc/routers/setup.ts
62140
- import { isNotNull as isNotNull3 } from "drizzle-orm";
62600
+ import { isNotNull as isNotNull4 } from "drizzle-orm";
62141
62601
  import * as z12 from "zod";
62142
62602
  var setupRouter;
62143
62603
  var init_setup2 = __esm(() => {
@@ -62151,7 +62611,7 @@ var init_setup2 = __esm(() => {
62151
62611
  init_context();
62152
62612
  setupRouter = router({
62153
62613
  doctor: publicProcedure.query(({ ctx }) => {
62154
- const projectModels = ctx.db.select({ model: projects.model }).from(projects).where(isNotNull3(projects.model)).all().map((p) => p.model);
62614
+ const projectModels = ctx.db.select({ model: projects.model }).from(projects).where(isNotNull4(projects.model)).all().map((p) => p.model);
62155
62615
  return runDoctor({
62156
62616
  exec: createSystemExec(),
62157
62617
  runtimes: configuredRuntimes(ctx.config, projectModels),
@@ -62241,18 +62701,27 @@ var testNotesRouter;
62241
62701
  var init_test_notes2 = __esm(() => {
62242
62702
  init_test_notes();
62243
62703
  init_context();
62704
+ init_errors();
62244
62705
  testNotesRouter = router({
62245
62706
  list: publicProcedure.input(z14.object({ featureId: z14.string() })).query(({ ctx, input }) => listByFeature4(ctx, input.featureId)),
62246
62707
  add: publicProcedure.input(z14.object({
62247
62708
  featureId: z14.string(),
62248
62709
  text: z14.string().min(1),
62249
- videoTimestamp: z14.number().nonnegative().optional()
62250
- })).mutation(({ ctx, input }) => addNote(ctx, input.featureId, input.text, "human", input.videoTimestamp)),
62710
+ videoTimestamp: z14.number().nonnegative().optional(),
62711
+ reviewTicketId: z14.string().optional()
62712
+ })).mutation(({ ctx, input }) => {
62713
+ if (input.videoTimestamp !== undefined && !input.reviewTicketId)
62714
+ throw new InvalidInputError("a video timestamp must identify its review ticket");
62715
+ return addNote(ctx, input.featureId, input.text, "human", input.videoTimestamp, input.reviewTicketId);
62716
+ }),
62251
62717
  edit: publicProcedure.input(z14.object({ noteId: z14.string(), text: z14.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
62252
62718
  remove: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
62253
62719
  toggle: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
62254
62720
  promote: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId)),
62255
- promoteMany: publicProcedure.input(z14.object({ noteIds: z14.array(z14.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds))
62721
+ promoteMany: publicProcedure.input(z14.object({ noteIds: z14.array(z14.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds)),
62722
+ reopen: publicProcedure.input(z14.object({ noteId: z14.string() })).mutation(({ ctx, input }) => reopenNote(ctx, input.noteId)),
62723
+ triage: publicProcedure.input(z14.object({ featureId: z14.string(), quickFixIds: z14.array(z14.string()), quickFixFindingIds: z14.array(z14.string()), dismissIds: z14.array(z14.string()), carry: z14.boolean() })).mutation(({ ctx, input }) => triageNotes(ctx, input.featureId, input)),
62724
+ triagePreview: publicProcedure.input(z14.object({ featureId: z14.string() })).query(({ ctx, input }) => triagePreview(ctx, input.featureId))
62256
62725
  });
62257
62726
  });
62258
62727
 
@@ -62278,6 +62747,7 @@ var init_ticket = __esm(() => {
62278
62747
  return { stopped: false, swept: false };
62279
62748
  }),
62280
62749
  cancel: publicProcedure.input(z15.object({ ticketId: z15.string(), reason: z15.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
62750
+ durationStats: publicProcedure.input(z15.object({ projectId: z15.string() })).query(({ ctx, input }) => ticketDurationStats(ctx, input.projectId)),
62281
62751
  edit: publicProcedure.input(z15.object({
62282
62752
  ticketId: z15.string(),
62283
62753
  title: z15.string().min(1).optional(),
@@ -62323,14 +62793,14 @@ var init_router = __esm(() => {
62323
62793
  });
62324
62794
 
62325
62795
  // src/workflows/reconcile-runs.ts
62326
- import { eq as eq16 } from "drizzle-orm";
62796
+ import { eq as eq17 } from "drizzle-orm";
62327
62797
  async function reconcileStaleRuns(ctx) {
62328
- const stale = ctx.db.select().from(runs).where(eq16(runs.status, "running")).all().map(rowToRun);
62798
+ const stale = ctx.db.select().from(runs).where(eq17(runs.status, "running")).all().map(rowToRun);
62329
62799
  const reconciled = [];
62330
62800
  for (const run7 of stale) {
62331
62801
  if (isRunActive(run7.id))
62332
62802
  continue;
62333
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq16(runs.id, run7.id)).run();
62803
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq17(runs.id, run7.id)).run();
62334
62804
  const released = releaseForSession(ctx, run7.id);
62335
62805
  const swept = workflowClaimsFeatureBranch(run7.workflow) ? sweepOrphanedBurning(ctx, run7.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
62336
62806
  if (workflowClaimsFeatureBranch(run7.workflow)) {