runcastle 1.2.12 → 1.3.0

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