runcastle 1.0.21 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -106,6 +106,7 @@ var init_config = __esm(() => {
106
106
  burnConcurrency: z.number().int().min(1).max(8).default(3),
107
107
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
108
108
  burnAttempts: z.number().int().min(1).max(5).default(3),
109
+ burnConflictAttempts: z.number().int().min(0).max(3).default(2),
109
110
  setupCommand: z.string().optional(),
110
111
  burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
111
112
  }));
@@ -149,6 +150,9 @@ function loadConfig(env = process.env) {
149
150
  if (env.RUNCASTLE_BURN_ATTEMPTS) {
150
151
  overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
151
152
  }
153
+ if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
154
+ overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
155
+ }
152
156
  if (env.RUNCASTLE_SETUP_COMMAND)
153
157
  overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
154
158
  if (env.RUNCASTLE_BURN_WORKSPACE)
@@ -215,7 +219,8 @@ var init_schemas = __esm(() => {
215
219
  status: TicketStatus,
216
220
  commits: z2.array(z2.string()),
217
221
  error: z2.string().optional(),
218
- attemptBranch: z2.string().optional()
222
+ attemptBranch: z2.string().optional(),
223
+ conflictFiles: z2.array(z2.string()).optional()
219
224
  });
220
225
  WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
221
226
  WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
@@ -490,7 +495,8 @@ var init_db_schema = __esm(() => {
490
495
  status: text("status").notNull().$type(),
491
496
  commits: text("commits", { mode: "json" }).notNull().$type(),
492
497
  error: text("error"),
493
- attemptBranch: text("attempt_branch")
498
+ attemptBranch: text("attempt_branch"),
499
+ conflictFiles: text("conflict_files", { mode: "json" }).$type()
494
500
  });
495
501
  waypoints = sqliteTable("waypoints", {
496
502
  id: text("id").primaryKey(),
@@ -1706,7 +1712,8 @@ function rowToTicket(row) {
1706
1712
  status: row.status,
1707
1713
  commits: row.commits,
1708
1714
  error: row.error ?? undefined,
1709
- attemptBranch: row.attemptBranch ?? undefined
1715
+ attemptBranch: row.attemptBranch ?? undefined,
1716
+ conflictFiles: row.conflictFiles ?? undefined
1710
1717
  };
1711
1718
  }
1712
1719
  function resolveBlocking(inputs, startSeq) {
@@ -1740,7 +1747,8 @@ function storeTickets(ctx, featureId, inputs) {
1740
1747
  status: "pending",
1741
1748
  commits: [],
1742
1749
  error: null,
1743
- attemptBranch: null
1750
+ attemptBranch: null,
1751
+ conflictFiles: null
1744
1752
  }));
1745
1753
  ctx.db.insert(tickets).values(rows).run();
1746
1754
  emit(ctx, featureId, {
@@ -1812,6 +1820,8 @@ function updateTicket(ctx, id, patch) {
1812
1820
  set.error = patch.error;
1813
1821
  if (patch.attemptBranch !== undefined)
1814
1822
  set.attemptBranch = patch.attemptBranch;
1823
+ if (patch.conflictFiles !== undefined)
1824
+ set.conflictFiles = patch.conflictFiles;
1815
1825
  ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1816
1826
  emit(ctx, current2.featureId, {
1817
1827
  type: "ticket.updated",
@@ -2198,6 +2208,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
2198
2208
  return [];
2199
2209
  }
2200
2210
  }
2211
+ async function commitSummaries(repoPath, base, branch, limit = 20) {
2212
+ try {
2213
+ const out = await git(repoPath).raw([
2214
+ "log",
2215
+ `--max-count=${limit}`,
2216
+ "--format=%h %s",
2217
+ `${base}..${branch}`
2218
+ ]);
2219
+ return out.split(`
2220
+ `).map((l) => l.trim()).filter(Boolean);
2221
+ } catch {
2222
+ return [];
2223
+ }
2224
+ }
2201
2225
  async function deleteTempBranch(repoPath, branch) {
2202
2226
  try {
2203
2227
  return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
@@ -2260,8 +2284,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2260
2284
  await gw.merge([tempBranch]);
2261
2285
  } catch (e) {
2262
2286
  if (await mergeInProgress(gw)) {
2287
+ const files = await conflictedFiles(gw);
2263
2288
  await gw.raw(["merge", "--abort"]);
2264
- return { ok: false, conflict: true, error: errMsg(e) };
2289
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2265
2290
  }
2266
2291
  return { ok: false, error: errMsg(e) };
2267
2292
  }
@@ -2292,8 +2317,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2292
2317
  await gw.merge([tempBranch]);
2293
2318
  } catch (e) {
2294
2319
  if (await mergeInProgress(gw)) {
2320
+ const files = await conflictedFiles(gw);
2295
2321
  await gw.raw(["merge", "--abort"]);
2296
- return { ok: false, conflict: true, error: errMsg(e) };
2322
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2297
2323
  }
2298
2324
  return { ok: false, error: errMsg(e) };
2299
2325
  }
@@ -2810,13 +2836,16 @@ function detectCycle(tickets2) {
2810
2836
  }
2811
2837
  return null;
2812
2838
  }
2813
- function renderTicketPrompt(template, values) {
2839
+ function renderTemplate(template, values) {
2814
2840
  let out = template;
2815
- for (const key of PLACEHOLDERS) {
2816
- out = out.split(`{{${key}}}`).join(values[key]);
2841
+ for (const [key, value] of Object.entries(values)) {
2842
+ out = out.split(`{{${key}}}`).join(value);
2817
2843
  }
2818
2844
  return out;
2819
2845
  }
2846
+ function renderTicketPrompt(template, values) {
2847
+ return renderTemplate(template, values);
2848
+ }
2820
2849
  function buildTicketJson(ticket) {
2821
2850
  return JSON.stringify({
2822
2851
  seq: ticket.seq,
@@ -2850,6 +2879,23 @@ ${f.content.trim()}`).join(`
2850
2879
 
2851
2880
  `);
2852
2881
  }
2882
+ function buildConflictFilesBlock(files) {
2883
+ if (files.length === 0) {
2884
+ return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
2885
+ }
2886
+ return files.map((f) => `- \`${f}\``).join(`
2887
+ `);
2888
+ }
2889
+ function buildOtherSideBlock(summaries) {
2890
+ if (summaries.length === 0) {
2891
+ return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
2892
+ }
2893
+ return summaries.map((s) => `- ${s}`).join(`
2894
+ `);
2895
+ }
2896
+ function resolveMergeCommand(mode, featureBranch2) {
2897
+ return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
2898
+ }
2853
2899
  function parseEnvFile(content) {
2854
2900
  const out = {};
2855
2901
  for (const rawLine of content.split(/\r?\n/)) {
@@ -3074,6 +3120,42 @@ function createSerialQueue() {
3074
3120
  return next;
3075
3121
  };
3076
3122
  }
3123
+ async function landWithResolve(branch, deps) {
3124
+ let current2 = branch;
3125
+ for (let attempt = 1;; attempt++) {
3126
+ const merge = await deps.merge(current2);
3127
+ if (merge.ok)
3128
+ return { status: "landed", branch: current2 };
3129
+ const detail = merge.error ?? "merge failed";
3130
+ if (!merge.conflict)
3131
+ return { status: "failed", branch: current2, error: detail };
3132
+ const files = merge.files ?? [];
3133
+ if (attempt > deps.maxResolveAttempts) {
3134
+ return { status: "conflict", branch: current2, files, error: detail };
3135
+ }
3136
+ const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3137
+ deps.emit({
3138
+ type: "merge.conflict.resolving",
3139
+ message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
3140
+ data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
3141
+ });
3142
+ const resolved = await deps.resolve({
3143
+ branch: current2,
3144
+ files,
3145
+ attempt,
3146
+ maxAttempts: deps.maxResolveAttempts
3147
+ });
3148
+ current2 = resolved.branch;
3149
+ if (!resolved.ok) {
3150
+ return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
3151
+ }
3152
+ deps.emit({
3153
+ type: "merge.conflict.resolved",
3154
+ message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
3155
+ data: { branch: current2, files, attempt }
3156
+ });
3157
+ }
3158
+ }
3077
3159
  function errorHeadline(s) {
3078
3160
  const lines = s.split(`
3079
3161
  `).map((l) => l.trim()).filter(Boolean);
@@ -3221,8 +3303,14 @@ async function burnRun(ctx, deps) {
3221
3303
  return { status: done === total ? "succeeded" : "failed", summary };
3222
3304
  }
3223
3305
  function burnerTemplatePath() {
3306
+ return burnerAssetPath("implement-ticket.md");
3307
+ }
3308
+ function resolverTemplatePath() {
3309
+ return burnerAssetPath("resolve-conflict.md");
3310
+ }
3311
+ function burnerAssetPath(file) {
3224
3312
  const here = dirname3(fileURLToPath2(import.meta.url));
3225
- return join9(resolveSkillsRoot(here), "burner", "implement-ticket.md");
3313
+ return join9(resolveSkillsRoot(here), "burner", file);
3226
3314
  }
3227
3315
  function readDocsDigestFromDisk(projectId, slug) {
3228
3316
  const worktree = worktreeDir(projectId, slug);
@@ -3286,13 +3374,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3286
3374
  const workspaceMode = resolveBurnWorkspaceMode(config2);
3287
3375
  if (workspaceMode === "isolated")
3288
3376
  await ensureIsolatedPushTarget();
3289
- const template = readFileSync4(burnerTemplatePath(), "utf8");
3290
- const basePrompt = renderTicketPrompt(template, {
3291
- TICKET_JSON: buildTicketJson(ticket),
3292
- FEATURE_BRIEF: buildFeatureBrief(feature),
3293
- DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
3377
+ const ticketJson = buildTicketJson(ticket);
3378
+ const featureBrief = buildFeatureBrief(feature);
3379
+ const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3380
+ const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3381
+ const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3382
+ TICKET_JSON: ticketJson,
3383
+ FEATURE_BRIEF: featureBrief,
3384
+ DOCS_DIGEST: docsDigest,
3294
3385
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3295
- WORKSPACE_NOTES: buildWorkspaceNotes(workspaceMode)
3386
+ WORKSPACE_NOTES: workspaceNotes
3296
3387
  });
3297
3388
  const toolchain = readRepoToolchain(project.repoPath);
3298
3389
  const pm = detectPackageManager(toolchain);
@@ -3325,9 +3416,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3325
3416
  activeTicketAborts.set(ticket.id, ticketAbort);
3326
3417
  const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
3327
3418
  const maxAttempts = Math.max(1, config2.burnAttempts);
3419
+ const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
3328
3420
  let baseBranch = feature.branch;
3329
3421
  let retryNotes;
3330
- if (ticket.attemptBranch) {
3422
+ if (ticket.attemptBranch && !conflictResume) {
3331
3423
  const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
3332
3424
  if (preserved.length > 0) {
3333
3425
  baseBranch = ticket.attemptBranch;
@@ -3346,7 +3438,140 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3346
3438
  const preserveChain = (branch) => {
3347
3439
  ctx.updateTicket(ticket.id, { attemptBranch: branch });
3348
3440
  };
3441
+ const runResolver = async (input) => {
3442
+ const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3443
+ const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3444
+ const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
3445
+ TICKET_JSON: ticketJson,
3446
+ FEATURE_BRIEF: featureBrief,
3447
+ DOCS_DIGEST: docsDigest,
3448
+ WORKSPACE_NOTES: workspaceNotes,
3449
+ FEATURE_BRANCH: feature.branch,
3450
+ CONFLICT_FILES: buildConflictFilesBlock(input.files),
3451
+ OTHER_SIDE: buildOtherSideBlock(otherSide),
3452
+ MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
3453
+ });
3454
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
3455
+ appendTranscript(ticket.id, {
3456
+ kind: "text",
3457
+ text: `
3458
+ \u2014 landing conflict on ${feature.branch}; resolver pass ${input.attempt}/${input.maxAttempts} \u2014
3459
+ `
3460
+ });
3461
+ const bestTip = async () => {
3462
+ const made = await branchCommitsAhead(project.repoPath, input.branch, resolveBranch);
3463
+ return made.length > 0 ? resolveBranch : input.branch;
3464
+ };
3465
+ let result;
3466
+ try {
3467
+ result = await run({
3468
+ agent: buildBurnAgent(config2, token, model),
3469
+ sandbox: selectSandbox(config2, mounts),
3470
+ cwd: project.repoPath,
3471
+ prompt,
3472
+ branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
3473
+ signal,
3474
+ name: `ticket-${ticket.seq}-resolve`,
3475
+ maxIterations: config2.burnMaxIterations,
3476
+ ...hookCommand ? {
3477
+ hooks: {
3478
+ sandbox: {
3479
+ onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3480
+ }
3481
+ }
3482
+ } : {},
3483
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
3484
+ });
3485
+ } catch (err) {
3486
+ if (ctx.signal.aborted)
3487
+ throw err;
3488
+ return {
3489
+ ok: false,
3490
+ branch: await bestTip(),
3491
+ error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3492
+ };
3493
+ }
3494
+ const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3495
+ if (blocked !== undefined && blocked.trim().length > 0) {
3496
+ return {
3497
+ ok: false,
3498
+ branch: await bestTip(),
3499
+ error: `resolver reported BLOCKED:
3500
+ ${blocked.trim()}`
3501
+ };
3502
+ }
3503
+ const missing = await branchCommitsAhead(project.repoPath, resolveBranch, feature.branch);
3504
+ if (missing.length > 0) {
3505
+ return {
3506
+ ok: false,
3507
+ branch: await bestTip(),
3508
+ error: `resolver did not complete the merge \u2014 ${missing.length} commit(s) of ${feature.branch} are still missing from the branch`
3509
+ };
3510
+ }
3511
+ return { ok: true, branch: resolveBranch };
3512
+ };
3513
+ let landedCommits = [];
3514
+ const landDeps = {
3515
+ merge: (branch) => land(async () => {
3516
+ const ahead = await branchCommitsAhead(project.repoPath, feature.branch, branch);
3517
+ const res = await mergeTempBranch(project.repoPath, feature.branch, branch);
3518
+ if (res.ok)
3519
+ landedCommits = ahead;
3520
+ return res;
3521
+ }),
3522
+ resolve: runResolver,
3523
+ maxResolveAttempts: Math.max(0, config2.burnConflictAttempts),
3524
+ emit: (e) => ctx.emitEvent({ ...e, ticketId: ticket.id }),
3525
+ label: `ticket ${ticket.seq}`,
3526
+ featureBranch: feature.branch
3527
+ };
3528
+ const landChain = async (branch, commits) => {
3529
+ const landing = await landWithResolve(branch, landDeps);
3530
+ if (landing.status === "landed") {
3531
+ ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
3532
+ return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
3533
+ }
3534
+ preserveChain(landing.branch);
3535
+ if (landing.status === "conflict") {
3536
+ ctx.updateTicket(ticket.id, { conflictFiles: landing.files });
3537
+ const where = landing.files.length > 0 ? ` on ${landing.files.length} file(s): ${landing.files.join(", ")}` : "";
3538
+ const why = landDeps.maxResolveAttempts === 0 ? `Automatic resolution is off (burnConflictAttempts = 0): ${errorHeadline(landing.error)}` : `The automatic resolver could not finish: ${errorHeadline(landing.error)}`;
3539
+ return {
3540
+ status: "failed",
3541
+ error: `ticket ${ticket.seq} is implemented on ${landing.branch} but conflicts with ${feature.branch}${where}. ${why}`,
3542
+ event: {
3543
+ type: "merge.conflict.needs-human",
3544
+ message: `ticket ${ticket.seq}: could not auto-resolve the conflict with ${feature.branch} \u2014 resolve it from the run lane`
3545
+ }
3546
+ };
3547
+ }
3548
+ return {
3549
+ status: "failed",
3550
+ error: `ticket ${ticket.seq} committed to ${landing.branch} but landing on ${feature.branch} failed: ${errorHeadline(landing.error)} \u2014 the branch is preserved for manual recovery`
3551
+ };
3552
+ };
3349
3553
  try {
3554
+ if (conflictResume) {
3555
+ const branch = ticket.attemptBranch;
3556
+ const pending = await branchCommitsAhead(project.repoPath, feature.branch, branch);
3557
+ if (pending.length === 0) {
3558
+ ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
3559
+ ctx.emitEvent({
3560
+ type: "merge.conflict.resolved",
3561
+ message: `ticket ${ticket.seq}: ${branch} is already merged into ${feature.branch} \u2014 nothing left to land`,
3562
+ ticketId: ticket.id,
3563
+ data: { branch }
3564
+ });
3565
+ return { status: "done", commits: ticket.commits };
3566
+ }
3567
+ ctx.emitEvent({
3568
+ type: "ticket.resuming",
3569
+ message: `ticket ${ticket.seq}: implemented on ${branch} \u2014 retrying the landing (${pending.length} commit(s))`,
3570
+ ticketId: ticket.id,
3571
+ data: { attemptBranch: branch, preservedCommits: pending.length, conflict: true }
3572
+ });
3573
+ return await landChain(branch, pending);
3574
+ }
3350
3575
  let result;
3351
3576
  let tempBranch = "";
3352
3577
  for (let attempt = 1;; attempt++) {
@@ -3402,12 +3627,16 @@ ${retryNotes}` : basePrompt,
3402
3627
  };
3403
3628
  }
3404
3629
  if (isMergeConflictError(err)) {
3630
+ if (salvaged.length > 0) {
3631
+ preserveChain(tempBranch);
3632
+ ctx.updateTicket(ticket.id, { conflictFiles: [] });
3633
+ }
3405
3634
  return {
3406
3635
  status: "failed",
3407
3636
  error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
3408
3637
  event: {
3409
3638
  type: "merge.conflict.needs-human",
3410
- message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
3639
+ message: `ticket ${ticket.seq}: merge conflict on ${feature.branch}${salvaged.length > 0 ? " \u2014 resolve it from the run lane" : " \u2014 resolve manually per the error, then re-burn"}`
3411
3640
  }
3412
3641
  };
3413
3642
  }
@@ -3465,28 +3694,7 @@ ${blocked.trim()}` };
3465
3694
  preserveChain(tempBranch);
3466
3695
  return outcome;
3467
3696
  }
3468
- const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
3469
- if (!merge.ok) {
3470
- const detail = merge.error ?? "merge failed";
3471
- if (merge.conflict) {
3472
- return {
3473
- status: "failed",
3474
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
3475
- event: {
3476
- type: "merge.conflict.needs-human",
3477
- message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
3478
- }
3479
- };
3480
- }
3481
- preserveChain(tempBranch);
3482
- return {
3483
- status: "failed",
3484
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
3485
- };
3486
- }
3487
- if (ticket.attemptBranch)
3488
- ctx.updateTicket(ticket.id, { attemptBranch: null });
3489
- return outcome;
3697
+ return await landChain(tempBranch, outcome.commits);
3490
3698
  } finally {
3491
3699
  activeTicketAborts.delete(ticket.id);
3492
3700
  throttle.flush();
@@ -3519,7 +3727,7 @@ function readTokenFromEnvFile(path) {
3519
3727
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3520
3728
  return token && token.length > 0 ? token : undefined;
3521
3729
  }
3522
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3730
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3523
3731
  var init_ticket_burner = __esm(() => {
3524
3732
  init_src();
3525
3733
  init_config_load();
@@ -3527,13 +3735,6 @@ var init_ticket_burner = __esm(() => {
3527
3735
  init_skills_root();
3528
3736
  init_agent_stream();
3529
3737
  init_git();
3530
- PLACEHOLDERS = [
3531
- "TICKET_JSON",
3532
- "FEATURE_BRIEF",
3533
- "DOCS_DIGEST",
3534
- "COMMIT_CONVENTION",
3535
- "WORKSPACE_NOTES"
3536
- ];
3537
3738
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3538
3739
  PM_CACHE_SANDBOX_PATHS = {
3539
3740
  bun: "~/.bun/install/cache",
@@ -3624,7 +3825,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
3624
3825
  }
3625
3826
  function renderResearchPrompt(template, values) {
3626
3827
  let out = template;
3627
- for (const key of PLACEHOLDERS2) {
3828
+ for (const key of PLACEHOLDERS) {
3628
3829
  out = out.split(`{{${key}}}`).join(values[key]);
3629
3830
  }
3630
3831
  return out;
@@ -3726,7 +3927,7 @@ function readTokenFromEnvFile2(path) {
3726
3927
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3727
3928
  return token && token.length > 0 ? token : undefined;
3728
3929
  }
3729
- var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS2, research;
3930
+ var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
3730
3931
  var init_research = __esm(() => {
3731
3932
  init_src();
3732
3933
  init_config_load();
@@ -3734,7 +3935,7 @@ var init_research = __esm(() => {
3734
3935
  init_skills_root();
3735
3936
  init_git();
3736
3937
  init_ticket_burner();
3737
- PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3938
+ PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3738
3939
  research = {
3739
3940
  id: "research",
3740
3941
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4074,24 +4275,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
4074
4275
  preservedCommits = found.commits.length;
4075
4276
  }
4076
4277
  }
4278
+ const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
4077
4279
  for (const t of toReset.values()) {
4078
4280
  const isTarget = t.id === ticket.id;
4079
4281
  updateTicket(ctx, t.id, {
4080
4282
  status: "pending",
4081
4283
  error: null,
4082
- ...isTarget ? { attemptBranch: resumedFrom } : {}
4284
+ ...isTarget ? { attemptBranch: resumedFrom } : {},
4285
+ ...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
4083
4286
  });
4084
4287
  }
4085
4288
  const seqs = [...toReset.keys()].sort((a, b) => a - b);
4086
- const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
4289
+ const how = opts.fresh ? " from scratch" : resolvingConflict ? ` \u2014 resolving its conflict with ${feature.branch} (${preservedCommits} commit(s) preserved on ${resumedFrom})` : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
4087
4290
  emit(ctx, feature.id, {
4088
4291
  type: "ticket.retry",
4089
4292
  message: seqs.length > 1 ? `retrying ticket ${ticket.seq}${how} (+ failed blocker${seqs.length > 2 ? "s" : ""} ${seqs.filter((s) => s !== ticket.seq).join(", ")})` : `retrying ticket ${ticket.seq}${how}`,
4090
4293
  ticketId: ticket.id,
4091
- data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
4294
+ data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
4092
4295
  });
4093
4296
  const { runId } = await burn(ctx, feature.id, { resetFailed: false });
4094
- return { runId, retried: seqs, resumedFrom, preservedCommits };
4297
+ return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
4095
4298
  }
4096
4299
  function escalateToMap(ctx, featureId, input) {
4097
4300
  const feature = getFeatureRow(ctx, featureId);
@@ -6052,6 +6255,14 @@ var init_settings = __esm(() => {
6052
6255
  valueSchema: z9.number().int().min(1).max(5),
6053
6256
  parseEnv: (raw) => Number(raw)
6054
6257
  },
6258
+ {
6259
+ key: "burnConflictAttempts",
6260
+ configKey: "burnConflictAttempts",
6261
+ envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
6262
+ restartRequired: false,
6263
+ valueSchema: z9.number().int().min(0).max(3),
6264
+ parseEnv: (raw) => Number(raw)
6265
+ },
6055
6266
  {
6056
6267
  key: "setupCommand",
6057
6268
  configKey: "setupCommand",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,69 @@
1
+ <!-- Rendered by the ticket-burner when a ticket's branch fails to land on the feature branch. Placeholder tokens are filled in by the workflow. -->
2
+
3
+ # Resolve a landing conflict — unattended
4
+
5
+ You already implemented a ticket. Your commits are on your branch, but they could **not** be landed on the feature branch: while you were working, sibling tickets landed their own commits and you both touched the same files.
6
+
7
+ Your one job now is to **merge the feature branch into your branch and resolve the conflicts**, so your work and theirs both survive. You are not re-implementing anything, and you are not starting new work.
8
+
9
+ There is **no human to ask** — everything you need is in this prompt and in the repo. This is the last automated attempt before a human is pulled in, so be careful, not fast.
10
+
11
+ ## How you run
12
+
13
+ You run **non-interactively** (`claude --print`), for up to a few fresh iterations against the same worktree:
14
+
15
+ - **Ending your turn ends your process.** There are no background-task completion notifications in print mode — a "the notification will re-invoke me" plan never fires. Run long commands (test suites) in the foreground with a generous timeout.
16
+ - **Signal completion.** When the merge commit is in, print exactly `<promise>COMPLETE</promise>` as the last line of your final message. Do the same after writing `BLOCKED.md` (see hard rules).
17
+
18
+ ## Where to work
19
+
20
+ {{WORKSPACE_NOTES}}
21
+
22
+ ## The conflict
23
+
24
+ Merging your branch into `{{FEATURE_BRANCH}}` conflicts on:
25
+
26
+ {{CONFLICT_FILES}}
27
+
28
+ These commits landed on `{{FEATURE_BRANCH}}` while you were working — this is the other side of the merge, the work you must preserve alongside your own:
29
+
30
+ {{OTHER_SIDE}}
31
+
32
+ ## The ticket you implemented
33
+
34
+ ```json
35
+ {{TICKET_JSON}}
36
+ ```
37
+
38
+ ## Feature context
39
+
40
+ {{FEATURE_BRIEF}}
41
+
42
+ ## Feature docs (spec / decisions digest)
43
+
44
+ {{DOCS_DIGEST}}
45
+
46
+ ## How to work
47
+
48
+ 1. **Orient before touching anything.** `git log --oneline -15` to see your own commits, then read the conflicting files. Understand what *your* commits were doing there (the ticket above is the intent) and what the *other* side's commits were doing (the log above, plus `git show` on them).
49
+
50
+ 2. **Start the merge:**
51
+
52
+ ```
53
+ {{MERGE_COMMAND}}
54
+ ```
55
+
56
+ It will stop with conflicts. `git status` lists the unmerged paths.
57
+
58
+ 3. **Resolve by intent, not by picking a side.** For every `<<<<<<< / ======= / >>>>>>>` hunk, keep *both* behaviours working. Reconcile the logic where the two sides genuinely overlap — the feature docs above are the tie-breaker on intent. Never delete one side's behaviour just to make the conflict go away, and never resolve by taking your whole side (`--ours`) or theirs (`--theirs`) wholesale unless one side is genuinely a strict superset.
59
+
60
+ 4. **Watch for semantic conflicts, not just textual ones.** Files that merged cleanly can still be broken by the other side's rename, signature change, or moved export. After resolving the marked hunks, run typecheck and the tests covering both sides' touched code, and fix what the merge broke.
61
+
62
+ 5. **Complete the merge.** Stage the resolved files and commit — keep it a merge commit (`git commit --no-verify`, do not `--squash`, do not rebase, do not amend your earlier commits). A merge commit whose message names what was reconciled is ideal.
63
+
64
+ ## Hard rules
65
+
66
+ - **Never expand scope.** Resolving the conflict is the whole job. No refactors, no improvements, no adjacent fixes, not even tempting ones in the files you are already editing.
67
+ - **Never lose committed work.** Not yours, not theirs. `git merge --abort` + "start over from one side" is not a resolution.
68
+ - **Never push, and never touch the remote.** The orchestrator lands your branch itself once the merge commit exists.
69
+ - **If the two sides genuinely cannot be reconciled without a product decision** (they implement contradictory behaviour, not merely overlapping code): `git merge --abort`, write `BLOCKED.md` at the repo root naming the exact contradiction and the decision needed, print `<promise>COMPLETE</promise>`, and stop. A human will decide. Do not guess.