runcastle 1.3.4 → 1.3.6

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
@@ -52,8 +52,8 @@ var init_docs = __esm(() => {
52
52
 
53
53
  // ../core/src/config.ts
54
54
  import * as z from "zod";
55
- function resolveSandboxImage(config) {
56
- return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
55
+ function resolveSandboxImage(config, project) {
56
+ return project?.sandboxImage?.trim() || config.sandboxImage?.trim() || DEFAULT_SANDBOX_IMAGE;
57
57
  }
58
58
  function resolveBurnCacheMode(config) {
59
59
  return BURN_CACHE_SANDBOXES.includes(config.sandbox) ? config.burnCache : "off";
@@ -181,6 +181,14 @@ import * as z2 from "zod";
181
181
  function isProjectSessionKind(kind) {
182
182
  return PROJECT_SESSION_KINDS.includes(kind);
183
183
  }
184
+ function driveDenialOf(result) {
185
+ return {
186
+ ...result.deniedReason ? { deniedReason: result.deniedReason } : {},
187
+ ...result.deniedCode ? { deniedCode: result.deniedCode } : {},
188
+ ...result.retriable !== undefined ? { retriable: result.retriable } : {},
189
+ ...result.dirtyFiles ? { dirtyFiles: result.dirtyFiles } : {}
190
+ };
191
+ }
184
192
  function unresolvedMergeConflict(events) {
185
193
  let conflict = null;
186
194
  for (const event of events) {
@@ -197,7 +205,7 @@ function unresolvedMergeConflict(events) {
197
205
  }
198
206
  return conflict;
199
207
  }
200
- 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;
208
+ var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, RunStatus, FeatureStatus, SessionStatus, TicketKind, FindingKind, FindingSeverity, FindingStatus, FindingOpenReason, FindingResolvedBy, ReviewFindingInput, ReviewFinding, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNoteAuthor, TestNote, PREPARED_KEYS, PreparedKey, PROVENANCE_KEYS, ProvenanceKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow, DriveState;
201
209
  var init_schemas = __esm(() => {
202
210
  init_config();
203
211
  Phase = z2.enum([
@@ -231,8 +239,9 @@ var init_schemas = __esm(() => {
231
239
  TicketKind = z2.enum(["implementation", "review"]);
232
240
  FindingKind = z2.enum(["defect", "observation"]);
233
241
  FindingSeverity = z2.enum(["high", "medium", "low"]);
234
- FindingStatus = z2.enum(["open", "fixing", "fixed", "failed", "dismissed"]);
242
+ FindingStatus = z2.enum(["open", "fixing", "fixed", "failed", "dismissed", "carried"]);
235
243
  FindingOpenReason = z2.enum(["over-cap", "fix-failed", "verification"]);
244
+ FindingResolvedBy = z2.enum(["fix-ticket", "session"]);
236
245
  ReviewFindingInput = z2.object({
237
246
  kind: FindingKind,
238
247
  severity: FindingSeverity,
@@ -256,6 +265,9 @@ var init_schemas = __esm(() => {
256
265
  openReason: FindingOpenReason.nullable(),
257
266
  failureReason: z2.string().nullable(),
258
267
  fixTicketId: z2.string().nullable(),
268
+ carriedLap: z2.number().nullable(),
269
+ resolvedBy: FindingResolvedBy.nullable(),
270
+ resolutionNote: z2.string().nullable(),
259
271
  createdAt: z2.number()
260
272
  });
261
273
  TicketInput = z2.object({
@@ -332,12 +344,14 @@ var init_schemas = __esm(() => {
332
344
  "driveInstructions"
333
345
  ];
334
346
  PreparedKey = z2.enum(PREPARED_KEYS);
347
+ PROVENANCE_KEYS = [...PREPARED_KEYS, "sandboxImage"];
348
+ ProvenanceKey = z2.enum(PROVENANCE_KEYS);
335
349
  DRIVE_LOOP_KEYS = [
336
350
  "devCommand",
337
351
  "driveSetupCommand",
338
352
  "driveStopCommand"
339
353
  ];
340
- FindingSource = z2.enum(["prep", "human", "session"]);
354
+ FindingSource = z2.enum(["prep", "human", "session", "build"]);
341
355
  ProjectName = z2.string().min(1, "a project needs a name").max(PROJECT_NAME_MAX, `a project name can be at most ${PROJECT_NAME_MAX} characters`);
342
356
  Project = z2.object({
343
357
  id: z2.string(),
@@ -346,6 +360,7 @@ var init_schemas = __esm(() => {
346
360
  sessionBranch: z2.string().optional(),
347
361
  devCommand: z2.string().optional(),
348
362
  model: z2.string().optional(),
363
+ sandboxImage: z2.string().optional(),
349
364
  setupCommand: z2.string().optional(),
350
365
  verifyCommands: z2.string().optional(),
351
366
  knownFailures: z2.string().optional(),
@@ -356,7 +371,7 @@ var init_schemas = __esm(() => {
356
371
  closedAt: z2.number().optional()
357
372
  });
358
373
  ProjectFinding = z2.object({
359
- key: PreparedKey,
374
+ key: ProvenanceKey,
360
375
  source: FindingSource,
361
376
  evidence: z2.string().optional(),
362
377
  establishedAt: z2.number(),
@@ -644,6 +659,7 @@ var init_db_schema = __esm(() => {
644
659
  devCommand: text("dev_command"),
645
660
  model: text("model"),
646
661
  sandbox: text("sandbox"),
662
+ sandboxImage: text("sandbox_image"),
647
663
  closedAt: integer("closed_at"),
648
664
  setupCommand: text("setup_command"),
649
665
  verifyCommands: text("verify_commands"),
@@ -738,6 +754,9 @@ var init_db_schema = __esm(() => {
738
754
  openReason: text("open_reason").$type(),
739
755
  failureReason: text("failure_reason"),
740
756
  fixTicketId: text("fix_ticket_id"),
757
+ carriedLap: integer("carried_lap"),
758
+ resolvedBy: text("resolved_by").$type(),
759
+ resolutionNote: text("resolution_note"),
741
760
  createdAt: integer("created_at").notNull()
742
761
  });
743
762
  testNotes = sqliteTable("test_notes", {
@@ -814,51 +833,15 @@ var init_src = __esm(() => {
814
833
  });
815
834
 
816
835
  // ../core/src/paths.ts
817
- var exports_paths = {};
818
- __export(exports_paths, {
819
- worktreeDir: () => worktreeDir,
820
- sessionDir: () => sessionDir,
821
- sandboxBuildDir: () => sandboxBuildDir,
822
- sameDataDir: () => sameDataDir,
823
- reviewWalkthroughPath: () => reviewWalkthroughPath,
824
- reviewDir: () => reviewDir,
825
- projectWorktreesDir: () => projectWorktreesDir,
826
- prodDataDir: () => prodDataDir,
827
- logsDir: () => logsDir,
828
- installIdPath: () => installIdPath,
829
- featureDocsRel: () => featureDocsRel,
830
- envPath: () => envPath,
831
- devDataDir: () => devDataDir,
832
- dbPath: () => dbPath,
833
- dataDir: () => dataDir,
834
- configPath: () => configPath,
835
- burnCacheDir: () => burnCacheDir,
836
- attachmentRelPath: () => attachmentRelPath,
837
- annotationsDir: () => annotationsDir,
838
- annotationPath: () => annotationPath,
839
- PROJECT_WORKTREE_SLUG: () => PROJECT_WORKTREE_SLUG,
840
- ATTACHMENTS_DIR: () => ATTACHMENTS_DIR
841
- });
842
836
  import { homedir } from "os";
843
837
  import { join, resolve } from "path";
844
838
  function prodDataDir() {
845
839
  return join(homedir(), ".runcastle");
846
840
  }
847
- function devDataDir() {
848
- const override = process.env.RUNCASTLE_DEV_DATA_DIR;
849
- return override ? resolve(override) : join(homedir(), ".runcastle-dev");
850
- }
851
841
  function dataDir() {
852
842
  const override = process.env.RUNCASTLE_DATA_DIR;
853
843
  return override ? resolve(override) : prodDataDir();
854
844
  }
855
- function sameDataDir(a, b) {
856
- const canon = (p) => {
857
- const abs = resolve(p).replace(/[\\/]+$/, "");
858
- return process.platform === "win32" ? abs.toLowerCase().replace(/\//g, "\\") : abs;
859
- };
860
- return canon(a) === canon(b);
861
- }
862
845
  function dbPath() {
863
846
  return join(dataDir(), "runcastle.db");
864
847
  }
@@ -911,11 +894,6 @@ var ATTACHMENTS_DIR = ".runcastle-attachments", PROJECT_WORKTREE_SLUG = "__proje
911
894
  var init_paths = () => {};
912
895
 
913
896
  // ../core/src/config-load.ts
914
- var exports_config_load = {};
915
- __export(exports_config_load, {
916
- loadConfig: () => loadConfig,
917
- hostLogicalCpus: () => hostLogicalCpus
918
- });
919
897
  import { existsSync, readFileSync } from "fs";
920
898
  import { availableParallelism } from "os";
921
899
  function loadConfig(env = process.env, logicalCpus = hostLogicalCpus()) {
@@ -2129,6 +2107,7 @@ function rowToProject(row) {
2129
2107
  sessionBranch: row.sessionBranch ?? undefined,
2130
2108
  devCommand: row.devCommand ?? undefined,
2131
2109
  model: row.model ?? undefined,
2110
+ sandboxImage: row.sandboxImage ?? undefined,
2132
2111
  setupCommand: row.setupCommand ?? undefined,
2133
2112
  verifyCommands: row.verifyCommands ?? undefined,
2134
2113
  knownFailures: row.knownFailures ?? undefined,
@@ -2797,8 +2776,8 @@ var init_drive_env = () => {};
2797
2776
 
2798
2777
  // src/services/findings.ts
2799
2778
  import { and as and4, eq as eq4 } from "drizzle-orm";
2800
- function isPreparedKey(key) {
2801
- return PREPARED_SET.has(key);
2779
+ function isProvenanceKey(key) {
2780
+ return PROVENANCE_SET.has(key);
2802
2781
  }
2803
2782
  function findingRow(ctx, projectId, key) {
2804
2783
  return ctx.db.select({
@@ -2840,7 +2819,7 @@ function recordFinding(ctx, projectId, input) {
2840
2819
  });
2841
2820
  }
2842
2821
  function recordHuman(ctx, projectId, key, value) {
2843
- if (!isPreparedKey(key))
2822
+ if (!isProvenanceKey(key))
2844
2823
  return;
2845
2824
  const existing = findingRow(ctx, projectId, key);
2846
2825
  if (value === null) {
@@ -2884,8 +2863,8 @@ async function listFindings(ctx, project) {
2884
2863
  distances.set(sha, await commitsSince(project.repoPath, sha, mainLine));
2885
2864
  }
2886
2865
  }
2887
- const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
2888
- return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
2866
+ const order = new Map(PROVENANCE_KEYS.map((k, i) => [k, i]));
2867
+ return rows.filter((row) => isProvenanceKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
2889
2868
  const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
2890
2869
  return {
2891
2870
  key: row.key,
@@ -2905,7 +2884,7 @@ function unsetPreparedKeys(project) {
2905
2884
  return value === undefined || value.trim() === "";
2906
2885
  });
2907
2886
  }
2908
- var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET, UNVERIFIED;
2887
+ var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET, PROVENANCE_SET, UNVERIFIED;
2909
2888
  var init_findings = __esm(() => {
2910
2889
  init_src();
2911
2890
  init_schema();
@@ -2918,7 +2897,8 @@ var init_findings = __esm(() => {
2918
2897
  driveSetupCommand: projects.driveSetupCommand,
2919
2898
  driveStopCommand: projects.driveStopCommand,
2920
2899
  dbResetCommand: projects.dbResetCommand,
2921
- driveInstructions: projects.driveInstructions
2900
+ driveInstructions: projects.driveInstructions,
2901
+ sandboxImage: projects.sandboxImage
2922
2902
  };
2923
2903
  COLUMN_NAME = {
2924
2904
  setupCommand: "setupCommand",
@@ -2928,9 +2908,11 @@ var init_findings = __esm(() => {
2928
2908
  driveSetupCommand: "driveSetupCommand",
2929
2909
  driveStopCommand: "driveStopCommand",
2930
2910
  dbResetCommand: "dbResetCommand",
2931
- driveInstructions: "driveInstructions"
2911
+ driveInstructions: "driveInstructions",
2912
+ sandboxImage: "sandboxImage"
2932
2913
  };
2933
2914
  PREPARED_SET = new Set(PREPARED_KEYS);
2915
+ PROVENANCE_SET = new Set(PROVENANCE_KEYS);
2934
2916
  UNVERIFIED = { verifiedAt: null, verifiedSha: null };
2935
2917
  });
2936
2918
 
@@ -2963,6 +2945,18 @@ function deriveDriveState(info) {
2963
2945
  function migrationPaths(paths) {
2964
2946
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2965
2947
  }
2948
+ function deniedStart(deniedReason, deniedCode, dirtyFiles) {
2949
+ return {
2950
+ ok: false,
2951
+ deniedReason,
2952
+ deniedCode,
2953
+ retriable: deniedCode === "slot_held",
2954
+ ...dirtyFiles ? { dirtyFiles } : {}
2955
+ };
2956
+ }
2957
+ function truncatedFileList(files) {
2958
+ return `${files.slice(0, 5).join(", ")}${files.length > 5 ? ", \u2026" : ""}`;
2959
+ }
2966
2960
  function featureBranch(slug) {
2967
2961
  return `feature/${slug}`;
2968
2962
  }
@@ -3692,6 +3686,10 @@ async function commitPipelineDocs(repoPath) {
3692
3686
  await commitDocs(repoPath, "runcastle: docs the pipeline left uncommitted");
3693
3687
  } catch {}
3694
3688
  }
3689
+ async function driveBlockingPaths(repoPath) {
3690
+ await commitPipelineDocs(repoPath);
3691
+ return porcelainPaths((await git(repoPath).raw(["status", "--porcelain"])).trim());
3692
+ }
3695
3693
  function activeTestDriveFeatureId() {
3696
3694
  return testDriveState?.kind === "feature" ? testDriveState.featureId : undefined;
3697
3695
  }
@@ -3779,7 +3777,7 @@ async function testDrive(ctx, project, feature, action, purpose = "human") {
3779
3777
  if (carriedChanges.length > 0) {
3780
3778
  emit(ctx, feature.id, {
3781
3779
  type: "testdrive.carried_changes",
3782
- message: `${carriedChanges.length} uncommitted file(s) came back with you onto ${previousBranch2}: ${carriedChanges.slice(0, 5).join(", ")}${carriedChanges.length > 5 ? ", \u2026" : ""}`,
3780
+ message: `${carriedChanges.length} uncommitted file(s) came back with you onto ${previousBranch2}: ${truncatedFileList(carriedChanges)}`,
3783
3781
  data: { branch: previousBranch2, files: carriedChanges }
3784
3782
  });
3785
3783
  }
@@ -3792,18 +3790,22 @@ async function testDrive(ctx, project, feature, action, purpose = "human") {
3792
3790
  ...teardown?.failure ? { hookFailure: teardown.failure } : {}
3793
3791
  };
3794
3792
  }
3795
- await commitPipelineDocs(project.repoPath);
3796
- const porcelain = (await g.raw(["status", "--porcelain"])).trim();
3797
- if (porcelain !== "")
3798
- return { ok: false, deniedReason: DENY_DIRTY };
3793
+ const dirtyFiles = await driveBlockingPaths(project.repoPath);
3794
+ if (dirtyFiles.length > 0) {
3795
+ if (purpose === "review") {
3796
+ emit(ctx, feature.id, {
3797
+ type: "reviewdrive.denied",
3798
+ message: `review drive denied \u2014 ${dirtyFiles.length} uncommitted file(s) in the working tree: ${truncatedFileList(dirtyFiles)}`,
3799
+ data: { code: "dirty", dirtyFiles }
3800
+ });
3801
+ }
3802
+ return deniedStart(DENY_DIRTY, "dirty", dirtyFiles);
3803
+ }
3799
3804
  if (testDriveState) {
3800
- return {
3801
- ok: false,
3802
- deniedReason: testDriveState.kind === "dryRun" ? DENY_DRY_RUN_ACTIVE : DENY_ACTIVE
3803
- };
3805
+ return deniedStart(testDriveState.kind === "dryRun" ? DENY_DRY_RUN_ACTIVE : DENY_ACTIVE, "slot_held");
3804
3806
  }
3805
3807
  if (purpose === "human" && hasActiveRun(ctx, feature.id)) {
3806
- return { ok: false, deniedReason: DENY_ACTIVE_RUN };
3808
+ return deniedStart(DENY_ACTIVE_RUN, "active_run");
3807
3809
  }
3808
3810
  const talkWorktree = worktreeDir(project.id, feature.slug);
3809
3811
  let detachedWorktree;
@@ -3863,7 +3865,7 @@ async function reviewDrive(ctx, project, feature, action) {
3863
3865
  return {
3864
3866
  ok: stop.ok,
3865
3867
  action: "stop",
3866
- ...stop.deniedReason ? { deniedReason: stop.deniedReason } : {},
3868
+ ...driveDenialOf(stop),
3867
3869
  drive: activeDriveInfo(),
3868
3870
  ...stop.hookFailure ? { hookFailure: stop.hookFailure } : {}
3869
3871
  };
@@ -3892,7 +3894,7 @@ async function startReviewDrive(ctx, project, feature) {
3892
3894
  return {
3893
3895
  ok: start.ok,
3894
3896
  action: "start",
3895
- ...start.deniedReason ? { deniedReason: start.deniedReason } : {},
3897
+ ...driveDenialOf(start),
3896
3898
  drive: activeDriveInfo(),
3897
3899
  ...start.hookFailure ? { hookFailure: start.hookFailure } : {}
3898
3900
  };
@@ -4108,15 +4110,17 @@ ${result.output}` : ""}`,
4108
4110
  }
4109
4111
  async function dirtyPaths(g) {
4110
4112
  try {
4111
- const out = (await g.raw(["status", "--porcelain"])).trim();
4112
- if (!out)
4113
- return [];
4114
- return out.split(`
4115
- `).map((line) => line.slice(3).trim()).map((p) => p.includes(" -> ") ? p.split(" -> ").at(-1) ?? p : p).map((p) => p.replace(/^"|"$/g, "")).filter(Boolean);
4113
+ return porcelainPaths((await g.raw(["status", "--porcelain"])).trim());
4116
4114
  } catch {
4117
4115
  return [];
4118
4116
  }
4119
4117
  }
4118
+ function porcelainPaths(out) {
4119
+ if (!out)
4120
+ return [];
4121
+ return out.split(`
4122
+ `).map((line) => line.slice(3).trim()).map((p) => p.includes(" -> ") ? p.split(" -> ").at(-1) ?? p : p).map((p) => p.replace(/^"|"$/g, "")).filter(Boolean);
4123
+ }
4120
4124
  async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch) {
4121
4125
  let files;
4122
4126
  try {
@@ -4524,7 +4528,7 @@ function reportFinding(ctx, args) {
4524
4528
  }
4525
4529
  function updateStatus(ctx, findingId, patch) {
4526
4530
  const current = getFinding(ctx, findingId);
4527
- ctx.db.update(reviewFindings).set(patch).where(eq6(reviewFindings.id, findingId)).run();
4531
+ ctx.db.update(reviewFindings).set({ carriedLap: null, resolvedBy: null, resolutionNote: null, ...patch }).where(eq6(reviewFindings.id, findingId)).run();
4528
4532
  emit(ctx, current.featureId, {
4529
4533
  type: "finding.updated",
4530
4534
  message: `finding ${findingId} marked ${patch.status}`,
@@ -4543,7 +4547,8 @@ function markFixed(ctx, findingId) {
4543
4547
  return updateStatus(ctx, findingId, {
4544
4548
  status: "fixed",
4545
4549
  openReason: null,
4546
- failureReason: null
4550
+ failureReason: null,
4551
+ resolvedBy: "fix-ticket"
4547
4552
  });
4548
4553
  }
4549
4554
  function markFailed(ctx, findingId, reason) {
@@ -4570,6 +4575,8 @@ function dismiss(ctx, findingId) {
4570
4575
  function defectState(finding, fixTicket) {
4571
4576
  if (finding.status === "dismissed")
4572
4577
  return "dismissed";
4578
+ if (finding.status === "carried")
4579
+ return "carried";
4573
4580
  if (finding.status === "fixed" || fixTicket?.status === "done")
4574
4581
  return "fixed";
4575
4582
  if (fixTicket && fixTicket.status !== "failed" && fixTicket.status !== "cancelled") {
@@ -4578,11 +4585,15 @@ function defectState(finding, fixTicket) {
4578
4585
  return finding.status === "open" || finding.status === "failed" || fixTicket?.status === "failed" || fixTicket?.status === "cancelled" ? "open" : "fixing";
4579
4586
  }
4580
4587
  function viewByFeature(ctx, featureId) {
4588
+ const feature = getFeatureRow(ctx, featureId);
4581
4589
  const findings = listByFeature3(ctx, featureId);
4582
4590
  const tickets2 = listByFeature2(ctx, featureId);
4583
4591
  const summary = { found: 0, fixed: 0, open: 0, observations: 0 };
4584
4592
  const openDefects = [];
4593
+ const carriedFindings = findings.filter(isCarried);
4585
4594
  for (const finding of findings) {
4595
+ if (finding.lap !== feature.lap)
4596
+ continue;
4586
4597
  if (finding.kind === "observation") {
4587
4598
  summary.observations += 1;
4588
4599
  continue;
@@ -4596,11 +4607,75 @@ function viewByFeature(ctx, featureId) {
4596
4607
  openDefects.push(finding);
4597
4608
  }
4598
4609
  }
4599
- return { findings, openDefects, summary };
4610
+ return { findings, openDefects, carriedFindings, summary };
4611
+ }
4612
+ function openDefectsAcrossLaps(ctx, featureId) {
4613
+ const tickets2 = listByFeature2(ctx, featureId);
4614
+ return listByFeature3(ctx, featureId).filter((finding) => finding.kind === "defect" && defectState(finding, fixTicketOf(finding, tickets2)) === "open");
4615
+ }
4616
+ function isCarried(finding) {
4617
+ return finding.status === "carried";
4618
+ }
4619
+ function carriedDefectsAcrossLaps(ctx, featureId) {
4620
+ return listByFeature3(ctx, featureId).filter(isCarried);
4621
+ }
4622
+ function undispositionedDefects(ctx, featureId) {
4623
+ const { lap } = getFeatureRow(ctx, featureId);
4624
+ return openDefectsAcrossLaps(ctx, featureId).filter((finding) => finding.lap < lap);
4600
4625
  }
4601
4626
  function fixTicketOf(finding, tickets2) {
4602
4627
  return finding.fixTicketId ? tickets2.find((t) => t.id === finding.fixTicketId) : undefined;
4603
4628
  }
4629
+ function requireDispositionable(ctx, featureId, findingId) {
4630
+ const finding = getFinding(ctx, findingId);
4631
+ const eligible = finding.featureId === featureId && (finding.status === "carried" || openDefectsAcrossLaps(ctx, featureId).some((defect) => defect.id === findingId));
4632
+ if (!eligible) {
4633
+ throw new InvalidInputError(`finding ${findingId} is not an open or carried defect of this feature`);
4634
+ }
4635
+ return finding;
4636
+ }
4637
+ function carryFinding(ctx, featureId, findingId, note) {
4638
+ requireDispositionable(ctx, featureId, findingId);
4639
+ return updateStatus(ctx, findingId, {
4640
+ status: "carried",
4641
+ openReason: null,
4642
+ failureReason: null,
4643
+ carriedLap: getFeatureRow(ctx, featureId).lap,
4644
+ resolutionNote: note?.trim() || null
4645
+ });
4646
+ }
4647
+ function closeAsAddressed(ctx, featureId, findingId, note) {
4648
+ const attestation = note.trim();
4649
+ if (!attestation) {
4650
+ throw new InvalidInputError("closing a defect as addressed requires a note saying what addressed it");
4651
+ }
4652
+ requireDispositionable(ctx, featureId, findingId);
4653
+ return updateStatus(ctx, findingId, {
4654
+ status: "fixed",
4655
+ openReason: null,
4656
+ failureReason: null,
4657
+ resolvedBy: "session",
4658
+ resolutionNote: attestation
4659
+ });
4660
+ }
4661
+ function requireLinkableFindings(ctx, featureId, findingIds) {
4662
+ if (new Set(findingIds).size !== findingIds.length) {
4663
+ throw new InvalidInputError("two tickets in this batch link to the same finding");
4664
+ }
4665
+ for (const findingId of findingIds)
4666
+ requireDispositionable(ctx, featureId, findingId);
4667
+ }
4668
+ function linkFixTicket(ctx, featureId, findingId, fixTicketId) {
4669
+ requireDispositionable(ctx, featureId, findingId);
4670
+ ctx.db.update(reviewFindings).set({ fixTicketId }).where(eq6(reviewFindings.id, findingId)).run();
4671
+ return markFixing(ctx, findingId);
4672
+ }
4673
+ function reopenFinding(ctx, findingId) {
4674
+ const finding = getFinding(ctx, findingId);
4675
+ if (finding.status !== "carried")
4676
+ throw new InvalidInputError("only a carried finding can be reopened");
4677
+ return updateStatus(ctx, findingId, { status: "open", openReason: null, failureReason: null });
4678
+ }
4604
4679
  function promoteOpenDefects(ctx, featureId, findingIds) {
4605
4680
  const open = viewByFeature(ctx, featureId).openDefects;
4606
4681
  const defects = findingIds ? findingIds.map((id) => {
@@ -4646,12 +4721,17 @@ function carriedWork(ctx, featureId) {
4646
4721
  const carried = ctx.db.select({ id: testNotes.id }).from(testNotes).where(and7(eq7(testNotes.featureId, featureId), eq7(testNotes.status, "carried"))).all();
4647
4722
  return {
4648
4723
  carriedNotes: carried.length,
4649
- openDefects: viewByFeature(ctx, featureId).openDefects.map((defect) => ({
4650
- title: defect.title,
4651
- location: defect.location,
4652
- detail: defect.detail,
4653
- reproStep: defect.reproStep
4654
- }))
4724
+ openDefects: openDefectsAcrossLaps(ctx, featureId).map(toCarriedDefect),
4725
+ carriedDefects: carriedDefectsAcrossLaps(ctx, featureId).map(toCarriedDefect)
4726
+ };
4727
+ }
4728
+ function toCarriedDefect(defect) {
4729
+ return {
4730
+ id: defect.id,
4731
+ title: defect.title,
4732
+ location: defect.location,
4733
+ detail: defect.detail,
4734
+ reproStep: defect.reproStep
4655
4735
  };
4656
4736
  }
4657
4737
  function hasCarriedWork(carried) {
@@ -4956,8 +5036,12 @@ function renderRevisitPrompt(feature, lap, purpose, runtime = DEFAULT_RUNTIME, c
4956
5036
  `**${carriedSummary} \u2014 address them.** They are this lap's opening agenda:`,
4957
5037
  `- \`${docs2}/test-notes.md\`, section \`## Carried, still open\` \u2014 every note carried`,
4958
5038
  " and not yet done, whatever lap captured it. It exists; read it.",
4959
- "- `get_feature_context` \u2192 `openDefects` \u2014 each open defect with its title,",
4960
- " location, detail and repro step.",
5039
+ "- `get_feature_context` \u2192 `openDefects` \u2014 each open defect with its `id`, title,",
5040
+ " location, detail and repro step; and `carriedDefects`, the ones an earlier lap",
5041
+ " parked (agenda, never an obligation to re-carry). Every open defect from an",
5042
+ ' EARLIER lap needs one of three answers before `complete_phase("tickets")` will',
5043
+ " pass: **link** it (emit this lap's ticket for it with `originFindingId` set),",
5044
+ " **carry** it, or **close it as addressed** \u2014 the last two are `resolve_finding`.",
4961
5045
  `- \`${docs2}/spec.md\`, section \`## Later laps\` \u2014 scope parked by earlier laps,`,
4962
5046
  " OPTIONAL; a missing one is normal, not an error."
4963
5047
  ] : [
@@ -5537,8 +5621,152 @@ function codexLoggedIn(env = process.env, fileExists = existsSync8) {
5537
5621
  }
5538
5622
  var init_codex_auth = () => {};
5539
5623
 
5624
+ // src/services/sandbox-image.ts
5625
+ import { createHash as createHash2 } from "crypto";
5626
+ import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
5627
+ import { join as join10 } from "path";
5628
+ function projectSandboxDir(repoPath) {
5629
+ return join10(repoPath, ".runcastle", "sandbox");
5630
+ }
5631
+ function projectDockerfilePath(repoPath) {
5632
+ return join10(projectSandboxDir(repoPath), "Dockerfile");
5633
+ }
5634
+ function projectImageTag(projectId) {
5635
+ return `${DEFAULT_SANDBOX_IMAGE}-${projectId}`;
5636
+ }
5637
+ function hashDockerfileContents(contents) {
5638
+ return createHash2("sha256").update(contents).digest("hex");
5639
+ }
5640
+ function hashDockerfile(path) {
5641
+ try {
5642
+ return hashDockerfileContents(readFileSync4(path));
5643
+ } catch {
5644
+ return null;
5645
+ }
5646
+ }
5647
+ async function inspectBuiltImage(exec, runtime, tag) {
5648
+ const out = await exec(runtime, [
5649
+ "image",
5650
+ "inspect",
5651
+ "--format",
5652
+ `{{index .Config.Labels "${DOCKERFILE_HASH_LABEL}"}}`,
5653
+ tag
5654
+ ]);
5655
+ if (!(out.ok && out.code === 0))
5656
+ return { present: false, hash: null };
5657
+ const value = out.stdout.trim();
5658
+ return { present: true, hash: value === "" || value === "<no value>" ? null : value };
5659
+ }
5660
+ function unmanagedImageReason(imageName, projectDockerfilePresent = false) {
5661
+ const route = projectDockerfilePresent ? "clear the sandbox image setting to let runcastle build the .runcastle/sandbox/Dockerfile this repo already ships" : `clear the sandbox image setting to go back to ${DEFAULT_SANDBOX_IMAGE}, or commit a .runcastle/sandbox/Dockerfile for runcastle to build`;
5662
+ return `${imageName} is a custom image managed outside runcastle \u2014 ${route}.`;
5663
+ }
5664
+ function unmanagedImage(project) {
5665
+ const { id, overwritable } = project;
5666
+ const stored = project.stored?.trim() ?? "";
5667
+ if (stored === "" || overwritable)
5668
+ return null;
5669
+ if (stored === DEFAULT_SANDBOX_IMAGE || stored === projectImageTag(id))
5670
+ return null;
5671
+ return stored;
5672
+ }
5673
+ function stockBuildArgs(runtime, ids2 = {
5674
+ uid: process.getuid?.(),
5675
+ gid: process.getgid?.()
5676
+ }) {
5677
+ if (runtime === "podman")
5678
+ return {};
5679
+ return {
5680
+ ...ids2.uid === undefined ? {} : { AGENT_UID: String(ids2.uid) },
5681
+ ...ids2.gid === undefined ? {} : { AGENT_GID: String(ids2.gid) }
5682
+ };
5683
+ }
5684
+ function shippedDockerfile(project) {
5685
+ if (!project)
5686
+ return null;
5687
+ const path = projectDockerfilePath(project.repoPath);
5688
+ return existsSync9(path) ? path : null;
5689
+ }
5690
+ function planImageBuild(input) {
5691
+ const { config: config2, project, stockContext, stockFresh, buildArgs } = input;
5692
+ const stockHash = hashDockerfile(join10(stockContext, "Dockerfile")) ?? "";
5693
+ const stockStep = {
5694
+ tag: DEFAULT_SANDBOX_IMAGE,
5695
+ context: stockContext,
5696
+ dockerfileHash: stockHash,
5697
+ buildArgs
5698
+ };
5699
+ const projectDockerfile = shippedDockerfile(project);
5700
+ const handTyped = project ? unmanagedImage({
5701
+ id: project.id,
5702
+ stored: project.sandboxImage ?? null,
5703
+ overwritable: project.sandboxImageOverwritable
5704
+ }) : null;
5705
+ if (handTyped !== null) {
5706
+ return {
5707
+ kind: "refused",
5708
+ imageName: handTyped,
5709
+ reason: unmanagedImageReason(handTyped, projectDockerfile !== null)
5710
+ };
5711
+ }
5712
+ if (project && projectDockerfile) {
5713
+ const projectTag = projectImageTag(project.id);
5714
+ return {
5715
+ kind: "chain",
5716
+ projectTag,
5717
+ steps: [
5718
+ ...stockFresh ? [] : [stockStep],
5719
+ {
5720
+ tag: projectTag,
5721
+ context: projectSandboxDir(project.repoPath),
5722
+ dockerfileHash: hashDockerfile(projectDockerfile) ?? "",
5723
+ buildArgs: {}
5724
+ }
5725
+ ]
5726
+ };
5727
+ }
5728
+ const imageName = resolveSandboxImage(config2, project);
5729
+ const managed = imageName === DEFAULT_SANDBOX_IMAGE || project !== null && imageName === projectImageTag(project.id);
5730
+ if (!managed)
5731
+ return { kind: "refused", imageName, reason: unmanagedImageReason(imageName) };
5732
+ return { kind: "stock", steps: [stockStep] };
5733
+ }
5734
+ function buildStepArgs(step) {
5735
+ return [
5736
+ "build",
5737
+ "-t",
5738
+ step.tag,
5739
+ "--label",
5740
+ `${DOCKERFILE_HASH_LABEL}=${step.dockerfileHash}`,
5741
+ ...Object.entries(step.buildArgs).flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]),
5742
+ step.context
5743
+ ];
5744
+ }
5745
+ function shellQuote(arg, platform) {
5746
+ if (/^[\w@%+=:,./\\-]+$/.test(arg))
5747
+ return arg;
5748
+ if (platform === "win32")
5749
+ return `"${arg}"`;
5750
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
5751
+ }
5752
+ function imageBuildTerminal(runtime, plan, platform = process.platform) {
5753
+ const steps = plan.steps;
5754
+ const last = steps[steps.length - 1];
5755
+ if (!last)
5756
+ throw new Error("image build plan has no steps");
5757
+ const cwd = last.context;
5758
+ if (steps.length === 1)
5759
+ return { cmd: runtime, args: buildStepArgs(last), cwd };
5760
+ const line = steps.map((step) => [runtime, ...buildStepArgs(step)].map((a) => shellQuote(a, platform)).join(" ")).join(" && ");
5761
+ return platform === "win32" ? { cmd: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", line], cwd } : { cmd: "/bin/sh", args: ["-c", line], cwd };
5762
+ }
5763
+ var DOCKERFILE_HASH_LABEL = "runcastle.dockerfile-hash";
5764
+ var init_sandbox_image = __esm(() => {
5765
+ init_src();
5766
+ });
5767
+
5540
5768
  // src/doctor/doctor.ts
5541
- import { existsSync as existsSync9, statSync } from "fs";
5769
+ import { existsSync as existsSync10 } from "fs";
5542
5770
  async function versionProbe(exec, id, label, bin, fix) {
5543
5771
  const out = await exec(bin, ["--version"]);
5544
5772
  if (out.ok && out.code === 0) {
@@ -5651,7 +5879,7 @@ function runtimeAfkKeyProbe(spec, env, severity = "error") {
5651
5879
  };
5652
5880
  }
5653
5881
  async function runtimeProbes(spec, deps) {
5654
- const fileExists = deps.fileExists ?? existsSync9;
5882
+ const fileExists = deps.fileExists ?? existsSync10;
5655
5883
  const afkKeyProbe = runtimeAfkKeyProbe(spec, deps.env, deps.severity);
5656
5884
  return [
5657
5885
  await runtimeBinaryProbe(spec, deps.exec, deps.severity),
@@ -5745,49 +5973,98 @@ async function containerRuntimeProbe(exec) {
5745
5973
  fix: "Install Docker Desktop or Podman (see docs/research/PREREQS-NOTES.md \xA74). Not needed for interactive-only use."
5746
5974
  };
5747
5975
  }
5748
- async function sandcastleImageProbe(exec, imageName, burnerDockerfile, fileMtime) {
5749
- const id = "sandcastle-image";
5750
- const label = "Sandcastle container image";
5751
- for (const runtime of ["docker", "podman"]) {
5752
- const present = await exec(runtime, ["--version"]);
5753
- if (!(present.ok && present.code === 0))
5754
- continue;
5755
- const inspect = await exec(runtime, ["image", "inspect", "--format", "{{.Created}}", imageName]);
5756
- if (inspect.ok && inspect.code === 0) {
5757
- const created = new Date(inspect.stdout.trim());
5758
- const dockerfileChanged = fileMtime(burnerDockerfile);
5759
- if (dockerfileChanged > created) {
5760
- const date = (value) => value.toISOString().slice(0, 10);
5761
- return {
5762
- id,
5763
- label,
5764
- tier: 2,
5765
- status: "stale",
5766
- severity: "error",
5767
- detail: `${imageName} built ${date(created)}, burner Dockerfile changed ${date(dockerfileChanged)} \u2014 rebuild`,
5768
- fix: "Open Settings \u2192 Burns (Rebuild image)."
5769
- };
5770
- }
5771
- return { id, label, tier: 2, status: "ok", severity: "error", detail: `${imageName} present` };
5976
+ async function sandcastleImageProbe(input) {
5977
+ const { exec, burnerDockerfile, dockerfileHash, project } = input;
5978
+ const runtime = await presentRuntime(exec);
5979
+ if (!runtime) {
5980
+ return {
5981
+ ...IMAGE_ROW,
5982
+ status: "missing",
5983
+ severity: "error",
5984
+ detail: "no container runtime available to inspect the image",
5985
+ fix: "Install a container runtime (Docker or Podman) first, then build the image from the Enable AFK burns card in the app."
5986
+ };
5987
+ }
5988
+ const projectHash = project ? dockerfileHash(projectDockerfilePath(project.repoPath)) : null;
5989
+ const handTyped = project ? unmanagedImage(project) : null;
5990
+ let stored = project?.stored ?? null;
5991
+ if (project && stored !== null && projectHash === null && project.overwritable) {
5992
+ project.clearStored();
5993
+ stored = null;
5994
+ }
5995
+ const stockHash = dockerfileHash(burnerDockerfile);
5996
+ const stock = await inspectBuiltImage(exec, runtime, DEFAULT_SANDBOX_IMAGE);
5997
+ const stockFresh = stock.present && (stockHash === null || stock.hash === stockHash);
5998
+ if (project && handTyped === null && projectHash !== null) {
5999
+ const tag = projectImageTag(project.id);
6000
+ const image = await inspectBuiltImage(exec, runtime, tag);
6001
+ if (!image.present || project.overwritable && stored !== tag) {
6002
+ return {
6003
+ ...IMAGE_ROW,
6004
+ status: "not-built-yet",
6005
+ severity: "error",
6006
+ detail: image.present ? `${tag} is built but is not this project's image yet` : `.runcastle/sandbox/Dockerfile is not built \u2014 ${tag} does not exist`,
6007
+ fix: "Open Settings \u2192 Burns (Build image) \u2014 runcastle builds .runcastle/sandbox/Dockerfile for you."
6008
+ };
6009
+ }
6010
+ if (image.hash !== projectHash) {
6011
+ return staleImage(`${tag} no longer matches .runcastle/sandbox/Dockerfile`);
6012
+ }
6013
+ if (!stockFresh) {
6014
+ const base = stock.present ? "no longer matches the burner Dockerfile" : "is not built";
6015
+ return staleImage(`${tag} is built on ${DEFAULT_SANDBOX_IMAGE}, which ${base}`);
5772
6016
  }
5773
6017
  return {
5774
- id,
5775
- label,
5776
- tier: 2,
6018
+ ...IMAGE_ROW,
6019
+ status: "ok",
6020
+ severity: "error",
6021
+ detail: `${tag} built from .runcastle/sandbox/Dockerfile`
6022
+ };
6023
+ }
6024
+ const imageName = resolveSandboxImage({ sandboxImage: input.imageName }, { sandboxImage: stored });
6025
+ if (imageName !== DEFAULT_SANDBOX_IMAGE) {
6026
+ const custom = await inspectBuiltImage(exec, runtime, imageName);
6027
+ const clauses = [
6028
+ `${imageName} is a custom image, managed outside runcastle`,
6029
+ ...projectHash === null ? [] : [`and outranks this repo's .runcastle/sandbox/Dockerfile`],
6030
+ ...custom.present ? [] : ["and is not built locally"]
6031
+ ];
6032
+ return {
6033
+ ...IMAGE_ROW,
6034
+ status: "custom",
6035
+ severity: custom.present ? "info" : "error",
6036
+ detail: clauses.join(" \u2014 "),
6037
+ fix: unmanagedImageReason(imageName, projectHash !== null)
6038
+ };
6039
+ }
6040
+ if (!stock.present) {
6041
+ return {
6042
+ ...IMAGE_ROW,
5777
6043
  status: "missing",
5778
6044
  severity: "error",
5779
6045
  detail: `image ${imageName} not found locally`,
5780
6046
  fix: 'Start runcastle and click "Build image" on the Enable AFK burns card \u2014 it builds this for you (one click). Only needed for AFK/sandboxed burns.'
5781
6047
  };
5782
6048
  }
6049
+ if (!stockFresh)
6050
+ return staleImage(`${imageName} no longer matches the burner Dockerfile`);
6051
+ return { ...IMAGE_ROW, status: "ok", severity: "error", detail: `${imageName} present` };
6052
+ }
6053
+ async function presentRuntime(exec) {
6054
+ for (const runtime of ["docker", "podman"]) {
6055
+ const present = await exec(runtime, ["--version"]);
6056
+ if (present.ok && present.code === 0)
6057
+ return runtime;
6058
+ }
6059
+ return null;
6060
+ }
6061
+ function staleImage(detail) {
5783
6062
  return {
5784
- id,
5785
- label,
5786
- tier: 2,
5787
- status: "missing",
6063
+ ...IMAGE_ROW,
6064
+ status: "stale",
5788
6065
  severity: "error",
5789
- detail: "no container runtime available to inspect the image",
5790
- fix: "Install a container runtime (Docker or Podman) first, then build the image from the Enable AFK burns card in the app."
6066
+ detail: `${detail} \u2014 rebuild`,
6067
+ fix: "Open Settings \u2192 Burns (Rebuild image)."
5791
6068
  };
5792
6069
  }
5793
6070
  async function runDoctor(env) {
@@ -5795,7 +6072,7 @@ async function runDoctor(env) {
5795
6072
  const processEnv = env.env ?? process.env;
5796
6073
  const imageName = env.imageName ?? DEFAULT_SANDBOX_IMAGE;
5797
6074
  const burnerDockerfile = env.burnerDockerfile ?? burnerDockerfilePath();
5798
- const fileMtime = env.fileMtime ?? ((path) => statSync(path).mtime);
6075
+ const dockerfileHash = env.dockerfileHash ?? hashDockerfile;
5799
6076
  const required = new Set(env.runtimes ?? [DEFAULT_RUNTIME]);
5800
6077
  const results = [
5801
6078
  await bunProbe(exec),
@@ -5810,7 +6087,13 @@ async function runDoctor(env) {
5810
6087
  severity: required.has(runtime) ? "error" : "info"
5811
6088
  }));
5812
6089
  }
5813
- results.push(await gitIdentityProbe(exec, env.cwd), await containerRuntimeProbe(exec), await sandcastleImageProbe(exec, imageName, burnerDockerfile, fileMtime));
6090
+ results.push(await gitIdentityProbe(exec, env.cwd), await containerRuntimeProbe(exec), await sandcastleImageProbe({
6091
+ exec,
6092
+ imageName,
6093
+ burnerDockerfile,
6094
+ dockerfileHash,
6095
+ ...env.projectImage ? { project: env.projectImage } : {}
6096
+ }));
5814
6097
  const counts = (r) => r.severity === "error";
5815
6098
  return {
5816
6099
  results,
@@ -5823,11 +6106,12 @@ function exitCodeFor(report, mode) {
5823
6106
  return report.tier1Ok ? 0 : 1;
5824
6107
  return report.ok ? 0 : 1;
5825
6108
  }
5826
- var RUNTIME_SPECS;
6109
+ var RUNTIME_SPECS, IMAGE_ROW;
5827
6110
  var init_doctor = __esm(() => {
5828
6111
  init_src();
5829
6112
  init_asset_paths();
5830
6113
  init_codex_auth();
6114
+ init_sandbox_image();
5831
6115
  RUNTIME_SPECS = {
5832
6116
  "claude-code": {
5833
6117
  runtime: "claude-code",
@@ -5859,21 +6143,22 @@ var init_doctor = __esm(() => {
5859
6143
  authFile: codexAuthFile
5860
6144
  }
5861
6145
  };
6146
+ IMAGE_ROW = { id: "sandcastle-image", label: "Sandcastle container image", tier: 2 };
5862
6147
  });
5863
6148
 
5864
6149
  // src/launcher/runtimes/codex.ts
5865
6150
  import {
5866
6151
  copyFileSync,
5867
- existsSync as existsSync10,
6152
+ existsSync as existsSync11,
5868
6153
  mkdirSync as mkdirSync4,
5869
- readFileSync as readFileSync4,
6154
+ readFileSync as readFileSync5,
5870
6155
  readdirSync as readdirSync2,
5871
- statSync as statSync2,
6156
+ statSync,
5872
6157
  writeFileSync as writeFileSync3
5873
6158
  } from "fs";
5874
- import { basename, dirname as dirname4, join as join10, resolve as resolve7 } from "path";
6159
+ import { basename, dirname as dirname4, join as join11, resolve as resolve7 } from "path";
5875
6160
  function codexHomeDir2(sessionId) {
5876
- return join10(sessionDir(sessionId), "codex-home");
6161
+ return join11(sessionDir(sessionId), "codex-home");
5877
6162
  }
5878
6163
  function realCodexAuthFile(env = process.env) {
5879
6164
  return codexAuthFile(env);
@@ -5882,7 +6167,7 @@ function toml(value) {
5882
6167
  return JSON.stringify(value);
5883
6168
  }
5884
6169
  function approvalPolicyFor(permissionMode) {
5885
- return permissionMode === undefined || permissionMode === "acceptEdits" ? "never" : "untrusted";
6170
+ return permissionMode === undefined || permissionMode === "acceptEdits" ? "never" : "on-request";
5886
6171
  }
5887
6172
  function renderCodexConfig(input) {
5888
6173
  const { session, config: config2, model, worktreePath, inheritedMcpServers, permissionMode } = input;
@@ -5921,11 +6206,11 @@ function mcpServerTables(userConfigToml) {
5921
6206
  `).trim();
5922
6207
  }
5923
6208
  function readInheritedMcpServers(env = process.env) {
5924
- const path = join10(dirname4(realCodexAuthFile(env)), "config.toml");
5925
- if (!existsSync10(path))
6209
+ const path = join11(dirname4(realCodexAuthFile(env)), "config.toml");
6210
+ if (!existsSync11(path))
5926
6211
  return "";
5927
6212
  try {
5928
- return mcpServerTables(readFileSync4(path, "utf8"));
6213
+ return mcpServerTables(readFileSync5(path, "utf8"));
5929
6214
  } catch {
5930
6215
  return "";
5931
6216
  }
@@ -5946,19 +6231,19 @@ function renderCodexHooks(hookClient, kind, platform = process.platform) {
5946
6231
  };
5947
6232
  }
5948
6233
  function writeCodexSkills(worktreePath, packDir) {
5949
- if (!existsSync10(worktreePath))
6234
+ if (!existsSync11(worktreePath))
5950
6235
  return [];
5951
- const source = join10(packDir, "skills");
5952
- if (!existsSync10(source))
6236
+ const source = join11(packDir, "skills");
6237
+ if (!existsSync11(source))
5953
6238
  return [];
5954
6239
  const written = [];
5955
6240
  for (const name of readdirSync2(source)) {
5956
- const skill = join10(source, name, "SKILL.md");
5957
- if (!existsSync10(skill))
6241
+ const skill = join11(source, name, "SKILL.md");
6242
+ if (!existsSync11(skill))
5958
6243
  continue;
5959
- const target = join10(worktreePath, CODEX_SKILLS_REL, name);
6244
+ const target = join11(worktreePath, CODEX_SKILLS_REL, name);
5960
6245
  mkdirSync4(target, { recursive: true });
5961
- const path = join10(target, "SKILL.md");
6246
+ const path = join11(target, "SKILL.md");
5962
6247
  copyFileSync(skill, path);
5963
6248
  written.push(path);
5964
6249
  }
@@ -5972,8 +6257,8 @@ function excludeFromGit(worktreePath, entry) {
5972
6257
  if (!info)
5973
6258
  return;
5974
6259
  mkdirSync4(info, { recursive: true });
5975
- const path = join10(info, "exclude");
5976
- const existing = existsSync10(path) ? readFileSync4(path, "utf8") : "";
6260
+ const path = join11(info, "exclude");
6261
+ const existing = existsSync11(path) ? readFileSync5(path, "utf8") : "";
5977
6262
  if (existing.split(/\r?\n/).some((line) => line.trim() === entry))
5978
6263
  return;
5979
6264
  const head = existing.length > 0 && !existing.endsWith(`
@@ -5984,17 +6269,17 @@ function excludeFromGit(worktreePath, entry) {
5984
6269
  } catch {}
5985
6270
  }
5986
6271
  function gitInfoDir(worktreePath) {
5987
- const dotGit = join10(worktreePath, ".git");
5988
- if (!existsSync10(dotGit))
6272
+ const dotGit = join11(worktreePath, ".git");
6273
+ if (!existsSync11(dotGit))
5989
6274
  return null;
5990
- if (statSync2(dotGit).isDirectory())
5991
- return join10(dotGit, "info");
5992
- const named = /^gitdir:\s*(.+)$/m.exec(readFileSync4(dotGit, "utf8"));
6275
+ if (statSync(dotGit).isDirectory())
6276
+ return join11(dotGit, "info");
6277
+ const named = /^gitdir:\s*(.+)$/m.exec(readFileSync5(dotGit, "utf8"));
5993
6278
  if (!named)
5994
6279
  return null;
5995
6280
  const gitDir = resolve7(worktreePath, named[1].trim());
5996
6281
  const parent = dirname4(gitDir);
5997
- return basename(parent) === "worktrees" ? join10(dirname4(parent), "info") : join10(gitDir, "info");
6282
+ return basename(parent) === "worktrees" ? join11(dirname4(parent), "info") : join11(gitDir, "info");
5998
6283
  }
5999
6284
  function buildCodexArgs(input) {
6000
6285
  return [
@@ -6013,7 +6298,7 @@ var init_codex = __esm(() => {
6013
6298
  init_skills_root();
6014
6299
  init_skills();
6015
6300
  CODEX = RUNTIME_SPECS.codex;
6016
- CODEX_SKILLS_REL = join10(".agents", "skills");
6301
+ CODEX_SKILLS_REL = join11(".agents", "skills");
6017
6302
  KICKOFF_LINES2 = kickoffLinesFor("codex");
6018
6303
  codexRuntime = {
6019
6304
  id: "codex",
@@ -6029,7 +6314,7 @@ var init_codex = __esm(() => {
6029
6314
  doctorHint: 'Run `runcastle doctor` and fix its "Codex CLI" probe, then restart runcastle from a ' + "terminal where `codex --version` works."
6030
6315
  };
6031
6316
  }
6032
- if (!existsSync10(realCodexAuthFile())) {
6317
+ if (!existsSync11(realCodexAuthFile())) {
6033
6318
  return {
6034
6319
  ok: false,
6035
6320
  reason: "Codex is installed but not logged in \u2014 runcastle copies your own credentials into " + `each session's home, and there are none at ${realCodexAuthFile()}`,
@@ -6041,9 +6326,9 @@ var init_codex = __esm(() => {
6041
6326
  async writeArtifacts(input) {
6042
6327
  const home = codexHomeDir2(input.session.id);
6043
6328
  mkdirSync4(home, { recursive: true });
6044
- const configPath2 = join10(home, "config.toml");
6045
- const hooksPath = join10(home, "hooks.json");
6046
- const agentsPath = join10(home, "AGENTS.md");
6329
+ const configPath2 = join11(home, "config.toml");
6330
+ const hooksPath = join11(home, "hooks.json");
6331
+ const agentsPath = join11(home, "AGENTS.md");
6047
6332
  writeFileSync3(configPath2, renderCodexConfig({
6048
6333
  session: input.session,
6049
6334
  config: input.config,
@@ -6056,8 +6341,8 @@ var init_codex = __esm(() => {
6056
6341
  writeFileSync3(agentsPath, renderSessionPrompt({ ...input, runtime: this.id }), "utf8");
6057
6342
  const files = [configPath2, hooksPath, agentsPath];
6058
6343
  const auth = realCodexAuthFile();
6059
- if (existsSync10(auth)) {
6060
- const copied = join10(home, "auth.json");
6344
+ if (existsSync11(auth)) {
6345
+ const copied = join11(home, "auth.json");
6061
6346
  copyFileSync(auth, copied);
6062
6347
  files.push(copied);
6063
6348
  }
@@ -6099,7 +6384,7 @@ var init_runtimes = __esm(() => {
6099
6384
  });
6100
6385
 
6101
6386
  // src/launcher/sessions.ts
6102
- import { statSync as statSync3 } from "fs";
6387
+ import { statSync as statSync2 } from "fs";
6103
6388
  import { and as and8, desc as desc3, eq as eq8, inArray as inArray2, isNotNull as isNotNull2, sql as sql2 } from "drizzle-orm";
6104
6389
  function createSessionRow(ctx, input) {
6105
6390
  const row = ctx.db.insert(sessions).values({
@@ -6454,7 +6739,7 @@ function transcriptBytes(session) {
6454
6739
  if (!session?.transcriptPath)
6455
6740
  return;
6456
6741
  try {
6457
- return statSync3(session.transcriptPath).size;
6742
+ return statSync2(session.transcriptPath).size;
6458
6743
  } catch {
6459
6744
  return;
6460
6745
  }
@@ -6532,25 +6817,49 @@ var init_reconcile = __esm(() => {
6532
6817
  });
6533
6818
 
6534
6819
  // src/launcher/runtime.ts
6820
+ import { readFileSync as readFileSync6 } from "fs";
6535
6821
  function setRuntimeCtx(ctx) {
6536
6822
  current = ctx;
6537
6823
  }
6824
+ function followConfigFile(ctx) {
6825
+ followed = { ctx, raw: readConfigFile() };
6826
+ }
6827
+ function readConfigFile() {
6828
+ try {
6829
+ return readFileSync6(configPath(), "utf8");
6830
+ } catch {
6831
+ return "";
6832
+ }
6833
+ }
6834
+ function syncFollowedConfig() {
6835
+ if (!followed)
6836
+ return;
6837
+ const raw = readConfigFile();
6838
+ if (raw === followed.raw)
6839
+ return;
6840
+ followed.raw = raw;
6841
+ try {
6842
+ followed.ctx.config = loadConfig();
6843
+ } catch {}
6844
+ }
6538
6845
  async function getRuntimeCtx() {
6539
- if (current)
6846
+ if (current) {
6847
+ syncFollowedConfig();
6540
6848
  return current;
6541
- const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
6542
- Promise.resolve().then(() => (init_client(), exports_client)),
6543
- Promise.resolve().then(() => (init_paths(), exports_paths)),
6544
- Promise.resolve().then(() => (init_config_load(), exports_config_load))
6545
- ]);
6546
- const db = createDb2(dbPath2());
6547
- current = { db, config: loadConfig2() };
6849
+ }
6850
+ const { createDb: createDb2 } = await Promise.resolve().then(() => (init_client(), exports_client));
6851
+ const db = createDb2(dbPath());
6852
+ current = { db, config: loadConfig() };
6548
6853
  return current;
6549
6854
  }
6550
- var current = null;
6855
+ var current = null, followed = null;
6856
+ var init_runtime = __esm(() => {
6857
+ init_config_load();
6858
+ init_paths();
6859
+ });
6551
6860
 
6552
6861
  // src/services/gates.ts
6553
- import { existsSync as existsSync11 } from "fs";
6862
+ import { existsSync as existsSync12 } from "fs";
6554
6863
  import { and as and9, desc as desc4, eq as eq9, inArray as inArray4 } from "drizzle-orm";
6555
6864
  function checkGate(ctx, check, feature) {
6556
6865
  switch (check) {
@@ -6586,6 +6895,10 @@ function checkGate(ctx, check, feature) {
6586
6895
  reason: 'no review ticket on this lap \u2014 every batch closes with one (kind: "review"); emit it, ' + "fix the kind on a review-shaped ticket, or override this gate with a reason"
6587
6896
  };
6588
6897
  }
6898
+ const undispositioned = undispositionedDefects(ctx, feature.id);
6899
+ if (undispositioned.length > 0) {
6900
+ return { satisfied: false, reason: undispositionedReason(undispositioned) };
6901
+ }
6589
6902
  return { satisfied: true };
6590
6903
  }
6591
6904
  case "all-tickets-terminal": {
@@ -6602,6 +6915,10 @@ function checkGate(ctx, check, feature) {
6602
6915
  return { satisfied: false, reason: "use the Merge button to ship" };
6603
6916
  }
6604
6917
  }
6918
+ function undispositionedReason(defects) {
6919
+ const titles = defects.map((defect) => `"${defect.title}"`).join(", ");
6920
+ return `${defects.length} defect${defects.length === 1 ? "" : "s"} from an earlier lap ${defects.length === 1 ? "is" : "are"} still un-dispositioned: ${titles} \u2014 for each, either emit this lap's ticket for it with ` + "`originFindingId` set (link), or call `resolve_finding` to carry it or close it as " + "addressed. The human can also dismiss it from the review page.";
6921
+ }
6605
6922
  function notYetTerminal(noun, statuses, order) {
6606
6923
  const counts = new Map;
6607
6924
  for (const s of statuses)
@@ -6615,7 +6932,7 @@ function notYetTerminal(noun, statuses, order) {
6615
6932
  }
6616
6933
  function fileGate(ctx, feature, fileName, reason) {
6617
6934
  const project = projectForFeature(ctx, feature);
6618
- return existsSync11(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
6935
+ return existsSync12(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
6619
6936
  }
6620
6937
  function lapWorked(ctx, feature) {
6621
6938
  return ctx.db.select({ id: sessions.id }).from(sessions).where(and9(eq9(sessions.featureId, feature.id), eq9(sessions.lap, feature.lap), inArray4(sessions.kind, [...DOC_WRITING_KINDS]))).all().length > 0;
@@ -6675,20 +6992,21 @@ var init_gates = __esm(() => {
6675
6992
  init_events();
6676
6993
  init_feature_docs();
6677
6994
  init_repo();
6995
+ init_review_findings();
6678
6996
  init_tickets();
6679
6997
  init_waypoints();
6680
6998
  DOC_WRITING_KINDS = ["ideation", "revisit", "converge"];
6681
6999
  });
6682
7000
 
6683
7001
  // src/services/knowledge.ts
6684
- import { existsSync as existsSync12, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "fs";
6685
- import { join as join11, relative as relative2, resolve as resolve8, sep } from "path";
7002
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, readFileSync as readFileSync7, readdirSync as readdirSync3, statSync as statSync3, writeFileSync as writeFileSync4 } from "fs";
7003
+ import { join as join12, relative as relative2, resolve as resolve8, sep } from "path";
6686
7004
  function scaffoldDocs(ctx, feature, opts = {}) {
6687
7005
  const project = projectForFeature(ctx, feature);
6688
7006
  const dir = featureDocsDir(project, feature);
6689
7007
  mkdirSync5(dir, { recursive: true });
6690
7008
  const briefPath = featureDocPath(project, feature, "brief.md");
6691
- if (!existsSync12(briefPath)) {
7009
+ if (!existsSync13(briefPath)) {
6692
7010
  const override = opts.brief?.trim();
6693
7011
  const created = new Date().toISOString();
6694
7012
  const brief = override ? `${override}
@@ -6715,7 +7033,7 @@ function scaffoldDocs(ctx, feature, opts = {}) {
6715
7033
  function scaffoldMapDoc(project, feature, seed) {
6716
7034
  mkdirSync5(featureDocsDir(project, feature), { recursive: true });
6717
7035
  const mapPath = featureDocPath(project, feature, "map.md");
6718
- if (existsSync12(mapPath))
7036
+ if (existsSync13(mapPath))
6719
7037
  return;
6720
7038
  writeFileSync4(mapPath, mapDocBody(feature, seed), "utf8");
6721
7039
  }
@@ -6735,18 +7053,18 @@ function mapDocBody(feature, seed) {
6735
7053
  `);
6736
7054
  }
6737
7055
  function readCharter(repoRoot) {
6738
- const path = join11(repoRoot, CHARTER_FILE);
6739
- if (!existsSync12(path))
7056
+ const path = join12(repoRoot, CHARTER_FILE);
7057
+ if (!existsSync13(path))
6740
7058
  return;
6741
7059
  try {
6742
- return readFileSync5(path, "utf8");
7060
+ return readFileSync7(path, "utf8");
6743
7061
  } catch {
6744
7062
  return;
6745
7063
  }
6746
7064
  }
6747
7065
  function listLiveAdrs(repoRoot) {
6748
- const dir = join11(repoRoot, ADR_DIR_REL);
6749
- if (!existsSync12(dir))
7066
+ const dir = join12(repoRoot, ADR_DIR_REL);
7067
+ if (!existsSync13(dir))
6750
7068
  return [];
6751
7069
  const docs2 = [];
6752
7070
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
@@ -6754,7 +7072,7 @@ function listLiveAdrs(repoRoot) {
6754
7072
  continue;
6755
7073
  let content;
6756
7074
  try {
6757
- content = readFileSync5(join11(dir, entry.name), "utf8");
7075
+ content = readFileSync7(join12(dir, entry.name), "utf8");
6758
7076
  } catch {
6759
7077
  continue;
6760
7078
  }
@@ -6767,10 +7085,10 @@ function listLiveAdrs(repoRoot) {
6767
7085
  function listDocs(ctx, feature) {
6768
7086
  const project = projectForFeature(ctx, feature);
6769
7087
  const dir = featureDocsDir(project, feature);
6770
- if (!existsSync12(dir))
7088
+ if (!existsSync13(dir))
6771
7089
  return [];
6772
7090
  return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => {
6773
- const path = join11(dir, e.name);
7091
+ const path = join12(dir, e.name);
6774
7092
  return { relPath: e.name, title: readTitle(path, e.name), ...writtenAt(path) };
6775
7093
  }).sort((a, b) => a.relPath.localeCompare(b.relPath));
6776
7094
  }
@@ -6783,20 +7101,20 @@ function readDoc(ctx, feature, relPath) {
6783
7101
  if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
6784
7102
  throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
6785
7103
  }
6786
- if (!existsSync12(target))
7104
+ if (!existsSync13(target))
6787
7105
  throw new NotFoundError(`doc not found: ${relPath}`);
6788
- return { content: readFileSync5(target, "utf8") };
7106
+ return { content: readFileSync7(target, "utf8") };
6789
7107
  }
6790
7108
  function writtenAt(path) {
6791
7109
  try {
6792
- return { updatedAt: Math.floor(statSync4(path).mtimeMs) };
7110
+ return { updatedAt: Math.floor(statSync3(path).mtimeMs) };
6793
7111
  } catch {
6794
7112
  return {};
6795
7113
  }
6796
7114
  }
6797
7115
  function readTitle(path, fallback) {
6798
7116
  try {
6799
- const firstLine = readFileSync5(path, "utf8").split(`
7117
+ const firstLine = readFileSync7(path, "utf8").split(`
6800
7118
  `, 1)[0] ?? "";
6801
7119
  const heading = firstLine.match(/^#\s+(.+)$/);
6802
7120
  return heading ? heading[1].trim() : fallback;
@@ -7009,6 +7327,13 @@ var require$1, __create, __defProp2, __getOwnPropDesc, __getOwnPropNames, __getP
7009
7327
  toString() {
7010
7328
  return this.items.join(this.separator);
7011
7329
  }
7330
+ }, elidePayloadTokens = (command) => command.replace(/'[A-Za-z0-9+\/=]{120,}'/g, "'\u2026'"), formatExecFailureMessage = (command, result, maxTailChars = MAX_TAIL_CHARS) => {
7331
+ const output = [result.stdout, result.stderr].map((stream) => (stream ?? "").trimEnd()).filter((stream) => stream.length > 0).join(`
7332
+ `);
7333
+ const tail = output.length > maxTailChars ? output.slice(output.length - maxTailChars) : output;
7334
+ const headline = `Command failed (exit ${result.exitCode}): ${elidePayloadTokens(command)}`;
7335
+ return tail ? `${headline}
7336
+ ${tail}` : headline;
7012
7337
  };
7013
7338
  var init_chunk_NGBM7T3E = __esm(() => {
7014
7339
  require$1 = createRequire3(import.meta.url);
@@ -7033,7 +7358,7 @@ import { createRequire as createRequire4 } from "module";
7033
7358
  import * as clack from "@clack/prompts";
7034
7359
  import { join as join$1, basename as basename2, dirname as dirname5, resolve as resolve9, posix as posix2, normalize, isAbsolute as isAbsolute2 } from "path";
7035
7360
  import { styleText, promisify } from "util";
7036
- import { existsSync as existsSync13, statSync as statSync5 } from "fs";
7361
+ import { existsSync as existsSync14, statSync as statSync4 } from "fs";
7037
7362
  import { tmpdir as tmpdir2, homedir as homedir3 } from "os";
7038
7363
  import { mkdtemp, writeFile, mkdir, readFile, rm, readdir, stat } from "fs/promises";
7039
7364
  import { exec, execFile } from "child_process";
@@ -8112,7 +8437,7 @@ var Effect_exports, isFunction = (input) => typeof input === "function", dual =
8112
8437
  next4 = b2;
8113
8438
  }
8114
8439
  return out;
8115
- }, getEquivalence2, dedupeWith, dedupe = (self) => dedupeWith(self, equivalence()), join12, TypeId4, emptyArray, getEquivalence3 = (isEquivalent) => make((self, that) => self.length === that.length && toReadonlyArray(self).every((value5, i) => isEquivalent(value5, unsafeGet2(that, i)))), _equivalence3, ChunkProto, makeChunk = (backing) => {
8440
+ }, getEquivalence2, dedupeWith, dedupe = (self) => dedupeWith(self, equivalence()), join13, TypeId4, emptyArray, getEquivalence3 = (isEquivalent) => make((self, that) => self.length === that.length && toReadonlyArray(self).every((value5, i) => isEquivalent(value5, unsafeGet2(that, i)))), _equivalence3, ChunkProto, makeChunk = (backing) => {
8116
8441
  const chunk4 = Object.create(ChunkProto);
8117
8442
  chunk4.backing = backing;
8118
8443
  switch (backing._tag) {
@@ -9811,7 +10136,7 @@ ${prefix}}`;
9811
10136
  Object.defineProperty(error3, "toString", {
9812
10137
  enumerable: false,
9813
10138
  value() {
9814
- const path2 = pipe(this.path, join12(options.pathDelim));
10139
+ const path2 = pipe(this.path, join13(options.pathDelim));
9815
10140
  return `(Invalid data at ${path2}: "${this.message}")`;
9816
10141
  }
9817
10142
  });
@@ -9826,7 +10151,7 @@ ${prefix}}`;
9826
10151
  Object.defineProperty(error3, "toString", {
9827
10152
  enumerable: false,
9828
10153
  value() {
9829
- const path2 = pipe(this.path, join12(options.pathDelim));
10154
+ const path2 = pipe(this.path, join13(options.pathDelim));
9830
10155
  return `(Missing data at ${path2}: "${this.message}")`;
9831
10156
  }
9832
10157
  });
@@ -9842,7 +10167,7 @@ ${prefix}}`;
9842
10167
  Object.defineProperty(error3, "toString", {
9843
10168
  enumerable: false,
9844
10169
  value() {
9845
- const path2 = pipe(this.path, join12(options.pathDelim));
10170
+ const path2 = pipe(this.path, join13(options.pathDelim));
9846
10171
  return `(Source unavailable at ${path2}: "${this.message}")`;
9847
10172
  }
9848
10173
  });
@@ -9857,7 +10182,7 @@ ${prefix}}`;
9857
10182
  Object.defineProperty(error3, "toString", {
9858
10183
  enumerable: false,
9859
10184
  value() {
9860
- const path2 = pipe(this.path, join12(options.pathDelim));
10185
+ const path2 = pipe(this.path, join13(options.pathDelim));
9861
10186
  return `(Unsupported operation at ${path2}: "${this.message}")`;
9862
10187
  }
9863
10188
  });
@@ -9887,7 +10212,7 @@ ${prefix}}`;
9887
10212
  pathDelim: "_",
9888
10213
  seqDelim: ","
9889
10214
  }, options);
9890
- const makePathString = (path) => pipe(path, join12(pathDelim));
10215
+ const makePathString = (path) => pipe(path, join13(pathDelim));
9891
10216
  const unmakePathString = (pathString) => pathString.split(pathDelim);
9892
10217
  const getEnv = () => typeof process !== "undefined" && ("env" in process) && typeof process.env === "object" ? process.env : {};
9893
10218
  const load = (path, primitive2, split2 = true) => {
@@ -10008,7 +10333,7 @@ ${prefix}}`;
10008
10333
  return fail2(right3.left);
10009
10334
  }
10010
10335
  if (isRight2(left3) && isRight2(right3)) {
10011
- const path = pipe(prefix, join12("."));
10336
+ const path = pipe(prefix, join13("."));
10012
10337
  const fail19 = fromFlatLoopFail(prefix, path);
10013
10338
  const [lefts, rights] = extend(fail19, fail19, pipe(left3.right, map3(right2)), pipe(right3.right, map3(right2)));
10014
10339
  return pipe(lefts, zip(rights), forEachSequential(([left4, right4]) => pipe(zip2(left4, right4), map10(([left5, right5]) => op.zip(left5, right5)))));
@@ -17940,7 +18265,7 @@ schema (${ast._tag}): ${ast}`;
17940
18265
  const cowFlags = getCopyOnWriteFlags(process.platform);
17941
18266
  for (const relativePath of paths) {
17942
18267
  const src = join$1(hostRepoDir, relativePath);
17943
- if (!existsSync13(src)) {
18268
+ if (!existsSync14(src)) {
17944
18269
  continue;
17945
18270
  }
17946
18271
  const dest = join$1(worktreePath, relativePath);
@@ -18082,7 +18407,7 @@ ${e instanceof Error ? e.message : String(e)}`
18082
18407
  await mkdir(patchesRoot, { recursive: true });
18083
18408
  let dirName = base;
18084
18409
  let counter6 = 0;
18085
- while (existsSync13(join$1(patchesRoot, dirName))) {
18410
+ while (existsSync14(join$1(patchesRoot, dirName))) {
18086
18411
  counter6++;
18087
18412
  dirName = `${base}-${counter6}`;
18088
18413
  }
@@ -18254,8 +18579,7 @@ ${msg}`);
18254
18579
  }), GIT_SETUP_TIMEOUT_MS = 1e4, HOOK_TIMEOUT_MS = 60000, COMMIT_COLLECTION_TIMEOUT_MS = 30000, MERGE_TO_HOST_TIMEOUT_MS = 30000, GIT_SETUP_MAX_RETRIES = 2, GIT_SETUP_RETRY_DELAY_MS = 250, TRANSIENT_EXEC_EXIT_CODES, isTransientExecError = (err) => err._tag === "ExecError" && err.exitCode !== undefined && TRANSIENT_EXEC_EXIT_CODES.has(err.exitCode), execOk2 = (sandbox3, command, options) => Effect_exports.flatMap(sandbox3.exec(command, options), (result) => result.exitCode !== 0 ? Effect_exports.fail(new ExecError({
18255
18580
  command,
18256
18581
  exitCode: result.exitCode,
18257
- message: `Command failed (exit ${result.exitCode}): ${command}
18258
- ${result.stderr}`
18582
+ message: formatExecFailureMessage(command, result, sandbox3.maxOutputTailChars)
18259
18583
  })) : Effect_exports.succeed(result)), execOkWithGitTimeout = (sandbox3, command, gitSetupTimeoutMs, options) => execOk2(sandbox3, command, options).pipe(withTimeout(gitSetupTimeoutMs, () => new GitSetupTimeoutError({
18260
18584
  message: `Git command timed out after ${gitSetupTimeoutMs}ms: ${command}`,
18261
18585
  timeoutMs: gitSetupTimeoutMs,
@@ -18591,7 +18915,7 @@ ${result.stderr}`
18591
18915
  yield* Effect_exports.gen(function* () {
18592
18916
  for (const relativePath of pathsToCopy) {
18593
18917
  const hostPath = join$1(options.hostRepoDir, relativePath);
18594
- if (!existsSync13(hostPath)) {
18918
+ if (!existsSync14(hostPath)) {
18595
18919
  continue;
18596
18920
  }
18597
18921
  const sandboxPath = posix2.join(handle.worktreePath, relativePath);
@@ -18634,6 +18958,7 @@ ${result.stderr}`
18634
18958
  message: "copyIn is not supported for this sandbox provider"
18635
18959
  }));
18636
18960
  }, makeSandboxFromHandle = (handle) => ({
18961
+ maxOutputTailChars: handle.maxOutputTailChars,
18637
18962
  exec: (command, options) => Effect_exports.tryPromise({
18638
18963
  try: () => handle.exec(command, options),
18639
18964
  catch: (e) => new ExecError({
@@ -18732,7 +19057,7 @@ Worktree removed (no uncommitted changes)`);
18732
19057
  };
18733
19058
  }
18734
19059
  const resolvedHostPath = resolveHostPath(m.hostPath);
18735
- if (!existsSync13(resolvedHostPath)) {
19060
+ if (!existsSync14(resolvedHostPath)) {
18736
19061
  throw new Error(`Mount hostPath does not exist: ${m.hostPath}` + (m.hostPath !== resolvedHostPath ? ` (resolved to ${resolvedHostPath})` : ""));
18737
19062
  }
18738
19063
  return {
@@ -18845,7 +19170,7 @@ Worktree removed (no uncommitted changes)`);
18845
19170
  const selinux = mount.volume !== undefined ? undefined : selinuxLabel || undefined;
18846
19171
  const options = [mount.readonly ? "ro" : undefined, selinux].filter((option5) => option5 !== undefined).join(",");
18847
19172
  return options ? `${base}:${options}` : base;
18848
- }, processFileMountParents = (mounts, sandboxHomedir, statFn = statSync5) => {
19173
+ }, processFileMountParents = (mounts, sandboxHomedir, statFn = statSync4) => {
18849
19174
  const parentDirs = /* @__PURE__ */ new Set;
18850
19175
  for (const mount of mounts) {
18851
19176
  let isFile2;
@@ -19846,7 +20171,7 @@ var init_chunk_VOG34SRF = __esm(() => {
19846
20171
  }
19847
20172
  return [];
19848
20173
  });
19849
- join12 = /* @__PURE__ */ dual(2, (self, sep2) => fromIterable2(self).join(sep2));
20174
+ join13 = /* @__PURE__ */ dual(2, (self, sep2) => fromIterable2(self).join(sep2));
19850
20175
  TypeId4 = /* @__PURE__ */ Symbol.for("effect/Chunk");
19851
20176
  emptyArray = [];
19852
20177
  _equivalence3 = /* @__PURE__ */ getEquivalence3(equals);
@@ -53503,6 +53828,7 @@ var noSandbox = (options) => ({
53503
53828
  const maxOutputTailChars = options?.maxOutputTailChars ?? MAX_TAIL_CHARS;
53504
53829
  const handle = {
53505
53830
  worktreePath,
53831
+ maxOutputTailChars,
53506
53832
  exec: (command, opts) => {
53507
53833
  const cwd = opts?.cwd ?? worktreePath;
53508
53834
  const isWindows = process.platform === "win32";
@@ -53599,7 +53925,7 @@ var init_chunk_62WN33RK = __esm(() => {
53599
53925
  // ../../node_modules/.bun/@ai-hero+sandcastle@0.12.0/node_modules/@ai-hero/sandcastle/dist/index.js
53600
53926
  import { createRequire as createRequire8 } from "module";
53601
53927
  import { mkdirSync as mkdirSync6, appendFileSync } from "fs";
53602
- import path, { join as join14, posix as posix4, dirname as dirname7, relative as relative3 } from "path";
53928
+ import path, { join as join15, posix as posix4, dirname as dirname7, relative as relative3 } from "path";
53603
53929
  import { styleText as styleText2 } from "util";
53604
53930
  import * as clack2 from "@clack/prompts";
53605
53931
  import { readdir as readdir2, access as access3, readFile as readFile3, mkdir as mkdir3, writeFile as writeFile3, rm as rm3 } from "fs/promises";
@@ -53668,7 +53994,7 @@ async function run6(options) {
53668
53994
  const targetBranch = effectiveBranchType === "merge-to-head" ? currentHostBranch : undefined;
53669
53995
  const resolvedLogging = options.logging ?? {
53670
53996
  type: "file",
53671
- path: join14(hostRepoDir, ".sandcastle", "logs", buildLogFilename(resolvedBranch, targetBranch, options.name))
53997
+ path: join15(hostRepoDir, ".sandcastle", "logs", buildLogFilename(resolvedBranch, targetBranch, options.name))
53672
53998
  };
53673
53999
  const displayLayer = resolvedLogging.type === "file" ? (() => {
53674
54000
  printFileDisplayStartup({
@@ -54302,7 +54628,7 @@ ${errorDetail}`
54302
54628
  }
54303
54629
  return vars;
54304
54630
  }), resolveEnv = (repoDir) => Effect_exports.gen(function* () {
54305
- const sandcastleEnv = yield* parseEnvFile2(join14(repoDir, ".sandcastle", ".env"));
54631
+ const sandcastleEnv = yield* parseEnvFile2(join15(repoDir, ".sandcastle", ".env"));
54306
54632
  const result = {};
54307
54633
  for (const key of Object.keys(sandcastleEnv)) {
54308
54634
  const value = sandcastleEnv[key] || process.env[key];
@@ -54524,11 +54850,11 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54524
54850
  const normalized = isRoot ? cwd : cwd.replace(/[\\/]+$/, "");
54525
54851
  return normalized.replace(/^([A-Za-z]):/, "$1").replace(/[\\/]/g, "-");
54526
54852
  }, claudeHostSessionPath = (cwd, id3, projectsDir) => {
54527
- const base = projectsDir ?? join14(process.env.HOME ?? "~", ".claude", "projects");
54528
- return join14(base, encodeProjectPath(cwd), `${id3}.jsonl`);
54853
+ const base = projectsDir ?? join15(process.env.HOME ?? "~", ".claude", "projects");
54854
+ return join15(base, encodeProjectPath(cwd), `${id3}.jsonl`);
54529
54855
  }, claudeSandboxSessionPath = (cwd, id3, projectsDir) => posix4.join(projectsDir, encodeProjectPath(cwd), `${id3}.jsonl`), claudeSubagentsDirInSandbox = (cwd, id3, projectsDir) => posix4.join(projectsDir, encodeProjectPath(cwd), id3, "subagents"), claudeSubagentsDirOnHost = (cwd, id3, projectsDir) => {
54530
- const base = projectsDir ?? join14(process.env.HOME ?? "~", ".claude", "projects");
54531
- return join14(base, encodeProjectPath(cwd), id3, "subagents");
54856
+ const base = projectsDir ?? join15(process.env.HOME ?? "~", ".claude", "projects");
54857
+ return join15(base, encodeProjectPath(cwd), id3, "subagents");
54532
54858
  }, listClaudeSubagentSessionsInSandbox = async (cwd, id3, handle, sandboxProjectsDir) => {
54533
54859
  const dir = claudeSubagentsDirInSandbox(cwd, id3, sandboxProjectsDir);
54534
54860
  const result = await handle.exec(`find ${JSON.stringify(dir)} -type f -name ${JSON.stringify("agent-*.jsonl")} 2>/dev/null`);
@@ -54540,7 +54866,7 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54540
54866
  return stdout.split(`
54541
54867
  `).filter((line) => line !== "");
54542
54868
  }, findClaudeSessionOnHost = async (id3, projectsDir) => {
54543
- const root = projectsDir ?? join14(process.env.HOME ?? "~", ".claude", "projects");
54869
+ const root = projectsDir ?? join15(process.env.HOME ?? "~", ".claude", "projects");
54544
54870
  let entries;
54545
54871
  try {
54546
54872
  entries = await readdir2(root, { withFileTypes: true });
@@ -54550,7 +54876,7 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54550
54876
  for (const entry of entries) {
54551
54877
  if (!entry.isDirectory())
54552
54878
  continue;
54553
- const candidate = join14(root, entry.name, `${id3}.jsonl`);
54879
+ const candidate = join15(root, entry.name, `${id3}.jsonl`);
54554
54880
  if (await pathExists(candidate)) {
54555
54881
  return { path: candidate, searchedRoot: root };
54556
54882
  }
@@ -54586,7 +54912,7 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54586
54912
  return;
54587
54913
  }
54588
54914
  for (const entry of entries) {
54589
- const child = join14(dir, entry.name);
54915
+ const child = join15(dir, entry.name);
54590
54916
  if (entry.isFile() && isCodexSessionFilename(entry.name, id3)) {
54591
54917
  return child;
54592
54918
  }
@@ -54600,11 +54926,11 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54600
54926
  };
54601
54927
  return visit(rootDir);
54602
54928
  }, findCodexSessionOnHost = async (id3, sessionsDir) => {
54603
- const root = sessionsDir ?? join14(process.env.HOME ?? "~", ".codex", "sessions");
54929
+ const root = sessionsDir ?? join15(process.env.HOME ?? "~", ".codex", "sessions");
54604
54930
  const path2 = await findCodexSessionPath(root, id3);
54605
54931
  return { path: path2, searchedRoot: root };
54606
54932
  }, locateCodexHostSession = async (id3, sessionsDir) => {
54607
- const root = sessionsDir ?? join14(process.env.HOME ?? "~", ".codex", "sessions");
54933
+ const root = sessionsDir ?? join15(process.env.HOME ?? "~", ".codex", "sessions");
54608
54934
  const path2 = await findCodexSessionPath(root, id3);
54609
54935
  if (!path2)
54610
54936
  throw new Error(`session ${id3} not found in ${root}`);
@@ -54680,7 +55006,7 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54680
55006
  } catch {}
54681
55007
  return [];
54682
55008
  }, CURSOR_PRINT_PROMPT_MAX_BYTES, readSandboxFile = async (handle, sandboxPath, tag) => {
54683
- const tmpPath = join14(tmpdir4(), `sandcastle-${tag}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
55009
+ const tmpPath = join15(tmpdir4(), `sandcastle-${tag}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
54684
55010
  await handle.copyFileOut(sandboxPath, tmpPath);
54685
55011
  try {
54686
55012
  return await readFile3(tmpPath, "utf-8");
@@ -54688,7 +55014,7 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54688
55014
  await rm3(tmpPath, { force: true }).catch(() => {});
54689
55015
  }
54690
55016
  }, writeSandboxFile = async (handle, sandboxPath, content, tag) => {
54691
- const tmpPath = join14(tmpdir4(), `sandcastle-${tag}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
55017
+ const tmpPath = join15(tmpdir4(), `sandcastle-${tag}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
54692
55018
  await writeFile3(tmpPath, content);
54693
55019
  try {
54694
55020
  await handle.exec(`mkdir -p ${JSON.stringify(posix4.dirname(sandboxPath))}`);
@@ -54738,7 +55064,7 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54738
55064
  sourcePath: sandboxSubagentPath,
54739
55065
  fromCwd: sandboxCwd,
54740
55066
  toCwd: hostCwd,
54741
- destPath: join14(hostSubagentsDir, posix4.basename(sandboxSubagentPath)),
55067
+ destPath: join15(hostSubagentsDir, posix4.basename(sandboxSubagentPath)),
54742
55068
  tag: "claude-sub"
54743
55069
  });
54744
55070
  } catch (err) {
@@ -54775,8 +55101,8 @@ Emit only a corrected <${error.tag}> block. Do not change files or run commands.
54775
55101
  const located = await locateCodexSandboxSession(sessionId, handle, sandboxSessionsDir);
54776
55102
  const jsonl = await readSandboxFile(handle, located.path, "codex-cap");
54777
55103
  const rewritten = transferCodexSession(jsonl, sandboxCwd, hostCwd);
54778
- const root = hostSessionsDir ?? join14(process.env.HOME ?? "~", ".codex", "sessions");
54779
- const target = join14(root, located.relativePath);
55104
+ const root = hostSessionsDir ?? join15(process.env.HOME ?? "~", ".codex", "sessions");
55105
+ const target = join15(root, located.relativePath);
54780
55106
  await mkdir3(dirname7(target), { recursive: true });
54781
55107
  await writeFile3(target, rewritten);
54782
55108
  capturedPaths.set(sessionId, target);
@@ -54937,6 +55263,7 @@ var init_dist = __esm(() => {
54937
55263
  init_chunk_BIWNFKGV();
54938
55264
  init_chunk_62WN33RK();
54939
55265
  init_chunk_NGBM7T3E();
55266
+ init_chunk_NGBM7T3E();
54940
55267
  createRequire8(import.meta.url);
54941
55268
  AgentStreamEmitter = class extends Context_exports.Tag("AgentStreamEmitter")() {
54942
55269
  };
@@ -54982,7 +55309,7 @@ var init_dist = __esm(() => {
54982
55309
  });
54983
55310
 
54984
55311
  // src/services/settings.ts
54985
- import { existsSync as existsSync14, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
55312
+ import { existsSync as existsSync15, mkdirSync as mkdirSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
54986
55313
  import { dirname as dirname8 } from "path";
54987
55314
  import * as z3 from "zod";
54988
55315
  import { eq as eq10 } from "drizzle-orm";
@@ -54990,10 +55317,10 @@ function hostDefaults(logicalCpus) {
54990
55317
  return { ...DEFAULTS, burnConcurrency: resolveDefaultBurnConcurrency(logicalCpus) };
54991
55318
  }
54992
55319
  function readRawConfig(configFile) {
54993
- if (!existsSync14(configFile))
55320
+ if (!existsSync15(configFile))
54994
55321
  return {};
54995
55322
  try {
54996
- const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
55323
+ const parsed = JSON.parse(readFileSync8(configFile, "utf8"));
54997
55324
  if (typeof parsed !== "object" || parsed === null)
54998
55325
  return {};
54999
55326
  return foldLegacyModelConfig(parsed);
@@ -55020,6 +55347,7 @@ function projectOverrides(ctx, projectId) {
55020
55347
  const row = ctx.db.select({
55021
55348
  model: projects.model,
55022
55349
  sandbox: projects.sandbox,
55350
+ sandboxImage: projects.sandboxImage,
55023
55351
  sessionBranch: projects.sessionBranch,
55024
55352
  devCommand: projects.devCommand,
55025
55353
  setupCommand: projects.setupCommand,
@@ -55033,6 +55361,7 @@ function projectOverrides(ctx, projectId) {
55033
55361
  return {
55034
55362
  model: row?.model ?? null,
55035
55363
  sandbox: row?.sandbox ?? null,
55364
+ sandboxImage: row?.sandboxImage ?? null,
55036
55365
  sessionBranch: row?.sessionBranch ?? null,
55037
55366
  devCommand: row?.devCommand ?? null,
55038
55367
  setupCommand: row?.setupCommand ?? null,
@@ -55235,6 +55564,7 @@ var init_settings = __esm(() => {
55235
55564
  key: "sandboxImage",
55236
55565
  configKey: "sandboxImage",
55237
55566
  envVar: "RUNCASTLE_SANDBOX_IMAGE",
55567
+ projectColumn: "sandboxImage",
55238
55568
  restartRequired: false,
55239
55569
  valueSchema: z3.string().min(1),
55240
55570
  parseEnv: idEnv
@@ -55362,9 +55692,8 @@ var init_settings = __esm(() => {
55362
55692
  });
55363
55693
 
55364
55694
  // src/services/setup.ts
55365
- import { copyFileSync as copyFileSync2, existsSync as existsSync15, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync4, writeFileSync as writeFileSync6 } from "fs";
55366
- import { dirname as dirname9, join as join15 } from "path";
55367
- import { fileURLToPath as fileURLToPath6 } from "url";
55695
+ import { copyFileSync as copyFileSync2, existsSync as existsSync16, mkdirSync as mkdirSync8, readFileSync as readFileSync9, readdirSync as readdirSync4, writeFileSync as writeFileSync6 } from "fs";
55696
+ import { dirname as dirname9, join as join16 } from "path";
55368
55697
  async function writeGitIdentity(exec3, identity4) {
55369
55698
  const name = identity4.name.trim();
55370
55699
  const email = identity4.email.trim();
@@ -55464,55 +55793,26 @@ async function resolveRuntime(exec3, preferred) {
55464
55793
  return other;
55465
55794
  return preferred;
55466
55795
  }
55467
- function resolveSandcastleBin() {
55468
- try {
55469
- const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
55470
- let dir = dirname9(fileURLToPath6(mainUrl));
55471
- for (let hops = 0;hops < 6; hops++) {
55472
- const manifestPath = join15(dir, "package.json");
55473
- if (existsSync15(manifestPath)) {
55474
- const manifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
55475
- if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
55476
- return join15(dir, manifest.bin.sandcastle);
55477
- }
55478
- }
55479
- const parent = dirname9(dir);
55480
- if (parent === dir)
55481
- break;
55482
- dir = parent;
55483
- }
55484
- return null;
55485
- } catch {
55486
- return null;
55487
- }
55488
- }
55489
- function terminalSpec(kind, opts) {
55796
+ function terminalSpec(kind) {
55490
55797
  if (kind === "setup-token")
55491
55798
  return { cmd: "claude", args: ["setup-token"] };
55492
55799
  for (const spec of Object.values(RUNTIME_SPECS)) {
55493
55800
  if (kind === LOGIN_TERMINAL_KIND[spec.runtime])
55494
55801
  return { cmd: spec.bin, args: spec.loginArgs };
55495
55802
  }
55496
- if (!opts.sandcastleBin) {
55497
- throw new NotFoundError("The bundled sandcastle CLI (@ai-hero/sandcastle) could not be located \u2014 reinstall runcastle.");
55498
- }
55499
- return {
55500
- cmd: "node",
55501
- args: [opts.sandcastleBin, opts.runtime, "build-image", "--image-name", opts.imageName]
55502
- };
55803
+ throw new InvalidInputError(`no terminal command for ${kind}`);
55503
55804
  }
55504
55805
  function refreshSandcastleConfig(templateDir, targetDir) {
55505
- const dir = join15(targetDir, ".sandcastle");
55806
+ const dir = join16(targetDir, ".sandcastle");
55506
55807
  mkdirSync8(dir, { recursive: true });
55507
55808
  for (const name of readdirSync4(templateDir)) {
55508
- copyFileSync2(join15(templateDir, name), join15(dir, name));
55809
+ copyFileSync2(join16(templateDir, name), join16(dir, name));
55509
55810
  }
55510
55811
  return dir;
55511
55812
  }
55512
55813
  function prepareSandboxBuildContext(templateDir = sandcastleTemplateDir(), target = sandboxBuildDir()) {
55513
55814
  mkdirSync8(target, { recursive: true });
55514
- refreshSandcastleConfig(templateDir, target);
55515
- return target;
55815
+ return refreshSandcastleConfig(templateDir, target);
55516
55816
  }
55517
55817
  function createCredentialVerifier(exec3, runtime5 = DEFAULT_RUNTIME) {
55518
55818
  const spec = RUNTIME_SPECS[runtime5];
@@ -55547,7 +55847,7 @@ function createCredentialVerifier(exec3, runtime5 = DEFAULT_RUNTIME) {
55547
55847
  function fileAfkTokenIo(exec3, runtime5 = DEFAULT_RUNTIME) {
55548
55848
  const path2 = envPath();
55549
55849
  return {
55550
- read: () => existsSync15(path2) ? readFileSync7(path2, "utf8") : "",
55850
+ read: () => existsSync16(path2) ? readFileSync9(path2, "utf8") : "",
55551
55851
  write: (content) => {
55552
55852
  mkdirSync8(dirname9(path2), { recursive: true });
55553
55853
  writeFileSync6(path2, content, "utf8");
@@ -55791,13 +56091,13 @@ var init_no_sandbox = __esm(() => {
55791
56091
  });
55792
56092
 
55793
56093
  // src/workflows/review-ticket.ts
55794
- import { existsSync as existsSync16, mkdirSync as mkdirSync9, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
55795
- import { delimiter, join as join16 } from "path";
56094
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync10, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
56095
+ import { delimiter, join as join17 } from "path";
55796
56096
  function reviewTemplatePath(ticket) {
55797
56097
  return burnerAssetPath(ticket?.passKind === "verification" ? "verify-fixes.md" : "review-ticket.md");
55798
56098
  }
55799
56099
  function renderReviewPrompt(templateOrTicket, values) {
55800
- const template = typeof templateOrTicket === "string" ? templateOrTicket : readFileSync8(reviewTemplatePath(templateOrTicket), "utf8");
56100
+ const template = typeof templateOrTicket === "string" ? templateOrTicket : readFileSync10(reviewTemplatePath(templateOrTicket), "utf8");
55801
56101
  return renderTemplate(template, values);
55802
56102
  }
55803
56103
  function findOnPath(bin, env = process.env, platform = process.platform) {
@@ -55805,8 +56105,8 @@ function findOnPath(bin, env = process.env, platform = process.platform) {
55805
56105
  const suffixes = platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
55806
56106
  for (const dir of dirs) {
55807
56107
  for (const suffix of suffixes) {
55808
- const candidate = join16(dir, `${bin}${suffix}`);
55809
- if (existsSync16(candidate))
56108
+ const candidate = join17(dir, `${bin}${suffix}`);
56109
+ if (existsSync17(candidate))
55810
56110
  return candidate;
55811
56111
  }
55812
56112
  }
@@ -55828,7 +56128,7 @@ function buildDriveAvailability(browserPath, devCommand, inheritedMode) {
55828
56128
  }
55829
56129
  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.";
55830
56130
  }
55831
- function inheritedReviewMode(verifiedTicketId, fileExists2 = existsSync16) {
56131
+ function inheritedReviewMode(verifiedTicketId, fileExists2 = existsSync17) {
55832
56132
  return verifiedTicketId && fileExists2(reviewWalkthroughPath(verifiedTicketId)) ? "drive" : "gates";
55833
56133
  }
55834
56134
  function buildDriveInstructions(instructions) {
@@ -55867,18 +56167,18 @@ function writeReviewArtifacts(ticket, runId, config2) {
55867
56167
  const dir = reviewDir(ticket.id);
55868
56168
  rmSync2(dir, { recursive: true, force: true });
55869
56169
  mkdirSync9(dir, { recursive: true });
55870
- const mcpConfigPath = join16(dir, "mcp.json");
56170
+ const mcpConfigPath = join17(dir, "mcp.json");
55871
56171
  const mcpConfig = renderRunMcpConfig(runId, config2);
55872
56172
  writeFileSync7(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), "utf8");
55873
56173
  return {
55874
56174
  dir,
55875
56175
  mcp: { path: mcpConfigPath, config: mcpConfig },
55876
- digestPath: join16(dir, "DIGEST.md"),
55877
- blockedPath: join16(dir, "BLOCKED.md"),
56176
+ digestPath: join17(dir, "DIGEST.md"),
56177
+ blockedPath: join17(dir, "BLOCKED.md"),
55878
56178
  walkthroughPath: reviewWalkthroughPath(ticket.id)
55879
56179
  };
55880
56180
  }
55881
- function shouldStopAfterDigest(iteration, digestPath, fileExists2 = existsSync16) {
56181
+ function shouldStopAfterDigest(iteration, digestPath, fileExists2 = existsSync17) {
55882
56182
  return iteration >= 2 && fileExists2(digestPath);
55883
56183
  }
55884
56184
  async function releaseDriveQuietly() {
@@ -55956,7 +56256,7 @@ async function reviewTicketOutcome(ctx, ticket, deps, timer) {
55956
56256
  maxIterations: deps.config.burnMaxIterations,
55957
56257
  logging: {
55958
56258
  type: "file",
55959
- path: join16(logsDir(), `review-${feature.id}-${ticket.seq}.log`),
56259
+ path: join17(logsDir(), `review-${feature.id}-${ticket.seq}.log`),
55960
56260
  onAgentStreamEvent: onStreamEvent
55961
56261
  }
55962
56262
  };
@@ -56145,6 +56445,7 @@ var dockerExec = (args2) => Effect_exports.async((resume2) => {
56145
56445
  const unregisterShutdown = registerShutdown(removeContainerSync);
56146
56446
  const handle = {
56147
56447
  worktreePath,
56448
+ maxOutputTailChars,
56148
56449
  exec: (command, opts) => {
56149
56450
  const effectiveCommand = opts?.sudo ? `sudo ${command}` : command;
56150
56451
  const args2 = ["exec"];
@@ -56405,6 +56706,7 @@ var podman = (options) => {
56405
56706
  const unregisterShutdown = registerShutdown(removeContainerSync);
56406
56707
  const handle = {
56407
56708
  worktreePath,
56709
+ maxOutputTailChars,
56408
56710
  exec: (command, opts) => {
56409
56711
  const effectiveCommand = opts?.sudo ? `sudo ${command}` : command;
56410
56712
  const args2 = ["exec"];
@@ -56558,13 +56860,47 @@ var init_podman = __esm(() => {
56558
56860
  });
56559
56861
 
56560
56862
  // src/workflows/ticket-burner.ts
56561
- import { existsSync as existsSync17, mkdirSync as mkdirSync10, readFileSync as readFileSync9, readdirSync as readdirSync5, rmSync as rmSync3 } from "fs";
56562
- import { dirname as dirname10, join as join17, posix as posixPath, win32 as winPath } from "path";
56563
- import { fileURLToPath as fileURLToPath7 } from "url";
56863
+ import { existsSync as existsSync18, mkdirSync as mkdirSync10, readFileSync as readFileSync11, readdirSync as readdirSync5, rmSync as rmSync3 } from "fs";
56864
+ import { dirname as dirname10, join as join18, posix as posixPath, win32 as winPath } from "path";
56865
+ import { fileURLToPath as fileURLToPath6 } from "url";
56564
56866
  function missingImageRuntimeMessage(runtime5, image) {
56565
56867
  const binary = RUNTIME_BINARY[runtime5];
56566
56868
  return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 Burns (Rebuild image).`;
56567
56869
  }
56870
+ function extractCommandNames(command) {
56871
+ const names = [];
56872
+ for (const segment of (command ?? "").split(COMMAND_SEPARATORS)) {
56873
+ const words = segment.replace(/[(){}'"`]/g, " ").split(/\s+/).filter(Boolean);
56874
+ const word = words.find((w) => !ASSIGNMENT_PREFIX.test(w)) ?? "";
56875
+ if (!PLAIN_COMMAND_NAME.test(word) || NON_COMMAND_WORDS.has(word))
56876
+ continue;
56877
+ if (!names.includes(word))
56878
+ names.push(word);
56879
+ }
56880
+ return names;
56881
+ }
56882
+ function preflightCommandNames(input) {
56883
+ return [
56884
+ ...new Set([
56885
+ input.agentBinary,
56886
+ ...extractCommandNames(input.setupCommand),
56887
+ ...extractCommandNames(input.verifyCommands)
56888
+ ])
56889
+ ].sort();
56890
+ }
56891
+ function buildToolchainProbeArgs(image, names) {
56892
+ const script = `for c in ${names.join(" ")}; do command -v "$c" >/dev/null 2>&1 || echo "$c"; done`;
56893
+ return ["run", "--rm", "--entrypoint", "sh", image, "-c", script];
56894
+ }
56895
+ function parseMissingCommands(stdout, names) {
56896
+ const absent = new Set(stdout.split(`
56897
+ `).map((line) => line.trim()));
56898
+ return names.filter((name) => absent.has(name));
56899
+ }
56900
+ function missingToolchainMessage(names, image) {
56901
+ const one = names.length === 1;
56902
+ return `${names.join(", ")} ${one ? "is" : "are"} not installed in image ${image} \u2014 add ${one ? "it" : "them"} to .runcastle/sandbox/Dockerfile and rebuild.`;
56903
+ }
56568
56904
  function burnAuthReady(runtime5, token, loggedIn = codexLoggedIn) {
56569
56905
  if (token !== undefined)
56570
56906
  return true;
@@ -56580,6 +56916,9 @@ function verificationDue(tickets2, runTicketIds) {
56580
56916
  const verifies = [...reviews].reverse().find((ticket) => ticket.status === "done") ?? null;
56581
56917
  if (lastReview?.status === "failed")
56582
56918
  return { due: false, landed: [], verifies };
56919
+ const failed = inRun.some((ticket) => !isReviewTicket(ticket) && ticket.status === "failed");
56920
+ if (failed)
56921
+ return { due: false, landed: [], verifies };
56583
56922
  const cutoff = verifies?.completedAt ?? Number.NEGATIVE_INFINITY;
56584
56923
  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));
56585
56924
  return { due: landed.length > 0, landed, verifies };
@@ -56721,10 +57060,10 @@ _(The "${DROPPED_MAP_SECTIONS.join('" and "')}" sections are omitted here: they
56721
57060
  }
56722
57061
  function buildProjectStandards(repoPath) {
56723
57062
  const present = [];
56724
- if (existsSync17(join17(repoPath, AGENT_CONVENTIONS_FILE))) {
57063
+ if (existsSync18(join18(repoPath, AGENT_CONVENTIONS_FILE))) {
56725
57064
  present.push("`CLAUDE.md` \u2014 this repo's agent-facing conventions, and the highest authority on them");
56726
57065
  }
56727
- if (existsSync17(join17(repoPath, CHARTER_FILE))) {
57066
+ if (existsSync18(join18(repoPath, CHARTER_FILE))) {
56728
57067
  present.push(`\`${CHARTER_FILE}\` \u2014 the project charter: what this codebase is for`);
56729
57068
  }
56730
57069
  const adrs = listLiveAdrs(repoPath).map((a) => a.relPath);
@@ -56755,7 +57094,7 @@ function buildDriveNotes(settings) {
56755
57094
  add5("stop", settings.driveStopCommand);
56756
57095
  add5("dev server", settings.devCommand);
56757
57096
  add5("db reset", settings.dbResetCommand);
56758
- const hasDir = existsSync17(join17(settings.repoPath, ".runcastle"));
57097
+ const hasDir = existsSync18(join18(settings.repoPath, ".runcastle"));
56759
57098
  if (configured.length === 0 && !hasDir) {
56760
57099
  return [
56761
57100
  "This project has no test-drive machinery configured, so there is nothing here for you to keep in step \u2014 do not invent any.",
@@ -56975,10 +57314,10 @@ function parseSetupMarker(text3) {
56975
57314
  return { cold: m[1] === "1", syncMs: Number(m[2]), installMs: Number(m[3]) };
56976
57315
  }
56977
57316
  function takeSetupMarker(worktreePath) {
56978
- const file = join17(worktreePath, SETUP_MARKER_FILE);
57317
+ const file = join18(worktreePath, SETUP_MARKER_FILE);
56979
57318
  let text3;
56980
57319
  try {
56981
- text3 = readFileSync9(file, "utf8");
57320
+ text3 = readFileSync11(file, "utf8");
56982
57321
  } catch {
56983
57322
  return;
56984
57323
  }
@@ -57051,7 +57390,7 @@ function attachedNoteIds(context5) {
57051
57390
  return ids3;
57052
57391
  }
57053
57392
  function attachmentSources(context5) {
57054
- return attachedNoteIds(context5).map(annotationPath).filter((p) => existsSync17(p));
57393
+ return attachedNoteIds(context5).map(annotationPath).filter((p) => existsSync18(p));
57055
57394
  }
57056
57395
  function buildAttachmentCopyCommands(sources, platform = process.platform) {
57057
57396
  if (sources.length === 0)
@@ -57071,7 +57410,7 @@ function buildAttachmentCopyCommands(sources, platform = process.platform) {
57071
57410
  ];
57072
57411
  }
57073
57412
  async function clearAttachments(workspacePath, repoPath) {
57074
- rmSync3(join17(workspacePath, ATTACHMENTS_DIR), { recursive: true, force: true });
57413
+ rmSync3(join18(workspacePath, ATTACHMENTS_DIR), { recursive: true, force: true });
57075
57414
  await unexcludePath(repoPath, ATTACHMENTS_PREFIX);
57076
57415
  }
57077
57416
  function buildVerifyNotes(config2) {
@@ -57093,12 +57432,12 @@ function buildVerifyNotes(config2) {
57093
57432
  `);
57094
57433
  }
57095
57434
  function readRepoToolchain(repoPath) {
57096
- const pkgPath = join17(repoPath, "package.json");
57097
- const hasPackageJson = existsSync17(pkgPath);
57435
+ const pkgPath = join18(repoPath, "package.json");
57436
+ const hasPackageJson = existsSync18(pkgPath);
57098
57437
  let packageManagerField;
57099
57438
  if (hasPackageJson) {
57100
57439
  try {
57101
- const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
57440
+ const pkg = JSON.parse(readFileSync11(pkgPath, "utf8"));
57102
57441
  if (typeof pkg.packageManager === "string")
57103
57442
  packageManagerField = pkg.packageManager;
57104
57443
  } catch {}
@@ -57107,10 +57446,10 @@ function readRepoToolchain(repoPath) {
57107
57446
  hasPackageJson,
57108
57447
  ...packageManagerField !== undefined ? { packageManagerField } : {},
57109
57448
  lockfiles: {
57110
- bun: existsSync17(join17(repoPath, "bun.lock")) || existsSync17(join17(repoPath, "bun.lockb")),
57111
- pnpm: existsSync17(join17(repoPath, "pnpm-lock.yaml")),
57112
- yarn: existsSync17(join17(repoPath, "yarn.lock")),
57113
- npm: existsSync17(join17(repoPath, "package-lock.json"))
57449
+ bun: existsSync18(join18(repoPath, "bun.lock")) || existsSync18(join18(repoPath, "bun.lockb")),
57450
+ pnpm: existsSync18(join18(repoPath, "pnpm-lock.yaml")),
57451
+ yarn: existsSync18(join18(repoPath, "yarn.lock")),
57452
+ npm: existsSync18(join18(repoPath, "package-lock.json"))
57114
57453
  }
57115
57454
  };
57116
57455
  }
@@ -57246,19 +57585,36 @@ function isMergeConflictError(err) {
57246
57585
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
57247
57586
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
57248
57587
  }
57588
+ function refusalNamingCredential(refusal) {
57589
+ return new RegExp(`(?:${refusal}[^\\n]*${CREDENTIAL_SUBJECT}|${CREDENTIAL_SUBJECT}[^\\n]*${refusal})`, "i");
57590
+ }
57591
+ function missingCommandRegex(name) {
57592
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57593
+ return new RegExp(`\\b${escaped}\\b[^\\n]*(?:exited with code 127|command not found|not found|no such file or directory)`, "i");
57594
+ }
57249
57595
  function missingAgentBinaryMessage(err, runtime5, image) {
57250
57596
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
57251
57597
  const binary = AGENT_BINARY[runtime5];
57252
- const missing = new RegExp(`\\b${binary}\\b[^\\n]*(?:exited with code 127|command not found|not found|no such file or directory)`, "i");
57253
- if (!missing.test(msg))
57598
+ if (!missingCommandRegex(binary).test(msg))
57254
57599
  return;
57255
57600
  return `${binary} is not installed in image ${image} \u2014 the image predates the burner Dockerfile. Rebuild it from Settings \u2192 Burns (Rebuild image).`;
57256
57601
  }
57257
- function classifyTicketRunError(err, runtime5) {
57602
+ function missingSetupBinaryMessage(err, setupCommand, image) {
57603
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
57604
+ const missing = extractCommandNames(setupCommand).find((name) => missingCommandRegex(name).test(msg));
57605
+ return missing ? missingToolchainMessage([missing], image) : undefined;
57606
+ }
57607
+ function classifyTicketRunError(err, runtime5, setupCommand) {
57258
57608
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
57259
57609
  const runtimes = runtime5 ? [runtime5] : ["claude-code", "codex"];
57260
57610
  const forRuntimes = (table) => runtimes.flatMap((r) => table[r]);
57261
57611
  if (runtimes.some((r) => missingAgentBinaryMessage(msg, r, "image")))
57612
+ return "run-fatal";
57613
+ if ([...RUN_FATAL_ERROR_PATTERNS, ...forRuntimes(RUNTIME_RUN_FATAL_ERROR_PATTERNS)].some((p) => p.test(msg)))
57614
+ return "run-fatal";
57615
+ if (missingSetupBinaryMessage(msg, setupCommand, "image"))
57616
+ return "fatal";
57617
+ if (MISSING_COMMAND_PATTERNS.some((p) => p.test(msg)))
57262
57618
  return "fatal";
57263
57619
  if ([...FATAL_ERROR_PATTERNS, ...forRuntimes(RUNTIME_FATAL_ERROR_PATTERNS)].some((p) => p.test(msg)))
57264
57620
  return "fatal";
@@ -57300,11 +57656,20 @@ function delayUnlessAborted(ms, signal) {
57300
57656
  signal.addEventListener("abort", onAbort, { once: true });
57301
57657
  });
57302
57658
  }
57303
- async function stopTicketRun(ticketId) {
57659
+ function stopTicketRun(ticketId) {
57660
+ return abortTicketRun(ticketId, new Error("ticket stopped by user"));
57661
+ }
57662
+ function haltTicketRun(ticketId, headline) {
57663
+ return abortTicketRun(ticketId, new RunHaltedAbort(headline));
57664
+ }
57665
+ function ticketStopReason(reason) {
57666
+ return reason instanceof RunHaltedAbort ? `stopped: run halted (${reason.headline})` : "stopped by user";
57667
+ }
57668
+ async function abortTicketRun(ticketId, reason) {
57304
57669
  const controller = activeTicketAborts.get(ticketId);
57305
57670
  if (!controller)
57306
57671
  return { stopped: false, confirmed: true };
57307
- controller.abort(new Error("ticket stopped by user"));
57672
+ controller.abort(reason);
57308
57673
  const { confirmed } = await killRegistry().killAndWait(ticketId);
57309
57674
  return { stopped: true, confirmed };
57310
57675
  }
@@ -57368,11 +57733,7 @@ function errorHeadline(s) {
57368
57733
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
57369
57734
  return causes.at(-1) ?? lines[0] ?? "";
57370
57735
  }
57371
- function failedBlockerNote(seqs) {
57372
- const list = [...seqs].sort((a, b) => a - b).join(", ");
57373
- return `> Reviewed with failed implementation ticket(s): ${list}.`;
57374
- }
57375
- async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
57736
+ async function burnTickets(ctx, tickets2, execute, concurrency = 1, ticketRuntime) {
57376
57737
  const width = Math.max(1, Math.floor(concurrency));
57377
57738
  const scheduled = [...tickets2];
57378
57739
  const bySeq = indexBySeq(scheduled);
@@ -57380,10 +57741,23 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
57380
57741
  const pending3 = new Set(scheduled.filter((t) => t.status === "pending").map((t) => t.seq));
57381
57742
  const inFlight = new Map;
57382
57743
  const digests = [];
57744
+ const deferred = [];
57745
+ const collapsed = [];
57383
57746
  let admitted = 0;
57384
57747
  let verificationChecked = false;
57385
- const satisfied = (t, s) => s === "done" || s === "cancelled" || isReviewTicket(t) && s === "failed";
57748
+ let halted;
57749
+ const satisfied = (s) => s === "done" || s === "cancelled";
57386
57750
  const implementationsSettled = () => scheduled.every((t) => isReviewTicket(t) || !pending3.has(t.seq) && !inFlight.has(t.seq));
57751
+ const failedImplementations = () => scheduled.filter((t) => !isReviewTicket(t) && status3.get(t.seq) === "failed").map((t) => t.seq);
57752
+ const reviewGate = () => {
57753
+ if (!implementationsSettled())
57754
+ return "wait";
57755
+ if (failedImplementations().length > 0)
57756
+ return "defer";
57757
+ const implementations = scheduled.filter((t) => !isReviewTicket(t));
57758
+ const landed = implementations.some((t) => status3.get(t.seq) === "done");
57759
+ return implementations.length > 0 && !landed ? "collapsed" : "ready";
57760
+ };
57387
57761
  const admitNewTickets = () => {
57388
57762
  const added = [];
57389
57763
  for (const t of ctx.listTickets?.() ?? []) {
@@ -57458,20 +57832,18 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
57458
57832
  for (const b of t.blockedBy) {
57459
57833
  const present = bySeq.has(b);
57460
57834
  const bs = present ? status3.get(b) : undefined;
57461
- if (!present || bs === "failed" && !satisfied(t, bs))
57835
+ if (!present)
57836
+ return { blockedBy: b, present };
57837
+ if (bs === "failed" && !isReviewTicket(t))
57462
57838
  return { blockedBy: b, present };
57463
57839
  }
57464
- if (isReviewTicket(t) && !implementationsSettled())
57465
- return "wait";
57466
- return t.blockedBy.every((b) => satisfied(t, status3.get(b))) ? "ready" : "wait";
57840
+ if (isReviewTicket(t))
57841
+ return reviewGate();
57842
+ return t.blockedBy.every((b) => satisfied(status3.get(b))) ? "ready" : "wait";
57467
57843
  };
57468
57844
  const harvestDigest2 = (t, digest) => {
57469
- const failed = isReviewTicket(t) ? scheduled.filter((x) => !isReviewTicket(x) && status3.get(x.seq) === "failed").map((x) => x.seq) : [];
57470
- const body = [failed.length > 0 ? failedBlockerNote(failed) : undefined, digest].filter(Boolean).join(`
57471
-
57472
- `);
57473
- if (body)
57474
- digests.push({ seq: t.seq, title: t.title, digest: body });
57845
+ if (digest)
57846
+ digests.push({ seq: t.seq, title: t.title, digest });
57475
57847
  };
57476
57848
  const failTicket = (seq2, error, extra, digest) => {
57477
57849
  const t = bySeq.get(seq2);
@@ -57484,10 +57856,34 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
57484
57856
  if (extra)
57485
57857
  ctx.emitEvent({ ...extra, ticketId: t.id });
57486
57858
  };
57859
+ const haltRun = async (t, outcome) => {
57860
+ const halt = {
57861
+ runtime: outcome.runtime,
57862
+ ticketSeq: t.seq,
57863
+ headline: errorHeadline(outcome.error)
57864
+ };
57865
+ halted = halt;
57866
+ ctx.emitEvent({
57867
+ type: "run.halted",
57868
+ message: `run halted: ${halt.headline}`,
57869
+ ticketId: t.id,
57870
+ data: { runtime: halt.runtime, ticketSeq: halt.ticketSeq }
57871
+ });
57872
+ const doomed = [...inFlight.keys()].filter((other) => other !== t.seq).map((other) => bySeq.get(other)).filter((x) => x !== undefined).filter((x) => ticketRuntime === undefined || ticketRuntime(x) === outcome.runtime);
57873
+ await Promise.all(doomed.map((x) => haltTicketRun(x.id, halt.headline)));
57874
+ };
57875
+ const refreshedForLaunch = (t) => {
57876
+ const fresh3 = ctx.listTickets?.().find((row) => row.id === t.id);
57877
+ if (!fresh3)
57878
+ return t;
57879
+ bySeq.set(t.seq, fresh3);
57880
+ return fresh3;
57881
+ };
57487
57882
  const runOne = async (seq2) => {
57488
- const t = bySeq.get(seq2);
57489
- if (!t)
57883
+ const snapshot = bySeq.get(seq2);
57884
+ if (!snapshot)
57490
57885
  return;
57886
+ const t = refreshedForLaunch(snapshot);
57491
57887
  status3.set(seq2, "burning");
57492
57888
  ctx.updateTicket(t.id, { status: "burning" });
57493
57889
  mirrorFinding(t, "fixing");
@@ -57530,35 +57926,67 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
57530
57926
  ticketId: t.id,
57531
57927
  data: { error: outcome.error }
57532
57928
  });
57929
+ if (outcome.runFatal && !halted) {
57930
+ await haltRun(t, { error: outcome.error, runtime: outcome.runFatal.runtime });
57931
+ }
57533
57932
  }
57534
57933
  if (isReviewTicket(t))
57535
57934
  admitNewTickets();
57536
57935
  };
57537
57936
  while (pending3.size > 0 || inFlight.size > 0 || !verificationChecked) {
57538
57937
  ctx.signal.throwIfAborted();
57539
- if (pending3.size === 0 && inFlight.size === 0) {
57938
+ if (halted && inFlight.size === 0)
57939
+ break;
57940
+ if (!halted && pending3.size === 0 && inFlight.size === 0) {
57540
57941
  appendVerificationIfDue();
57541
57942
  if (pending3.size === 0)
57542
57943
  break;
57543
57944
  }
57544
- for (const seq2 of [...pending3]) {
57945
+ for (const seq2 of halted ? [] : [...pending3]) {
57545
57946
  const st = readyState(seq2);
57546
- if (typeof st === "object") {
57547
- pending3.delete(seq2);
57548
- const t = bySeq.get(seq2);
57549
- const reason = st.present ? `blocked by failed ticket ${st.blockedBy}` : `blocked by missing ticket ${st.blockedBy}`;
57550
- failTicket(seq2, reason);
57947
+ if (st === "ready" || st === "wait")
57948
+ continue;
57949
+ pending3.delete(seq2);
57950
+ const t = bySeq.get(seq2);
57951
+ if (st === "defer") {
57952
+ deferred.push(seq2);
57551
57953
  if (t) {
57954
+ const failed = failedImplementations();
57552
57955
  ctx.emitEvent({
57553
- type: "ticket.blocked",
57554
- message: `ticket ${t.seq} ${reason}`,
57956
+ type: "ticket.deferred",
57957
+ message: `ticket ${t.seq} deferred to the next burn: implementation ticket(s) ${failed.join(", ")} failed \u2014 retry or cancel them, then burn again`,
57555
57958
  ticketId: t.id,
57556
- data: { reason, blockedBy: st.blockedBy }
57959
+ data: { failed }
57557
57960
  });
57558
57961
  }
57962
+ continue;
57963
+ }
57964
+ if (st === "collapsed") {
57965
+ collapsed.push(seq2);
57966
+ status3.set(seq2, "cancelled");
57967
+ if (t) {
57968
+ ctx.updateTicket(t.id, { status: "cancelled", error: NOTHING_LANDED });
57969
+ ctx.emitEvent({
57970
+ type: "ticket.cancelled",
57971
+ message: `ticket ${t.seq} cancelled: ${NOTHING_LANDED}`,
57972
+ ticketId: t.id,
57973
+ data: { reason: NOTHING_LANDED }
57974
+ });
57975
+ }
57976
+ continue;
57977
+ }
57978
+ const reason = st.present ? `blocked by failed ticket ${st.blockedBy}` : `blocked by missing ticket ${st.blockedBy}`;
57979
+ failTicket(seq2, reason);
57980
+ if (t) {
57981
+ ctx.emitEvent({
57982
+ type: "ticket.blocked",
57983
+ message: `ticket ${t.seq} ${reason}`,
57984
+ ticketId: t.id,
57985
+ data: { reason, blockedBy: st.blockedBy }
57986
+ });
57559
57987
  }
57560
57988
  }
57561
- while (inFlight.size < width) {
57989
+ while (!halted && inFlight.size < width) {
57562
57990
  const readySeq = [...pending3].find((seq2) => readyState(seq2) === "ready");
57563
57991
  if (readySeq === undefined)
57564
57992
  break;
@@ -57594,7 +58022,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
57594
58022
  for (const s of status3.values())
57595
58023
  if (s === "done")
57596
58024
  done10 += 1;
57597
- return { done: done10, admitted, digests };
58025
+ return { done: done10, admitted, digests, deferred, collapsed, ...halted ? { halted } : {} };
57598
58026
  }
57599
58027
  function composeRunDigest(entries) {
57600
58028
  if (entries.length === 0)
@@ -57627,18 +58055,18 @@ async function burnRun(ctx, deps) {
57627
58055
  return { status: "failed", summary: "burn aborted: auth token missing" };
57628
58056
  }
57629
58057
  if (deps.config.sandbox !== "noSandbox" && deps.exec) {
57630
- const image = resolveSandboxImage(deps.config);
58058
+ const image = resolveSandboxImage(deps.config, ctx.project);
57631
58059
  const binary = RUNTIME_BINARY[deps.runtime];
57632
- const probe = await deps.exec(deps.config.sandbox, [
57633
- "run",
57634
- "--rm",
57635
- "--entrypoint",
57636
- binary,
57637
- image,
57638
- "--version"
57639
- ]);
57640
- if (!probe.ok || probe.code !== 0) {
57641
- const message = missingImageRuntimeMessage(deps.runtime, image);
58060
+ const prepared = resolvePreparedSettings(deps.config, ctx.project);
58061
+ const names = preflightCommandNames({
58062
+ agentBinary: binary,
58063
+ setupCommand: resolveSetupCommand(readRepoToolchain(ctx.project.repoPath), prepared.setupCommand),
58064
+ verifyCommands: prepared.verifyCommands
58065
+ });
58066
+ const probe = await deps.exec(deps.config.sandbox, buildToolchainProbeArgs(image, names));
58067
+ const missing = probe.ok && probe.code === 0 ? parseMissingCommands(probe.stdout, names) : [binary];
58068
+ if (missing.length > 0) {
58069
+ const message = missing.includes(binary) ? missingImageRuntimeMessage(deps.runtime, image) : missingToolchainMessage(missing, image);
57642
58070
  ctx.emitEvent({ type: IMAGE_RUNTIME_MISSING_EVENT, message });
57643
58071
  return { status: "failed", summary: message };
57644
58072
  }
@@ -57653,12 +58081,22 @@ async function burnRun(ctx, deps) {
57653
58081
  });
57654
58082
  return { status: "failed", summary: `dependency cycle: ${path2}` };
57655
58083
  }
57656
- const { done: done10, admitted, digests } = await burnTickets(ctx, tickets2, gateTicketAuth(deps), deps.concurrency);
57657
- const total = burnable.length + admitted;
57658
- const summary = cancelled > 0 ? `${done10}/${total} tickets done (${cancelled} cancelled)` : `${done10}/${total} tickets done`;
57659
- ctx.emitEvent({ type: "burn.summary", message: summary, data: { done: done10, total, cancelled } });
58084
+ const { done: done10, admitted, digests, halted, deferred, collapsed } = await burnTickets(ctx, tickets2, gateTicketAuth(deps), deps.concurrency, deps.ticketRuntime);
58085
+ const total = burnable.length + admitted - collapsed.length;
58086
+ const cancelledCount = cancelled + collapsed.length;
58087
+ const counts = cancelledCount > 0 ? `${done10}/${total} tickets done (${cancelledCount} cancelled)` : `${done10}/${total} tickets done`;
58088
+ const summary = halted ? `run halted at ticket ${halted.ticketSeq}: ${halted.headline} \u2014 ${counts}` : deferred.length > 0 ? `${counts} \u2014 review deferred until the failed ticket(s) are retried or cancelled` : counts;
58089
+ ctx.emitEvent({
58090
+ type: "burn.summary",
58091
+ message: summary,
58092
+ data: { done: done10, total, cancelled: cancelledCount }
58093
+ });
57660
58094
  const digest = composeRunDigest(digests);
57661
- return { status: done10 === total ? "succeeded" : "failed", summary, ...digest ? { digest } : {} };
58095
+ return {
58096
+ status: !halted && done10 === total ? "succeeded" : "failed",
58097
+ summary,
58098
+ ...digest ? { digest } : {}
58099
+ };
57662
58100
  }
57663
58101
  function burnerTemplatePath() {
57664
58102
  return burnerAssetPath("implement-ticket.md");
@@ -57667,8 +58105,8 @@ function resolverTemplatePath() {
57667
58105
  return burnerAssetPath("resolve-conflict.md");
57668
58106
  }
57669
58107
  function burnerAssetPath(file) {
57670
- const here = dirname10(fileURLToPath7(import.meta.url));
57671
- return join17(resolveSkillsRoot(here), "burner", file);
58108
+ const here = dirname10(fileURLToPath6(import.meta.url));
58109
+ return join18(resolveSkillsRoot(here), "burner", file);
57672
58110
  }
57673
58111
  function listFeatureDocs(docsDir, prefix = "") {
57674
58112
  const out = [];
@@ -57677,7 +58115,7 @@ function listFeatureDocs(docsDir, prefix = "") {
57677
58115
  if (e.isFile() && e.name.endsWith(".md"))
57678
58116
  out.push(rel);
57679
58117
  else if (e.isDirectory() && !prefix)
57680
- out.push(...listFeatureDocs(join17(docsDir, e.name), rel));
58118
+ out.push(...listFeatureDocs(join18(docsDir, e.name), rel));
57681
58119
  }
57682
58120
  return out;
57683
58121
  }
@@ -57691,11 +58129,11 @@ function readDocsDigest(projectId, slug) {
57691
58129
  ...missing ? { missing } : {}
57692
58130
  });
57693
58131
  const worktree = worktreeDir(projectId, slug);
57694
- if (!existsSync17(worktree)) {
58132
+ if (!existsSync18(worktree)) {
57695
58133
  return done10("_No talk worktree on disk \u2014 docs digest skipped._", "no-worktree");
57696
58134
  }
57697
- const docsDir = join17(worktree, ...docsRel.split("/"));
57698
- if (!existsSync17(docsDir)) {
58135
+ const docsDir = join18(worktree, ...docsRel.split("/"));
58136
+ if (!existsSync18(docsDir)) {
57699
58137
  return done10("_No docs/features dir in the talk worktree \u2014 docs digest skipped._", "no-docs-dir");
57700
58138
  }
57701
58139
  const names = listFeatureDocs(docsDir).sort((a, b) => agentDigestDocOrder(a) - agentDigestDocOrder(b) || a.localeCompare(b));
@@ -57711,7 +58149,7 @@ function readDocsDigest(projectId, slug) {
57711
58149
  }
57712
58150
  let content;
57713
58151
  try {
57714
- content = readFileSync9(join17(docsDir, ...name.split("/")), "utf8");
58152
+ content = readFileSync11(join18(docsDir, ...name.split("/")), "utf8");
57715
58153
  } catch {
57716
58154
  continue;
57717
58155
  }
@@ -57757,10 +58195,10 @@ function readAgentFile(dirs, file) {
57757
58195
  for (const dir of dirs) {
57758
58196
  if (!dir)
57759
58197
  continue;
57760
- const p = join17(dir, file);
57761
- if (existsSync17(p)) {
58198
+ const p = join18(dir, file);
58199
+ if (existsSync18(p)) {
57762
58200
  try {
57763
- return readFileSync9(p, "utf8");
58201
+ return readFileSync11(p, "utf8");
57764
58202
  } catch {}
57765
58203
  }
57766
58204
  }
@@ -57828,8 +58266,8 @@ function buildAgentExtraArgs(runtime5, options) {
57828
58266
  function quoteArg(value) {
57829
58267
  return `"${value}"`;
57830
58268
  }
57831
- function selectSandbox(config2, mounts = [], env = {}, kill = {}) {
57832
- const imageOpts = buildSandboxOptions(config2, mounts, env, kill.containerName);
58269
+ function selectSandbox(config2, project3, mounts = [], env = {}, kill = {}) {
58270
+ const imageOpts = buildSandboxOptions(config2, project3, mounts, env, kill.containerName);
57833
58271
  switch (config2.sandbox) {
57834
58272
  case "docker":
57835
58273
  return docker(imageOpts);
@@ -57841,9 +58279,9 @@ function selectSandbox(config2, mounts = [], env = {}, kill = {}) {
57841
58279
  throw new Error(`no sandbox provider for sandbox "${config2.sandbox}" \u2014 refusing to run the agent unsandboxed`);
57842
58280
  }
57843
58281
  }
57844
- function buildSandboxOptions(config2, mounts = [], env = {}, containerName) {
58282
+ function buildSandboxOptions(config2, project3, mounts = [], env = {}, containerName) {
57845
58283
  return {
57846
- imageName: resolveSandboxImage(config2),
58284
+ imageName: resolveSandboxImage(config2, project3),
57847
58285
  ...mounts.length > 0 ? { mounts } : {},
57848
58286
  ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {},
57849
58287
  ...Object.keys(env).length > 0 ? { env } : {},
@@ -57904,7 +58342,7 @@ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolat
57904
58342
  const guardInstalled = config2.burnGuard && config2.sandbox !== "noSandbox";
57905
58343
  const prepared = resolvePreparedSettings(config2, project3);
57906
58344
  const verifyNotes = buildVerifyNotes(prepared);
57907
- const basePrompt = renderTicketPrompt(readFileSync9(burnerTemplatePath(), "utf8"), {
58345
+ const basePrompt = renderTicketPrompt(readFileSync11(burnerTemplatePath(), "utf8"), {
57908
58346
  WORKSPACE_NOTES: workspaceNotes,
57909
58347
  PROJECT_STANDARDS: blocks.projectStandards,
57910
58348
  FEATURE_BRIEF: featureBrief,
@@ -57932,10 +58370,10 @@ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolat
57932
58370
  const guardInstall = guardInstalled ? buildGuardInstallCommand(model.runtime) : undefined;
57933
58371
  const withPrelude = (setup) => chainSetupCommands(codexAuthMount ? buildCodexAuthCopyCommand() : undefined, guardInstall, setup);
57934
58372
  const agentOptions = guardInstalled ? { bypassHookTrust: true } : {};
57935
- const slotStamp = buildSlotStamp(resolveSandboxImage(config2), toolchain.packageManagerField);
58373
+ const slotStamp = buildSlotStamp(resolveSandboxImage(config2, project3), toolchain.packageManagerField);
57936
58374
  const setupHookFor = (branch) => withPrelude(slot !== undefined ? buildSlotSetupCommand(slot, branch, setupCommand, pm, slotStamp) : workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand);
57937
58375
  mkdirSync10(logsDir(), { recursive: true });
57938
- const logFilePath = join17(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
58376
+ const logFilePath = join18(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
57939
58377
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
57940
58378
  beginTranscript(ticket.id);
57941
58379
  const timer = createToolTimer();
@@ -58007,6 +58445,17 @@ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolat
58007
58445
  const preserveChain = (branch) => {
58008
58446
  ctx.updateTicket(ticket.id, { attemptBranch: branch });
58009
58447
  };
58448
+ const stoppedOutcome = (salvaged) => {
58449
+ const reason = ticketStopReason(ticketAbort.signal.reason);
58450
+ return {
58451
+ status: "failed",
58452
+ error: `${reason}${salvaged > 0 ? ` \u2014 ${salvaged} commit(s) preserved; retry to continue from them` : ""}`,
58453
+ event: {
58454
+ type: "ticket.stopped",
58455
+ message: `ticket ${ticket.seq} ${reason}${salvaged > 0 ? ` \u2014 ${salvaged} commit(s) preserved for retry` : ""}`
58456
+ }
58457
+ };
58458
+ };
58010
58459
  const discards = new Map;
58011
58460
  const discardWorktree = (branch) => {
58012
58461
  const pending3 = discards.get(branch) ?? cleanupBurnWorktree(project3.repoPath, branch);
@@ -58017,7 +58466,7 @@ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolat
58017
58466
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
58018
58467
  const containerName = burnContainerName(ctx.runId, ticket.seq, "resolve");
58019
58468
  const otherSide = await commitSummaries(project3.repoPath, input.branch, feature.branch);
58020
- const prompt = renderTemplate(readFileSync9(resolverTemplatePath(), "utf8"), {
58469
+ const prompt = renderTemplate(readFileSync11(resolverTemplatePath(), "utf8"), {
58021
58470
  TICKET_JSON: ticketJson,
58022
58471
  FEATURE_BRIEF: featureBrief,
58023
58472
  DOCS_DIGEST: docsDigest,
@@ -58046,7 +58495,7 @@ async function burnTicket(ctx, ticket, config2, token, model, land, ensureIsolat
58046
58495
  makeKillable(containerName);
58047
58496
  result = await run6({
58048
58497
  agent: buildBurnAgent(config2, token, model, agentOptions),
58049
- sandbox: selectSandbox(config2, mounts, sandboxEnv, killHandles(containerName)),
58498
+ sandbox: selectSandbox(config2, project3, mounts, sandboxEnv, killHandles(containerName)),
58050
58499
  cwd: project3.repoPath,
58051
58500
  prompt,
58052
58501
  branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
@@ -58179,7 +58628,7 @@ ${blocked3.trim()}`
58179
58628
  }
58180
58629
  const runOptions = {
58181
58630
  agent: buildBurnAgent(config2, token, model, agentOptions),
58182
- sandbox: selectSandbox(config2, mounts, sandboxEnv, killHandles(containerName)),
58631
+ sandbox: selectSandbox(config2, project3, mounts, sandboxEnv, killHandles(containerName)),
58183
58632
  cwd: project3.repoPath,
58184
58633
  prompt: retryNotes ? `${basePrompt}
58185
58634
 
@@ -58215,21 +58664,15 @@ ${retryNotes}` : basePrompt,
58215
58664
  if (ctx.signal.aborted)
58216
58665
  throw err;
58217
58666
  const msg = err instanceof Error ? err.message : String(err);
58218
- const missingBinary = missingAgentBinaryMessage(err, model.runtime, resolveSandboxImage(config2));
58667
+ const sandboxImage = resolveSandboxImage(config2, project3);
58668
+ const missingBinary = missingAgentBinaryMessage(err, model.runtime, sandboxImage) ?? missingSetupBinaryMessage(err, setupCommand, sandboxImage);
58219
58669
  const salvaged = await branchCommitsAhead(project3.repoPath, feature.branch, tempBranch);
58220
58670
  if (salvaged.length > 0)
58221
58671
  baseBranch = tempBranch;
58222
58672
  if (ticketAbort.signal.aborted) {
58223
58673
  if (salvaged.length > 0)
58224
58674
  preserveChain(tempBranch);
58225
- return {
58226
- status: "failed",
58227
- error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
58228
- event: {
58229
- type: "ticket.stopped",
58230
- message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
58231
- }
58232
- };
58675
+ return stoppedOutcome(salvaged.length);
58233
58676
  }
58234
58677
  if (isWorktreeTeardownError(err)) {
58235
58678
  const removed = await discardWorktree(tempBranch);
@@ -58260,9 +58703,10 @@ ${retryNotes}` : basePrompt,
58260
58703
  if (missingBinary) {
58261
58704
  if (salvaged.length > 0)
58262
58705
  preserveChain(tempBranch);
58263
- return { status: "failed", error: missingBinary };
58706
+ return { status: "failed", error: missingBinary, runFatal: { runtime: model.runtime } };
58264
58707
  }
58265
- if (classifyTicketRunError(err, model.runtime) === "retryable" && attempt < maxAttempts) {
58708
+ const verdict = classifyTicketRunError(err, model.runtime, setupCommand);
58709
+ if (verdict === "retryable" && attempt < maxAttempts) {
58266
58710
  const headline = errorHeadline(msg);
58267
58711
  retryNotes = buildRetryNotes({ error: headline, commitCount: salvaged.length });
58268
58712
  ctx.emitEvent({
@@ -58282,20 +58726,17 @@ ${retryNotes}` : basePrompt,
58282
58726
  if (ticketAbort.signal.aborted) {
58283
58727
  if (salvaged.length > 0)
58284
58728
  preserveChain(tempBranch);
58285
- return {
58286
- status: "failed",
58287
- error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
58288
- event: {
58289
- type: "ticket.stopped",
58290
- message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
58291
- }
58292
- };
58729
+ return stoppedOutcome(salvaged.length);
58293
58730
  }
58294
58731
  continue;
58295
58732
  }
58296
58733
  if (salvaged.length > 0)
58297
58734
  preserveChain(tempBranch);
58298
- return { status: "failed", error: msg };
58735
+ return {
58736
+ status: "failed",
58737
+ error: msg,
58738
+ ...verdict === "run-fatal" ? { runFatal: { runtime: model.runtime } } : {}
58739
+ };
58299
58740
  }
58300
58741
  }
58301
58742
  if (!result)
@@ -58339,7 +58780,7 @@ function resolveBurnDeps(ctx) {
58339
58780
  const exec3 = createSystemExec({ cwd: ctx.project.repoPath });
58340
58781
  const imageProbeCache = new Map;
58341
58782
  const cachedExec = (command, args2) => {
58342
- const key = `${resolveSandboxImage(config2)}\x00${model.runtime}`;
58783
+ const key = [resolveSandboxImage(config2, ctx.project), model.runtime, command, ...args2].join("\x00");
58343
58784
  let result = imageProbeCache.get(key);
58344
58785
  if (!result) {
58345
58786
  result = exec3(command, args2);
@@ -58357,7 +58798,7 @@ function resolveBurnDeps(ctx) {
58357
58798
  const engine = config2.sandbox === "podman" ? "podman" : "docker";
58358
58799
  return cacheVolumeReady ??= ensureBurnCacheVolume({
58359
58800
  engine,
58360
- imageName: resolveSandboxImage(config2),
58801
+ imageName: resolveSandboxImage(config2, ctx.project),
58361
58802
  projectId: ctx.project.id,
58362
58803
  exec: exec3
58363
58804
  });
@@ -58391,6 +58832,7 @@ function resolveBurnDeps(ctx) {
58391
58832
  const { model: ticketModel, token: ticketToken } = ticketCredentials(ticket);
58392
58833
  return burnAuthReady(ticketModel.runtime, ticketToken) ? undefined : ticketModel.runtime;
58393
58834
  },
58835
+ ticketRuntime: (ticket) => ticketCredentials(ticket).model.runtime,
58394
58836
  concurrency: config2.burnConcurrency,
58395
58837
  executeTicketRun: (c, ticket, run7) => {
58396
58838
  const { model: ticketModel, token: ticketToken } = ticketCredentials(ticket);
@@ -58407,9 +58849,9 @@ function resolveBurnDeps(ctx) {
58407
58849
  function readTokenFromEnvFile(path2, runtime5) {
58408
58850
  const key = RUNTIME_AUTH_KEY[runtime5];
58409
58851
  let fromFile;
58410
- if (existsSync17(path2)) {
58852
+ if (existsSync18(path2)) {
58411
58853
  try {
58412
- fromFile = parseEnvFile3(readFileSync9(path2, "utf8"))[key];
58854
+ fromFile = parseEnvFile3(readFileSync11(path2, "utf8"))[key];
58413
58855
  } catch {
58414
58856
  fromFile = undefined;
58415
58857
  }
@@ -58417,7 +58859,7 @@ function readTokenFromEnvFile(path2, runtime5) {
58417
58859
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env[key];
58418
58860
  return token && token.length > 0 ? token : undefined;
58419
58861
  }
58420
- 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, TOOLCHAIN_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;
58862
+ var ATTACHMENTS_PREFIX, AUTH_MISSING_EVENT = "auth.missing", IMAGE_RUNTIME_MISSING_EVENT = "burn.image_runtime_missing", RUNTIME_BINARY, COMMAND_SEPARATORS, ASSIGNMENT_PREFIX, NON_COMMAND_WORDS, PLAIN_COMMAND_NAME, DROPPED_MAP_SECTIONS, AGENT_CONVENTIONS_FILE = "CLAUDE.md", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, TOOLCHAIN_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, REFUSAL_STATUS, DETACHED_FORBIDDEN, CREDENTIAL_SUBJECT, ACCOUNT_PERMISSION_DENIED, RUN_FATAL_ERROR_PATTERNS, REFUSED_CREDENTIAL_STATUS, RUNTIME_RUN_FATAL_ERROR_PATTERNS, FATAL_ERROR_PATTERNS, RUNTIME_FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, RUNTIME_RETRYABLE_ERROR_PATTERNS, AGENT_BINARY, MISSING_COMMAND_PATTERNS, activeTicketAborts, RunHaltedAbort, NOTHING_LANDED = "nothing landed \u2014 every implementation ticket in the run was cancelled", SETUP_HOOK_TIMEOUT_MS, ticketBurner;
58421
58863
  var init_ticket_burner = __esm(() => {
58422
58864
  init_src();
58423
58865
  init_config_load();
@@ -58442,6 +58884,26 @@ var init_ticket_burner = __esm(() => {
58442
58884
  "claude-code": "claude",
58443
58885
  codex: "codex"
58444
58886
  };
58887
+ COMMAND_SEPARATORS = /&&|\|\||[;|\n]/;
58888
+ ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*=/;
58889
+ NON_COMMAND_WORDS = new Set([
58890
+ "cd",
58891
+ "export",
58892
+ "set",
58893
+ "unset",
58894
+ "source",
58895
+ ".",
58896
+ "exec",
58897
+ "env",
58898
+ "eval",
58899
+ "command",
58900
+ "time",
58901
+ "sudo",
58902
+ "echo",
58903
+ "true",
58904
+ "false"
58905
+ ]);
58906
+ PLAIN_COMMAND_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
58445
58907
  DROPPED_MAP_SECTIONS = ["Not yet specified", "Out of scope"];
58446
58908
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
58447
58909
  PM_CACHE_SANDBOX_PATHS = {
@@ -58485,27 +58947,42 @@ var init_ticket_burner = __esm(() => {
58485
58947
  /is not a working tree/i,
58486
58948
  /resource busy|ebusy|eperm|eacces/i
58487
58949
  ];
58488
- FATAL_ERROR_PATTERNS = [
58950
+ REFUSAL_STATUS = String.raw`\b(?:401|403)\b`;
58951
+ DETACHED_FORBIDDEN = String.raw`(?<!${REFUSAL_STATUS}[\W_]*)forbidden(?![\W_]*${REFUSAL_STATUS})`;
58952
+ CREDENTIAL_SUBJECT = String.raw`(?:api[ _-]?key|token|credential|login|auth(?:entic\w*|ori[sz]\w*|[nz])?\b|unauthorized|${DETACHED_FORBIDDEN}|account|organi[sz]ation|\borg\b)`;
58953
+ ACCOUNT_PERMISSION_DENIED = refusalNamingCredential(String.raw`permission denied`);
58954
+ RUN_FATAL_ERROR_PATTERNS = [
58489
58955
  /invalid (api key|x-api-key)/i,
58490
- /authentication|unauthorized|permission denied/i,
58956
+ /authentication|unauthorized/i,
58957
+ ACCOUNT_PERMISSION_DENIED,
58491
58958
  /credit balance|billing/i,
58492
58959
  /oauth token|setup-token/i,
58493
- /issue with the selected model|model not found|unknown model/i,
58494
- /does not support resumeSession|resumeSession .* not found/i
58960
+ /usage limit/i
58495
58961
  ];
58496
- RUNTIME_FATAL_ERROR_PATTERNS = {
58962
+ REFUSED_CREDENTIAL_STATUS = refusalNamingCredential(String.raw`(?:${REFUSAL_STATUS}|forbidden)`);
58963
+ RUNTIME_RUN_FATAL_ERROR_PATTERNS = {
58497
58964
  "claude-code": [],
58498
58965
  codex: [
58499
- /invalid_api_key|invalid_request_error/i,
58500
- /\b401\b|\b403\b/,
58966
+ /invalid_api_key/i,
58967
+ REFUSED_CREDENTIAL_STATUS,
58501
58968
  /insufficient_quota|exceeded your current quota/i,
58502
- /model_not_found|does not exist or you do not have access/i,
58503
58969
  /CODEX_API_KEY/,
58504
58970
  /not logged in/i,
58505
58971
  /\bauth (failed|required)\b/i,
58506
58972
  /refresh token/i
58507
58973
  ]
58508
58974
  };
58975
+ FATAL_ERROR_PATTERNS = [
58976
+ /issue with the selected model|model not found|unknown model/i,
58977
+ /does not support resumeSession|resumeSession .* not found/i
58978
+ ];
58979
+ RUNTIME_FATAL_ERROR_PATTERNS = {
58980
+ "claude-code": [],
58981
+ codex: [
58982
+ /invalid_request_error/i,
58983
+ /model_not_found|does not exist or you do not have access/i
58984
+ ]
58985
+ };
58509
58986
  RETRYABLE_ERROR_PATTERNS = [
58510
58987
  /exited with code \d+/i,
58511
58988
  /idle timeout|AgentIdleTimeout/i,
@@ -58524,7 +59001,19 @@ var init_ticket_burner = __esm(() => {
58524
59001
  "claude-code": "claude",
58525
59002
  codex: "codex"
58526
59003
  };
59004
+ MISSING_COMMAND_PATTERNS = [
59005
+ /\bexit(?:ed with)?(?: code)? 127\b/i,
59006
+ /command not found/i
59007
+ ];
58527
59008
  activeTicketAborts = new Map;
59009
+ RunHaltedAbort = class RunHaltedAbort extends Error {
59010
+ headline;
59011
+ constructor(headline) {
59012
+ super(`ticket stopped: run halted (${headline})`);
59013
+ this.headline = headline;
59014
+ this.name = "RunHaltedAbort";
59015
+ }
59016
+ };
58528
59017
  SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
58529
59018
  ticketBurner = {
58530
59019
  id: "ticket-burner",
@@ -58533,9 +59022,9 @@ var init_ticket_burner = __esm(() => {
58533
59022
  });
58534
59023
 
58535
59024
  // src/workflows/research.ts
58536
- import { mkdirSync as mkdirSync11, readFileSync as readFileSync10 } from "fs";
58537
- import { dirname as dirname11, join as join18 } from "path";
58538
- import { fileURLToPath as fileURLToPath8 } from "url";
59025
+ import { mkdirSync as mkdirSync11, readFileSync as readFileSync12 } from "fs";
59026
+ import { dirname as dirname11, join as join19 } from "path";
59027
+ import { fileURLToPath as fileURLToPath7 } from "url";
58539
59028
  function waypointSlug(waypoint) {
58540
59029
  const base = waypoint.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
58541
59030
  return `${waypoint.seq}-${base || "waypoint"}`;
@@ -58602,8 +59091,8 @@ function interpretResearchResult(result, docRelPath) {
58602
59091
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
58603
59092
  }
58604
59093
  function researchTemplatePath() {
58605
- const here = dirname11(fileURLToPath8(import.meta.url));
58606
- return join18(resolveSkillsRoot(here), "burner", "research-waypoint.md");
59094
+ const here = dirname11(fileURLToPath7(import.meta.url));
59095
+ return join19(resolveSkillsRoot(here), "burner", "research-waypoint.md");
58607
59096
  }
58608
59097
  function researchContainerName(runId, seq2) {
58609
59098
  return `runcastle-${runId}-w${seq2}`;
@@ -58619,7 +59108,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
58619
59108
  const { project: project3, feature } = ctx;
58620
59109
  const docRelPath = researchDocRel(feature.slug, waypoint);
58621
59110
  const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
58622
- const template = readFileSync10(researchTemplatePath(), "utf8");
59111
+ const template = readFileSync12(researchTemplatePath(), "utf8");
58623
59112
  const prompt = renderResearchPrompt(template, {
58624
59113
  WAYPOINT_JSON: buildWaypointJson(waypoint),
58625
59114
  FEATURE_BRIEF: buildFeatureBrief(feature),
@@ -58627,12 +59116,12 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
58627
59116
  RESEARCH_DOC_PATH: docRelPath
58628
59117
  });
58629
59118
  mkdirSync11(logsDir(), { recursive: true });
58630
- const logFilePath = join18(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
59119
+ const logFilePath = join19(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
58631
59120
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
58632
59121
  const codexAuthMount = codexAuthMountFor(model.runtime, config2.sandbox);
58633
59122
  const runOptions = {
58634
59123
  agent: buildBurnAgent(config2, token, model),
58635
- sandbox: selectSandbox(config2, codexAuthMount ? [codexAuthMount] : [], {}, registerResearchKill(config2, ctx.runId, waypoint.seq)),
59124
+ sandbox: selectSandbox(config2, project3, codexAuthMount ? [codexAuthMount] : [], {}, registerResearchKill(config2, ctx.runId, waypoint.seq)),
58636
59125
  cwd: project3.repoPath,
58637
59126
  prompt,
58638
59127
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
@@ -59233,16 +59722,30 @@ async function rethinkAndLaunch(ctx, featureId, launch3) {
59233
59722
  function errMsg2(e) {
59234
59723
  return e instanceof Error ? e.message : String(e);
59235
59724
  }
59725
+ function latestRunDeniedDirty(ctx, featureId) {
59726
+ const latestRun = listRunsByFeature(ctx, featureId)[0];
59727
+ if (!latestRun)
59728
+ return false;
59729
+ const deniedAt = latestEventTs(ctx, featureId, "reviewdrive.denied");
59730
+ return deniedAt !== undefined && deniedAt >= latestRun.startedAt;
59731
+ }
59236
59732
  async function retryTicket(ctx, ticketId, opts = {}) {
59237
59733
  const ticket = getTicket(ctx, ticketId);
59238
59734
  const feature = getFeatureRow(ctx, ticket.featureId);
59239
- if (ticket.status !== "failed") {
59735
+ const retryingDeniedReview = ticket.kind === "review" && ticket.status === "done" && latestRunDeniedDirty(ctx, feature.id);
59736
+ if (ticket.status !== "failed" && !retryingDeniedReview) {
59240
59737
  throw new GateError(`only failed tickets can be retried \u2014 ticket ${ticket.seq} is ${ticket.status}`);
59241
59738
  }
59242
59739
  if (hasActiveRun(ctx, feature.id)) {
59243
59740
  throw new GateError("a run is live for this feature \u2014 retry after it finishes, or cancel it first");
59244
59741
  }
59245
59742
  const project3 = projectForFeature(ctx, feature);
59743
+ if (retryingDeniedReview) {
59744
+ const stillDirty = await driveBlockingPaths(project3.repoPath);
59745
+ if (stillDirty.length > 0) {
59746
+ throw new GateError(`the working tree is still dirty \u2014 the review drive would be denied again. Commit or ` + `discard ${stillDirty.length} file(s) first: ${truncatedFileList(stillDirty)}`);
59747
+ }
59748
+ }
59246
59749
  const all8 = listByFeature2(ctx, feature.id);
59247
59750
  const bySeq = new Map(all8.map((t) => [t.seq, t]));
59248
59751
  const toReset = new Map([[ticket.seq, ticket]]);
@@ -59424,7 +59927,7 @@ var init_features = __esm(() => {
59424
59927
  });
59425
59928
 
59426
59929
  // src/services/test-notes.ts
59427
- import { existsSync as existsSync19, mkdirSync as mkdirSync12, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
59930
+ import { existsSync as existsSync20, mkdirSync as mkdirSync12, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
59428
59931
  import { dirname as dirname12 } from "path";
59429
59932
  import { and as and10, asc as asc5, eq as eq13, lt } from "drizzle-orm";
59430
59933
  function rowToNote(row) {
@@ -59439,7 +59942,7 @@ function rowToNote(row) {
59439
59942
  reviewTicketId: row.reviewTicketId ?? undefined,
59440
59943
  carriedLap: row.carriedLap ?? undefined,
59441
59944
  videoTimestamp: row.videoTimestamp ?? undefined,
59442
- screenshotUrl: existsSync19(annotationPath(row.id)) ? noteScreenshotUrl(row.id) : undefined,
59945
+ screenshotUrl: existsSync20(annotationPath(row.id)) ? noteScreenshotUrl(row.id) : undefined,
59443
59946
  createdAt: row.createdAt,
59444
59947
  updatedAt: row.updatedAt
59445
59948
  });
@@ -59709,8 +60212,8 @@ var init_test_notes = __esm(() => {
59709
60212
  });
59710
60213
 
59711
60214
  // src/mcp/server.ts
59712
- import { existsSync as existsSync20, readFileSync as readFileSync11 } from "fs";
59713
- import { join as join19, relative as relative4, resolve as resolve10, sep as sep2 } from "path";
60215
+ import { existsSync as existsSync21, readFileSync as readFileSync13 } from "fs";
60216
+ import { join as join20, relative as relative4, resolve as resolve10, sep as sep2 } from "path";
59714
60217
  import { StreamableHTTPTransport } from "@hono/mcp";
59715
60218
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
59716
60219
  import { Hono } from "hono";
@@ -59795,6 +60298,7 @@ function featureContext(ctx, reader) {
59795
60298
  moreDocs,
59796
60299
  docsNote: DOCS_NOTE,
59797
60300
  openDefects: carried.openDefects,
60301
+ carriedDefects: carried.carriedDefects,
59798
60302
  tickets: listByFeature2(ctx, feature.id).map(stripDigest),
59799
60303
  annotatedModels: annotatedModels(ctx)
59800
60304
  };
@@ -59851,7 +60355,13 @@ function toolEmitTickets(ctx, session, input) {
59851
60355
  const feature = getFeatureRow(ctx, requireFeatureId(session));
59852
60356
  refuseIfReadOnly(session, "emitting tickets");
59853
60357
  refuseMisKindedReview(input.tickets);
60358
+ const linkedFindingIds = input.tickets.flatMap((t) => t.originFindingId ?? []);
60359
+ requireLinkableFindings(ctx, feature.id, linkedFindingIds);
59854
60360
  const stored = storeTickets(ctx, feature.id, input.tickets);
60361
+ for (const ticket of stored) {
60362
+ if (ticket.originFindingId)
60363
+ linkFixTicket(ctx, feature.id, ticket.originFindingId, ticket.id);
60364
+ }
59855
60365
  return {
59856
60366
  stored: stored.length,
59857
60367
  tickets: stored.map((t) => ({ id: t.id, seq: t.seq, title: t.title }))
@@ -59876,6 +60386,13 @@ function toolCancelTicket(ctx, session, input) {
59876
60386
  refuseIfReadOnly(session, "cancelling a ticket");
59877
60387
  return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
59878
60388
  }
60389
+ function toolResolveFinding(ctx, session, input) {
60390
+ const featureId = requireFeatureId(session);
60391
+ refuseIfReadOnly(session, "resolving a finding");
60392
+ const { findingId, disposition, note: note2 } = ResolveFindingInput.parse(input);
60393
+ const finding = disposition === "carry" ? carryFinding(ctx, featureId, findingId, note2) : closeAsAddressed(ctx, featureId, findingId, note2 ?? "");
60394
+ return { ok: true, finding };
60395
+ }
59879
60396
  function toolEscalateToMap(ctx, session, input) {
59880
60397
  return escalateToMap(ctx, requireFeatureId(session), input);
59881
60398
  }
@@ -60142,16 +60659,16 @@ function adrRef(adr) {
60142
60659
  }
60143
60660
  function toolReadAdr(ctx, session, input) {
60144
60661
  requireProject(ctx, session);
60145
- const root = resolve10(join19(session.worktreePath, ADR_DIR_REL));
60662
+ const root = resolve10(join20(session.worktreePath, ADR_DIR_REL));
60146
60663
  const name = input.relPath.replace(/\\/g, "/").replace(/^\.?\/*docs\/adr\//i, "");
60147
60664
  const target = resolve10(root, name);
60148
60665
  const rel = relative4(root, target);
60149
60666
  if (rel === "" || rel.startsWith("..") || rel.split(sep2).includes("..")) {
60150
60667
  throw new InvalidInputError(`adr path escapes ${ADR_DIR_REL}: ${input.relPath}`);
60151
60668
  }
60152
- if (!existsSync20(target))
60669
+ if (!existsSync21(target))
60153
60670
  throw new NotFoundError(`adr not found: ${input.relPath}`);
60154
- return { relPath: `${ADR_DIR_REL}/${name}`, content: readFileSync11(target, "utf8") };
60671
+ return { relPath: `${ADR_DIR_REL}/${name}`, content: readFileSync13(target, "utf8") };
60155
60672
  }
60156
60673
  function featureIndexLine(feature) {
60157
60674
  if (feature.status === "shipped") {
@@ -60290,7 +60807,7 @@ function buildMcpServer(audience) {
60290
60807
  if (wants("review_drive")) {
60291
60808
  server.registerTool("review_drive", {
60292
60809
  title: "Drive the feature branch for review",
60293
- description: "Boot the integrated feature branch on the human's checkout, under your run's " + "identity, so you can review it. ALWAYS stop what you started, including when the " + "review goes wrong: the drive holds a machine-wide slot and the human cannot use their " + "own checkout until you release it. Refusals (a dirty tree, a drive the human is " + "already running) are final and never worth retrying \u2014 reporting one is the honest " + "outcome. Refused unless your call carries a live run identity.",
60810
+ description: "Boot the integrated feature branch on the human's checkout, under your run's " + "identity, so you can review it. ALWAYS stop what you started, including when the " + "review goes wrong: the drive holds a machine-wide slot and the human cannot use their " + "own checkout until you release it. A refused `start` says which refusal it is: " + '`deniedCode: "slot_held"` (`retriable: true`) means somebody else \u2014 a drive or a ' + "preparation dry run \u2014 holds the machine-wide slot, which frees itself when they " + "finish, so call `start` again about ten times roughly thirty seconds apart before you " + 'give up on it. `deniedCode: "dirty"` (`retriable: false`) is final \u2014 the uncommitted ' + "files in `dirtyFiles` are the human's to clear and no wait will do it, so report it " + "and review without the app. Refused unless your call carries a live run identity.",
60294
60811
  inputSchema: {
60295
60812
  action: z4.enum(["start", "status", "stop"]).describe("`start` switches the checkout to the feature branch, renders driveEnv (per-branch " + "database), runs driveSetupCommand and spawns devCommand. `status` reports the " + "drive and hands back the localhost URL once the dev server has printed one \u2014 " + "poll it, the URL is not ready at start. `stop` tears the environment down and " + "puts the checkout back.")
60296
60813
  }
@@ -60510,6 +61027,18 @@ function buildMcpServer(audience) {
60510
61027
  return ok(result);
60511
61028
  });
60512
61029
  }
61030
+ if (wants("resolve_finding")) {
61031
+ server.registerTool("resolve_finding", {
61032
+ title: "Resolve a review finding",
61033
+ description: "Say what this lap did about a defect an earlier lap\u2019s review left open: `carry` it into " + "a later lap, or close it as `addressed` because this lap\u2019s work already answers it. To " + "link it instead, emit the ticket that fixes it with `originFindingId` set \u2014 the burn " + "then closes the finding itself when that ticket lands. Every earlier-lap open defect " + 'must be linked, carried or closed before `complete_phase("tickets")` will pass.',
61034
+ inputSchema: ResolveFindingShape
61035
+ }, async (args2, extra) => {
61036
+ const rs = await resolveCtxSession(extra);
61037
+ if (!rs)
61038
+ return noSession();
61039
+ return ok(toolResolveFinding(rs.ctx, rs.session, args2));
61040
+ });
61041
+ }
60513
61042
  if (wants("escalate_to_map")) {
60514
61043
  server.registerTool("escalate_to_map", {
60515
61044
  title: "Escalate to map",
@@ -60606,12 +61135,29 @@ async function resolveAudience(sessionId, runId) {
60606
61135
  return;
60607
61136
  }
60608
61137
  }
60609
- var DOCS_NOTE, REVIEW_TITLE, DRAFTING_KINDS, ADRS_NOTE, FEATURE_KINDS, FEATURE_WRITE_KINDS, PROJECT_KINDS, ALL_AUDIENCES, TOOL_AUDIENCES, CompletablePhase, EventType, mcp, server_default;
61138
+ async function readMcpBody(c) {
61139
+ if (c.req.method !== "POST")
61140
+ return;
61141
+ let text3;
61142
+ try {
61143
+ text3 = await c.req.text();
61144
+ } catch (e) {
61145
+ console.error(`[mcp] request body read failed after ${c.req.header("content-length") ?? "unknown"} ` + `declared bytes: ${e instanceof Error ? e.message : String(e)}`);
61146
+ return;
61147
+ }
61148
+ try {
61149
+ return JSON.parse(text3);
61150
+ } catch {
61151
+ return;
61152
+ }
61153
+ }
61154
+ var DOCS_NOTE, REVIEW_TITLE, ResolveFindingShape, ResolveFindingInput, DRAFTING_KINDS, ADRS_NOTE, FEATURE_KINDS, FEATURE_WRITE_KINDS, PROJECT_KINDS, ALL_AUDIENCES, TOOL_AUDIENCES, CompletablePhase, EventType, mcp, server_default;
60610
61155
  var init_server = __esm(() => {
60611
61156
  init_src();
60612
61157
  init_paths();
60613
61158
  init_errors();
60614
61159
  init_artifacts();
61160
+ init_runtime();
60615
61161
  init_sessions();
60616
61162
  init_features();
60617
61163
  init_carried_work();
@@ -60627,6 +61173,20 @@ var init_server = __esm(() => {
60627
61173
  init_waypoints();
60628
61174
  DOCS_NOTE = "docs[] holds this feature\u2019s canonical docs in full. moreDocs[] is everything else that " + "exists in docs/features/<slug>/ \u2014 not inlined, not gone: read any of them with " + "read_feature_doc({ relPath }). A `withheld` reason means it was left out on purpose; fetch " + "it anyway if a ticket points at it.";
60629
61175
  REVIEW_TITLE = /^review[: ]/i;
61176
+ ResolveFindingShape = {
61177
+ findingId: z4.string().min(1).describe("The finding id, from `get_feature_context`\u2019s openDefects or carriedDefects."),
61178
+ disposition: z4.enum(["carry", "addressed"]).describe("`carry` parks it for a later lap \u2014 out of the open count, still visible, and the human can " + "reopen it. `addressed` closes it because this lap\u2019s work already answers it."),
61179
+ note: z4.string().optional().describe('REQUIRED for `addressed`: what addressed it, e.g. "lap 2\u2019s ticket 7 rewrote the endpoint". ' + "Optional for `carry`, where it records why it was parked.")
61180
+ };
61181
+ ResolveFindingInput = z4.object(ResolveFindingShape).superRefine((input, refinement) => {
61182
+ if (input.disposition === "addressed" && !input.note?.trim()) {
61183
+ refinement.addIssue({
61184
+ code: "custom",
61185
+ path: ["note"],
61186
+ message: "closing a defect as addressed requires a note saying what addressed it"
61187
+ });
61188
+ }
61189
+ });
60630
61190
  DRAFTING_KINDS = ["ideation", "revisit", "waypoint", "converge"];
60631
61191
  ADRS_NOTE = "adrs[] is an index of the project\u2019s LIVE decisions (superseded ones are already omitted), not " + "their text. Read the ones your work touches with read_adr({ relPath }) \u2014 they bind you the " + "same either way. They are also plain files in this worktree at docs/adr/.";
60632
61192
  FEATURE_KINDS = [
@@ -60648,6 +61208,7 @@ var init_server = __esm(() => {
60648
61208
  update_ticket: FEATURE_WRITE_KINDS,
60649
61209
  cancel_ticket: FEATURE_WRITE_KINDS,
60650
61210
  complete_phase: FEATURE_WRITE_KINDS,
61211
+ resolve_finding: FEATURE_WRITE_KINDS,
60651
61212
  escalate_to_map: FEATURE_KINDS,
60652
61213
  emit_waypoints: FEATURE_KINDS,
60653
61214
  resolve_waypoint: FEATURE_KINDS,
@@ -60673,11 +61234,12 @@ var init_server = __esm(() => {
60673
61234
  }
60674
61235
  });
60675
61236
  mcp.all("*", async (c) => {
61237
+ const body = await readMcpBody(c);
60676
61238
  const audience = await resolveAudience(c.req.header("X-Runcastle-Session"), c.req.header(RUN_HEADER));
60677
61239
  const server = buildMcpServer(audience);
60678
61240
  const transport = new StreamableHTTPTransport({ enableJsonResponse: true });
60679
61241
  await server.connect(transport);
60680
- const res = await transport.handleRequest(c);
61242
+ const res = await transport.handleRequest(c, body);
60681
61243
  return res ?? c.body(null, 202);
60682
61244
  });
60683
61245
  server_default = mcp;
@@ -60950,6 +61512,7 @@ function sessionStartContext(ctx, feature) {
60950
61512
  var hooks, hooks_default;
60951
61513
  var init_hooks = __esm(() => {
60952
61514
  init_edit_guard();
61515
+ init_runtime();
60953
61516
  init_sessions();
60954
61517
  init_events();
60955
61518
  init_git();
@@ -61018,12 +61581,12 @@ var init_hooks = __esm(() => {
61018
61581
  });
61019
61582
 
61020
61583
  // src/routes/reviews.ts
61021
- import { createReadStream, statSync as statSync6 } from "fs";
61584
+ import { createReadStream, statSync as statSync5 } from "fs";
61022
61585
  import { Readable } from "stream";
61023
61586
  import { Hono as Hono3 } from "hono";
61024
61587
  function fileSize(path2) {
61025
61588
  try {
61026
- const stat3 = statSync6(path2);
61589
+ const stat3 = statSync5(path2);
61027
61590
  return stat3.isFile() ? stat3.size : undefined;
61028
61591
  } catch {
61029
61592
  return;
@@ -61074,6 +61637,7 @@ var init_reviews = __esm(() => {
61074
61637
  init_src();
61075
61638
  init_paths();
61076
61639
  init_errors();
61640
+ init_runtime();
61077
61641
  init_test_notes();
61078
61642
  init_tickets();
61079
61643
  reviews = new Hono3;
@@ -61210,15 +61774,15 @@ var init_stream = __esm(() => {
61210
61774
  });
61211
61775
 
61212
61776
  // src/routes/web.ts
61213
- import { existsSync as existsSync21, statSync as statSync7 } from "fs";
61777
+ import { existsSync as existsSync22, statSync as statSync6 } from "fs";
61214
61778
  import { readFile as readFile4 } from "fs/promises";
61215
- import { extname, join as join20, normalize as normalize2, resolve as resolve11, sep as sep3 } from "path";
61779
+ import { extname, join as join21, normalize as normalize2, resolve as resolve11, sep as sep3 } from "path";
61216
61780
  function contentType(path2) {
61217
61781
  return MIME[extname(path2).toLowerCase()] ?? "application/octet-stream";
61218
61782
  }
61219
61783
  function isFile2(path2) {
61220
61784
  try {
61221
- return statSync7(path2).isFile();
61785
+ return statSync6(path2).isFile();
61222
61786
  } catch {
61223
61787
  return false;
61224
61788
  }
@@ -61230,7 +61794,7 @@ function safeJoin(root, pathname) {
61230
61794
  } catch {
61231
61795
  return null;
61232
61796
  }
61233
- const joined = normalize2(join20(root, decoded));
61797
+ const joined = normalize2(join21(root, decoded));
61234
61798
  if (joined !== root && !joined.startsWith(root + sep3))
61235
61799
  return null;
61236
61800
  return joined;
@@ -61243,7 +61807,7 @@ function resolveWebDist() {
61243
61807
  }
61244
61808
  function mountWebApp(app, distDir) {
61245
61809
  const root = resolve11(distDir);
61246
- const indexHtml = join20(root, "index.html");
61810
+ const indexHtml = join21(root, "index.html");
61247
61811
  app.get("*", async (c, next4) => {
61248
61812
  const { pathname } = new URL(c.req.url);
61249
61813
  if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
@@ -61263,7 +61827,7 @@ function mountWebApp(app, distDir) {
61263
61827
  }
61264
61828
  function mountWebAppIfBuilt(app) {
61265
61829
  const dist = resolveWebDist();
61266
- if (existsSync21(join20(dist, "index.html")))
61830
+ if (existsSync22(join21(dist, "index.html")))
61267
61831
  mountWebApp(app, dist);
61268
61832
  }
61269
61833
  var MIME;
@@ -61290,16 +61854,16 @@ var init_web = __esm(() => {
61290
61854
  });
61291
61855
 
61292
61856
  // src/version.ts
61293
- import { readFileSync as readFileSync12 } from "fs";
61294
- import { dirname as dirname13, join as join21 } from "path";
61295
- import { fileURLToPath as fileURLToPath9 } from "url";
61857
+ import { readFileSync as readFileSync14 } from "fs";
61858
+ import { dirname as dirname13, join as join23 } from "path";
61859
+ import { fileURLToPath as fileURLToPath8 } from "url";
61296
61860
  function runcastleVersion() {
61297
61861
  if (cached4)
61298
61862
  return cached4;
61299
- const here = dirname13(fileURLToPath9(import.meta.url));
61300
- for (const pkgPath of [join21(here, "..", "package.json"), join21(here, "package.json")]) {
61863
+ const here = dirname13(fileURLToPath8(import.meta.url));
61864
+ for (const pkgPath of [join23(here, "..", "package.json"), join23(here, "package.json")]) {
61301
61865
  try {
61302
- const pkg = JSON.parse(readFileSync12(pkgPath, "utf8"));
61866
+ const pkg = JSON.parse(readFileSync14(pkgPath, "utf8"));
61303
61867
  if (typeof pkg.version === "string") {
61304
61868
  cached4 = pkg.version;
61305
61869
  return cached4;
@@ -61314,13 +61878,13 @@ var init_version = () => {};
61314
61878
 
61315
61879
  // src/services/install-id.ts
61316
61880
  import { randomUUID as randomUUID3 } from "crypto";
61317
- import { existsSync as existsSync22, mkdirSync as mkdirSync13, readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "fs";
61881
+ import { existsSync as existsSync23, mkdirSync as mkdirSync13, readFileSync as readFileSync15, writeFileSync as writeFileSync9 } from "fs";
61318
61882
  import { dirname as dirname14 } from "path";
61319
61883
  function getInstallId() {
61320
61884
  const file = installIdPath();
61321
61885
  try {
61322
- if (existsSync22(file)) {
61323
- const existing = readFileSync13(file, "utf8").trim();
61886
+ if (existsSync23(file)) {
61887
+ const existing = readFileSync15(file, "utf8").trim();
61324
61888
  if (UUID_RE.test(existing))
61325
61889
  return existing;
61326
61890
  }
@@ -61519,8 +62083,8 @@ var init_events2 = __esm(() => {
61519
62083
  });
61520
62084
 
61521
62085
  // src/launcher/launcher.ts
61522
- import { existsSync as existsSync23 } from "fs";
61523
- import { join as join23 } from "path";
62086
+ import { existsSync as existsSync24 } from "fs";
62087
+ import { join as join25 } from "path";
61524
62088
  import { and as and12, eq as eq15 } from "drizzle-orm";
61525
62089
  function ptyExitMessage(exitCode) {
61526
62090
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
@@ -61995,7 +62559,7 @@ async function buildPrepareBrief(ctx, project3) {
61995
62559
  };
61996
62560
  }
61997
62561
  function probePrepareHost(repoPath) {
61998
- const has4 = (rel) => existsSync23(join23(repoPath, rel));
62562
+ const has4 = (rel) => existsSync24(join25(repoPath, rel));
61999
62563
  const lock = LOCKFILES.find(([file]) => has4(file));
62000
62564
  return {
62001
62565
  platform: process.platform,
@@ -62188,7 +62752,7 @@ var init_launcher = __esm(() => {
62188
62752
 
62189
62753
  // src/services/outcome.ts
62190
62754
  import { mkdirSync as mkdirSync14, writeFileSync as writeFileSync10 } from "fs";
62191
- import { join as join25 } from "path";
62755
+ import { join as join26 } from "path";
62192
62756
  function digestHasSubstance(text3) {
62193
62757
  return text3.split(`
62194
62758
  `).some((line) => {
@@ -62288,9 +62852,9 @@ async function promoteOutcomeDoc(ctx, project3, feature, target, delta) {
62288
62852
  delta: { commits: delta.commits ?? 0, files: delta.files ?? 0 }
62289
62853
  });
62290
62854
  await onBranch(project3.repoPath, target, async () => {
62291
- const docsDir = join25(project3.repoPath, ...featureDocsRel(feature.slug).split("/"));
62855
+ const docsDir = join26(project3.repoPath, ...featureDocsRel(feature.slug).split("/"));
62292
62856
  mkdirSync14(docsDir, { recursive: true });
62293
- writeFileSync10(join25(docsDir, "outcome.md"), doc, "utf8");
62857
+ writeFileSync10(join26(docsDir, "outcome.md"), doc, "utf8");
62294
62858
  await commitDocs(project3.repoPath, `runcastle: outcome for ${feature.slug}`);
62295
62859
  });
62296
62860
  } catch (error) {
@@ -62417,9 +62981,9 @@ var init_feature = __esm(() => {
62417
62981
  });
62418
62982
 
62419
62983
  // src/services/fsbrowse.ts
62420
- import { existsSync as existsSync24, readdirSync as readdirSync6, statSync as statSync8 } from "fs";
62984
+ import { existsSync as existsSync25, readdirSync as readdirSync6, statSync as statSync7 } from "fs";
62421
62985
  import { homedir as homedir4 } from "os";
62422
- import { basename as basename3, dirname as dirname15, isAbsolute as isAbsolute3, join as join26, resolve as resolve12, sep as sep4 } from "path";
62986
+ import { basename as basename3, dirname as dirname15, isAbsolute as isAbsolute3, join as join27, resolve as resolve12, sep as sep4 } from "path";
62423
62987
  function expandPath(input, home = homedir4()) {
62424
62988
  const trimmed = input.trim();
62425
62989
  if (trimmed === "")
@@ -62428,12 +62992,12 @@ function expandPath(input, home = homedir4()) {
62428
62992
  if (out === "~")
62429
62993
  out = home;
62430
62994
  else if (out.startsWith("~/") || out.startsWith("~\\"))
62431
- out = join26(home, out.slice(2));
62995
+ out = join27(home, out.slice(2));
62432
62996
  return resolve12(out);
62433
62997
  }
62434
62998
  function looksLikeRepo(dir) {
62435
62999
  try {
62436
- return existsSync24(join26(dir, ".git"));
63000
+ return existsSync25(join27(dir, ".git"));
62437
63001
  } catch {
62438
63002
  return false;
62439
63003
  }
@@ -62444,20 +63008,20 @@ function listRoots(home = homedir4()) {
62444
63008
  for (let code = 67;code <= 90; code++) {
62445
63009
  const drive = `${String.fromCharCode(code)}:${sep4}`;
62446
63010
  try {
62447
- if (existsSync24(drive))
63011
+ if (existsSync25(drive))
62448
63012
  roots3.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
62449
63013
  } catch {}
62450
63014
  }
62451
63015
  } else {
62452
63016
  roots3.push({ label: "/", path: "/", kind: "drive" });
62453
63017
  }
62454
- if (existsSync24(home))
63018
+ if (existsSync25(home))
62455
63019
  roots3.push({ label: "Home", path: home, kind: "home" });
62456
63020
  const seen = new Set(roots3.map(pathKey));
62457
63021
  for (const name of COMMON_DIRS) {
62458
- const candidate = join26(home, name);
63022
+ const candidate = join27(home, name);
62459
63023
  const key = pathKey({ path: candidate });
62460
- if (seen.has(key) || !existsSync24(candidate))
63024
+ if (seen.has(key) || !existsSync25(candidate))
62461
63025
  continue;
62462
63026
  seen.add(key);
62463
63027
  roots3.push({ label: name, path: candidate, kind: "common" });
@@ -62488,12 +63052,12 @@ function browseDir(input, showHidden = false, home = homedir4()) {
62488
63052
  if (!isAbsolute3(target)) {
62489
63053
  throw new InvalidInputError(`path is not absolute: ${target}`);
62490
63054
  }
62491
- if (!existsSync24(target)) {
63055
+ if (!existsSync25(target)) {
62492
63056
  throw new InvalidInputError(`path does not exist: ${target}`);
62493
63057
  }
62494
63058
  let dir = target;
62495
63059
  try {
62496
- if (!statSync8(target).isDirectory())
63060
+ if (!statSync7(target).isDirectory())
62497
63061
  dir = dirname15(target);
62498
63062
  } catch (e) {
62499
63063
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
@@ -62516,14 +63080,14 @@ function browseDir(input, showHidden = false, home = homedir4()) {
62516
63080
  continue;
62517
63081
  if (!showHidden && name.startsWith("."))
62518
63082
  continue;
62519
- const path2 = join26(dir, name);
63083
+ const path2 = join27(dir, name);
62520
63084
  const isSymlink = dirent.isSymbolicLink();
62521
63085
  if (isSymlink && !showHidden)
62522
63086
  continue;
62523
63087
  let isDirectory = dirent.isDirectory();
62524
63088
  if (isSymlink) {
62525
63089
  try {
62526
- isDirectory = statSync8(path2).isDirectory();
63090
+ isDirectory = statSync7(path2).isDirectory();
62527
63091
  } catch {
62528
63092
  continue;
62529
63093
  }
@@ -62553,7 +63117,7 @@ var init_fsbrowse = __esm(() => {
62553
63117
  });
62554
63118
 
62555
63119
  // src/services/transcripts.ts
62556
- import { readFileSync as readFileSync14 } from "fs";
63120
+ import { readFileSync as readFileSync16 } from "fs";
62557
63121
  function* entries(jsonl) {
62558
63122
  for (const line of jsonl.split(`
62559
63123
  `)) {
@@ -62634,7 +63198,7 @@ function readTranscript2(path2, runtime5) {
62634
63198
  if (!path2)
62635
63199
  return { status: "ok", turns: [] };
62636
63200
  try {
62637
- return parseTranscript(readFileSync14(path2, "utf8"), runtime5);
63201
+ return parseTranscript(readFileSync16(path2, "utf8"), runtime5);
62638
63202
  } catch {
62639
63203
  return { status: "ok", turns: [] };
62640
63204
  }
@@ -62747,8 +63311,8 @@ var init_conversations = __esm(() => {
62747
63311
  });
62748
63312
 
62749
63313
  // src/services/projects.ts
62750
- import { existsSync as existsSync25 } from "fs";
62751
- import { basename as basename4, join as join27 } from "path";
63314
+ import { existsSync as existsSync26 } from "fs";
63315
+ import { basename as basename4, join as join28 } from "path";
62752
63316
  import { eq as eq16, isNull } from "drizzle-orm";
62753
63317
  function listProjects(ctx) {
62754
63318
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
@@ -62827,7 +63391,7 @@ async function assertRepoTolerant(ctx, repoPath) {
62827
63391
  } catch (e) {
62828
63392
  if (!isNotImplemented(e))
62829
63393
  throw e;
62830
- if (!existsSync25(join27(repoPath, ".git"))) {
63394
+ if (!existsSync26(join28(repoPath, ".git"))) {
62831
63395
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
62832
63396
  }
62833
63397
  }
@@ -62892,6 +63456,7 @@ var init_review_findings2 = __esm(() => {
62892
63456
  reviewFindingsRouter = router({
62893
63457
  listByFeature: publicProcedure.input(z9.object({ featureId: z9.string() })).query(({ ctx, input }) => viewByFeature(ctx, input.featureId)),
62894
63458
  dismiss: publicProcedure.input(z9.object({ findingId: z9.string() })).mutation(({ ctx, input }) => dismiss(ctx, input.findingId)),
63459
+ reopen: publicProcedure.input(z9.object({ findingId: z9.string() })).mutation(({ ctx, input }) => reopenFinding(ctx, input.findingId)),
62895
63460
  fixOpenDefects: publicProcedure.input(z9.object({ featureId: z9.string() })).mutation(async ({ ctx, input }) => {
62896
63461
  const promoted = promoteOpenDefects(ctx, input.featureId);
62897
63462
  const { runId } = await burn(ctx, input.featureId);
@@ -62977,13 +63542,13 @@ var init_settings2 = __esm(() => {
62977
63542
  });
62978
63543
 
62979
63544
  // src/doctor/afk-env.ts
62980
- import { existsSync as existsSync26, readFileSync as readFileSync15 } from "fs";
63545
+ import { existsSync as existsSync27, readFileSync as readFileSync17 } from "fs";
62981
63546
  function envWithAfkCredentials() {
62982
63547
  const merged = { ...process.env };
62983
63548
  try {
62984
63549
  const path2 = envPath();
62985
- if (existsSync26(path2)) {
62986
- const fromFile = parseEnvFile3(readFileSync15(path2, "utf8"));
63550
+ if (existsSync27(path2)) {
63551
+ const fromFile = parseEnvFile3(readFileSync17(path2, "utf8"));
62987
63552
  for (const runtime5 of AGENT_RUNTIMES) {
62988
63553
  const key = RUNTIME_SPECS[runtime5].afkKey;
62989
63554
  const value = fromFile[key];
@@ -63001,9 +63566,84 @@ var init_afk_env = __esm(() => {
63001
63566
  init_doctor();
63002
63567
  });
63003
63568
 
63569
+ // src/services/project-image.ts
63570
+ function adoptProjectImage(ctx, projectId, tag) {
63571
+ if (!isOverwritable(ctx, projectId, "sandboxImage"))
63572
+ return false;
63573
+ recordFinding(ctx, projectId, {
63574
+ key: "sandboxImage",
63575
+ value: tag,
63576
+ source: "build",
63577
+ evidence: `Built from .runcastle/sandbox/Dockerfile as ${tag}.`
63578
+ });
63579
+ emitProject(ctx, projectId, {
63580
+ type: "settings.updated",
63581
+ message: `sandboxImage set to ${tag} by the image build`,
63582
+ data: { key: "sandboxImage", scope: "project", value: tag }
63583
+ });
63584
+ return true;
63585
+ }
63586
+ function releaseProjectImage(ctx, projectId) {
63587
+ if (!isOverwritable(ctx, projectId, "sandboxImage"))
63588
+ return false;
63589
+ recordFinding(ctx, projectId, { key: "sandboxImage", value: null, source: "build" });
63590
+ emitProject(ctx, projectId, {
63591
+ type: "settings.updated",
63592
+ message: "sandboxImage cleared \u2014 .runcastle/sandbox/Dockerfile is gone",
63593
+ data: { key: "sandboxImage", scope: "project", value: null }
63594
+ });
63595
+ return true;
63596
+ }
63597
+ var init_project_image = __esm(() => {
63598
+ init_events();
63599
+ init_findings();
63600
+ });
63601
+
63004
63602
  // src/trpc/routers/setup.ts
63603
+ import { join as join29 } from "path";
63005
63604
  import { isNotNull as isNotNull4 } from "drizzle-orm";
63006
63605
  import * as z12 from "zod";
63606
+ function projectImageEnv(ctx, project3) {
63607
+ return {
63608
+ id: project3.id,
63609
+ repoPath: project3.repoPath,
63610
+ stored: project3.sandboxImage ?? null,
63611
+ overwritable: isOverwritable(ctx, project3.id, "sandboxImage"),
63612
+ clearStored: () => releaseProjectImage(ctx, project3.id)
63613
+ };
63614
+ }
63615
+ async function buildImageTerminal(ctx, projectId) {
63616
+ const exec3 = createSystemExec();
63617
+ const runtime5 = await resolveRuntime(exec3, ctx.config.sandbox === "podman" ? "podman" : "docker");
63618
+ const project3 = projectId ? requireProjectById(ctx, projectId) : null;
63619
+ const stockContext = prepareSandboxBuildContext();
63620
+ const stockHash = hashDockerfile(join29(stockContext, "Dockerfile"));
63621
+ const plan = planImageBuild({
63622
+ config: ctx.config,
63623
+ project: project3 ? {
63624
+ id: project3.id,
63625
+ repoPath: project3.repoPath,
63626
+ sandboxImage: project3.sandboxImage,
63627
+ sandboxImageOverwritable: isOverwritable(ctx, project3.id, "sandboxImage")
63628
+ } : null,
63629
+ stockContext,
63630
+ stockFresh: stockHash !== null && (await inspectBuiltImage(exec3, runtime5, DEFAULT_SANDBOX_IMAGE)).hash === stockHash,
63631
+ buildArgs: stockBuildArgs(runtime5)
63632
+ });
63633
+ if (plan.kind === "refused")
63634
+ throw new InvalidInputError(plan.reason);
63635
+ const spec = imageBuildTerminal(runtime5, plan);
63636
+ if (plan.kind !== "chain" || !project3)
63637
+ return { spec };
63638
+ const { projectTag } = plan;
63639
+ return {
63640
+ spec,
63641
+ onExit: ({ exitCode }) => {
63642
+ if (exitCode === 0)
63643
+ adoptProjectImage(ctx, project3.id, projectTag);
63644
+ }
63645
+ };
63646
+ }
63007
63647
  var setupRouter;
63008
63648
  var init_setup2 = __esm(() => {
63009
63649
  init_src();
@@ -63011,41 +63651,48 @@ var init_setup2 = __esm(() => {
63011
63651
  init_afk_env();
63012
63652
  init_doctor();
63013
63653
  init_system_exec();
63654
+ init_errors();
63014
63655
  init_asset_paths();
63015
63656
  init_registry();
63657
+ init_findings();
63658
+ init_project_image();
63659
+ init_repo();
63660
+ init_sandbox_image();
63016
63661
  init_setup();
63017
63662
  init_resolve_executable();
63018
63663
  init_context();
63019
63664
  setupRouter = router({
63020
- doctor: publicProcedure.query(({ ctx }) => {
63665
+ doctor: publicProcedure.input(z12.object({
63666
+ projectId: z12.string().optional()
63667
+ }).optional()).query(({ ctx, input }) => {
63021
63668
  const projectModels = ctx.db.select({ model: projects.model }).from(projects).where(isNotNull4(projects.model)).all().map((p) => p.model);
63669
+ const project3 = input?.projectId ? requireProjectById(ctx, input.projectId) : null;
63022
63670
  return runDoctor({
63023
63671
  exec: createSystemExec(),
63024
63672
  burnerDockerfile: burnerDockerfilePath(),
63025
63673
  env: envWithAfkCredentials(),
63026
63674
  runtimes: configuredRuntimes(ctx.config, projectModels),
63027
- ...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
63675
+ imageName: resolveSandboxImage(ctx.config),
63676
+ ...project3 ? { projectImage: projectImageEnv(ctx, project3) } : {}
63028
63677
  });
63029
63678
  }),
63030
63679
  runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
63031
63680
  gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
63032
63681
  afkToken: publicProcedure.input(z12.object({ token: z12.string(), runtime: AgentRuntime.default("claude-code") })).mutation(({ input }) => saveAfkCredential(fileAfkTokenIo(createSystemExec(), input.runtime), input.token, input.runtime)),
63033
63682
  seedModelDefaults: publicProcedure.input(z12.object({ runtimes: z12.array(AgentRuntime) })).mutation(({ ctx, input }) => seedModelDefaults(ctx, input.runtimes)),
63034
- startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image", "claude-login", "codex-login"]) })).mutation(async ({ ctx, input }) => {
63035
- const exec3 = createSystemExec();
63036
- const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
63037
- const runtime5 = await resolveRuntime(exec3, preferred);
63038
- const imageName = resolveSandboxImage(ctx.config);
63039
- const sandcastleBin = input.kind === "build-image" ? resolveSandcastleBin() ?? undefined : undefined;
63040
- const spec = terminalSpec(input.kind, { runtime: runtime5, imageName, sandcastleBin });
63683
+ startTerminal: publicProcedure.input(z12.object({
63684
+ kind: z12.enum(["setup-token", "build-image", "claude-login", "codex-login"]),
63685
+ projectId: z12.string().optional()
63686
+ })).mutation(async ({ ctx, input }) => {
63041
63687
  const sessionId = newId("setup");
63042
- const cwd = input.kind === "build-image" ? prepareSandboxBuildContext() : process.cwd();
63688
+ const { spec, onExit: onExit4 } = input.kind === "build-image" ? await buildImageTerminal(ctx, input.projectId) : { spec: { ...terminalSpec(input.kind), cwd: process.cwd() }, onExit: undefined };
63043
63689
  const { file, args: args2 } = resolveSpawnTarget(spec.cmd, spec.args);
63044
63690
  ptyRegistry().create({
63045
63691
  sessionId,
63046
63692
  cmd: file,
63047
63693
  args: args2,
63048
- opts: { cwd, env: process.env }
63694
+ opts: { cwd: spec.cwd, env: process.env },
63695
+ ...onExit4 ? { onExit: onExit4 } : {}
63049
63696
  });
63050
63697
  return { sessionId };
63051
63698
  })
@@ -63307,6 +63954,7 @@ async function startServer() {
63307
63954
  runMigrations(db);
63308
63955
  const ctx = { db, config: config2 };
63309
63956
  const app = buildApp(ctx);
63957
+ followConfigFile(ctx);
63310
63958
  const reconciled = reconcileStaleSessions(ctx);
63311
63959
  if (reconciled.length > 0) {
63312
63960
  console.log(`reconciled ${reconciled.length} stale session(s) from a previous server run`);
@@ -63348,6 +63996,7 @@ var init_src2 = __esm(() => {
63348
63996
  init_client();
63349
63997
  init_migrate();
63350
63998
  init_reconcile();
63999
+ init_runtime();
63351
64000
  init_server();
63352
64001
  init_registry();
63353
64002
  init_ws();