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